diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx
index f6f2cae..126b020 100644
--- a/services/web-ui/public/screens-asset.jsx
+++ b/services/web-ui/public/screens-asset.jsx
@@ -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 && (
-
+
)}
@@ -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 (
- {Array.from({ length: frames }).map(function(_, i) {
+ {items.map(function(src, i) {
return (
-
-
+
+ {src ? (
+

+ ) : (
+
+ )}
);
})}
@@ -515,6 +584,7 @@ function FilmStrip({ seed, current, total, onSeek, comments }) {
return
{msToTimecode(p * total)};
})}
+ {loading &&
Building filmstrip…
}
);
}
diff --git a/services/web-ui/public/screens-editor.jsx b/services/web-ui/public/screens-editor.jsx
index 282ba28..dd57171 100644
--- a/services/web-ui/public/screens-editor.jsx
+++ b/services/web-ui/public/screens-editor.jsx
@@ -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() {
)}
-
-
+
+
@@ -339,7 +451,13 @@ function Editor() {
No assets
) : assets.filter(a => !seqFilter || a.bin_id === seqFilter).map(a => (
loadSourceAsset(a)}>
@@ -364,11 +482,25 @@ function Editor() {
setScale(s);
}} />
{scale}px
+
Drag media here. Right-click clips for actions.
+ {clipMenu && (
+
+ )}
+
{/* ── New sequence modal ── */}
{showNewSeq && (
{
+ 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 (
+
+
{clip.display_name || clip.name || 'Clip'}
+
+
+
+
+
+
+ );
+}
+
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}
/>
-
+
{window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'}
@@ -522,4 +681,4 @@ function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSe
return null;
}
-window.Editor = Editor;
\ No newline at end of file
+window.Editor = Editor;