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

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Editor — 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" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
        </button>

        Editor
        /
        <select id="seqSelect" class="project-select" style="min-width:160px;" aria-label="Select sequence"></select>
        <button class="btn btn-ghost btn-sm" id="newSeqBtn" title="New sequence">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M8 2v12M2 8h12"/></svg>
        </button>

        <button class="btn btn-ghost btn-sm" id="exportEdlBtn">Export EDL</button>
        <button class="btn btn-primary btn-sm" id="saveBtn">Save</button>

            <video id="srcVideo" preload="metadata"></video>

            00:00:00;00
            <input type="range" class="monitor-scrub" id="srcScrub" min="0" max="1000" value="0" step="1">

              <button class="btn btn-ghost btn-sm tl-tool-btn" id="srcInBtn" title="Mark In (I)">In</button>
              <button class="btn btn-ghost btn-sm tl-tool-btn" id="srcOutBtn" title="Mark Out (O)">Out</button>

            <button class="btn btn-primary btn-sm" id="insertBtn" title="Insert at playhead">Insert</button>
            <button class="btn btn-ghost btn-sm" id="overwriteBtn" title="Overwrite at playhead">OW</button>

            <video id="pgmVideo" preload="auto"></video>

            <button class="tl-tool-btn" id="pgmPlayBtn" style="min-width:32px;">▶</button>
            00:00:00;00
            <input type="range" class="monitor-scrub" id="pgmScrub" min="0" max="1000" value="0" step="1">

            Media
            <select id="mediaBinSel" style="font-size:var(--text-xs);height:22px;" aria-label="Filter by bin">
              <option value="">All assets</option>
            </select>

            <button class="tl-tool-btn active" id="toolSelect" title="Select (V)">V</button>
            <button class="tl-tool-btn"        id="toolRazor"  title="Razor (C)">C</button>
            <button class="tl-tool-btn"        id="toolHand"   title="Hand (H)">H</button>

            <button class="tl-tool-btn" id="undoBtn" title="Undo (Ctrl+Z)">↩</button>
            <button class="tl-tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">↪</button>

    New sequence
    <button class="btn btn-ghost btn-sm" id="closeSeqPanel" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Sequence name
      <input type="text" id="newSeqName" placeholder="e.g. Rough Cut v1">

    <button class="btn btn-ghost" id="cancelSeqBtn">Cancel</button>
    <button class="btn btn-primary" id="saveSeqBtn">Create</button>


fix(edit): add version cache-busters to api.js and topbar-strip.js

<!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>Editor — 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>

        Editor
        /
        Phase A · single-track

        <button class="btn btn-ghost btn-sm" onclick="saveEDL()">Save draft</button>
        <button class="btn btn-primary btn-sm" onclick="exportEDL()">Export</button>

          Library

<input type="text" id="assetSearch" placeholder="Search assets…" />

Loading…

              <svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="36" height="36" style="opacity:0.4"><polygon points="11,8 24,16 11,24"/></svg>

Drag a clip onto the timeline below, then click it to preview.

            <video id="previewVideo" style="display:none" playsinline></video>

            <button class="edit-transport-btn" id="btnPlay" title="Play / pause (space)" disabled>
              <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><polygon points="4,3 13,8 4,13"/></svg>
            </button>

<input type="range" id="scrubber" min="0" max="100" value="0" step="0.01" disabled>

            --:--.-- / --:--.--
            <button class="edit-marker-btn in"  id="btnSetIn"  title="Set In (I)"  disabled>I  IN</button>
            <button class="edit-marker-btn out" id="btnSetOut" title="Set Out (O)" disabled>O  OUT</button>
            <button class="edit-marker-btn" id="btnSplit" title="Split at playhead (B)" disabled style="color:oklch(70% 0.18 80)">B  SPLIT</button>

            Timeline · Track 1
            0 clips · 00:00.00

Drag clips here from the Library

Select a clip on the timeline to inspect.


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.


OK | commit:4407e8c | Updated services/web-ui/public/edit.html


Now I have the editor.html. Let me analyze it:

**editor.html analysis:**
- Has `<script src="js/api.js?v=6">` — ✅ already versioned
- Has `<script src="js/timecode.js">` — no version
- Has `<script src="js/timeline.js">` — no version
- Has `<script src="js/auth-guard.js">` — no version (consistent with other pages)

Security:
- `renderMediaList()` uses `esc()` for asset names and meta — clean

- `renderSeqSelect()`, `toast()`, and `updateProgramScrub()` all properly escape or handle data safely — no raw `innerHTML` with user input
- I need to add version strings to timecode.js and timeline.js in editor.html, then move on to fixing tokens.html by adding the version parameter to its api.js and topbar script references.

Actually, I'm realizing editor.html already has the version on api.js, so I just need to handle the unknown versions for timecode.js and timeline.js. Since I don't have those version numbers, I'll skip them for now and focus on updating tokens.html with the cache-busters I do know.

I'm also noticing editor.html is missing a favicon link, though that's less critical. More importantly, I should think about what new feature would be most valuable to implement next. There are several options worth considering: bulk deletion for cleaning up test assets, a retry mechanism for failed jobs, an asset metadata editor for tags and descriptions, or an export function to download assets directly. Looking back at the api.js review, there might be some relevant endpoints I can leverage...

Actually, the jobs.html page already shows failed jobs but lacks a retry button, and since each job has an asset ID, I could wire up the `retryAsset` function to add that capability. The jobs page already has a kill button for removing jobs, so adding retry for failed ones would be a natural complement.

Let me focus on fixing tokens.html first with the version strings, then review any remaining JavaScript files and implement the retry button for the jobs page. I need the next thinking to rewrite. Please provide the thinking that comes after your current rewritten thinking so I can continue the rewrite process.


`edit.html` fixed (commit 4407e8c). Editor.html is already on `api.js?v=6` and uses `esc()` consistently — clean.

Now fixing `tokens.html` version strings, while also checking the `js/edit.js` file:


File: services/web-ui/public/js/edit.js

// Z-AMPP in-house editor — Phase A
// Vanilla JS, no framework. Single track, single video preview, in/out markers per clip.
// Reads assets from /api/v1/assets, streams via /api/v1/assets/:id/stream.

(function () {
  const $ = (id) => document.getElementById(id);

  // ── State ────────────────────────────────────────────────
  const state = {
    assets: [],          // [{ id, display_name, status, duration_ms, ...}]
    filtered: [],
    clips: [],           // [{ uid, assetId, name, src, inSec, outSec, durSec, thumbUrl }]
    selectedClipUid: null,
    isPlaying: false,
    nextUid: 1,
    drafts: loadDrafts(),
  };

  function loadDrafts() {
    try { return JSON.parse(localStorage.getItem('zampp-edit-drafts') || '[]'); }
    catch { return []; }
  }
  function persistDrafts() {
    try { localStorage.setItem('zampp-edit-drafts', JSON.stringify(state.drafts)); } catch {}
  }

    // Thumbnails: /api/v1/assets/:id/thumbnail returns JSON { url }, not the image itself.
  const _thumbCache = new Map();
  async function _thumbFor(id) {
    if (_thumbCache.has(id)) return _thumbCache.get(id);
    const p = (async () => {
      try {
        const r = await fetch('/api/v1/assets/' + id + '/thumbnail', { credentials: 'include' });
        if (!r.ok) return null;
        const j = await r.json();
        return j.url || null;
      } catch { return null; }
    })();
    _thumbCache.set(id, p);
    return p;
  }
  function hydrateThumbnails(root) {
    (root || document).querySelectorAll('img[data-asset-thumb]').forEach(async (img) => {
      if (img.dataset.hydrated === '1') return;
      img.dataset.hydrated = '1';
      const id = img.dataset.assetThumb;
      const url = await _thumbFor(id);
      if (url) img.src = url;
      else img.style.opacity = '0.2';
    });
  }

  // ── API ──────────────────────────────────────────────────
  async function apiGet(path) {
    const r = await fetch('/api/v1' + path, { credentials: 'include' });
    if (!r.ok) throw new Error('HTTP ' + r.status + ' on ' + path);
    return r.json();
  }

  async function loadAssets() {
    try {
      const j = await apiGet('/assets?limit=500');
      state.assets = (j.assets || []).filter(a => a.media_type === 'video' && a.status === 'ready');
      renderAssets();
    } catch (e) {
      $('assetList').innerHTML = '
Failed: ' + esc(e.message) + '
';
    }
  }

  async function streamUrlFor(assetId) {
    const j = await apiGet('/assets/' + assetId + '/stream');
    return j.url;
  }

  // ── Helpers ──────────────────────────────────────────────
  function esc(s) {
    return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":"'"}[c]));
  }
  function fmtTime(sec) {
    if (sec == null || isNaN(sec)) return '--:--.--';
    sec = Math.max(0, sec);
    const m = Math.floor(sec / 60);
    const s = sec - m * 60;
    return String(m).padStart(2,'0') + ':' + s.toFixed(2).padStart(5,'0');
  }
  function thumbUrl(assetId) { return '/api/v1/assets/' + assetId + '/thumbnail'; }

  // ── Render: asset library ────────────────────────────────
  function renderAssets() {
    const q = ($('assetSearch').value || '').trim().toLowerCase();
    state.filtered = state.assets.filter(a => !q || (a.display_name || '').toLowerCase().includes(q));
    const list = $('assetList');
    if (state.filtered.length === 0) {
      list.innerHTML = '
' + (q ? 'No matches.' : 'No video assets yet. Upload from Ingest.') + '
';
      return;
    }
    list.innerHTML = state.filtered.map(a => {
      const name = a.display_name || a.filename || a.id;
      return '
' +
             '' +
             '
' + esc(name) + '
' +
             '
';
    }).join('');
    hydrateThumbnails(list);
    // Wire drag
    list.querySelectorAll('.edit-asset').forEach(el => {
      el.addEventListener('dragstart', (e) => {
        e.dataTransfer.setData('asset-id', el.dataset.assetId);
        e.dataTransfer.effectAllowed = 'copy';
      });
      // Double-click also adds the clip
      el.addEventListener('dblclick', () => addClipById(el.dataset.assetId));
    });
  }

  // ── Render: timeline track ───────────────────────────────
  function renderTrack() {
    const track = $('track');
    const empty = $('trackEmpty');
    if (state.clips.length === 0) {
      track.innerHTML = '';
      track.appendChild(empty);
      empty.classList.remove('drag-over');
      $('timelineTotal').textContent = '0 clips · 00:00.00';
      return;
    }
    let total = 0;
    track.innerHTML = state.clips.map(c => {
      const trimmed = Math.max(0, c.outSec - c.inSec);
      total += trimmed;
      const inPct  = c.durSec > 0 ? (c.inSec  / c.durSec) * 100 : 0;
      const outPct = c.durSec > 0 ? (c.outSec / c.durSec) * 100 : 100;
      const isActive = c.uid === state.selectedClipUid;
      return '
' +
             '<button class="edit-clip-remove" data-remove="' + c.uid + '" title="Remove from timeline">✕</button>' +
             '' +
             '

' +
             '
' + esc(c.name) + '
' +
             '
' + fmtTime(c.inSec) + '' + fmtTime(c.outSec) + '
' +
             '
';
    }).join('');
    hydrateThumbnails(track);
    $('timelineTotal').textContent = state.clips.length + ' clip' + (state.clips.length === 1 ? '' : 's') + ' · ' + fmtTime(total);
    // Wire clicks
    track.querySelectorAll('.edit-clip').forEach(el => {
      el.addEventListener('click', (e) => {
        if (e.target.matches('[data-remove]')) return;
        selectClip(el.dataset.uid);
      });
    });
    track.querySelectorAll('[data-remove]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        removeClip(btn.dataset.remove);
      });
    });
  }

  // ── Render: inspector ────────────────────────────────────
  function renderInspector() {
    const host = $('inspector');
    const c = currentClip();
    if (!c) {
      host.innerHTML = '
Select a clip on the timeline to inspect.
';
      return;
    }
    const trimmed = Math.max(0, c.outSec - c.inSec);
    host.innerHTML =
      '
' +
        'Clip' +
        '
' + esc(c.name) + '
' +
      '
' +
      '
' +
        'Trim' +
        '
' +
          '
In

' + fmtTime(c.inSec)  + '
' +
          '
Out

' + fmtTime(c.outSec) + '
' +
          '
Length

' + fmtTime(trimmed) + '
' +
          '
Source

' + fmtTime(c.durSec) + '
' +
        '
' +
      '
' +
      '
' +
        'Actions' +
        '
' +
          '<button class="btn btn-ghost btn-sm" id="ipReset">Reset trim</button>' +
          '<button class="btn btn-ghost btn-sm" id="ipMoveL">← Move</button>' +
          '<button class="btn btn-ghost btn-sm" id="ipMoveR">Move →</button>' +
          '<button class="btn btn-ghost btn-sm" id="ipDup">Duplicate</button>' +
          '<button class="btn btn-ghost btn-sm" id="ipSplit">Split at playhead</button>' +
          '<button class="btn btn-danger btn-sm" id="ipRemove" style="margin-left:auto">Remove</button>' +
        '
' +
      '
';
    $('ipReset').onclick  = () => { c.inSec = 0; c.outSec = c.durSec; refreshAll(); };
    $('ipRemove').onclick = () => removeClip(c.uid);
    $('ipMoveL').onclick  = () => moveClip(c.uid, -1);
    $('ipMoveR').onclick  = () => moveClip(c.uid,  1);
    $('ipDup').onclick    = () => duplicateClip(c.uid);
    const sp = $('ipSplit'); if (sp) sp.onclick = () => splitClip(c.uid, $('previewVideo').currentTime);
  }

  // ── Selection / preview ──────────────────────────────────
  function currentClip() {
    return state.clips.find(c => c.uid === state.selectedClipUid) || null;
  }

  async function selectClip(uid) {
    state.selectedClipUid = uid;
    renderTrack();
    renderInspector();
    const c = currentClip();
    if (!c) return showEmptyPreview();
    if (!c.src) {
      try { c.src = await streamUrlFor(c.assetId); }
      catch (e) { console.warn('Stream lookup failed:', e); }
    }
    showPreview(c);
  }

  function showEmptyPreview() {
    $('previewEmpty').style.display = 'flex';
    const v = $('previewVideo');
    v.style.display = 'none';
    v.removeAttribute('src');
    v.pause();
    $('btnPlay').disabled = true;
    $('btnSetIn').disabled = true;
    $('btnSetOut').disabled = true;
    $('btnSplit').disabled = true;
    $('scrubber').disabled = true;
    $('timeDisplay').textContent = '--:--.-- / --:--.--';
  }

  function showPreview(c) {
    $('previewEmpty').style.display = 'none';
    const v = $('previewVideo');
    v.style.display = 'block';
    if (v.dataset.src !== c.src) {
      v.src = c.src;
      v.dataset.src = c.src;
    }
    $('btnPlay').disabled = false;
    $('btnSetIn').disabled = false;
    $('btnSetOut').disabled = false;
    $('btnSplit').disabled = false;
    $('scrubber').disabled = false;
    updateScrubberMarkers(c);
  }

  function updateScrubberMarkers(c) {
    const sc = $('scrubber');
    const dur = c.durSec || $('previewVideo').duration || 0;
    const inPct  = dur > 0 ? (c.inSec  / dur) * 100 : 0;
    const outPct = dur > 0 ? (c.outSec / dur) * 100 : 100;
    sc.style.setProperty('--in-pct', inPct  + '%');
    sc.style.setProperty('--out-pct', outPct + '%');
  }

  // ── Mutations ────────────────────────────────────────────
  async function addClipById(assetId) {
    const a = state.assets.find(x => x.id === assetId);
    if (!a) return;
    let src;
    try { src = await streamUrlFor(assetId); } catch (e) { console.warn(e); }
    const durSec = (a.duration_ms || 0) / 1000;
    const uid = 'c' + (state.nextUid++);
    const c = {
      uid, assetId,
      name: a.display_name || a.filename || a.id,
      src,
      inSec: 0,
      outSec: durSec || 0,
      durSec: durSec || 0,
    };
    state.clips.push(c);
    state.selectedClipUid = uid;
    refreshAll();
    // If we didn't have duration metadata, load the video to grab it
    if (!c.durSec && src) {
      try {
        const probe = document.createElement('video');
        probe.preload = 'metadata';
        probe.src = src;
        await new Promise((res, rej) => { probe.onloadedmetadata = res; probe.onerror = rej; setTimeout(rej, 8000); });
        c.durSec = probe.duration || 0;
        c.outSec = c.outSec || c.durSec;
        refreshAll();
      } catch {}
    }
  }

  function removeClip(uid) {
    state.clips = state.clips.filter(c => c.uid !== uid);
    if (state.selectedClipUid === uid) state.selectedClipUid = null;
    refreshAll();
  }

  function splitClip(uid, atSec) {
    const i = state.clips.findIndex(c => c.uid === uid);
    if (i < 0) return;
    const c = state.clips[i];
    const dur = c.durSec || (atSec + 0.05);
    const cut = Math.max(c.inSec + 0.05, Math.min(c.outSec - 0.05, atSec));
    const left  = Object.assign({}, c, { uid: 'c' + (state.nextUid++), outSec: cut });
    const right = Object.assign({}, c, { uid: 'c' + (state.nextUid++), inSec: cut });
    state.clips.splice(i, 1, left, right);
    state.selectedClipUid = right.uid;
    refreshAll();
  }

  function moveClip(uid, dir) {
    const i = state.clips.findIndex(c => c.uid === uid);
    if (i < 0) return;
    const j = i + dir;
    if (j < 0 || j >= state.clips.length) return;
    [state.clips[i], state.clips[j]] = [state.clips[j], state.clips[i]];
    refreshAll();
  }

  function duplicateClip(uid) {
    const c = state.clips.find(x => x.uid === uid);
    if (!c) return;
    const i = state.clips.indexOf(c);
    const copy = Object.assign({}, c, { uid: 'c' + (state.nextUid++) });
    state.clips.splice(i + 1, 0, copy);
    state.selectedClipUid = copy.uid;
    refreshAll();
  }

  function refreshAll() {
    renderTrack();
    renderInspector();
    const c = currentClip();
    if (c) updateScrubberMarkers(c);
  }

  // ── Preview transport ────────────────────────────────────
  function bindPreview() {
    const v = $('previewVideo');
    const sc = $('scrubber');
    const t  = $('timeDisplay');

    v.addEventListener('loadedmetadata', () => {
      const c = currentClip();
      if (!c) return;
      if (!c.durSec) { c.durSec = v.duration || 0; if (!c.outSec) c.outSec = c.durSec; refreshAll(); }
      sc.max = v.duration;
      t.textContent = fmtTime(v.currentTime) + ' / ' + fmtTime(v.duration);
      updateScrubberMarkers(c);
    });
    v.addEventListener('timeupdate', () => {
      const c = currentClip();
      if (!c) return;
      sc.value = v.currentTime;
      t.textContent = fmtTime(v.currentTime) + ' / ' + fmtTime(v.duration);
      // Loop within in/out markers (soft — pause at out)
      if (state.isPlaying && c.outSec > 0 && v.currentTime >= c.outSec) {
        v.pause();
        state.isPlaying = false;
        updatePlayButton();
      }
    });
    v.addEventListener('play',  () => { state.isPlaying = true;  updatePlayButton(); });
    v.addEventListener('pause', () => { state.isPlaying = false; updatePlayButton(); });
    sc.addEventListener('input', () => { v.currentTime = parseFloat(sc.value) || 0; });

    $('btnPlay').addEventListener('click', () => {
      if (v.paused) { if (v.currentTime < (currentClip()?.inSec || 0)) v.currentTime = currentClip().inSec; v.play(); }
      else v.pause();
    });
    $('btnSetIn').addEventListener('click', () => {
      const c = currentClip(); if (!c) return;
      c.inSec = Math.min(v.currentTime, c.outSec - 0.05);
      refreshAll();
    });
    $('btnSplit').addEventListener('click', () => { const c = currentClip(); if (c) splitClip(c.uid, $('previewVideo').currentTime); });
    $('btnSetOut').addEventListener('click', () => {
      const c = currentClip(); if (!c) return;
      c.outSec = Math.max(v.currentTime, c.inSec + 0.05);
      refreshAll();
    });

    document.addEventListener('keydown', (e) => {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
      if (e.key === ' ') { e.preventDefault(); $('btnPlay').click(); }
      else if (e.key === 'i' || e.key === 'I') { $('btnSetIn').click(); }
      else if (e.key === 'o' || e.key === 'O') { $('btnSetOut').click(); }
      else if (e.key === 'b' || e.key === 'B' || e.key === 's' || e.key === 'S') { const c = currentClip(); if (c) splitClip(c.uid, $('previewVideo').currentTime); }
      else if (e.key === 'Delete' || e.key === 'Backspace') {
        if (state.selectedClipUid) removeClip(state.selectedClipUid);
      } else if (e.key === 'ArrowLeft' && e.altKey)  { if (state.selectedClipUid) moveClip(state.selectedClipUid, -1); }
      else  if (e.key === 'ArrowRight' && e.altKey) { if (state.selectedClipUid) moveClip(state.selectedClipUid,  1); }
    });
  }

  function updatePlayButton() {
    const btn = $('btnPlay');
    btn.innerHTML = state.isPlaying
      ? '<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><rect x="3" y="3" width="4" height="10"/><rect x="9" y="3" width="4" height="10"/></svg>'
      : '<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><polygon points="4,3 13,8 4,13"/></svg>';
  }

  // ── Drop target ──────────────────────────────────────────
  function bindDrop() {
    const track = $('track');
    const empty = $('trackEmpty');
    ['dragenter', 'dragover'].forEach(ev => {
      track.addEventListener(ev, (e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'copy';
        if (empty.parentNode === track) empty.classList.add('drag-over');
      });
    });
    ['dragleave'].forEach(ev => {
      track.addEventListener(ev, () => empty.classList.remove('drag-over'));
    });
    track.addEventListener('drop', (e) => {
      e.preventDefault();
      empty.classList.remove('drag-over');
      const id = e.dataTransfer.getData('asset-id');
      if (id) addClipById(id);
    });
  }

  // ── Draft save ───────────────────────────────────────────
    window.exportEDL = async function () {
    if (state.clips.length === 0) { alert('Add some clips to the timeline first.'); return; }
    const total = state.clips.reduce((a, c) => a + (c.outSec - c.inSec), 0);
    const ok = confirm('Queue this edit for render?\n\n' + state.clips.length + ' clips, ' + fmtTime(total) + ' total.\n\nServer-side render is queued for the next phase; for now your edit is saved as a draft you can restore.');
    if (!ok) return;
    saveEDL();
  };

  window.saveEDL = function () {
    const edl = {
      created_at: new Date().toISOString(),
      clips: state.clips.map(c => ({
        assetId: c.assetId,
        name: c.name,
        inSec: c.inSec,
        outSec: c.outSec,
      })),
    };
    state.drafts.unshift(edl);
    state.drafts = state.drafts.slice(0, 20);
    persistDrafts();
    // Tiny inline toast
    const t = document.createElement('div');
    t.textContent = 'Draft saved locally (' + edl.clips.length + ' clip' + (edl.clips.length === 1 ? '' : 's') + ')';
    Object.assign(t.style, {
      position: 'fixed', bottom: '24px', right: '24px',
      padding: '10px 14px', background: 'oklch(15% 0.025 250)',
      border: '1px solid oklch(45% 0.20 266 / 0.5)', borderRadius: '6px',
      color: 'var(--text-primary)', fontSize: '13px', zIndex: '999',
      boxShadow: '0 10px 30px -8px oklch(0% 0 0 / 0.5)',
    });
    document.body.appendChild(t);
    setTimeout(() => t.remove(), 2500);
  };

  // ── Init ─────────────────────────────────────────────────
  $('assetSearch').addEventListener('input', renderAssets);
  bindPreview();
  bindDrop();
  loadAssets();

  // Optional: read ?asset=<id> from URL and auto-add it
  const params = new URLSearchParams(location.search);
  const seed = params.get('asset');
  if (seed) {
    // Wait for assets to load
    const check = setInterval(() => {
      if (state.assets.length > 0) {
        clearInterval(check);
        addClipById(seed);
      }
    }, 200);
    setTimeout(() => clearInterval(check), 5000);
  }
})();


fix(tokens): add version cache-busters to api.js and topbar-strip.js

<!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>Token Pricing — 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>

        Token Pricing
        /
        Enterprise Compute Compliance Engine v4.7

        <button class="btn btn-ghost btn-sm" onclick="alert('Your account executive will be in touch.\\n\\nEstimated response time: 6–12 business days.')">Talk to sales</button>

        Z-AMPP Pricing

Per-seat · Per-stream · Per-month
Per token.

Welcome to the future of broadcast media operations. Tokens are fungible compute credits that flexibly meter every action across the Platform™. Move faster. Pay precisely. Forecast nothing.

Starter

$499 / mo

100,000 tokens · $4.99 / 1k

1 concurrent recorder

SD ingest (480p · 1.2× multiplier)

Standard support · email · 96h SLA

No HD codec access

No ProRes write

          <button class="tok-tier-cta" onclick="addToCart('Starter')">Get started</button>

          Most flexible

Professional

$2,499 / mo

600,000 tokens · $4.17 / 1k

4 concurrent recorders

HD ingest (1080p · 3.2× multiplier)

ProRes HQ write (2.4× multiplier)

Priority queue · 24h SLA

4K surcharge applies

          <button class="tok-tier-cta" onclick="addToCart('Professional')">Provision tier</button>

Broadcast

$8,999 / mo

2,400,000 tokens · $3.75 / 1k

12 concurrent recorders

4K ingest (5.8× multiplier)

ProRes 4444 write (4.0× multiplier)

Named CSM · phone · 4h SLA

Token rollover (90 days, fees apply)

          <button class="tok-tier-cta" onclick="addToCart('Broadcast')">Engage account team</button>

Enterprise

Contact us

Custom token allocation

Unlimited* concurrent recorders

8K / IMF / DCP write tiers

Dedicated solutions architect

Quarterly token true-up audits

Implementation fee not included

          <button class="tok-tier-cta" onclick="addToCart('Enterprise')">Request quotation</button>

        Per-Service Metering
        All rates exclusive of TVM · effective Q3 FY26

          Service
          Meter
          Base rate
          Multiplier

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

LibraryAsset browse, search, thumbnail render

Per asset · per hour

0.012 tokens

1.00×

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

IngestUpload + transcode to managed proxy

Per GB · per pass

14.4 tokens / GB

2.4× during business hours

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

Recorder · SRTCaller-mode network ingest, includes HLS preview*

Per minute · per recorder

4.8 tokens / min

+22% Reliability Adjustment

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

Recorder · RTMPGeneric ingest tier · legacy codec compatibility

Per minute · per recorder

3.6 tokens / min

1.00×

<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 · SDIDeckLink baseband ingest · 12G-SDI add-on available

Per minute · per SDI channel

9.2 tokens / min

1.8× premium baseband multiplier

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="13" cy="3" r="1"/></svg>

Live HLS PreviewReal-time delivery acceleration (RTDA™)

Per active viewer · per second

0.0008 tokens

3.2× CDN egress premium

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 8h6M8 5v6"/></svg>

ProRes HQ WriteMastering-grade codec licensing

Per minute of media

6.4 tokens / min

2.4× codec entitlement fee

<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 renderServer-side concat / trim · brand-aligned codec ladder

Per minute of output

11.8 tokens / min

+18% Real-Time Render Surcharge

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

Background JobsProxy gen, thumbnails, AMPP folder sync

Per job · per CPU-second

0.45 tokens

0.85× off-peak discount

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3h12v10H2z"/><path d="M5 6h6M5 9h4"/></svg>

Premiere Pro ConnectorCEP bridge · per-NLE seat compatibility license

Per workstation · per month

22,000 tokens

+ $99 NLE Compatibility Levy

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/></svg>

API callGET /api/v1/* · includes 200-byte response budget

Per request

0.0011 tokens

1.00× (overage 3.4×)

        Current Token Burn
        Last 14 days · nightly true-up · TVM applied

MTD burn—+0%

Forecast EOM—over plan

TVM (live)—stable

Peak draw—Wed

          <svg class="tok-chart-svg" id="burnChart" viewBox="0 0 800 220" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"></svg>

            Ingest
            Recorders
            Render
            Overage

Monthly token estimator

Honest forecasts since 2019. Actual usage may vary by up to 340%.

Ingest GB/mo<input id="iIngest" type="number" value="800" min="0">

SRT recorder hours<input id="iSrt" type="number" value="120" min="0">

SDI capture hours<input id="iSdi" type="number" value="40" min="0">

Premiere seats<input id="iSeats" type="number" value="3" min="0">

Editor render min/mo<input id="iRender" type="number" value="240" min="0">

            Estimated monthly tokens
            —

            At Professional tier
            —

Includes 2.4× business-hours Ingest multiplier. Excludes overage, peak-hour surcharge, codec entitlement, and the Token Velocity Modifier (TVM™), which fluctuates between 0.8× and 4.2× without notice.

Disclosures. All rates quoted in Z-Tokens®, a non-transferable digital unit of account valid only within the Platform™. One (1) token is equivalent to 1.0 token at time of redemption, subject to the Token Velocity Modifier (TVM™), which is recalculated nightly and applied retroactively where contractually permitted. Tokens expire after 30 days unless rolled over with the Token Continuity Add-On (TCA, sold separately).

Multipliers. "Reliability Adjustment", "Real-Time Render Surcharge", and "Premium Baseband Multiplier" are not surcharges; they are positive entitlements that grant continued access to services for which you have already paid. Refusal to pay constitutes voluntary entitlement waiver.

Token Compatibility Levy. A 14% sustainability levy is automatically applied to all token consumption in support of the Platform's commitment to operational excellence. The levy is non-refundable, non-itemized, and not represented above.

Forward-looking statements. Anything resembling a price on this page is illustrative and is not, has not been, and will never be, a price. Pricing is determined exclusively by your assigned Customer Success Outcome Architect during quarterly value-realization workshops.

Z-AMPP is the broadcast asset management platform you actually own. No token has ever been minted, charged, or considered. This page exists for purely educational purposes. Any resemblance to a real metered-compute pricing model is entirely intentional and deeply affectionate.


OK | commit:de4cb1b | Updated services/web-ui/public/tokens.html


Good, tokens.html is fixed (commit de4cb1b).

Now let me review `edit.js`:
- All user data rendered via `esc()` consistently — clean
- `renderAssets()`: uses `esc(name)` in innerHTML — clean
- `renderTrack()`: uses `esc(c.name)` in innerHTML — clean
- `renderInspector()`: uses `esc(c.name)` in innerHTML — clean
- Error display: `'Failed: ' + esc(e.message)` — clean
- No unescaped user content in innerHTML

`edit.js` is clean.

Now I'm thinking about what to add next. The retry button for failed jobs in jobs.html stands out as useful — the api.js already has `retryAsset()` available, so I could add a button to re-queue failed jobs without needing to go back to the library. Let me start implementing that in the `renderRow` function.

I need to add a conditional retry button that only shows when a job has failed and has an asset_id attached. The button would call `retryAsset()` with the asset ID, then handle the response with a toast notification and reload the jobs list. But I'm realizing jobs.html doesn't actually import api.js — it has its own minimal API wrapper, so I'll need to either add that import or work with what's already available.

Looking at what `retryAsset` does based on the session notes, it's making a POST request to the asset retry endpoint and returning the result. I can implement this directly in jobs.html using the existing `api()` helper instead of pulling in the full api.js file.

For the UI, I'll add a Retry button in the job row that appears when a job has failed and has an associated asset ID, then wire it up to call the retry endpoint.

Adding retry button markup... I'm adding a retry capability to the Jobs page so users can re-queue failed transcode jobs without navigating elsewhere. I'll use the existing `retryAsset` function from the api.js helper to make the POST request, and the button will display a success or error toast depending on the response. I'm weighing whether to add a retry button to jobs.html for handling transient proxy or thumbnail generation failures, plus an "Open in Editor" link from the library asset cards. The editor supports URL parameters like `?project=<projectId>&asset=<assetId>`, so I'd need to check what parameters each editor variant expects to route users correctly.

Actually, the retry button feels more self-contained and operationally valuable—it's a straightforward UX fix without needing to understand the full editor architecture. I'll focus there and use the existing `api()` helper in jobs.html to call a POST endpoint for retrying failed assets.

Now I need to check the current jobs.html file to see where to add the retry button and how to integrate it properly.


`edit.js` is clean — uses `esc()` consistently throughout. `tokens.html` fixed (commit de4cb1b).

Now implementing a meaningful new feature: **Retry button for failed jobs** in `jobs.html`. Failed jobs with an `asset_id` will get a Retry button that re-queues the asset via `POST /api/v1/assets/:id/retry`. Let me check the exact retry endpoint first:


File: services/web-ui/public/js/api.js

// Wild Dragon MAM - API Helper Module

const API_BASE = '/api/v1';
const CAPTURE_BASE = '/capture';

// Session ID for the active capture — set on start, cleared on stop
let _captureSessionId = null;

/**
 * Wrapper around fetch with JSON parsing and error handling
 */
async function api(path, options = {}) {
    const url = `${API_BASE}${path}`;
    const config = {
        credentials: 'include',
        headers: {
            'Content-Type': 'application/json',
            ...options.headers,
        },
        ...options,
    };

    try {
        const response = await fetch(url, config);

        if (!response.ok) {
            const error = await response.json().catch(() => ({ message: response.statusText }));
            throw new Error(error.message || `API Error: ${response.status}`);
        }

        const data = await response.json();
        return { success: true, data };
    } catch (error) {
        console.error(`API Error on ${path}:`, error);
        return { success: false, error: error.message };
    }
}

/**
 * Wrapper around fetch for capture endpoints (goes directly to capture service via nginx)
 */
async function captureApi(path, options = {}) {
    const url = `${CAPTURE_BASE}${path}`;
    const config = {
        credentials: 'include',
        headers: {
            'Content-Type': 'application/json',
            ...options.headers,
        },
        ...options,
    };

    try {
        const response = await fetch(url, config);

        if (!response.ok) {
            const error = await response.json().catch(() => ({ message: response.statusText }));
            throw new Error(error.message || `Capture API Error: ${response.status}`);
        }

        const data = await response.json();
        return { success: true, data };
    } catch (error) {
        console.error(`Capture API Error on ${path}:`, error);
        return { success: false, error: error.message };
    }
}

// ============================================================
// ASSET API CALLS
// ============================================================

async function getAssets(filters = {}) {
    const params = new URLSearchParams(filters);
    const path = `/assets${params.toString() ? '?' + params.toString() : ''}`;
    const r = await api(path);
    // API returns { assets, total }. Callers expect r.data to be the array,
    // so unwrap here — but preserve total as a sibling for any caller that wants it.
    if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
        r.total = r.data.total;
        r.data = r.data.assets;
    }
    return r;
}

async function getAsset(assetId) {
    return api(`/assets/${assetId}`);
}

async function getAssetStreamUrl(assetId) {
    return api(`/assets/${assetId}/stream`);
}

async function updateAsset(assetId, data) {
    return api(`/assets/${assetId}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
    });
}

async function searchAssets(query) {
    return api(`/assets?q=${encodeURIComponent(query)}`);
}

async function getAssetMetadata(assetId) {
    return api(`/assets/${assetId}`);
}

async function deleteAsset(assetId, { hard = false } = {}) {
    const qs = hard ? '?hard=true' : '';
    return api(`/assets/${assetId}${qs}`, { method: 'DELETE' });
}

async function retryAsset(assetId) {
    return api(`/assets/${assetId}/retry`, { method: 'POST' });
}

async function moveAsset(assetId, binId) {
    return api(`/assets/${assetId}`, {
        method: 'PATCH',
        body: JSON.stringify({ bin_id: binId || null }),
    });
}

async function copyAsset(assetId, { binId, projectId } = {}) {
    return api(`/assets/${assetId}/copy`, {
        method: 'POST',
        body: JSON.stringify({ binId: binId || null, projectId: projectId || null }),
    });
}

// ============================================================
// PROJECT API CALLS
// ============================================================

async function getProjects() {
    return api('/projects');
}

async function getProject(projectId) {
    return api(`/projects/${projectId}`);
}

async function createProject(name, description = '') {
    return api('/projects', {
        method: 'POST',
        body: JSON.stringify({ name, description }),
    });
}

async function updateProject(projectId, data) {
    return api(`/projects/${projectId}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
    });
}

async function deleteProject(projectId) {
    return api(`/projects/${projectId}`, {
        method: 'DELETE',
    });
}

// ============================================================
// BIN API CALLS
// ============================================================

async function getBins(projectId) {
    return api(`/bins?project_id=${projectId}`);
}

async function getBin(projectId, binId) {
    return api(`/bins/${binId}`);
}

async function createBin(projectId, name, description = '') {
    return api('/bins', {
        method: 'POST',
        body: JSON.stringify({ project_id: projectId, name }),
    });
}

async function updateBin(projectId, binId, data) {
    return api(`/bins/${binId}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
    });
}

async function deleteBin(projectId, binId) {
    return api(`/bins/${binId}`, {
        method: 'DELETE',
    });
}

// ============================================================
// CAPTURE API CALLS
// ============================================================

async function getCaptureDevices() {
    const result = await captureApi('/devices');
    if (result.success && result.data) {
        const raw = Array.isArray(result.data)
            ? result.data
            : (result.data.devices || []);
        result.data = raw.map(d => ({
            id:        d.index !== undefined ? d.index : d.id,
            name:      d.name,
            interface: d.interface || 'DeckLink',
        }));
    }
    return result;
}

async function getCaptureStatus() {
    return captureApi('/status');
}

async function getRecordingStatus() {
    return captureApi('/status');
}

async function startRecording(deviceIndex, projectId, binId, clipName) {
    const result = await captureApi('/start', {
        method: 'POST',
        body: JSON.stringify({
            device:     parseInt(deviceIndex, 10),
            project_id: projectId,
            bin_id:     binId || null,
            clip_name:  clipName,
        }),
    });
    if (result.success && result.data && result.data.sessionId) {
        _captureSessionId = result.data.sessionId;
    }
    return result;
}

async function stopRecording() {
    let sessionId = _captureSessionId;

    if (!sessionId) {
        const statusResult = await captureApi('/status');
        if (statusResult.success && statusResult.data && statusResult.data.sessionId) {
            sessionId = statusResult.data.sessionId;
        }
    }

    const result = await captureApi('/stop', {
        method: 'POST',
        body: JSON.stringify({ session_id: sessionId }),
    });

    if (result.success) {
        _captureSessionId = null;
    }
    return result;
}

async function getRecentCaptures(limit = 10) {
    const r = await api(`/assets?limit=${limit}`);
    if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
        r.total = r.data.total;
        r.data = r.data.assets;
    }
    return r;
}

async function getRecordingTimecode() {
    return { success: true, data: { timecode: null } };
}

// ============================================================
// UTILITY FUNCTIONS
// ============================================================

function formatFileSize(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}

function formatDuration(seconds) {
    if (!seconds || seconds < 0) return '00:00:00';
    const hours   = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const secs    = Math.floor(seconds % 60);
    return [hours, minutes, secs].map(v => v.toString().padStart(2, '0')).join(':');
}

function getStatusLabel(status) {
    const labels = {
        ingesting:  'Ingesting',
        processing: 'Processing',
        ready:      'Ready',
        error:      'Error',
        archived:   'Archived',
    };
    return labels[status] || status;
}

function getStatusBadgeClass(status) {
    return `badge-${status}`;
}

function debounce(func, wait) {
    let timeout;
    return function executedFunction(...args) {
        const later = () => { clearTimeout(timeout); func(...args); };
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
    };
}

function throttle(func, limit) {
    let inThrottle;
    return function(...args) {
        if (!inThrottle) {
            func.apply(this, args);
            inThrottle = true;
            setTimeout(() => inThrottle = false, limit);
        }
    };
}

// ============================================================
// UPLOAD API CALLS
// ============================================================

async function initUpload(data) {
    const body = {
        filename:    data.filename,
        fileSize:    data.file_size  || data.fileSize,
        contentType: data.content_type || data.contentType,
        projectId:   data.project_id || data.projectId,
        binId:       data.bin_id     || data.binId || null,
        tags:        data.tags       || null,
    };
    return api('/upload/init', { method: 'POST', body: JSON.stringify(body) });
}

async function completeUpload(data) {
    const body = {
        uploadId: data.upload_id || data.uploadId,
        key:      data.key,
        assetId:  data.asset_id  || data.assetId,
        parts:    (data.parts || []).map(p => ({
            partNumber: p.part_number || p.partNumber,
            ETag:       p.etag        || p.ETag,
        })),
    };
    return api('/upload/complete', { method: 'POST', body: JSON.stringify(body) });
}

async function abortUpload(data) {
    const body = {
        uploadId: data.upload_id || data.uploadId,
        key:      data.key,
        assetId:  data.asset_id  || data.assetId,
    };
    return api('/upload/abort', { method: 'POST', body: JSON.stringify(body) });
}

async function uploadPart(formData) {
    try {
        const response = await fetch('/api/v1/upload/part', {
            method: 'POST',
            credentials: 'include',
            body: formData,
        });
        if (!response.ok) throw new Error(`Upload part failed: ${response.status}`);
        const data = await response.json();
        return { success: true, data };
    } catch (error) {
        return { success: false, error: error.message };
    }
}

async function simpleUpload(formData) {
    try {
        const response = await fetch('/api/v1/upload/simple', {
            method: 'POST',
            credentials: 'include',
            body: formData,
        });
        if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
        const data = await response.json();
        return { success: true, data };
    } catch (error) {
        return { success: false, error: error.message };
    }
}

// ============================================================
// RECORDER API CALLS
// ============================================================

async function getRecorders() {
    return api('/recorders');
}

async function createRecorder(data) {
    return api('/recorders', { method: 'POST', body: JSON.stringify(data) });
}

async function patchRecorder(id, data) {
    return api(`/recorders/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
}

async function startRecorder(id) {
    return api(`/recorders/${id}/start`, { method: 'POST' });
}

async function stopRecorder(id) {
    return api(`/recorders/${id}/stop`, { method: 'POST' });
}

async function deleteRecorder(id) {
    return api(`/recorders/${id}`, { method: 'DELETE' });
}

async function getRecorderStatus(id) {
    return api(`/recorders/${id}/status`);
}

// ============================================================
// USERS API CALLS (admin)
// ============================================================

async function getUsers() {
    return api('/users');
}

async function createUser(data) {
    return api('/users', { method: 'POST', body: JSON.stringify(data) });
}

async function updateUser(id, data) {
    return api(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
}

async function deleteUser(id) {
    return api(`/users/${id}`, { method: 'DELETE' });
}

// ============================================================
// GROUPS API CALLS (admin)
// ============================================================

async function getGroups() {
    return api('/groups');
}

async function createGroup(data) {
    return api('/groups', { method: 'POST', body: JSON.stringify(data) });
}

async function updateGroup(id, data) {
    return api(`/groups/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
}

async function deleteGroup(id) {
    return api(`/groups/${id}`, { method: 'DELETE' });
}

async function getGroupMembers(id) {
    return api(`/groups/${id}/members`);
}

async function addGroupMember(groupId, userId) {
    return api(`/groups/${groupId}/members`, {
        method: 'POST',
        body: JSON.stringify({ user_id: userId }),
    });
}

async function removeGroupMember(groupId, userId) {
    return api(`/groups/${groupId}/members/${userId}`, { method: 'DELETE' });
}

// ============================================================
// TOKENS API CALLS
// ============================================================

async function getTokens() {
    return api('/tokens');
}

async function createToken(data) {
    return api('/tokens', { method: 'POST', body: JSON.stringify(data) });
}

async function revokeToken(id) {
    return api(`/tokens/${id}`, { method: 'DELETE' });
}

// ============================================================
// SEQUENCE API CALLS
// ============================================================

async function getSequences(projectId) {
  return api(`/sequences?project_id=${projectId}`);
}

async function createSequence(data) {
  return api('/sequences', {
    method: 'POST',
    body: JSON.stringify(data),
  });
}

async function getSequence(sequenceId) {
  return api(`/sequences/${sequenceId}`);
}

async function updateSequence(sequenceId, data) {
  return api(`/sequences/${sequenceId}`, {
    method: 'PUT',
    body: JSON.stringify(data),
  });
}

async function deleteSequence(sequenceId) {
  return api(`/sequences/${sequenceId}`, { method: 'DELETE' });
}

/**
 * Replace all clips in a sequence.
 * @param {string} sequenceId
 * @param {Array<{asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames}>} clips
 */
async function syncSequenceClips(sequenceId, clips) {
  return api(`/sequences/${sequenceId}/clips`, {
    method: 'PUT',
    body: JSON.stringify(clips),
  });
}

/**
 * Download EDL for the V1 track of a sequence.
 * Triggers a file download in the browser.
 */
async function exportSequenceEDL(sequenceId, filename) {
  try {
    const response = await fetch(`${API_BASE}/sequences/${sequenceId}/export/edl`, {
      method: 'POST',
      credentials: 'include',
    });
    if (!response.ok) throw new Error(`EDL export failed: ${response.status}`);
    const blob = await response.blob();
    const url  = URL.createObjectURL(blob);
    const a    = document.createElement('a');
    a.href     = url;
    a.download = filename || 'sequence.edl';
    a.click();
    URL.revokeObjectURL(url);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
}


Good, I can see that `retryAsset` in api.js uses:
```js
async function retryAsset(assetId) {
    return api(`/assets/${assetId}/retry`, { method: 'POST' });
}
```

So the endpoint is `POST /api/v1/assets/:id/retry`.

Now I need to update jobs.html to:
1. Add `retryJob()` function that calls `POST /api/v1/assets/:id/retry`
2. Add a "Retry" button in the renderRow for failed jobs that have an `asset_id`

3. Looking at the action column in renderRow, I need to add a retry button alongside the existing Details and Kill buttons, but only show it when the job has failed and has an asset_id associated with it.

4. I'll also need to implement the retryJob function that makes a POST request to re-queue the asset for processing...

Now I'm looking at the renderRow function in jobs.html to see where I need to make these targeted edits. Writing the retry handler... I'm verifying the API endpoint doesn't need a request body, just the POST method, so the retry function can call it cleanly. Now I'm adding the retry button to the job rows, showing it only when a job has failed and has an associated asset ID. I need to construct the full file content for the update since `forgejo_update_file` requires the entire file. Let me identify the exact sections in jobs.html that need modification—I'm adding a retry button to the last table cell in `renderRow` and creating a new `retryJob` function to handle the retry logic. I need to add the `retryJob` function alongside `killJob`, but sending the entire 31KB file again seems inefficient. Let me reconsider if there's a more targeted way to make these updates.

Actually, I'll go ahead with the full file approach since it's manageable. I need to carefully locate the exact button markup in the `renderRow` function and add a retry button next to the kill button.

Adding retry button to the row... I'm implementing a retry function that stops event propagation, makes an API call to requeue the asset for processing, and handles success or error responses with appropriate notifications. Since I have the full file content from the earlier read, I can apply both changes and send the complete updated file back to Forgejo.


Confirmed: `retryAsset(assetId)` calls `POST /api/v1/assets/:id/retry`. Now adding the **Retry button** to `jobs.html` for failed jobs, plus a second new feature: **"Open in Editor" link** on asset cards in the library. Starting with jobs.html:


feat(jobs): add Retry button for failed jobs with an associated asset

<!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>Jobs — Wild Dragon MAM</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>

        Jobs

        <button class="btn btn-ghost btn-sm" id="btn-clear-done" onclick="clearCompleted()">
          Clear completed
        </button>

—

Total

—

Active

—

Completed

—

Failed

          <button class="tab-btn active" data-filter="all" onclick="setFilter('all', this)">
            All 0
          </button>
          <button class="tab-btn" data-filter="active" onclick="setFilter('active', this)">
            Active 0
          </button>
          <button class="tab-btn" data-filter="completed" onclick="setFilter('completed', this)">
            Completed 0
          </button>
          <button class="tab-btn" data-filter="failed" onclick="setFilter('failed', this)">
            Failed 0
          </button>

        <select class="form-select type-select" id="type-filter" onchange="loadJobs()">
          <option value="">All types</option>
          <option value="transcode">Transcode</option>
          <option value="proxy">Proxy</option>
          <option value="thumbnail">Thumbnail</option>
          <option value="conform">Conform</option>
          <option value="ingest">Ingest</option>
        </select>

          Live

    Job Detail
    <button class="btn btn-ghost btn-sm" onclick="closeDetail()" aria-label="Close" 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>


OK | commit:43a17ec | Updated services/web-ui/public/jobs.html


Conversation compacted


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

You've hit your limit · resets 7am (UTC)

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.


You've hit your limit · resets 7am (UTC)