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);
+ }
+})();