2246 lines
80 KiB
Markdown
2246 lines
80 KiB
Markdown
# 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 (0–5)
|
||
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 8–9.
|
||
|
||
- [ ] **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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
</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
|