dragonflight/docs/superpowers/plans/2026-05-24-nle-editor-react-polish-phase-1-2-3.md

797 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# NLE Editor: React SPA Polish — Phases 13 Implementation Plan
> **Date:** 2026-05-24
> **Status:** Draft — awaiting user review before execution
> **Reference spec:** `docs/superpowers/specs/2026-05-18-editor-growing-file-features-design.md`
> **Architecture note:** The original NLE editor plan (`2026-05-18-nle-editor.md`) was written for standalone HTML pages. Commit `6322b61` deleted those pages — the React SPA is now the only entry. This plan adapts Phase 1 for the React SPA architecture. Phases 23 follow the original design spec.
---
## Architectural Context
**Backend (complete, no changes needed):**
- `003-editor-sequences.sql` migration applied — `sequences` + `sequence_clips` tables live
- `routes/sequences.js` — full CRUD, clip sync (`PUT /:id/clips`), EDL export — registered in `index.js`
**Frontend (React SPA):**
- `screens-editor.jsx` — skeleton with all features disabled behind "In Development" overlay
- `shell.jsx` — Editor nav item with `DEV` badge
- `app.jsx``case 'editor': content = <Editor />`
- `data.jsx`**no** sequence API helpers exist
- `js/timecode.js` — 59.94 DF timecode (vanilla JS, unused by SPA)
- `js/timeline.js` — DOM timeline engine (vanilla JS, unused by SPA)
**Other services:**
- `services/editor/` — OpenReel Video (separate WebCodecs/WebGPU NLE, proxied at `/editor/`). Not part of this plan's scope — the React SPA editor is the primary in-app NLE.
- `services/premiere-plugin/` — Premiere Pro CEP panel (external integration, not in scope)
---
## File Map
### Phase 1 — Core Editor
| Action | Path | Description |
|--------|------|-------------|
| MODIFY | `services/web-ui/public/data.jsx` | Add `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` |
| MODIFY | `services/web-ui/public/screens-editor.jsx` | Full rewrite: source monitor, media panel, timeline, program monitor, sequence management, auto-save, undo/redo, EDL export |
| MODIFY | `services/web-ui/public/shell.jsx` | Remove `DEV` badge from Editor nav item |
| NO CHANGE | `services/web-ui/public/js/timecode.js` | Imported by screens-editor.jsx via `<script>` tag or inlined |
| NO CHANGE | `services/web-ui/public/js/timeline.js` | Same as above |
### Phase 2 — UX & Growing File
| Action | Path | Description |
|--------|------|-------------|
| MODIFY | `services/web-ui/public/screens-editor.jsx` | Multi-track refinements, zoom slider, JKL transport, waveform display, inspector wiring |
| MODIFY | `services/web-ui/public/screens-editor.jsx` | Style migration from legacy CSS variables to `/dist/app.css` Tailwind primitives |
| MODIFY | `services/capture/src/capture-manager.js` | HLS segment output during recording (`hls_time 2`, `hls_list_size 0`) |
| CREATE | `services/capture/src/routes/live.js` | Serve HLS playlists + segments from `$HLS_SESSION_DIR/<sessionId>/` |
| MODIFY | `services/capture/src/index.js` | Register live routes |
| MODIFY | `services/web-ui/nginx.conf` | Proxy `/capture/live/` → capture service |
| MODIFY | `services/web-ui/public/screens-ingest.jsx` | Add hls.js live preview panel to capture/recorder screens |
### Phase 3 — Export, Conform & Features
| Action | Path | Description |
|--------|------|-------------|
| MODIFY | `services/mam-api/src/routes/sequences.js` | Add FCP XML export endpoint |
| MODIFY | `services/web-ui/public/screens-editor.jsx` | Conform integration, hi-res relink from timeline |
| MODIFY | `services/web-ui/public/screens-asset.jsx` | Player rebuild: custom transport, 59.94 TC, JKL, "Open in Editor" |
| MODIFY | `services/web-ui/public/screens-asset.jsx` | Subclip creation from player in/out markers |
| MODIFY | `services/web-ui/public/screens-library.jsx` | Multi-select (shift-click/checkbox), floating action bar |
| MODIFY | `services/mam-api/src/routes/bins.js` | Smart bins: `is_smart`, `smart_query` columns, dynamic query |
| MODIFY | `services/web-ui/public/screens-library.jsx` | Smart bin UI (icon, dynamic content) |
| MODIFY | `services/mam-api/src/routes/projects.js` | Metadata templates CRUD |
| MODIFY | `services/mam-api/src/db/migrations/` | Migration(s) for new columns/tables |
| MODIFY | `services/web-ui/public/screens-asset.jsx` | Metadata form in asset detail |
| MODIFY | `services/web-ui/public/screens-library.jsx` | Bulk operations UI |
---
## Phase 1 — Tasks
### Task 1.1: Add Sequence API Helpers to data.jsx
**Files:** MODIFY `services/web-ui/public/data.jsx`
Add the following functions after the existing `async function loadData()` block (before the final `window.ZAMPP_API` export):
```js
// ── Sequence API ────────────────────────────────────────────────
async function getSequences(projectId) {
return apiFetch('/sequences?project_id=' + projectId);
}
async function createSequence(data) {
return apiFetch('/sequences', {
method: 'POST',
body: JSON.stringify(data),
});
}
async function getSequence(sequenceId) {
return apiFetch('/sequences/' + sequenceId);
}
async function updateSequence(sequenceId, data) {
return apiFetch('/sequences/' + sequenceId, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async function deleteSequence(sequenceId) {
return apiFetch('/sequences/' + sequenceId, { method: 'DELETE' });
}
async function syncSequenceClips(sequenceId, clips) {
return apiFetch('/sequences/' + sequenceId + '/clips', {
method: 'PUT',
body: JSON.stringify(clips),
});
}
async function exportSequenceEDL(sequenceId) {
const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error('EDL export failed: ' + res.status);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sequence.edl';
a.click();
URL.revokeObjectURL(url);
}
```
Append to the `window.ZAMPP_API` export:
```js
window.ZAMPP_API = {
fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative,
getSequences, createSequence, getSequence, updateSequence,
deleteSequence, syncSequenceClips, exportSequenceEDL,
};
```
**Verify:**
```js
// In browser console after rebuild:
ZAMPP_API.getSequences('<project-id>').then(r => console.log(r))
// Expected: array of sequences (or empty array)
```
---
### Task 1.2: Timecode Utility for React
**Files:** No new file — add to `screens-editor.jsx` or use existing `public/js/timecode.js`.
The existing `js/timecode.js` exports `window.TC` with `{ framesToTC, tcToFrames, secondsToFrames, framesToSeconds, FPS }`. In the React SPA, we either:
- **Option A** (preferred): Load via existing `<script>` tag in `index.html` (if not already), access via `window.TC`
- **Option B**: Inline the pure functions in `screens-editor.jsx`
Check `index.html` to see if `timecode.js` is already loaded. If not, add:
```html
<script src="js/timecode.js"></script>
```
For the React component, create a thin wrapper:
```js
// Inside screens-editor.jsx, before the Editor component:
function useTimecode() {
// Ensure TC is loaded
if (typeof TC === 'undefined') {
console.warn('timecode.js not loaded — timecode functions unavailable');
}
return {
framesToTC: (f) => TC ? TC.framesToTC(f) : String(f),
tcToFrames: (tc) => TC ? TC.tcToFrames(tc) : 0,
secondsToFrames: (s) => TC ? TC.secondsToFrames(s) : Math.round(s * 60),
framesToSeconds: (f) => TC ? TC.framesToSeconds(f) : f / 60,
FPS: TC ? TC.FPS : 59.94,
};
}
```
**Verify:** In browser console — `TC.framesToTC(3596)``"00:01:00;00"`
---
### Task 1.3: React Timeline Component
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx` (add components inline, same file pattern as other screens)
Build a `TimelinePanel` React component that wraps the existing `timeline.js` engine:
```js
function TimelinePanel({ clips, onClipsChanged, playheadFrames, onPlayheadMoved, fps }) {
const containerRef = React.useRef(null);
const engineRef = React.useRef(null);
React.useEffect(() => {
if (!containerRef.current || engineRef.current) return;
engineRef.current = window.Timeline;
window.Timeline.init(containerRef.current, {
fps: fps || 59.94,
scale: 100,
onClipsChanged: onClipsChanged,
onPlayheadMoved: onPlayheadMoved,
});
return () => { /* cleanup */ };
}, []);
React.useEffect(() => {
if (engineRef.current && clips) {
engineRef.current.render(clips, { fps: fps || 59.94 });
}
}, [clips, fps]);
React.useEffect(() => {
if (engineRef.current) {
engineRef.current.setPlayhead(playheadFrames || 0);
}
}, [playheadFrames]);
return (
<div className="timeline-panel">
<div className="timeline-toolbar">
{/* Tool buttons: Select(V), Razor(C), Hand(H) */}
{/* Undo/Redo */}
{/* Save status indicator */}
</div>
<div className="timeline-container" ref={containerRef} />
</div>
);
}
```
The component mounts the timeline once, then pushes clip/playhead updates via `render()` and `setPlayhead()`. No rebuild needed on every frame.
**Tool toolbar state:**
- Three tool buttons (V/C/H) that call `Timeline.setTool(name)`
- Active tool highlighted — sync with keyboard shortcuts
- Undo/redo buttons call the editor's history stack (not timeline.js's — timeline.js doesn't have an undo stack, the editor page in the original plan managed it in the app state)
**Verify:** Timeline renders 4 track rows (V1, V2, A1, A2) with ruler, playhead at frame 0, no clips. Tool buttons switch cursor mode.
---
### Task 1.4: Rewrite screens-editor.jsx
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
Full rewrite preserving the `Editor` component on `window.Editor`. The new structure:
```
┌─ SOURCE MONITOR ───────┬─ PROGRAM MONITOR ───────┐
│ [video preview] │ [video preview] │
│ TC: 00:00:00;00 │ TC: 00:00:00;00 │
│ [=====scrub=========] │ [=====scrub=========] │
│ [In] [Out] [Insert] │ [▶] [⏮] [⏭] │
├─ MEDIA PANEL ──────────┴─ TIMELINE ─────────────┤
│ [sequence picker ▼] [+]│ [V] [C] [H] | [↩] [↪] │
│ [filter by bin ▼] │ ruler: 00:00 — 00:05 … │
│ [asset list] │ V1 ░░░░[clip]░░░░ │
│ │ V2 │
│ │ A1 ░░░░[clip]░░░░ │
│ │ A2 │
└────────────────────────┴─────────────────────────┘
```
**App state (React hooks):**
```js
const [projectId, setProjectId] = React.useState(null);
const [sequences, setSequences] = React.useState([]);
const [currentSeq, setCurrentSeq] = React.useState(null); // { id, name, clips: [...] }
const [assets, setAssets] = React.useState([]); // from ZAMPP_DATA.ASSETS
const [sourceAsset, setSourceAsset] = React.useState(null);
const [srcIn, setSrcIn] = React.useState(null);
const [srcOut, setSrcOut] = React.useState(null);
const [playing, setPlaying] = React.useState(false);
const [playheadFrames, setPlayheadFrames] = React.useState(0);
const [saveStatus, setSaveStatus] = React.useState('');
const [history, setHistory] = React.useState([[]]);
const [historyIdx, setHistoryIdx] = React.useState(0);
```
**Source monitor:**
- `<video>` element with `src` from `apiFetch('/assets/' + assetId + '/stream')` — matches the pattern in `screens-asset.jsx` lines 53-54
- Scrub slider, current-time TC display (59.94 DF via `TC.framesToTC`)
- Mark In (`I` key or button), Mark Out (`O` key or button)
- In/out range highlighted (stored as seconds in state)
- Insert button: calls `Timeline.addClip(asset, srcIn, srcOut, 0)` then triggers auto-save
- Overwrite button: same but shifts/overwrites existing clips at playhead
**Media panel:**
- Left column in the bottom row
- Sequence picker (`<select>` from `getSequences(projectId)`) + "New sequence" (+) button
- Bin filter (`<select>` from `ZAMPP_DATA.BINS`)
- Asset list: filtered by project, each showing thumbnail (`AssetThumb` component from `visuals.jsx`) + name + duration
- Double-click loads asset into source monitor
**Program monitor:**
- Clip-by-clip virtual playback (same algorithm as the original editor.html lines 1746-1864)
- On play: gather V1 clips sorted by `timeline_in_frames`, find clip at current playhead, load its signed stream URL, `currentTime = source_in_seconds`
- On `timeupdate` event: check if `currentTime >= source_out_seconds` → advance to next clip
- Timecode display synced to timeline playhead
- Transport: Play/Pause (Space), Stop (K), Forward (L), Reverse (J — v1 can just stop)
- `<video>` element with preload=auto
**Auto-save:**
- `markDirty()` → clears previous timer, sets `setTimeout(saveSequence, 2000)`
- `saveSequence()``ZAMPP_API.syncSequenceClips(seqId, clips)` → show "Saved" / "Save failed"
- On mount: load project's sequences, open most-recent (or first)
**Undo/redo:**
- `onClipsChanged` callback from Timeline → push clone of clips onto history stack, cap at 50
- `undo()` → pop history, render
- `redo()` → advance history index, render
- Ctrl+Z / Ctrl+Shift+Z
**EDL export:**
- Button in top toolbar → calls `exportSequenceEDL(currentSeq.id)` → triggers file download
**Remove "In Development" overlay:**
- Delete the `<div style={{ position: 'absolute', inset: 0, backdropFilter: 'blur(6px)' ... }}>` block (lines 98-117)
- Delete the `FauxFrame` import/reference
- Replace `FauxFrame` with real `<video>` element in the canvas area
- Enable all previously disabled buttons
**Keyboard shortcuts:**
- `I` / `O` — mark in/out in source monitor
- `V` / `C` / `H` — select/razor/hand tool
- `Space` — play/pause program monitor
- `J` / `K` / `L` — reverse/stop/forward
- `Delete` / `Backspace` — remove selected clip
- `Ctrl+Z` / `Ctrl+Shift+Z` — undo/redo
- `Ctrl+S` — save
- `←` / `→` — frame step (±1 frame at 59.94)
**Verify:**
- Navigation to Editor route renders the 4-panel layout
- Sequence picker shows existing sequences from API (or "No sequences")
- Double-click asset loads it into source monitor with video playback
- Mark In/Out and click Insert places a clip on V1
- Clip appears in timeline, auto-save fires within 2s
- Program monitor plays clip back
- Ctrl+Z undoes the insert
- EDL export downloads a `.edl` file
- API check: `GET /api/v1/sequences/:id` returns `{ clips: [...] }`
---
### Task 1.5: Update Nav Badge
**Files:** MODIFY `services/web-ui/public/shell.jsx`
Change:
```js
{ id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } }
```
To:
```js
{ id: "editor", label: "Editor", icon: "editor" }
```
**Verify:** Sidebar shows "Editor" without the amber DEV pill badge.
---
## Phase 2 — Tasks
### Task 2.1: Multi-track & Timeline Refinements
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
**Ripple delete:** Add a "ripple" mode / toggle. When enabled, deleting a clip shifts all clips to its right left by the deleted clip's duration. Implemented via `onClipsChanged` callback:
```js
function deleteWithRipple(clipId) {
const clip = clips.find(c => c._id === clipId);
if (!clip) return;
const duration = clip.timeline_out_frames - clip.timeline_in_frames;
// Remove clip
let newClips = clips.filter(c => c._id !== clipId);
// Shift clips to the right of the gap
newClips = newClips.map(c => {
if (c.timeline_in_frames >= clip.timeline_in_frames) {
return { ...c,
timeline_in_frames: c.timeline_in_frames - duration,
timeline_out_frames: c.timeline_out_frames - duration,
};
}
return c;
});
// Update state
}
```
**Snap-to-playhead:** When dragging a clip, snap its left edge to the current playhead position if within 5 frames. Implemented in the `_onMoveStart` handler or a post-process in `onClipsChanged`.
**Track locking:** Add lock toggle to track headers (V1, V2, A1, A2). Locked tracks ignore clip drops, moves, and deletes.
**Clip overlap prevention:** When moving a clip, clamp `timeline_in_frames` so clips don't overlap on the same track. Trim duration if needed.
**Thumbnails in clips:** When a clip is wider than 120px, fetch the asset's thumbnail and render it as the clip body background (low opacity, stretched to fit).
**Verify:** Dragging clips snaps at playhead, ripple delete shifts downstream clips, locked tracks show visual indicator and reject edits.
---
### Task 2.2: Zoom Slider & Improved Ruler
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
Add a horizontal zoom slider in the timeline toolbar (range: 20500 px/s):
```jsx
<input type="range" min="20" max="500" value={scale}
onChange={e => {
setScale(Number(e.target.value));
Timeline.setScale(Number(e.target.value));
}} />
```
The ruler needs to adapt tick intervals based on zoom level:
- `< 30 px/s`: tick every 10s, label every 30s
- `3060 px/s`: tick every 5s, label every 15s
- `60120 px/s`: tick every 2s, label every 10s
- `> 120 px/s`: tick every 1s, label every 5s
The existing `timeline.js` `_renderRuler` function already handles this (lines 779). The React wrapper just needs to expose `setScale`.
**Verify:** Slider changes scale, ruler ticks and labels update accordingly.
---
### Task 2.3: JKL Transport & Frame Stepping
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
Full jog/shuttle keyboard:
- `J` — play reverse at 1x (press twice for 2x, three times for 4x)
- `K` — stop/pause
- `L` — play forward at 1x (press twice for 2x, three times for 4x)
- `←` — step back 1 frame
- `→` — step forward 1 frame
- `Shift+←` — step back 1 second (59.94 frames)
- `Shift+→` — step forward 1 second
- `Home` — jump to timeline start
- `End` — jump to timeline end
**Program monitor bar buttons:**
```jsx
<div className="monitor-bar">
<button onClick={stepBack} title="Step back (←)"></button>
<button onClick={stepBack1Sec} title="Step back 1s"></button>
<button onClick={togglePlay} title="Play (Space)">{playing ? '⏸' : '▶'}</button>
<button onClick={stepForward1Sec} title="Step forward 1s"></button>
<button onClick={stepForward} title="Step forward (→)"></button>
<span className="monitor-tc">{TC.framesToTC(playheadFrames)}</span>
<input type="range" className="monitor-scrub" ... />
</div>
```
**Verify:** JKL play/stop/forward works, arrow keys step precisely 1 frame at 59.94.
---
### Task 2.4: Waveform Display in Audio Tracks
This requires both backend and frontend changes.
**Backend (proxy worker):**
MODIFY `services/worker/src/workers/proxy.js` — after transcode, run:
```bash
ffmpeg -i <proxied-file> -filter_complex "aformat=channel_layouts=mono,astats=metadata=1:reset=1" -f null -
```
Parse the `stderr` for `pts_time` and `Bit_depth` to extract per-second peak arrays. Store as `waveforms/<assetId>.json` in S3.
**New column:** `waveform_s3_key TEXT` on `assets` table (migration 013).
**Frontend:**
MODIFY `screens-editor.jsx` — for audio track clips (A1, A2), fetch waveform JSON and render SVG polyline within the clip block:
```jsx
// Inside clip rendering for audio tracks:
{track >= 100 && waveform && (
<svg className="clip-waveform" viewBox={`0 0 ${width} 48`} preserveAspectRatio="none">
<polyline points={waveformToPoints(waveform, width, 48)} fill="none" stroke="var(--signal-good)" strokeWidth="1" />
</svg>
)}
```
**Verify:** Audio clips on A1/A2 show waveform polylines. Waveform data is fetched from S3 (or gracefully falls back to empty).
---
### Task 2.5: Inspector Panel Wiring
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
Replace inspector placeholder values with real data from the selected clip:
- **Transform:** Position (always 0,0 for v1 — no keyframes yet), scale (always 100%), rotation (always 0°)
- **Color:** Exposure/contrast/saturation (all 0.0 — placeholder for future)
- **Audio:** Level from asset metadata if available, or 0.0 dB
When no clip is selected, show a "Select a clip to inspect" message in the inspector panel.
**Verify:** Clicking a timeline clip shows its display_name and basic properties in the inspector.
---
### Task 2.6: Style Migration to Tailwind Primitives
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
Replace inline styles and legacy CSS class names with `/dist/app.css` Tailwind primitives from Wave 1:
| Element | Old style | New class |
|---------|-----------|-----------|
| Tool buttons | `tl-tool-btn` | `wd-btn wd-btn--ghost wd-btn--sm` |
| Primary action | `btn primary sm` | `wd-btn wd-btn--primary wd-btn--sm` |
| Secondary action | `btn ghost sm` | `wd-btn wd-btn--ghost wd-btn--sm` |
| Save status | `tl-save-status` | `wd-text-tertiary text-xs` |
| Media panel | `media-panel` | Custom component class |
| Form fields | `field-input` | `wd-input` |
| Labels | `form-label` | `wd-label` |
| Dividers | `tl-sep` | `wd-topbar-divider` |
| Sequence select | inline styles | `wd-select wd-select--sm` |
Add the Tailwind link if not already present:
```html
<link rel="stylesheet" href="/dist/app.css">
```
(May already be in `index.html` from the Wave 1 build — verify before adding.)
**Verify:** Editor looks consistent with other migrated pages. No visual regression in the editor layout.
---
### Task 2.7: Growing File / HLS Live Preview
See the detailed spec at `docs/superpowers/specs/2026-05-18-editor-growing-file-features-design.md` Section 2.
**Capture service changes:**
1. Add `HLS_SESSION_DIR` env var (default: `/tmp/wd-hls`)
2. In `capture-manager.js` `start()`: add HLS output pad to FFmpeg args
3. Add route `GET /capture/live/:sessionId/index.m3u8` and `GET /capture/live/:sessionId/:file`
4. In `stop()`: wait for FFmpeg exit, stitch segments to MP4, upload to S3, cleanup
**Frontend (capture screen):**
MODIFY `services/web-ui/public/screens-ingest.jsx` — when recorder status shows `liveUrl`:
```jsx
const hlsRef = React.useRef(null);
React.useEffect(() => {
if (!recorder.liveUrl) return;
if (typeof Hls !== 'undefined') {
const hls = new Hls();
hls.loadSource(recorder.liveUrl);
hls.attachMedia(videoRef.current);
hlsRef.current = hls;
return () => hls.destroy();
}
}, [recorder.liveUrl]);
```
Load `hls.js` from CDN in `index.html`:
```html
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
```
**Verify:** During active recording, the capture/recorder screen shows a live HLS preview. After stop, the preview disappears and the full proxy appears in the library.
---
## Phase 3 — Tasks
### Task 3.1: Timeline Conform Integration
**Files:** MODIFY `services/mam-api/src/routes/sequences.js`, MODIFY `services/web-ui/public/screens-editor.jsx`
**Backend:** Add FCP XML export endpoint following the EDL pattern:
```js
// POST /api/v1/sequences/:id/export/fcpxml
router.post('/:id/export/fcpxml', async (req, res, next) => {
// Generate FCP XML from sequence clips
// Set Content-Type: application/xml
});
```
**Frontend:** In the editor topbar, add a "Conform" button that:
1. Opens a slide-panel with conform options: resolution (1080p/4K), codec (H.264/ProRes/H.265), preset (Broadcast/Web/Archive)
2. On submit: exports EDL/FCPXML, POSTs to the existing conform queue via `apiFetch('/sequences/' + id + '/export/conform', { method: 'POST', body: JSON.stringify({ format, resolution, codec }) })`
3. Shows a job status indicator
4. Jump to Jobs screen on completion
**Verify:** Select preset → click Conform → job appears in Jobs screen with type "Conform" → completes → conformed file is available.
---
### Task 3.2: Hi-Res Auto-Relink from Editor
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
Add "Relink to hi-res" button in the editor toolbar:
1. Gather all unique assets referenced by timeline clips
2. POST `/api/v1/assets/relink` with the asset IDs
3. Server creates trim jobs → BullMQ trims hi-res segments from S3 originals
4. Poll for completion, then update clip `streamUrl`s to point to hi-res segments
5. Show progress bar per asset
**Verify:** Click Relink → hi-res trims appear → timeline clips now play hi-res segments instead of proxies.
---
### Task 3.3: Timecoded Comments
**Files:** MODIFY `services/web-ui/public/screens-editor.jsx`
Add a comments panel to the editor (right side, collapsible):
```jsx
<div className="editor-comments">
<div className="editor-comments-header">Comments</div>
<div className="editor-comments-list">
{comments.map(c => (
<div key={c.id} className="editor-comment" onClick={() => Timeline.setPlayhead(TC.secondsToFrames(c.timecode_seconds))}>
<span className="editor-comment-tc">{TC.framesToTC(TC.secondsToFrames(c.timecode_seconds))}</span>
<span className="editor-comment-body">{c.body}</span>
</div>
))}
</div>
<div className="editor-comments-input">
<input type="text" placeholder="Comment at current timecode..." />
<button onClick={postComment}>Post</button>
</div>
</div>
```
`postComment` calls the existing comments API:
```js
apiFetch('/assets/' + clip.asset_id + '/comments', {
method: 'POST',
body: JSON.stringify({ timecode_seconds: TC.framesToSeconds(playheadFrames), body }),
});
```
**Verify:** Navigate to a frame → post comment → comment appears in list → click comment jumps to that frame.
---
### Task 3.4: Player Rebuild (P1)
See `docs/superpowers/specs/2026-05-18-editor-growing-file-features-design.md` Section 3 P1.
**Files:** MODIFY `services/web-ui/public/screens-asset.jsx`
Changes:
- Replace browser default video controls with custom transport bar (scrub slider + timecode display at 59.94 DF + frame-step buttons + JKL)
- Inline rename: click `display_name` → editable input → auto-save on blur
- "Open in Editor" button → `navigate('editor')` + pass asset context
- Migrate from legacy `var(--color-bg-tertiary)` CSS to current Wave 1 tokens (`var(--bg-panel)`, etc.)
---
### Task 3.5: Subclips (P2)
**Files:** MODIFY `services/web-ui/public/screens-asset.jsx`, MODIFY `services/mam-api/src/routes/assets.js`
**Backend:**
New columns on `assets`: `parent_asset_id UUID REFERENCES assets`, `subclip_in_ms BIGINT`, `subclip_out_ms BIGINT`
Migration 014. POST `/api/v1/assets` accepts `{ parent_asset_id, subclip_in_ms, subclip_out_ms, display_name, project_id }`.
**Frontend:**
In the player, when in/out markers are set, show "Create Subclip" button:
```jsx
{srcIn !== null && srcOut !== null && (
<button onClick={createSubclip} className="wd-btn wd-btn--primary wd-btn--sm">
Create Subclip
</button>
)}
```
`createSubclip``apiFetch('/assets', { method: 'POST', body: JSON.stringify({ parent_asset_id: asset.id, subclip_in_ms, subclip_out_ms, display_name, project_id }) })`
Library shows subclips with a ✂ badge; player pre-seeks to `subclip_in_ms`.
---
### Task 3.6: Multi-select & Bulk Ops (P3)
**Files:** MODIFY `services/web-ui/public/screens-library.jsx`
- Add checkbox to each asset card (visible on hover)
- Shift-click range selection
- Floating action bar when >= 1 selected: "Move to bin" (slide-panel with bin tree), "Add tags" (tag input), "Delete" (confirm modal)
- Bulk operations call `PATCH /api/v1/assets/bulk` with `{ ids: [...], action: 'move_bin' | 'add_tags' | 'delete', value: ... }`
---
### Task 3.7: Smart Bins (P6)
**Files:** MODIFY `services/mam-api/src/routes/bins.js`, migration 015
**Backend:**
```sql
ALTER TABLE bins ADD COLUMN is_smart BOOLEAN DEFAULT false;
ALTER TABLE bins ADD COLUMN smart_query JSONB;
```
GET `/api/v1/bins/:id/assets` for smart bins executes a dynamic query based on `smart_query`'s filters (tags, media_type, created_after, etc.).
**Frontend:**
Smart bins show with ✦ icon in the bin tree. When selected, assets are fetched from `GET /api/v1/bins/:id/assets` instead of filtered client-side.
---
### Task 3.8: Metadata Templates (P7)
**Files:** Migration 016, MODIFY `services/mam-api/src/routes/projects.js`, MODIFY `services/mam-api/src/routes/assets.js`, MODIFY `services/web-ui/public/screens-admin.jsx` or `screens-projects.jsx`
```sql
CREATE TABLE project_metadata_fields (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
field_type TEXT NOT NULL DEFAULT 'text',
options JSONB,
required BOOLEAN DEFAULT false,
sort_order INTEGER DEFAULT 0
);
ALTER TABLE assets ADD COLUMN metadata JSONB DEFAULT '{}';
```
**Frontend:** In project settings, add a "Metadata fields" tab where users define per-project metadata schemas. In asset detail, the metadata form renders based on the project's template (text inputs, selects, checkboxes, etc.).
---
## Verification Plan
After each Phase is complete:
```bash
# 1. Rebuild web-ui
docker compose up -d --build web-ui
# 2. Verify HTTP
curl -sk -o /dev/null -w '%{http_code}' http://localhost:47434/ # → 200
# 3. Smoke test the editor route
curl -sk http://localhost:47434/ | grep -c 'screens-editor.jsx' # → 1
# 4. API smoke (after Phase 1)
curl -sk -b /tmp/wd.cookies \
"http://localhost:47432/api/v1/sequences?project_id=$(curl -sk -b /tmp/wd.cookies http://localhost:47432/api/v1/projects | jq -r '.[0].id')"
# → 200, array (possibly empty)
# 5. Full Phase 3 acceptance
bash deploy/api-smoke.sh # → all 27 routes pass
```
---
## Self-Review Checklist
### Phase 1
- [ ] Sequence API helpers added to `data.jsx` and exported on `window.ZAMPP_API`
- [ ] Timecode utility available via `window.TC` or `useTimecode()`
- [ ] Timeline React component wraps timeline.js with proper lifecycle
- [ ] Source monitor loads proxy stream, supports in/out marking, Insert/Overwrite
- [ ] Media panel shows project assets, filters by bin
- [ ] Sequence picker lists sequences, supports create/open/save
- [ ] Program monitor plays V1 clips with clip-by-clip virtual playback
- [ ] Auto-save fires 2s after any timeline change
- [ ] Undo/redo stack (Ctrl+Z / Ctrl+Shift+Z)
- [ ] EDL export downloads valid CMX3600 file
- [ ] Keyboard shortcuts: I/O, V/C/H, Space/JKL, Delete, arrows
- [ ] "In Development" overlay removed
- [ ] Editor nav item has no DEV badge
### Phase 2
- [ ] Ripple delete shifts downstream clips
- [ ] Snap-to-playhead on drag
- [ ] Track locking (V2, A1, A2 lockable)
- [ ] Clip overlap prevention
- [ ] Zoom slider (20500 px/s)
- [ ] JKL transport with speed levels
- [ ] Frame stepping (←/→, shift+←/→)
- [ ] Waveform display on audio track clips
- [ ] Inspector panel shows real selected clip data
- [ ] Editor styles migrated to `/dist/app.css` Tailwind primitives
- [ ] HLS live preview works during capture
- [ ] Capture service: HLS segment writing, stitching, cleanup
### Phase 3
- [ ] FCP XML export endpoint
- [ ] Timeline conform via BullMQ queue
- [ ] Hi-Res Auto-Relink from editor
- [ ] Timecoded comments in editor
- [ ] Player rebuilt with custom transport + 59.94 TC
- [ ] Subclip creation from player markers
- [ ] Multi-select with shift-click/checkbox
- [ ] Bulk operation action bar (move, tag, delete)
- [ ] Smart bins with dynamic queries
- [ ] Metadata template CRUD per project
- [ ] Metadata JSONB form in asset detail