Spaces:
Sleeping
Sleeping
| 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 <button title={title} disabled={disabled} onClick={onClick} className={"grid h-7 w-7 place-items-center rounded hover:bg-white/5 disabled:opacity-30 disabled:hover:bg-transparent " + (active ? "text-primary" : "text-fg-muted hover:text-fg")}>{children}</button>; | |
| } | |
| // 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 ( | |
| <div className="absolute top-0 bottom-0 z-30" style={{ left: LABEL_W + (playheadMs / 1000) * pxps }}> | |
| <div className="pointer-events-none absolute top-0 bottom-0 -left-px w-0.5 bg-[var(--tl-playhead)]" /> | |
| <div onPointerDown={onScrub} title="Drag to scrub" className="absolute -left-[7px] top-0 flex h-6 w-[15px] cursor-ew-resize justify-center"> | |
| <div className="h-3 w-3 rounded-b-[3px] bg-[var(--tl-playhead)] shadow-sm" /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <div className="pointer-events-none absolute top-0 bottom-0 z-40" style={{ left: LABEL_W + (snap.ms / 1000) * pxps }}> | |
| <div className="absolute top-0 bottom-0 -left-px w-0.5" style={{ background: color, boxShadow: `0 0 7px ${color}, 0 0 2px ${color}` }} /> | |
| <div className="absolute -left-[3px] -top-px h-1.5 w-1.5 rotate-45" style={{ background: color }} /> | |
| <div className="absolute -left-[3px] -bottom-px h-1.5 w-1.5 rotate-45" style={{ background: color }} /> | |
| </div> | |
| ); | |
| } | |
| function TimeReadout({ durationMs }: { durationMs: number }) { | |
| const playheadMs = useStore((s) => s.playheadMs); | |
| return <span className="num text-xs">{fmtTime(playheadMs)} / {fmtTime(durationMs)}</span>; | |
| } | |
| function TransportButton() { | |
| const playing = useStore((s) => s.playing); | |
| const toggleFn = useStore((s) => s.toggleFn); | |
| return <button title={playing ? "Pause (Space)" : "Play (Space)"} onClick={() => toggleFn?.()} className="grid h-7 w-7 place-items-center rounded text-fg hover:bg-white/5">{playing ? <Pause size={15} /> : <Play size={15} />}</button>; | |
| } | |
| 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<string, number> => { const st = useStore.getState(); const out: Record<string, number> = {}; 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<string | null>(null); | |
| const rulerLaneRef = useRef<HTMLDivElement>(null); | |
| const contentRef = useRef<HTMLDivElement>(null); | |
| const scrollRef = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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<string>): 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 ( | |
| <TrackLane key={track.id} track={track} active={activeTrackId === track.id} contentW={contentW} canUp={i > 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) => ( | |
| <ClipBlock key={v.id} item={v} color="var(--color-tl-video)" hasSource={v.kind !== "image"} locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef} | |
| trackId={track.id} laneKind="video" onMoveTrack={(id, tid) => 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) => ( | |
| <ClipBlock key={m.id} item={m} color="var(--color-tl-motion)" locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef} | |
| trackId={track.id} laneKind="video" onMoveTrack={(id, tid) => 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) => ( | |
| <ClipBlock key={v.id} item={v} color="var(--color-tl-audio)" hasSource locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef} | |
| trackId={track.id} laneKind="audio" onMoveTrack={(id, tid) => 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 /> | |
| ))} | |
| </TrackLane> | |
| ); | |
| }; | |
| return ( | |
| <section data-tour="ed-timeline" className="flex min-w-0 shrink-0 select-none flex-col overflow-hidden border-t border-border bg-surface"> | |
| <div className="flex items-center gap-1 border-b border-border px-2 py-1.5 text-fg-muted"> | |
| <div className="relative"> | |
| <IconBtn title="Add a track" active={addMenu} onClick={() => setAddMenu((o) => !o)}><Plus size={15} /></IconBtn> | |
| {addMenu && <AddTrackMenu onClose={() => setAddMenu(false)} />} | |
| </div> | |
| <IconBtn title="Split at playhead (S)" onClick={() => splitAtPlayhead()}><Scissors size={15} /></IconBtn> | |
| <IconBtn title="Snapping" active={snap} onClick={() => setSnap((s) => !s)}><Magnet size={15} /></IconBtn> | |
| <IconBtn title="Close gaps β pull every clip flush" onClick={() => closeGaps()}><FoldHorizontal size={15} /></IconBtn> | |
| <IconBtn title={recording ? "Recordingβ¦" : "Record voiceover"} active={recording} onClick={() => setRecording((r) => !r)}><Mic size={15} /></IconBtn> | |
| <IconBtn title={screenRec ? "Recording screenβ¦" : "Record screen"} active={screenRec} onClick={() => setScreenRec((r) => !r)}><Monitor size={15} /></IconBtn> | |
| <div className="flex-1" /> | |
| <TransportButton /> | |
| <TimeReadout durationMs={stats.durationMs} /> | |
| <div className="flex-1" /> | |
| <IconBtn title="Zoom out" onClick={() => setPxps((p) => Math.max(4, p / 1.4))}><ZoomOut size={15} /></IconBtn> | |
| <IconBtn title="Zoom in" onClick={() => setPxps((p) => Math.min(120, p * 1.4))}><ZoomIn size={15} /></IconBtn> | |
| <CaptionMenu /> | |
| <IconBtn title="Fullscreen preview" onClick={requestProgramFullscreen}><Maximize2 size={14} /></IconBtn> | |
| </div> | |
| {recording && <VoiceRecorder onClose={() => setRecording(false)} />} | |
| {screenRec && <ScreenRecorder onClose={() => setScreenRec(false)} />} | |
| {selectedIds.length > 1 && ( | |
| <div className="flex items-center gap-2 border-b border-primary/30 bg-primary/10 px-3 py-1.5 text-[12px]"> | |
| <span className="font-semibold text-fg">{selectedIds.length} clips selected</span> | |
| <span className="hidden text-fg-subtle sm:inline">β {selectedGrouped ? "grouped: drag any to move all" : "Group to move together, or Combine into one clip"} Β· β/Shift-click to adjust</span> | |
| <div className="flex-1" /> | |
| {selectedMediaCount > 1 && <button onClick={() => combineSelectedAction()} title="Combine into one video β the clips play back-to-back and become a single clip, so an overlay / crop / effect applies to all of them at once" className="flex items-center gap-1 rounded bg-primary px-2 py-1 text-[11px] font-semibold text-white hover:brightness-110"><Combine size={12} /> Combine</button>} | |
| <button onClick={() => groupSelectedAction()} title="Group β move & select together, stays editable (βG)" className="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg"><Link2 size={12} /> Group</button> | |
| {selectedGrouped && <button onClick={() => ungroupSelectedAction()} title="Ungroup (ββ§G)" className="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg"><Unlink size={12} /> Ungroup</button>} | |
| <button onClick={removeSelected} className="flex items-center gap-1 rounded bg-destructive px-2 py-1 text-[11px] font-semibold text-white hover:brightness-110"><Trash2 size={12} /> Delete {selectedIds.length}</button> | |
| <button onClick={clearSelection} className="rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg">Clear</button> | |
| </div> | |
| )} | |
| {/* height fits the tracks (no dead space below), scrolls when there are many; caps so the preview stays large */} | |
| <div ref={scrollRef} className="overflow-auto bg-[var(--color-surface-sunken)]" style={{ maxHeight: "42vh" }}> | |
| <div ref={contentRef} style={{ width: LABEL_W + contentW, minWidth: "100%" }} className="relative" onDragOver={onMusicDragOver} onDrop={onMusicDrop}> | |
| <Ruler pxps={pxps} durationMs={stats.durationMs} px={px} onScrub={startScrub} laneRef={rulerLaneRef} /> | |
| {/* 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 && ( | |
| <Lane label="β«" name="Music" color="var(--color-tl-audio)" contentW={contentW} onPointerDown={startScrub}> | |
| <div className="absolute inset-0" onDragOver={onMusicDragOver} onDrop={onMusicDrop}> | |
| <div className="pointer-events-none absolute inset-0 flex items-center px-3 text-[10px] text-fg-subtle"><Music2 size={11} className="mr-1.5 opacity-60" /> Drag a track here from the <span className="mx-1 text-fg-muted">Music</span> panel β it becomes a clip you can move, trim & fade.</div> | |
| </div> | |
| </Lane> | |
| )} | |
| {/* 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 && ( | |
| <Lane label="A1" name="Dialogue" color="var(--color-tl-audio)" contentW={contentW} onPointerDown={startScrub}> | |
| {edl.map((it) => ( | |
| <div key={it.id} className="absolute top-1 h-8 rounded border" title={`${Math.round(it.durationMs / 1000)}s`} | |
| style={{ left: px(it.timelineStartMs), width: Math.max(2, px(it.durationMs) - 1), background: "color-mix(in oklch, var(--color-tl-audio) 26%, transparent)", borderColor: "var(--color-tl-audio)" }} /> | |
| ))} | |
| </Lane> | |
| )} | |
| {marquee && <div className="pointer-events-none absolute z-20 rounded-sm border border-primary bg-primary/20" style={{ left: marquee.left, top: marquee.top, width: marquee.w, height: marquee.h }} />} | |
| {snapLine && <SnapGuide snap={snapLine} pxps={pxps} />} | |
| <Playhead pxps={pxps} onScrub={startScrub} /> | |
| </div> | |
| </div> | |
| {hoverPv && hoverPv.item.template && ( | |
| <div className="pointer-events-none fixed z-50" style={{ left: Math.min(Math.max(8, hoverPv.rect.left + hoverPv.rect.width / 2 - 120), (typeof window !== "undefined" ? window.innerWidth : 1280) - 248), top: hoverPv.rect.top - 164 }}> | |
| <div className="overflow-hidden rounded-lg border border-border bg-black shadow-xl ring-1 ring-white/40"> | |
| <AutoPreview template={hoverPv.item.template} props={(hoverPv.item.props as Record<string, unknown>) || {}} 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" }} /> | |
| <div className="truncate bg-black px-2 py-1 text-[10px] font-medium text-white/80">{hoverPv.item.label}</div> | |
| </div> | |
| </div> | |
| )} | |
| </section> | |
| ); | |
| } | |
| // "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 ( | |
| <> | |
| <div className="fixed inset-0 z-30" onClick={onClose} /> | |
| <div className="absolute left-0 top-full z-40 mt-1 w-52 rounded-lg border border-border bg-surface-raised p-1 shadow-xl"> | |
| <button onClick={() => { addTrack("video"); onClose(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-primary/10"> | |
| <span className="grid h-7 w-7 place-items-center rounded bg-[var(--color-tl-video)]/15 text-[var(--color-tl-video)]"><Video size={15} /></span> | |
| <span><span className="block text-[13px] font-medium text-fg">Video track</span><span className="block text-[11px] text-fg-subtle">Stacks on top β covers lanes below</span></span> | |
| </button> | |
| <button onClick={() => { addTrack("audio"); onClose(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-primary/10"> | |
| <span className="grid h-7 w-7 place-items-center rounded bg-[var(--color-tl-audio)]/15 text-[var(--color-tl-audio)]"><AudioLines size={15} /></span> | |
| <span><span className="block text-[13px] font-medium text-fg">Voice / audio track</span><span className="block text-[11px] text-fg-subtle">For voiceover or music β mixes in</span></span> | |
| </button> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // 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<MediaRecorder | null>(null); | |
| const stream = useRef<MediaStream | null>(null); // held so the mic is ALWAYS released, even if MediaRecorder() throws | |
| const chunks = useRef<Blob[]>([]); | |
| const startedAt = useRef(0); | |
| const timer = useRef<ReturnType<typeof setInterval> | 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 ( | |
| <div className="flex items-center gap-3 border-b border-border bg-surface-raised px-3 py-2 text-[13px]"> | |
| <Mic size={15} className={state === "recording" ? "text-destructive" : "text-primary"} /> | |
| {state === "idle" && <><span className="text-fg">Record a voiceover from your mic</span> | |
| <button onClick={start} className="rounded-md bg-primary px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-105">Start</button></>} | |
| {state === "recording" && <> | |
| <span className="flex items-center gap-1.5 text-fg"><span className="h-2 w-2 animate-pulse rounded-full bg-destructive" /> Recording <span className="num">{secs}s</span></span> | |
| <button onClick={stop} className="flex items-center gap-1 rounded-md bg-destructive px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-110"><Square size={11} /> Stop & place</button></>} | |
| {state === "saving" && <span className="text-fg-subtle">Savingβ¦</span>} | |
| {err && <span className="text-[12px] text-destructive">{err}</span>} | |
| <div className="flex-1" /> | |
| <button onClick={onClose} className="rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg">{state === "recording" ? "Cancel" : "Close"}</button> | |
| </div> | |
| ); | |
| } | |
| function Ruler({ pxps, durationMs, px, onScrub, laneRef }: { pxps: number; durationMs: number; px: (ms: number) => number; onScrub: (e: React.PointerEvent) => void; laneRef: React.Ref<HTMLDivElement> }) { | |
| const interval = tickInterval(pxps); | |
| const count = Math.ceil(durationMs / 1000 / interval) + 1; | |
| return ( | |
| <div className="sticky top-0 z-10 flex h-6 select-none bg-surface"> | |
| <div className="shrink-0 border-b border-r border-border bg-surface" style={{ width: LABEL_W }} /> | |
| <div ref={laneRef} className="relative flex-1 cursor-ew-resize border-b border-border" onPointerDown={onScrub}> | |
| {Array.from({ length: count }, (_, i) => { | |
| const t = i * interval; | |
| return ( | |
| <div key={i} className="absolute top-0 bottom-0 border-l border-white/15" style={{ left: px(t * 1000) }}> | |
| <span className="num absolute left-1 top-0.5 text-[9px] text-fg-subtle">{fmtTime(t * 1000).replace(/\.\d+$/, "")}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <div className={"group/lane flex border-b border-border/60 bg-surface " + (active ? "ring-1 ring-inset ring-primary/40" : "")}> | |
| <div className={"relative flex h-10 shrink-0 items-center gap-1 border-r px-2 " + (active ? "border-primary/40" : "border-border")} style={{ width: LABEL_W }} | |
| onClick={() => setActiveTrack(track.id)} title="Click to target this track for new clips"> | |
| {active && <span className="absolute left-0 top-0 bottom-0 w-0.5" style={{ background: color }} />} | |
| <span className="num shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold text-white" style={{ background: color }}>{track.kind === "audio" ? "A" : "V"}</span> | |
| {editing ? ( | |
| <input autoFocus defaultValue={track.name} onBlur={(e) => { 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" /> | |
| ) : ( | |
| <span className="min-w-0 flex-1 truncate text-[11px] text-fg-muted" onDoubleClick={(e) => { e.stopPropagation(); onEdit(true); }} title={track.name + " β double-click to rename"}>{track.name}</span> | |
| )} | |
| {/* always-visible state toggle: hide (video) / mute (audio) */} | |
| {isAudio ? ( | |
| <button title={track.muted ? "Unmute" : "Mute"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "muted"); }} className="shrink-0 text-fg-subtle hover:text-fg">{track.muted ? <VolumeX size={13} className="text-destructive" /> : <Volume2 size={13} />}</button> | |
| ) : ( | |
| <button title={track.hidden ? "Show" : "Hide"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "hidden"); }} className="shrink-0 text-fg-subtle hover:text-fg">{track.hidden ? <EyeOff size={13} className="text-destructive" /> : <Eye size={13} />}</button> | |
| )} | |
| {isAudio && <input type="range" min={0} max={1} step={0.05} value={track.volume ?? 1} onClick={(e) => 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) */} | |
| <div className="ml-auto flex shrink-0 items-center opacity-0 transition group-hover/lane:opacity-100 pointer-coarse:opacity-100"> | |
| <button title={track.locked ? "Unlock" : "Lock"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "locked"); }} className="grid h-5 w-4 place-items-center text-fg-subtle hover:text-fg">{track.locked ? <Lock size={11} className="text-primary" /> : <Unlock size={11} />}</button> | |
| <button title="Move up" disabled={!canUp} onClick={(e) => { e.stopPropagation(); moveTrack(track.id, -1); }} className="grid h-5 w-3.5 place-items-center text-fg-subtle hover:text-fg disabled:opacity-20"><ChevronUp size={12} /></button> | |
| <button title="Move down" disabled={!canDown} onClick={(e) => { e.stopPropagation(); moveTrack(track.id, 1); }} className="grid h-5 w-3.5 place-items-center text-fg-subtle hover:text-fg disabled:opacity-20"><ChevronDown size={12} /></button> | |
| <button title="Delete track" onClick={(e) => { e.stopPropagation(); removeTrack(track.id); }} className="grid h-5 w-4 place-items-center text-fg-subtle hover:text-destructive"><Trash2 size={11} /></button> | |
| </div> | |
| </div> | |
| <div data-track-id={track.id} data-track-kind={track.kind} className={"relative h-10 flex-1 " + (track.hidden ? "opacity-40" : "")} style={{ minWidth: contentW }} onPointerDown={onLaneDown}> | |
| {children} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <div className="flex border-b border-border/60 bg-surface"> | |
| <div className="flex h-10 shrink-0 items-center gap-1.5 border-r border-border px-2" style={{ width: LABEL_W }}> | |
| <span className="num rounded px-1.5 py-0.5 text-[10px] font-semibold text-white" style={{ background: color }}>{label}</span> | |
| <span className="truncate text-[11px] text-fg-muted">{name}</span> | |
| <Volume2 size={11} className="ml-auto text-fg-subtle" /> | |
| </div> | |
| <div className="relative h-10 flex-1" style={{ minWidth: contentW }} onPointerDown={onPointerDown}> | |
| {children} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| 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<string, number>; 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<HTMLButtonElement>(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 ( | |
| <> | |
| <button ref={btnRef} onPointerDown={(e) => e.stopPropagation()} onClick={toggle} | |
| title={value ? `Transition: ${cur?.label ?? value.type} Β· ${(dur / 1000).toFixed(1)}s β click to change` : "Add an in-transition"} | |
| className={"absolute left-2 top-0.5 z-20 grid h-[15px] min-w-[15px] place-items-center rounded px-0.5 text-[10px] font-bold leading-none " + (value ? "bg-surface-raised text-fg shadow" : "bg-black/45 text-white opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto hover:bg-black/75")}> | |
| {value ? (cur?.glyph ?? "β₯") : <ArrowRightLeft size={9} />} | |
| </button> | |
| {open && createPortal( | |
| <div data-trans-menu className="fixed z-[200] w-[236px] rounded-lg border border-border bg-surface p-2 shadow-2xl" style={{ left: pos.left, top: pos.top }} onClick={(e) => e.stopPropagation()}> | |
| <div className="mb-1.5 flex items-center justify-between px-1"> | |
| <span className="text-[11px] font-semibold text-fg">Transition in</span> | |
| {value && <button onClick={() => { onChange(null); setOpen(false); }} className="text-[10px] text-fg-subtle hover:text-destructive">Remove</button>} | |
| </div> | |
| <div className="grid grid-cols-3 gap-1"> | |
| {TRANSITIONS.map((t) => ( | |
| <button key={t.id} title={t.blurb} onClick={() => { onChange({ type: t.id, durationMs: dur }); setOpen(false); }} | |
| className={"flex flex-col items-center gap-0.5 rounded px-1 py-1.5 " + (value?.type === t.id ? "bg-primary text-white" : "text-fg-muted hover:bg-white/5 hover:text-fg")}> | |
| <span className="text-[14px] leading-none">{t.glyph}</span> | |
| <span className="truncate text-[9px]">{t.label}</span> | |
| </button> | |
| ))} | |
| </div> | |
| <div className="mt-2 flex items-center gap-1 px-1"> | |
| <span className="mr-auto text-[10px] text-fg-subtle">Length</span> | |
| {[300, 500, 800, 1200].map((d) => ( | |
| <button key={d} onClick={() => onChange({ type: value?.type || "fade", durationMs: d })} | |
| className={"rounded px-1.5 py-0.5 text-[10px] " + (dur === d ? "bg-fg text-surface" : "text-fg-muted hover:bg-white/5")}>{(d / 1000).toFixed(1)}s</button> | |
| ))} | |
| </div> | |
| </div>, | |
| 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<string>) => number; | |
| pushHistory: () => void; onUpdate: (id: string, p: Record<string, number>) => 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<HTMLDivElement | null>; 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<HTMLButtonElement>(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<HTMLElement>("[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<string, number> = { 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 ( | |
| <div data-clip-id={item.id} className={"group absolute top-1 flex h-8 items-center overflow-hidden rounded border text-[10px] font-medium text-white " + (selected ? "ring-2 ring-white ring-offset-1 ring-offset-black/20 " : "") + (menuOpen ? "ring-2 ring-primary " : "") + (locked ? "opacity-70" : "")} | |
| title={locked ? `${item.label} β track locked` : `${item.label}${groupColor ? " β grouped: drag to move the whole group" : " β drag to move (up/down to change lane), edges to trim"} Β· right-click for actions`} | |
| onPointerDown={drag("move")} | |
| onContextMenu={locked ? undefined : (e) => { 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 && <div className="pointer-events-none absolute inset-y-0 left-0 z-[1] flex w-3 items-center justify-center" style={{ background: groupColor }}><Link2 size={8} className="text-black/70" /></div>} | |
| {!locked && <div onPointerDown={drag("left")} style={{ touchAction: "none" }} className="absolute left-0 top-0 z-0 h-full w-1.5 cursor-ew-resize hover:bg-white/40" />} | |
| {/* fade ramps (audio): show the fade-in / fade-out like a pro NLE */} | |
| {audio && item.fadeInMs ? <div className="pointer-events-none absolute inset-y-0 left-0" style={{ width: Math.min(px(item.fadeInMs), px(item.durationMs) * 0.5), background: "linear-gradient(90deg, rgba(0,0,0,0.5), transparent)" }} /> : null} | |
| {audio && item.fadeOutMs ? <div className="pointer-events-none absolute inset-y-0 right-0" style={{ width: Math.min(px(item.fadeOutMs), px(item.durationMs) * 0.5), background: "linear-gradient(270deg, rgba(0,0,0,0.5), transparent)" }} /> : null} | |
| {onTransition && !locked && <TransitionBadge value={item.transition} onChange={(t) => onTransition(item.id, t)} />} | |
| <span className="pointer-events-none flex items-center gap-1 truncate px-2" style={{ paddingLeft: item.transition ? 22 : groupColor ? 16 : undefined }}>{audio && <AudioLines size={11} className="shrink-0 opacity-80" />}<span className="truncate">{item.label}</span>{sfx && <span title={`Sound: ${sfx === "none" ? "muted" : sfx}`} className="ml-0.5 inline-flex shrink-0 items-center gap-0.5 rounded bg-black/45 px-1 text-[8.5px] font-normal text-white/85">{sfx === "none" ? "π" : `π ${sfx}`}</span>}</span> | |
| {/* one calm, non-destructive affordance: the β― pill opens the actions menu (so does right-click). */} | |
| {!locked && ( | |
| <button ref={moreRef} title="Clip actions β split, duplicate, delete (or right-click)" | |
| onPointerDown={(e) => e.stopPropagation()} | |
| onClick={(e) => { e.stopPropagation(); const r = moreRef.current!.getBoundingClientRect(); openMenuAt(r.right - 176, r.bottom + 4); }} | |
| className={"absolute right-0.5 top-0.5 z-10 grid h-4 w-5 place-items-center rounded bg-black/45 text-white transition hover:bg-black/75 " + (menuOpen ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto")}> | |
| <MoreHorizontal size={11} /> | |
| </button> | |
| )} | |
| {!locked && <div onPointerDown={drag("right")} style={{ touchAction: "none" }} className="absolute right-0 top-0 z-0 h-full w-1.5 cursor-ew-resize hover:bg-white/40" />} | |
| {menuOpen && createPortal( | |
| <div data-clip-actions className="fixed z-[200] w-44 rounded-lg border border-border bg-surface p-1 shadow-2xl" style={{ left: menuPos.left, top: menuPos.top }} onClick={(e) => e.stopPropagation()}> | |
| <div className="truncate px-2 py-1 text-[10px] font-medium text-fg-subtle">{item.label}</div> | |
| {onSplit && <button onClick={() => { setMenuOpen(false); onSplit(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-fg-muted hover:bg-white/5 hover:text-fg"><Scissors size={13} /> Split at playhead</button>} | |
| {onDuplicate && <button onClick={() => { setMenuOpen(false); onDuplicate(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-fg-muted hover:bg-white/5 hover:text-fg"><Copy size={13} /> Duplicate</button>} | |
| <div className="my-1 h-px bg-border" /> | |
| <button onClick={() => { setMenuOpen(false); onRemove(item.id); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-destructive hover:bg-destructive/15"><Trash2 size={13} /> Delete clip</button> | |
| </div>, | |
| document.body, | |
| )} | |
| </div> | |
| ); | |
| } | |