Skip to content

Merge v0.5.1 + export fixes, GPU picker(nvidia/intel), NVENC, 4K presets - #17

Open
progerWolf wants to merge 30 commits into
HelpFreedom:mainfrom
progerWolf:feat/gpu-and-project-presets
Open

Merge v0.5.1 + export fixes, GPU picker(nvidia/intel), NVENC, 4K presets#17
progerWolf wants to merge 30 commits into
HelpFreedom:mainfrom
progerWolf:feat/gpu-and-project-presets

Conversation

@progerWolf

Copy link
Copy Markdown

Merges v0.5.1 and 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 typecheck and npm run build are 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 > 0 jumped to t=0 in the export

electron/ffmpeg.ts

ExportMuxer ended each segment's chain with an unbounded apad followed by atrim=0:<duration>. On an input-seeked segment (-ss inPoint -i file, i.e. any clip trimmed at the head) that trailing atrim threw 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, even asetpts=N/SR/TB) with the atrim still in place still collapsed the clip, while every chain without the trailing atrim placed it correctly.

Fix: apad + atrimapad=whole_dur=<duration>. Each stream is padded to exactly the export length and then EOFs, so amix (duration=longest) still terminates — a two-input mix completes in 50 ms — and nothing trims after adelay.

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.ts

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 a recent BtbN build alike) format=yuv420p already maps the gray alpha into limited range, so the explicit pc→tv after 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 e2e32 passed 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:p2 so 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 setups

electron/runtime-env.ts, electron/main.ts, electron/preload.ts

The raw-video encode (preload) and the mux temp (main) used os.tmpdir(). Here /tmp is a 7.7 GB RAM-backed tmpfs; a large export overflows it mid-run and ffmpeg dies with EDQUOT/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.ts now points KADR_TMPDIR at <userData>/tmp; both call sites use it with an os.tmpdir() fallback, and the renderer inherits the env the same way it already inherits KADR_FFMPEG.

Cancelling an export filed three failures in the session log

src/engine/exporter.ts, electron/preload.ts

Every cancelled export produced, on a run that ended exactly as asked:

Uncaught Error: write EPIPE
необработанный отказ промиса   (x2)

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. The finally block now claims them. 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 delivered through the write callback and rawEncExit.

Verified live: export a clip, cancel mid-flight, count new log rows — 3 → 0, and e2e35 still 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.ts

Mp4FrameSource.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 orphaned kadr-export[-raw]-*.mp4 intermediates left by a crash (age-guarded, so a concurrent live export is never touched).

A keyframe without easing blacked out the whole frame, silently

src/engine/anim.ts, src/state/store.ts, src/engine/player.ts

evalAnim indexed ease[k0.easing] unguarded, so a keyframe written by a script or an imported project with a missing/unknown easing threw TypeError. Because comp.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: evalAnim falls back to linear, sanitizeProject coerces unknown easings on load (healing existing project files), and drawFrame guards each clip so a bad one skips itself instead of taking the frame down.


2. Features

Take or leave — these are additions, not repairs.

  • GPU picker (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.
  • NVENC export (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 -encoders grep, because a build can list h264_nvenc and still fail to open it on an older driver (NVENC API 13.1 needs driver ≥ 610).
  • 4K presets — vertical 2160×3840 and square 2160×2160.
  • Project format picker — choose the canvas at New Project, and when the first video lands in an empty project ("match video" preselected).
  • Rounded-rectangle mask — new shape with a rounded-box SDF in the compositor, a radius control, and a new squircle glyph (the icon set had none).
  • Smaller things — re-importing a file overwritten under the same name refreshes the asset instead of reusing the stale one (dedupe by mtime); addTrack takes an optional insert index.

3. Packaging

scripts/pack.sh + electron-builder.yml build 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 payloadelectron/kadr-skill.md (missing since v0.3.0 introduced it), scripts/ttsqc_run.py, python/** and python/models/scorer.pkl. A packaged build starts fine and then fails the feature: the Claude panel logs skill 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. createFragment clamped to Math.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

  • Only Linux, only this machine (Bazzite, Wayland session, NVIDIA 580.173.02 + Intel UHD 630). No Windows or macOS.
  • The GPU picker is Linux-only by construction (DRM render nodes, PRIME offload).
  • The voice-over and defect features that arrived with v0.5.x were not exercised beyond confirming their runtime files now ship.

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.

Ashurov Abdulaziz and others added 30 commits August 20, 2026 16:53
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant