686 lines
No EOL
31 KiB
JavaScript
686 lines
No EOL
31 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 [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 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);
|
|
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) { 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);
|
|
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) { 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);
|
|
currentSeqRef.current = 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 => {
|
|
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) { 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() {
|
|
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);
|
|
setIsDirty(false);
|
|
isDirtyRef.current = 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 => {
|
|
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) { 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));
|
|
}
|
|
|
|
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 (
|
|
<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} />
|
|
{renamingSeq ? (
|
|
<input value={renameVal} autoFocus onChange={e => setRenameVal(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') renameSequence(); if (e.key === 'Escape') setRenamingSeq(false); }}
|
|
onBlur={renameSequence}
|
|
style={{ fontSize: 12, height: 24, padding: '0 6px', background: 'var(--bg-surface)', border: '1px solid var(--accent)', borderRadius: 3, color: 'var(--text-primary)', maxWidth: 200 }} />
|
|
) : (
|
|
<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>
|
|
{currentSeq && (
|
|
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} title="Rename sequence"
|
|
onClick={() => { setRenameVal(currentSeq.name); setRenamingSeq(true); }}>
|
|
<Icon name="edit" size={12} />
|
|
</button>
|
|
)}
|
|
{currentSeq && (
|
|
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} title="Delete sequence"
|
|
onClick={deleteSequence}>
|
|
<Icon name="trash" 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)">↩</button>
|
|
<button className="btn ghost sm" onClick={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)">↪</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 }} />
|
|
<select value={insertTrack} onChange={e => setInsertTrack(Number(e.target.value))}
|
|
title="Target track"
|
|
style={{ fontSize: 10, height: 20, background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 2, color: 'var(--text-secondary)', marginRight: 4 }}>
|
|
<option value={0}>V1</option>
|
|
<option value={1}>V2</option>
|
|
<option value={100}>A1</option>
|
|
<option value={101}>A2</option>
|
|
</select>
|
|
<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"
|
|
draggable="true"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'grab', fontSize: 11, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border-faint)' }}
|
|
onDragStart={function(e) {
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
e.dataTransfer.setData('text/plain', a.id);
|
|
e.dataTransfer.setData('application/json', JSON.stringify({ assetId: a.id }));
|
|
}}
|
|
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>
|
|
<span style={{ marginLeft: 8, fontSize: 10, color: 'var(--text-tertiary)' }}>Drag media here. Right-click clips for actions.</span>
|
|
</div>
|
|
<div ref={tlRef} className="timeline-container" style={{ flex: 1, minHeight: 0 }} />
|
|
</div>
|
|
</div>
|
|
|
|
{clipMenu && (
|
|
<ClipContextMenu
|
|
clip={clipMenu.clip}
|
|
x={clipMenu.x}
|
|
y={clipMenu.y}
|
|
onClose={function() { setClipMenu(null); }}
|
|
onOpenSource={openClipInSource}
|
|
onDuplicate={duplicateClip}
|
|
onSplit={splitClipAtPlayhead}
|
|
onDelete={deleteClip}
|
|
/>
|
|
)}
|
|
|
|
{/* ── 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 ClipContextMenu({ clip, x, y, onClose, onOpenSource, onDuplicate, onSplit, onDelete }) {
|
|
const ref = React.useRef(null);
|
|
const [pos, setPos] = React.useState({ left: x, top: y });
|
|
React.useLayoutEffect(() => {
|
|
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 (
|
|
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
|
|
onClick={function(e) { e.stopPropagation(); }}
|
|
onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>
|
|
<div className="ctx-header">{clip.display_name || clip.name || 'Clip'}</div>
|
|
<button onClick={function() { onClose(); onOpenSource(clip); }}><Icon name="play" size={11} />Load in source</button>
|
|
<button onClick={function() { onClose(); onDuplicate(clip); }}><Icon name="copy" size={11} />Duplicate</button>
|
|
<button onClick={function() { onClose(); onSplit(clip); }}><Icon name="cut" size={11} />Split at playhead</button>
|
|
<div className="ctx-divider" />
|
|
<button className="danger" onClick={function() { onClose(); onDelete(clip); }}><Icon name="trash" size={11} />Remove clip</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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_in_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; 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 vid = videoRef.current;
|
|
if (!vid) return;
|
|
// Sync timeline playhead to current program position
|
|
const elapsed = vid.currentTime - (clip.source_in_frames / fps);
|
|
const tlFrames = clip.timeline_in_frames + Math.round(elapsed * fps);
|
|
setPlayheadFrames(Math.max(0, tlFrames));
|
|
if (window.Timeline) window.Timeline.setPlayhead(Math.max(0, tlFrames));
|
|
const srcOutSecs = clip.source_out_frames / fps;
|
|
if (vid.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 ? '⏸' : '▶'}</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; |