// screens-editor.jsx - NLE timeline editor // Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); } function Editor() { const [projectId, setProjectId] = React.useState(null); const [sequences, setSequences] = React.useState([]); const [currentSeq, setCurrentSeq] = React.useState(null); const [assets, setAssets] = React.useState([]); const [bins, setBins] = React.useState([]); const [sourceAsset, setSourceAsset] = React.useState(null); const [srcIn, setSrcIn] = React.useState(null); const [srcOut, setSrcOut] = React.useState(null); const [playing, setPlaying] = React.useState(false); const [playheadFrames, setPlayheadFrames] = React.useState(0); const [saveStatus, setSaveStatus] = React.useState(''); const [selectedClipId, setSelectedClipId] = React.useState(null); const [history, setHistory] = React.useState([[]]); const [historyIdx, setHistoryIdx] = React.useState(0); const [scale, setScale] = React.useState(100); const [tool, setTool] = React.useState('select'); const [seqFilter, setSeqFilter] = React.useState(''); const [showNewSeq, setShowNewSeq] = React.useState(false); const [newSeqName, setNewSeqName] = React.useState(''); const [isDirty, setIsDirty] = React.useState(false); 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); const saveTimerRef = React.useRef(null); const statusTimerRef = React.useRef(null); const streamCacheRef = React.useRef({}); const mountedRef = React.useRef(true); React.useEffect(() => () => { mountedRef.current = false; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); if (statusTimerRef.current) clearTimeout(statusTimerRef.current); }, []); const tlInitRef = React.useRef(false); // 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); const assetsRef = React.useRef([]); React.useEffect(() => { const data = window.ZAMPP_DATA; setAssets(data.ASSETS || []); setBins(data.BINS || []); const pid = data.PROJECTS && data.PROJECTS.length ? data.PROJECTS[0].id : null; setProjectId(pid); if (pid) loadSequences(pid); }, []); React.useEffect(() => { currentSeqRef.current = currentSeq; }, [currentSeq]); React.useEffect(() => { isDirtyRef.current = isDirty; }, [isDirty]); React.useEffect(() => { assetsRef.current = assets; }, [assets]); 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; window.Timeline.init(tlRef.current, { fps: 59.94, scale: 100, onClipsChanged: handleClipsChanged, onPlayheadMoved: handlePlayheadMoved, onClipContextMenu: handleClipContextMenu, onExternalDrop: handleExternalDrop, }); }, []); React.useEffect(() => { if (window.Timeline && tlInitRef.current) { window.Timeline.setScale(scale); } }, [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); const list = Array.isArray(r) ? r : []; setSequences(list); if (list.length) openSequence(list[0].id); } catch (e) { window.DF_LOG.warn('[editor] load sequences failed', e); } } async function openSequence(id) { try { const r = await window.ZAMPP_API.getSequence(id); 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]; historyIdxRef.current = 0; setHistory([clips]); setHistoryIdx(0); setIsDirty(false); isDirtyRef.current = false; setSaveStatus(''); renderTimelineClips(clips); } catch (e) { window.DF_LOG.warn('[editor] open sequence failed', e); } } async function createNewSequence() { if (!projectId) return; const name = newSeqName.trim() || ('Sequence ' + (sequences.length + 1)); try { const r = await window.ZAMPP_API.createSequence({ project_id: projectId, name }); setSequences(prev => [r, ...prev]); setNewSeqName(''); setShowNewSeq(false); openSequence(r.id); } catch (e) { window.DF_LOG.warn('[editor] create sequence failed', e); } } async function renameSequence() { if (!currentSeq || !renameVal.trim()) { setRenamingSeq(false); return; } try { await window.ZAMPP_API.updateSequence(currentSeq.id, { name: renameVal.trim() }); setSequences(prev => prev.map(s => s.id === currentSeq.id ? { ...s, name: renameVal.trim() } : s)); setCurrentSeq(prev => prev ? { ...prev, name: renameVal.trim() } : prev); } catch (e) { window.DF_LOG.warn('[editor] rename failed', e); } setRenamingSeq(false); } async function deleteSequence() { if (!currentSeq) return; if (!window.confirm('Delete sequence "' + currentSeq.name + '"? This cannot be undone.')) return; try { await window.ZAMPP_API.deleteSequence(currentSeq.id); 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); } catch (e) { window.DF_LOG.warn('[editor] delete failed', e); } } function renderTimelineClips(clips) { if (window.Timeline && tlRef.current) { window.Timeline.render(clips || [], { fps: 59.94 }); } } function handleClipsChanged(clips) { 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(); historyRef.current = newHistory; historyIdxRef.current = newHistory.length - 1; setHistory(newHistory); setHistoryIdx(newHistory.length - 1); markDirty(); } function handlePlayheadMoved(frames) { 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 = assetsRef.current.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) { window.DF_LOG.warn('[editor] stream URL failed', 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() { const seq = currentSeqRef.current; if (!seq || !isDirtyRef.current) return; clearTimeout(saveTimerRef.current); setSaveStatus('Saving…'); try { const clips = (seq.clips || []).map(c => ({ asset_id: c.asset_id, track: c.track, timeline_in_frames: c.timeline_in_frames, timeline_out_frames: c.timeline_out_frames, source_in_frames: c.source_in_frames, source_out_frames: c.source_out_frames, })); await window.ZAMPP_API.syncSequenceClips(seq.id, clips); if (!mountedRef.current) return; setIsDirty(false); isDirtyRef.current = false; setSaveStatus('Saved'); if (statusTimerRef.current) clearTimeout(statusTimerRef.current); statusTimerRef.current = setTimeout(() => { if (mountedRef.current) setSaveStatus(''); }, 2000); } catch (e) { if (mountedRef.current) setSaveStatus('Save failed'); window.DF_LOG.warn('[editor] auto-save failed', e); } } function undo() { if (historyIdxRef.current <= 0) return; const idx = historyIdxRef.current - 1; historyIdxRef.current = idx; setHistoryIdx(idx); const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() })); setCurrentSeq(prev => { const next = prev ? { ...prev, clips } : prev; currentSeqRef.current = next; return next; }); renderTimelineClips(clips); markDirty(); } function redo() { if (historyIdxRef.current >= historyRef.current.length - 1) return; const idx = historyIdxRef.current + 1; historyIdxRef.current = idx; setHistoryIdx(idx); const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() })); setCurrentSeq(prev => { const next = prev ? { ...prev, clips } : prev; currentSeqRef.current = next; return next; }); renderTimelineClips(clips); markDirty(); } async function loadSourceAsset(asset) { setSourceAsset(asset); setSrcIn(null); setSrcOut(null); const vid = srcVideoRef.current; if (!vid) return; vid.src = ''; const cache = streamCacheRef.current; let url = cache[asset.id]; if (!url) { try { const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream'); if (r && r.url) { url = r.url; cache[asset.id] = url; } } catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); } } if (url) { vid.src = url; vid.load(); } } function markSrcIn() { const vid = srcVideoRef.current; if (vid) setSrcIn(vid.currentTime); } function markSrcOut() { const vid = srcVideoRef.current; if (vid) setSrcOut(vid.currentTime); } function addToTimeline() { if (!sourceAsset || !currentSeq) return; if (!window.Timeline) return; const vid = srcVideoRef.current; const sIn = srcIn != null ? srcIn : 0; const sOut = srcOut != null ? srcOut : (vid && vid.duration ? vid.duration : ((sourceAsset.duration_ms || 10000) / 1000)); window.Timeline.addClip( { ...sourceAsset, streamUrl: streamCacheRef.current[sourceAsset.id] }, sIn, sOut, insertTrack ); } function exportEDL() { if (!currentSeq) return; const name = (currentSeq.name || 'sequence').replace(/[^a-z0-9]/gi, '_') + '.edl'; window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => window.DF_LOG.warn('[editor] 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'; return (