Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
df03f69
perf(timeline): stage zoom-out clip mounts under a global per-frame b…
walterlow May 28, 2026
a0fda3b
perf(timeline): drop per-frame forced reflow in the zoom scroll path
walterlow May 28, 2026
7bc44e1
perf(timeline): pass tracked scrollLeft to the viewport-sync RAF
walterlow May 28, 2026
f075ba3
fix(timeline): render waveform from true peak envelope
walterlow May 28, 2026
7b40a2a
perf(timeline): window video filmstrip tiles to the visible range
walterlow May 28, 2026
34fd375
fix(preview): keep continuous overlay across transition windows durin…
walterlow May 29, 2026
969578a
feat(debug): add captureTransition preview-render diagnostics
walterlow May 29, 2026
446ef38
refactor(timeline): extract store hydrate/serialize from persistence
walterlow May 29, 2026
6707363
feat(browser): register external media URLs without a blob
walterlow May 29, 2026
1258d45
feat(headless): add headless render + edit CLI via Playwright harness
walterlow May 29, 2026
f97e570
feat(headless): decode AC-3/E-AC-3 audio by seeding media metadata
walterlow May 29, 2026
7f6d838
feat(headless): fail loudly when WebGPU is missing for GPU effects
walterlow May 29, 2026
9c4e7b1
feat(headless): warm-browser batch rendering + regression test
walterlow May 29, 2026
939267a
feat(headless): richer edit ops — addClip, addTrack, keyframes, effec…
walterlow May 29, 2026
1980d20
feat(headless): render service (serve.mjs) + shared render core
walterlow May 29, 2026
2673920
feat(headless): Dockerfile + platform-aware Chrome args + GPU health
walterlow May 29, 2026
aa4919b
feat(headless): warn (don't silently drop) when audio codec can't encode
walterlow May 29, 2026
f2ab035
fix(headless): real AAC audio via WASM encoder; GPU-aware Docker
walterlow May 29, 2026
651e528
docs(headless): scope Docker to Linux GPU hosts; report real vs softw…
walterlow May 29, 2026
55b6479
feat(export): in-app render queue with per-project persistence
walterlow May 29, 2026
2851096
feat(export): restore render queue paused; save on status change imme…
walterlow May 29, 2026
12205cf
feat(export): per-project exports folder + location notice
walterlow May 29, 2026
fc73844
feat(export): per-project exports by id, but still surface loose top-…
walterlow May 29, 2026
4c8d05c
refactor(export): drop legacy top-level exports merge
walterlow May 29, 2026
d3e8c0c
i18n(export): simplify exports-location notice
walterlow May 29, 2026
d92b29a
refactor(headless): dedupe shared CLI/harness/seed helpers; drop dead…
walterlow May 29, 2026
7423916
docs(changelog): roll up week of 2026-05-18; open week of 2026-05-25
walterlow May 29, 2026
cf15c53
fix(text): unify layout across render paths to stop skim shift
walterlow May 29, 2026
d7c8161
fix(audio): keep video audio off the mixer; split embedded audio reli…
walterlow May 29, 2026
966067e
feat(preview): skim preview to in/out boundary while dragging markers
walterlow May 29, 2026
52e5cce
fix(media-library): surface re-imported media and fix duplicate banner
walterlow May 29, 2026
ff78b95
Atomicize project media unlink and timeline save
walterlow May 30, 2026
da93780
Refactor media preparation into a readiness gate
walterlow May 30, 2026
2a1d7d7
Localize media preparation and keyframe partials
walterlow May 30, 2026
068a910
Fix filmstrip readiness and import prep grouping
walterlow May 30, 2026
873f7d5
Optimize media import prep for large filmstrips and waveforms
walterlow May 30, 2026
cc5affe
feat: refactor core UI and data flow
walterlow May 30, 2026
babb25e
Lazy-load editor panels and optimize project list
walterlow May 30, 2026
fc131d1
(fix): refresh timeline track visuals on toggle
walterlow May 30, 2026
e37feff
fix: route editor lazy imports through feature contracts
walterlow May 30, 2026
444729d
fix: refresh project list after delete
walterlow May 30, 2026
3d344a7
test(preview): make cooldown overlay assertion robust to renderer churn
walterlow May 30, 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
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ coverage
server/temp/media/*
server/temp/output/*

# Headless scratch / outputs / sample workspace (rebuilt or not needed in image)
headless/output
headless/assets
tmp-workspace
tmp
*.mp4

# Git
.git
.gitignore
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ crates/*/Cargo.lock

tmp

# Headless render harness: generated outputs, test assets, scratch workspaces.
# (The harness source — headless/*.mjs, src/headless/ — is committed.)
headless/output/
headless/assets/
tmp-workspace/

.vercel

# FreeCut workspace artifacts, if the repo folder itself is picked as a
Expand Down
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,36 @@ All notable changes to FreeCut. Weekly CalVer: `YYYY.MM.DD` = the Monday of the

<!-- Entries below are generated via the `changelog` skill. Newest first. -->

## [Current] — week of 2026-05-18
## [Current] — week of 2026-05-25

### Added
- In-app render queue — line up several exports and they render one after another, surviving a page refresh
- One-click quality presets in the export dialog
- Exports now save to a per-project folder, with a notice showing where files land
- Automatic caption styling, with per-item progress in the AI panel

### Fixed
- Waveforms render from true audio peaks and stay sharp when zoomed in
- Waveforms no longer flash a skeleton when moving a clip to another track
- Preview no longer jumps when entering pen/path edit mode
- Preview stays continuous through transitions during playback
- Remaining placeholder strings are now translated across all 9 languages

### Improved
- Much smoother timeline zooming and scrolling, especially with many clips on screen
- The editor stays responsive while audio loads — decoding now runs in the background

## [2026.05.18] — week of 2026-05-18 to 2026-05-24

### Added
- Hold (stepped) interpolation for keyframes
- Language switcher on the projects page, with more panels translated (text tools, transitions, scene browser)

### Fixed
- Rotated videos display the correct orientation in skim preview and exports
- Scrub overlay stays aligned and the skim indicator sits flush on clip edges
- Transitions on same-clip (A-A) splits now render correctly
- Splitting a reversed clip keeps both halves continuous

### Improved
- Filmstrips and waveforms render smoother while zooming the timeline
Expand Down
12 changes: 12 additions & 0 deletions headless.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FreeCut Headless Render Harness</title>
</head>
<body>
<div id="freecut-headless">FreeCut headless render harness</div>
<script type="module" src="/src/headless/main.ts"></script>
</body>
</html>
53 changes: 53 additions & 0 deletions headless/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# FreeCut headless render service.
#
# Builds the harness (dist/) and runs serve.mjs with one warm headless Chrome.
# Google Chrome (not Chromium) is used so H.264/AAC (proprietary codecs) work;
# Mesa lavapipe provides software Vulkan so WebGPU effects can render without a
# physical GPU. For best performance / guaranteed effects, run on a GPU host
# with passthrough (see README) instead of software Vulkan.
#
# Build (context = repo root):
# docker build -f headless/Dockerfile -t freecut-headless .
# Run (mount your workspace read-only):
# docker run --rm -p 8787:8787 -v /path/to/FreeCutProjects:/workspace:ro freecut-headless
# curl localhost:8787/health # -> {"ok":true,"gpu":true,...}
FROM node:24-bookworm

# Google Chrome (proprietary codecs) + software Vulkan (lavapipe) for WebGPU + fonts.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget gnupg \
&& wget -qO- https://dl.google.com/linux/linux_signing_key.pub \
| gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \
> /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
google-chrome-stable \
mesa-vulkan-drivers libvulkan1 vulkan-tools \
fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Use the system Google Chrome; don't download Playwright's bundled Chromium.
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1

# Install deps (ignore lifecycle scripts: the prepare hook sets up git hooks,
# which aren't present/needed in the image).
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts

# Build the harness (dist/headless.html + assets).
COPY . .
RUN npm run build

# Vulkan ICDs are auto-discovered: on a Linux GPU host (run with
# `--gpus all -e NVIDIA_DRIVER_CAPABILITIES=all`) the NVIDIA Vulkan ICD is
# mounted and WebGPU uses the real GPU; otherwise the bundled Mesa lavapipe
# provides software WebGPU. --no-sandbox is required running Chrome as root.
# (To force software even on a GPU host: -e VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json)
ENV FREECUT_CHROME_ARGS="--no-sandbox"
ENV FREECUT_WORKSPACE=/workspace

EXPOSE 8787
CMD ["sh", "-c", "node headless/serve.mjs --workspace \"$FREECUT_WORKSPACE\" --port 8787"]
242 changes: 242 additions & 0 deletions headless/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
# FreeCut Headless

Render **and edit** FreeCut projects from the command line — no editor UI — by
driving the **real** engine and timeline action modules inside headless Chrome
via Playwright.

Because the engine depends on browser APIs (WebCodecs, WebGPU, OffscreenCanvas,
OfflineAudioContext), a Node port would be a fragile rewrite. Instead, a tiny
Node driver launches headless Chrome, loads a UI-less harness page (`window.freecut`)
that reuses the exact export pipeline and Zustand timeline stores, and captures
the output. Fidelity matches the in-app export — including hardware GPU effects,
transitions, audio, and (for edits) transition repair + linked-clip cascades.

Two CLIs:
- **`render.mjs`** (`npm run headless`) — render a project (or a slice) to video/audio.
- **`edit.mjs`** — apply structural edits (add/split/trim/move/delete/transition) and write the project back.

## How it works

```
Node CLI (render.mjs)
├─ reads the workspace folder on disk (project.json + media/<id>/)
├─ serves the built harness (dist/) + media on one COEP-isolated origin,
│ with HTTP Range (server.mjs) [default — no dev server needed]
└─ launches headless Chrome (Playwright, channel: chrome)
└─ loads headless.html → src/headless/main.ts (window.freecut)
├─ migrateProject + convertTimelineToComposition
├─ registers media URLs (range-streamed via mediabunny UrlSource)
└─ renderComposition → Blob → download → saved by the driver
```

The browser harness lives in `src/headless/` (TypeScript, built by Vite). The
Node driver lives here in `headless/*.mjs` (plain ESM, run directly).

Media is **range-streamed**, not downloaded: the harness registers each media
file's HTTP URL (no Blob), so mediabunny reads only the byte ranges it needs.
A 5-second slice of a 3 GB source renders without loading the whole file.

## Prerequisites

- Google Chrome installed (the driver uses `channel: 'chrome'`).
- `playwright` (already a devDependency).
- A built harness (`dist/`). Build it once:

```bash
npm run build # produces dist/headless.html (re-run after harness changes)
```

The CLI serves `dist/` itself — **no dev server required**. (Or pass `--build`
to have the CLI build automatically when `dist/` is missing.)

## Usage

```bash
# List projects in a workspace folder
npm run headless -- --workspace "C:\path\to\workspace" --list

# Render a project to MP4 (H.264 + AAC), using the project's resolution/fps
npm run headless -- --workspace "C:\path\to\workspace" --project <projectId> \
--out ./my-render.mp4

# Render only a slice (great for very long projects)
npm run headless -- --workspace "<ws>" --project <id> --in 10 --duration 5

# Override codec / container / resolution / fps
npm run headless -- --workspace "<ws>" --project <id> \
--codec vp9 --container webm --resolution 1920x1080 --fps 30 --quality ultra

# Audio only
npm run headless -- --workspace "<ws>" --project <id> --audio-only --container mp3
```

### Options

| Flag | Default | Notes |
|------|---------|-------|
| `--workspace <dir>` | (required) | The FreeCut workspace folder (picked in the app). |
| `--project <id\|file>` | (required) | Project id under the workspace, or a path to a `project.json`. |
| `--out <path>` | `headless/output/<name>.<ext>` | Output file. |
| `--codec <c>` | `h264` | `h264 \| h265 \| vp9 \| vp8 \| av1`. Falls back automatically if unsupported. |
| `--container <c>` | derived | `mp4 \| webm \| mov \| mkv` (or `mp3 \| wav \| m4a` with `--audio-only`). |
| `--resolution <WxH>` | project metadata | e.g. `1920x1080`. |
| `--fps <n>` | project metadata | |
| `--quality <q>` | `high` | `low \| medium \| high \| ultra` (controls bitrate). |
| `--in <sec>` | 0 | Render range start (seconds). |
| `--out-sec <sec>` | end | Render range end (seconds). |
| `--duration <sec>` | — | Render this many seconds from `--in`. |
| `--audio-only` | off | Render audio only. |
| `--build` | off | Build `dist/` first if the harness isn't built. |
| `--head` | off | Run a visible browser for debugging. |
| `--harness-url <url>` | — | Dev mode: drive a running `npm run dev` server instead of `dist/`. |

## Notes & limitations

- **Media must be mirrored to the workspace folder on disk.** The CLI reads
`media/<id>/<file>`. If a media source is missing (imported but never read in
the app), open the project in FreeCut once so it's mirrored, then re-run.
- **Codec support is verified at render time** and falls back the same way the
app does (e.g. H.264 → VP9 if unavailable). Headless Chrome here supports
H.264/HEVC/VP9/AV1 video and AAC/Opus audio with hardware WebGPU.
- **Audio codecs:** AAC/MP3/Opus/Vorbis/FLAC/PCM decode natively; **AC-3/E-AC-3
(Dolby Digital / DD+) decode via `@mediabunny/ac3`** — the CLI passes each
media's `metadata.json` to the harness, which seeds the media-library store so
the codec is recognized and the AC-3 decoder is registered. Truly exotic
codecs (e.g. DTS) still can't be decoded headlessly; the CLI warns and that
audio is silent (video unaffected). Supporting those would need a Node-side
pre-decode (ffmpeg / `@mediabunny/server`) — not wired up since it needs a
heavy native dependency and is rarely needed.
- A harmless `Video load error` may log — that's the optional DOM `<video>`
fallback; decode goes through mediabunny/WebCodecs and is unaffected.

## Editing (edit.mjs)

Applies a list of edit ops by driving the **real** timeline action modules
(`hydrateTimelineStoresFromProject` → actions → `buildTimelineFromStores`), so
transition repair, track ordering, split-id rebinding, and linked video/audio
cascades all behave exactly like the editor.

```bash
# Dry run (apply ops, print result, write nothing)
node headless/edit.mjs --workspace "<ws>" --project <id> --ops edits.json

# Write the edited project to a new file
node headless/edit.mjs --workspace "<ws>" --project <id> --ops edits.json --out ./edited.json

# Overwrite the source project.json (destructive — explicit opt-in)
node headless/edit.mjs --workspace "<ws>" --project <id> --ops edits.json --in-place
```

Safe by default: with neither `--out` nor `--in-place` it's a dry run.

`edits.json` is an array of ops (each `{ "op": "<name>", ... }`):

| op | fields |
|----|--------|
| `addText` | `text`, `from`, `durationInFrames`, `trackId?`, `color?`, `fontSize?`, `fontWeight?`, `textAlign?`, `verticalAlign?` |
| `addItem` | `item` (a full `TimelineItem`) |
| `updateItem` | `id`, `updates` (partial `TimelineItem`) |
| `moveItem` | `id`, `from`, `trackId?` |
| `removeItems` | `ids` (array) |
| `split` | `id`, `frame` |
| `trimStart` / `trimEnd` | `id`, `amount` |
| `addTransition` | `leftClipId`, `rightClipId`, `type?`, `durationInFrames?` |
| `addClip` | `mediaId`, `from`, `trackId?`, `durationInFrames?` (video adds a linked audio companion; source range computed from the media's metadata) |
| `addTrack` | `kind?` (`video`\|`audio`), `order?` |
| `addKeyframe` | `itemId`, `property`, `frame`, `value`, `easing?` |
| `removeKeyframes` | `itemId`, `property` |
| `addEffect` | `itemId`, `gpuEffectType` + `params?` (or a full `effect` object) |
| `removeEffect` | `itemId`, `effectId` |
| `setTransform` | `id`, `transform` (e.g. `{ "x": 0, "y": 150, "opacity": 0.5, "rotation": 0 }`) |

`addClip` reads the media's `metadata.json` (passed automatically by the CLI),
so its source range, fps, and audio companion match an in-app import.

```json
[
{ "op": "updateItem", "id": "text-1", "updates": { "text": "New caption", "color": "#ff3366" } },
{ "op": "split", "id": "vid-1", "frame": 45 },
{ "op": "addText", "text": "Outro", "from": 120, "durationInFrames": 60, "color": "#ffffff" }
]
```

## Render service (serve.mjs)

For automation / many renders, run a long-lived service that keeps one warm
Chrome + harness over a workspace, avoiding the per-call cold start. Requests
are serialized (one page op at a time).

```bash
npm run headless:serve -- --workspace "<ws>" --port 8787 # add --build on first run

# then:
curl localhost:8787/health
curl localhost:8787/projects
curl -X POST localhost:8787/render -H 'content-type: application/json' \
-d '{"project":"<id>","codec":"vp9","duration":5}' -o out.webm
curl -X POST localhost:8787/edit -H 'content-type: application/json' \
-d '{"project":"<id>","ops":[{"op":"addText","text":"Hi","from":0}]}'
```

| Route | Body | Returns |
|-------|------|---------|
| `GET /health` | — | `{ ok, gpu: { available, vendor, architecture }, software, harnessUrl }` |
| `GET /projects` | — | `[{ id, name, updatedAt }]` |
| `POST /render` | `{ project\|projectObject, codec?, container?, resolution?, fps?, quality?, in?, outSec?, duration?, audioOnly? }` | the rendered file (attachment) |
| `POST /edit` | `{ project\|projectObject, ops, ... }` | `{ ok, project, applied, results }` |

`project` is a workspace project id; `projectObject` is an inline Project JSON.
Media is resolved from the service's workspace by id.

## Docker (Linux GPU server deployment)

**Docker here is for deploying the render service on a Linux host with an NVIDIA
GPU** — not for desktop use. On Windows/macOS, **render natively**
(`npm run headless:serve`); Docker Desktop on Windows runs containers in a WSL2
VM that exposes CUDA/NVENC but **no Vulkan**, so WebGPU there is software-only
and GPU effects can't run. Use the container on a real Linux GPU box (or render
natively).

```bash
# Build (context = repo root)
docker build -f headless/Dockerfile -t freecut-headless .

# Run on a Linux host WITH a GPU (NVIDIA Container Toolkit installed):
docker run --rm -p 8787:8787 --gpus all -e NVIDIA_DRIVER_CAPABILITIES=all \
-v /path/to/FreeCutProjects:/workspace:ro freecut-headless

# Confirm it's using the real GPU (not software):
curl localhost:8787/health
# good: {"ok":true,"gpu":{"available":true,"vendor":"nvidia",...},"software":false}
# bad: {... "vendor":"mesa","architecture":"llvmpipe", "software":true} -> effects will fail
curl -X POST localhost:8787/render -H 'content-type: application/json' \
-d '{"project":"<id>","duration":5}' -o out.mp4
```

Without `--gpus all` (or on Windows), the container falls back to software
WebGPU: cuts/text/transitions and audio still render, but GPU effects fail with
a clear error and the service logs a warning at startup.

### What's verified

In-container against a real workspace:

- Builds and serves; renders **video, text, and transitions**.
- **Audio works, including AAC** — the `@mediabunny/aac-encoder` WASM polyfill is
registered automatically when there's no native AAC encoder (Linux Chrome); Opus
(webm) and MP3 also work.
- **GPU effects** need a real GPU. Software WebGPU (lavapipe and SwiftShader) hits a
Dawn device-loss on the effects pipeline, surfaced as a clear render error.

### Why Windows Docker can't use the GPU

Docker Desktop on Windows runs containers in a WSL2 VM that exposes CUDA/NVENC and
`/dev/dxg` but **no Vulkan ICD** (`vulkaninfo` finds no driver in-container), and
WebGPU needs Vulkan. So GPU effects in Docker require a **native Linux GPU host**;
on Windows/macOS, render natively instead.

## Dev/regression scripts

- `node headless/probe.mjs` — report WebGPU + WebCodecs support in headless Chrome.
- `node headless/smoke.mjs` — render a zero-media text title to WebM.
- `node headless/media-smoke.mjs` — render a generated test clip (video+audio) to MP4.
Loading
Loading