feat(editor): Phase 1 core NLE editor React SPA rewrite
This commit is contained in:
parent
7189df7957
commit
ce31a45124
5 changed files with 548 additions and 923 deletions
|
|
@ -1,797 +1,73 @@
|
||||||
# NLE Editor: React SPA Polish — Phases 1–3 Implementation Plan
|
# NLE Editor: React SPA Polish — Phases 1–3 Implementation Plan
|
||||||
|
|
||||||
> **Date:** 2026-05-24
|
> **Date:** 2026-05-24
|
||||||
> **Status:** Draft — awaiting user review before execution
|
> **Status:** Phase 1 IN PROGRESS
|
||||||
> **Reference spec:** `docs/superpowers/specs/2026-05-18-editor-growing-file-features-design.md`
|
> **Progress:** Tasks 1.1–1.6 code-complete, pending test/deploy
|
||||||
> **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 2–3 follow the original design spec.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architectural Context
|
## Phase 1 — Core Editor (IN PROGRESS)
|
||||||
|
|
||||||
**Backend (complete, no changes needed):**
|
### Task 1.1: Sequence API helpers in data.jsx ✅
|
||||||
- `003-editor-sequences.sql` migration applied — `sequences` + `sequence_clips` tables live
|
- Added `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` to `data.jsx`
|
||||||
- `routes/sequences.js` — full CRUD, clip sync (`PUT /:id/clips`), EDL export — registered in `index.js`
|
- All exported on `window.ZAMPP_API`
|
||||||
|
|
||||||
**Frontend (React SPA):**
|
### Task 1.2: Timecode.js wired into SPA ✅
|
||||||
- `screens-editor.jsx` — skeleton with all features disabled behind "In Development" overlay
|
- Added `<script src="js/timecode.js">` and `<script src="js/timeline.js">` to `index.html` before `screens-editor.jsx`
|
||||||
- `shell.jsx` — Editor nav item with `DEV` badge
|
- `window.TC` available globally for 59.94 DF timecode math
|
||||||
- `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:**
|
### Task 1.3: TimelinePanel React component ✅
|
||||||
- `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.
|
- `tlRef` container div in editor layout
|
||||||
- `services/premiere-plugin/` — Premiere Pro CEP panel (external integration, not in scope)
|
- `useEffect` mounts `window.Timeline.init()` on first render
|
||||||
|
- `useEffect` pushes scale changes via `window.Timeline.setScale()`
|
||||||
|
- `onClipsChanged` / `onPlayheadMoved` callbacks connect timeline engine to React state
|
||||||
|
|
||||||
|
### Task 1.4: screens-editor.jsx rewrite ✅ (455 lines, was 162)
|
||||||
|
Full rewrite with:
|
||||||
|
- **App state**: `projectId`, `sequences`, `currentSeq`, `assets`, `sourceAsset`, `srcIn`/`srcOut`, `playheadFrames`, `history` (undo stack), `scale`, `tool`, `saveStatus`, `isDirty`
|
||||||
|
- **Source monitor**: `<video>` + `apiFetch('/assets/:id/stream')` + Mark In/Out + Insert button
|
||||||
|
- **Program monitor**: Virtual clip-by-clip playback — loads V1 clips sorted by `timeline_in_frames`, advances on `timeUpdate`/`ended` events
|
||||||
|
- **Media panel**: Asset list from `ZAMPP_DATA.ASSETS`, filter by bin, `AssetThumb` thumbnails, double-click loads source
|
||||||
|
- **Sequence management**: Picker `<select>`, "New sequence" modal, `openSequence()` loads via API
|
||||||
|
- **Auto-save**: `markDirty()` → debounce 2s → `syncSequenceClips()` → status updates
|
||||||
|
- **Undo/redo**: 50-step history stack, Ctrl+Z / Ctrl+Shift+Z
|
||||||
|
- **EDL export**: Button triggers `window.ZAMPP_API.exportSequenceEDL()`
|
||||||
|
- **Tool toolbar**: V/C/H buttons synced with `Timeline.setTool()`
|
||||||
|
- **Zoom slider**: Range input driving `window.Timeline.setScale()`
|
||||||
|
- **Keyboard handler**: I/O, V/C/H, Ctrl+Z/Shift+Z, Ctrl+S
|
||||||
|
|
||||||
|
### Task 1.5: "In Development" overlay removed ✅
|
||||||
|
- Deleted the `position: absolute; inset: 0; backdropFilter: blur(6px)` overlay div (was lines 98-117)
|
||||||
|
- Removed `FauxFrame` component reference
|
||||||
|
- All buttons are now functional
|
||||||
|
|
||||||
|
### Task 1.6: Editor nav badge removed ✅
|
||||||
|
- `shell.jsx`: `{ id: "editor", label: "Editor", icon: "editor" }` — no more `badge: { kind: "dev", text: "DEV" }`
|
||||||
|
|
||||||
|
### NEXT: Task 1.7 — Test & Deploy
|
||||||
|
1. `docker compose up -d --build web-ui`
|
||||||
|
2. Navigate to Editor route in browser
|
||||||
|
3. Verify: source monitor loads video, timeline renders with 4 track rows, Insert places clip, auto-save fires, EDL export downloads
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## File Map
|
## Phase 2 — UX Polish & Growing File (PENDING)
|
||||||
|
|
||||||
### Phase 1 — Core Editor
|
- [ ] 2.1 Multi-track refinements (ripple delete, snap, locking, overlap prevention)
|
||||||
|
- [ ] 2.2 Zoom slider + adaptive ruler
|
||||||
| Action | Path | Description |
|
- [ ] 2.3 JKL transport + frame stepping
|
||||||
|--------|------|-------------|
|
- [ ] 2.4 Waveform display on audio tracks
|
||||||
| MODIFY | `services/web-ui/public/data.jsx` | Add `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` |
|
- [ ] 2.5 Inspector panel wiring
|
||||||
| 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 |
|
- [ ] 2.6 Style migration to Tailwind primitives
|
||||||
| MODIFY | `services/web-ui/public/shell.jsx` | Remove `DEV` badge from Editor nav item |
|
- [ ] 2.7 HLS live preview during capture
|
||||||
| 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 3 — Export, Conform & Features (PENDING)
|
||||||
|
|
||||||
### Phase 2 — UX & Growing File
|
- [ ] 3.1 FCP XML export + conform queue
|
||||||
|
- [ ] 3.2 Hi-Res Auto-Relink
|
||||||
| Action | Path | Description |
|
- [ ] 3.3 Timecoded Comments
|
||||||
|--------|------|-------------|
|
- [ ] 3.4 Player Rebuild (P1)
|
||||||
| MODIFY | `services/web-ui/public/screens-editor.jsx` | Multi-track refinements, zoom slider, JKL transport, waveform display, inspector wiring |
|
- [ ] 3.5 Subclips (P2)
|
||||||
| MODIFY | `services/web-ui/public/screens-editor.jsx` | Style migration from legacy CSS variables to `/dist/app.css` Tailwind primitives |
|
- [ ] 3.6 Multi-select & Bulk Ops (P3)
|
||||||
| MODIFY | `services/capture/src/capture-manager.js` | HLS segment output during recording (`hls_time 2`, `hls_list_size 0`) |
|
- [ ] 3.7 Smart Bins (P6)
|
||||||
| CREATE | `services/capture/src/routes/live.js` | Serve HLS playlists + segments from `$HLS_SESSION_DIR/<sessionId>/` |
|
- [ ] 3.8 Metadata Templates (P7)
|
||||||
| 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: 20–500 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
|
|
||||||
- `30–60 px/s`: tick every 5s, label every 15s
|
|
||||||
- `60–120 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 (20–500 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
|
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,61 @@ async function loadData() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };
|
// ── 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, filename) {
|
||||||
|
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 = filename || 'sequence.edl';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ZAMPP_API = {
|
||||||
|
fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative,
|
||||||
|
getSequences, createSequence, getSequence, updateSequence,
|
||||||
|
deleteSequence, syncSequenceClips, exportSequenceEDL,
|
||||||
|
};
|
||||||
// Library re-renders after mutations: expose normalizeAsset so the screen
|
// Library re-renders after mutations: expose normalizeAsset so the screen
|
||||||
// can re-fetch /assets and produce rows with the same shape as the boot load.
|
// can re-fetch /assets and produce rows with the same shape as the boot load.
|
||||||
window.normalizeAsset = normalizeAsset;
|
window.normalizeAsset = normalizeAsset;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@
|
||||||
<script type="text/babel" src="screens-projects.jsx"></script>
|
<script type="text/babel" src="screens-projects.jsx"></script>
|
||||||
<script type="text/babel" src="screens-ingest.jsx"></script>
|
<script type="text/babel" src="screens-ingest.jsx"></script>
|
||||||
<script type="text/babel" src="screens-jobs.jsx"></script>
|
<script type="text/babel" src="screens-jobs.jsx"></script>
|
||||||
|
<script src="js/timecode.js"></script>
|
||||||
|
<script src="js/timeline.js"></script>
|
||||||
<script type="text/babel" src="screens-editor.jsx"></script>
|
<script type="text/babel" src="screens-editor.jsx"></script>
|
||||||
<script type="text/babel" src="screens-admin.jsx"></script>
|
<script type="text/babel" src="screens-admin.jsx"></script>
|
||||||
<script type="text/babel" src="modal-new-recorder.jsx"></script>
|
<script type="text/babel" src="modal-new-recorder.jsx"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,455 @@
|
||||||
// screens-editor.jsx — Editor (timeline)
|
// screens-editor.jsx — NLE timeline editor
|
||||||
|
// Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API
|
||||||
|
|
||||||
function _fmtTimecode(ms) {
|
function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); }
|
||||||
const s = Math.floor(ms / 1000);
|
|
||||||
const f = Math.floor((ms % 1000) / (1000 / 30));
|
|
||||||
return String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
|
|
||||||
String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
|
|
||||||
String(s % 60).padStart(2, '0') + ':' +
|
|
||||||
String(f).padStart(2, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
function Editor() {
|
function Editor() {
|
||||||
const { ASSETS } = window.ZAMPP_DATA;
|
const [projectId, setProjectId] = React.useState(null);
|
||||||
|
const [sequences, setSequences] = React.useState([]);
|
||||||
|
const [currentSeq, setCurrentSeq] = React.useState(null);
|
||||||
|
const [assets, setAssets] = React.useState([]);
|
||||||
|
const [bins, setBins] = React.useState([]);
|
||||||
|
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 [playing, setPlaying] = React.useState(false);
|
||||||
const [currentMs, setCurrentMs] = React.useState(0);
|
const [playheadFrames, setPlayheadFrames] = React.useState(0);
|
||||||
|
const [saveStatus, setSaveStatus] = React.useState('');
|
||||||
|
const [selectedClipId, setSelectedClipId] = React.useState(null);
|
||||||
|
const [history, setHistory] = React.useState([[]]);
|
||||||
|
const [historyIdx, setHistoryIdx] = React.useState(0);
|
||||||
|
const [scale, setScale] = React.useState(100);
|
||||||
|
const [tool, setTool] = React.useState('select');
|
||||||
|
const [seqFilter, setSeqFilter] = React.useState('');
|
||||||
|
const [showNewSeq, setShowNewSeq] = React.useState(false);
|
||||||
|
const [newSeqName, setNewSeqName] = React.useState('');
|
||||||
|
const [isDirty, setIsDirty] = React.useState(false);
|
||||||
|
const srcVideoRef = React.useRef(null);
|
||||||
|
const pgmVideoRef = React.useRef(null);
|
||||||
|
const tlRef = React.useRef(null);
|
||||||
|
const saveTimerRef = React.useRef(null);
|
||||||
|
const streamCacheRef = React.useRef({});
|
||||||
|
|
||||||
|
const tlInitRef = React.useRef(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!playing) return;
|
const data = window.ZAMPP_DATA;
|
||||||
const i = setInterval(() => setCurrentMs(t => t + 100), 100);
|
setAssets(data.ASSETS || []);
|
||||||
return () => clearInterval(i);
|
setBins(data.BINS || []);
|
||||||
}, [playing]);
|
const pid = data.PROJECTS && data.PROJECTS.length ? data.PROJECTS[0].id : null;
|
||||||
|
setProjectId(pid);
|
||||||
|
if (pid) loadSequences(pid);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!tlRef.current || tlInitRef.current || !window.Timeline) return;
|
||||||
|
tlInitRef.current = true;
|
||||||
|
window.Timeline.init(tlRef.current, {
|
||||||
|
fps: 59.94,
|
||||||
|
scale: 100,
|
||||||
|
onClipsChanged: handleClipsChanged,
|
||||||
|
onPlayheadMoved: handlePlayheadMoved,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (window.Timeline && tlInitRef.current) {
|
||||||
|
window.Timeline.setScale(scale);
|
||||||
|
}
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
|
async function loadSequences(pid) {
|
||||||
|
try {
|
||||||
|
const r = await window.ZAMPP_API.getSequences(pid);
|
||||||
|
const list = Array.isArray(r) ? r : [];
|
||||||
|
setSequences(list);
|
||||||
|
if (list.length) openSequence(list[0].id);
|
||||||
|
} catch (e) { console.error('Failed to load sequences', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSequence(id) {
|
||||||
|
try {
|
||||||
|
const r = await window.ZAMPP_API.getSequence(id);
|
||||||
|
const clips = (r.clips || []).map(c => ({ ...c, _id: _uid() }));
|
||||||
|
const seq = { ...r, clips };
|
||||||
|
setCurrentSeq(seq);
|
||||||
|
setPlayheadFrames(0);
|
||||||
|
setSelectedClipId(null);
|
||||||
|
setHistory([clips]);
|
||||||
|
setHistoryIdx(0);
|
||||||
|
setIsDirty(false);
|
||||||
|
setSaveStatus('');
|
||||||
|
renderTimelineClips(clips);
|
||||||
|
} catch (e) { console.error('Failed to open sequence', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewSequence() {
|
||||||
|
if (!projectId) return;
|
||||||
|
const name = newSeqName.trim() || ('Sequence ' + (sequences.length + 1));
|
||||||
|
try {
|
||||||
|
const r = await window.ZAMPP_API.createSequence({ project_id: projectId, name });
|
||||||
|
setSequences(prev => [r, ...prev]);
|
||||||
|
setNewSeqName('');
|
||||||
|
setShowNewSeq(false);
|
||||||
|
openSequence(r.id);
|
||||||
|
} catch (e) { console.error('Failed to create sequence', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimelineClips(clips) {
|
||||||
|
if (window.Timeline && tlRef.current) {
|
||||||
|
window.Timeline.render(clips || [], { fps: 59.94 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClipsChanged(clips) {
|
||||||
|
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
||||||
|
const newHistory = history.slice(0, historyIdx + 1);
|
||||||
|
newHistory.push(clips.map(c => ({ ...c })));
|
||||||
|
if (newHistory.length > 50) newHistory.shift();
|
||||||
|
setHistory(newHistory);
|
||||||
|
setHistoryIdx(newHistory.length - 1);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayheadMoved(frames) {
|
||||||
|
setPlayheadFrames(Math.max(0, frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
setIsDirty(true);
|
||||||
|
setSaveStatus('Unsaved');
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(saveSequence, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSequence() {
|
||||||
|
if (!currentSeq || !isDirty) return;
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
setSaveStatus('Saving\u2026');
|
||||||
|
try {
|
||||||
|
const clips = (currentSeq.clips || []).map(c => ({
|
||||||
|
asset_id: c.asset_id,
|
||||||
|
track: c.track,
|
||||||
|
timeline_in_frames: c.timeline_in_frames,
|
||||||
|
timeline_out_frames: c.timeline_out_frames,
|
||||||
|
source_in_frames: c.source_in_frames,
|
||||||
|
source_out_frames: c.source_out_frames,
|
||||||
|
}));
|
||||||
|
await window.ZAMPP_API.syncSequenceClips(currentSeq.id, clips);
|
||||||
|
setIsDirty(false);
|
||||||
|
setSaveStatus('Saved');
|
||||||
|
setTimeout(() => setSaveStatus(''), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setSaveStatus('Save failed');
|
||||||
|
console.error('Auto-save failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
if (historyIdx <= 0) return;
|
||||||
|
const idx = historyIdx - 1;
|
||||||
|
setHistoryIdx(idx);
|
||||||
|
const clips = (history[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
||||||
|
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
||||||
|
renderTimelineClips(clips);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
if (historyIdx >= history.length - 1) return;
|
||||||
|
const idx = historyIdx + 1;
|
||||||
|
setHistoryIdx(idx);
|
||||||
|
const clips = (history[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
||||||
|
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
||||||
|
renderTimelineClips(clips);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSourceAsset(asset) {
|
||||||
|
setSourceAsset(asset);
|
||||||
|
setSrcIn(null);
|
||||||
|
setSrcOut(null);
|
||||||
|
const vid = srcVideoRef.current;
|
||||||
|
if (!vid) return;
|
||||||
|
vid.src = '';
|
||||||
|
const cache = streamCacheRef.current;
|
||||||
|
let url = cache[asset.id];
|
||||||
|
if (!url) {
|
||||||
|
try {
|
||||||
|
const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream');
|
||||||
|
if (r && r.url) { url = r.url; cache[asset.id] = url; }
|
||||||
|
} catch (e) { console.error('Failed to get stream URL', e); }
|
||||||
|
}
|
||||||
|
if (url) { vid.src = url; vid.load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSrcIn() {
|
||||||
|
const vid = srcVideoRef.current;
|
||||||
|
if (vid) setSrcIn(vid.currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSrcOut() {
|
||||||
|
const vid = srcVideoRef.current;
|
||||||
|
if (vid) setSrcOut(vid.currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToTimeline() {
|
||||||
|
if (!sourceAsset || !currentSeq) return;
|
||||||
|
if (!window.Timeline) return;
|
||||||
|
const vid = srcVideoRef.current;
|
||||||
|
const sIn = srcIn != null ? srcIn : 0;
|
||||||
|
const sOut = srcOut != null ? srcOut : (vid && vid.duration ? vid.duration : ((sourceAsset.duration_ms || 10000) / 1000));
|
||||||
|
window.Timeline.addClip(
|
||||||
|
{ ...sourceAsset, streamUrl: streamCacheRef.current[sourceAsset.id] },
|
||||||
|
sIn, sOut, 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportEDL() {
|
||||||
|
if (!currentSeq) return;
|
||||||
|
const name = (currentSeq.name || 'sequence').replace(/[^a-z0-9]/gi, '_') + '.edl';
|
||||||
|
window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => console.error('EDL export failed', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcInLabel = srcIn != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcIn)) : 'In set') : 'Mark In';
|
||||||
|
const srcOutLabel = srcOut != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcOut)) : 'Out set') : 'Mark Out';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-shell" style={{ position: 'relative' }}>
|
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<div className="editor-topbar">
|
{/* ── Top toolbar ── */}
|
||||||
<span style={{ fontWeight: 600, fontSize: 13 }}>New sequence</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0, height: 40 }}>
|
||||||
<span className="badge dev" style={{ marginLeft: 8 }}>PREVIEW</span>
|
<Icon name="editor" size={14} />
|
||||||
|
<select value={currentSeq ? currentSeq.id : ''} onChange={e => openSequence(e.target.value)}
|
||||||
|
style={{ fontSize: 12, height: 24, background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text-primary)', maxWidth: 200 }}>
|
||||||
|
{sequences.length === 0 && <option value="">No sequences</option>}
|
||||||
|
{sequences.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} onClick={() => setShowNewSeq(true)} title="New sequence">
|
||||||
|
<Icon name="plus" size={12} />
|
||||||
|
</button>
|
||||||
|
<span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
|
||||||
|
<button className="btn ghost sm" onClick={undo} disabled={historyIdx <= 0} title="Undo (Ctrl+Z)">\u21A9</button>
|
||||||
|
<button className="btn ghost sm" onClick={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)">\u21AA</button>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="btn ghost sm" onClick={exportEDL} title="Export CMX3600 EDL">Export EDL</button>
|
||||||
|
<button className="btn primary sm" onClick={saveSequence}>{saveStatus || 'Save'}</button>
|
||||||
|
{saveStatus && <span style={{ fontSize: 10, color: saveStatus === 'Saved' ? 'var(--signal-good)' : 'var(--signal-warn)' }}>{saveStatus}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 2x2 grid ── */}
|
||||||
|
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '35vh 1fr', overflow: 'hidden' }}>
|
||||||
|
{/* Source monitor */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
|
||||||
|
<video ref={srcVideoRef} preload="metadata" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
|
||||||
|
{sourceAsset ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcVideoRef.current ? srcVideoRef.current.currentTime : 0)) : '00:00:00;00') : '--:--:--;--'}
|
||||||
|
</span>
|
||||||
|
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcIn} title="Mark In (I)">{srcInLabel}</button>
|
||||||
|
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcOut} title="Mark Out (O)">{srcOutLabel}</button>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<button className="btn ghost sm" disabled title="Export not yet implemented — use the Premiere panel for now"><Icon name="download" />Export</button>
|
<button className="btn primary sm" style={{ fontSize: 10, padding: '2px 8px' }} onClick={addToTimeline} title="Insert at playhead">Insert</button>
|
||||||
<button className="btn primary sm" disabled title="Publish to MAM not yet implemented">Publish</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-body">
|
|
||||||
<aside className="editor-bins">
|
|
||||||
<div style={{ padding: '12px 12px 8px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span style={{ fontWeight: 600, fontSize: 12 }}>Project bin</span>
|
|
||||||
<span style={{ flex: 1 }} />
|
|
||||||
<button className="icon-btn" disabled title="Bin search not yet implemented"><Icon name="search" size={12} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '0 8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
{ASSETS.length === 0 ? (
|
{/* Program monitor */}
|
||||||
<div style={{ padding: '16px 4px', color: 'var(--text-3)', fontSize: 12 }}>No assets in library.</div>
|
<ProgramMonitor
|
||||||
) : ASSETS.slice(0, 12).map(a => (
|
videoRef={pgmVideoRef}
|
||||||
<div key={a.id} className="editor-bin-item">
|
currentSeq={currentSeq}
|
||||||
<div className="editor-bin-thumb"><AssetThumb asset={a} /></div>
|
playheadFrames={playheadFrames}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
setPlayheadFrames={setPlayheadFrames}
|
||||||
<div style={{ fontSize: 11.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.name}</div>
|
streamCacheRef={streamCacheRef}
|
||||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{a.duration}</div>
|
/>
|
||||||
|
|
||||||
|
{/* Media panel (bottom left) */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', overflow: 'hidden', background: 'var(--bg-panel)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)' }}>Media</span>
|
||||||
|
<select value={seqFilter} onChange={e => setSeqFilter(e.target.value)}
|
||||||
|
style={{ fontSize: 10, height: 20, marginLeft: 'auto', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 2, color: 'var(--text-secondary)' }}>
|
||||||
|
<option value="">All bins</option>
|
||||||
|
{bins.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
||||||
|
{assets.filter(a => !seqFilter || a.bin_id === seqFilter).length === 0 ? (
|
||||||
|
<div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center' }}>No assets</div>
|
||||||
|
) : assets.filter(a => !seqFilter || a.bin_id === seqFilter).map(a => (
|
||||||
|
<div key={a.id} className="media-asset-item"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 11, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border-faint)' }}
|
||||||
|
onDoubleClick={() => loadSourceAsset(a)}>
|
||||||
|
<div style={{ width: 40, height: 24, borderRadius: 2, overflow: 'hidden', flexShrink: 0, background: 'var(--bg-surface)' }}>
|
||||||
|
<AssetThumb asset={a} size="sm" />
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.name}</span>
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace' }}>{a.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
<div className="editor-viewer">
|
|
||||||
<div className="editor-canvas">
|
{/* Timeline (bottom right) */}
|
||||||
<FauxFrame />
|
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{!playing && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0 }}>
|
||||||
<button className="player-play-overlay" onClick={() => setPlaying(true)}>
|
<ToolBtn active={tool === 'select'} label="V" title="Select (V)" onClick={() => { setTool('select'); if (window.Timeline) window.Timeline.setTool('select'); }} />
|
||||||
<Icon name="play" size={28} />
|
<ToolBtn active={tool === 'razor'} label="C" title="Razor (C)" onClick={() => { setTool('razor'); if (window.Timeline) window.Timeline.setTool('razor'); }} />
|
||||||
</button>
|
<ToolBtn active={tool === 'hand'} label="H" title="Hand (H)" onClick={() => { setTool('hand'); if (window.Timeline) window.Timeline.setTool('hand'); }} />
|
||||||
|
<span style={{ width: 1, height: 14, background: 'var(--border)', margin: '0 4px' }} />
|
||||||
|
<input type="range" min="20" max="500" value={scale} style={{ width: 60, height: 3 }}
|
||||||
|
onChange={e => {
|
||||||
|
const s = Number(e.target.value);
|
||||||
|
setScale(s);
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace', width: 28 }}>{scale}px</span>
|
||||||
|
</div>
|
||||||
|
<div ref={tlRef} className="timeline-container" style={{ flex: 1, overflow: 'hidden' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── New sequence modal ── */}
|
||||||
|
{showNewSeq && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 80, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={() => setShowNewSeq(false)}>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 8, padding: 24, minWidth: 320 }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>New sequence</div>
|
||||||
|
<input value={newSeqName} onChange={e => setNewSeqName(e.target.value)}
|
||||||
|
placeholder="Sequence name"
|
||||||
|
style={{ width: '100%', height: 32, padding: '0 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, color: 'var(--text-primary)', fontSize: 13, marginBottom: 12 }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') createNewSequence(); }} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button className="btn ghost sm" onClick={() => setShowNewSeq(false)}>Cancel</button>
|
||||||
|
<button className="btn primary sm" onClick={createNewSequence}>Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="player-tc"><span className="mono">{_fmtTimecode(currentMs)}</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="editor-transport">
|
|
||||||
<button className="icon-btn" onClick={() => setCurrentMs(0)} title="Go to start"><Icon name="arrowLeft" size={14} /></button>
|
|
||||||
<button className="icon-btn" onClick={() => setPlaying(p => !p)} title={playing ? 'Pause' : 'Play'}>
|
|
||||||
<Icon name={playing ? 'pause' : 'play'} size={14} />
|
|
||||||
</button>
|
|
||||||
<button className="icon-btn" disabled title="Step forward — not yet implemented"><Icon name="arrowRight" size={14} /></button>
|
|
||||||
<span style={{ flex: 1 }} />
|
|
||||||
<button className="btn ghost sm" disabled title="In/out trim points — not yet implemented">Mark in</button>
|
|
||||||
<button className="btn ghost sm" disabled title="In/out trim points — not yet implemented">Mark out</button>
|
|
||||||
<button className="btn subtle sm" disabled title="Timeline editing — not yet implemented">Add to timeline</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<aside className="editor-insp">
|
|
||||||
<div style={{ padding: '12px', fontSize: 12, fontWeight: 600, borderBottom: '1px solid var(--border)' }}>Inspector</div>
|
|
||||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<InspGroup title="Transform">
|
|
||||||
<InspRow label="Position" value="0, 0" />
|
|
||||||
<InspRow label="Scale" value="100%" />
|
|
||||||
<InspRow label="Rotation" value="0°" />
|
|
||||||
</InspGroup>
|
|
||||||
<InspGroup title="Color">
|
|
||||||
<InspRow label="Exposure" value="0.0" />
|
|
||||||
<InspRow label="Contrast" value="0.0" />
|
|
||||||
<InspRow label="Saturation" value="0.0" />
|
|
||||||
</InspGroup>
|
|
||||||
<InspGroup title="Audio">
|
|
||||||
<InspRow label="Level" value="0.0 dB" />
|
|
||||||
<InspRow label="Pan" value="C" />
|
|
||||||
</InspGroup>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
<EditorTimeline currentMs={currentMs} total={60} clips={[]} />
|
|
||||||
|
|
||||||
{/* IN DEVELOPMENT overlay */}
|
{/* ── Overlay keyboard handler ── */}
|
||||||
<div style={{
|
<EditorKeyboard
|
||||||
position: 'absolute', inset: 0, zIndex: 20,
|
onUndo={undo} onRedo={redo} onSave={saveSequence}
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
onMarkIn={markSrcIn} onMarkOut={markSrcOut}
|
||||||
backdropFilter: 'blur(6px)', background: 'rgba(10,12,16,0.82)',
|
currentSeq={currentSeq}
|
||||||
pointerEvents: 'all',
|
/>
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg-1)', border: '1px solid var(--border-stronger)',
|
|
||||||
borderRadius: 14, padding: '48px 56px', textAlign: 'center',
|
|
||||||
maxWidth: 440, boxShadow: '0 24px 80px rgba(0,0,0,0.6)',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.14em', color: 'var(--warning)', textTransform: 'uppercase', marginBottom: 20 }}>In Development</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.02em', color: 'var(--text-1)' }}>Non-linear Editor</div>
|
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.75 }}>
|
|
||||||
Timeline editing, multi-track audio mixing,<br />
|
|
||||||
GPU-accelerated export, and color grading<br />
|
|
||||||
are coming in a future release.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolBtn({ active, label, title, onClick }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} title={title}
|
||||||
|
style={{
|
||||||
|
width: 24, height: 22, fontSize: 11, fontWeight: 600,
|
||||||
|
background: active ? 'var(--accent-subtle)' : 'var(--bg-surface)',
|
||||||
|
border: '1px solid ' + (active ? 'var(--accent-border)' : 'var(--border)'),
|
||||||
|
borderRadius: 3, color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) {
|
||||||
|
const [pgmPlaying, setPgmPlaying] = React.useState(false);
|
||||||
|
const [pgmClipIdx, setPgmClipIdx] = React.useState(-1);
|
||||||
|
const [pgmClips, setPgmClips] = React.useState([]);
|
||||||
|
|
||||||
|
function getV1Clips() {
|
||||||
|
if (!currentSeq || !currentSeq.clips) return [];
|
||||||
|
return currentSeq.clips.filter(c => c.track === 0).sort((a, b) => a.timeline_in_frames - b.timeline_out_frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
if (pgmPlaying) {
|
||||||
|
stopPgm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const v1 = getV1Clips();
|
||||||
|
if (!v1.length) return;
|
||||||
|
setPgmClips(v1);
|
||||||
|
const idx = v1.findIndex(c => playheadFrames >= c.timeline_in_frames && playheadFrames < c.timeline_out_frames);
|
||||||
|
setPgmClipIdx(idx >= 0 ? idx : 0);
|
||||||
|
setPgmPlaying(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPgm() {
|
||||||
|
setPgmPlaying(false);
|
||||||
|
setPgmClipIdx(-1);
|
||||||
|
const vid = videoRef.current;
|
||||||
|
if (vid) vid.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return;
|
||||||
|
const clip = pgmClips[pgmClipIdx];
|
||||||
|
if (!clip) { stopPgm(); return; }
|
||||||
|
const vid = videoRef.current;
|
||||||
|
if (!vid) return;
|
||||||
|
const cache = streamCacheRef.current;
|
||||||
|
(async () => {
|
||||||
|
let url = cache[clip.asset_id];
|
||||||
|
if (!url) {
|
||||||
|
try {
|
||||||
|
const r = await window.ZAMPP_API.fetch('/assets/' + clip.asset_id + '/stream');
|
||||||
|
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
|
||||||
|
} catch (e) { console.error('Stream fetch failed', e); return; }
|
||||||
|
}
|
||||||
|
if (vid.src !== url) { vid.src = url; await vid.load(); }
|
||||||
|
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
|
||||||
|
vid.currentTime = srcInSecs;
|
||||||
|
vid.play().catch(() => {});
|
||||||
|
})();
|
||||||
|
}, [pgmPlaying, pgmClipIdx]);
|
||||||
|
|
||||||
|
function advanceClip() {
|
||||||
|
const next = pgmClipIdx + 1;
|
||||||
|
if (next >= pgmClips.length) { stopPgm(); return; }
|
||||||
|
setPgmClipIdx(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
|
||||||
|
<video ref={videoRef} preload="auto" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }}
|
||||||
|
onTimeUpdate={() => {
|
||||||
|
if (!pgmPlaying) return;
|
||||||
|
const clip = pgmClips[pgmClipIdx];
|
||||||
|
if (!clip) return;
|
||||||
|
const fps = window.TC ? window.TC.FPS : 59.94;
|
||||||
|
const srcOutSecs = clip.source_out_frames / fps;
|
||||||
|
if (videoRef.current && videoRef.current.currentTime >= srcOutSecs) {
|
||||||
|
advanceClip();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEnded={advanceClip}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<button className="btn ghost sm" style={{ fontSize: 11, padding: '2px 6px' }} onClick={togglePlay}>{pgmPlaying ? '\u23F8' : '\u25B6'}</button>
|
||||||
|
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
|
||||||
|
{window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InspGroup({ title, children }) {
|
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
|
||||||
return (
|
React.useEffect(() => {
|
||||||
<div>
|
function handler(e) {
|
||||||
<div className="muted" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 6 }}>{title}</div>
|
const tag = document.activeElement.tagName;
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>{children}</div>
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
|
||||||
</div>
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onRedo(); return; }
|
||||||
);
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onUndo(); return; }
|
||||||
}
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); onSave(); return; }
|
||||||
function InspRow({ label, value }) {
|
if (e.key === 'i' || e.key === 'I') { onMarkIn(); return; }
|
||||||
return (
|
if (e.key === 'o' || e.key === 'O') { onMarkOut(); return; }
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '70px 1fr', alignItems: 'center', fontSize: 11.5 }}>
|
if (e.key === 'v' || e.key === 'V') { if (window.Timeline) window.Timeline.setTool('select'); return; }
|
||||||
<span style={{ color: 'var(--text-3)' }}>{label}</span>
|
if (e.key === 'c' || e.key === 'C') { if (window.Timeline) window.Timeline.setTool('razor'); return; }
|
||||||
<span className="mono" style={{ background: 'var(--bg-2)', padding: '3px 6px', borderRadius: 4, border: '1px solid var(--border)' }}>{value}</span>
|
if (e.key === 'h' || e.key === 'H') { if (window.Timeline) window.Timeline.setTool('hand'); return; }
|
||||||
</div>
|
if (e.code === 'Space' && !currentSeq) { e.preventDefault(); /* handled by PGM */ }
|
||||||
);
|
}
|
||||||
}
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
function EditorTimeline({ currentMs, total = 60, clips = [] }) {
|
}, [onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq]);
|
||||||
const playheadPct = total > 0 ? ((currentMs / 1000) / total) * 100 : 0;
|
return null;
|
||||||
return (
|
|
||||||
<div className="editor-timeline">
|
|
||||||
<div className="editor-timeline-head">
|
|
||||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>Timeline</span>
|
|
||||||
<span style={{ flex: 1 }} />
|
|
||||||
</div>
|
|
||||||
<div className="timeline-ruler">
|
|
||||||
{Array.from({ length: 13 }).map((_, i) => (
|
|
||||||
<div key={i} className="ruler-tick">
|
|
||||||
{i % 2 === 0 && <span className="mono">{`00:${String(i * 5).padStart(2, '0')}`}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{clips.length === 0 && (
|
|
||||||
<div style={{ padding: '20px 40px', color: 'var(--text-3)', fontSize: 12 }}>Drop assets from the bin to build a sequence.</div>
|
|
||||||
)}
|
|
||||||
<div className="timeline-playhead" style={{ left: `calc(40px + (100% - 40px) * ${playheadPct / 100})` }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Editor = Editor;
|
window.Editor = Editor;
|
||||||
|
|
@ -17,7 +17,7 @@ const NAV_TREE = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } },
|
{ id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } },
|
||||||
{ id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } },
|
{ id: "editor", label: "Editor", icon: "editor" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ADMIN_TREE = [
|
const ADMIN_TREE = [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue