diff --git a/services/web-ui/public/edit.html b/services/web-ui/public/edit.html index c8c5414..a337f5a 100644 --- a/services/web-ui/public/edit.html +++ b/services/web-ui/public/edit.html @@ -334,7 +334,7 @@
- +
@@ -368,6 +368,7 @@ --:--.-- / --:--.-- + @@ -392,6 +393,6 @@ - + diff --git a/services/web-ui/public/js/edit.js b/services/web-ui/public/js/edit.js index 8c2dae2..3071fd3 100644 --- a/services/web-ui/public/js/edit.js +++ b/services/web-ui/public/js/edit.js @@ -24,6 +24,32 @@ 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' }); @@ -71,10 +97,11 @@ 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) => { @@ -106,12 +133,13 @@ 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 => { @@ -158,6 +186,7 @@ '' + '' + '' + + '' + '' + '' + ''; @@ -166,6 +195,7 @@ $('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 ────────────────────────────────── @@ -195,6 +225,7 @@ $('btnPlay').disabled = true; $('btnSetIn').disabled = true; $('btnSetOut').disabled = true; + $('btnSplit').disabled = true; $('scrubber').disabled = true; $('timeDisplay').textContent = '--:--.-- / --:--.--'; } @@ -210,6 +241,7 @@ $('btnPlay').disabled = false; $('btnSetIn').disabled = false; $('btnSetOut').disabled = false; + $('btnSplit').disabled = false; $('scrubber').disabled = false; updateScrubberMarkers(c); } @@ -262,6 +294,19 @@ 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; @@ -327,6 +372,7 @@ 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); @@ -338,6 +384,7 @@ 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); } @@ -375,6 +422,14 @@ } // ── 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(),