This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
1. Primary Request and Intent:
   The user said (before sleeping): "we are going to be working on https://forge.wilddragon.net/zgaetano/wild-dragon - Main branch, I am going to bed. I would like you to continue to test, and develop new features for this system."

   This is an autonomous overnight development session on the Wild Dragon MAM (Media Asset Management) platform. The assistant was systematically reading files, finding bugs, and committing fixes across the entire codebase.

2. Key Technical Concepts:
   - **Wild Dragon MAM / Z-AMPP**: Self-hosted broadcast media asset management platform replacing Grass Valley AMPP FramelightX
   - **Services**: mam-api (Express/Node), web-ui (Nginx static), worker (BullMQ), capture (FFmpeg/DeckLink)
   - **PostgreSQL 16**: Asset/project/sequence/recorder metadata with session store
   - **Redis 7 + BullMQ**: Async job queues (proxy, thumbnail, conform)
   - **S3-compatible storage (RustFS)**: Media file storage with signed URLs
   - **FFmpeg/ffprobe**: Media transcoding, thumbnail extraction
   - **Docker socket API**: Dynamic recorder container management via Unix socket `/var/run/docker.sock`
   - **Forgejo MCP**: Used to read/update files in the repository without cloning
   - **59.94 drop-frame timecode**: Throughout editor (CMX3600 EDL export, timeline)
   - **BullMQ job states**: waiting, active, completed, failed, delayed, paused
   - **IntersectionObserver**: Lazy thumbnail loading in library
   - **Session-based auth**: `express-session` + `connect-pg-simple`, cookie with `credentials: 'include'`
   - **Schema auto-migrations**: SQL files in `services/mam-api/src/db/migrations/` run idempotently on API startup
   - **Two editors**: `edit.html` (Phase A simple single-track) and `editor.html` (full NLE with `timeline.js`)

3. Files and Code Sections:

   - **`services/mam-api/src/routes/assets.js`** (MODIFIED - commit e6314be)
     - The `GET /` list route used `COUNT(*) OVER() AS full_count` as a window function to get total, but leaked this internal column in every row of the response
     - Fix: strip with destructuring before sending
     ```js
     res.json({
       assets: result.rows.map(({ full_count, ...rest }) => rest),
       total,
     });
     ```

   - **`services/web-ui/public/index.html`** (MODIFIED - commit e472075)
     - Thumbnail `setImgSrc()` had no error handler — when a signed S3 URL expired and the `<img>` fired `onerror`, the stale URL stayed in `state.thumbCache` forever
     - Fix: added onerror to evict cache entry, remove `loaded` class, and re-add to IntersectionObserver for retry on next viewport entry
     ```js
     function setImgSrc(img, src) {
       img.src = src;
       img.onload  = () => img.classList.add('loaded');
       img.onerror = () => {
         delete state.thumbCache[img.dataset.assetId];
         img.classList.remove('loaded');
         thumbObserver.observe(img);
       };
     }
     ```

   - **`services/web-ui/public/jobs.html`** (MODIFIED - commit 08e5ba6)
     - Two bugs found:
       1. `killJob()` called `fetchJobs()` which doesn't exist — the function is `loadJobs()`
       2. The inline `api()` helper was missing `credentials: 'include'`, causing 401 errors in production
     - Fix 1: `fetchJobs()` → `loadJobs()` in killJob
     - Fix 2:
     ```js
     async function api(path, opts = {}) {
       const r = await fetch(API + path, {
         credentials: 'include',  // added
         headers: { 'Content-Type': 'application/json' },
         ...opts
       });
     ```

   - **`services/web-ui/public/js/api.js`** (READ - no changes)
     - Confirmed auth mechanism uses `credentials: 'include'` and cookie-based sessions
     - Contains all API helper functions including `patchRecorder` added in previous session

   - **`services/web-ui/public/capture.html`** (MODIFIED - commit ff892a1)
     - `renderRecent()` used `c.duration` which is always undefined — asset schema stores milliseconds as `duration_ms`
     - Also `c.clip_name` fallback is not a real field, corrected to `c.display_name`
     - Fix:
     ```js
     // Before:
     <td>${c.duration ? formatDuration(c.duration) : '—'}</td>
     // After:
     <td>${c.duration_ms ? formatDuration(c.duration_ms / 1000) : '—'}</td>
     ```
     ```js
     // Before:
     esc(c.filename || c.clip_name || 'untitled')
     // After:
     esc(c.display_name || c.filename || 'untitled')
     ```

   - **`services/worker/src/workers/thumbnail.js`** (MODIFIED - commit fb3b998)
     - Critical bug: when thumbnail extraction failed (ffmpeg error, network blip, etc.), the catch block threw without setting asset status. Asset stayed in `processing` state forever since the thumbnail worker is the only thing that sets `status='ready'`.
     - Since the proxy already exists, the asset is playable — it should become `ready` even without a thumbnail.
     - Fix added to catch block:
     ```js
     } catch (error) {
       console.error(`[thumbnail] Error processing asset ${assetId}:`, error);
       // Thumbnail failed but the asset is still usable via proxy — mark it ready
       // so it doesn't stay in 'processing' state forever.
       await query(
         `UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`,
         [assetId]
       ).catch(e => console.error('[thumbnail] Failed to update asset status:', e));
       throw error;
     }
     ```

   - **`services/worker/src/workers/proxy.js`** (READ - no changes)
     - Downloads source → transcodes → uploads proxy → updates asset metadata (fps, codec, resolution, duration_ms, file_size) → queues thumbnail job
     - Sets status='error' on failure (correct)
     - Does NOT set status='ready' — relies on thumbnail worker to do that (correct flow)

   - **`services/worker/src/workers/conform.js`** (READ - no changes)
     - Parses EDL → downloads source clips → trims segments → concatenates → uploads output
     - Solid implementation, no issues found

   - **`services/mam-api/src/routes/sequences.js`** (READ - no changes)
     - Correct 59.94 drop-frame timecode math (FRAMES_PER_HOUR = 215784)
     - GET /:id returns sequence with clips joined to assets, signed stream URLs attached
     - PUT /:id/clips is a full atomic replace in a transaction (correct approach)
     - POST /:id/export/edl generates CMX3600 EDL for V1 track only

   - **`services/mam-api/src/index.js`** (READ - no changes)
     - All routes wired correctly
     - Migration runner reads SQL files from `services/mam-api/src/db/migrations/` sorted alphabetically, runs idempotently
     - Session-based auth with PostgreSQL session store

   - **`services/mam-api/src/db/migrations/003-editor-sequences.sql`** (READ - no changes)
     - Creates `sequences` and `sequence_clips` tables with proper indexes
     - Idempotent: uses `IF NOT EXISTS` and `DO $ BEGIN` guards throughout
     - Constraints: `uq_sequences_project_name`, `chk_timeline_range`, `chk_source_range`, `chk_track_valid`

   - **`services/web-ui/public/editor.html`** (MODIFIED - commit 0f37d01)
     - Keyboard shortcuts V/C/H called `updateToolbarActive()` which only updated button visual state (CSS class) but never called `Timeline.setTool()`, so pressing a key shortcut didn't actually change the active tool
     - Also had stale api.js reference without version string
     - Fix: Added `Timeline.setTool(tool)` call inside `updateToolbarActive()`:
     ```js
     function updateToolbarActive(tool) {
       const map = { select: 'toolSelect', razor: 'toolRazor', hand: 'toolHand' };
       Object.keys(map).forEach(function(k) {
         document.getElementById(map[k]).classList.remove('active');
       });
       if (map[tool]) document.getElementById(map[tool]).classList.add('active');
       Timeline.setTool(tool);  // added
     }
     ```
     - Changed `<script src="js/api.js">` → `<script src="js/api.js?v=6">`

   - **`services/mam-api/src/routes/recorders.js`** (MODIFIED - commit b23700f)
     - The `/start` route contained `const assetIdLive = (await import('uuid')).v4()` — a dynamic module import inside a request handler, even though `uuidv4` was already imported statically at the top of the file
     - Fix:
     ```js
     // Before:
     const assetIdLive = (await import('uuid')).v4();
     // After:
     const assetIdLive = uuidv4();
     ```

4. Errors and Fixes:
   - **assets.js leaking `full_count`**: Window function column leaked in every asset row. Fixed by mapping rows to strip the field before responding.
   - **Thumbnail cache stale URL**: No onerror handler meant expired S3 URLs were cached forever. Fixed with eviction + re-observe.
   - **jobs.html `fetchJobs()` undefined**: `killJob()` called non-existent `fetchJobs()`. Fixed to `loadJobs()`.
   - **jobs.html auth failure**: Inline `api()` wrapper missing `credentials: 'include'`. Fixed.
   - **capture.html duration always `—`**: `c.duration` is undefined on asset objects; the field is `duration_ms`. Fixed to `c.duration_ms / 1000`.
   - **thumbnail worker stuck in processing**: On failure, asset stayed in `processing` state forever. Fixed by setting `status='ready'` in catch block before re-throwing.
   - **editor keyboard shortcuts non-functional**: V/C/H shortcuts updated toolbar visually but didn't call `Timeline.setTool()`. Fixed.
   - **recorders.js unnecessary dynamic import**: `(await import('uuid')).v4()` inside route handler when `uuidv4` already imported statically. Fixed to use `uuidv4()`.

5. Problem Solving:
   - Systematically reviewed every major file in the codebase: web-UI pages, API routes, worker services, migrations
   - Identified bugs ranging from critical (thumbnail worker stuck-in-processing) to functional (keyboard shortcuts don't work) to cosmetic (duration display)
   - All fixes are minimal, targeted, and include accurate commit messages explaining WHY the change was needed
   - The `credentials: 'include'` issue in jobs.html is particularly important as it would cause silent auth failures in production where cookies are required

6. All User Messages:
   - "we are going to be working on https://forge.wilddragon.net/zgaetano/wild-dragon - Main branch, I am going to bed. I would like you to continue to test, and develop new features for this system." (from previous session)
   - "Tool loaded." (response to first ToolSearch for forgejo tools)
   - "Tool loaded." (response to second ToolSearch for forgejo tools)
   - "CRITICAL: Respond with TEXT ONLY..." (current summary request)

7. Pending Tasks:
   - Continue systematic review of remaining pages: `users.html`, `tokens.html`, `projects.html`, `recorders.html` (already edited in previous session), `upload.html`
   - Review `js/timeline.js` for editor timeline logic issues
   - Review `js/timecode.js` for timecode math
   - Review remaining API routes: `bins.js`, `upload.js`, `auth.js`, `users.js`, `tokens.js`
   - Look for any feature gaps or new features that would improve the platform
   - The user's original ask was "continue to test, and develop new features" — exploration should continue

8. Current Work:
   The last action was committing a fix to `services/mam-api/src/routes/recorders.js` (commit b23700f) to replace a redundant dynamic import with the already-imported static `uuidv4` function. Before that, the migration file `003-editor-sequences.sql` was read and verified as correct. The session was in a systematic code review phase, going file by file through the codebase looking for bugs.

   Seven commits were made in this session:
   1. e6314be - assets.js `full_count` leak
   2. e472075 - index.html thumbnail stale URL
   3. 08e5ba6 - jobs.html auth + undefined function
   4. ff892a1 - capture.html duration field
   5. fb3b998 - thumbnail worker stuck-in-processing
   6. 0f37d01 - editor keyboard shortcuts
   7. b23700f - recorders.js dynamic import

9. Optional Next Step:
   Continue the autonomous review. The next logical areas to check are files not yet examined:
   - `services/web-ui/public/js/timeline.js` — the core editor timeline module that `editor.html` depends on (Timeline.init, Timeline.render, Timeline.addClip, Timeline.setTool, Timeline.setPlayhead, Timeline.getPlayhead)
   - `services/web-ui/public/js/timecode.js` — TC library used throughout editor for 59.94 DF timecode conversion
   - `services/web-ui/public/projects.html` — projects management page not yet reviewed
   - `services/web-ui/public/recorders.html` — previously edited in old session, worth verifying full state after edits

   The user asked: "I would like you to continue to test, and develop new features for this system." The timeline.js is the most complex and highest-value JS module to review next, as bugs there would affect every editor operation.

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /home/node/.claude/projects/-home-node/e59aae98-44b9-4cb7-8cf4-3f02287a5c47.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


## claude.ai Context7
Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.

Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.


we are going to be working on https://forge.wilddragon.net/zgaetano/wild-dragon  Main branch, I am going to bed. I would like you to continue to test, and develop new features for this system.


Let me continue the autonomous development session. Based on the summary, I've been systematically reviewing and fixing bugs in the Wild Dragon MAM platform. The last commit was to recorders.js.

Next areas to check:
1. `services/web-ui/public/js/timeline.js` - core editor timeline module
2. `services/web-ui/public/js/timecode.js` - TC library
3. `services/web-ui/public/projects.html` - projects management page
4. Other unchecked files

Let me use the Forgejo MCP tools to read these files.

I need to load the tool schemas first to see what operations are available.


ToolSearch

Question: select:mcp__claude_ai_mcp-server__forgejo_forgejo_get_file,mcp__claude_ai_mcp-server__forgejo_forgejo_list_directory,mcp__claude_ai_mcp-server__forgejo_forgejo_update_file


Question: select:mcp__claude_ai_mcp-server__forgejo_forgejo_get_file,mcp__claude_ai_mcp-server__forgejo_forgejo_list_directory,mcp__claude_ai_mcp-server__forgejo_forgejo_update_file


Good, the tools are loaded. Let me continue the systematic review. I'll start with `timeline.js` and `timecode.js` simultaneously since they're independent reads.


File: services/web-ui/public/js/timeline.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.removeEventListener('keydown', _onKeyDown);
    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 ─────────────────────────────────────────────────────

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


File: services/web-ui/public/js/timecode.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);


Let me review both files for bugs.

**timecode.js:**
- FPS_EXACT = 60000/1001 ≈ 59.9400599...
- NOM = 60 (nominal integer fps)
- DROP = 4 (frames dropped per minute except every 10th)
- FRAMES_PER_MIN = 60*60 - 4 = 3596 ✓
- FRAMES_PER_10MIN = 3596*10 + 4 = 35964 ✓

- FRAMES_PER_HOUR = 35964*6 = 215784 ✓

Now checking the framesToTC() function logic: it divides the total frames into hours, then processes the remainder to extract tens of minutes, individual minutes, seconds, and frames, accounting for the drop frame pattern where frames are skipped except at 10-minute boundaries.

Let me trace through some test cases to verify this is working correctly. At frame 0, everything zeros out to 00:00:00;00, which is right. But at frame 4, I'm getting M=1 when it should stay at 00:00:00;04—something's off with how I'm handling the drop frame logic. I'm verifying the frame calculation: frame 3600 maps to 00:01:00;04, and frame 7196 corresponds to 00:02:00;04. Let me confirm the math—a 10-minute block contains 3600 frames for minute 0 (no drops) plus 9 × 3596 frames for minutes 1-9 (with 4-frame drops each), totaling 35964 frames. Now I need to work out the conversion algorithm: compute the hours first, then handle the remaining frames by determining which 10-minute segment they fall into.

Checking the logic for handling frames within a 10-minute group...

I'm realizing there's a potential bug here. When rem=4, the code would incorrectly calculate m=1, but frame 4 should still be in minute 0 of the group. Let me trace through the algorithm more carefully, starting with how frame 3600 (the first frame of minute 1) should be handled.

The issue is that my current approach isn't accounting for the structure of drop-frame timecode correctly. In a 10-minute block, there's one non-drop minute with 3600 frames, followed by nine drop minutes with 3596 frames each. I need to rethink how to properly map frame numbers to minutes and seconds within this structure. I need to reconsider how drop-frame timecode actually maps frames to labels. The key insight is that frames themselves aren't skipped—only the timecode numbers are. So for each 10-minute block, I'm working out how to convert a frame number directly to its corresponding timecode label, accounting for the fact that certain frame numbers correspond to skipped timecode values. rem' + 4 gives me the position within the full 3600-frame range, then I can extract the second and frame number using division and modulo. Let me verify this works at the boundaries: when rem' is 0, I get second 0 and frame 4 (which is 00;04), and when rem' is 3595, I get second 59 and frame 59 (which is 59;59).

Now I'm writing the framesToTC function that converts total frames into timecode format, handling the frame-dropping logic for drop-frame timecode by calculating hours, tens of minutes, and then checking whether we're in a drop minute to determine the remaining minutes and frames. I need to verify that the tcToFrames function is working correctly by checking the math. For a timecode like 00:01:00;04, I'm calculating the total frames by accounting for the dropped frames at each non-ten-minute boundary—specifically, 4 frame numbers are skipped at each minute that isn't a multiple of 10, so the total dropped frames equal DROP times the count of such minutes.

Now I'm working through the framesToTC conversion in reverse, testing specific frame numbers to make sure the logic handles the frame dropping correctly at minute boundaries.

Testing frame 4 reveals an issue: the algorithm is producing 00:01:00;00 when it should output 00:00:00;04, which suggests there's a bug in how the minute and frame calculations interact when frames fall right at the drop threshold. I'm realizing the algorithm produces an invalid timecode for frame 4—it outputs 00:01:00;00, but those frames are supposed to be dropped in drop-frame timecode. The issue is that the condition `rem >= DROP` triggers too early; it should only apply after the first minute (which is non-drop), but instead it's catching frame 4 in that first minute and incorrectly bumping it into the second minute. I think I've got it now—the formula FRAMES_PER_10MIN = 10*FRAMES_PER_MIN + DROP suggests the algorithm maps frame positions differently, where the remainder tells us which minute within a 10-minute block we're in, and the DROP offset accounts for the dropped frames that accumulate. So when rem < DROP, we're at the boundary of a 10-minute group, and when rem >= DROP, we've moved into the actual minutes of that group.

But wait, there's something off with my logic here—if rem=4 puts us at minute 1, frame 0, that should be a dropped frame, yet the calculation seems to treat it as valid. Let me reconsider what rem=0 actually means within a single minute's frame count.

Actually, I think I've found the bug. Testing frame 3600 as a sanity check: the math gives me 00:02:00;00, but that frame should actually be 00:01:00;04—the first valid frame right after the drop at the 1-minute mark. The algorithm is clearly producing the wrong timecode.

Let me recalculate the frame distribution to make sure I have the right baseline. In 59.94 DF, there are 35964 frames per 10 minutes, which means 215784 frames per hour, but I need to verify how those frames actually distribute across the minutes with the drop pattern.

Now checking the tcToFrames function with the timecode 00:01:00;04 — it correctly converts to frame 3600, which confirms the algorithm is working as expected for this case.

Writing the corrected framesToTC function... Verifying the timecode conversion logic across key frame boundaries... I'm catching an error in my calculation—frame 35963 should give minute 9, not 10. Let me verify: 3600 + 9×3596 = 35964, so the maximum frame before the next group is indeed 35963, and the math checks out with m = floor(32363/3596) + 1 = 8 + 1 = 9.

Now testing the boundary at frame 35964: since rem wraps within the hour, rem = 0 and tm = 1, giving me 00:10:00;00, which is correct. The algorithm is working as intended. the original algorithm actually handles frames 0-3 correctly at the start of each 10-minute block, but fails for any frame >= 4 within those blocks.

Let me trace through what happens at frame 4: the remainder calculation triggers the minute increment logic, producing 00:01:00;00, which is itself a dropped frame number in drop-frame timecode—that's a clear bug. This matters because the editor displays timecodes using this function, so users would see invalid timecodes in the UI. The conversion functions also depend on this, so the bug likely cascades through the timeline calculations. in practice since input uses the displayed TC which comes from framesToTC. So framesToTC has a real bug affecting timecode display in the editor that I need to fix.

Now looking at timeline.js, the addClip function defaults srcOut to the asset duration in seconds, then converts it to frames—that part checks out. But I need to verify what the editor actually passes in for srcIn and srcOut parameters.

For the trim tool, there's an issue where right-trimming caps source_out_frames to the asset boundary, but it doesn't prevent timeline_out_frames from growing beyond what the source can actually cover. This means you could end up with a clip where the timeline duration exceeds the source duration, which is wrong. The fix would be to cap timeline_out_frames appropriately...

But wait, there's another problem with how assetFrames is calculated. When clip.duration_ms is null, assetFrames defaults to origSrcOut, which would prevent any right-trim extension at all—that's also incorrect.

If duration_ms is unknown, we're stuck with a mismatch where timeline_out_frames can extend but source_out_frames gets capped at the original value. This is somewhat expected when we lack duration info, but it's worth noting.

Looking at how srcOut gets set elsewhere—in addClip it defaults to asset.duration_ms converted to frames, which seems reasonable. And then there's the keyboard handler in _onKeyDown that manages tool shortcuts like v and c...

Actually, I'm noticing there might be duplicate keyboard handlers firing. The timeline.js _onKeyDown is calling setTool directly for v/c/h keys, but editor.html also has its own keyboard handler that calls updateToolbarActive, which then calls Timeline.setTool again. So when the user presses 'v', both handlers trigger and setTool gets called twice—it's redundant but not really breaking anything.

Looking back at the fix summary, the editor.html handler was already there before; the bug was just that it wasn't calling Timeline.setTool. So this double-handler situation isn't new from our changes. There's an edge case where if focus is on an input field, timeline.js's handler bails out early but editor.html's might still fire, though that was already the case.

For the ruler click handler, it's correctly accounting for the scroll position when calculating the x coordinate.

The ruler itself has a hard-coded 10-minute limit, so clips beyond that won't get ruler marks, though the clips themselves render fine in the tracks area. The tick interval logic looks reasonable based on the scale.

One thing I'm noticing is the hand tool—it changes the cursor to grab but there's no actual panning implementation. Clicking with the hand tool selected doesn't do anything since the click handler only handles deselection for the select tool. So the cursor changes but you can't actually pan the timeline.

Looking at the trim logic, the left-side capping prevents source_in_frames from going negative, which is correct. The right-trim issue is that it doesn't cap timeline_out_frames when source_out_frames hits the asset boundary.

The most critical issue is the timecode bug in timecode.js—the framesToTC function is broken for frames >= 4 within a 10-minute group, which means the editor displays wrong timecodes everywhere. That's the one I need to fix first.

Writing the timecode fix... Let me trace through a couple test cases with this algorithm to verify the bug. Frame 60 should map to 00:00:01;00, but the calculation gives 00:01:00;56 instead—the minutes are way off. And frame 3600 produces 00:02:00;00 when it should be 00:01:00;00, confirming the drop-frame logic is incorrectly adding extra minutes.

My proposed fix splits the logic: for the first minute of each 10-minute group, I skip the drop-frame adjustment entirely, then for subsequent minutes I subtract the initial 3600 frames and recalculate with the proper drop compensation.

Let me trace through a few key frames to verify this works—frame 60 gives 00:00:01;00, frame 3599 gives 00:00:59;59, and frame 3600 correctly jumps to 00:01:00;04 with the four-frame offset applied. I'm checking frame 3659 next to make sure the math holds for the second minute of the group. The timecode calculation checks out for the minute boundary. Now I need to write out the corrected timecode.js file, then examine the timeline.js right-trim issue where the timeline output can grow beyond the source material's frame count when the source gets capped at the asset limit. Refining the cap logic...

I realize I can simplify this by using `Infinity` as the fallback—since `Math.min(x, Infinity)` just returns `x`, I don't need separate branches for when duration is known versus unknown.

Looking at the assetFrames calculation, when `duration_ms` exists it gives me the full clip duration in frames, and `assetFrames - origSrcOut` correctly represents how much further right I can extend before hitting the asset boundary. This logic checks out.

Now I'm mapping out the fixes needed: the main one is updating `framesToTC` to handle the first minute differently from subsequent minutes when dealing with drop-frame timecode.

For the timeline boundary issue, I need to cap `timeline_out_frames` so that `source_out_frames` never exceeds the asset's actual duration.

Beyond these two core fixes, I should also review the other files in the codebase—the web UI pages and various API route handlers—to see if there are related issues or dependencies I need to account for.

Let me start by implementing the timecode fix...

Writing timecode utilities... Continuing with the timecode conversion logic...

Now I'm handling the frame calculation differently depending on whether we're in the first minute of a 10-minute block (where no frames drop) or in minutes 1–9 (where frames 0–3 are skipped at each second boundary). For the non-drop first minute, I'm just dividing the remaining frames by the nominal frame rate. For the other minutes, I'm accounting for the dropped frames by adjusting the remainder before extracting the seconds and frame number.

Then I'm combining the 10-minute block count with the minute offset to get the full hour:minute value, and formatting everything as a timecode string with the frame separator.

Now I'm writing the inverse function to convert a timecode string back to frame count...

Finishing the frame calculation...

Now I'm exporting these timecode utilities to the global object so they're accessible throughout the application.

Moving on to fix the timeline trimming logic...

I'm recalculating the timeline output frame position when dragging the right trim boundary, making sure the new position doesn't exceed the asset's available frames or go below the minimum valid position. The `Math.min` with `Infinity` handles cases where there's no duration constraint, which works fine in JavaScript. I need to think through whether capping the new timeline output position also requires adjusting how the source output frames are calculated to maintain consistency. I'm settling on this approach: when the asset duration is known, cap the output extension at the source boundary, but if duration is null, allow the timeline to extend beyond what we can source. Now I need to apply these fixes to both timecode.js and timeline.js, starting with replacing the `framesToTC` function and adding the constant, then fixing the right-trim logic in `_onTrimStart`. Let me also double-check the `addClip` function's parameter handling...

Looking at how `srcOut` gets converted to frames, the logic seems sound since both values are in seconds before conversion. The edge case where `srcOut` is 0 would incorrectly fall back to the default, but that's unlikely in practice since a real clip wouldn't have zero duration.

Now I'm examining the trim function to see if there are similar issues there, particularly how it handles the source and timeline frame boundaries during drag operations. I'm realizing the timeline.js key handler might be duplicating what editor.html already does, but the real priority is fixing that timecode bug first. Then I need to check whether editor.html has its own keydown listener—the summary mentions the V/C/H shortcuts were updated to call Timeline.setTool(), but I'm not sure if both handlers are firing and causing conflicts. So there's a double re-render happening—timeline.js handles the keyboard shortcuts directly while editor.html also processes them and updates the toolbar. Since the user is sleeping and this is just a minor inefficiency, I'll leave it as is and focus on the timecode bug instead. Looking at the keyboard handler in timeline.js, those `return` statements after `setTool()` prevent the event from propagating further.

Now I'm going to apply the fixes, starting with the critical timecode bug in timecode.js, then the trim boundary issue in timeline.js. I'm also checking whether trimming the left handle could push `source_in_frames` negative—the code uses `Math.max(0, ...)` to clamp it, so that's protected. (0, origSrcIn + dIn)` - capped at 0. Good.
`timeline_in_frames = Math.max(0, ...)` - can't go negative. Good.

But what if you drag the left handle to the right, causing the clip to shrink? `newTlIn = Math.min(origTlIn + dFr, origTlOut - 1)` - can't go past the right edge. Good.

What if you drag the left handle left when timeline_in_frames is already 0? `newTlIn = Math.max(0, origTlIn + dFr)` = 0 (clamped). `dIn = 0 - origTlIn = 0` (if origTlIn was 0). `source_in_frames = Math.max(0, origSrcIn + 0) = origSrcIn`. Fine.

But can you drag the left handle left when timeline_in_frames is 0 to try to reveal earlier source material? No - the timeline position clamps to 0, so the source offset doesn't change either. That's the right behavior.

What if source_in_frames is already at 0 and you try dragging the left handle left? Same constraint applies - the timeline can't go negative, so you're stuck. But if the source starts partway through the clip, dragging left when the timeline is at 0 would let you shift the source offset backward, revealing earlier material from the source.

Actually, wait - if timeline_in_frames is 0 and you drag left (dFr < 0), `newTlIn = Math.max(0, 0 + dFr)` = 0. So the timeline position stays at 0. Then `dIn = 0 - origTlIn`. If origTlIn was 0, then dIn = 0, so source_in_frames doesn't change. You can't reveal earlier source material this way. That's correct.

The left trim logic checks out. Now I'm ready to implement the two fixes.


Reading both files reveals two real bugs:

1. **timecode.js `framesToTC` is wrong** — the condition `if (rem >= DROP)` fires at frame 4 (still within minute 0 of the 10-minute group), setting `m=1` when it should still be in minute 0. Every frame ≥ 4 produces incorrect timecode.

2. **timeline.js right-trim has no boundary cap** — `timeline_out_frames` can extend past the asset's source duration.

Fixing timecode.js first since it affects every timecode display in the editor:


// 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_FIRST_MIN = NOM * 60;                    // 3600 – non-drop minute
  const FRAMES_PER_MIN   = NOM * 60 - DROP;             // 3596 – drop minute
  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
   *
   * Each 10-minute group contains one non-drop minute (3600 frames) followed
   * by nine drop minutes (3596 frames each).  Frame numbers 0–3 are skipped at
   * the start of every drop minute; we account for this by shifting the
   * within-minute frame index by DROP when computing 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 group (0–5)
    rem       = rem % FRAMES_PER_10MIN;

    let m, ss, ff;
    if (rem < FRAMES_FIRST_MIN) {
      // First minute of the 10-min group — no frame drop
      m  = 0;
      ss = Math.floor(rem / NOM);
      ff = rem % NOM;
    } else {
      // Minutes 1–9: frame numbers 0–3 at second :00 are skipped
      rem -= FRAMES_FIRST_MIN;
      m   = Math.floor(rem / FRAMES_PER_MIN) + 1;
      const remInMin = rem % FRAMES_PER_MIN;
      // Shift by DROP so the label starts at :00;04 instead of :00;00
      const adj = remInMin + DROP;
      ss  = Math.floor(adj / NOM);
      ff  = adj % NOM;
    }
    const M = tm * 10 + m;
    return `${pad2(h)}:${pad2(M)}:${pad2(ss)};${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);

fix(timecode): correct framesToTC for all frames beyond position 3

The previous algorithm used `if (rem >= DROP)` (i.e. rem >= 4) to decide
whether to advance to the next minute group.  This fired immediately at
frame 4, still inside minute 0 of the 10-minute non-drop group, producing
00:01:00;00 for what should be 00:00:00;04.  Every timecode display in
the editor was wrong for any position past the first four frames.

Each 10-minute block has one 3600-frame non-drop minute followed by nine
3596-frame drop minutes.  The fix checks `rem < FRAMES_FIRST_MIN` (3600)
to identify the non-drop minute, then subtracts it before dividing into
drop-minute slots.  Frame labels within drop minutes are shifted by DROP
(+4) so the first usable label is :00;04 as per SMPTE 12M.


OK | commit:3c689cc | Updated services/web-ui/public/js/timecode.js


Good, committed. Now let me fix the timeline.js right-trim boundary issue.


Now fix the right-trim boundary in timeline.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.removeEventListener('keydown', _onKeyDown);
    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;
    // Full asset duration in frames (null when unknown)
    var assetFrames = clip.duration_ms
      ? Math.round((clip.duration_ms / 1000) * s.fps)
      : null;

    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;
        // When we know the asset duration, don't extend past the source boundary
        if (assetFrames !== null) {
          var maxOut = assetFrames - origSrcOut;
          if (dOut > maxOut) dOut = maxOut;
        }
        clip.timeline_out_frames = origTlOut + dOut;
        clip.source_out_frames   = assetFrames !== null
          ? Math.min(assetFrames, origSrcOut + dOut)
          : 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 ─────────────────────────────────────────────────────

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

fix(timeline): cap right-trim at source asset boundary

When duration_ms is known, dragging the right-trim handle past the end
of the source clip could push timeline_out_frames beyond what the source
material covers.  Cap the delta so neither timeline_out_frames nor
source_out_frames can extend past the available source frames.

Also changed assetFrames fallback from origSrcOut (prevents any extension
when duration is unknown) to null, so the guard is simply skipped when
we don't have duration metadata.


OK | commit:0ea8d7c | Updated services/web-ui/public/js/timeline.js


Good. Now let me continue reading the remaining unreviewed files in parallel:
- `services/web-ui/public/projects.html`
- `services/web-ui/public/users.html`
- `services/mam-api/src/routes/auth.js`
- `services/mam-api/src/routes/users.js`


Good. Now reading the remaining unreviewed files in parallel:


File: services/web-ui/public/projects.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Projects — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <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

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <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

        <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

        <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

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Projects

        <button class="btn btn-primary btn-sm" onclick="openNewProject()">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>
          New project
        </button>

          All Projects
          --

          <input type="text" id="projSearch" placeholder="Search projects…" />

Loading…

          <svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4">
            <path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/>
          </svg>

Select a project on the left, or create a new one.

New project

    Name
    <input type="text" id="newProjName" placeholder="e.g. 2026 Sunday Service" autocomplete="off" />
    Description (optional)
    <textarea id="newProjDesc" rows="2" placeholder="A short note about the project"></textarea>

      <button class="btn btn-ghost btn-sm" onclick="closeModal('newProjModal')">Cancel</button>
      <button class="btn btn-primary btn-sm" onclick="createProject()">Create</button>

New bin

    Name
    <input type="text" id="newBinName" placeholder="e.g. Cameras, B-Roll, Interviews" autocomplete="off" />

      <button class="btn btn-ghost btn-sm" onclick="closeModal('newBinModal')">Cancel</button>
      <button class="btn btn-primary btn-sm" onclick="createBin()">Create</button>


File: services/web-ui/public/users.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Users — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <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

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <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

        <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

        <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

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Users & Groups

        <button class="tab active" onclick="switchTab('users',this)">Users</button>
        <button class="tab" onclick="switchTab('groups',this)">Groups</button>

            Users
            <button class="btn btn-primary btn-sm" onclick="openUserPanel()">
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
              New user
            </button>

                Username
                Display name
                Role
                Groups
                Created

Loading…

            Groups
            <button class="btn btn-primary btn-sm" onclick="openGroupPanel()">
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
              New group
            </button>

                Name
                Description
                Members
                Created

Loading…

    New user
    <button class="btn btn-ghost btn-sm" onclick="closeUserPanel()" 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>

    <input type="hidden" id="editUserId">

      Username
      <input type="text" id="uUsername" placeholder="e.g. jsmith">

      Display name
      <input type="text" id="uDisplayName" placeholder="e.g. Jane Smith">

      Role
      <select id="uRole">
        <option value="editor">Editor</option>
        <option value="viewer">Viewer</option>
        <option value="admin">Admin</option>
      </select>

      Password
      <input type="password" id="uPassword" placeholder="Min 8 characters">

    <button class="btn btn-ghost" onclick="closeUserPanel()">Cancel</button>
    <button class="btn btn-primary" id="saveUserBtn" onclick="saveUser()">Create user</button>

    New group
    <button class="btn btn-ghost btn-sm" onclick="closeGroupPanel()" 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>

    <input type="hidden" id="editGroupId">

      Group name
      <input type="text" id="gName" placeholder="e.g. News Team">

      Description
      <textarea id="gDescription" rows="2" placeholder="Optional description"></textarea>

      Members

      <select id="addMemberSelect" style="margin-top:var(--sp-2);">
        <option value="">Add member…</option>
      </select>

    <button class="btn btn-ghost" onclick="closeGroupPanel()">Cancel</button>
    <button class="btn btn-primary" id="saveGroupBtn" onclick="saveGroup()">Create group</button>


File: services/mam-api/src/routes/auth.js

/**
 * Authentication routes
 *
 * POST /api/v1/auth/login   — exchange username+password for a session cookie
 * POST /api/v1/auth/logout  — destroy the current session
 * GET  /api/v1/auth/me      — return the currently authenticated user
 * POST /api/v1/auth/setup   — one-time admin bootstrap (disabled after first user exists)
 */
import express from 'express';
import bcrypt from 'bcrypt';
import pool from '../db/pool.js';

const router = express.Router();

// ---------------------------------------------------------------------------
// POST /login
// ---------------------------------------------------------------------------
router.post('/login', async (req, res, next) => {
  try {
    const { username, password } = req.body;

    if (!username || !password) {
      return res.status(400).json({ error: 'Username and password are required' });
    }

    const result = await pool.query(
      'SELECT * FROM users WHERE username = $1',
      [username.trim().toLowerCase()]
    );

    if (result.rows.length === 0) {
      // Timing-safe: still run compare on a dummy hash so response time is constant
      await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    const user = result.rows[0];
    const valid = await bcrypt.compare(password, user.password_hash);

    if (!valid) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Regenerate session ID to prevent fixation attacks
    req.session.regenerate((err) => {
      if (err) return next(err);
      req.session.userId   = user.id;
      req.session.username = user.username;
      req.session.role     = user.role;
      res.json({
        id:           user.id,
        username:     user.username,
        display_name: user.display_name,
        role:         user.role,
      });
    });
  } catch (err) {
    next(err);
  }
});

// ---------------------------------------------------------------------------
// POST /logout
// ---------------------------------------------------------------------------
router.post('/logout', (req, res, next) => {
  req.session.destroy((err) => {
    if (err) return next(err);
    res.clearCookie('connect.sid');
    res.json({ message: 'Logged out' });
  });
});

// ---------------------------------------------------------------------------
// GET /me
// ---------------------------------------------------------------------------
router.get('/me', async (req, res) => {
  // When auth is disabled return a synthetic guest/admin user so the frontend
  // auth-guard never receives a 401 and never redirects to login.html.
  if (process.env.AUTH_ENABLED !== 'true') {
    return res.json({ id: null, username: 'admin', display_name: 'Admin', role: 'admin' });
  }

  if (!req.session || !req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  try {
    const result = await pool.query(
      'SELECT id, username, display_name, role FROM users WHERE id = $1',
      [req.session.userId]
    );
    if (result.rows.length === 0) {
      req.session.destroy(() => {});
      return res.status(401).json({ error: 'User not found' });
    }
    res.json(result.rows[0]);
  } catch (err) {
    // Fallback to session data if DB unreachable
    res.json({
      id:       req.session.userId,
      username: req.session.username,
      role:     req.session.role,
    });
  }
});

// ---------------------------------------------------------------------------
// POST /setup  — one-time first-admin bootstrap
// ---------------------------------------------------------------------------
router.post('/setup', async (req, res, next) => {
  try {
    const { username, password, display_name } = req.body;

    if (!username || !password) {
      return res.status(400).json({ error: 'Username and password are required' });
    }
    if (password.length < 8) {
      return res.status(400).json({ error: 'Password must be at least 8 characters' });
    }

    // Block if any user already exists
    const count = await pool.query('SELECT COUNT(*) FROM users');
    if (parseInt(count.rows[0].count, 10) > 0) {
      return res.status(403).json({
        error: 'Setup is already complete. Use an existing admin account to add more users.',
      });
    }

    const hash = await bcrypt.hash(password, 12);
    const result = await pool.query(
      `INSERT INTO users (username, password_hash, display_name, role)
       VALUES ($1, $2, $3, 'admin')
       RETURNING id, username, display_name, role`,
      [username.trim().toLowerCase(), hash, display_name || username]
    );

    res.status(201).json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/mam-api/src/routes/users.js

/**
 * User management routes (admin-only when AUTH_ENABLED=true)
 *
 * GET    /api/v1/users       — list all users
 * POST   /api/v1/users       — create user
 * GET    /api/v1/users/:id   — get user
 * PATCH  /api/v1/users/:id   — update user (display_name, role, password)
 * DELETE /api/v1/users/:id   — delete user
 */
import express from 'express';
import bcrypt  from 'bcrypt';
import pool    from '../db/pool.js';
import { requireAuth, requireAdmin } from '../middleware/auth.js';

const router = express.Router();
router.use(requireAuth, requireAdmin);

const VALID_ROLES = ['admin', 'editor', 'viewer'];

// ── List ──────────────────────────────────────────────────────
router.get('/', async (_req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT u.id, u.username, u.display_name, u.role, u.created_at,
              COUNT(ug.group_id)::int AS group_count
       FROM users u
       LEFT JOIN user_groups ug ON ug.user_id = u.id
       GROUP BY u.id
       ORDER BY u.created_at`
    );
    res.json(rows);
  } catch (err) { next(err); }
});

// ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
  try {
    const { username, password, display_name, role = 'editor' } = req.body;
    if (!username || !password)
      return res.status(400).json({ error: 'username and password required' });
    if (password.length < 8)
      return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
    if (!VALID_ROLES.includes(role))
      return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });

    const hash = await bcrypt.hash(password, 12);
    const { rows } = await pool.query(
      `INSERT INTO users (username, password_hash, display_name, role)
       VALUES ($1, $2, $3, $4)
       RETURNING id, username, display_name, role, created_at`,
      [username.trim().toLowerCase(), hash, display_name || username, role]
    );
    res.status(201).json(rows[0]);
  } catch (err) {
    if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' });
    next(err);
  }
});

// ── Get ───────────────────────────────────────────────────────
router.get('/:id', async (req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT id, username, display_name, role, created_at FROM users WHERE id = $1`,
      [req.params.id]
    );
    if (!rows.length) return res.status(404).json({ error: 'User not found' });
    res.json(rows[0]);
  } catch (err) { next(err); }
});

// ── Update ────────────────────────────────────────────────────
router.patch('/:id', async (req, res, next) => {
  try {
    const { display_name, role, password } = req.body;
    const sets = []; const vals = [];

    if (display_name !== undefined) {
      sets.push(`display_name = $${sets.length + 1}`);
      vals.push(display_name);
    }
    if (role !== undefined) {
      if (!VALID_ROLES.includes(role))
        return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
      sets.push(`role = $${sets.length + 1}`);
      vals.push(role);
    }
    if (password) {
      if (password.length < 8)
        return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
      const hashed = await bcrypt.hash(password, 12);
      sets.push(`password_hash = $${sets.length + 1}`);
      vals.push(hashed);
    }
    if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });

    vals.push(req.params.id);
    const { rows } = await pool.query(
      `UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length}
       RETURNING id, username, display_name, role, created_at`,
      vals
    );
    if (!rows.length) return res.status(404).json({ error: 'User not found' });
    res.json(rows[0]);
  } catch (err) { next(err); }
});

// ── Delete ────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
  try {
    const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
    if (!rowCount) return res.status(404).json({ error: 'User not found' });
    res.json({ message: 'User deleted' });
  } catch (err) { next(err); }
});

export default router;