import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; import { Plus, Scissors, Magnet, Mic, Play, Pause, ZoomIn, ZoomOut, Maximize2, Eye, EyeOff, Volume2, VolumeX, Trash2, ArrowRightLeft, Lock, Unlock, ChevronUp, ChevronDown, Copy, Video, AudioLines, Square, FoldHorizontal, MoreHorizontal, Music2, X, Link2, Unlink, Monitor, Combine, } from "lucide-react"; import { useStore, selectStats, type MotionItem, type Track } from "../store"; import { ScreenRecorder } from "./ScreenRecorder"; import { sfxForScene } from "../remotion/mg/catalog.mjs"; import { CaptionMenu } from "./CaptionMenu"; import { AutoPreview } from "./TemplateGallery"; import { TRANSITIONS, DEFAULT_TRANSITION_MS, type ClipTransition } from "../lib/transitions"; import { fmtTime } from "../lib/time"; const LABEL_W = 150; const tickInterval = (pxps: number) => [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300].find((n) => n * pxps >= 64) || 300; function IconBtn({ children, title, onClick, active, disabled }: { children: React.ReactNode; title?: string; onClick?: () => void; active?: boolean; disabled?: boolean }) { return ; } // Subscribes to playheadMs alone so playback doesn't re-render the whole timeline. function Playhead({ pxps, onScrub }: { pxps: number; onScrub: (e: React.PointerEvent) => void }) { const playheadMs = useStore((s) => s.playheadMs); return (
); } // the magnet guide line: a bright vertical line spanning every lane at the locked time while dragging — // the "you snapped" affordance every pro NLE shows. Colour-codes the anchor (playhead vs clip-edge). function SnapGuide({ snap, pxps }: { snap: { ms: number; type: "playhead" | "edge" | "bound" }; pxps: number }) { const color = snap.type === "playhead" ? "var(--tl-playhead)" : snap.type === "bound" ? "#9CA3FF" : "#2DE3C0"; return (
); } function TimeReadout({ durationMs }: { durationMs: number }) { const playheadMs = useStore((s) => s.playheadMs); return {fmtTime(playheadMs)} / {fmtTime(durationMs)}; } function TransportButton() { const playing = useStore((s) => s.playing); const toggleFn = useStore((s) => s.toggleFn); return ; } const TRACK_COLOR = (t: Track) => (t.kind === "audio" ? "var(--color-tl-audio)" : "var(--color-tl-video)"); // scroll `el` horizontally when a drag's pointer is near its left/right edge → returns px actually // scrolled (0 if none). Lets clip-drags and box-select keep going past the visible edge with the cursor. function edgeScrollStep(el: HTMLElement, clientX: number): number { const EDGE = 64, MAX = 24; const r = el.getBoundingClientRect(); let dx = 0; if (clientX > r.right - EDGE) dx = Math.min(MAX, ((clientX - (r.right - EDGE)) / EDGE) * MAX); else if (clientX < r.left + EDGE) dx = -Math.min(MAX, (((r.left + EDGE) - clientX) / EDGE) * MAX); if (!dx) return 0; const before = el.scrollLeft; el.scrollLeft = Math.max(0, before + dx); return el.scrollLeft - before; } function requestProgramFullscreen() { const el = document.getElementById("program-monitor"); if (!el) return; if (document.fullscreenElement) document.exitFullscreen?.(); else el.requestFullscreen?.().catch(() => {}); } export function Timeline() { const edl = useStore((s) => s.edl); const motion = useStore((s) => s.motion); const videos = useStore((s) => s.videos); const tracks = useStore((s) => s.tracks); const activeTrackId = useStore((s) => s.activeTrackId); const setMusic = useStore((s) => s.setMusic); const sfxStyle = useStore((s) => s.sfxStyle); const removeMotion = useStore((s) => s.removeMotion); const updateMotion = useStore((s) => s.updateMotion); const selectMotion = useStore((s) => s.selectMotion); const selectVideo = useStore((s) => s.selectVideo); const selectedVideoId = useStore((s) => s.selectedVideoId); const toggleSelect = useStore((s) => s.toggleSelect); const selectRange = useStore((s) => s.selectRange); const selectedIds = useStore((s) => s.selectedIds); const removeSelected = useStore((s) => s.removeSelected); const clearSelection = useStore((s) => s.clearSelection); const setSelection = useStore((s) => s.setSelection); const groupSelectedAction = useStore((s) => s.groupSelected); const ungroupSelectedAction = useStore((s) => s.ungroupSelected); const combineSelectedAction = useStore((s) => s.combineSelected); // how many selected clips are combinable media (video/image, not audio) — gates the Combine button const selectedMediaCount = useStore((s) => s.selectedIds.reduce((n, id) => { const v = s.videos.find((x) => x.id === id); return v && v.kind !== "audio" ? n + 1 : n; }, 0)); const setStarts = useStore((s) => s.setStarts); // ── grouping helpers ── const clipById = (id: string) => { const st = useStore.getState(); return st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); }; const groupMemberIds = (g: string) => { const st = useStore.getState(); return [...st.motion.filter((m) => m.group === g), ...st.videos.filter((v) => v.group === g)].map((c) => c.id); }; // a stable, vivid colour per group id so a group's members read as visually linked const groupColor = (g: string) => { let h = 0; for (let i = 0; i < g.length; i++) h = (h * 31 + g.charCodeAt(i)) % 360; return `hsl(${h} 88% 64%)`; }; // which clips should move together when dragging `id`: its whole group, else the multi-selection, else just it const resolveMoveSet = (id: string): string[] => { const st = useStore.getState(); const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); if (c?.group) return groupMemberIds(c.group); if (st.selectedIds.includes(id) && st.selectedIds.length > 1) return st.selectedIds.slice(); return [id]; }; const startsOf = (ids: string[]): Record => { const st = useStore.getState(); const out: Record = {}; for (const id of ids) { const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); if (c) out[id] = c.startMs; } return out; }; const multiMove = { resolve: resolveMoveSet, startsOf, apply: setStarts, select: (ids: string[]) => setSelection(ids, false) }; const selectedGrouped = selectedIds.some((id) => !!clipById(id)?.group); // plain-click selection that EXPANDS to the whole group when the clicked clip is grouped const onSceneSelect = (id: string, mods: { meta: boolean; shift: boolean }) => { if (mods.meta) return toggleSelect(id); if (mods.shift) return selectRange(id); const s = useStore.getState(); const c = s.motion.find((m) => m.id === id); if (c?.group) return setSelection(groupMemberIds(c.group), false); // in multi-select mode (≥1 selected, nothing open in the inspector) a plain click toggles; else open if (s.inspectId == null && s.selectedIds.length > 0) toggleSelect(id); else selectMotion(id); }; const onVideoSelect = (id: string, mods: { meta: boolean; shift: boolean }) => { if (mods.meta) return toggleSelect(id); const s = useStore.getState(); const c = s.videos.find((v) => v.id === id); if (c?.group) return setSelection(groupMemberIds(c.group), false); selectVideo(id); }; // ⌘/Ctrl+G groups the selected clips, ⌘/Ctrl+Shift+G ungroups useEffect(() => { const onKey = (e: KeyboardEvent) => { const tg = e.target as HTMLElement | null; if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.isContentEditable)) return; if ((e.metaKey || e.ctrlKey) && (e.key === "g" || e.key === "G")) { e.preventDefault(); if (e.shiftKey) ungroupSelectedAction(); else groupSelectedAction(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [groupSelectedAction, ungroupSelectedAction]); const removeVideo = useStore((s) => s.removeVideo); const updateVideo = useStore((s) => s.updateVideo); const duplicateVideo = useStore((s) => s.duplicateVideo); const duplicateMotion = useStore((s) => s.duplicateMotion); const setTransition = useStore((s) => s.setTransition); const pushHistory = useStore((s) => s.pushHistory); const setPlayhead = useStore((s) => s.setPlayhead); const splitAtPlayhead = useStore((s) => s.splitAtPlayhead); const closeGaps = useStore((s) => s.closeGaps); const splitClip = useStore((s) => s.splitClip); const stats = useStore(useShallow(selectStats)); const [pxps, setPxps] = useState(16); const [snap, setSnap] = useState(true); // the active magnet guide line while dragging/trimming (the time + what it locked onto) — drawn over the lanes const [snapLine, setSnapLine] = useState<{ ms: number; type: "playhead" | "edge" | "bound" } | null>(null); const snapRef = useRef<{ ms: number; type: string } | null>(null); const setLine = (next: { ms: number; type: "playhead" | "edge" | "bound" } | null) => { const a = snapRef.current, same = (!a && !next) || (!!a && !!next && a.ms === next.ms && a.type === next.type); if (!same) { snapRef.current = next; setSnapLine(next); } }; const [addMenu, setAddMenu] = useState(false); const [recording, setRecording] = useState(false); const [screenRec, setScreenRec] = useState(false); const [editingTrack, setEditingTrack] = useState(null); const rulerLaneRef = useRef(null); const contentRef = useRef(null); const scrollRef = useRef(null); // the horizontal scroll viewport — for edge auto-scroll while dragging const [marquee, setMarquee] = useState<{ left: number; top: number; w: number; h: number } | null>(null); const marqueeActive = useRef(false); const [hoverPv, setHoverPv] = useState<{ item: MotionItem; rect: DOMRect } | null>(null); const hoverTimer = useRef | null>(null); const px = (ms: number) => (Number.isFinite(ms) ? (ms / 1000) * pxps : 0); const contentW = px(stats.durationMs) + 80; const seek = (ms: number) => { const c = Math.min(Math.max(0, ms), stats.durationMs); setPlayhead(c); useStore.getState().seekFn?.(c); }; // the magnet guide line is transient — clear it whenever any drag/trim gesture ends useEffect(() => { const clear = () => setLine(null); window.addEventListener("pointerup", clear); window.addEventListener("pointercancel", clear); return () => { window.removeEventListener("pointerup", clear); window.removeEventListener("pointercancel", clear); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // split a specific clip: at the playhead if it's inside the clip, else at the clip's midpoint const splitClipSmart = (item: { id: string; startMs: number; durationMs: number }) => { const ph = useStore.getState().playheadMs; const inside = ph > item.startMs + 100 && ph < item.startMs + item.durationMs - 100; if (!inside) seek(item.startMs + item.durationMs / 2); splitClip(item.id); }; const startScrub = (e: React.PointerEvent) => { const lane = rulerLaneRef.current; if (!lane) return; // live rect so the position stays correct as the viewport auto-scrolls under the cursor const toMs = (cx: number) => ((cx - lane.getBoundingClientRect().left) / pxps) * 1000; // touch: don't hijack the native scroll gesture — just seek on a stationary tap if (e.pointerType === "touch") { const dx0 = e.clientX, dy0 = e.clientY; const up = (ev: PointerEvent) => { window.removeEventListener("pointerup", up); if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) seek(toMs(ev.clientX)); }; window.addEventListener("pointerup", up); return; } e.preventDefault(); e.stopPropagation(); seek(toMs(e.clientX)); let lastX = e.clientX; const onMove = (ev: PointerEvent) => { lastX = ev.clientX; seek(toMs(ev.clientX)); }; // drag the playhead/cursor past the edge → keep scrolling so it follows the cursor let raf = 0; const tick = () => { const sc = scrollRef.current; if (sc && edgeScrollStep(sc, lastX)) seek(toMs(lastX)); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); const onUp = () => { cancelAnimationFrame(raf); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); }; // The magnet. Aligns a dragged/trimmed clip to the STRONGEST nearby anchor — any other clip's start OR // end (across EVERY lane, so stacked players line up), the playhead/cursor, or the timeline start/end. // Both the clip's own start AND end are candidates, so two clips can be made to start OR end together. // Returns the snapped ms and lights a guide line at the locked time (cleared on pointer-up). const snapMs = (ms: number, selfId?: string, dur = 0, exclude?: Set): number => { if (!snap) { setLine(null); return ms; } const st = useStore.getState(); const skip = (id: string) => id === selfId || (!!exclude && exclude.has(id)); // a moving group must not snap to its own members const ph = Math.round(st.playheadMs), end = Math.round(stats.durationMs); // a comfortable ~10px catch zone that stays catchable at any zoom (pro NLEs feel "sticky", not pixel-perfect) const thresh = Math.max(45, Math.min(420, (10 / pxps) * 1000)); // typed targets so the guide line can colour-code WHAT it locked onto (clip edge vs playhead vs bound) const targets: { at: number; type: "playhead" | "edge" | "bound" }[] = [ { at: ph, type: "playhead" }, { at: 0, type: "bound" }, { at: end, type: "bound" }, ]; for (const m of st.motion) if (!skip(m.id)) targets.push({ at: m.startMs, type: "edge" }, { at: m.startMs + m.durationMs, type: "edge" }); for (const v of st.videos) if (!skip(v.id)) targets.push({ at: v.startMs, type: "edge" }, { at: v.startMs + v.durationMs, type: "edge" }); // pick the closest hit among "my start → target" and "my end → target"; clip edges win ties over the playhead let best: { result: number; at: number; type: "playhead" | "edge" | "bound"; dist: number } | null = null; for (const tg of targets) { const bias = tg.type === "edge" ? -0.5 : 0; // a hair of preference for clip edges so flush-cuts win ties const dStart = Math.abs(ms - tg.at); if (dStart < thresh && (!best || dStart + bias < best.dist)) best = { result: Math.round(tg.at), at: tg.at, type: tg.type, dist: dStart + bias }; if (dur) { const dEnd = Math.abs(ms + dur - tg.at); if (dEnd < thresh && (!best || dEnd + bias < best.dist)) best = { result: Math.round(tg.at - dur), at: tg.at, type: tg.type, dist: dEnd + bias }; } } if (best) { setLine({ ms: best.at, type: best.type }); return best.result; } setLine(null); return ms; }; // marquee box-select on a visual lane: drag to highlight a time range → select every scene ON THAT // LANE that it touches. Shift extends. A plain click (no drag) seeks + deselects. const startMarqueeFor = (trackId: string) => (e: React.PointerEvent) => { if (e.button !== 0) return; const content = contentRef.current; if (!content) return; // touch: a box-select drag would fight native scrolling — tap to seek + clear instead if (e.pointerType === "touch") { const dx0 = e.clientX, dy0 = e.clientY, additive0 = e.shiftKey; const up = (ev: PointerEvent) => { window.removeEventListener("pointerup", up); if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) { const r = content.getBoundingClientRect(); seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000); if (!additive0) clearSelection(); } }; window.addEventListener("pointerup", up); return; } const additive = e.shiftKey; const rect0 = content.getBoundingClientRect(); const x0 = e.clientX - rect0.left, y0 = e.clientY - rect0.top; let moved = false; marqueeActive.current = true; document.body.style.userSelect = "none"; // never let a box-select drag highlight page text if (hoverTimer.current) clearTimeout(hoverTimer.current); setHoverPv(null); let lastEv = e.nativeEvent as PointerEvent; const apply = (ev: PointerEvent) => { lastEv = ev; const r = content.getBoundingClientRect(); const x1 = ev.clientX - r.left, y1 = ev.clientY - r.top; const left = Math.min(x0, x1), top = Math.min(y0, y1), w = Math.abs(x1 - x0), h = Math.abs(y1 - y0); if (!moved && (w > 4 || h > 4)) moved = true; if (!moved) return; setMarquee({ left, top, w, h }); // CROSS-TRACK box-select: pick ANY clip (scene or media, on ANY lane) whose rendered rect intersects // the box — using the live DOM rects so it works across stacked players, not just the lane you started on. const bx0 = r.left + left, by0 = r.top + top, bx1 = bx0 + w, by1 = by0 + h; const ids: string[] = []; content.querySelectorAll("[data-clip-id]").forEach((el) => { const cr = (el as HTMLElement).getBoundingClientRect(); if (cr.right > bx0 && cr.left < bx1 && cr.bottom > by0 && cr.top < by1) { const id = el.getAttribute("data-clip-id"); if (id) ids.push(id); } }); setSelection(ids, additive); }; // keep scrolling while the pointer hovers near a horizontal edge, re-applying so the box follows let raf = 0; const tick = () => { const sc = scrollRef.current; if (sc && moved) { if (edgeScrollStep(sc, lastEv.clientX)) apply(lastEv); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); const onUp = (ev: PointerEvent) => { cancelAnimationFrame(raf); window.removeEventListener("pointermove", apply); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); marqueeActive.current = false; document.body.style.userSelect = ""; setMarquee(null); if (!moved) { const r = content.getBoundingClientRect(); seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000); if (!additive) clearSelection(); } }; window.addEventListener("pointermove", apply); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); }; const onClipHover = (item: ClipLike, el: HTMLElement | null) => { if (hoverTimer.current) clearTimeout(hoverTimer.current); const m = useStore.getState().motion.find((x) => x.id === item.id); if (!el || !m || !m.template || marqueeActive.current) { setHoverPv(null); return; } const rect = el.getBoundingClientRect(); hoverTimer.current = setTimeout(() => { if (!marqueeActive.current) setHoverPv({ item: m, rect }); }, 260); }; const visualTracks = tracks.filter((t) => t.kind === "video"); // index 0 = top lane = top z const audioTracks = tracks.filter((t) => t.kind === "audio"); // background-music drag-and-drop (from the Music panel) → place it as a clip on the Music lane const hasMusicClip = videos.some((v) => v.music); const onMusicDragOver = (e: React.DragEvent) => { if (e.dataTransfer.types.includes("application/x-elg-music")) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } }; const onMusicDrop = (e: React.DragEvent) => { const id = e.dataTransfer.getData("application/x-elg-music"); if (id) { e.preventDefault(); setMusic(id); } }; // render one track's lane (header controls + its clips) const renderTrack = (track: Track, i: number, group: Track[]) => { const locked = !!track.locked; const media = videos.filter((v) => v.trackId === track.id && v.kind !== "audio"); const scenes = motion.filter((m) => m.trackId === track.id); const audioClips = videos.filter((v) => v.trackId === track.id && v.kind === "audio"); const onLaneDown = track.kind === "video" && !locked ? startMarqueeFor(track.id) : startScrub; return ( 0} canDown={i < group.length - 1} editing={editingTrack === track.id} onEdit={(v) => setEditingTrack(v ? track.id : null)} onLaneDown={onLaneDown}> {track.kind === "video" && media.map((v) => ( updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id} groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove} onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} /> ))} {track.kind === "video" && scenes.map((m) => ( updateMotion(id, { trackId: tid })} sfx={m.sfx !== undefined ? m.sfx : sfxForScene(sfxStyle, m.template)} groupColor={m.group ? groupColor(m.group) : undefined} multiMove={multiMove} onUpdate={updateMotion} onRemove={removeMotion} onSelect={onSceneSelect} onHover={onClipHover} onTransition={setTransition} onSplit={() => splitClipSmart(m)} onDuplicate={() => duplicateMotion(m.id)} selected={selectedIds.includes(m.id)} /> ))} {track.kind === "audio" && audioClips.map((v) => ( updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id} groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove} onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} audio /> ))} ); }; return (
setAddMenu((o) => !o)}> {addMenu && setAddMenu(false)} />}
splitAtPlayhead()}> setSnap((s) => !s)}> closeGaps()}> setRecording((r) => !r)}> setScreenRec((r) => !r)}>
setPxps((p) => Math.max(4, p / 1.4))}> setPxps((p) => Math.min(120, p * 1.4))}>
{recording && setRecording(false)} />} {screenRec && setScreenRec(false)} />} {selectedIds.length > 1 && (
{selectedIds.length} clips selected — {selectedGrouped ? "grouped: drag any to move all" : "Group to move together, or Combine into one clip"} · ⌘/Shift-click to adjust
{selectedMediaCount > 1 && } {selectedGrouped && }
)} {/* height fits the tracks (no dead space below), scrolls when there are many; caps so the preview stays large */}
{/* visual tracks (top → bottom) then audio tracks; the top visual lane composites on top */} {visualTracks.map((t, i) => renderTrack(t, i, visualTracks))} {audioTracks.map((t, i) => renderTrack(t, i, audioTracks))} {/* Music drop hint — shown only until a track is placed; once placed it's a REAL clip on the Music audio lane (rendered by the audio tracks above) that you can move, trim, cut & fade. */} {!hasMusicClip && (
Drag a track here from the Music panel — it becomes a clip you can move, trim & fade.
)} {/* the transcript dialogue lane (driven by the script) — only shown once there's a transcript, so script/scene-only projects aren't padded with an empty lane */} {edl.length > 0 && ( {edl.map((it) => (
))} )} {marquee &&
} {snapLine && }
{hoverPv && hoverPv.item.template && (
) || {}} theme={hoverPv.item.theme || "dark"} accent={hoverPv.item.accent || "#E7723B"} bg={hoverPv.item.bg} anim={hoverPv.item.anim} font={hoverPv.item.font} durationInFrames={Math.max(1, Math.round((hoverPv.item.durationMs / 1000) * 30))} style={{ width: 240, aspectRatio: "16 / 9", display: "block" }} />
{hoverPv.item.label}
)}
); } // "Add a track" dropdown: a video lane (composites above) or a voice/audio lane (mixes in). function AddTrackMenu({ onClose }: { onClose: () => void }) { const addTrack = useStore((s) => s.addTrack); return ( <>
); } // Record a voiceover from the mic → upload as an audio asset → drop it on a voice track at the playhead. function VoiceRecorder({ onClose }: { onClose: () => void }) { const projectId = useStore((s) => s.projectId); const addAsset = useStore((s) => s.addAsset); const addVideo = useStore((s) => s.addVideo); const [state, setState] = useState<"idle" | "recording" | "saving">("idle"); const [elapsed, setElapsed] = useState(0); const [err, setErr] = useState(""); const rec = useRef(null); const stream = useRef(null); // held so the mic is ALWAYS released, even if MediaRecorder() throws const chunks = useRef([]); const startedAt = useRef(0); const timer = useRef | null>(null); const releaseMic = () => { stream.current?.getTracks().forEach((t) => t.stop()); stream.current = null; }; // teardown (unmount / Cancel / Mic re-toggle): stop the timer and free the mic, discarding any in-flight take useEffect(() => () => { if (timer.current) clearInterval(timer.current); releaseMic(); }, []); async function start() { setErr(""); try { stream.current = await navigator.mediaDevices.getUserMedia({ audio: true }); const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg"].find((m) => MediaRecorder.isTypeSupported?.(m)) || ""; const mr = new MediaRecorder(stream.current, mime ? { mimeType: mime } : undefined); chunks.current = []; mr.ondataavailable = (e) => { if (e.data.size) chunks.current.push(e.data); }; mr.onstop = () => { releaseMic(); upload(new Blob(chunks.current, { type: mr.mimeType || "audio/webm" }), Date.now() - startedAt.current); }; rec.current = mr; startedAt.current = Date.now(); mr.start(); setState("recording"); timer.current = setInterval(() => setElapsed(Date.now() - startedAt.current), 200); } catch (e) { releaseMic(); // construction failed after getUserMedia → don't leave the mic open setErr(e instanceof Error && e.name === "NotAllowedError" ? "Microphone permission denied." : "Couldn't access the microphone."); } } function stop() { if (timer.current) clearInterval(timer.current); const mr = rec.current; if (!mr || mr.state === "inactive") { releaseMic(); return; } setState("saving"); mr.stop(); // onstop releases the mic + uploads the take } async function upload(blob: Blob, durationMs: number) { if (!projectId || blob.size === 0) { onClose(); return; } try { const ext = (blob.type.includes("ogg") ? "ogg" : "webm"); const name = `Voiceover ${new Date().toLocaleTimeString()}.${ext}`; const measured = Math.max(500, Math.round(durationMs)); const res = await fetch(`/api/projects/${projectId}/assets?name=${encodeURIComponent(name)}&kind=audio&durationMs=${measured}`, { method: "POST", headers: { "Content-Type": blob.type || "audio/webm" }, body: blob }); const a = await res.json(); if (!a.id) throw new Error(a.error || "upload failed"); const asset = { ...a, kind: "audio" as const, durationMs: Math.max(500, Math.round(durationMs)) }; // trust the measured length addAsset(asset); addVideo(asset); // routes to a voice track (created if none) at the playhead onClose(); } catch (e) { setErr(e instanceof Error ? e.message : "Couldn't save the recording."); setState("idle"); } } const secs = (elapsed / 1000).toFixed(1); return (
{state === "idle" && <>Record a voiceover from your mic } {state === "recording" && <> Recording {secs}s } {state === "saving" && Saving…} {err && {err}}
); } function Ruler({ pxps, durationMs, px, onScrub, laneRef }: { pxps: number; durationMs: number; px: (ms: number) => number; onScrub: (e: React.PointerEvent) => void; laneRef: React.Ref }) { const interval = tickInterval(pxps); const count = Math.ceil(durationMs / 1000 / interval) + 1; return (
{Array.from({ length: count }, (_, i) => { const t = i * interval; return (
{fmtTime(t * 1000).replace(/\.\d+$/, "")}
); })}
); } // A real, controllable track lane: click to target it for new clips; toggle visibility/mute/lock, // adjust audio volume, reorder (which restacks the picture), rename, or delete. function TrackLane({ track, active, contentW, canUp, canDown, editing, onEdit, onLaneDown, children }: { track: Track; active: boolean; contentW: number; canUp: boolean; canDown: boolean; editing: boolean; onEdit: (v: boolean) => void; onLaneDown?: (e: React.PointerEvent) => void; children?: React.ReactNode; }) { const moveTrack = useStore((s) => s.moveTrack); const removeTrack = useStore((s) => s.removeTrack); const renameTrack = useStore((s) => s.renameTrack); const toggleTrackFlag = useStore((s) => s.toggleTrackFlag); const setTrackVolume = useStore((s) => s.setTrackVolume); const setActiveTrack = useStore((s) => s.setActiveTrack); const color = TRACK_COLOR(track); const isAudio = track.kind === "audio"; return (
setActiveTrack(track.id)} title="Click to target this track for new clips"> {active && } {track.kind === "audio" ? "A" : "V"} {editing ? ( { renameTrack(track.id, e.target.value || track.name); onEdit(false); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") onEdit(false); }} className="min-w-0 flex-1 rounded border border-primary bg-surface px-1 text-[11px] outline-none" /> ) : ( { e.stopPropagation(); onEdit(true); }} title={track.name + " — double-click to rename"}>{track.name} )} {/* always-visible state toggle: hide (video) / mute (audio) */} {isAudio ? ( ) : ( )} {isAudio && e.stopPropagation()} onChange={(e) => setTrackVolume(track.id, +e.target.value)} title="Volume" className="h-1 w-9 shrink-0 cursor-pointer accent-[var(--color-tl-audio)]" />} {/* hover cluster: lock, reorder, delete (always shown on touch — there's no hover) */}
{children}
); } // Static lane (used for the transcript dialogue row). function Lane({ label, name, color, contentW, children, onPointerDown }: { label: string; name: string; color: string; contentW: number; children?: React.ReactNode; onPointerDown?: (e: React.PointerEvent) => void }) { return (
{label} {name}
{children}
); } interface ClipLike { id: string; label: string; startMs: number; durationMs: number; sourceInMs?: number; srcDurationMs?: number; transition?: ClipTransition; fadeInMs?: number; fadeOutMs?: number; group?: string } interface MultiMove { resolve: (id: string) => string[]; startsOf: (ids: string[]) => Record; apply: (starts: { id: string; startMs: number }[]) => void; select: (ids: string[]) => void } function TransitionBadge({ value, onChange }: { value?: ClipTransition; onChange: (t: ClipTransition | null) => void }) { const [open, setOpen] = useState(false); const [pos, setPos] = useState({ left: 0, top: 0 }); const btnRef = useRef(null); const dur = value?.durationMs ?? DEFAULT_TRANSITION_MS; const cur = value ? TRANSITIONS.find((t) => t.id === value.type) : null; useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (!(e.target as HTMLElement)?.closest?.("[data-trans-menu]")) setOpen(false); }; const onScroll = () => setOpen(false); window.addEventListener("mousedown", onDown); window.addEventListener("scroll", onScroll, true); return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("scroll", onScroll, true); }; }, [open]); const toggle = (e: React.MouseEvent) => { e.stopPropagation(); const r = btnRef.current!.getBoundingClientRect(); const menuH = 250, menuW = 236; const top = r.bottom + menuH > window.innerHeight ? r.top - menuH - 4 : r.bottom + 4; setPos({ left: Math.min(Math.max(8, r.left), window.innerWidth - menuW - 8), top: Math.max(8, top) }); setOpen((o) => !o); }; return ( <> {open && createPortal(
e.stopPropagation()}>
Transition in {value && }
{TRANSITIONS.map((t) => ( ))}
Length {[300, 500, 800, 1200].map((d) => ( ))}
, document.body, )} ); } // Generic timeline clip — drag to move, trim both edges, split, duplicate, delete. For source media // (video/audio), left-trim advances sourceInMs and right-trim is clamped to the source length. function ClipBlock({ item, color, hasSource, locked, audio, pxps, px, snapMs, pushHistory, onUpdate, onRemove, onSelect, onHover, onTransition, onSplit, onDuplicate, selected, trackId, laneKind, onMoveTrack, scrollRef, sfx, groupColor, multiMove }: { item: ClipLike; color: string; hasSource?: boolean; locked?: boolean; audio?: boolean; pxps: number; px: (ms: number) => number; snapMs: (ms: number, selfId?: string, dur?: number, exclude?: Set) => number; pushHistory: () => void; onUpdate: (id: string, p: Record) => void; onRemove: (id: string) => void; onSelect?: (id: string, mods: { meta: boolean; shift: boolean }) => void; onHover?: (item: ClipLike, el: HTMLElement | null) => void; onTransition?: (id: string, t: ClipTransition | null) => void; onSplit?: () => void; onDuplicate?: () => void; selected?: boolean; trackId?: string; laneKind?: "video" | "audio"; onMoveTrack?: (id: string, trackId: string) => void; scrollRef?: React.RefObject; sfx?: string; groupColor?: string; multiMove?: MultiMove; }) { // the lane this clip currently lives on — drag updates it live as the pointer crosses into a same-kind lane const curTrack = useRef(trackId); curTrack.current = trackId; // Destructive actions (split/duplicate/delete) are tucked behind a deliberate menu — opened only by a // clean click on the ⋯ pill or a right-click on the clip — so they can NEVER fire from an accidental // press while you're dragging, trimming, or selecting. (Old design fired them on pointer-DOWN.) const [menuOpen, setMenuOpen] = useState(false); const [menuPos, setMenuPos] = useState({ left: 0, top: 0 }); const moreRef = useRef(null); useEffect(() => { if (!menuOpen) return; const onDown = (ev: MouseEvent) => { if (!(ev.target as HTMLElement).closest("[data-clip-actions]")) setMenuOpen(false); }; const onScroll = () => setMenuOpen(false); const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") setMenuOpen(false); }; window.addEventListener("mousedown", onDown); window.addEventListener("scroll", onScroll, true); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("keydown", onKey); }; }, [menuOpen]); const openMenuAt = (left: number, top: number) => { const w = 176, h = 150; setMenuPos({ left: Math.min(Math.max(8, left), window.innerWidth - w - 8), top: Math.min(Math.max(8, top), window.innerHeight - h - 8) }); setMenuOpen(true); }; const drag = (mode: "move" | "left" | "right") => (e: React.PointerEvent) => { if (e.button !== 0) return; // ignore right/middle click → lets onContextMenu open the actions menu e.stopPropagation(); e.preventDefault(); const mods = { meta: e.metaKey || e.ctrlKey, shift: e.shiftKey }; if (locked) { // locked clips can still be selected, just not moved/trimmed const onUpLocked = () => { window.removeEventListener("pointerup", onUpLocked); window.removeEventListener("pointercancel", onUpLocked); onSelect?.(item.id, mods); }; window.addEventListener("pointerup", onUpLocked); window.addEventListener("pointercancel", onUpLocked); return; } let startX = e.clientX; // mutable: edge auto-scroll shifts it so the screen-delta math stays correct const o = { start: item.startMs, dur: item.durationMs, srcIn: item.sourceInMs ?? 0, srcDur: item.srcDurationMs ?? Infinity }; const maxDur = hasSource ? o.srcDur - o.srcIn : Infinity; // a GROUP / multi-select move: drag the whole set together (resolved once at gesture start) const set = mode === "move" && multiMove ? multiMove.resolve(item.id) : [item.id]; const multi = set.length > 1; const origins = multi ? multiMove!.startsOf(set) : null; const minOrigin = origins ? Math.min(...Object.values(origins)) : 0; const exclude = multi ? new Set(set) : undefined; let selectedSet = false; let moved = false; let lastX = e.clientX, lastY = e.clientY; const applyMove = (clientX: number, clientY: number) => { if (!moved) { if (Math.abs(clientX - startX) < 2) return; moved = true; pushHistory(); } const dms = ((clientX - startX) / pxps) * 1000; if (mode === "move") { if (multi) { if (!selectedSet) { multiMove!.select(set); selectedSet = true; } // reflect the moving set in the highlight const snapped = snapMs(o.start + dms, item.id, o.dur, exclude); // snap the dragged clip; exclude the rest of the set let delta = snapped - o.start; delta = Math.max(delta, -minOrigin); // never push any member below 0 multiMove!.apply(set.map((id) => ({ id, startMs: (origins![id] ?? 0) + delta }))); } else { onUpdate(item.id, { startMs: Math.round(Math.max(0, snapMs(o.start + dms, item.id, o.dur))) }); // vertical drag → hop the clip to whatever same-kind lane is under the cursor (V1 ↔ V2, A1 ↔ A2) if (onMoveTrack && laneKind) { const lane = (document.elementFromPoint(clientX, clientY) as HTMLElement | null)?.closest("[data-track-id]"); const destId = lane?.dataset.trackId; if (destId && lane?.dataset.trackKind === laneKind && destId !== curTrack.current) { curTrack.current = destId; onMoveTrack(item.id, destId); } } } } else if (mode === "left") { let ns = snapMs(o.start + dms, item.id); const minStart = hasSource ? o.start - o.srcIn : 0; ns = Math.min(Math.max(minStart, ns), o.start + o.dur - 100); const s = Math.round(ns); const delta = s - o.start; const patch: Record = { startMs: s, durationMs: o.dur - delta }; if (hasSource) patch.sourceInMs = o.srcIn + delta; onUpdate(item.id, patch); } else { let nd = snapMs(o.start + Math.max(100, o.dur + dms), item.id) - o.start; nd = Math.min(Math.max(100, nd), maxDur); onUpdate(item.id, { durationMs: Math.round(nd) }); } }; const onMove = (ev: PointerEvent) => { lastX = ev.clientX; lastY = ev.clientY; applyMove(ev.clientX, ev.clientY); }; // while a drag's pointer hovers near the viewport edge, keep scrolling so it follows the cursor let raf = 0; const tick = () => { const sc = scrollRef?.current; if (sc && moved) { const d = edgeScrollStep(sc, lastX); if (d) { startX -= d; applyMove(lastX, lastY); } } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); const onUp = () => { cancelAnimationFrame(raf); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); if (mode === "move" && !moved) onSelect?.(item.id, mods); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); }; return (
{ e.preventDefault(); e.stopPropagation(); openMenuAt(e.clientX, e.clientY); }} onMouseEnter={(e) => onHover?.(item, e.currentTarget)} onMouseLeave={() => onHover?.(item, null)} style={{ left: px(item.startMs), width: Math.max(10, px(item.durationMs) - 1), background: `color-mix(in oklch, ${color} 72%, black)`, borderColor: groupColor || color, cursor: locked ? "default" : "grab", touchAction: locked ? undefined : "none", ...(groupColor ? { boxShadow: `inset 0 0 0 1.5px ${groupColor}` } : {}) }}> {/* group marker: a coloured left bar + link glyph so grouped clips read as linked across tracks */} {groupColor &&
} {!locked &&
} {/* fade ramps (audio): show the fade-in / fade-out like a pro NLE */} {audio && item.fadeInMs ?
: null} {audio && item.fadeOutMs ?
: null} {onTransition && !locked && onTransition(item.id, t)} />} {audio && }{item.label}{sfx && {sfx === "none" ? "🔇" : `🔊 ${sfx}`}} {/* one calm, non-destructive affordance: the ⋯ pill opens the actions menu (so does right-click). */} {!locked && ( )} {!locked &&
} {menuOpen && createPortal(
e.stopPropagation()}>
{item.label}
{onSplit && } {onDuplicate && }
, document.body, )}
); }