feat(editor): splice tool (B/S key + Split button), thumbnail hydration via signed URL, enable Export (draft for now)

This commit is contained in:
Zac Gaetano 2026-05-18 10:25:53 -04:00
parent 3ae150ad53
commit 32bce2e263
2 changed files with 60 additions and 4 deletions

View file

@ -334,7 +334,7 @@
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<button class="btn btn-ghost btn-sm" onclick="saveEDL()">Save draft</button> <button class="btn btn-ghost btn-sm" onclick="saveEDL()">Save draft</button>
<button class="btn btn-primary btn-sm" disabled title="Phase C">Export</button> <button class="btn btn-primary btn-sm" onclick="exportEDL()">Export</button>
</div> </div>
</header> </header>
@ -368,6 +368,7 @@
<span class="edit-time" id="timeDisplay">--:--.-- / --:--.--</span> <span class="edit-time" id="timeDisplay">--:--.-- / --:--.--</span>
<button class="edit-marker-btn in" id="btnSetIn" title="Set In (I)" disabled>I IN</button> <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 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>
</div> </div>
</div> </div>
@ -392,6 +393,6 @@
<script src="js/api.js"></script> <script src="js/api.js"></script>
<script src="js/topbar-strip.js"></script> <script src="js/topbar-strip.js"></script>
<script src="js/edit.js?v=1"></script> <script src="js/edit.js?v=2"></script>
</body> </body>
</html> </html>

View file

@ -24,6 +24,32 @@
try { localStorage.setItem('zampp-edit-drafts', JSON.stringify(state.drafts)); } catch {} 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 ────────────────────────────────────────────────── // ── API ──────────────────────────────────────────────────
async function apiGet(path) { async function apiGet(path) {
const r = await fetch('/api/v1' + path, { credentials: 'include' }); const r = await fetch('/api/v1' + path, { credentials: 'include' });
@ -71,10 +97,11 @@
list.innerHTML = state.filtered.map(a => { list.innerHTML = state.filtered.map(a => {
const name = a.display_name || a.filename || a.id; const name = a.display_name || a.filename || a.id;
return '<div class="edit-asset" draggable="true" data-asset-id="' + a.id + '" title="' + esc(name) + '">' + return '<div class="edit-asset" draggable="true" data-asset-id="' + a.id + '" title="' + esc(name) + '">' +
'<img class="edit-asset-thumb" src="' + thumbUrl(a.id) + '" onerror="this.style.opacity=0.2"/>' + '<img class="edit-asset-thumb" data-asset-thumb="' + a.id + '"/>' +
'<div class="edit-asset-name">' + esc(name) + '</div>' + '<div class="edit-asset-name">' + esc(name) + '</div>' +
'</div>'; '</div>';
}).join(''); }).join('');
hydrateThumbnails(list);
// Wire drag // Wire drag
list.querySelectorAll('.edit-asset').forEach(el => { list.querySelectorAll('.edit-asset').forEach(el => {
el.addEventListener('dragstart', (e) => { el.addEventListener('dragstart', (e) => {
@ -106,12 +133,13 @@
const isActive = c.uid === state.selectedClipUid; const isActive = c.uid === state.selectedClipUid;
return '<div class="edit-clip ' + (isActive ? 'active' : '') + '" data-uid="' + c.uid + '" title="' + esc(c.name) + '">' + return '<div class="edit-clip ' + (isActive ? 'active' : '') + '" data-uid="' + c.uid + '" title="' + esc(c.name) + '">' +
'<button class="edit-clip-remove" data-remove="' + c.uid + '" title="Remove from timeline">✕</button>' + '<button class="edit-clip-remove" data-remove="' + c.uid + '" title="Remove from timeline">✕</button>' +
'<img class="edit-clip-thumb" src="' + thumbUrl(c.assetId) + '" onerror="this.style.opacity=0.2"/>' + '<img class="edit-clip-thumb" data-asset-thumb="' + c.assetId + '"/>' +
'<div class="edit-clip-bar"><div class="edit-clip-bar-fill" style="left:' + inPct + '%; right:' + (100 - outPct) + '%"></div></div>' + '<div class="edit-clip-bar"><div class="edit-clip-bar-fill" style="left:' + inPct + '%; right:' + (100 - outPct) + '%"></div></div>' +
'<div class="edit-clip-name">' + esc(c.name) + '</div>' + '<div class="edit-clip-name">' + esc(c.name) + '</div>' +
'<div class="edit-clip-meta"><span>' + fmtTime(c.inSec) + '</span><span>' + fmtTime(c.outSec) + '</span></div>' + '<div class="edit-clip-meta"><span>' + fmtTime(c.inSec) + '</span><span>' + fmtTime(c.outSec) + '</span></div>' +
'</div>'; '</div>';
}).join(''); }).join('');
hydrateThumbnails(track);
$('timelineTotal').textContent = state.clips.length + ' clip' + (state.clips.length === 1 ? '' : 's') + ' · ' + fmtTime(total); $('timelineTotal').textContent = state.clips.length + ' clip' + (state.clips.length === 1 ? '' : 's') + ' · ' + fmtTime(total);
// Wire clicks // Wire clicks
track.querySelectorAll('.edit-clip').forEach(el => { track.querySelectorAll('.edit-clip').forEach(el => {
@ -158,6 +186,7 @@
'<button class="btn btn-ghost btn-sm" id="ipMoveL">← Move</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="ipMoveR">Move →</button>' +
'<button class="btn btn-ghost btn-sm" id="ipDup">Duplicate</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>' + '<button class="btn btn-danger btn-sm" id="ipRemove" style="margin-left:auto">Remove</button>' +
'</div>' + '</div>' +
'</div>'; '</div>';
@ -166,6 +195,7 @@
$('ipMoveL').onclick = () => moveClip(c.uid, -1); $('ipMoveL').onclick = () => moveClip(c.uid, -1);
$('ipMoveR').onclick = () => moveClip(c.uid, 1); $('ipMoveR').onclick = () => moveClip(c.uid, 1);
$('ipDup').onclick = () => duplicateClip(c.uid); $('ipDup').onclick = () => duplicateClip(c.uid);
const sp = $('ipSplit'); if (sp) sp.onclick = () => splitClip(c.uid, $('previewVideo').currentTime);
} }
// ── Selection / preview ────────────────────────────────── // ── Selection / preview ──────────────────────────────────
@ -195,6 +225,7 @@
$('btnPlay').disabled = true; $('btnPlay').disabled = true;
$('btnSetIn').disabled = true; $('btnSetIn').disabled = true;
$('btnSetOut').disabled = true; $('btnSetOut').disabled = true;
$('btnSplit').disabled = true;
$('scrubber').disabled = true; $('scrubber').disabled = true;
$('timeDisplay').textContent = '--:--.-- / --:--.--'; $('timeDisplay').textContent = '--:--.-- / --:--.--';
} }
@ -210,6 +241,7 @@
$('btnPlay').disabled = false; $('btnPlay').disabled = false;
$('btnSetIn').disabled = false; $('btnSetIn').disabled = false;
$('btnSetOut').disabled = false; $('btnSetOut').disabled = false;
$('btnSplit').disabled = false;
$('scrubber').disabled = false; $('scrubber').disabled = false;
updateScrubberMarkers(c); updateScrubberMarkers(c);
} }
@ -262,6 +294,19 @@
refreshAll(); 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) { function moveClip(uid, dir) {
const i = state.clips.findIndex(c => c.uid === uid); const i = state.clips.findIndex(c => c.uid === uid);
if (i < 0) return; if (i < 0) return;
@ -327,6 +372,7 @@
c.inSec = Math.min(v.currentTime, c.outSec - 0.05); c.inSec = Math.min(v.currentTime, c.outSec - 0.05);
refreshAll(); refreshAll();
}); });
$('btnSplit').addEventListener('click', () => { const c = currentClip(); if (c) splitClip(c.uid, $('previewVideo').currentTime); });
$('btnSetOut').addEventListener('click', () => { $('btnSetOut').addEventListener('click', () => {
const c = currentClip(); if (!c) return; const c = currentClip(); if (!c) return;
c.outSec = Math.max(v.currentTime, c.inSec + 0.05); c.outSec = Math.max(v.currentTime, c.inSec + 0.05);
@ -338,6 +384,7 @@
if (e.key === ' ') { e.preventDefault(); $('btnPlay').click(); } if (e.key === ' ') { e.preventDefault(); $('btnPlay').click(); }
else if (e.key === 'i' || e.key === 'I') { $('btnSetIn').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 === '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') { else if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.selectedClipUid) removeClip(state.selectedClipUid); if (state.selectedClipUid) removeClip(state.selectedClipUid);
} else if (e.key === 'ArrowLeft' && e.altKey) { if (state.selectedClipUid) moveClip(state.selectedClipUid, -1); } } else if (e.key === 'ArrowLeft' && e.altKey) { if (state.selectedClipUid) moveClip(state.selectedClipUid, -1); }
@ -375,6 +422,14 @@
} }
// ── Draft save ─────────────────────────────────────────── // ── 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 () { window.saveEDL = function () {
const edl = { const edl = {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),