dragonflight/services/web-ui/public/screens-editor.jsx

455 lines
No EOL
21 KiB
JavaScript

// 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 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);
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);
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); }
}
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 = history.slice(0, historyIdx + 1);
newHistory.push(clips.map(c => ({ ...c })));
if (newHistory.length > 50) newHistory.shift();
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 (historyIdx <= 0) return;
const idx = historyIdx - 1;
setHistoryIdx(idx);
const clips = (history[idx] || []).map(c => ({ ...c, _id: _uid() }));
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
renderTimelineClips(clips);
markDirty();
}
function redo() {
if (historyIdx >= history.length - 1) return;
const idx = historyIdx + 1;
setHistoryIdx(idx);
const clips = (history[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, 0
);
}
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 (
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* ── Top toolbar ── */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0, height: 40 }}>
<Icon name="editor" size={14} />
<select value={currentSeq ? currentSeq.id : ''} onChange={e => openSequence(e.target.value)}
style={{ fontSize: 12, height: 24, background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text-primary)', maxWidth: 200 }}>
{sequences.length === 0 && <option value="">No sequences</option>}
{sequences.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} onClick={() => setShowNewSeq(true)} title="New sequence">
<Icon name="plus" size={12} />
</button>
<span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
<button className="btn ghost sm" onClick={undo} disabled={historyIdx <= 0} title="Undo (Ctrl+Z)">\u21A9</button>
<button className="btn ghost sm" onClick={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)">\u21AA</button>
<span style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={exportEDL} title="Export CMX3600 EDL">Export EDL</button>
<button className="btn primary sm" onClick={saveSequence}>{saveStatus || 'Save'}</button>
{saveStatus && <span style={{ fontSize: 10, color: saveStatus === 'Saved' ? 'var(--signal-good)' : 'var(--signal-warn)' }}>{saveStatus}</span>}
</div>
{/* ── 2x2 grid ── */}
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '35vh 1fr', overflow: 'hidden' }}>
{/* Source monitor */}
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
<video ref={srcVideoRef} preload="metadata" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
{sourceAsset ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcVideoRef.current ? srcVideoRef.current.currentTime : 0)) : '00:00:00;00') : '--:--:--;--'}
</span>
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcIn} title="Mark In (I)">{srcInLabel}</button>
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcOut} title="Mark Out (O)">{srcOutLabel}</button>
<div style={{ flex: 1 }} />
<button className="btn primary sm" style={{ fontSize: 10, padding: '2px 8px' }} onClick={addToTimeline} title="Insert at playhead">Insert</button>
</div>
</div>
{/* Program monitor */}
<ProgramMonitor
videoRef={pgmVideoRef}
currentSeq={currentSeq}
playheadFrames={playheadFrames}
setPlayheadFrames={setPlayheadFrames}
streamCacheRef={streamCacheRef}
/>
{/* Media panel (bottom left) */}
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', overflow: 'hidden', background: 'var(--bg-panel)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)' }}>Media</span>
<select value={seqFilter} onChange={e => setSeqFilter(e.target.value)}
style={{ fontSize: 10, height: 20, marginLeft: 'auto', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 2, color: 'var(--text-secondary)' }}>
<option value="">All bins</option>
{bins.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{assets.filter(a => !seqFilter || a.bin_id === seqFilter).length === 0 ? (
<div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center' }}>No assets</div>
) : assets.filter(a => !seqFilter || a.bin_id === seqFilter).map(a => (
<div key={a.id} className="media-asset-item"
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 11, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border-faint)' }}
onDoubleClick={() => loadSourceAsset(a)}>
<div style={{ width: 40, height: 24, borderRadius: 2, overflow: 'hidden', flexShrink: 0, background: 'var(--bg-surface)' }}>
<AssetThumb asset={a} size="sm" />
</div>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.name}</span>
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace' }}>{a.duration}</span>
</div>
))}
</div>
</div>
{/* Timeline (bottom right) */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0 }}>
<ToolBtn active={tool === 'select'} label="V" title="Select (V)" onClick={() => { setTool('select'); if (window.Timeline) window.Timeline.setTool('select'); }} />
<ToolBtn active={tool === 'razor'} label="C" title="Razor (C)" onClick={() => { setTool('razor'); if (window.Timeline) window.Timeline.setTool('razor'); }} />
<ToolBtn active={tool === 'hand'} label="H" title="Hand (H)" onClick={() => { setTool('hand'); if (window.Timeline) window.Timeline.setTool('hand'); }} />
<span style={{ width: 1, height: 14, background: 'var(--border)', margin: '0 4px' }} />
<input type="range" min="20" max="500" value={scale} style={{ width: 60, height: 3 }}
onChange={e => {
const s = Number(e.target.value);
setScale(s);
}} />
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace', width: 28 }}>{scale}px</span>
</div>
<div ref={tlRef} className="timeline-container" style={{ flex: 1, overflow: 'hidden' }} />
</div>
</div>
{/* ── New sequence modal ── */}
{showNewSeq && (
<div style={{ position: 'fixed', inset: 0, zIndex: 80, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setShowNewSeq(false)}>
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 8, padding: 24, minWidth: 320 }}
onClick={e => e.stopPropagation()}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>New sequence</div>
<input value={newSeqName} onChange={e => setNewSeqName(e.target.value)}
placeholder="Sequence name"
style={{ width: '100%', height: 32, padding: '0 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, color: 'var(--text-primary)', fontSize: 13, marginBottom: 12 }}
onKeyDown={e => { if (e.key === 'Enter') createNewSequence(); }} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button className="btn ghost sm" onClick={() => setShowNewSeq(false)}>Cancel</button>
<button className="btn primary sm" onClick={createNewSequence}>Create</button>
</div>
</div>
</div>
)}
{/* ── Overlay keyboard handler ── */}
<EditorKeyboard
onUndo={undo} onRedo={redo} onSave={saveSequence}
onMarkIn={markSrcIn} onMarkOut={markSrcOut}
currentSeq={currentSeq}
/>
</div>
);
}
function ToolBtn({ active, label, title, onClick }) {
return (
<button onClick={onClick} title={title}
style={{
width: 24, height: 22, fontSize: 11, fontWeight: 600,
background: active ? 'var(--accent-subtle)' : 'var(--bg-surface)',
border: '1px solid ' + (active ? 'var(--accent-border)' : 'var(--border)'),
borderRadius: 3, color: active ? 'var(--accent)' : 'var(--text-secondary)',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{label}</button>
);
}
function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) {
const [pgmPlaying, setPgmPlaying] = React.useState(false);
const [pgmClipIdx, setPgmClipIdx] = React.useState(-1);
const [pgmClips, setPgmClips] = React.useState([]);
function getV1Clips() {
if (!currentSeq || !currentSeq.clips) return [];
return currentSeq.clips.filter(c => c.track === 0).sort((a, b) => a.timeline_in_frames - b.timeline_out_frames);
}
function togglePlay() {
if (pgmPlaying) {
stopPgm();
return;
}
const v1 = getV1Clips();
if (!v1.length) return;
setPgmClips(v1);
const idx = v1.findIndex(c => playheadFrames >= c.timeline_in_frames && playheadFrames < c.timeline_out_frames);
setPgmClipIdx(idx >= 0 ? idx : 0);
setPgmPlaying(true);
}
function stopPgm() {
setPgmPlaying(false);
setPgmClipIdx(-1);
const vid = videoRef.current;
if (vid) vid.pause();
}
React.useEffect(() => {
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return;
const clip = pgmClips[pgmClipIdx];
if (!clip) { stopPgm(); return; }
const vid = videoRef.current;
if (!vid) return;
const cache = streamCacheRef.current;
(async () => {
let url = cache[clip.asset_id];
if (!url) {
try {
const r = await window.ZAMPP_API.fetch('/assets/' + clip.asset_id + '/stream');
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(); }
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
vid.currentTime = srcInSecs;
vid.play().catch(() => {});
})();
}, [pgmPlaying, pgmClipIdx]);
function advanceClip() {
const next = pgmClipIdx + 1;
if (next >= pgmClips.length) { stopPgm(); return; }
setPgmClipIdx(next);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
<video ref={videoRef} preload="auto" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }}
onTimeUpdate={() => {
if (!pgmPlaying) return;
const clip = pgmClips[pgmClipIdx];
if (!clip) return;
const fps = window.TC ? window.TC.FPS : 59.94;
const srcOutSecs = clip.source_out_frames / fps;
if (videoRef.current && videoRef.current.currentTime >= srcOutSecs) {
advanceClip();
}
}}
onEnded={advanceClip}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<button className="btn ghost sm" style={{ fontSize: 11, padding: '2px 6px' }} onClick={togglePlay}>{pgmPlaying ? '\u23F8' : '\u25B6'}</button>
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
{window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'}
</span>
</div>
</div>
);
}
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
React.useEffect(() => {
function handler(e) {
const tag = document.activeElement.tagName;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onRedo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onUndo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); onSave(); return; }
if (e.key === 'i' || e.key === 'I') { onMarkIn(); return; }
if (e.key === 'o' || e.key === 'O') { onMarkOut(); return; }
if (e.key === 'v' || e.key === 'V') { if (window.Timeline) window.Timeline.setTool('select'); return; }
if (e.key === 'c' || e.key === 'C') { if (window.Timeline) window.Timeline.setTool('razor'); return; }
if (e.key === 'h' || e.key === 'H') { if (window.Timeline) window.Timeline.setTool('hand'); return; }
if (e.code === 'Space' && !currentSeq) { e.preventDefault(); /* handled by PGM */ }
}
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq]);
return null;
}
window.Editor = Editor;