Conversation
…udget Zoom-out pulls clips into range and mounted the whole newly-visible cluster (TimelineItem trees) in one synchronous commit, spiking the frame to ~33ms and locking the gesture at ~30fps. Zoom-in unmounts, so it stayed smooth. Stage the mid-gesture expansion so clips mount a few per frame via a shared rAF coordinator. The budget is global across all track hooks (not per-track) since the mount cost is on one main thread — per-track would multiply by track count and reintroduce the spike. The settle path still mounts any stragglers at once.
At extreme zoom (timeline >1M px wide), a fast zoom fling ran at a sustained 20-30fps. LoAF attribution showed ~16ms of forced synchronous layout per frame, not React (dev Profiler put React commit at ~2.5ms median). The scroll-clamp and pending-scroll layout effects read container.clientWidth / scrollLeft right after writing the new width every frame, forcing a reflow of the giant tree. Use the cached viewport width (invariant under horizontal zoom) and the tracked scrollLeft instead of live reads, and pass the just-written scrollLeft into syncViewportFromContainer so it skips the read-back. Cuts dropped frames on a full fling-to-max from ~51 to ~7-15 with a 17ms (60fps) median.
The scroll handler already records scrollLeft into scrollLeftRef before scheduling a viewport sync, but the RAF then re-read container.scrollLeft — forcing a synchronous reflow whenever a zoom width write landed the same frame. Hand the tracked value to syncViewportFromContainer instead. Removes the last per-frame forced layout in the zoom path (LoAF forcedStyleAndLayout ~16ms/frame to ~0); moved scrollLeftRef up so it's in scope for the scheduler.
The clip waveform height came from a hand-tuned heuristic (mean*0.38 + max2*0.34 + needle*2.35, then ^1.05) that produced two zoom-dependent artifacts: the needle*2.35 term exploded isolated transients into disproportionate spikes, and the stable mean+max2 terms masked real per-column peak dips, flattening loud/compressed content into a uniform saturated band. Replace it with a faithful peak envelope (peak*0.85 + mean*0.15) and a gentle perceptual gamma (^0.72) that lifts quiet passages without distorting loud transients. Extracted into a shared, tested computeWaveformAmplitude helper so both the regular and compound-clip renderers stay in sync.
ClipFilmstrip rendered one <img> tile per slot across the clip's entire width, ignoring the visibleStartRatio/visibleEndRatio props it already received. A clip spanning a large fraction of a max-zoom timeline mounted thousands of tiles that React reconciled and the compositor painted on every scroll frame, making navigator scrubbing drop to ~20fps with a ~460ms stall on first paint. Window the slot grid to the visible region (+ pad) via the existing computeFilmstripRenderWindow helper, mirroring ImageFilmstrip and the waveform's TiledCanvas. Off-window areas keep showing the repeating cover-frame background, so windowing is seamless. Cuts the giant-clip tile count from ~6300 to ~210 and restores ~60fps scrubbing.
…g playback A transition only stayed on the per-frame fast-scrub overlay during playback when some item incidentally forced the continuous overlay (GPU effect, blend, corner-pin, or composition). A plain transition therefore relied on an unrelated item happening to keep it on; when that item ended at the cut, the overlay dropped into the buffered path, which could leave second-half frames un-rendered and collapse the wipe to a single clip until the window ended. This was most visible with reversed clips but was not reverse-specific, and only affected live preview (export decodes every frame synchronously). Force the continuous overlay on active transition-window frames during playback as well as scrubbing, so every transition renders every frame via the path that already worked for the transitions that looked correct.
Diagnosing 'live preview looks wrong but export is fine' transition bugs meant hand-pasting console probes and reading raw per-frame dumps. Add a reusable DEV-only facility instead.
Adds src/shared/logging/preview-trace.ts: a trace sink plus a pure analyzePreviewTrace() that summarizes, per transition half, which overlay path the pump chose and which participants composited, flags stalls/gaps and mid-window overlay path switches, and emits a plain-language verdict (unit-tested).
Wires permanent import.meta.env.DEV-guarded hooks (tree-shaken from prod, no-op unless a trace is running) into the render pump's overlay decision and the renderer's transition-participant draws, and exposes window.__DEBUG__.captureTransition(frame?) (one-call seek+play+record+analyze) plus __DEBUG__.previewTrace.{start,stop,clear,events}().
Split the pure store-hydration half out of loadTimeline into hydrateTimelineStoresFromProject(project), and the pure stores-to-ProjectTimeline serialization out of saveTimeline into buildTimelineFromStores(). Both are behavior-preserving; loadTimeline and saveTimeline now call them. This lets callers that already hold a Project and persist project.json themselves (the headless render/edit harness) hydrate the stores and serialize them back without the storage read, migrated-project persist, orphaned-media dialog, or thumbnail-generation side effects.
Add blobUrlManager.registerUrl(mediaId, url): track an externally-hosted URL (e.g. an HTTP URL) for a media id WITHOUT loading the bytes into a Blob. Because no Blob is registered in the object-url registry, consumers that build a mediabunny input fall through to UrlSource — i.e. the media is range-streamed over HTTP instead of held fully in memory. The entry's blob field is now optional and a guarded revoke skips external URLs; existing acquire() behavior is unchanged. Used by the headless render harness to stream source media from disk without downloading whole files.
Render and edit FreeCut projects from the CLI with no editor UI, by driving the real render engine and timeline action modules inside headless Chrome. The engine relies on browser APIs (WebCodecs, WebGPU, OffscreenCanvas, OfflineAudioContext), so a Node driver launches Chrome and loads a UI-less Vite entry (headless.html -> src/headless/main.ts) exposing window.freecut, instead of porting the engine to Node. - src/headless/main.ts: renderProject/renderTimeline reuse the export pipeline (migrate -> convert -> renderComposition); media is registered as HTTP URLs and range-streamed via mediabunny UrlSource. - src/headless/edit.ts: editProject hydrates the real stores, applies ops through the real action modules (add/update/move/remove/split/trim/ transition), and serializes back. - headless/render.mjs, headless/edit.mjs: the two CLIs (npm run headless). - headless/server.mjs: standalone server (built dist/ + media, one COEP-isolated origin, HTTP Range) so no dev server is required. - headless/media-server.mjs, lib/workspace.mjs, probe/smoke scripts, README. - vite.config.ts: multi-entry (index.html + headless.html). - package.json: playwright devDependency + "headless" script. Validated on real projects: text, video+audio, nested compound clips, GPU adjustment effects, crossfade transitions, range slices, a 5s slice of a 3GB MKV (range streaming, no OOM), and an edit->render round-trip.
The export audio path keys AC-3/E-AC-3 detection on getMediaAudioCodecById, which reads the media-library store — empty in the headless harness, so Dolby Digital / DD+ tracks were dropped (silent). The render CLI now reads each referenced media's metadata.json and passes it through; the harness seeds useMediaLibraryStore so the codec is recognized, the @mediabunny/ac3 decoder is registered, and the audio decodes (verified on a real E-AC-3 clip: -29 dB mean, not silence). Also warn in the CLI when a media's audioCodecSupported is false (e.g. DTS), which the browser can't decode headlessly — that audio stays silent.
Probe navigator.gpu at render start. If the composition uses enabled GPU effects (which have no Canvas2D fallback) and WebGPU is unavailable, throw a clear error instead of silently rendering without them. Transitions keep their Canvas2D fallback, so a missing GPU there is only a warning. Important for headless/CI/Docker environments where the GPU may be absent or software.
- Servers now resolve media dynamically from the workspace (resolver instead of a fixed id->path map), so one warm browser + server handles any project. - render.mjs: extract prepareJob/renderJob; add --batch <jobs.json> to render many jobs (each with the same keys as the CLI flags) reusing one browser. - test.mjs (npm run headless:test): self-contained regression — builds the harness, asserts a text render (duration/bytes/mime) and an edit op, no workspace/media/ffprobe needed, non-zero exit on failure. - add npm run headless:edit; media-server accepts a Map or a resolver.
…ts, transform Extend the edit harness with ops that drive the real action modules: - addClip <mediaId>: builds a video (+ linked audio companion) / audio / image item, computing the source range from the media's metadata (seeded into the media-library store); edit.mjs passes metadata.json for addClip media. - addTrack: create a classic video/audio track (createClassicTrack + setTracks). - addKeyframe / removeKeyframes: keyframe-actions. - addEffect (gpuEffectType + params, or a full effect) / removeEffect. - setTransform: updateItemTransform. Verified via edit->render: addClip places real content with audio; addEffect gpu-grayscale and setTransform render through WebGPU headlessly.
Extract the shared render core (settings, range, media resolution, harness/ media servers, per-page renderJob) into headless/lib/render-core.mjs so the CLI and the service share one implementation; render.mjs is now a thin CLI over it. Add headless/serve.mjs (npm run headless:serve): a long-lived service that keeps one warm Chrome + harness over a workspace and exposes GET /health, GET /projects, POST /render (streams the file), POST /edit. Requests are serialized to avoid GPU/CPU contention. Verified health/projects/render/edit end to end.
- chromeLaunchArgs(): pick the ANGLE backend per platform (d3d11/metal/vulkan)
instead of hardcoding Windows d3d11, and allow FREECUT_CHROME_ARGS to append
flags (e.g. --no-sandbox in Docker). render/serve/test use it.
- serve.mjs GET /health now probes WebGPU and reports { ok, gpu, harnessUrl }.
- headless/Dockerfile: Google Chrome (H.264/AAC) + Mesa lavapipe (software
WebGPU), builds dist/, runs serve.mjs; mount the workspace read-only. The
WebGPU-presence check fails loudly if effects can't render in-container.
- .dockerignore: exclude headless scratch + sample workspace from the context.
Note: the image build/run wasn't validated here (no Docker daemon in this
session); the non-Docker paths (render/edit/serve/test) are all verified.
Linux Chrome has no WebCodecs AAC encoder, so mp4/AAC output silently dropped audio. The harness now checks AudioEncoder.isConfigSupported and returns the issue in summary.warnings; the CLI prints it and the service sets an X-Freecut-Warnings response header (ASCII-sanitized so a warning can never turn a successful render into a 500). Opus (webm) and mp3 still carry audio. Docker validated end to end: builds, /health reports gpu:true (software WebGPU via lavapipe), and video / text / transition renders work; mp4 returns a valid video with the AAC warning, vp9/webm carries Opus audio. Heavy GPU effects can hit a Dawn device-loss under software WebGPU and surface as a clear error — use a GPU host for those. README documents all of this.
Issue 1 (AAC dropped on Linux) — FIXED: register the @mediabunny/aac-encoder WASM polyfill when no native AAC encoder exists, so mp4/AAC carries audio everywhere (verified in-container: h264 + aac at -13dB). Uses mediabunny's canEncodeAudio so the post-register check is accurate; warns only if a codec still can't encode. Added the dep + vite optimizeDeps exclude. Issue 2 (effects need a real GPU) — software WebGPU (lavapipe AND SwiftShader) both hit a Dawn device-loss on the effects pipeline; not fixable via flags. Resolution is to use a real GPU: - Dockerfile no longer force-pins lavapipe, so on a Linux host with the NVIDIA Container Toolkit (`--gpus all -e NVIDIA_DRIVER_CAPABILITIES=all`) the GPU's Vulkan ICD is auto-discovered and effects use the real GPU; otherwise lavapipe provides software WebGPU (verified still gpu:true + renders). - Docker Desktop on Windows/WSL2 exposes CUDA/NVENC but no Vulkan ICD, so containerized WebGPU is software-only there — run the service natively for GPU effects. chromeLaunchArgs() gained a FREECUT_CHROME_ARGS_REPLACE override. README documents the native-vs-Linux-GPU-host guidance.
…are GPU
Docker is for deploying on a Linux host with an NVIDIA GPU (real Vulkan via the
Container Toolkit). Docker Desktop on Windows/WSL2 has no Vulkan ICD, so WebGPU
is software-only there — run natively for GPU effects. README repositioned
accordingly (Windows = native; Docker = Linux GPU server).
serve.mjs now probes the WebGPU adapter: logs it at startup, warns loudly when
it's software (no real GPU), and GET /health returns
{ gpu: { available, vendor, architecture }, software } so an operator can
confirm the real GPU is in use (e.g. nvidia/lovelace vs google/swiftshader).
Add a serial render queue to the in-app export pipeline so users can line up multiple segments/jobs and a dedicated Exports dialog to manage them and browse saved outputs. - Queue store + serial runner (one job at a time — the WebGPU device is a tab-wide singleton, so concurrent renders would contend) - Extract shared render-pipeline core (settings/codec resolution + worker/main-thread orchestration) reused by single export and queue - Snapshot the timeline at enqueue so later edits don't change a queued job - Segment generators: current range, one-per-marker, fixed-duration chunks (split within the active I/O range; one codec probe per batch) - Outputs saved to <workspace>/exports/; Exports dialog tabs: queue + saved-files browser (download/delete) - Persist the queue per project to projects/<id>/render-queue.json: restore on load, requeue in-flight jobs, debounced status-keyed saves, deduped snapshots, project-switch safe - Toolbar queue button with active-count badge; i18n for all 9 languages
…diately - On load, keep a restored queue with pending work PAUSED until the user resumes, so a refresh never silently kicks off renders (no pause when there's nothing queued, so jobs added after a refresh still run) - Replace the 600ms debounced save with an immediate microtask-coalesced save so a job's terminal status is on disk before a refresh can lose it
- Save renders to the project's own projects/<id>/exports/ folder (was a shared top-level exports/); list/read/delete are now project-scoped - Saved exports tab shows where files live (workspace folder + this project's exports folder) and notes the browser can't open the OS folder directly — use Download to get a copy (File System Access API has no reveal-in-folder capability) - Thread projectId through the Exports dialog; i18n for all 9 languages
…level files Save renders to projects/<id>/exports/ (grouped with the project, removed with it). To avoid hiding files made before this change, listExportFiles merges the project's folder with any loose files still in the top-level exports/ folder; read/delete are addressed by full path so both work.
The feature is brand new, so there's nothing to migrate — list only the project's own projects/<id>/exports/ folder instead of also surfacing loose files from the top-level exports/ folder.
Drop the "browsers can't open it directly" caveat; just tell users the files can be found in this project's local folder. Updated all 9 languages.
… exports Extract parseArgs + chromeLaunchArgs into headless/lib/cli.mjs (the hardcoded GPU_ARGS copies in probe/smoke/media-smoke were Windows-only; they now get the platform-correct ANGLE backend + env override). edit.mjs reuses render-core's startHarness (generalized for the media-less edit path) instead of its own copy. Share collectAddClipMedia (workspace.mjs) and seedMediaLibrary (src/headless/seed-media.ts). Remove unused deleteRenderQueue + exportsDir exports; simplify abortJob to void.
Text rendered through three independent layout implementations — DOM/CSS at
rest, Canvas 2D for skim+export, and the GPU glyph atlas — plus a separate
auto-fit height calc. All three manual paths measured letter-spacing as
(n-1)·LS while CSS uses n·LS (trailing spacing after the last glyph), so
centered/letter-spaced titles shifted ~2px horizontally when skimming vs at
rest. The remaining vertical/stroke difference came from skim painting text on
a 2D canvas while at-rest used the DOM/CSS renderer.
- Add src/shared/typography/{text-style,text-measurer,text-block-layout} as the
single source of style defaults + span cascade, CSS-equivalent measurement
(native ctx.letterSpacing + fontKerning), and block geometry.
- Route the Canvas 2D renderer, GPU glyph atlas, DOM text-content, and the
auto-fit height calc through it; canvas now draws one fillText/strokeText per
line (no per-character path).
- Keep styled text (shadow/stroke) on the DOM Player during skim, like captions,
so it uses the same renderer as at rest — no vertical/stroke/AA pop.
Export now matches the preview for letter-spaced text.
…ably Audio and video are meant to live on separate tracks, but a video clip could reach the timeline with un-split embedded audio (preview canvas drop, or legacy/pre-existing projects), which then surfaced the video track in the mixer with a fader/EQ strip. - Mixer: isAudioMixerTrack() now excludes video tracks on principle, regardless of any un-split embedded audio. - Canvas drop: a dropped video carrying audio splits onto a linked audio track via the new addItemWithLinkedAudio action, matching the timeline-drop behavior instead of bypassing the split. - Load-time backfill: surgically split audible videos that lack a companion (root + compositions), preserving existing track names and order; runs alongside the legacy A/V layout repair. - Add a reusable embedded-audio-split core (reuses makeGeneratedAudioItem / cloneVolumeKeyframes) plus tests.
Dragging the in/out handles now scrubs the preview to the boundary frame so you can see exactly where you're trimming, then clears the preview override on release. - Source monitor: in/out handle drags and range slide set the preview source frame (out is exclusive, so it skims to out - 1). - Timeline in/out markers + range drag: same skim-to-boundary behavior via the playback preview frame.
Re-importing a file removed from the project (but still in the workspace) hit the dedup duplicate path, which only stripped the optimistic placeholder — leaving the file invisible and showing a spurious "already exists in library" banner. - ensureImportedMediaVisible now guarantees the resolved record appears exactly once (covering fresh imports, dedup re-adds, and a concurrent loadMediaItems wiping the placeholder mid-import) - the "already exists" banner now fires only when the file is already visibly in the library, not for by-design cross-workspace re-association
|
Too many files changed for review. ( |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo many files! This PR contains 228 files, which is 78 over the limit of 150. To get a review, narrow the scope: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (228)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The "short cooldown after the overlap ends" sync test pinned its frame-61 assertion to a single captured mock renderer instance. Transition entry now cycles through several short-lived renderer instances (the structure key churns as the session settles), so the cooldown frame is drawn by a renderer created after the captured handle — the frame still renders and displays correctly (displayed=61, overlay visible), but the call landed on a different instance. Assert the cooldown frame was rendered by some instance instead, keeping the displayed-frame and visibility checks that capture the test's real contract. Fixes the flaky/failing Preview Sync Stress quality check.
Summary
Promotes 41 commits from
developtostaging. 229 files changed, +12,977 / −3,059. Headlined by a new headless render+edit CLI, an in-app render queue, timeline zoom/scroll perf work, and a core UI/data-flow refactor.Highlights
Headless render + edit CLI (new)
serve.mjs) + shared render core; Dockerfile with platform-aware Chrome args and GPU health checksExport
Timeline performance
Media library & import
Preview / audio / text fixes
Core refactor & misc
Test plan
🤖 Generated with Claude Code