Skip to content

Commit 07dd7c6

Browse files
amitay keisarclaude
andcommitted
feat: add proxy file generation for smooth editor playback
Recordings over ~1 minute caused noticeable lag during editor playback and scrubbing because the editor played back raw VP9/VP8 .webm files directly. VP9 has sparse keyframes (every 2-5s), making every seek decode potentially dozens of frames, and software-decoding 1920×1080 VP9 while running canvas compositing saturated the renderer thread. This change introduces per-take proxy MP4 generation — a lightweight H264 transcode of each screen recording that the editor uses for display, while exports continue to use the original full-quality source. Proxy generation: - New proxy-service.js: builds and runs ffmpeg to transcode .webm → .mp4 (960×540, H264, CRF 23, ultrafast preset, 2 threads, keyframe every 0.5s, AAC 64k, +faststart for instant browser load) - Concurrency queue (max 2 simultaneous ffmpeg jobs) prevents CPU overload - Writes to .tmp path first, renames on success, deletes .tmp on failure - Triggered automatically after each recording stops (fire-and-forget) - On project open, queues generation for any takes missing a proxy (backward-compatible with existing projects) IPC and data model: - New proxy:generate IPC handler with progress events (started/progress/done/error) - proxyPath field added to take data model (persisted in project.json as relative path, resolved to absolute on load — same pattern as screenPath) - proxyPath included in file staging/unstaging for take cleanup lifecycle Editor integration: - getOrCreateTakeVideos() uses proxy when available, falls back to original - Hot-swap: when proxy finishes, the cached video element's src is updated in-place, preserving playback state and restarting the draw loop - Source resolution probe: a throwaway video element reads dimensions from the original .webm (not the proxy) so exports render at full resolution - Timeline progress indicator: animated amber bar on section bands shows real-time proxy generation progress (percent-width, CSS transition) - Draw loop safety: 200ms fallback timer prevents requestVideoFrameCallback stalls during src swaps or section transitions Export is unaffected — render-service.js always reads from take.screenPath (the original .webm), never from proxyPath. Tests: 243 pass, proxy-service.js at 100% statement coverage. New tests cover proxy generation (happy path, failure cleanup, concurrency limit), IPC handler (success/error/destroyed-sender), proxyPath persistence round-trip, and file staging with proxy files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ca6fcc1 commit 07dd7c6

17 files changed

Lines changed: 1048 additions & 13 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-03-26
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
## Context
2+
3+
The editor currently plays back raw `.webm` recordings (VP9/VP8) directly in HTML5 `<video>` elements. For recordings over ~1 minute, two problems compound:
4+
5+
1. **Seek latency**: VP9 WebM stores keyframes every 2–5 seconds. Seeking requires the browser to locate the nearest prior keyframe and software-decode every frame up to the target. For a 3-second keyframe interval at 30 fps, a single seek can decode 90 frames before showing the result.
6+
2. **Decode + canvas throughput**: `editorDrawLoop` runs on every video frame callback, reading a 1920×1080 VP9 frame and blitting it through an intermediate `editorZoomBuffer` canvas before compositing onto the final `editorCtx`. VP9 has limited hardware acceleration in Electron on macOS, so this saturates the renderer thread.
7+
8+
The existing infrastructure (ffmpeg-static bundled, `runFfmpeg()` utility, `take.screenPath`/`cameraPath`/`mousePath` pattern, relative-path serialization in project.json) provides a clean foundation to add proxy generation with minimal new surface area.
9+
10+
## Goals / Non-Goals
11+
12+
**Goals:**
13+
- Generate a lightweight proxy `.mp4` per take in the background immediately after recording stops.
14+
- On project open, queue proxy generation for any takes that lack a proxy (backward-compatible with existing projects).
15+
- The editor `<video>` element uses the proxy when available, falling back to the original `.webm` while the proxy is being built.
16+
- Proxy path is persisted on the take in `project.json` (relative path, same pattern as screenPath).
17+
- Proxy files are staged/unstaged/cleaned as part of the existing file-lifecycle mechanism.
18+
- Export (`render-service.js`) is never touched — it always reads from the original `.webm`.
19+
20+
**Non-Goals:**
21+
- Proxy for the camera video — camera recordings are smaller and already hardware-friendly (lower resolution, short duration). Camera continues to use its original file.
22+
- Quality preview / pre-rendered composite — this is a separate problem (Option B from exploration). This change is purely a source-file proxy.
23+
- Proxy regeneration after source file is edited — proxies represent the raw capture, not the edit state.
24+
- UI progress bar for proxy generation — a simple console log is sufficient for the first iteration. Can be enhanced later.
25+
26+
## Decisions
27+
28+
### Decision 1: Proxy codec and encoding settings
29+
30+
**Chosen**: `libx264`, 960×540, CRF 23, preset `fast`, `-g 15`, AAC 64 kbps, `-movflags +faststart`
31+
32+
| Setting | Value | Rationale |
33+
|---|---|---|
34+
| Codec | libx264 | Hardware-acceleratable in Chromium/Electron; universally supported; excellent random-seek via keyframe index |
35+
| Resolution | 960×540 | Half of 1920×1080 in each dimension → 4× less pixel data per frame to decode and blit; visually sharp enough for editing |
36+
| CRF | 23 | Default quality point for H264; good visual fidelity; proxy is not the export |
37+
| Preset | fast | ~3–5× realtime encode speed on typical Mac; a 90-second recording proxies in ~20–30 s |
38+
| Keyframe interval | `-g 15` | At 30 fps → keyframe every 0.5 s → worst-case seek decodes 15 frames, not 90 |
39+
| Audio | AAC 64 kbps | The screen video element is the audio source in the editor; keeping audio in the proxy avoids splitting video/audio between two elements |
40+
| faststart | `-movflags +faststart` | Moves the moov atom to the file head; browser can start playing without fully downloading the file |
41+
42+
**Alternatives considered:**
43+
- **VP9 with lower CRF**: Still slow to seek, and hardware acceleration is unreliable in Electron.
44+
- **No-encode remux to MP4**: Preserves VP9 but doesn't fix keyframe spacing or resolution; seek improvement is marginal.
45+
- **Half-res ProRes / lossless**: Excellent quality, terrible file size, not needed for a display proxy.
46+
47+
### Decision 2: When proxy generation is triggered
48+
49+
**Chosen**: Two trigger points, both fire-and-forget:
50+
1. **After `stopRecording()`** — immediately after the take is saved to disk and `persistProjectNow()` returns, the renderer sends `proxy:generate` for the new take's `screenPath`.
51+
2. **On project open**`activateProject()` inspects all takes; for any take where `proxyPath` is null or the proxy file does not exist on disk, it queues a `proxy:generate` call.
52+
53+
**Rationale**: Both triggers ensure the proxy is always available for any take the user might edit. The post-recording trigger covers the common case; the open-project trigger handles backward compatibility.
54+
55+
**Alternatives considered:**
56+
- **On first scrub/play in editor** (lazy): Avoids generating proxies for takes the user never edits, but means the first interaction is always slow.
57+
- **Explicit "Optimize for editing" button**: Good UX affordance, but adds friction and requires users to know about it.
58+
59+
### Decision 3: Where to store the proxy file
60+
61+
**Chosen**: Alongside the source file in the project folder.
62+
- Filename: derived from the screen source filename with `-proxy` suffix and `.mp4` extension.
63+
- Example: `recording-1710000000000-screen.webm``recording-1710000000000-screen-proxy.mp4`
64+
65+
**Rationale**: Project folders are self-contained. Moving, copying, or archiving a project folder keeps all its proxies with it. The existing path-relative serialization (`toProjectRelativePath` / `toProjectAbsolutePath`) works without modification.
66+
67+
### Decision 4: IPC design
68+
69+
**Chosen**: `proxy:generate` is a fire-and-forget `ipcMain.handle` that starts the ffmpeg process and returns immediately (returns the expected proxy output path). Progress and completion are communicated back to the renderer via `event.sender.send('proxy:progress', { takeId, status, proxyPath })` — matching the pattern used by `render-composite-progress`.
70+
71+
**Rationale**: Blocking the renderer on proxy generation would freeze the editor. The renderer can start editing against the original `.webm` immediately and transparently switch to the proxy when the `proxy:progress` event carries `status: 'done'`.
72+
73+
**On completion**: The renderer updates `take.proxyPath` in the in-memory `activeProject.takes` array and calls `persistProjectNow()` to write it to disk. The next `getOrCreateTakeVideos()` call (which happens on seek or section switch) picks up the proxy automatically.
74+
75+
### Decision 5: Fallback behavior while proxy is being built
76+
77+
**Chosen**: `getOrCreateTakeVideos()` checks `take.proxyPath` first. If it exists and the file is present on disk, it uses it. Otherwise it uses `take.screenPath`. The fallback is silent — the user sees normal (potentially slower) behavior until the proxy is ready, then the next seek or section switch uses it.
78+
79+
**No mid-session hot-swap of the active video element**: Swapping `src` on a playing `<video>` causes a momentary stall. The proxy takes effect on the next element creation (section switch or re-open). This is acceptable since proxy generation is fast (~20–30 s) and the user typically starts editing after the recording is processed.
80+
81+
### Decision 6: Data model — proxyPath on take
82+
83+
**Chosen**: Add `proxyPath` as an optional field on the take object, alongside `screenPath`, `cameraPath`, and `mousePath`.
84+
- In memory: absolute path (or `null`)
85+
- In `project.json`: relative path (or `null`), via existing `toProjectRelativePath` / `toProjectAbsolutePath`
86+
- `normalizeProjectData` in `shared/domain/project.js` reads `take.proxyPath` through the same path-resolution logic
87+
88+
**Rationale**: Consistent with existing take data model. The proxy path persists across sessions — if the proxy already exists on disk when the project is opened, no regeneration is needed.
89+
90+
### Decision 7: Proxy cleanup
91+
92+
**Chosen**: The existing `project:stageTakeFiles` / `project:unstageTakeFiles` IPC calls are extended to include `proxyPath` in their file list. No new IPC channel is needed.
93+
94+
**Rationale**: The staging mechanism already handles any number of file paths for a take. Adding the proxy path to the array keeps the logic centralized.
95+
96+
## Risks / Trade-offs
97+
98+
- **Disk space**: Each 1-minute proxy is ~20–40 MB (H264 960×540). For projects with many takes this adds up. Mitigation: proxies can be deleted and regenerated at any time from the source `.webm`.
99+
- **libx264 availability**: The bundled `ffmpeg-static` binary includes libx264 on all platforms (verified by the existing export path which uses `libx264` with CRF 12). No risk.
100+
- **Race condition — app quit during proxy generation**: The ffmpeg child process may be orphaned if the app quits mid-proxy. Mitigation: the output file will be incomplete; on next project open the proxy existence check (`fs.existsSync(proxyPath)`) will fail (since ffmpeg writes to a temp path then renames, or the partial file will fail to load), so the take will be re-queued. Alternatively, write to a `.tmp` path first and rename on success.
101+
- **Multiple takes on project open**: If a project has 10 takes all missing proxies, 10 ffmpeg processes could start simultaneously. Mitigation: use a simple queue (sequential or max-2 concurrent) in the main process.
102+
103+
## Migration Plan
104+
105+
1. Ship the change. Existing projects open normally — all takes have `proxyPath: null`.
106+
2. On first open, any take missing a proxy is queued for background generation.
107+
3. No user action required. Proxies are built silently.
108+
4. Rollback: deleting proxy `.mp4` files from project folders reverts behavior to original — no data loss.
109+
110+
## Open Questions
111+
112+
- Should a visible "Optimizing for editing..." status indicator be shown in the editor header while proxies are generating? (Can be deferred to a follow-up polish pass.)
113+
- Should proxies be capped at the source video's native resolution if the source is already ≤960×540? (Edge case — screen recordings are typically ≥1080p.)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Why
2+
3+
Editing and scrubbing recordings longer than ~1 minute is noticeably laggy in the editor canvas. The root cause is that the editor plays back the original `.webm` source files (VP9/VP8 codec) directly: VP9 has sparse keyframes (every 2–5 s), so every seek requires decoding potentially dozens of frames; and decoding 1920×1080 VP9 in software while simultaneously running canvas 2D compositing saturates the renderer thread. Since the user edits constantly (scrubs, adjusts zoom, cuts sections), this lag affects every editing session on longer recordings.
4+
5+
## What Changes
6+
7+
- **After each recording finishes**, a background ffmpeg job transcodes the raw `.webm` screen recording into a lightweight proxy `.mp4` (H264, 960×540, keyframe every 0.5 s, `+faststart`).
8+
- **The editor uses the proxy** for its `<video>` element when it exists, falling back to the original `.webm` while the proxy is still being built.
9+
- **On project open**, any takes that are missing a proxy (e.g. recorded before this feature shipped) are queued for background proxy generation.
10+
- **The proxy path is persisted** on the take in `project.json` alongside `screenPath` / `cameraPath` / `mousePath`.
11+
- **Proxy cleanup** is integrated into the existing file-staging mechanism: when a take is staged for deletion, its proxy is staged too.
12+
- **Export is unaffected**: `render-service.js` always uses the original `screenPath` — the proxy is display-only.
13+
14+
## Capabilities
15+
16+
### New Capabilities
17+
18+
- `take-proxy-files`: Background generation, persistence, and lifecycle management of per-take proxy MP4 files used to accelerate editor playback and seeking.
19+
20+
### Modified Capabilities
21+
22+
- `take-file-cleanup`: The file staging/unstaging/cleanup operations must include `proxyPath` in addition to `screenPath`, `cameraPath`, and `mousePath`.
23+
24+
## Impact
25+
26+
- **New file**: `src/main/services/proxy-service.js` — ffmpeg proxy generation logic
27+
- **Modified**: `src/main/ipc/register-handlers.js` — new IPC channels `proxy:generate`, forwarded progress events
28+
- **Modified**: `src/main/services/project-service.js` — include `proxyPath` in take serialization / deserialization / staging
29+
- **Modified**: `src/shared/domain/project.js``normalizeProjectData` passes through `proxyPath`
30+
- **Modified**: `src/renderer/app.js``getOrCreateTakeVideos()` prefers proxy; `stopRecording()` fires proxy generation after take is saved; project-open path queues missing proxies
31+
- **No change** to `src/main/services/render-service.js` — export always uses originals
32+
- **Dependency**: `ffmpeg-static` already present; `libx264` included in bundled ffmpeg binary (verified in existing render flow)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Stage unreferenced take files to .deleted/
4+
When a take becomes unreferenced (last section deleted or unsaved), the system SHALL move its files (screenPath, cameraPath, mousePath if present, **and proxyPath if present**) to a `.deleted/` subfolder inside the project directory via IPC. The take SHALL be removed from `project.takes`. An overlay media file is unreferenced when no overlay segment's `mediaPath` points to it. The `stageTakeIfUnreferenced` pattern SHALL be extended to also support overlay media files via a parallel `stageOverlayFileIfUnreferenced(mediaPath)` function.
5+
6+
#### Scenario: Delete last section referencing a take
7+
- **WHEN** user deletes the last section (unsaved) that references take A
8+
- **THEN** take A's screen and camera files are moved to `<projectFolder>/.deleted/` and take A is removed from `project.takes`
9+
10+
#### Scenario: Take has camera file
11+
- **WHEN** an unreferenced take has both screenPath and cameraPath
12+
- **THEN** both files are moved to `.deleted/`
13+
14+
#### Scenario: Take has no camera file
15+
- **WHEN** an unreferenced take has only screenPath (cameraPath is null)
16+
- **THEN** only the screen file is moved to `.deleted/`
17+
18+
#### Scenario: Take with mouse trail file
19+
- **WHEN** an unreferenced take has screenPath, cameraPath, and mousePath
20+
- **THEN** all three files are moved to `.deleted/`
21+
22+
#### Scenario: Take without mouse trail (legacy)
23+
- **WHEN** an unreferenced take has screenPath and cameraPath but no mousePath
24+
- **THEN** only screen and camera files are moved (no error for missing mousePath)
25+
26+
#### Scenario: Take with proxy file
27+
- **WHEN** an unreferenced take has a non-null proxyPath pointing to an existing file
28+
- **THEN** the proxy file is also moved to `.deleted/` alongside the screen, camera, and mouse trail files
29+
30+
#### Scenario: Take without proxy file (legacy or proxy not yet generated)
31+
- **WHEN** an unreferenced take has proxyPath null or the proxy file does not exist on disk
32+
- **THEN** the staging operation proceeds for the other files without error
33+
34+
#### Scenario: Stage unreferenced overlay media file
35+
- **WHEN** the last overlay segment referencing `overlay-media/img.png` is deleted
36+
- **THEN** `overlay-media/img.png` is moved to `.deleted/overlay-media/img.png`
37+
38+
#### Scenario: Overlay media still referenced
39+
- **WHEN** an overlay segment is deleted but another segment still references the same `mediaPath`
40+
- **THEN** the media file is NOT staged for deletion
41+
42+
### Requirement: Unstage take files on undo
43+
When an undo operation restores a section that was the last reference to a take, the system SHALL move the take's files back from `.deleted/` to the project directory and re-add the take to `project.takes`. **This includes the mouse trail file and proxy file if they were staged.** This SHALL apply to both take files and overlay media files.
44+
45+
#### Scenario: Undo restores unreferenced take
46+
- **WHEN** user undoes a section delete that had triggered file staging
47+
- **THEN** the take's files are moved back from `.deleted/` to the project directory and the take is restored to `project.takes`
48+
49+
#### Scenario: Undo restores take with mouse trail
50+
- **WHEN** user undoes a section delete that had triggered file staging for a take with mousePath
51+
- **THEN** the screen, camera, and mouse trail files are all restored from `.deleted/`
52+
53+
#### Scenario: Undo restores take with proxy file
54+
- **WHEN** user undoes a section delete that had triggered file staging for a take with proxyPath
55+
- **THEN** the proxy file is also restored from `.deleted/` to the project directory
56+
- **AND** take.proxyPath remains valid after undo
57+
58+
#### Scenario: Unstage overlay media on undo
59+
- **WHEN** an overlay deletion is undone and the media was staged
60+
- **THEN** `overlay-media/img.png` is restored from `.deleted/overlay-media/` to `overlay-media/`

0 commit comments

Comments
 (0)