diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index f6f2cae..126b020 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -34,6 +34,8 @@ function AssetDetail({ asset, onClose }) { const [streamLoading, setStreamLoading] = React.useState(false); const [videoDuration, setVideoDuration] = React.useState(0); const [retrying, setRetrying] = React.useState(false); + const [filmFrames, setFilmFrames] = React.useState([]); + const [filmstripLoading, setFilmstripLoading] = React.useState(false); const videoRef = React.useRef(null); const assetId = asset && asset.id; @@ -49,6 +51,7 @@ function AssetDetail({ asset, onClose }) { setVideoDuration(0); setCurrentMs(0); setPlaying(false); + setFilmFrames([]); setStreamLoading(true); window.ZAMPP_API.fetch('/assets/' + assetId + '/stream') .then(function(r) { @@ -75,6 +78,67 @@ function AssetDetail({ asset, onClose }) { return function() { hls.destroy(); }; }, [streamUrl, streamType]); + // Build filmstrip from real video when browser-playable media exists. + React.useEffect(() => { + if (!streamUrl || totalMs <= 0 || streamType === 'hls') { + setFilmFrames([]); + setFilmstripLoading(false); + return; + } + let cancelled = false; + const build = async function() { + setFilmstripLoading(true); + try { + const probe = document.createElement('video'); + probe.crossOrigin = 'anonymous'; + probe.muted = true; + probe.playsInline = true; + probe.preload = 'auto'; + probe.src = streamUrl; + await new Promise(function(resolve, reject) { + probe.onloadedmetadata = resolve; + probe.onerror = reject; + }); + const frameCount = 28; + const width = 160; + const height = 90; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + const nextFrames = []; + for (let i = 0; i < frameCount; i++) { + const at = frameCount === 1 ? 0 : (probe.duration * i) / (frameCount - 1); + await new Promise(function(resolve) { + const done = function() { + try { + ctx.drawImage(probe, 0, 0, width, height); + nextFrames.push(canvas.toDataURL('image/jpeg', 0.72)); + } catch (_) { + nextFrames.push(null); + } + resolve(); + }; + const onSeeked = function() { + probe.removeEventListener('seeked', onSeeked); + done(); + }; + probe.addEventListener('seeked', onSeeked); + probe.currentTime = Math.min(Math.max(at, 0), Math.max(0, probe.duration - 0.05)); + }); + if (cancelled) return; + } + if (!cancelled) setFilmFrames(nextFrames); + } catch (_) { + if (!cancelled) setFilmFrames([]); + } finally { + if (!cancelled) setFilmstripLoading(false); + } + }; + build(); + return function() { cancelled = true; }; + }, [streamUrl, streamType, totalMs]); + // Fake playback timer — only used when no real video stream React.useEffect(() => { if (!playing || totalMs <= 0 || streamUrl) return; @@ -384,7 +448,7 @@ function AssetDetail({ asset, onClose }) { )} {totalMs > 0 && ( - + )} @@ -479,22 +543,27 @@ function PlaybackBar({ current, total, onSeek, comments }) { ); } -function FilmStrip({ seed, current, total, onSeek, comments }) { +function FilmStrip({ seed, current, total, onSeek, comments, frames, loading }) { const ref = React.useRef(null); - const frames = 28; + const fallbackFrames = 28; const handle = function(e) { if (!ref.current || total <= 0) return; const r = ref.current.getBoundingClientRect(); onSeek(((e.clientX - r.left) / r.width) * total); }; const pct = total > 0 ? (current / total) * 100 : 0; + const items = Array.isArray(frames) && frames.length ? frames : Array.from({ length: fallbackFrames }).map(function(_, i) { return null; }); return (
- {Array.from({ length: frames }).map(function(_, i) { + {items.map(function(src, i) { return ( -
- +
+ {src ? ( + + ) : ( + + )}
); })} @@ -515,6 +584,7 @@ function FilmStrip({ seed, current, total, onSeek, comments }) { return {msToTimecode(p * total)}; })}
+ {loading &&
Building filmstrip…
}
); } diff --git a/services/web-ui/public/screens-editor.jsx b/services/web-ui/public/screens-editor.jsx index 282ba28..dd57171 100644 --- a/services/web-ui/public/screens-editor.jsx +++ b/services/web-ui/public/screens-editor.jsx @@ -27,6 +27,7 @@ function Editor() { const [insertTrack, setInsertTrack] = React.useState(0); const [renamingSeq, setRenamingSeq] = React.useState(false); const [renameVal, setRenameVal] = React.useState(''); + const [clipMenu, setClipMenu] = React.useState(null); const srcVideoRef = React.useRef(null); const pgmVideoRef = React.useRef(null); const tlRef = React.useRef(null); @@ -37,6 +38,8 @@ function Editor() { // Refs so Timeline callbacks always read current values without stale closure issues const historyRef = React.useRef([[]]); const historyIdxRef = React.useRef(0); + const currentSeqRef = React.useRef(null); + const isDirtyRef = React.useRef(false); React.useEffect(() => { const data = window.ZAMPP_DATA; @@ -47,6 +50,23 @@ function Editor() { if (pid) loadSequences(pid); }, []); + React.useEffect(() => { currentSeqRef.current = currentSeq; }, [currentSeq]); + React.useEffect(() => { isDirtyRef.current = isDirty; }, [isDirty]); + + React.useEffect(() => { + const refreshAssets = function() { + window.ZAMPP_API.refreshAssets() + .then(function(list) { setAssets(list); }) + .catch(function() {}); + window.ZAMPP_API.fetch('/bins') + .then(function(list) { setBins((list || []).map(function(b) { return { ...b, count: b.asset_count || 0, icon: b.type || 'grid' }; })); }) + .catch(function() {}); + }; + const onAssetsChanged = function() { refreshAssets(); }; + window.addEventListener('df:assets-changed', onAssetsChanged); + return function() { window.removeEventListener('df:assets-changed', onAssetsChanged); }; + }, []); + React.useEffect(() => { if (!tlRef.current || tlInitRef.current || !window.Timeline) return; tlInitRef.current = true; @@ -55,6 +75,8 @@ function Editor() { scale: 100, onClipsChanged: handleClipsChanged, onPlayheadMoved: handlePlayheadMoved, + onClipContextMenu: handleClipContextMenu, + onExternalDrop: handleExternalDrop, }); }, []); @@ -64,6 +86,17 @@ function Editor() { } }, [scale]); + React.useEffect(() => { + if (!clipMenu) return; + const close = function() { setClipMenu(null); }; + window.addEventListener('click', close); + window.addEventListener('contextmenu', close); + return function() { + window.removeEventListener('click', close); + window.removeEventListener('contextmenu', close); + }; + }, [clipMenu]); + async function loadSequences(pid) { try { const r = await window.ZAMPP_API.getSequences(pid); @@ -79,6 +112,7 @@ function Editor() { const clips = (r.clips || []).map(c => ({ ...c, _id: _uid() })); const seq = { ...r, clips }; setCurrentSeq(seq); + currentSeqRef.current = seq; setPlayheadFrames(0); setSelectedClipId(null); historyRef.current = [clips]; @@ -86,6 +120,7 @@ function Editor() { setHistory([clips]); setHistoryIdx(0); setIsDirty(false); + isDirtyRef.current = false; setSaveStatus(''); renderTimelineClips(clips); } catch (e) { console.error('Failed to open sequence', e); } @@ -121,6 +156,7 @@ function Editor() { const remaining = sequences.filter(s => s.id !== currentSeq.id); setSequences(remaining); setCurrentSeq(null); + currentSeqRef.current = null; setHistory([[]]); setHistoryIdx(0); if (remaining.length) openSequence(remaining[0].id); @@ -134,7 +170,11 @@ function Editor() { } function handleClipsChanged(clips) { - setCurrentSeq(prev => prev ? { ...prev, clips } : prev); + setCurrentSeq(prev => { + const next = prev ? { ...prev, clips } : prev; + currentSeqRef.current = next; + return next; + }); const newHistory = historyRef.current.slice(0, historyIdxRef.current + 1); newHistory.push(clips.map(c => ({ ...c }))); if (newHistory.length > 50) newHistory.shift(); @@ -149,19 +189,46 @@ function Editor() { setPlayheadFrames(Math.max(0, frames)); } + function handleClipContextMenu(payload) { + setSelectedClipId(payload.clip._id); + setClipMenu(payload); + } + + async function handleExternalDrop(payload) { + const raw = payload.dataTransfer.getData('application/json') || payload.dataTransfer.getData('text/plain'); + let assetId = raw; + if (!assetId) return; + try { + const parsed = JSON.parse(raw); + assetId = parsed.assetId || parsed.id || assetId; + } catch (_) {} + const asset = assets.find(function(a) { return a.id === assetId; }); + if (!asset) return; + let streamUrl = streamCacheRef.current[asset.id]; + if (!streamUrl) { + try { + const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream'); + if (r && r.url) { streamUrl = r.url; streamCacheRef.current[asset.id] = r.url; } + } catch (e) { console.error('Failed to get stream URL', e); } + } + window.Timeline.addClip({ ...asset, streamUrl }, 0, (asset.duration_ms || 10000) / 1000, payload.track, payload.timelineFrames); + } + function markDirty() { setIsDirty(true); + isDirtyRef.current = true; setSaveStatus('Unsaved'); clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(saveSequence, 2000); } async function saveSequence() { - if (!currentSeq || !isDirty) return; + const seq = currentSeqRef.current; + if (!seq || !isDirtyRef.current) return; clearTimeout(saveTimerRef.current); - setSaveStatus('Saving\u2026'); + setSaveStatus('Saving…'); try { - const clips = (currentSeq.clips || []).map(c => ({ + const clips = (seq.clips || []).map(c => ({ asset_id: c.asset_id, track: c.track, timeline_in_frames: c.timeline_in_frames, @@ -169,8 +236,9 @@ function Editor() { source_in_frames: c.source_in_frames, source_out_frames: c.source_out_frames, })); - await window.ZAMPP_API.syncSequenceClips(currentSeq.id, clips); + await window.ZAMPP_API.syncSequenceClips(seq.id, clips); setIsDirty(false); + isDirtyRef.current = false; setSaveStatus('Saved'); setTimeout(() => setSaveStatus(''), 2000); } catch (e) { @@ -185,7 +253,11 @@ function Editor() { historyIdxRef.current = idx; setHistoryIdx(idx); const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() })); - setCurrentSeq(prev => prev ? { ...prev, clips } : prev); + setCurrentSeq(prev => { + const next = prev ? { ...prev, clips } : prev; + currentSeqRef.current = next; + return next; + }); renderTimelineClips(clips); markDirty(); } @@ -196,7 +268,11 @@ function Editor() { historyIdxRef.current = idx; setHistoryIdx(idx); const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() })); - setCurrentSeq(prev => prev ? { ...prev, clips } : prev); + setCurrentSeq(prev => { + const next = prev ? { ...prev, clips } : prev; + currentSeqRef.current = next; + return next; + }); renderTimelineClips(clips); markDirty(); } @@ -247,6 +323,42 @@ function Editor() { window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => console.error('EDL export failed', e)); } + function openClipInSource(clip) { + const asset = assets.find(function(a) { return a.id === clip.asset_id; }); + if (asset) loadSourceAsset(asset); + setClipMenu(null); + } + + function deleteClip(clip) { + const clips = ((currentSeqRef.current && currentSeqRef.current.clips) || []).filter(function(c) { return c._id !== clip._id; }); + setClipMenu(null); + handleClipsChanged(clips); + renderTimelineClips(clips); + } + + function duplicateClip(clip) { + const duration = clip.timeline_out_frames - clip.timeline_in_frames; + const dupe = { ...clip, _id: _uid(), timeline_in_frames: clip.timeline_out_frames, timeline_out_frames: clip.timeline_out_frames + duration }; + const clips = [ ...((currentSeqRef.current && currentSeqRef.current.clips) || []), dupe ]; + setClipMenu(null); + handleClipsChanged(clips); + renderTimelineClips(clips); + } + + function splitClipAtPlayhead(clip) { + const split = playheadFrames; + if (split <= clip.timeline_in_frames || split >= clip.timeline_out_frames) { setClipMenu(null); return; } + const offset = split - clip.timeline_in_frames; + const left = { ...clip, _id: _uid(), timeline_out_frames: split, source_out_frames: clip.source_in_frames + offset }; + const right = { ...clip, _id: _uid(), timeline_in_frames: split, source_in_frames: clip.source_in_frames + offset }; + const clips = ((currentSeqRef.current && currentSeqRef.current.clips) || []).flatMap(function(c) { + return c._id === clip._id ? [left, right] : [c]; + }); + setClipMenu(null); + handleClipsChanged(clips); + renderTimelineClips(clips); + } + const srcInLabel = srcIn != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcIn)) : 'In set') : 'Mark In'; const srcOutLabel = srcOut != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcOut)) : 'Out set') : 'Mark Out'; @@ -283,8 +395,8 @@ function Editor() { )} - - + + @@ -339,7 +451,13 @@ function Editor() {
No assets
) : assets.filter(a => !seqFilter || a.bin_id === seqFilter).map(a => (
loadSourceAsset(a)}>
@@ -364,11 +482,25 @@ function Editor() { setScale(s); }} /> {scale}px + Drag media here. Right-click clips for actions.
+ {clipMenu && ( + + )} + {/* ── New sequence modal ── */} {showNewSeq && (
{ + if (!ref.current) return; + const r = ref.current.getBoundingClientRect(); + const margin = 8; + let nx = x, ny = y; + if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin; + if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin; + setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) }); + }, [x, y]); + + return ( +
+
{clip.display_name || clip.name || 'Clip'}
+ + + +
+ +
+ ); +} + function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) { const [pgmPlaying, setPgmPlaying] = React.useState(false); const [pgmClipIdx, setPgmClipIdx] = React.useState(-1); @@ -456,7 +615,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame if (r && r.url) { url = r.url; cache[clip.asset_id] = url; } } catch (e) { console.error('Stream fetch failed', e); return; } } - if (vid.src !== url) { vid.src = url; await vid.load(); } + if (vid.src !== url) { vid.src = url; vid.load(); } const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94); vid.currentTime = srcInSecs; vid.play().catch(() => {}); @@ -492,7 +651,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame onEnded={advanceClip} />
- + {window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'} @@ -522,4 +681,4 @@ function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSe return null; } -window.Editor = Editor; \ No newline at end of file +window.Editor = Editor;