fix(editor): setScale, hand pan, sort comparator, playhead sync, rename/delete, track selector

This commit is contained in:
Zac Gaetano 2026-05-24 21:03:12 -04:00
parent 721f847b28
commit 4673efac6a
2 changed files with 110 additions and 8 deletions

View file

@ -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,
}; };

View file

@ -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();
} }
}} }}