// 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 srcVideoRef = React.useRef(null); const pgmVideoRef = React.useRef(null); const tlRef = React.useRef(null); const saveTimerRef = React.useRef(null); const streamCacheRef = React.useRef({}); 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); 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(() => { 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, }); }, []); React.useEffect(() => { if (window.Timeline && tlInitRef.current) { window.Timeline.setScale(scale); } }, [scale]); 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) { console.error('Failed to load sequences', 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); setPlayheadFrames(0); setSelectedClipId(null); historyRef.current = [clips]; historyIdxRef.current = 0; setHistory([clips]); setHistoryIdx(0); setIsDirty(false); setSaveStatus(''); renderTimelineClips(clips); } catch (e) { console.error('Failed to open sequence', 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) { console.error('Failed to create sequence', 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) { console.error('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); setHistory([[]]); setHistoryIdx(0); if (remaining.length) openSequence(remaining[0].id); } catch (e) { console.error('Delete failed', e); } } function renderTimelineClips(clips) { if (window.Timeline && tlRef.current) { window.Timeline.render(clips || [], { fps: 59.94 }); } } function handleClipsChanged(clips) { setCurrentSeq(prev => prev ? { ...prev, clips } : prev); 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 markDirty() { setIsDirty(true); setSaveStatus('Unsaved'); clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(saveSequence, 2000); } async function saveSequence() { if (!currentSeq || !isDirty) return; clearTimeout(saveTimerRef.current); setSaveStatus('Saving\u2026'); try { const clips = (currentSeq.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(currentSeq.id, clips); setIsDirty(false); setSaveStatus('Saved'); setTimeout(() => setSaveStatus(''), 2000); } catch (e) { setSaveStatus('Save failed'); console.error('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 => prev ? { ...prev, clips } : prev); 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 => prev ? { ...prev, clips } : prev); 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) { console.error('Failed to get stream URL', 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 => console.error('EDL export failed', e)); } 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 (