diff --git a/services/web-ui/public/js/edit.js b/services/web-ui/public/js/edit.js new file mode 100644 index 0000000..8c2dae2 --- /dev/null +++ b/services/web-ui/public/js/edit.js @@ -0,0 +1,424 @@ +// 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 {} + } + + // ── 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(''); + // 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(''); + $('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); + } + + // ── 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; + $('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; + $('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 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(); + }); + $('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 === '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.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); + } +})();