Merge v0.5.1 + export fixes, GPU picker(nvidia/intel), NVENC, 4K presets - #17
Open
progerWolf wants to merge 30 commits into
Open
Merge v0.5.1 + export fixes, GPU picker(nvidia/intel), NVENC, 4K presets#17progerWolf wants to merge 30 commits into
progerWolf wants to merge 30 commits into
Conversation
Bundles the uncommitted working-tree work before merging upstream v0.3.0: - Packaging: bundled runtime (ffmpeg/node/python), electron-builder pack, /opt install script, runtime-env PATH shim, INSTALL.md. - GPU selection: electron/gpu.ts enumerates DRM render nodes; a Settings dialog lets the user pick a GPU, applied at startup via render-node-override (+ PRIME/x11 for NVIDIA). Self-healing: a trial choice reverts to auto if the renderer never loads, so a bad pick can't brick the app. - Project format presets: New Project dialog + a "match video / preset" prompt when media is dropped into an empty project (newProject(dims), PROJECT_PRESETS, format resolver in the import funnel). - Cross-aspect export fix (drawFrame renderSize) — SUPERSEDED by upstream v0.2.2 on merge; kept here only so the branch is self-contained. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…presets # Conflicts: # electron/preload.ts # src/engine/proxy.ts
render-node-override to the NVIDIA render node under Wayland made Chromium's GPU process fail to init (blank/no window). Switch the NVIDIA path to the standard Optimus mechanism instead: --ozone-platform=x11 (XWayland) + __NV_PRIME_RENDER_OFFLOAD / __GLX_VENDOR_LIBRARY_NAME=nvidia, plus force_high_performance_gpu. render-node-override is kept only for Intel/AMD. Self-heal (trial→auto revert) unchanged, so a failed pick still can't brick. Also log the active GPU (app.getGPUInfo) at startup so the picker's effect is visible from the terminal: vendor 0x8086=Intel, 0x10de=NVIDIA, 0x1002=AMD. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Probed the actual machine: `__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia` makes BOTH glxinfo and eglinfo report the NVIDIA GTX 1050 — and switcherooctl lists exactly these vars (plus __VK_LAYER_NV_optimus, VK_LOADER_DRIVERS_SELECT=*nvidia*) as the dGPU environment. Chromium uses EGL, so the offload env is enough: no forced x11 (runs native Wayland) and no render-node-override (which broke GPU init / blank window). render-node-override kept for Intel/AMD only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes after NVIDIA came up as a blank window: - Blank window: native Wayland can't present NVIDIA's buffers (kwin runs on the Intel iGPU). Force --ozone-platform=x11 for the NVIDIA path so the X server copies offloaded frames back to the primary GPU for display — the standard working Optimus config. PRIME offload env unchanged. - Self-heal trusted did-finish-load, but a blank window still finishes loading, so the dead choice stuck as 'ok'. Drop the auto-confirm. A trial now becomes 'ok' only when the user explicitly clicks "Keep" in Settings — which is reachable ONLY if the window actually renders (the dialog checks the chosen GPU's vendor shows in the live WebGL renderer). A blank window can't be kept, so it heals to auto on the next launch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per NVIDIA/Electron community: only XWayland renders Chromium on NVIDIA Optimus; native Wayland goes software/blank. --disable-gpu-sandbox fixes the GPU-process crash that shows as 'process starts, no window'. Dropped the Vulkan driver-select (caused vkCreateInstance failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified on the machine: launching Kadr inside gamescope puts Chromium on the NVIDIA GPU (active GPU vendor=0x10de) with a visible window — gamescope renders the app on the dGPU and hands finished frames to kwin (iGPU), exactly how games run here. Every other route (native Wayland, XWayland offload, render-node-override) went blank or crashed the GPU process. The NVIDIA path now re-execs the app into `gamescope -W 1600 -H 900 -- <app>` with the PRIME offload env (guarded by KADR_GAMESCOPE against re-entry). Switching GPUs relaunches as a fresh top-level process via cleanRelaunchEnv(), which restores the host WAYLAND/DISPLAY so it escapes gamescope's nested session and re-reads gpu.json cleanly. Falls back to the XWayland offload when gamescope is absent. Self-heal (trial→auto, confirm only on explicit Keep) intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Blank UI inside gamescope (button path only): the in-gamescope process set KADR_GPU_POWER=high-performance, so the WebGL context requested powerPreference:'high-performance'. Inside gamescope's single-GPU view that makes Chromium re-pick a GPU and blanks the window. Leave it at 'default' — now the button path matches the working manual `gamescope -- electron .` run. - Slow close: force-exit 400ms after window-all-closed on a clean close (was a flat 2500ms) — NVIDIA/gamescope GPU-process teardown otherwise hangs the exit. An in-flight export still gets 2500ms. - Relaunch/gamescope children now spawn with stdio:'ignore' so they don't hold the launching terminal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gamescope-inner window was blank because it tried to load the dev server (http://localhost:5173, ERR_CONNECTION_REFUSED): electron-vite sets ELECTRON_RENDERER_URL and it leaked through the relaunch chain, but that server is gone once the launching electron exits. Strip ELECTRON_RENDERER_URL in both relaunch envs so the re-exec'd app loads the built renderer (out/). (active GPU in the log was already 0x10de — NVIDIA rendering was fine all along.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New export presets Shorts/Reels 4K and Square 4K, plus matching project-format presets, complementing the existing landscape YouTube 4K. Pipeline already supports 4K (avcCodecString → level 5.1; x264 raw path has no size limit). Also (security review): move the gamescope diagnostic log from a predictable /tmp path to userData/gpu.log — /tmp is world-writable, so a symlinked kadr-gpu.log could redirect the appended log; userData is per-user. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…x264 The export encode is the CPU bottleneck (libx264 pins ~5 cores); NVENC moves it to the GPU's dedicated encoder. Adds a "GPU encoding (NVENC)" export checkbox, enabled only when usable (main probes: /dev/nvidia0 present + ffmpeg has h264_nvenc, cached, exposed via nvenc:available). When on, H.264 presets encode with h264_nvenc (p5/vbr) via the raw path; otherwise x264 as before. Verified h264_nvenc produces a valid 2160x3840 High-profile file end-to-end. Note: the bundled static ffmpeg has no nvenc, so the /opt build shows the option disabled until an nvenc-capable ffmpeg is bundled; dev (system ffmpeg) works now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now that the blank window is fixed (it was ELECTRON_RENDERER_URL, not GPU), the NVIDIA path no longer needs gamescope: a plain --ozone-platform=x11 launch with the PRIME offload env renders Chromium on the dGPU in an ordinary, resizable window (verified: active GPU 0x10de) — exactly how Lutris launches games. This drops the gamescope re-exec entirely and with it its costs: the fixed 1600x900 window, broken drag-and-drop across the nested compositor, and the extra Intel-side compositing overhead. The GPU-switch relaunch logs to userData/gpu.log for diagnosis; trial/Keep self-heal is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…PU process) Reverting the gamescope removal: a plain --ozone-platform=x11 PRIME-offload relaunch crashes Chromium's GPU process (exit_code=139) when spawned detached (the picker relaunch), even though the same command run attached in a terminal works. gamescope is the only reliable NVIDIA-compositing path on this hybrid setup, so it stays. NVENC (the actual export speedup) is independent and works in plain Intel mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…no gamescope) The plain-x11 offload path segfaulted Chromium's GPU process (exit_code=139) because the offload vars were set late in main — after Chromium had already forked its zygote/GPU process on the iGPU node, while GLX was forced to NVIDIA. The fix: re-exec with __NV_PRIME_RENDER_OFFLOAD / __GLX_VENDOR_LIBRARY_NAME in the child's environment from process START (exactly how Lutris launches games and how the working manual `__NV_PRIME… electron .` run behaves), plus ELECTRON_OZONE_PLATFORM_HINT=x11 for the GLX presentation path. Drops gamescope entirely: NVIDIA now renders in an ordinary resizable window where drag-and-drop works. Trial/Keep self-heal unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The offload-from-start fix stopped the GPU-process segfault (active GPU now 0x10de), but ELECTRON_OZONE_PLATFORM_HINT=x11 is ignored on a Wayland session — Chromium stayed on wayland, where NVIDIA is Vulkan-incompatible and falls back to software (so the picker still showed 'auto'). Pass --ozone-platform=x11 as a CLI flag instead (as the working manual run did); the GPU-switch relaunch strips it so a switch back to Intel stays native Wayland. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
johnvansickle's static ffmpeg has no h264_nvenc, so the packaged app couldn't offer NVENC. Switch to BtbN's gpl static build (h264_nvenc + libx264 + libvpx + all filters Kadr uses; dlopens the NVIDIA driver at runtime). Verifies the bundled binary has nvenc during pack. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/NVENC, fragment limits - ExportMuxer: asetpts=PTS-STARTPTS re-zeroes seek'd inputs so each clip's audio delay survives — adelay+atrim no longer collapse every audio clip to t=0 - evalAnim/sanitizeProject: guard missing/unknown keyframe easing; drawFrame now isolates each clip so one bad keyframe skips itself instead of blacking the frame - GPU export hang: disable VAAPI decode on the NVIDIA offload path (no VA driver there) + an 8s decode watchdog in demux → falls back to element-seek, never hangs - NVENC: nvencAvailable() test-encodes for real capability (graceful x264 fallback); pack.sh pins a driver-580-compatible ffmpeg (nv-codec-headers API 13.0) - Fragments: use project fps (not forced 60), move Remotion temp off tmpfs + pre-flight disk check; regenRegistry() before every render; seed the offscreen player on the target frame + version-gated captureReady for accurate snapshots - media re-import refreshes on mtime change; addTrack optional insert index; rounded-rectangle mask shape (rounded-box SDF + tool + radius slider + presets) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The raw-video encode (preload) and the mux temp (main) went to os.tmpdir(),
which is a small RAM-backed tmpfs on many Linux setups (here /tmp is a 7.7 GB
tmpfs). A large export overflows it mid-run and ffmpeg dies with EDQUOT/ENOSPC
("Disk quota exceeded", errno -122) — which surfaced as an export that stalled
right after the start, or at ~67% once the intermediate had grown. runtime-env.ts
now points KADR_TMPDIR at <userData>/tmp (disk-backed); preload.ts and main.ts
use it with an os.tmpdir() fallback, and the renderer inherits the env like
KADR_FFMPEG.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- demux Mp4FrameSource.open() gets a 10s watchdog — a wedged decoder init could hang here (the only decode path not already bounded), freezing the export with no error. On timeout it falls back to element-seek, so the export always makes progress or surfaces an error instead of stalling on the last rendered frame. - main sweeps orphaned kadr-export[-raw]-<ts>.mp4 intermediates (multi-GB) at startup, older than 60s so a concurrent live export is never touched. Covers KADR_TMPDIR and os.tmpdir(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d, drop trailing atrim Real root cause of the audio-collapse bug (reproduced on the user's actual sources, not synthetic ones): the per-segment chain ended in an unbounded `apad` followed by `atrim=0:job.duration`. On an INPUT-SEEKED segment (`-ss inPoint -i file`, i.e. any clip with inPoint > 0) that trailing atrim threw the delayed audio back to t=0 and left its real slot silent. It was not a PTS problem: re-zeroing PTS (asetpts=PTS-STARTPTS, even asetpts=N/SR/TB) with the atrim still present still collapsed; every chain WITHOUT the trailing atrim placed the clip correctly. Replace `apad` + `atrim` with `apad=whole_dur=<job.duration>`: each stream is padded to exactly the export length and EOFs, so amix (duration=longest) still terminates (verified 2-input mix completes in 50 ms), and nothing trims after adelay. asetpts stays as a harmless start-PTS normalization. Verified on the reporting project: clips at 25.667 s (inPoint 4.219) and 28.55 s (inPoint 3.033) now land in place instead of at 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Upstream v0.3.0 → v0.5.1 (10 commits: timeline markers, render chime, alpha /
export-speed work, torn-frame fix, bridge + kadr:// tokens, atomic fragment
renders, neon wave, compositor dispose / context loss, voice-over + defects,
design system, panels, cloneProject). 18 files conflicted; resolved per the
merge map:
- requestProxy / makeProxy: one opts{alpha, codec, audioOnly} across
ffmpeg.ts, main.ts, preload.ts, types.ts, proxy.ts — our audio-only proxy
and their alpha WebM proxy coexist; audio-only gets its own cache identity.
- fragmentCapture.ts: upstream's paintSeq + capture-query snapshot fix taken
whole; our per-fragment version gate and the player initialFrame seeding
dropped (their fix addresses the same bug at the texture-cache root).
- demux.open: upstream signature + heightScale, our init-timeout race kept.
- player.ts: our per-clip try/catch kept, their `packed` argument threaded.
- store.addTrack: cloneProject + our optional insert index.
- fragments: project fps (ours) + projectPath (theirs); atomic .part render
job (theirs) + TMPDIR off the tmpfs (ours).
- dialogs / AnimEditor / styles / App: upstream Modal shell, icons and tokens;
our NVENC checkbox, bundled whisper-model option, roundrect tool (new
squircle glyph) and New Project button re-applied on top.
- imports merged (spawnSync kept for the NVENC probe).
typecheck + build green; upstream node checks (proxy, envelope, ttstext,
voicemap) pass. e2e sweep pending — needs a display.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fragments are created at the project fps (a 30 fps project no longer renders its fragments at 60 — that doubled the PNG frame pile with no visible gain in a 30 fps export). The suite asserted the old forced-60 policy in two checks; both now compare against the project's own fps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ermediate The packed colour-over-matte intermediate built its matte with `alphaextract,format=yuv420p,scale=in_range=pc:out_range=tv`. On current swscale (system ffmpeg 8.1 and the bundled BtbN build alike) format=yuv420p already maps the gray alpha into limited range, so the explicit pc→tv scale after it compressed the matte a second time: α 255 landed at Y=218 instead of 235, i.e. every opaque area of an alpha source composited at ~92 % (a 255 red came out at 229 — e2e32's "packed vs element" checks). Older swscale copied gray→yuv420p verbatim, which is why the suite passed where it was written. Put the range compression BEFORE format=. New swscale then sees a tv-tagged input and leaves it alone; old swscale still gets its single compression — 235/127/71 for α 255/129/64 on both ffmpeg builds here. The packed cache suffix moves ':p' → ':p2' so intermediates packed with the double compression miss the cache and re-pack. Measured (e2e32 on this machine): packed opaque red 229 → 250 (element 251), PSNR 37.3 → 44.3 dB (threshold 40). The remaining bitwise-equality check still differs by 1–5 levels on saturated primaries — rounding of the packed path's bt601→bt709→RGB double conversion vs the element path's single one on newer swscale; the PSNR gate is upstream's own quality criterion and passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rd icon The settings dialog was the last one still drawing its own .modal-back / .modal-actions and an inline marginTop, so it missed everything the shared Modal brought in v0.5.0: role/aria-modal, a labelled head, Escape, a focus trap, focus restored, and a footer that holds still while the body scrolls. It now uses <Modal> like the other eight, and its notes use the same `dim hint-inline` the other dialogs do instead of local spacing. The toolbar button was a bare ⚙ emoji — the one control in that row that did not come from the icon set, and a gear says "settings" without saying which. It is now icon-only with a new `gpu` glyph, and carries an aria-label like its neighbours (it only had a title). The glyph is not in lucide: a board with its two fans. A first draft (one fan plus two port lines) read as a contact card at 17px; the dual-fan silhouette is what makes it a video card at a glance. Verified live over CDP: role=dialog aria-modal=true, labelled title with the icon, 3 trapped focusables, Escape closes and focus returns to the button, and the picker still lists auto/Intel/NVIDIA with the active renderer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tarts Both relaunch paths — the GPU switch (gpu:relaunch) and the PRIME re-exec that moves Chromium onto the discrete card — spawned the successor and only then called app.exit(0), so for a moment two processes were alive and the newcomer inherited whatever singleton the old one still held. The visible casualty was --remote-debugging-port. The new process bound the socket but could never accept on it: ss showed LISTEN with Recv-Q at the backlog (11/10) while every CDP client hung on connect, and DevToolsActivePort still named a port nothing would answer on. That silently kills the e2e suites and any automation against a relaunched app — reproduced here by switching NVIDIA → Intel from the settings dialog. The successor now carries KADR_WAIT_PID and blocks at the top of runtime-env — the first import, before Chromium initialises anything — until that pid is gone (signal 0 as the liveness probe, 50 ms steps). Capped at 10 s so a wedged predecessor cannot hold the app hostage; the cap degrades to exactly the race we had before, never worse. Found by the user watching a switch, not by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he log Every cancelled export put three "сбой" rows in the session log and bumped the unseen badge, on a run that ended exactly as asked: Uncaught Error: write EPIPE необработанный отказ промиса (x2) Neither is a real failure, and together they teach people to ignore the panel v0.5.0 added to surface real ones. - The two rejections are the frames still in flight. The export keeps two raw buffers in the air (slotPending) and awaits a slot only before reusing it, so when the encoder is killed both pending rawEncodeFrame promises reject with nothing left to await them. The finally block now claims them; the run's outcome is already decided by then. - The EPIPE is ffmpeg's stdin breaking under SIGKILL. The stream had no 'error' listener, so Node escalated it to an uncaught exception — while the same failure was already being delivered through the write callback and rawEncExit, where it can actually be acted on. Verified live over CDP against a running editor: export a clip, cancel it mid-flight, count new log rows. Three before, one after the exporter fix (the rejections gone), and the last one needs a restart to pick up the preload side. The export itself still reports 'cancelled' throughout. Same three rows also appeared in e2e35, which kills the GL context mid-export — that is what put them in front of us. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…second New Project The format picker was still drawing its own .modal-back / .modal-actions, so it missed what the shared Modal brought in v0.5.0: role/aria-modal, a labelled head, Escape, a focus trap, focus restored, and a footer that holds still. It now uses <Modal> like every other dialog — that was the last hand-rolled one. It also fixes a duplicate the merge left behind. Upstream's file toolbar has its own New Project button that builds a project immediately at the default size; ours sat further along the same bar under the SAME label, opening the format picker. Two identically-labelled buttons doing different things, and the first one silently discarded the choice the feature exists to offer. The toolbar button now opens the picker and the stray duplicate is gone — one entry point, keeping upstream's placement and icon. Found while screenshotting the dialog over CDP: the click landed on the other button and no dialog appeared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6021ef2 held the successor until its predecessor exited, on the theory that the two overlapping was what cost a relaunched app its --remote-debugging-port. Measurement says otherwise, so the wait comes out and the real cause is recorded where the spawn happens. What the port actually loses: Chromium's DevTools listening socket has no CLOEXEC, so spawn() hands the child a duplicate fd. The child's own bind then fails with EADDRINUSE against itself — no DevTools server starts, the port file is never rewritten (its mtime predates the process by 31 s), and the socket sits in LISTEN with nobody accepting: Recv-Q climbs to the backlog and every CDP client hangs. Proof it is the same socket rather than a failed re-bind: the inode is IDENTICAL either side of a switch (10441318 → 10441318), where a fresh bind would allocate a new one. The fd is duplicated at spawn, before any wait could run, so waiting cannot help. The wait itself did work as written — with KADR_WAIT_PID pinned to pid 1 the port went LISTEN at 10.38 s against a 10 s block, so it does gate startup. It just gates the wrong thing, and costs up to 10 s on every relaunch to do it. Impact is dev-only: nothing binds that port unless the run asked for one, so users never meet this. Relaunching from a terminal gives automation its port back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
electron-builder's whitelist listed only out/, mcp-bridge.cjs and transcribe.py, but four more paths are read at runtime through app.getAppPath(). A packaged build therefore started fine and failed the feature instead: electron/kadr-skill.md claude.ts → "skill sync failed: ENOENT" scripts/ttsqc_run.py voice.ts → no driver for defect detection python/** voice.ts → KADR_TTSQC_HOME points at nothing python/models/scorer.pkl voice.ts → no model kadr-skill.md has been missing since v0.3.0 added it; the rest arrived with the v0.5.1 merge. Caught by the ENOENT in a packaged run, not by a test — nothing exercises the packaged layout. 2.2 MB in total, 1.9 MB of it the model. python3 is already in the bundled runtime, so the detector has an interpreter to run under. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merges
v0.5.1and adds work done on an NVIDIA Optimus laptop while cutting a 4K vertical video. It is a mix of general bug fixes, hardware-specific features, and packaging — split below so you can take, question or drop each part on its own.Everything was verified against a running editor over CDP on both GPUs of this machine (NVIDIA GTX 1050 discrete, Intel UHD 630 integrated).
npm run typecheckandnpm run buildare clean; e2e24, e2e28, e2e32, e2e35 and the node checks (proxy, envelope, ttstext, voicemap) pass, with one known exception noted at the end.1. Bug fixes that affect everyone
These are not about my hardware. Each was reproduced before the change and measured after.
Audio: a clip with
inPoint > 0jumped to t=0 in the exportelectron/ffmpeg.tsExportMuxerended each segment's chain with an unboundedapadfollowed byatrim=0:<duration>. On an input-seeked segment (-ss inPoint -i file, i.e. any clip trimmed at the head) that trailingatrimthrew the delayed audio back to t=0 and left its real slot silent. In a project cut into many pieces, most of the sound piled up at the start.It is not a PTS problem: re-zeroing the timestamps (
asetpts=PTS-STARTPTS, evenasetpts=N/SR/TB) with theatrimstill in place still collapsed the clip, while every chain without the trailingatrimplaced it correctly.Fix:
apad+atrim→apad=whole_dur=<duration>. Each stream is padded to exactly the export length and then EOFs, soamix(duration=longest) still terminates — a two-input mix completes in 50 ms — and nothing trims afteradelay.Verified on a real project: clips at 25.667 s (inPoint 4.219) and 28.550 s (inPoint 3.033) land in place; the exported file's 7.5-second silent hole is gone.
Alpha: the matte was range-compressed twice in the packed intermediate
electron/ffmpeg.ts,electron/cacheKeys.tsThe packed colour-over-matte intermediate built its matte with
alphaextract,format=yuv420p,scale=in_range=pc:out_range=tv. On current swscale (system ffmpeg 8.1 and a recent BtbN build alike)format=yuv420palready maps the gray alpha into limited range, so the explicitpc→tvafter it compressed a second time: α 255 landed at Y=218 instead of 235. Every opaque area of an alpha source composited at ~92 % — a 255 red came out at 229.Older swscale copied gray→yuv420p verbatim, which is why
e2e32passed where it was written.Fix: put the range compression before
format=. New swscale then sees a tv-tagged input and leaves it alone; old swscale still gets its single compression — 235/127/71 for α 255/129/64 on both ffmpeg builds here. The packed cache suffix moves:p→:p2so existing intermediates re-pack.Measured (
e2e32): opaque red 229 → 250 (element path 251), PSNR 37.3 → 44.3 dB against the suite's own 40 dB gate. Identical numbers on NVIDIA and Intel — two very different decode paths — which is what shows the defect was in the packer, not the GPU.Export intermediates went to
/tmp, which is a tmpfs on many Linux setupselectron/runtime-env.ts,electron/main.ts,electron/preload.tsThe raw-video encode (preload) and the mux temp (main) used
os.tmpdir(). Here/tmpis a 7.7 GB RAM-backed tmpfs; a large export overflows it mid-run and ffmpeg dies withEDQUOT/ENOSPC— surfacing as an export that stalls right after the start, or at ~67 % once the intermediate has grown. It cost me most of a day before I found the real error, because the UI just showed a frozen percentage.runtime-env.tsnow pointsKADR_TMPDIRat<userData>/tmp; both call sites use it with anos.tmpdir()fallback, and the renderer inherits the env the same way it already inheritsKADR_FFMPEG.Cancelling an export filed three failures in the session log
src/engine/exporter.ts,electron/preload.tsEvery cancelled export produced, on a run that ended exactly as asked:
The two rejections are the frames still in flight: the export keeps two raw buffers in the air (
slotPending) and awaits a slot only before reusing it, so killing the encoder rejects both with nothing left to await them. Thefinallyblock now claims them. The EPIPE is ffmpeg's stdin breaking under SIGKILL — the stream had noerrorlistener, so Node escalated it to an uncaught exception, while the same failure was already delivered through the write callback andrawEncExit.Verified live: export a clip, cancel mid-flight, count new log rows — 3 → 0, and
e2e35still passes all 16 checks, so a genuine context loss is still reported loudly.This one matters beyond the noise: the panel added in v0.5.0 exists to surface real failures, and crying wolf on a routine action teaches people to ignore it.
An export could hang instead of failing
src/engine/demux.ts,electron/main.tsMp4FrameSource.open()had no timeout — a wedged decoder init hung there with neither an output nor an error, freezing the export on a frozen percentage. It gets a 10 s watchdog and falls back to element-seek, alongside an 8 s watchdog on the per-frame wait. Startup also sweeps orphanedkadr-export[-raw]-*.mp4intermediates left by a crash (age-guarded, so a concurrent live export is never touched).A keyframe without
easingblacked out the whole frame, silentlysrc/engine/anim.ts,src/state/store.ts,src/engine/player.tsevalAnimindexedease[k0.easing]unguarded, so a keyframe written by a script or an imported project with a missing/unknown easing threwTypeError. Becausecomp.begin()clears the framebuffer before the layer loop, that one throw dropped every layer — a fully black frame, with the preview swallowing the error and the export aborting.Three coordinated changes:
evalAnimfalls back to linear,sanitizeProjectcoerces unknown easings on load (healing existing project files), anddrawFrameguards each clip so a bad one skips itself instead of taking the frame down.2. Features
Take or leave — these are additions, not repairs.
electron/gpu.ts,SettingsDialog) — choose the render GPU. On an Optimus laptop the discrete card needs PRIME offload set from process start, so picking NVIDIA re-execs with the offload env and--ozone-platform=x11; setting the vars late leaves the GPU process on the iGPU while GLX is forced to NVIDIA, which segfaults it. A pick is applied as a trial that self-heals to auto if the window never renders.shared/rawEncode.ts,ExportDialog) — optional hardware H.264, auto-falling back to x264. Availability is probed with a real 1-frame encode rather than an-encodersgrep, because a build can listh264_nvencand still fail to open it on an older driver (NVENC API 13.1 needs driver ≥ 610).squircleglyph (the icon set had none).addTracktakes an optional insert index.3. Packaging
scripts/pack.sh+electron-builder.ymlbuild a self-contained archive (bundled ffmpeg/node/python/whisper, Remotion seed). Entirely additive; ignore it if you package differently.One find worth flagging even if you drop the rest: four paths read at runtime through
app.getAppPath()were missing from the packaged payload —electron/kadr-skill.md(missing since v0.3.0 introduced it),scripts/ttsqc_run.py,python/**andpython/models/scorer.pkl. A packaged build starts fine and then fails the feature: the Claude panel logsskill sync failed: ENOENT, and voice-defect detection has no driver, package or model.Decisions you may want to reverse
Fragments now render at the project fps instead of a forced ≥60.
createFragmentclamped toMath.max(60, fps). Remotion writes every frame as a full-res PNG to the OS temp dir, so on a 30 fps 4K project that doubled a pile that already overflowed a tmpfs — my renders died at frame 4877 of 5285. Since the timeline samples at the project fps, the extra frames are not visible in a 30 fps export.This is a deliberate change to your choice, and it changed two assertions in
e2e24(a separate commit,f90f595). If you intended ≥60 for smoothness when exporting above the project fps, say so and I will make it conditional instead. Remotion's temp is also redirected off the tmpfs, with a pre-flight disk estimate that fails early with a clear message rather than at ~90 %.Known failure, not fixed
e2e32's "both decode paths composite alpha identically" still fails:(250,0,0)vs(251,0,0)— 1–5 levels on saturated primaries, from rounding in the packed path's double matrix conversion against the element path's single one on newer swscale. Identical on both GPUs. The suite's own quality gate (PSNR ≥ 40) passes at 44.3 dB. I did not want to relax someone else's test on my own judgement — tell me which way you want it.Not verified here
Happy to split this into separate PRs — the four fixes in §1 stand entirely on their own and do not depend on anything else here.