Fix asset filmstrip and editor UX

This commit is contained in:
OpenCode 2026-05-25 05:14:36 +00:00
parent c501d88c63
commit 87f14b7c71
2 changed files with 248 additions and 19 deletions

View file

@ -34,6 +34,8 @@ function AssetDetail({ asset, onClose }) {
const [streamLoading, setStreamLoading] = React.useState(false);
const [videoDuration, setVideoDuration] = React.useState(0);
const [retrying, setRetrying] = React.useState(false);
const [filmFrames, setFilmFrames] = React.useState([]);
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
const videoRef = React.useRef(null);
const assetId = asset && asset.id;
@ -49,6 +51,7 @@ function AssetDetail({ asset, onClose }) {
setVideoDuration(0);
setCurrentMs(0);
setPlaying(false);
setFilmFrames([]);
setStreamLoading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
.then(function(r) {
@ -75,6 +78,67 @@ function AssetDetail({ asset, onClose }) {
return function() { hls.destroy(); };
}, [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
React.useEffect(() => {
if (!playing || totalMs <= 0 || streamUrl) return;
@ -384,7 +448,7 @@ function AssetDetail({ asset, onClose }) {
)}
{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>
@ -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 frames = 28;
const fallbackFrames = 28;
const handle = function(e) {
if (!ref.current || total <= 0) return;
const r = ref.current.getBoundingClientRect();
onSeek(((e.clientX - r.left) / r.width) * total);
};
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 (
<div className="filmstrip-wrap">
<div className="filmstrip" ref={ref} onClick={handle}>
{Array.from({ length: frames }).map(function(_, i) {
{items.map(function(src, i) {
return (
<div key={i} className="film-frame" style={{ background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
<FauxFrame seed={(seed + i) % 6} />
<div key={i} className="film-frame" style={src ? undefined : { background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
{src ? (
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (
<FauxFrame seed={(seed + i) % 6} />
)}
</div>
);
})}
@ -515,6 +584,7 @@ function FilmStrip({ seed, current, total, onSeek, comments }) {
return <span key={p} className="mono">{msToTimecode(p * total)}</span>;
})}
</div>
{loading && <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Building filmstrip</div>}
</div>
);
}

View file

@ -27,6 +27,7 @@ function Editor() {
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);
@ -37,6 +38,8 @@ function Editor() {
// 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);
React.useEffect(() => {
const data = window.ZAMPP_DATA;
@ -47,6 +50,23 @@ function Editor() {
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(() => {
if (!tlRef.current || tlInitRef.current || !window.Timeline) return;
tlInitRef.current = true;
@ -55,6 +75,8 @@ function Editor() {
scale: 100,
onClipsChanged: handleClipsChanged,
onPlayheadMoved: handlePlayheadMoved,
onClipContextMenu: handleClipContextMenu,
onExternalDrop: handleExternalDrop,
});
}, []);
@ -64,6 +86,17 @@ function Editor() {
}
}, [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);
@ -79,6 +112,7 @@ function Editor() {
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];
@ -86,6 +120,7 @@ function Editor() {
setHistory([clips]);
setHistoryIdx(0);
setIsDirty(false);
isDirtyRef.current = false;
setSaveStatus('');
renderTimelineClips(clips);
} catch (e) { console.error('Failed to open sequence', e); }
@ -121,6 +156,7 @@ function Editor() {
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);
@ -134,7 +170,11 @@ function Editor() {
}
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);
newHistory.push(clips.map(c => ({ ...c })));
if (newHistory.length > 50) newHistory.shift();
@ -149,19 +189,46 @@ function Editor() {
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() {
setIsDirty(true);
isDirtyRef.current = true;
setSaveStatus('Unsaved');
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(saveSequence, 2000);
}
async function saveSequence() {
if (!currentSeq || !isDirty) return;
const seq = currentSeqRef.current;
if (!seq || !isDirtyRef.current) return;
clearTimeout(saveTimerRef.current);
setSaveStatus('Saving\u2026');
setSaveStatus('Saving');
try {
const clips = (currentSeq.clips || []).map(c => ({
const clips = (seq.clips || []).map(c => ({
asset_id: c.asset_id,
track: c.track,
timeline_in_frames: c.timeline_in_frames,
@ -169,8 +236,9 @@ function Editor() {
source_in_frames: c.source_in_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);
isDirtyRef.current = false;
setSaveStatus('Saved');
setTimeout(() => setSaveStatus(''), 2000);
} catch (e) {
@ -185,7 +253,11 @@ function Editor() {
historyIdxRef.current = idx;
setHistoryIdx(idx);
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);
markDirty();
}
@ -196,7 +268,11 @@ function Editor() {
historyIdxRef.current = idx;
setHistoryIdx(idx);
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);
markDirty();
}
@ -247,6 +323,42 @@ function Editor() {
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';
@ -283,8 +395,8 @@ function Editor() {
</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>
<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>
@ -339,7 +451,13 @@ function Editor() {
<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)' }}
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" />
@ -364,11 +482,25 @@ function Editor() {
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' }}
@ -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 }) {
const [pgmPlaying, setPgmPlaying] = React.useState(false);
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; }
} 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);
vid.currentTime = srcInSecs;
vid.play().catch(() => {});
@ -492,7 +651,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
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>
<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>
@ -522,4 +681,4 @@ function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSe
return null;
}
window.Editor = Editor;
window.Editor = Editor;