feat(editor): splice tool (B/S key + Split button), thumbnail hydration via signed URL, enable Export (draft for now)
This commit is contained in:
parent
3ae150ad53
commit
32bce2e263
2 changed files with 60 additions and 4 deletions
|
|
@ -334,7 +334,7 @@
|
|||
</div>
|
||||
<div class="topbar-right">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
|
@ -368,6 +368,7 @@
|
|||
<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 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>
|
||||
|
||||
|
|
@ -392,6 +393,6 @@
|
|||
|
||||
<script src="js/api.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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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 '<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>';
|
||||
}).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 '<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>' +
|
||||
'<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-name">' + esc(c.name) + '</div>' +
|
||||
'<div class="edit-clip-meta"><span>' + fmtTime(c.inSec) + '</span><span>' + fmtTime(c.outSec) + '</span></div>' +
|
||||
'</div>';
|
||||
}).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 @@
|
|||
'<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="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>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue