dragonflight/services/web-ui/public/js/edit.js

424 lines
17 KiB
JavaScript

// 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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[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);
}
})();