Fix asset filmstrip and editor UX
This commit is contained in:
parent
c501d88c63
commit
87f14b7c71
2 changed files with 248 additions and 19 deletions
|
|
@ -34,6 +34,8 @@ function AssetDetail({ asset, onClose }) {
|
||||||
const [streamLoading, setStreamLoading] = React.useState(false);
|
const [streamLoading, setStreamLoading] = React.useState(false);
|
||||||
const [videoDuration, setVideoDuration] = React.useState(0);
|
const [videoDuration, setVideoDuration] = React.useState(0);
|
||||||
const [retrying, setRetrying] = React.useState(false);
|
const [retrying, setRetrying] = React.useState(false);
|
||||||
|
const [filmFrames, setFilmFrames] = React.useState([]);
|
||||||
|
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
|
||||||
const videoRef = React.useRef(null);
|
const videoRef = React.useRef(null);
|
||||||
|
|
||||||
const assetId = asset && asset.id;
|
const assetId = asset && asset.id;
|
||||||
|
|
@ -49,6 +51,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
setVideoDuration(0);
|
setVideoDuration(0);
|
||||||
setCurrentMs(0);
|
setCurrentMs(0);
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
|
setFilmFrames([]);
|
||||||
setStreamLoading(true);
|
setStreamLoading(true);
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
|
||||||
.then(function(r) {
|
.then(function(r) {
|
||||||
|
|
@ -75,6 +78,67 @@ function AssetDetail({ asset, onClose }) {
|
||||||
return function() { hls.destroy(); };
|
return function() { hls.destroy(); };
|
||||||
}, [streamUrl, streamType]);
|
}, [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
|
// Fake playback timer — only used when no real video stream
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!playing || totalMs <= 0 || streamUrl) return;
|
if (!playing || totalMs <= 0 || streamUrl) return;
|
||||||
|
|
@ -384,7 +448,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalMs > 0 && (
|
{totalMs > 0 && (
|
||||||
<FilmStrip seed={asset.seed || 1} current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
|
<FilmStrip seed={asset.seed || 1} current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} frames={filmFrames} loading={filmstripLoading} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -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 ref = React.useRef(null);
|
||||||
const frames = 28;
|
const fallbackFrames = 28;
|
||||||
const handle = function(e) {
|
const handle = function(e) {
|
||||||
if (!ref.current || total <= 0) return;
|
if (!ref.current || total <= 0) return;
|
||||||
const r = ref.current.getBoundingClientRect();
|
const r = ref.current.getBoundingClientRect();
|
||||||
onSeek(((e.clientX - r.left) / r.width) * total);
|
onSeek(((e.clientX - r.left) / r.width) * total);
|
||||||
};
|
};
|
||||||
const pct = total > 0 ? (current / total) * 100 : 0;
|
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 (
|
return (
|
||||||
<div className="filmstrip-wrap">
|
<div className="filmstrip-wrap">
|
||||||
<div className="filmstrip" ref={ref} onClick={handle}>
|
<div className="filmstrip" ref={ref} onClick={handle}>
|
||||||
{Array.from({ length: frames }).map(function(_, i) {
|
{items.map(function(src, i) {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="film-frame" style={{ background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
|
<div key={i} className="film-frame" style={src ? undefined : { background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
|
||||||
<FauxFrame seed={(seed + i) % 6} />
|
{src ? (
|
||||||
|
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
) : (
|
||||||
|
<FauxFrame seed={(seed + i) % 6} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -515,6 +584,7 @@ function FilmStrip({ seed, current, total, onSeek, comments }) {
|
||||||
return <span key={p} className="mono">{msToTimecode(p * total)}</span>;
|
return <span key={p} className="mono">{msToTimecode(p * total)}</span>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{loading && <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Building filmstrip…</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ function Editor() {
|
||||||
const [insertTrack, setInsertTrack] = React.useState(0);
|
const [insertTrack, setInsertTrack] = React.useState(0);
|
||||||
const [renamingSeq, setRenamingSeq] = React.useState(false);
|
const [renamingSeq, setRenamingSeq] = React.useState(false);
|
||||||
const [renameVal, setRenameVal] = React.useState('');
|
const [renameVal, setRenameVal] = React.useState('');
|
||||||
|
const [clipMenu, setClipMenu] = React.useState(null);
|
||||||
const srcVideoRef = React.useRef(null);
|
const srcVideoRef = React.useRef(null);
|
||||||
const pgmVideoRef = React.useRef(null);
|
const pgmVideoRef = React.useRef(null);
|
||||||
const tlRef = 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
|
// Refs so Timeline callbacks always read current values without stale closure issues
|
||||||
const historyRef = React.useRef([[]]);
|
const historyRef = React.useRef([[]]);
|
||||||
const historyIdxRef = React.useRef(0);
|
const historyIdxRef = React.useRef(0);
|
||||||
|
const currentSeqRef = React.useRef(null);
|
||||||
|
const isDirtyRef = React.useRef(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const data = window.ZAMPP_DATA;
|
const data = window.ZAMPP_DATA;
|
||||||
|
|
@ -47,6 +50,23 @@ function Editor() {
|
||||||
if (pid) loadSequences(pid);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!tlRef.current || tlInitRef.current || !window.Timeline) return;
|
if (!tlRef.current || tlInitRef.current || !window.Timeline) return;
|
||||||
tlInitRef.current = true;
|
tlInitRef.current = true;
|
||||||
|
|
@ -55,6 +75,8 @@ function Editor() {
|
||||||
scale: 100,
|
scale: 100,
|
||||||
onClipsChanged: handleClipsChanged,
|
onClipsChanged: handleClipsChanged,
|
||||||
onPlayheadMoved: handlePlayheadMoved,
|
onPlayheadMoved: handlePlayheadMoved,
|
||||||
|
onClipContextMenu: handleClipContextMenu,
|
||||||
|
onExternalDrop: handleExternalDrop,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -64,6 +86,17 @@ function Editor() {
|
||||||
}
|
}
|
||||||
}, [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) {
|
async function loadSequences(pid) {
|
||||||
try {
|
try {
|
||||||
const r = await window.ZAMPP_API.getSequences(pid);
|
const r = await window.ZAMPP_API.getSequences(pid);
|
||||||
|
|
@ -79,6 +112,7 @@ function Editor() {
|
||||||
const clips = (r.clips || []).map(c => ({ ...c, _id: _uid() }));
|
const clips = (r.clips || []).map(c => ({ ...c, _id: _uid() }));
|
||||||
const seq = { ...r, clips };
|
const seq = { ...r, clips };
|
||||||
setCurrentSeq(seq);
|
setCurrentSeq(seq);
|
||||||
|
currentSeqRef.current = seq;
|
||||||
setPlayheadFrames(0);
|
setPlayheadFrames(0);
|
||||||
setSelectedClipId(null);
|
setSelectedClipId(null);
|
||||||
historyRef.current = [clips];
|
historyRef.current = [clips];
|
||||||
|
|
@ -86,6 +120,7 @@ function Editor() {
|
||||||
setHistory([clips]);
|
setHistory([clips]);
|
||||||
setHistoryIdx(0);
|
setHistoryIdx(0);
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
|
isDirtyRef.current = false;
|
||||||
setSaveStatus('');
|
setSaveStatus('');
|
||||||
renderTimelineClips(clips);
|
renderTimelineClips(clips);
|
||||||
} catch (e) { console.error('Failed to open sequence', e); }
|
} catch (e) { console.error('Failed to open sequence', e); }
|
||||||
|
|
@ -121,6 +156,7 @@ function Editor() {
|
||||||
const remaining = sequences.filter(s => s.id !== currentSeq.id);
|
const remaining = sequences.filter(s => s.id !== currentSeq.id);
|
||||||
setSequences(remaining);
|
setSequences(remaining);
|
||||||
setCurrentSeq(null);
|
setCurrentSeq(null);
|
||||||
|
currentSeqRef.current = null;
|
||||||
setHistory([[]]);
|
setHistory([[]]);
|
||||||
setHistoryIdx(0);
|
setHistoryIdx(0);
|
||||||
if (remaining.length) openSequence(remaining[0].id);
|
if (remaining.length) openSequence(remaining[0].id);
|
||||||
|
|
@ -134,7 +170,11 @@ function Editor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClipsChanged(clips) {
|
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);
|
const newHistory = historyRef.current.slice(0, historyIdxRef.current + 1);
|
||||||
newHistory.push(clips.map(c => ({ ...c })));
|
newHistory.push(clips.map(c => ({ ...c })));
|
||||||
if (newHistory.length > 50) newHistory.shift();
|
if (newHistory.length > 50) newHistory.shift();
|
||||||
|
|
@ -149,19 +189,46 @@ function Editor() {
|
||||||
setPlayheadFrames(Math.max(0, 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 = 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() {
|
function markDirty() {
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
|
isDirtyRef.current = true;
|
||||||
setSaveStatus('Unsaved');
|
setSaveStatus('Unsaved');
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
saveTimerRef.current = setTimeout(saveSequence, 2000);
|
saveTimerRef.current = setTimeout(saveSequence, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSequence() {
|
async function saveSequence() {
|
||||||
if (!currentSeq || !isDirty) return;
|
const seq = currentSeqRef.current;
|
||||||
|
if (!seq || !isDirtyRef.current) return;
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
setSaveStatus('Saving\u2026');
|
setSaveStatus('Saving…');
|
||||||
try {
|
try {
|
||||||
const clips = (currentSeq.clips || []).map(c => ({
|
const clips = (seq.clips || []).map(c => ({
|
||||||
asset_id: c.asset_id,
|
asset_id: c.asset_id,
|
||||||
track: c.track,
|
track: c.track,
|
||||||
timeline_in_frames: c.timeline_in_frames,
|
timeline_in_frames: c.timeline_in_frames,
|
||||||
|
|
@ -169,8 +236,9 @@ function Editor() {
|
||||||
source_in_frames: c.source_in_frames,
|
source_in_frames: c.source_in_frames,
|
||||||
source_out_frames: c.source_out_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);
|
setIsDirty(false);
|
||||||
|
isDirtyRef.current = false;
|
||||||
setSaveStatus('Saved');
|
setSaveStatus('Saved');
|
||||||
setTimeout(() => setSaveStatus(''), 2000);
|
setTimeout(() => setSaveStatus(''), 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -185,7 +253,11 @@ function Editor() {
|
||||||
historyIdxRef.current = idx;
|
historyIdxRef.current = idx;
|
||||||
setHistoryIdx(idx);
|
setHistoryIdx(idx);
|
||||||
const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
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);
|
renderTimelineClips(clips);
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +268,11 @@ function Editor() {
|
||||||
historyIdxRef.current = idx;
|
historyIdxRef.current = idx;
|
||||||
setHistoryIdx(idx);
|
setHistoryIdx(idx);
|
||||||
const clips = (historyRef.current[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
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);
|
renderTimelineClips(clips);
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +323,42 @@ function Editor() {
|
||||||
window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => console.error('EDL export failed', e));
|
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 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';
|
const srcOutLabel = srcOut != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcOut)) : 'Out set') : 'Mark Out';
|
||||||
|
|
||||||
|
|
@ -283,8 +395,8 @@ function Editor() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
|
<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={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)">\u21AA</button>
|
<button className="btn ghost sm" onClick={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)">↪</button>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button className="btn ghost sm" onClick={exportEDL} title="Export CMX3600 EDL">Export EDL</button>
|
<button className="btn ghost sm" onClick={exportEDL} title="Export CMX3600 EDL">Export EDL</button>
|
||||||
<button className="btn primary sm" onClick={saveSequence}>{saveStatus || 'Save'}</button>
|
<button className="btn primary sm" onClick={saveSequence}>{saveStatus || 'Save'}</button>
|
||||||
|
|
@ -339,7 +451,13 @@ function Editor() {
|
||||||
<div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center' }}>No assets</div>
|
<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 => (
|
) : assets.filter(a => !seqFilter || a.bin_id === seqFilter).map(a => (
|
||||||
<div key={a.id} className="media-asset-item"
|
<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)' }}
|
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)}>
|
onDoubleClick={() => loadSourceAsset(a)}>
|
||||||
<div style={{ width: 40, height: 24, borderRadius: 2, overflow: 'hidden', flexShrink: 0, background: 'var(--bg-surface)' }}>
|
<div style={{ width: 40, height: 24, borderRadius: 2, overflow: 'hidden', flexShrink: 0, background: 'var(--bg-surface)' }}>
|
||||||
<AssetThumb asset={a} size="sm" />
|
<AssetThumb asset={a} size="sm" />
|
||||||
|
|
@ -364,11 +482,25 @@ function Editor() {
|
||||||
setScale(s);
|
setScale(s);
|
||||||
}} />
|
}} />
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace', width: 28 }}>{scale}px</span>
|
<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>
|
||||||
<div ref={tlRef} className="timeline-container" style={{ flex: 1, minHeight: 0 }} />
|
<div ref={tlRef} className="timeline-container" style={{ flex: 1, minHeight: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
</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 ── */}
|
{/* ── New sequence modal ── */}
|
||||||
{showNewSeq && (
|
{showNewSeq && (
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 80, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
<div style={{ position: 'fixed', inset: 0, zIndex: 80, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
|
@ -411,6 +543,33 @@ function ToolBtn({ active, label, title, onClick }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) {
|
||||||
const [pgmPlaying, setPgmPlaying] = React.useState(false);
|
const [pgmPlaying, setPgmPlaying] = React.useState(false);
|
||||||
const [pgmClipIdx, setPgmClipIdx] = React.useState(-1);
|
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; }
|
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
|
||||||
} catch (e) { console.error('Stream fetch failed', e); return; }
|
} 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);
|
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
|
||||||
vid.currentTime = srcInSecs;
|
vid.currentTime = srcInSecs;
|
||||||
vid.play().catch(() => {});
|
vid.play().catch(() => {});
|
||||||
|
|
@ -492,7 +651,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
||||||
onEnded={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 }}>
|
<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>
|
<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 }}>
|
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
|
||||||
{window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'}
|
{window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue