// 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 '
' + '' + '' + '
' + '
' + 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' + '
' + '' + '' + '' + '' + '' + '' + '
' + '
'; $('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 ? '' : ''; } // ── 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= 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); } })();