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

80 KiB
Raw Permalink Blame History

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

-- 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
# 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
docker compose exec db psql -U wilddragon -d wilddragon \
  -c "\dt sequences" -c "\dt sequence_clips"

Expected: both tables listed.

  • Step 4: Commit
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

// 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
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)

import sequencesRouter from './routes/sequences.js';
  • Step 2: Mount the route (after the tokensRouter mount, before the errorHandler line)
app.use('/api/v1/sequences', requireAuth, sequencesRouter);
  • Step 3: Restart the API container
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):

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:

curl -s -b /tmp/wd.cookies http://localhost:47432/api/v1/projects | jq '.[0].id'

Copy the UUID, then:

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", ... }
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
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):

// ============================================================
// 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
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

// 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:

// 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
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

// 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
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
<!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
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:

      <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:
          <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:
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
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)

git tag -a v0.phase1-editor -m "Phase 1: NLE Editor complete"
git push origin main --tags

Self-Review Checklist

Spec coverage:

  • New sequences + sequence_clips DB tables — Task 1
  • Sequences CRUD API — Task 2
  • Clip sync endpoint (PUT /clips) — Task 2
  • EDL export endpoint — Task 2
  • Register route in index.js — Task 3
  • api.js sequence helpers — Task 4
  • 59.94 DF timecode utility — Task 5
  • Timeline DOM engine — Task 6
  • Select tool (drag-move, trim edges) — Task 6
  • Razor tool (click-to-split) — Task 6
  • Keyboard shortcuts V/C/H, Delete — Task 6 + Task 7
  • 4-panel editor layout + CSS — Task 7
  • Source monitor (video, in/out, Insert/Overwrite) — Task 7
  • Program monitor (virtual clip-by-clip playback) — Task 7
  • Multiple named sequences per project — Task 7 (sequence picker dropdown)
  • Auto-save debounced 2s — Task 7
  • Undo/redo (local history stack) — Task 7
  • "Open in Editor" on library cards — Task 8
  • Editor sidebar nav link — Task 8
  • 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