fix(editor): setScale, hand pan, sort comparator, playhead sync, rename/delete, track selector
This commit is contained in:
parent
721f847b28
commit
4673efac6a
2 changed files with 110 additions and 8 deletions
|
|
@ -128,6 +128,7 @@
|
||||||
document.addEventListener('keydown', _onKeyDown);
|
document.addEventListener('keydown', _onKeyDown);
|
||||||
|
|
||||||
_renderRuler();
|
_renderRuler();
|
||||||
|
_initHandPan();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ruler ───────────────────────────────────────────────────────────────────
|
// ── Ruler ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -491,6 +492,45 @@
|
||||||
|
|
||||||
function getTool() { return s.activeTool; }
|
function getTool() { return s.activeTool; }
|
||||||
|
|
||||||
|
// ── Scale (zoom) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setScale(px) {
|
||||||
|
s.scale = Math.min(500, Math.max(20, px));
|
||||||
|
_renderClips();
|
||||||
|
_renderRuler();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScale() { return s.scale; }
|
||||||
|
|
||||||
|
// ── Hand tool: pan by dragging ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _initHandPan() {
|
||||||
|
var dragging = false;
|
||||||
|
var startX = 0;
|
||||||
|
var startScroll = 0;
|
||||||
|
|
||||||
|
s.tracksEl.addEventListener('mousedown', function (e) {
|
||||||
|
if (s.activeTool !== 'hand') return;
|
||||||
|
dragging = true;
|
||||||
|
startX = e.clientX;
|
||||||
|
startScroll = s.container.scrollLeft;
|
||||||
|
s.tracksEl.style.cursor = 'grabbing';
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', function (e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
var dx = e.clientX - startX;
|
||||||
|
s.container.scrollLeft = Math.max(0, startScroll - dx);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', function () {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
if (s.activeTool === 'hand') s.tracksEl.style.cursor = 'grab';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Add clip at playhead ─────────────────────────────────────────────────────
|
// ── Add clip at playhead ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function addClip(asset, srcIn, srcOut, track) {
|
function addClip(asset, srcIn, srcOut, track) {
|
||||||
|
|
@ -532,6 +572,7 @@
|
||||||
global.Timeline = {
|
global.Timeline = {
|
||||||
init, render, setTool, getTool,
|
init, render, setTool, getTool,
|
||||||
setPlayhead, getPlayhead,
|
setPlayhead, getPlayhead,
|
||||||
|
setScale, getScale,
|
||||||
addClip,
|
addClip,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ function Editor() {
|
||||||
const [showNewSeq, setShowNewSeq] = React.useState(false);
|
const [showNewSeq, setShowNewSeq] = React.useState(false);
|
||||||
const [newSeqName, setNewSeqName] = React.useState('');
|
const [newSeqName, setNewSeqName] = React.useState('');
|
||||||
const [isDirty, setIsDirty] = React.useState(false);
|
const [isDirty, setIsDirty] = React.useState(false);
|
||||||
|
const [insertTrack, setInsertTrack] = React.useState(0);
|
||||||
|
const [renamingSeq, setRenamingSeq] = React.useState(false);
|
||||||
|
const [renameVal, setRenameVal] = React.useState('');
|
||||||
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);
|
||||||
|
|
@ -95,6 +98,30 @@ function Editor() {
|
||||||
} catch (e) { console.error('Failed to create sequence', e); }
|
} 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);
|
||||||
|
setHistory([[]]);
|
||||||
|
setHistoryIdx(0);
|
||||||
|
if (remaining.length) openSequence(remaining[0].id);
|
||||||
|
} catch (e) { console.error('Delete failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
function renderTimelineClips(clips) {
|
function renderTimelineClips(clips) {
|
||||||
if (window.Timeline && tlRef.current) {
|
if (window.Timeline && tlRef.current) {
|
||||||
window.Timeline.render(clips || [], { fps: 59.94 });
|
window.Timeline.render(clips || [], { fps: 59.94 });
|
||||||
|
|
@ -201,7 +228,7 @@ function Editor() {
|
||||||
const sOut = srcOut != null ? srcOut : (vid && vid.duration ? vid.duration : ((sourceAsset.duration_ms || 10000) / 1000));
|
const sOut = srcOut != null ? srcOut : (vid && vid.duration ? vid.duration : ((sourceAsset.duration_ms || 10000) / 1000));
|
||||||
window.Timeline.addClip(
|
window.Timeline.addClip(
|
||||||
{ ...sourceAsset, streamUrl: streamCacheRef.current[sourceAsset.id] },
|
{ ...sourceAsset, streamUrl: streamCacheRef.current[sourceAsset.id] },
|
||||||
sIn, sOut, 0
|
sIn, sOut, insertTrack
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,14 +246,33 @@ function Editor() {
|
||||||
{/* ── Top toolbar ── */}
|
{/* ── 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 }}>
|
<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} />
|
<Icon name="editor" size={14} />
|
||||||
<select value={currentSeq ? currentSeq.id : ''} onChange={e => openSequence(e.target.value)}
|
{renamingSeq ? (
|
||||||
style={{ fontSize: 12, height: 24, background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text-primary)', maxWidth: 200 }}>
|
<input value={renameVal} autoFocus onChange={e => setRenameVal(e.target.value)}
|
||||||
{sequences.length === 0 && <option value="">No sequences</option>}
|
onKeyDown={e => { if (e.key === 'Enter') renameSequence(); if (e.key === 'Escape') setRenamingSeq(false); }}
|
||||||
{sequences.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
onBlur={renameSequence}
|
||||||
</select>
|
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">
|
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} onClick={() => setShowNewSeq(true)} title="New sequence">
|
||||||
<Icon name="plus" size={12} />
|
<Icon name="plus" size={12} />
|
||||||
</button>
|
</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' }} />
|
<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)">\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={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)">\u21AA</button>
|
||||||
|
|
@ -248,6 +294,14 @@ function Editor() {
|
||||||
<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={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>
|
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcOut} title="Mark Out (O)">{srcOutLabel}</button>
|
||||||
<div style={{ flex: 1 }} />
|
<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>
|
<button className="btn primary sm" style={{ fontSize: 10, padding: '2px 8px' }} onClick={addToTimeline} title="Insert at playhead">Insert</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -355,7 +409,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
||||||
|
|
||||||
function getV1Clips() {
|
function getV1Clips() {
|
||||||
if (!currentSeq || !currentSeq.clips) return [];
|
if (!currentSeq || !currentSeq.clips) return [];
|
||||||
return currentSeq.clips.filter(c => c.track === 0).sort((a, b) => a.timeline_in_frames - b.timeline_out_frames);
|
return currentSeq.clips.filter(c => c.track === 0).sort((a, b) => a.timeline_in_frames - b.timeline_in_frames);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
|
|
@ -414,8 +468,15 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
||||||
const clip = pgmClips[pgmClipIdx];
|
const clip = pgmClips[pgmClipIdx];
|
||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
const fps = window.TC ? window.TC.FPS : 59.94;
|
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;
|
const srcOutSecs = clip.source_out_frames / fps;
|
||||||
if (videoRef.current && videoRef.current.currentTime >= srcOutSecs) {
|
if (vid.currentTime >= srcOutSecs) {
|
||||||
advanceClip();
|
advanceClip();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue