Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e60b770
WIP: packaging, GPU picker, project format presets
Aug 20, 2026
7fde9c1
Merge remote-tracking branch 'origin/main' into feat/gpu-and-project-…
Aug 20, 2026
4fa0d54
gpu: NVIDIA via GLX PRIME offload (XWayland), drop render-node-override
Aug 20, 2026
320f3b2
gpu: NVIDIA via PRIME offload env, native Wayland (evidence-based)
Aug 20, 2026
858b842
gpu: present NVIDIA via XWayland + confirm-only-if-visible self-heal
Aug 20, 2026
ba9e553
gpu: NVIDIA x11 + --disable-gpu-sandbox (research-backed best-effort)
Aug 20, 2026
b7b1d6e
gpu: run NVIDIA inside gamescope (the path that actually renders)
Aug 20, 2026
3eecbe9
gpu: fix blank Kadr inside gamescope + slow close
Aug 20, 2026
31efa7c
gpu: fix blank NVIDIA window — strip ELECTRON_RENDERER_URL on relaunch
Aug 20, 2026
ffc5955
export: add vertical 4K (2160×3840) and square 4K (2160×2160) presets
Aug 20, 2026
48cea8b
export: optional NVENC (NVIDIA hardware H.264) with auto-fallback to …
Aug 20, 2026
3d81dfa
gpu: run NVIDIA via plain PRIME offload (normal window), drop gamescope
Aug 20, 2026
9a922f4
gpu: restore gamescope NVIDIA path (plain x11 offload segfaults the G…
Aug 20, 2026
37241ff
gpu: NVIDIA in a normal window via PRIME offload from process start (…
Aug 20, 2026
9579f48
gpu: force --ozone-platform=x11 via CLI flag for NVIDIA offload
Aug 20, 2026
f402091
pack: bundle BtbN ffmpeg (has NVENC) so /opt gets GPU encoding
Aug 20, 2026
9ca676e
pack: robust NVENC check (if-form, not && || which false-warned)
Aug 20, 2026
195c16b
fix: audio-export collapse, black-frame keyframes, NVIDIA export hang…
Aug 21, 2026
f15cf55
fix: write export intermediates to a disk-backed dir, not the /tmp tmpfs
Aug 21, 2026
55cd847
fix: never hang on export errors; sweep crashed export temps
Aug 21, 2026
ec88fc4
fix: audio clip with inPoint>0 jumped to t=0 in export — bound the pa…
Sep 4, 2026
5624722
Merge origin/main (v0.5.1) into feat/gpu-and-project-presets
Sep 8, 2026
f90f595
test(e2e24): expect fragments at the project fps, not a forced 60
Sep 8, 2026
46ed51e
fix(export): alpha matte was range-compressed twice in the packed int…
Sep 8, 2026
f9d6269
ui(settings): the GPU picker joins the design system, with a video-ca…
Sep 8, 2026
6021ef2
fix(gpu): wait for the old process to die before the relaunched one s…
Sep 8, 2026
c00f492
fix(export): cancelling an export no longer files three failures in t…
Sep 8, 2026
aa79eaf
ui: project format dialog joins the design system, and stops being a …
Sep 8, 2026
0887094
Revert the relaunch wait: it fixed a race that was not the cause
Sep 8, 2026
7e40201
pack: ship the runtime files the app reads from disk
Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules/
out/
dist/
runtime/
kadr-fragments-seed/
*.log
.claude/
*.autosave.kadr
Expand Down
99 changes: 99 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Установка Kadr

## Для пользователя — готовый архив (рекомендуется)

Не нужен ни `npm install`, ни компиляторы, ни системный ffmpeg. Особенно
актуально для immutable-дистрибутивов (Bazzite, Silverblue, Kinoite).

**tar.gz:**
```bash
tar -xzf kadr-<версия>-linux-x64.tar.gz
cd kadr-<версия>-linux-x64
./kadr
```

**AppImage:**
```bash
chmod +x Kadr-<версия>-linux-x86_64.AppImage
./Kadr-<версия>-linux-x86_64.AppImage
# нет FUSE? → ./Kadr-*.AppImage --appimage-extract-and-run
```

Что работает сразу, без доустановки:
- **Ядро** (импорт / превью / экспорт) — встроены статические `ffmpeg`/`ffprobe`.
- **Субтитры** (faster-whisper) — встроен переносимый Python. При первом
использовании один раз качается ML-модель (нужна сеть).
- **Remotion-фрагменты** — встроены Node и пред-установленные зависимости.
Если сборка не «прогрела» headless-Chromium, он один раз докачается при
первом рендере фрагмента (сеть; но уже без системного Node).

Опционально (ставит сам пользователь): **Claude-панель** требует установленного
`claude` CLI в `PATH` — это личный инструмент/аккаунт, встроить его нельзя.

Только `linux-x64` (glibc). Для arm64 / musl нужна отдельная сборка.

### Запуск из меню приложений (без терминала)

**AppImage** — уже запускается двойным кликом из файлового менеджера (нужен бит
«исполняемый»: ПКМ → Свойства → Разрешения). Чтобы появилась постоянная иконка в
меню приложений и автообновление — интегрируй его через **Gearlever** (штатный
GUI-способ для Bazzite/атомарных систем):
```bash
flatpak install flathub it.mijorus.gearlever
```
Открой Gearlever → перетащи в него `Kadr-*.AppImage` → «Add to menu». Дальше Kadr
запускается из меню как обычное приложение.

**tar.gz** — распакуй, положи папку в постоянное место и один раз запусти
`install.sh`:
```bash
tar -xzf kadr-<версия>-linux-x64.tar.gz
sudo mv kadr-<версия>-linux-x64 /opt/kadr # любое постоянное место
bash /opt/kadr/install.sh # БЕЗ sudo — пишет в ~/.local
```
После этого «Kadr» появляется в меню/поиске KDE с логотипом и запускается кликом,
без терминала — и всё (звук, экспорт, субтитры, Remotion, Claude-панель) работает
без ручной настройки. `install.sh` запускай **своим пользователем, не через sudo**
(он пишет только в `~/.local/share`, даже если приложение лежит в `/opt`).
Перенёс папку — запусти `bash /opt/kadr/install.sh` ещё раз. Убрать ярлык:
`bash /opt/kadr/install.sh --uninstall`.

---

## Для мейнтейнера — сборка архива

Сборку нужно делать в окружении с тулчейном (компилятор для `node-pty`), а не на
голом immutable-хосте. Штатный путь — distrobox:

```bash
distrobox create --name kadr-build --image fedora:42
distrobox enter kadr-build
sudo dnf install -y gcc-c++ make python3 nodejs npm tar xz which curl

cd ~/Projects/kadr
npm run pack # = bash scripts/pack.sh
```

Артефакты появятся в `dist/`. Скрипт сам:
1. собирает приложение (`npm ci` + `electron-vite build`);
2. кладёт статический ffmpeg/ffprobe, переносимый Node и Python+faster-whisper в
`runtime/`;
3. пред-устанавливает зависимости Remotion в `kadr-fragments-seed/`;
4. пересобирает `node-pty` под ABI встроенного Electron и пакует
tar.gz + AppImage через electron-builder.

Флаги `scripts/pack.sh`:
- `--lite` — только ядро + ffmpeg (без whisper и Remotion; ~в разы меньше размер);
- `--model <name>` — предвложить модель субтитров (`base`, `small`, …) для
полностью офлайн-субтитров;
- `--skip-build` — переиспользовать существующий `out/`.

Версии вложенных Node / Python / ffmpeg закреплены вверху `scripts/pack.sh` —
меняются там. Размер полного архива ориентировочно ~0.5–0.7 ГБ.

> Как это устроено под капотом: `electron/runtime-env.ts` (первый импорт
> `main.ts`) в упакованной сборке добавляет `resources/runtime/bin` в `PATH` и
> выставляет `KADR_FFMPEG`/`KADR_FFPROBE`, поэтому все внешние вызовы
> (`ffmpeg`, `python3`, `npm`, `npx`, `node`) резолвятся во вложенные бинарники.
> Упаковка идёт без asar — `electron/mcp-bridge.cjs` и `scripts/transcribe.py`
> запускаются внешними процессами по реальному пути и внутри asar не читались бы.
Binary file added build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions build/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# electron-builder config for the self-contained Linux archive.
# Run via scripts/pack.sh (inside a distrobox with a build toolchain), which
# stages runtime/ and kadr-fragments-seed/ before invoking electron-builder.
appId: com.blacktriangle.kadr
productName: Kadr
copyright: © Black Triangle — GPL-3.0

directories:
output: dist
buildResources: build

# node/python/npx read these files by real on-disk path (app.getAppPath()),
# and external processes can't read inside an asar — so ship unpacked.
asar: false

# Whitelist the app payload. electron-builder always adds production
# node_modules (pruned of devDependencies) on top of this, which mcp-bridge.cjs
# needs at runtime (@modelcontextprotocol/sdk).
files:
- out/**/*
- electron/mcp-bridge.cjs
# every one of these is read from disk at runtime by app.getAppPath(), so a
# build without them starts fine and then fails the feature: the Claude panel
# cannot sync its skill ("skill sync failed: ENOENT"), and voice-defect
# detection has no driver, no package and no model. 2.2 MB in total, of which
# 1.9 MB is python/models.
- electron/kadr-skill.md
- scripts/transcribe.py
- scripts/ttsqc_run.py
- python/**/*
- package.json

# Bundled runtime + Remotion seed land under resources/ (process.resourcesPath).
# Both dirs are produced by scripts/pack.sh; guarded there so a "lite" build can
# omit kadr-fragments-seed.
extraResources:
- from: runtime
to: runtime
- from: kadr-fragments-seed
to: kadr-fragments-seed

linux:
target:
- tar.gz
- AppImage
category: AudioVideo
icon: build/icon.png
synopsis: GPU-accelerated multi-track video editor
artifactName: ${name}-${version}-linux-${arch}.${ext}
# WM_CLASS of the Electron window — lets KDE/GNOME match the running window
# to this launcher (correct task-bar icon, grouping).
desktop:
entry:
StartupWMClass: Kadr
Categories: AudioVideo;AudioVideoEditing;Video;

# The AppImage is self-contained (its icon + .desktop are embedded — integrate
# it with Gearlever/appimaged). The tar.gz is just files, so ship a one-shot
# helper + icon at the archive root that registers a menu entry per-user.
extraFiles:
- from: scripts/install-desktop.sh
to: install.sh
- from: build/icon.png
to: kadr.png

# node-pty is rebuilt against the bundled Electron's ABI at pack time.
npmRebuild: true
4 changes: 3 additions & 1 deletion electron/cacheKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ export function mediaCacheKey(
export const proxySuffix = (alpha?: boolean) => (alpha ? ':a' : '')

/** decoded: packed colour+matte, plain alpha, or neither */
// ':p2' — packed-matte generation: intermediates packed before the matte
// range fix (double pc→tv compression) must miss the cache and re-pack.
export const decodedSuffix = (opts?: { alpha?: boolean; packed?: boolean }) =>
opts?.packed ? ':p' : opts?.alpha ? ':a' : ''
opts?.packed ? ':p2' : opts?.alpha ? ':a' : ''

/** reversed: the same source cut differently is a different render */
export const reverseSuffix = (start: number, duration: number) =>
Expand Down
44 changes: 38 additions & 6 deletions electron/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ export async function probeMedia(path: string): Promise<ProbeResult> {
width: video?.width || 0,
height: video?.height || 0,
fps: fps || 30,
hasAudio: !!audio
hasAudio: !!audio,
audioCodec: audio?.codec_name
}
if (kind === 'video' && video?.codec_name) asset.codec = video.codec_name
try { asset.mtimeMs = Math.round((await fsp.stat(path)).mtimeMs) } catch { /* stat optional */ }
if (kind === 'video') {
// alpha travels two ways: an alpha pixel format (yuva…, rgba, prores
// 4444) or WebM's container-level alpha_mode tag (vp8/vp9 alpha planes —
Expand Down Expand Up @@ -144,9 +146,19 @@ export function makeProxy(
out: string,
duration: number,
onProgress?: (p: number) => void,
opts?: { alpha?: boolean; codec?: string }
opts?: { alpha?: boolean; codec?: string; audioOnly?: boolean }
): Promise<void> {
const args = opts?.alpha ? [
// audioOnly: the source has no usable video (a music/voice file) but an audio
// codec Chromium can't decode (ac3/dts/…) — transcode just the audio to AAC
// so the preview element has sound. alpha: a VP9+alpha WebM keeps the
// transparency. Otherwise a normal 540p video+AAC proxy.
const args = opts?.audioOnly ? [
'-y', '-v', 'error', '-progress', 'pipe:1',
'-i', src,
'-vn', '-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
out
] : opts?.alpha ? [
'-y', '-v', 'error', '-progress', 'pipe:1',
...alphaInputArgs(opts.codec),
'-i', src,
Expand Down Expand Up @@ -334,7 +346,14 @@ export async function makeDecoded(
`[c]format=yuv420p,scale=in_color_matrix=${opts.matrix || 'bt601'}:out_color_matrix=bt709[col];` +
// the matte rides in the luma plane, mapped into the same limited range
// the decoder will expand back — verified to round-trip bit-exactly
'[a]alphaextract,format=yuv420p,scale=in_range=pc:out_range=tv[m];' +
// Range-compress the matte BEFORE format=yuv420p. Newer swscale already
// maps gray→yuv420p into limited range, so a scale=pc→tv placed after it
// compressed the matte TWICE (255 → 235 → 218): every opaque area of an
// alpha source composited at ~92 % (a 255 red came out at 229). With the
// explicit scale first, format= sees a tv-tagged input and leaves it alone
// on new swscale, while old swscale (a plain copy) still gets the one
// compression it needs — 235/127/71 for α 255/129/64 on both.
'[a]alphaextract,scale=in_range=pc:out_range=tv,format=yuv420p[m];' +
'[col][m]vstack=inputs=2[v]',
'-map', '[v]', '-an',
'-c:v', 'libx264', '-preset', 'veryfast', '-qp', '0', '-pix_fmt', 'yuv420p',
Expand Down Expand Up @@ -644,15 +663,28 @@ export class ExportMuxer {
const outDur = s.duration / speed // timeline-domain length after atempo
const chain = [
'aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo',
// Normalize the start PTS to 0 (a lavfi source already is). Defensive:
// some containers hand filter_complex a non-zero first PTS after an
// input seek. NOTE this alone does NOT fix the "clip jumps to t=0" bug —
// see the pad below for the actual cause.
'asetpts=PTS-STARTPTS',
`volume=${s.gain.toFixed(4)}`,
...(Math.abs(speed - 1) > 1e-4 ? atempoChain(speed) : []),
...(s.fadeIn > 0.001 ? [`afade=t=in:st=0:d=${Math.min(s.fadeIn, outDur).toFixed(3)}`] : []),
...(s.fadeOut > 0.001
? [`afade=t=out:st=${Math.max(0, outDur - s.fadeOut).toFixed(3)}:d=${Math.min(s.fadeOut, outDur).toFixed(3)}`]
: []),
`adelay=${ms}|${ms}`,
'apad',
`atrim=0:${job.duration.toFixed(3)}`
// Pad each stream to exactly job.duration with a BOUNDED pad so amix
// (duration=longest) reaches EOF — instead of an unbounded `apad`
// followed by `atrim=0:job.duration`. That trailing atrim is the real
// cause of the audio-collapse bug: on an input-seeked segment
// (`-ss inPoint -i file`, i.e. inPoint > 0) it throws the delayed audio
// back to t=0 and leaves its slot silent — even with PTS re-zeroed
// (verified: asetpts=N/SR/TB + atrim still collapses; any chain without
// the trailing atrim places the clip correctly). Segments are already
// range-bounded, so nothing here can exceed job.duration.
`apad=whole_dur=${job.duration.toFixed(3)}`
]
filters.push(`[${idx}:a]${chain.join(',')}[a${i}]`)
labels.push(`[a${i}]`)
Expand Down
Loading