dragonflight/docs/superpowers/plans/2026-05-18-nle-editor.md

2246 lines
80 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 (Phase 1) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Premiere-style NLE editor page (`editor.html`) with source monitor (in/out marking), DOM-based timeline (Select + Razor tools, undo/redo), program monitor (virtual clip-by-clip playback), and EDL export — backed by new `sequences` + `sequence_clips` DB tables and a REST API.
**Architecture:** New `editor.html` page loads three shared JS modules (`timecode.js`, `timeline.js`, plus existing `api.js`) as `<script>` tags. The API grows one new route file (`sequences.js`). Auto-save syncs the full clip array to the DB 2 s after any change. Phase 2 (HLS live preview) is a separate plan.
**Tech Stack:** Vanilla JS (no bundler, ES module style via script tags), Express/Node.js 18+, PostgreSQL 15, existing design-system CSS variables.
**Frame rate:** 59.94 fps drop-frame throughout. All timecode display uses `HH:MM:SS;FF` (semicolon = drop-frame per SMPTE).
**Testing:** No unit-test framework is set up. Each task includes `curl` commands (port 47432 = API-direct) and browser steps (port 47434 = nginx/web-UI). Run `docker compose logs mam-api -f` in a second terminal to watch errors.
---
## File Map
| Action | Path | Responsibility |
|--------|------|---------------|
| Create | `services/mam-api/src/db/schema_patch_editor.sql` | `sequences` + `sequence_clips` DDL |
| Create | `services/mam-api/src/routes/sequences.js` | Full sequences REST API |
| Modify | `services/mam-api/src/index.js` | Register sequences route |
| Modify | `services/web-ui/public/js/api.js` | Add sequence helper functions |
| Create | `services/web-ui/public/js/timecode.js` | 59.94 DF timecode math (`window.TC`) |
| Create | `services/web-ui/public/js/timeline.js` | DOM timeline engine (`window.Timeline`) |
| Create | `services/web-ui/public/editor.html` | 4-panel editor page (all app logic inline) |
| Modify | `services/web-ui/public/index.html` | "Open in Editor" card action + sidebar link |
---
## Task 1: DB Schema Migration
**Files:**
- Create: `services/mam-api/src/db/schema_patch_editor.sql`
- [ ] **Step 1: Create the migration file**
```sql
-- services/mam-api/src/db/schema_patch_editor.sql
-- Run once against the Wild Dragon PostgreSQL database.
-- Named timelines within a project (multiple per project, like Premiere)
CREATE TABLE IF NOT EXISTS sequences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Sequence 1',
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
width INTEGER NOT NULL DEFAULT 1920,
height INTEGER NOT NULL DEFAULT 1080,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sequences_project_id ON sequences(project_id);
CREATE INDEX IF NOT EXISTS idx_sequences_updated_at ON sequences(updated_at DESC);
-- Clips placed on a sequence timeline
CREATE TABLE IF NOT EXISTS sequence_clips (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
track INTEGER NOT NULL DEFAULT 0,
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
timeline_in_frames BIGINT NOT NULL,
timeline_out_frames BIGINT NOT NULL,
source_in_frames BIGINT NOT NULL DEFAULT 0,
source_out_frames BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_track_position ON sequence_clips(sequence_id, track, timeline_in_frames);
```
- [ ] **Step 2: Run the migration inside the Postgres container**
```bash
# From the repo root
docker compose exec db psql -U wilddragon -d wilddragon \
-f /dev/stdin < services/mam-api/src/db/schema_patch_editor.sql
```
Expected output: `CREATE TABLE`, `CREATE INDEX` (×4). No errors.
- [ ] **Step 3: Verify tables exist**
```bash
docker compose exec db psql -U wilddragon -d wilddragon \
-c "\dt sequences" -c "\dt sequence_clips"
```
Expected: both tables listed.
- [ ] **Step 4: Commit**
```bash
git add services/mam-api/src/db/schema_patch_editor.sql
git commit -m "feat(db): add sequences and sequence_clips tables"
```
---
## Task 2: Sequences API Route
**Files:**
- Create: `services/mam-api/src/routes/sequences.js`
- [ ] **Step 1: Create the route file**
```js
// services/mam-api/src/routes/sequences.js
import express from 'express';
import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
// ── 59.94 DF timecode helpers (for EDL export) ────────────────────────────────
const NOM = 60; // nominal integer fps
const DROP = 4; // frames dropped per minute (except every 10th)
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
function framesToTC(totalFrames) {
const fc = Math.max(0, Math.round(totalFrames));
const h = Math.floor(fc / FRAMES_PER_HOUR);
let rem = fc % FRAMES_PER_HOUR;
const tm = Math.floor(rem / FRAMES_PER_10MIN);
rem = rem % FRAMES_PER_10MIN;
let m = 0;
if (rem >= DROP) {
m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1;
rem = (rem - DROP) % FRAMES_PER_MIN;
}
const M = tm * 10 + m;
const s = Math.floor(rem / NOM);
const ff = rem % NOM;
return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`;
}
function generateEDL(seqName, clips) {
const lines = [`TITLE: ${seqName}`, ''];
clips.forEach((c, i) => {
const num = String(i + 1).padStart(3, '0');
const reel = (c.filename || 'UNKNOWN')
.replace(/\.[^.]+$/, '') // strip extension
.replace(/[^A-Za-z0-9_]/g, '_')
.toUpperCase()
.substring(0, 32)
.padEnd(8);
const srcIn = framesToTC(c.source_in_frames);
const srcOut = framesToTC(c.source_out_frames);
const recIn = framesToTC(c.timeline_in_frames);
const recOut = framesToTC(c.timeline_out_frames);
lines.push(`${num} ${reel} V C ${srcIn} ${srcOut} ${recIn} ${recOut}`);
});
return lines.join('\n');
}
// ── GET / list sequences for a project ─────────────────────────────────────
router.get('/', async (req, res, next) => {
try {
const { project_id } = req.query;
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
const r = await pool.query(
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
[project_id]
);
res.json(r.rows);
} catch (e) { next(e); }
});
// ── POST / create sequence ──────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
try {
const {
project_id,
name = 'Sequence 1',
frame_rate = 59.94,
width = 1920,
height = 1080,
} = req.body;
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
const r = await pool.query(
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[project_id, name, frame_rate, width, height]
);
res.status(201).json(r.rows[0]);
} catch (e) { next(e); }
});
// ── GET /:id sequence + all clips joined with asset data ───────────────────
router.get('/:id', async (req, res, next) => {
try {
const seqR = await pool.query(
`SELECT * FROM sequences WHERE id = $1`,
[req.params.id]
);
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const clipsR = await pool.query(
`SELECT sc.*,
a.display_name, a.filename, a.fps, a.duration_ms,
a.proxy_s3_key, a.thumbnail_s3_key
FROM sequence_clips sc
JOIN assets a ON a.id = sc.asset_id
WHERE sc.sequence_id = $1
ORDER BY sc.track, sc.timeline_in_frames`,
[req.params.id]
);
// Attach signed stream URLs (best-effort; missing proxy → streamUrl: null)
const clips = await Promise.all(
clipsR.rows.map(async (clip) => {
let streamUrl = null;
if (clip.proxy_s3_key) {
try { streamUrl = await getSignedUrlForObject(clip.proxy_s3_key); } catch (_) {}
}
return { ...clip, streamUrl };
})
);
res.json({ ...seqR.rows[0], clips });
} catch (e) { next(e); }
});
// ── PUT /:id update sequence metadata ──────────────────────────────────────
router.put('/:id', async (req, res, next) => {
try {
const { name, frame_rate, width, height } = req.body;
const updates = [];
const params = [];
let n = 1;
if (name !== undefined) { updates.push(`name = $${n++}`); params.push(name); }
if (frame_rate !== undefined) { updates.push(`frame_rate = $${n++}`); params.push(frame_rate); }
if (width !== undefined) { updates.push(`width = $${n++}`); params.push(width); }
if (height !== undefined) { updates.push(`height = $${n++}`); params.push(height); }
if (!updates.length) return res.status(400).json({ error: 'No fields to update' });
updates.push('updated_at = NOW()');
params.push(req.params.id);
const r = await pool.query(
`UPDATE sequences SET ${updates.join(', ')} WHERE id = $${n} RETURNING *`,
params
);
if (!r.rows.length) return res.status(404).json({ error: 'Sequence not found' });
res.json(r.rows[0]);
} catch (e) { next(e); }
});
// ── DELETE /:id ───────────────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
try {
await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── PUT /:id/clips full replace of clip array (single transaction) ──────────
router.put('/:id/clips', async (req, res, next) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
[req.params.id]
);
const clips = Array.isArray(req.body) ? req.body : [];
for (const c of clips) {
await client.query(
`INSERT INTO sequence_clips
(sequence_id, asset_id, track,
timeline_in_frames, timeline_out_frames,
source_in_frames, source_out_frames)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
req.params.id, c.asset_id, c.track,
c.timeline_in_frames, c.timeline_out_frames,
c.source_in_frames, c.source_out_frames,
]
);
}
await client.query(
`UPDATE sequences SET updated_at = NOW() WHERE id = $1`,
[req.params.id]
);
await client.query('COMMIT');
res.json({ ok: true, count: clips.length });
} catch (e) {
await client.query('ROLLBACK');
next(e);
} finally {
client.release();
}
});
// ── POST /:id/export/edl download CMX3600 EDL ──────────────────────────────
router.post('/:id/export/edl', async (req, res, next) => {
try {
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const seq = seqR.rows[0];
// Export V1 clips only (primary video track) sorted by position
const clipsR = await pool.query(
`SELECT sc.*, a.filename
FROM sequence_clips sc
JOIN assets a ON a.id = sc.asset_id
WHERE sc.sequence_id = $1 AND sc.track = 0
ORDER BY sc.timeline_in_frames`,
[req.params.id]
);
const edl = generateEDL(seq.name, clipsR.rows);
const filename = `${seq.name.replace(/[^a-z0-9]/gi, '_')}.edl`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(edl);
} catch (e) { next(e); }
});
export default router;
```
- [ ] **Step 2: Commit**
```bash
git add services/mam-api/src/routes/sequences.js
git commit -m "feat(api): add sequences route with CRUD, clip sync, and EDL export"
```
---
## Task 3: Register Sequences Route in API Server
**Files:**
- Modify: `services/mam-api/src/index.js`
- [ ] **Step 1: Add the import** (after the `tokensRouter` import on line ~23)
```js
import sequencesRouter from './routes/sequences.js';
```
- [ ] **Step 2: Mount the route** (after the `tokensRouter` mount, before the `errorHandler` line)
```js
app.use('/api/v1/sequences', requireAuth, sequencesRouter);
```
- [ ] **Step 3: Restart the API container**
```bash
docker compose restart mam-api
docker compose logs mam-api --tail=10
```
Expected: `MAM API listening on port 3000` — no import errors.
- [ ] **Step 4: Smoke-test the new endpoints**
First, get an auth cookie (replace credentials as needed):
```bash
curl -s -c /tmp/wd.cookies -X POST http://localhost:47432/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq .status
```
Expected: `"ok"` (or whatever the login response returns).
Get a project ID to use:
```bash
curl -s -b /tmp/wd.cookies http://localhost:47432/api/v1/projects | jq '.[0].id'
```
Copy the UUID, then:
```bash
PROJECT_ID="<paste-uuid-here>"
# Create a sequence
curl -s -b /tmp/wd.cookies -X POST http://localhost:47432/api/v1/sequences \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Test Seq\"}" | jq .
# Expected: { "id": "...", "name": "Test Seq", "frame_rate": "59.94", ... }
```
```bash
SEQ_ID="<paste-sequence-id>"
# List sequences
curl -s -b /tmp/wd.cookies \
"http://localhost:47432/api/v1/sequences?project_id=$PROJECT_ID" | jq '.[].name'
# Get sequence (with empty clips array)
curl -s -b /tmp/wd.cookies \
"http://localhost:47432/api/v1/sequences/$SEQ_ID" | jq '.clips | length'
# Expected: 0
# Delete the test sequence
curl -s -b /tmp/wd.cookies -X DELETE \
"http://localhost:47432/api/v1/sequences/$SEQ_ID" | jq .ok
# Expected: true
```
- [ ] **Step 5: Commit**
```bash
git add services/mam-api/src/index.js
git commit -m "feat(api): register sequences route"
```
---
## Task 4: Add Sequence Helpers to api.js
**Files:**
- Modify: `services/web-ui/public/js/api.js`
- [ ] **Step 1: Add the following block** at the end of `api.js`, before the closing comment (after the `revokeToken` function):
```js
// ============================================================
// SEQUENCE API CALLS
// ============================================================
async function getSequences(projectId) {
return api(`/sequences?project_id=${projectId}`);
}
async function createSequence(data) {
return api('/sequences', {
method: 'POST',
body: JSON.stringify(data),
});
}
async function getSequence(sequenceId) {
return api(`/sequences/${sequenceId}`);
}
async function updateSequence(sequenceId, data) {
return api(`/sequences/${sequenceId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async function deleteSequence(sequenceId) {
return api(`/sequences/${sequenceId}`, { method: 'DELETE' });
}
/**
* Replace all clips in a sequence.
* @param {string} sequenceId
* @param {Array<{asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames}>} clips
*/
async function syncSequenceClips(sequenceId, clips) {
return api(`/sequences/${sequenceId}/clips`, {
method: 'PUT',
body: JSON.stringify(clips),
});
}
/**
* Download EDL for the V1 track of a sequence.
* Triggers a file download in the browser.
*/
async function exportSequenceEDL(sequenceId, filename) {
try {
const response = await fetch(`${API_BASE}/sequences/${sequenceId}/export/edl`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error(`EDL export failed: ${response.status}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'sequence.edl';
a.click();
URL.revokeObjectURL(url);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
```
- [ ] **Step 2: Commit**
```bash
git add services/web-ui/public/js/api.js
git commit -m "feat(ui): add sequence API helpers to api.js"
```
---
## Task 5: Timecode Utility Module
**Files:**
- Create: `services/web-ui/public/js/timecode.js`
- [ ] **Step 1: Create the file**
```js
// services/web-ui/public/js/timecode.js
// 59.94 fps drop-frame timecode utilities.
// Exposes: window.TC
(function (global) {
'use strict';
// 59.94 = 60000/1001
const FPS_EXACT = 60000 / 1001;
const NOM = 60; // nominal integer fps
const DROP = 4; // frames dropped per minute (except every 10th)
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
/**
* Convert a frame count to a 59.94 DF timecode string: HH:MM:SS;FF
*/
function framesToTC(totalFrames) {
const fc = Math.max(0, Math.round(totalFrames));
const h = Math.floor(fc / FRAMES_PER_HOUR);
let rem = fc % FRAMES_PER_HOUR;
const tm = Math.floor(rem / FRAMES_PER_10MIN); // tens of minutes (05)
rem = rem % FRAMES_PER_10MIN;
let m = 0;
if (rem >= DROP) {
m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1;
rem = (rem - DROP) % FRAMES_PER_MIN;
}
const M = tm * 10 + m;
const s = Math.floor(rem / NOM);
const ff = rem % NOM;
return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`;
}
/**
* Convert a 59.94 DF timecode string (HH:MM:SS;FF or HH:MM:SS:FF) to frame count.
*/
function tcToFrames(tc) {
if (!tc) return 0;
const clean = String(tc).replace(';', ':');
const parts = clean.split(':').map(Number);
if (parts.length !== 4) return 0;
const [h, m, s, f] = parts;
const totalMinutes = h * 60 + m;
return (NOM * 3600 * h)
+ (NOM * 60 * m)
+ (NOM * s)
+ f
- DROP * (totalMinutes - Math.floor(totalMinutes / 10));
}
/**
* Convert seconds (float) → frame count at 59.94.
*/
function secondsToFrames(seconds) {
return Math.round(seconds * FPS_EXACT);
}
/**
* Convert frame count → seconds (float) at 59.94.
*/
function framesToSeconds(frames) {
return frames / FPS_EXACT;
}
global.TC = {
framesToTC,
tcToFrames,
secondsToFrames,
framesToSeconds,
FPS: FPS_EXACT,
};
})(window);
```
- [ ] **Step 2: Verify the math in browser console**
Open any Wild Dragon page, open DevTools, paste:
```js
// Load timecode.js (or inject it)
// Quick sanity checks:
TC.framesToTC(0) // "00:00:00;00"
TC.framesToTC(3596) // "00:01:00;00" (first minute boundary: 60*60-4=3596 frames)
TC.framesToTC(215784) // "01:00:00;00" (one hour)
TC.tcToFrames("00:01:00;00") // 3596
TC.secondsToFrames(1) // 60 (nearest integer at 59.94)
TC.framesToSeconds(60) // ~1.0007
```
Expected: all values match comments above.
- [ ] **Step 3: Commit**
```bash
git add services/web-ui/public/js/timecode.js
git commit -m "feat(ui): add 59.94 DF timecode utility module"
```
---
## Task 6: Timeline Engine Module
**Files:**
- Create: `services/web-ui/public/js/timeline.js`
- [ ] **Step 1: Create the file**
```js
// services/web-ui/public/js/timeline.js
// DOM-based NLE timeline engine. Exposes: window.Timeline
// Depends on: window.TC (timecode.js must be loaded first)
(function (global) {
'use strict';
const TRACK_HEIGHT = 48; // px, each track row
const HEADER_W = 40; // px, track label column width
const MIN_CLIP_PX = 4; // minimum rendered clip width
const TRACKS = [
{ id: 0, label: 'V1', type: 'video' },
{ id: 1, label: 'V2', type: 'video' },
{ id: 100, label: 'A1', type: 'audio' },
{ id: 101, label: 'A2', type: 'audio' },
];
// ── Internal state ──────────────────────────────────────────────────────────
const s = {
container: null,
rulerEl: null,
tracksEl: null,
playheadEl: null,
clips: [], // working copy; each has a local .id
scale: 100, // px per second
fps: 59.94,
playheadFrames: 0,
activeTool: 'select', // 'select' | 'razor' | 'hand'
selectedId: null,
onClipsChanged: null, // callback(clips[])
onPlayheadMoved: null, // callback(frames)
};
let _uid = 0;
function uid() { return 'tl_' + (++_uid); }
function framesToPx(f) { return (f / s.fps) * s.scale; }
function pxToFrames(px) { return Math.round((px / s.scale) * s.fps); }
// ── init ────────────────────────────────────────────────────────────────────
function init(container, options) {
options = options || {};
s.container = container;
s.fps = options.fps || 59.94;
s.scale = options.scale || 100;
s.onClipsChanged = options.onClipsChanged || null;
s.onPlayheadMoved = options.onPlayheadMoved || null;
container.innerHTML = '';
container.style.cssText = [
'position:relative', 'overflow-x:auto', 'overflow-y:hidden',
'user-select:none', 'display:flex', 'flex-direction:column',
].join(';');
// Ruler row
s.rulerEl = document.createElement('div');
s.rulerEl.className = 'tl-ruler';
s.rulerEl.style.cssText = [
'position:sticky', 'top:0', 'z-index:10',
'height:24px', 'flex-shrink:0',
'background:var(--bg-base)', 'border-bottom:1px solid var(--border)',
'cursor:pointer',
].join(';');
s.rulerEl.addEventListener('click', _onRulerClick);
container.appendChild(s.rulerEl);
// Tracks wrapper (clips + playhead live here)
s.tracksEl = document.createElement('div');
s.tracksEl.style.cssText = 'position:relative;flex:1;';
TRACKS.forEach(function (t) {
var row = document.createElement('div');
row.className = 'tl-track-row';
row.style.cssText = [
'display:flex', 'height:' + TRACK_HEIGHT + 'px',
'border-bottom:1px solid var(--border)',
].join(';');
// Sticky label
var hdr = document.createElement('div');
hdr.className = 'tl-track-hdr';
hdr.textContent = t.label;
hdr.style.cssText = [
'width:' + HEADER_W + 'px', 'flex-shrink:0',
'display:flex', 'align-items:center', 'justify-content:center',
'font-size:10px', 'font-weight:600', 'letter-spacing:.05em',
'color:var(--text-tertiary)', 'background:var(--bg-panel)',
'border-right:1px solid var(--border)',
'position:sticky', 'left:0', 'z-index:5',
].join(';');
row.appendChild(hdr);
// Clip area
var area = document.createElement('div');
area.className = 'tl-clip-area';
area.dataset.trackId = t.id;
area.style.cssText = 'flex:1;position:relative;overflow:hidden;';
area.addEventListener('click', _onAreaClick);
row.appendChild(area);
s.tracksEl.appendChild(row);
});
// Playhead
s.playheadEl = document.createElement('div');
s.playheadEl.className = 'tl-playhead';
s.playheadEl.style.cssText = [
'position:absolute', 'top:0', 'bottom:0',
'width:2px', 'background:var(--accent)',
'pointer-events:none', 'z-index:20',
].join(';');
s.tracksEl.appendChild(s.playheadEl);
container.appendChild(s.tracksEl);
// Zoom: Ctrl+wheel
container.addEventListener('wheel', function (e) {
if (!e.ctrlKey) return;
e.preventDefault();
var delta = e.deltaY < 0 ? 1.15 : 0.87;
s.scale = Math.min(500, Math.max(20, s.scale * delta));
_renderClips();
_renderRuler();
}, { passive: false });
// Keyboard shortcuts (Delete, tool keys)
document.addEventListener('keydown', _onKeyDown);
_renderRuler();
}
// ── Ruler ───────────────────────────────────────────────────────────────────
function _renderRuler() {
s.rulerEl.innerHTML = '';
var totalSecs = 600; // 10 minutes
var totalPx = HEADER_W + totalSecs * s.scale;
var canvas = document.createElement('canvas');
canvas.width = totalPx;
canvas.height = 24;
canvas.style.cssText = 'display:block;width:' + totalPx + 'px;height:24px;';
var ctx = canvas.getContext('2d');
var bgColor = getComputedStyle(document.documentElement)
.getPropertyValue('--bg-base').trim() || '#0d0f14';
var borderColor = getComputedStyle(document.documentElement)
.getPropertyValue('--border').trim() || '#2a2d35';
var textColor = getComputedStyle(document.documentElement)
.getPropertyValue('--text-tertiary').trim() || '#5a6070';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, totalPx, 24);
// Tick interval: coarser when zoomed out
var tickSecs = s.scale < 30 ? 10 : s.scale < 60 ? 5 : s.scale < 120 ? 2 : 1;
ctx.strokeStyle = borderColor;
ctx.fillStyle = textColor;
ctx.font = '9px Inter, sans-serif';
ctx.textAlign = 'left';
for (var t = 0; t <= totalSecs; t += tickSecs) {
var x = HEADER_W + t * s.scale;
ctx.beginPath();
ctx.moveTo(x, 18);
ctx.lineTo(x, 24);
ctx.stroke();
if (t % (tickSecs * 5) === 0) {
var mm = String(Math.floor(t / 60)).padStart(2, '0');
var ss = String(t % 60).padStart(2, '0');
ctx.fillText(mm + ':' + ss, x + 2, 14);
}
}
s.rulerEl.appendChild(canvas);
}
function _onRulerClick(e) {
var rect = s.rulerEl.getBoundingClientRect();
var x = e.clientX - rect.left - HEADER_W + s.container.scrollLeft;
if (x < 0) return;
var frames = pxToFrames(x);
setPlayhead(frames);
if (s.onPlayheadMoved) s.onPlayheadMoved(frames);
}
// ── Render clips ────────────────────────────────────────────────────────────
function render(clips, options) {
options = options || {};
s.clips = clips.map(function (c) { return Object.assign({}, c, { _id: c._id || uid() }); });
if (options.fps) s.fps = options.fps;
_renderClips();
}
function _renderClips() {
TRACKS.forEach(function (t) {
var area = s.tracksEl.querySelector('.tl-clip-area[data-track-id="' + t.id + '"]');
if (!area) return;
area.innerHTML = '';
s.clips
.filter(function (c) { return c.track === t.id; })
.sort(function (a, b) { return a.timeline_in_frames - b.timeline_in_frames; })
.forEach(function (clip) {
area.appendChild(_makeClipEl(clip, t));
});
});
_positionPlayhead();
}
function _makeClipEl(clip, track) {
var left = framesToPx(clip.timeline_in_frames);
var width = Math.max(MIN_CLIP_PX, framesToPx(clip.timeline_out_frames - clip.timeline_in_frames));
var isSelected = clip._id === s.selectedId;
var el = document.createElement('div');
el.className = 'tl-clip';
el.dataset.clipId = clip._id;
el.style.cssText = [
'position:absolute',
'left:' + left + 'px',
'width:' + width + 'px',
'height:' + (TRACK_HEIGHT - 2) + 'px',
'top:1px',
'border-radius:3px',
'box-sizing:border-box',
'overflow:hidden',
'display:flex',
'align-items:center',
'padding:0 6px',
'background:' + (track.type === 'video'
? 'oklch(55% 0.15 52 / 0.22)'
: 'oklch(55% 0.14 280 / 0.22)'),
'border:1px solid ' + (isSelected
? 'var(--accent)'
: 'var(--border-strong)'),
'cursor:' + (s.activeTool === 'razor' ? 'crosshair' : 'default'),
].join(';');
// Clip label
var lbl = document.createElement('span');
lbl.textContent = clip.display_name || 'Clip';
lbl.style.cssText = [
'font-size:9px', 'font-weight:500',
'color:var(--text-secondary)',
'overflow:hidden', 'text-overflow:ellipsis', 'white-space:nowrap',
'pointer-events:none', 'flex:1',
].join(';');
el.appendChild(lbl);
if (s.activeTool === 'select') {
// Trim handles (shown on hover)
var lh = _makeHandle('left');
var rh = _makeHandle('right');
el.appendChild(lh);
el.appendChild(rh);
el.addEventListener('mouseenter', function () {
lh.style.opacity = '0.8';
rh.style.opacity = '0.8';
});
el.addEventListener('mouseleave', function () {
lh.style.opacity = '0';
rh.style.opacity = '0';
});
// Drag to move (body only)
el.addEventListener('mousedown', function (e) {
if (e.target === lh || e.target === rh) return;
s.selectedId = clip._id;
_renderClips();
_onMoveStart(e, clip);
});
lh.addEventListener('mousedown', function (e) {
e.stopPropagation();
_onTrimStart(e, clip, 'left');
});
rh.addEventListener('mousedown', function (e) {
e.stopPropagation();
_onTrimStart(e, clip, 'right');
});
}
if (s.activeTool === 'razor') {
el.addEventListener('click', function (e) { _onRazorClick(e, clip); });
}
return el;
}
function _makeHandle(side) {
var h = document.createElement('div');
h.style.cssText = [
'position:absolute',
side + ':0',
'top:0', 'bottom:0',
'width:6px',
'cursor:' + (side === 'left' ? 'w-resize' : 'e-resize'),
'background:var(--accent)',
'opacity:0',
'transition:opacity 0.12s',
].join(';');
return h;
}
// ── Select tool: move ───────────────────────────────────────────────────────
function _onMoveStart(e, clip) {
var startX = e.clientX;
var origIn = clip.timeline_in_frames;
var origOut = clip.timeline_out_frames;
var duration = origOut - origIn;
function onMove(ev) {
var dx = ev.clientX - startX;
var dFrames = pxToFrames(dx);
clip.timeline_in_frames = Math.max(0, origIn + dFrames);
clip.timeline_out_frames = clip.timeline_in_frames + duration;
_renderClips();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ── Select tool: trim ───────────────────────────────────────────────────────
function _onTrimStart(e, clip, side) {
var startX = e.clientX;
var origTlIn = clip.timeline_in_frames;
var origTlOut = clip.timeline_out_frames;
var origSrcIn = clip.source_in_frames;
var origSrcOut = clip.source_out_frames;
// Maximum source out is the asset's full duration in frames
var assetFrames = clip.duration_ms
? Math.round((clip.duration_ms / 1000) * s.fps)
: origSrcOut;
function onMove(ev) {
var dx = ev.clientX - startX;
var dFr = pxToFrames(dx);
if (side === 'left') {
var newTlIn = Math.max(0, Math.min(origTlIn + dFr, origTlOut - 1));
var dIn = newTlIn - origTlIn;
clip.timeline_in_frames = newTlIn;
clip.source_in_frames = Math.max(0, origSrcIn + dIn);
} else {
var newTlOut = Math.max(origTlIn + 1, origTlOut + dFr);
var dOut = newTlOut - origTlOut;
clip.timeline_out_frames = newTlOut;
clip.source_out_frames = Math.min(assetFrames, origSrcOut + dOut);
}
_renderClips();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ── Razor tool ──────────────────────────────────────────────────────────────
function _onRazorClick(e, clip) {
// Frame at the click position within the clip element
var clipRect = e.currentTarget.getBoundingClientRect();
var xWithinClip = e.clientX - clipRect.left;
var framesInClip = pxToFrames(xWithinClip);
var clipDuration = clip.timeline_out_frames - clip.timeline_in_frames;
// Guard: don't split at the very edge
if (framesInClip <= 0 || framesInClip >= clipDuration) return;
var splitTlFrame = clip.timeline_in_frames + framesInClip;
var splitSrcFrame = clip.source_in_frames + framesInClip;
var left = Object.assign({}, clip, {
_id: uid(),
timeline_out_frames: splitTlFrame,
source_out_frames: splitSrcFrame,
});
var right = Object.assign({}, clip, {
_id: uid(),
timeline_in_frames: splitTlFrame,
source_in_frames: splitSrcFrame,
});
var idx = s.clips.findIndex(function (c) { return c._id === clip._id; });
s.clips.splice(idx, 1, left, right);
_renderClips();
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
// ── Area click (deselect) ───────────────────────────────────────────────────
function _onAreaClick(e) {
if (e.target !== e.currentTarget) return; // hit a clip, not empty space
if (s.activeTool === 'select') {
s.selectedId = null;
_renderClips();
}
}
// ── Keyboard ────────────────────────────────────────────────────────────────
function _onKeyDown(e) {
if (!s.container) return;
// Don't steal keys from input fields
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) return;
// Tool shortcuts
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
if (e.key === 'v' || e.key === 'V') { setTool('select'); return; }
if (e.key === 'c' || e.key === 'C') { setTool('razor'); return; }
if (e.key === 'h' || e.key === 'H') { setTool('hand'); return; }
}
// Delete selected clip
if ((e.key === 'Delete' || e.key === 'Backspace') && s.selectedId) {
e.preventDefault();
s.clips = s.clips.filter(function (c) { return c._id !== s.selectedId; });
s.selectedId = null;
_renderClips();
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
}
}
// ── Playhead ────────────────────────────────────────────────────────────────
function _positionPlayhead() {
if (!s.playheadEl) return;
s.playheadEl.style.left = (HEADER_W + framesToPx(s.playheadFrames)) + 'px';
}
function setPlayhead(frames) {
s.playheadFrames = Math.max(0, Math.round(frames));
_positionPlayhead();
}
function getPlayhead() { return s.playheadFrames; }
// ── Tool ────────────────────────────────────────────────────────────────────
function setTool(name) {
s.activeTool = name;
if (s.tracksEl) {
s.tracksEl.style.cursor =
name === 'razor' ? 'crosshair' :
name === 'hand' ? 'grab' : 'default';
}
_renderClips();
}
function getTool() { return s.activeTool; }
// ── Add clip at playhead ─────────────────────────────────────────────────────
/**
* Place a clip on the timeline at the current playhead.
* @param {object} asset - must have: asset_id, display_name, duration_ms, fps (or null)
* @param {number} srcIn - source in, seconds
* @param {number} srcOut - source out, seconds
* @param {number} track - track id (default 0 = V1)
*/
function addClip(asset, srcIn, srcOut, track) {
track = track !== undefined ? track : 0;
srcIn = srcIn || 0;
srcOut = srcOut || (asset.duration_ms ? asset.duration_ms / 1000 : 10);
var assetFps = asset.fps || s.fps;
var srcInFr = TC.secondsToFrames(srcIn);
var srcOutFr = TC.secondsToFrames(srcOut);
var tlInFr = s.playheadFrames;
var tlOutFr = tlInFr + (srcOutFr - srcInFr);
var clip = {
_id: uid(),
asset_id: asset.id || asset.asset_id,
display_name: asset.display_name || asset.filename || 'Clip',
duration_ms: asset.duration_ms || null,
streamUrl: asset.streamUrl || null,
track: track,
timeline_in_frames: tlInFr,
timeline_out_frames: tlOutFr,
source_in_frames: srcInFr,
source_out_frames: srcOutFr,
};
s.clips.push(clip);
_renderClips();
// Advance playhead to end of new clip
setPlayhead(tlOutFr);
if (s.onPlayheadMoved) s.onPlayheadMoved(tlOutFr);
if (s.onClipsChanged) s.onClipsChanged(s.clips.slice());
return clip;
}
// ── Public API ───────────────────────────────────────────────────────────────
global.Timeline = {
init, render, setTool, getTool,
setPlayhead, getPlayhead,
addClip,
};
})(window);
```
- [ ] **Step 2: Commit**
```bash
git add services/web-ui/public/js/timeline.js
git commit -m "feat(ui): add DOM-based timeline engine (select, razor, playhead)"
```
---
## Task 7: Editor Page — Shell, Layout, and CSS
**Files:**
- Create: `services/web-ui/public/editor.html`
This task creates the full HTML file (layout + styles). App logic is added in Tasks 89.
- [ ] **Step 1: Create `services/web-ui/public/editor.html`**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Editor — Wild Dragon</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/common.css">
<style>
/* ── Editor layout ─────────────────────────────────────────── */
.editor-shell {
display: flex;
flex: 1;
overflow: hidden;
height: 100%;
}
/* 2-column × 2-row grid */
.editor-body {
flex: 1;
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 40vh 1fr;
grid-template-areas:
"source program"
"media timeline-panel";
overflow: hidden;
}
/* ── Monitor shared styles ─────────────────────────────────── */
.monitor {
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
overflow: hidden;
background: var(--bg-base);
}
.monitor-source { grid-area: source; }
.monitor-program { grid-area: program; border-right: none; }
.monitor-video-wrap {
flex: 1;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.monitor-video-wrap video {
max-width: 100%;
max-height: 100%;
display: block;
}
.monitor-bar {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
background: var(--bg-panel);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.monitor-tc {
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
font-family: 'Courier New', monospace;
color: var(--accent);
letter-spacing: .04em;
min-width: 96px;
}
.monitor-scrub {
flex: 1;
height: 3px;
cursor: pointer;
accent-color: var(--accent);
}
.monitor-inout {
display: flex;
gap: var(--sp-1);
}
/* ── Media panel ───────────────────────────────────────────── */
.media-panel {
grid-area: media;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
overflow: hidden;
background: var(--bg-panel);
}
.media-panel-header {
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.media-panel-title {
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-tertiary);
}
.media-asset-list {
flex: 1;
overflow-y: auto;
padding: var(--sp-1) 0;
}
.media-asset-item {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
font-size: var(--text-xs);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
white-space: nowrap;
overflow: hidden;
}
.media-asset-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.media-asset-item.active { background: var(--accent-subtle); color: var(--accent); }
.media-asset-item span { overflow: hidden; text-overflow: ellipsis; }
/* ── Timeline panel ────────────────────────────────────────── */
.timeline-panel {
grid-area: timeline-panel;
display: flex;
flex-direction: column;
overflow: hidden;
}
.timeline-toolbar {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg-panel);
}
.tl-tool-btn {
height: 26px;
padding: 0 var(--sp-2);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 500;
cursor: pointer;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.tl-tool-btn:hover { border-color: var(--accent-border); color: var(--text-primary); }
.tl-tool-btn.active { background: var(--accent-subtle); border-color: var(--accent-border); color: var(--accent); }
.tl-sep { width: 1px; height: 18px; background: var(--border); }
.tl-save-status {
margin-left: auto;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.timeline-container {
flex: 1;
overflow: hidden;
}
/* ── Topbar: sequence info ─────────────────────────────────── */
.topbar-seq-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
padding: 0 var(--sp-2);
border-radius: var(--r-sm);
transition: background var(--t-fast);
}
.topbar-seq-name:hover { background: var(--bg-hover); }
</style>
</head>
<body>
<div class="shell">
<!-- Sidebar -->
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<div class="sidebar-brand-mark">
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
</div>
<span class="sidebar-brand-name">Wild Dragon</span>
</div>
<nav class="sidebar-nav">
<a href="index.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
Library
</a>
<a href="editor.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="14" height="10" rx="1"/><path d="M1 7h14M5 3v10M5 7l3-2v4l-3-2z"/></svg>
Editor
</a>
<a href="upload.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
Ingest
</a>
<a href="recorders.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
Recorders
</a>
<a href="capture.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
Capture
</a>
<a href="jobs.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
Jobs
</a>
<div class="sidebar-section-label">Admin</div>
<a href="settings.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.2 3.2l1 1M11.8 11.8l1 1M3.2 12.8l1-1M11.8 4.2l1-1"/></svg>
Settings
</a>
<a href="users.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6"/></svg>
Users
</a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="userAvatar">?</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="userName"></div>
<div class="sidebar-user-role" id="userRole"></div>
</div>
<button class="btn btn-ghost" id="logoutBtn" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
</button>
</div>
</div>
</nav>
<!-- Main area -->
<div class="main">
<!-- Topbar -->
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Editor</span>
<span class="topbar-sep">/</span>
<select id="seqSelect" class="project-select" style="min-width:160px;" aria-label="Select sequence"></select>
<button class="btn btn-ghost btn-sm" id="newSeqBtn" title="New sequence">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M8 2v12M2 8h12"/></svg>
</button>
</div>
<div class="topbar-right">
<button class="btn btn-ghost btn-sm" id="exportEdlBtn">Export EDL</button>
<button class="btn btn-primary btn-sm" id="saveBtn">Save</button>
</div>
</header>
<!-- Editor body -->
<div class="editor-shell">
<div class="editor-body">
<!-- Source Monitor -->
<div class="monitor monitor-source">
<div class="monitor-video-wrap">
<video id="srcVideo" preload="metadata"></video>
</div>
<div class="monitor-bar">
<span class="monitor-tc" id="srcTC">00:00:00;00</span>
<input type="range" class="monitor-scrub" id="srcScrub" min="0" max="1000" value="0" step="1">
<div class="monitor-inout">
<button class="btn btn-ghost btn-sm tl-tool-btn" id="srcInBtn" title="Mark In (I)">In</button>
<button class="btn btn-ghost btn-sm tl-tool-btn" id="srcOutBtn" title="Mark Out (O)">Out</button>
</div>
<button class="btn btn-primary btn-sm" id="insertBtn" title="Insert at playhead">Insert</button>
<button class="btn btn-ghost btn-sm" id="overwriteBtn" title="Overwrite at playhead">OW</button>
</div>
</div>
<!-- Program Monitor -->
<div class="monitor monitor-program">
<div class="monitor-video-wrap">
<video id="pgmVideo" preload="auto"></video>
</div>
<div class="monitor-bar">
<button class="tl-tool-btn" id="pgmPlayBtn" style="min-width:32px;"></button>
<span class="monitor-tc" id="pgmTC">00:00:00;00</span>
<input type="range" class="monitor-scrub" id="pgmScrub" min="0" max="1000" value="0" step="1">
</div>
</div>
<!-- Media Panel -->
<div class="media-panel">
<div class="media-panel-header">
<span class="media-panel-title">Media</span>
<select id="mediaBinSel" style="font-size:var(--text-xs);height:22px;" aria-label="Filter by bin">
<option value="">All assets</option>
</select>
</div>
<div class="media-asset-list" id="mediaAssetList"></div>
</div>
<!-- Timeline Panel -->
<div class="timeline-panel">
<div class="timeline-toolbar">
<button class="tl-tool-btn active" id="toolSelect" title="Select (V)">V</button>
<button class="tl-tool-btn" id="toolRazor" title="Razor (C)">C</button>
<button class="tl-tool-btn" id="toolHand" title="Hand (H)">H</button>
<div class="tl-sep"></div>
<button class="tl-tool-btn" id="undoBtn" title="Undo (Ctrl+Z)"></button>
<button class="tl-tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)"></button>
<span class="tl-save-status" id="saveStatus"></span>
</div>
<div class="timeline-container" id="timelineContainer"></div>
</div>
</div><!-- .editor-body -->
</div><!-- .editor-shell -->
</div><!-- .main -->
</div><!-- .shell -->
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<!-- New sequence dialog -->
<div class="slide-overlay" id="seqOverlay"></div>
<div class="slide-panel" id="seqPanel">
<div class="slide-panel-header">
<span class="slide-panel-title">New sequence</span>
<button class="btn btn-ghost btn-sm" id="closeSeqPanel" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button>
</div>
<div class="slide-panel-body">
<div class="form-group">
<label class="form-label" for="newSeqName">Sequence name</label>
<input type="text" id="newSeqName" placeholder="e.g. Rough Cut v1">
</div>
</div>
<div class="slide-panel-footer">
<button class="btn btn-ghost" id="cancelSeqBtn">Cancel</button>
<button class="btn btn-primary" id="saveSeqBtn">Create</button>
</div>
</div>
<script src="js/api.js"></script>
<script src="js/timecode.js"></script>
<script src="js/timeline.js"></script>
<script>
// ════════════════════════════════════════════════════════════════
// APP STATE
// ════════════════════════════════════════════════════════════════
const state = {
projectId: null,
sequences: [],
seq: null, // current full sequence object (includes .clips[])
bins: [],
assets: [], // all assets for project
sourceAsset: null, // loaded in source monitor
srcIn: null, // seconds (null = not set)
srcOut: null, // seconds (null = not set)
pgmPlaying: false,
pgmClipIdx: -1,
pgmClips: [], // sorted V1 clips for playback
streamCache: {}, // assetId → signed URL
saveTimer: null,
history: [], // undo stack of clip arrays
historyIdx: -1,
isDirty: false,
};
// ════════════════════════════════════════════════════════════════
// INIT
// ════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(location.search);
state.projectId = params.get('project');
const openAsset = params.get('asset');
if (!state.projectId) {
// No project in URL — try to load last project from library
const pr = await getProjects();
if (pr.success && pr.data.length) state.projectId = pr.data[0].id;
}
if (!state.projectId) { toast('No project selected', 'Go to Library first', 'warning'); return; }
setupToolbar();
setupSourceMonitor();
setupProgramMonitor();
setupKeyboard();
setupNewSeqPanel();
Timeline.init(document.getElementById('timelineContainer'), {
fps: 59.94,
scale: 100,
onClipsChanged: onClipsChanged,
onPlayheadMoved: onPlayheadMoved,
});
await loadProject();
await loadSequences();
await loadMediaAssets();
if (openAsset) {
const asset = state.assets.find(a => a.id === openAsset);
if (asset) loadSourceAsset(asset);
}
});
// ════════════════════════════════════════════════════════════════
// PROJECT + SEQUENCES
// ════════════════════════════════════════════════════════════════
async function loadProject() {
const r = await getBins(state.projectId);
state.bins = r.success ? r.data : [];
const sel = document.getElementById('mediaBinSel');
sel.innerHTML = '<option value="">All assets</option>' +
state.bins.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
sel.onchange = () => loadMediaAssets();
}
async function loadSequences() {
const r = await getSequences(state.projectId);
state.sequences = r.success ? r.data : [];
renderSeqSelect();
if (state.sequences.length) await openSequence(state.sequences[0].id);
}
function renderSeqSelect() {
const sel = document.getElementById('seqSelect');
if (!state.sequences.length) {
sel.innerHTML = '<option value="">No sequences — create one</option>';
return;
}
sel.innerHTML = state.sequences.map(s =>
`<option value="${s.id}">${esc(s.name)}</option>`
).join('');
sel.onchange = () => openSequence(sel.value);
}
async function openSequence(id) {
const r = await getSequence(id);
if (!r.success) { toast('Failed to load sequence', r.error, 'error'); return; }
state.seq = r.data;
// Push initial state onto undo stack
state.history = [cloneClips(state.seq.clips)];
state.historyIdx = 0;
document.getElementById('seqSelect').value = id;
Timeline.render(state.seq.clips, { fps: 59.94 });
updateProgramScrub();
}
// ════════════════════════════════════════════════════════════════
// MEDIA PANEL
// ════════════════════════════════════════════════════════════════
async function loadMediaAssets() {
const filters = { project_id: state.projectId };
const binId = document.getElementById('mediaBinSel').value;
if (binId) filters.bin_id = binId;
const r = await getAssets(filters);
state.assets = r.success ? r.data : [];
renderMediaList();
}
function renderMediaList() {
const list = document.getElementById('mediaAssetList');
list.innerHTML = '';
state.assets.forEach(asset => {
const el = document.createElement('div');
el.className = 'media-asset-item';
el.innerHTML = `
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" width="12" height="12">
<rect x="1" y="3" width="8" height="8" rx="1"/>
<path d="M9 6l4-2v6l-4-2"/>
</svg>
<span title="${esc(asset.display_name || asset.filename)}">${esc(asset.display_name || asset.filename)}</span>`;
el.ondblclick = () => loadSourceAsset(asset);
el.onclick = () => {
list.querySelectorAll('.media-asset-item').forEach(e => e.classList.remove('active'));
el.classList.add('active');
};
list.appendChild(el);
});
}
// ════════════════════════════════════════════════════════════════
// SOURCE MONITOR
// ════════════════════════════════════════════════════════════════
function setupSourceMonitor() {
const vid = document.getElementById('srcVideo');
const scrub = document.getElementById('srcScrub');
vid.addEventListener('timeupdate', () => {
document.getElementById('srcTC').textContent =
TC.framesToTC(TC.secondsToFrames(vid.currentTime));
if (!vid.duration) return;
scrub.value = Math.round((vid.currentTime / vid.duration) * 1000);
updateSrcInOutMarkers();
});
scrub.addEventListener('input', () => {
if (!vid.duration) return;
vid.currentTime = (scrub.value / 1000) * vid.duration;
});
document.getElementById('srcInBtn').onclick = markSrcIn;
document.getElementById('srcOutBtn').onclick = markSrcOut;
document.getElementById('insertBtn').onclick = doInsert;
document.getElementById('overwriteBtn').onclick = doOverwrite;
}
async function loadSourceAsset(asset) {
state.sourceAsset = asset;
state.srcIn = null;
state.srcOut = null;
const vid = document.getElementById('srcVideo');
vid.src = '';
// Get signed stream URL
let url = state.streamCache[asset.id];
if (!url) {
const r = await getAssetStreamUrl(asset.id);
if (!r.success || !r.data?.url) { toast('No proxy available', '', 'warning'); return; }
url = r.data.url;
state.streamCache[asset.id] = url;
}
vid.src = url;
vid.load();
updateSrcInOutMarkers();
}
function markSrcIn() {
const vid = document.getElementById('srcVideo');
state.srcIn = vid.currentTime;
updateSrcInOutMarkers();
}
function markSrcOut() {
const vid = document.getElementById('srcVideo');
state.srcOut = vid.currentTime;
updateSrcInOutMarkers();
}
function updateSrcInOutMarkers() {
const inBtn = document.getElementById('srcInBtn');
const outBtn = document.getElementById('srcOutBtn');
inBtn.title = state.srcIn != null ? `In: ${TC.framesToTC(TC.secondsToFrames(state.srcIn))}` : 'Mark In (I)';
outBtn.title = state.srcOut != null ? `Out: ${TC.framesToTC(TC.secondsToFrames(state.srcOut))}` : 'Mark Out (O)';
}
function doInsert() { _addToTimeline(false); }
function doOverwrite() { _addToTimeline(true); }
function _addToTimeline(overwrite) {
if (!state.sourceAsset) { toast('Load a clip first', '', 'warning'); return; }
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
const vid = document.getElementById('srcVideo');
const srcIn = state.srcIn != null ? state.srcIn : 0;
const srcOut = state.srcOut != null ? state.srcOut : (vid.duration || (state.sourceAsset.duration_ms || 10000) / 1000);
Timeline.addClip(
Object.assign({}, state.sourceAsset, { streamUrl: state.streamCache[state.sourceAsset.id] }),
srcIn, srcOut, 0 /* V1 */
);
// onClipsChanged will fire and trigger auto-save
}
// ════════════════════════════════════════════════════════════════
// PROGRAM MONITOR
// ════════════════════════════════════════════════════════════════
function setupProgramMonitor() {
const vid = document.getElementById('pgmVideo');
const scrub = document.getElementById('pgmScrub');
const btn = document.getElementById('pgmPlayBtn');
vid.addEventListener('timeupdate', onPgmTimeUpdate);
vid.addEventListener('ended', onPgmEnded);
scrub.addEventListener('input', () => {
if (!state.pgmClips.length) return;
const totalDuration = pgmTotalDuration();
if (totalDuration <= 0) return;
const targetSecs = (scrub.value / 1000) * totalDuration;
seekPgmToSeconds(targetSecs);
});
btn.onclick = togglePgmPlay;
}
function pgmTotalDuration() {
if (!state.pgmClips.length) return 0;
const last = state.pgmClips[state.pgmClips.length - 1];
return TC.framesToSeconds(last.timeline_out_frames);
}
function onPgmTimeUpdate() {
if (!state.pgmPlaying) return;
const vid = document.getElementById('pgmVideo');
const clip = state.pgmClips[state.pgmClipIdx];
if (!clip) return;
const srcOutSecs = TC.framesToSeconds(clip.source_out_frames);
if (vid.currentTime >= srcOutSecs) {
loadNextPgmClip();
return;
}
// Update timeline playhead
const elapsed = vid.currentTime - TC.framesToSeconds(clip.source_in_frames);
const tlFrames = clip.timeline_in_frames + TC.secondsToFrames(elapsed);
Timeline.setPlayhead(tlFrames);
// Update TC display + scrub
document.getElementById('pgmTC').textContent = TC.framesToTC(tlFrames);
const total = pgmTotalDuration();
if (total > 0)
document.getElementById('pgmScrub').value =
Math.round((TC.framesToSeconds(tlFrames) / total) * 1000);
}
function onPgmEnded() {
loadNextPgmClip();
}
async function loadNextPgmClip() {
state.pgmClipIdx++;
const clip = state.pgmClips[state.pgmClipIdx];
if (!clip) { stopPgm(); return; }
await _loadClipToPgm(clip);
document.getElementById('pgmVideo').play().catch(() => {});
}
async function _loadClipToPgm(clip) {
let url = state.streamCache[clip.asset_id];
if (!url) {
const r = await getAssetStreamUrl(clip.asset_id);
if (!r.success || !r.data?.url) return;
url = state.streamCache[clip.asset_id] = r.data.url;
}
const vid = document.getElementById('pgmVideo');
if (vid.src !== url) { vid.src = url; vid.load(); }
await new Promise(res => {
if (vid.readyState >= 1) { res(); return; }
vid.addEventListener('loadedmetadata', res, { once: true });
});
vid.currentTime = TC.framesToSeconds(clip.source_in_frames);
}
async function togglePgmPlay() {
if (state.pgmPlaying) { stopPgm(); return; }
// Build V1 clip list from current timeline state
if (!state.seq) return;
state.pgmClips = (state.seq.clips || [])
.filter(c => c.track === 0)
.sort((a, b) => a.timeline_in_frames - b.timeline_in_frames);
if (!state.pgmClips.length) return;
// Find clip at current playhead
const ph = Timeline.getPlayhead();
state.pgmClipIdx = state.pgmClips.findIndex(
c => ph >= c.timeline_in_frames && ph < c.timeline_out_frames
);
if (state.pgmClipIdx < 0) state.pgmClipIdx = 0;
state.pgmPlaying = true;
document.getElementById('pgmPlayBtn').textContent = '⏸';
const clip = state.pgmClips[state.pgmClipIdx];
await _loadClipToPgm(clip);
document.getElementById('pgmVideo').play().catch(() => {});
}
function stopPgm() {
state.pgmPlaying = false;
state.pgmClipIdx = -1;
document.getElementById('pgmPlayBtn').textContent = '▶';
document.getElementById('pgmVideo').pause();
}
function seekPgmToSeconds(targetSecs) {
stopPgm();
const targetFrames = TC.secondsToFrames(targetSecs);
Timeline.setPlayhead(targetFrames);
document.getElementById('pgmTC').textContent = TC.framesToTC(targetFrames);
}
function updateProgramScrub() {
document.getElementById('pgmScrub').value = 0;
document.getElementById('pgmTC').textContent = '00:00:00;00';
}
// ════════════════════════════════════════════════════════════════
// TIMELINE CALLBACKS
// ════════════════════════════════════════════════════════════════
function onClipsChanged(clips) {
if (!state.seq) return;
// Trim history forward
state.history = state.history.slice(0, state.historyIdx + 1);
state.history.push(cloneClips(clips));
state.historyIdx = state.history.length - 1;
state.seq.clips = clips;
markDirty();
}
function onPlayheadMoved(frames) {
document.getElementById('pgmTC').textContent = TC.framesToTC(frames);
const total = pgmTotalDuration();
if (total > 0)
document.getElementById('pgmScrub').value =
Math.round((TC.framesToSeconds(frames) / total) * 1000);
}
// ════════════════════════════════════════════════════════════════
// AUTO-SAVE
// ════════════════════════════════════════════════════════════════
function markDirty() {
state.isDirty = true;
setSaveStatus('Unsaved');
clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(saveSequence, 2000);
}
async function saveSequence() {
if (!state.seq || !state.isDirty) return;
clearTimeout(state.saveTimer);
setSaveStatus('Saving…');
const clips = (state.seq.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,
}));
const r = await syncSequenceClips(state.seq.id, clips);
if (r.success) {
state.isDirty = false;
setSaveStatus('Saved');
setTimeout(() => setSaveStatus(''), 2000);
} else {
setSaveStatus('Save failed');
toast('Save failed', r.error, 'error');
}
}
function setSaveStatus(msg) {
document.getElementById('saveStatus').textContent = msg;
}
// ════════════════════════════════════════════════════════════════
// UNDO / REDO
// ════════════════════════════════════════════════════════════════
function cloneClips(clips) {
return clips.map(c => Object.assign({}, c));
}
function undo() {
if (state.historyIdx <= 0) return;
state.historyIdx--;
applyHistory();
}
function redo() {
if (state.historyIdx >= state.history.length - 1) return;
state.historyIdx++;
applyHistory();
}
function applyHistory() {
const clips = cloneClips(state.history[state.historyIdx]);
state.seq.clips = clips;
Timeline.render(clips, { fps: 59.94 });
markDirty();
}
// ════════════════════════════════════════════════════════════════
// TOOLBAR + KEYBOARD
// ════════════════════════════════════════════════════════════════
function setupToolbar() {
const btns = {
select: document.getElementById('toolSelect'),
razor: document.getElementById('toolRazor'),
hand: document.getElementById('toolHand'),
};
Object.entries(btns).forEach(([tool, btn]) => {
btn.onclick = () => {
Object.values(btns).forEach(b => b.classList.remove('active'));
btn.classList.add('active');
Timeline.setTool(tool);
};
});
document.getElementById('undoBtn').onclick = undo;
document.getElementById('redoBtn').onclick = redo;
document.getElementById('saveBtn').onclick = saveSequence;
document.getElementById('exportEdlBtn').onclick = () => {
if (!state.seq) { toast('No sequence open', '', 'warning'); return; }
exportSequenceEDL(state.seq.id, (state.seq.name || 'sequence') + '.edl');
};
}
function setupKeyboard() {
document.addEventListener('keydown', (e) => {
const tag = document.activeElement.tagName;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
// Transport
if (e.code === 'Space') { e.preventDefault(); togglePgmPlay(); return; }
if (e.key === 'j' || e.key === 'J') { stopPgm(); return; } // J = stop (full Premiere J/K/L would need reverse)
if (e.key === 'k' || e.key === 'K') { stopPgm(); return; }
if (e.key === 'l' || e.key === 'L') { togglePgmPlay(); return; }
// In/Out marking
if (e.key === 'i' || e.key === 'I') { markSrcIn(); return; }
if (e.key === 'o' || e.key === 'O') { markSrcOut(); return; }
// Undo/Redo
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') { e.preventDefault(); redo(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveSequence(); return; }
// Tool sync (Timeline handles V/C/H internally; keep toolbar in sync)
if (e.key === 'v' || e.key === 'V') updateToolbarActive('select');
if (e.key === 'c' || e.key === 'C') updateToolbarActive('razor');
if (e.key === 'h' || e.key === 'H') updateToolbarActive('hand');
});
}
function updateToolbarActive(tool) {
const map = { select: 'toolSelect', razor: 'toolRazor', hand: 'toolHand' };
Object.values(map).forEach(id => document.getElementById(id).classList.remove('active'));
document.getElementById(map[tool])?.classList.add('active');
}
// ════════════════════════════════════════════════════════════════
// NEW SEQUENCE PANEL
// ════════════════════════════════════════════════════════════════
function setupNewSeqPanel() {
document.getElementById('newSeqBtn').onclick = () => openPanel('seq');
document.getElementById('closeSeqPanel').onclick = () => closePanel('seq');
document.getElementById('cancelSeqBtn').onclick = () => closePanel('seq');
document.getElementById('seqOverlay').onclick = () => closePanel('seq');
document.getElementById('saveSeqBtn').onclick = createNewSequence;
}
async function createNewSequence() {
const name = document.getElementById('newSeqName').value.trim() || 'Sequence ' + (state.sequences.length + 1);
const r = await createSequence({ project_id: state.projectId, name });
if (!r.success) { toast('Failed to create sequence', r.error, 'error'); return; }
closePanel('seq');
document.getElementById('newSeqName').value = '';
state.sequences.unshift(r.data);
renderSeqSelect();
await openSequence(r.data.id);
toast('Sequence created', name, 'success');
}
function openPanel(name) {
document.getElementById(name + 'Panel').classList.add('open');
document.getElementById(name + 'Overlay').classList.add('open');
}
function closePanel(name) {
document.getElementById(name + 'Panel').classList.remove('open');
document.getElementById(name + 'Overlay').classList.remove('open');
}
// ════════════════════════════════════════════════════════════════
// UTILITIES
// ════════════════════════════════════════════════════════════════
function toast(title, msg, type) {
type = type || 'info';
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg ? `<div class="toast-msg">${esc(msg)}</div>` : ''}</div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>
```
- [ ] **Step 2: Commit**
```bash
git add services/web-ui/public/editor.html
git commit -m "feat(ui): add NLE editor page (editor.html)"
```
---
## Task 8: Library Integration
**Files:**
- Modify: `services/web-ui/public/index.html`
- [ ] **Step 1: Add Editor nav link to the sidebar** — find the `<a href="upload.html"` nav item and insert this immediately before it:
```html
<a href="editor.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="14" height="10" rx="1"/><path d="M1 7h14M5 3v10M5 7l3-2v4l-3-2z"/></svg>
Editor
</a>
```
- [ ] **Step 2: Add "Open in Editor" button to asset cards** — in `index.html`, find the `deleteAssetPrompt` button inside `card.innerHTML`. Change the `asset-actions` div to add a second button:
```html
<div class="asset-actions">
<button class="asset-action-btn" onclick="openInEditor('${asset.id}', event)" title="Open in Editor">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="14" height="10" rx="1"/><path d="M1 7h14M5 3v10M5 7l3-2v4l-3-2z"/></svg>
</button>
<button class="asset-action-btn" onclick="deleteAssetPrompt('${asset.id}', event)" title="Delete">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg>
</button>
</div>
```
- [ ] **Step 3: Add the `openInEditor` function** in `index.html`'s `<script>` block, after the `deleteAssetPrompt` function:
```js
function openInEditor(assetId, e) {
e.stopPropagation();
const projectId = state.currentProjectId;
if (!projectId) { toast('Select a project first', '', 'warning'); return; }
location.href = `editor.html?project=${projectId}&asset=${assetId}`;
}
```
- [ ] **Step 4: Commit**
```bash
git add services/web-ui/public/index.html
git commit -m "feat(ui): add Open in Editor action to library cards"
```
---
## Task 9: End-to-End Smoke Test
No code changes — verification only.
- [ ] **Step 1: Open the editor directly**
Navigate to `http://localhost:47434/editor.html?project=<a-known-project-id>`.
Expected:
- 4-panel layout renders (source monitor top-left, program monitor top-right, media panel bottom-left, timeline bottom-right)
- Sidebar shows "Editor" nav item (active)
- Sequence selector shows existing sequences or "No sequences"
- Media panel shows assets for the project
- [ ] **Step 2: Create a sequence via the UI**
Click the `+` button next to the sequence selector → type "Test Sequence" → Create.
Expected:
- Sequence appears in the select dropdown
- No console errors
- `curl -s -b /tmp/wd.cookies "http://localhost:47432/api/v1/sequences?project_id=$PROJECT_ID" | jq '.[].name'` → shows `"Test Sequence"`
- [ ] **Step 3: Load a clip into the source monitor**
Double-click any asset in the media panel.
Expected:
- Video element in source monitor loads (proxy plays)
- Timecode display updates as video plays
- `In` / `Out` buttons are visible
- [ ] **Step 4: Mark in/out and insert to timeline**
Play the source video a few seconds. Press `I` for in. Advance a few more seconds. Press `O` for out. Click `Insert`.
Expected:
- A colored clip block appears on V1 in the timeline at frame 0
- Timeline auto-saves within 2 seconds (`Saved` appears in toolbar)
- `curl -s -b /tmp/wd.cookies "http://localhost:47432/api/v1/sequences/$SEQ_ID" | jq '.clips | length'``1`
- [ ] **Step 5: Test razor tool**
Press `C` to activate razor. Click in the middle of the clip on the timeline.
Expected:
- Clip splits into two pieces at the click point
- Both pieces are still on V1
- Auto-save fires
- [ ] **Step 6: Test undo**
Press `Ctrl+Z`.
Expected:
- The two split clips merge back into one clip
- [ ] **Step 7: Test EDL export**
Click "Export EDL" in the topbar.
Expected:
- Browser downloads a `.edl` file
- File contents look like:
```
TITLE: Test Sequence
001 FILENAME V C 00:00:00;00 00:00:05;00 00:00:00;00 00:00:05;00
```
- [ ] **Step 8: Test program monitor playback**
With at least one clip on the timeline, click the `▶` button in the program monitor.
Expected:
- Video loads and plays in the program monitor
- Timecode display updates
- Timeline playhead moves in sync
- [ ] **Step 9: Test library → editor navigation**
Go to `http://localhost:47434/index.html`. Hover over an asset card.
Expected:
- Two action buttons appear (pencil/editor icon + trash icon)
- Clicking the editor icon navigates to `editor.html?project=...&asset=...`
- The asset auto-loads into the source monitor
- [ ] **Step 10: Final commit (tag the Phase 1 completion)**
```bash
git tag -a v0.phase1-editor -m "Phase 1: NLE Editor complete"
git push origin main --tags
```
---
## Self-Review Checklist
**Spec coverage:**
- [x] New `sequences` + `sequence_clips` DB tables — Task 1
- [x] Sequences CRUD API — Task 2
- [x] Clip sync endpoint (`PUT /clips`) — Task 2
- [x] EDL export endpoint — Task 2
- [x] Register route in index.js — Task 3
- [x] `api.js` sequence helpers — Task 4
- [x] 59.94 DF timecode utility — Task 5
- [x] Timeline DOM engine — Task 6
- [x] Select tool (drag-move, trim edges) — Task 6
- [x] Razor tool (click-to-split) — Task 6
- [x] Keyboard shortcuts V/C/H, Delete — Task 6 + Task 7
- [x] 4-panel editor layout + CSS — Task 7
- [x] Source monitor (video, in/out, Insert/Overwrite) — Task 7
- [x] Program monitor (virtual clip-by-clip playback) — Task 7
- [x] Multiple named sequences per project — Task 7 (sequence picker dropdown)
- [x] Auto-save debounced 2s — Task 7
- [x] Undo/redo (local history stack) — Task 7
- [x] "Open in Editor" on library cards — Task 8
- [x] Editor sidebar nav link — Task 8
- [x] Frame rate: 59.94 drop-frame — throughout
**Not in this plan (Phase 2 and Phase 3):**
- Growing file / HLS live preview → separate plan
- Player rebuild → Phase 3 P1
- Subclips → Phase 3 P2
- Multi-select bulk ops → Phase 3 P3
- Waveform display → Phase 3 P4
- Timecoded comments → Phase 3 P5