feat(editor-native): Phase A — single-track editor logic (asset library, preview, in/out markers, drafts)
This commit is contained in:
parent
beb8f31674
commit
2e1bcd655f
1 changed files with 424 additions and 0 deletions
424
services/web-ui/public/js/edit.js
Normal file
424
services/web-ui/public/js/edit.js
Normal file
|
|
@ -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 = '<div class="edit-inspector-empty" style="color:var(--status-red)">Failed: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="edit-inspector-empty">' + (q ? 'No matches.' : 'No video assets yet. Upload from Ingest.') + '</div>';
|
||||
return;
|
||||
}
|
||||
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"/>' +
|
||||
'<div class="edit-asset-name">' + esc(name) + '</div>' +
|
||||
'</div>';
|
||||
}).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 '<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"/>' +
|
||||
'<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('');
|
||||
$('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 = '<div class="edit-inspector-empty">Select a clip on the timeline to inspect.</div>';
|
||||
return;
|
||||
}
|
||||
const trimmed = Math.max(0, c.outSec - c.inSec);
|
||||
host.innerHTML =
|
||||
'<div class="edit-inspector-section">' +
|
||||
'<span class="edit-inspector-label">Clip</span>' +
|
||||
'<div class="edit-inspector-clipname">' + esc(c.name) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="edit-inspector-section">' +
|
||||
'<span class="edit-inspector-label">Trim</span>' +
|
||||
'<dl class="edit-inspector-stats">' +
|
||||
'<dt>In</dt><dd>' + fmtTime(c.inSec) + '</dd>' +
|
||||
'<dt>Out</dt><dd>' + fmtTime(c.outSec) + '</dd>' +
|
||||
'<dt>Length</dt><dd>' + fmtTime(trimmed) + '</dd>' +
|
||||
'<dt>Source</dt><dd>' + fmtTime(c.durSec) + '</dd>' +
|
||||
'</dl>' +
|
||||
'</div>' +
|
||||
'<div class="edit-inspector-section">' +
|
||||
'<span class="edit-inspector-label">Actions</span>' +
|
||||
'<div class="edit-inspector-actions">' +
|
||||
'<button class="btn btn-ghost btn-sm" id="ipReset">Reset trim</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="ipDup">Duplicate</button>' +
|
||||
'<button class="btn btn-danger btn-sm" id="ipRemove" style="margin-left:auto">Remove</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
$('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
|
||||
? '<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><rect x="3" y="3" width="4" height="10"/><rect x="9" y="3" width="4" height="10"/></svg>'
|
||||
: '<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><polygon points="4,3 13,8 4,13"/></svg>';
|
||||
}
|
||||
|
||||
// ── 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=<id> 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);
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in a new issue