Spaces:
Sleeping
Sleeping
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { Sparkles, Wand2, Copy, Trash2, Plus, Minus, SlidersHorizontal, Gauge } from "lucide-react"; | |
| import { Player, type PlayerRef } from "@remotion/player"; | |
| import { EditorComposition } from "../remotion/EditorComposition"; | |
| import { useStore, selectStats, type MotionItem } from "../store"; | |
| import { useShallow } from "zustand/react/shallow"; | |
| import { msToFrame, frameToMs, FPS } from "../lib/time"; | |
| import { pipBox } from "../lib/pip"; | |
| import { COMP_W, COMP_H, OV_ANCHORS, clampOverlayScale } from "../lib/overlay"; | |
| import { isOverlayTemplate, isImpactTemplate, MG_ACCENTS } from "../remotion/mg/catalog.mjs"; | |
| // overlays AND full-screen Impact scenes can be moved + corner-resized in the preview | |
| const isMovable = (id?: string | null): boolean => !!id && (isOverlayTemplate(id) || isImpactTemplate(id)); | |
| // the pivot/anchor a scene scales about: Impact scenes are centred; overlays use their corner `pos` | |
| const anchorPos = (item?: { template?: string; props?: Record<string, unknown> } | null): string => | |
| item && item.template && isImpactTemplate(item.template) ? "center" : String((item?.props && item.props.pos) ?? "bc"); | |
| export function Preview() { | |
| const edl = useStore((s) => s.edl); | |
| const wordsById = useStore((s) => s.wordsById); | |
| const motion = useStore((s) => s.motion); | |
| const videos = useStore((s) => s.videos); | |
| const tracks = useStore((s) => s.tracks); | |
| const captionsOn = useStore((s) => s.captionsOn); | |
| const captionStyle = useStore((s) => s.captionStyle); | |
| const captionColors = useStore((s) => s.captionColors); | |
| const captionAnim = useStore((s) => s.captionAnim); | |
| const lang = useStore((s) => s.lang); | |
| const audioSrc = useStore((s) => s.audioSrc); | |
| const music = useStore((s) => s.music); | |
| const musicVolume = useStore((s) => s.musicVolume); | |
| const musicFadeInMs = useStore((s) => s.musicFadeInMs); | |
| const musicFadeOutMs = useStore((s) => s.musicFadeOutMs); | |
| const sfxStyle = useStore((s) => s.sfxStyle); | |
| const projectName = useStore((s) => s.projectName); | |
| const setPlayhead = useStore((s) => s.setPlayhead); | |
| const setSeekFn = useStore((s) => s.setSeekFn); | |
| const setToggleFn = useStore((s) => s.setToggleFn); | |
| const setPlaying = useStore((s) => s.setPlaying); | |
| const playing = useStore((s) => s.playing); | |
| const playbackQuality = useStore((s) => s.playbackQuality); | |
| const setPlaybackQuality = useStore((s) => s.setPlaybackQuality); | |
| const stats = useStore(useShallow(selectStats)); | |
| const ref = useRef<PlayerRef | null>(null); | |
| const stageRef = useRef<HTMLDivElement | null>(null); | |
| const [ready, setReady] = useState(false); | |
| const durationInFrames = Math.max(1, msToFrame(stats.durationMs)); | |
| // Adaptive playback (pro-NLE "reduced-resolution / draft" preview): while PLAYING, drop the heavy | |
| // per-frame effects ("preview" quality → no backdrop-blur etc.) and, on "smooth", also render the | |
| // preview at ~60% resolution then upscale. Paused frames + every export stay full fidelity. | |
| const fast = playing && playbackQuality !== "full"; | |
| const playQuality: "preview" | "final" = fast ? "preview" : "final"; | |
| const playScale = playing && playbackQuality === "smooth" ? 0.6 : 1; | |
| // callback ref so the effect re-runs once the Player actually mounts (it isn't | |
| // in the tree on the empty-state render, so a plain ref would stay null forever) | |
| const setPlayerRef = useCallback((r: PlayerRef | null) => { ref.current = r; setReady(!!r); }, []); | |
| useEffect(() => { | |
| const p = ref.current; | |
| if (!p) return; | |
| const onFrame = (e: any) => setPlayhead(frameToMs(e.detail.frame)); | |
| const onPlay = () => setPlaying(true); | |
| const onPause = () => setPlaying(false); | |
| p.addEventListener("frameupdate", onFrame); | |
| p.addEventListener("play", onPlay); | |
| p.addEventListener("pause", onPause); | |
| setSeekFn((ms: number) => { try { p.seekTo(msToFrame(ms)); } catch {} }); | |
| setToggleFn(() => { try { p.toggle(); } catch {} }); | |
| return () => { | |
| p.removeEventListener("frameupdate", onFrame); | |
| p.removeEventListener("play", onPlay); | |
| p.removeEventListener("pause", onPause); | |
| setSeekFn(null); setToggleFn(null); setPlaying(false); | |
| }; | |
| }, [ready, setPlayhead, setSeekFn, setToggleFn, setPlaying]); | |
| const empty = edl.length === 0 && motion.length === 0 && videos.length === 0; | |
| return ( | |
| <section data-tour="ed-preview" className="flex min-w-0 flex-1 flex-col bg-[#0e0e12]"> | |
| <div id="program-monitor" className="flex min-h-0 flex-1 items-center justify-center bg-[#0e0e12] p-3"> | |
| {empty ? ( | |
| <div className="grid aspect-video w-full max-w-3xl place-items-center rounded-md border border-dashed border-white/10"> | |
| <div className="flex max-w-sm flex-col items-center gap-3 px-6 text-center"> | |
| <div className="grid h-12 w-12 place-items-center rounded-full bg-primary/15"><Wand2 size={22} className="text-primary" /></div> | |
| <div> | |
| <div className="text-sm font-semibold text-white/90">Nothing to preview yet</div> | |
| <div className="mt-1 text-[12.5px] leading-relaxed text-white/50">Let the AI agent build {projectName ? <span className="text-white/75">“{projectName}”</span> : "your video"} from the script — it’ll outline the sections and add the scenes.</div> | |
| </div> | |
| <button onClick={() => useStore.getState().requestAgent("Build an explainer video from my script — propose a section outline with treatments, then start building: set a design style and add the scenes in order.")} | |
| className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-[13px] font-semibold text-white hover:brightness-105"> | |
| <Sparkles size={15} /> Build it with AI | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div ref={stageRef} className="relative h-full w-full"> | |
| {/* reduced-resolution playback: render the Player into a smaller box while playing, then CSS-scale | |
| it up to fill — fewer pixels to rasterise/composite (incl. the video draw). Identity when 1. */} | |
| <div data-mg-playerwrap="" style={playScale < 1 | |
| ? { position: "absolute", top: 0, left: 0, width: `${playScale * 100}%`, height: `${playScale * 100}%`, transform: `scale(${1 / playScale})`, transformOrigin: "top left", willChange: "transform" } | |
| : { position: "absolute", inset: 0 }}> | |
| <Player | |
| ref={setPlayerRef} | |
| component={EditorComposition} | |
| inputProps={{ edl, wordsById, motion, videos, tracks, audioSrc, music, musicVolume, musicFadeInMs, musicFadeOutMs, sfxStyle, captionsOn, captionStyle, captionColors, captionAnim, lang, quality: playQuality }} | |
| durationInFrames={durationInFrames} | |
| compositionWidth={1920} | |
| compositionHeight={1080} | |
| fps={FPS} | |
| style={{ width: "100%", height: "100%" }} | |
| controls | |
| // stacking many scenes that start together (each may fire an SFX) exceeds Remotion's default | |
| // pool of 5 shared audio tags → the Player throws "Tried to mount N Html5Audio tags". Raise the | |
| // pool so a busy timeline of overlays doesn't crash the preview. Playback is user-initiated, so | |
| // the autoplay workaround these tags provide isn't needed beyond covering simultaneous cues. | |
| numberOfSharedAudioTags={24} | |
| acknowledgeRemotionLicense | |
| /> | |
| </div> | |
| <SceneSelectionLayer stageRef={stageRef} /> | |
| <PipTransformOverlay stageRef={stageRef} /> | |
| <CropOverlay stageRef={stageRef} /> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex shrink-0 items-center justify-between gap-3 border-t border-white/5 bg-[#0e0e12] px-3 py-1 text-[11px] text-white/40"> | |
| <div className="flex items-center gap-3"> | |
| <span>1920×1080</span><span>·</span><span>{Math.round(stats.durationMs / 1000)}s</span><span>·</span> | |
| <span>{stats.kept} words</span><span>·</span><span>{stats.motionCount} scene{stats.motionCount === 1 ? "" : "s"}</span> | |
| </div> | |
| {/* Playback quality (pro-NLE style) — trade preview fidelity for smoother playback on big videos / | |
| many graphics. Exports are ALWAYS full quality regardless of this. Persisted per device. */} | |
| <div className="flex items-center gap-1" title="Playback quality — lower it for smoother playback on big videos or many overlays. Paused frames and every export stay full quality."> | |
| <Gauge size={12} className="mr-0.5 text-white/35" /> | |
| {([["full", "Full"], ["balanced", "Balanced"], ["smooth", "Smooth"]] as const).map(([q, label]) => ( | |
| <button key={q} onClick={() => setPlaybackQuality(q)} | |
| className={"rounded px-1.5 py-0.5 text-[10.5px] font-medium transition " + (playbackQuality === q ? "bg-white/15 text-white/90" : "text-white/40 hover:bg-white/5 hover:text-white/70")}> | |
| {label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| const MIN_PX = 90; | |
| type Corner = "tl" | "tr" | "bl" | "br"; | |
| type SelRect = { left: number; top: number; w: number; h: number }; | |
| // Measure the TIGHT on-screen box of a scene's ACTUAL visible content (not its full-frame wrapper) by | |
| // unioning the rects of its first non-full-frame descendants — so the selection box hugs the graphic | |
| // itself wherever it has animated/been dragged to. Returns viewport-coord edges, clamped to the frame. | |
| function measureContentRect(root: Element): { left: number; top: number; right: number; bottom: number } | null { | |
| const full = root.getBoundingClientRect(); | |
| if (full.width < 4 || full.height < 4) return null; | |
| let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity, found = false; | |
| const visit = (el: Element, depth: number) => { | |
| for (const child of Array.from(el.children)) { | |
| const cr = child.getBoundingClientRect(); | |
| if (cr.width < 3 || cr.height < 3) continue; | |
| const cs = window.getComputedStyle(child); | |
| if (cs.visibility === "hidden" || cs.display === "none" || cs.opacity === "0") continue; | |
| // near-full-frame nodes are wrappers/masks — recurse THROUGH them but don't count them as content | |
| if (cr.width >= full.width * 0.9 && cr.height >= full.height * 0.9) { if (depth < 6) visit(child, depth + 1); continue; } | |
| if (cr.left < l) l = cr.left; if (cr.top < t) t = cr.top; | |
| if (cr.right > r) r = cr.right; if (cr.bottom > b) b = cr.bottom; found = true; | |
| } | |
| }; | |
| visit(root, 0); | |
| if (!found) return null; | |
| return { left: Math.max(full.left, l), top: Math.max(full.top, t), right: Math.min(full.right, r), bottom: Math.min(full.bottom, b) }; | |
| } | |
| // DIRECT-MANIPULATION layer over the Player. Click a graphic IN the video to select it (no trip to the | |
| // timeline); the selection box is MEASURED so it hugs the graphic itself. For overlays: drag the interior | |
| // to move (ox/oy, with magenta centre-snap guides) and the corner handles to resize (scale, pivoting | |
| // about the overlay anchor so it grows in place). A floating quick toolbar pins above the selection for | |
| // in-context colour/size/duplicate/delete + a jump to the full inspector. Clicking empty video or Esc | |
| // deselects. ox/oy/scale are applied identically in SceneContent so the gizmo == the exported result. | |
| function SceneSelectionLayer({ stageRef }: { stageRef: React.RefObject<HTMLDivElement | null> }) { | |
| const selId = useStore((s) => s.selectedIds[0] ?? s.inspectId ?? null); | |
| const m = useStore((s) => (selId ? s.motion.find((x) => x.id === selId) : undefined)); | |
| const playheadMs = useStore((s) => s.playheadMs); | |
| const playing = useStore((s) => s.playing); | |
| const updateMotion = useStore((s) => s.updateMotion); | |
| const selectMotion = useStore((s) => s.selectMotion); | |
| const clearSelection = useStore((s) => s.clearSelection); | |
| const pushHistory = useStore((s) => s.pushHistory); | |
| const [box, setBox] = useState({ w: 0, h: 0 }); | |
| const [sel, setSel] = useState<SelRect | null>(null); | |
| const [guide, setGuide] = useState({ v: false, h: false }); | |
| const [sizing, setSizing] = useState(false); | |
| const lastKey = useRef(""); | |
| // stage size | |
| useEffect(() => { | |
| const el = stageRef.current; if (!el) return; | |
| const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight }); | |
| measure(); | |
| const ro = new ResizeObserver(measure); ro.observe(el); | |
| return () => ro.disconnect(); | |
| }, [stageRef]); | |
| // continuously re-measure the SELECTED scene's content box so the gizmo tracks it as it animates / is | |
| // dragged. Only re-renders when the box actually moves (key diff) → no per-frame churn while static. | |
| useEffect(() => { | |
| // hide the gizmo while playing (matches pro NLEs) — and skip the per-frame measurement entirely | |
| if (!selId || playing) { setSel(null); lastKey.current = ""; return; } | |
| let raf = 0, alive = true; | |
| const tick = () => { | |
| if (!alive) return; | |
| const stage = stageRef.current; | |
| const node = stage?.querySelector(`[data-mg-scene-id="${CSS.escape(selId)}"]`); | |
| let next: SelRect | null = null; | |
| if (stage && node) { | |
| const sb = stage.getBoundingClientRect(); | |
| const r = measureContentRect(node); | |
| if (r) next = { left: r.left - sb.left, top: r.top - sb.top, w: r.right - r.left, h: r.bottom - r.top }; | |
| } | |
| const key = next ? `${next.left | 0}:${next.top | 0}:${next.w | 0}:${next.h | 0}` : "none"; | |
| if (key !== lastKey.current) { lastKey.current = key; setSel(next); } | |
| raf = requestAnimationFrame(tick); | |
| }; | |
| raf = requestAnimationFrame(tick); | |
| return () => { alive = false; cancelAnimationFrame(raf); }; | |
| }, [selId, stageRef, box.w, box.h, playing]); | |
| // Esc deselects | |
| useEffect(() => { | |
| const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && selId) clearSelection(); }; | |
| window.addEventListener("keydown", onKey); | |
| return () => window.removeEventListener("keydown", onKey); | |
| }, [selId, clearSelection]); | |
| // letterboxed 16:9 content rect inside the stage box (same mapping as the PiP overlay) | |
| const cw = Math.min(box.w, (box.h * COMP_W) / COMP_H); | |
| const ch = (cw * COMP_H) / COMP_W; | |
| const cl = (box.w - cw) / 2, ct = (box.h - ch) / 2; | |
| const disp = cw / COMP_W; // display scale (screen px per composition px) | |
| const CONTROLS = 58, SNAP = 18; | |
| const isOv = isMovable(m?.template); | |
| const sc = m?.scale && m.scale > 0 ? m.scale : 1; | |
| // anchor (composition px) → its live position on screen, the pivot the resize scales about | |
| const A = OV_ANCHORS[anchorPos(m)] || OV_ANCHORS.bc; | |
| const ax = A.x * COMP_W, ay = A.y * COMP_H; | |
| const pvx = cl + disp * (ax + (m?.ox || 0)), pvy = ct + disp * (ay + (m?.oy || 0)); | |
| // hit-test every visible scene under the cursor; the SMALLEST box wins (an overlay beats a full-frame bg) | |
| const pick = (cx: number, cy: number): string | null => { | |
| const stage = stageRef.current; if (!stage) return null; | |
| let hitId: string | null = null, hitArea = Infinity; | |
| stage.querySelectorAll("[data-mg-scene-id]").forEach((node) => { | |
| const id = node.getAttribute("data-mg-scene-id"); if (!id) return; | |
| const r = measureContentRect(node); if (!r) return; | |
| if (cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom) { | |
| const area = (r.right - r.left) * (r.bottom - r.top); | |
| if (area <= hitArea) { hitArea = area; hitId = id; } | |
| } | |
| }); | |
| return hitId; | |
| }; | |
| // click the video: select the graphic under the cursor (empty → deselect); for an overlay, the same | |
| // gesture can grab-and-move it. Resize is on the corner handles. Centre-snap guides show while moving. | |
| const onSurfaceDown = (e: React.PointerEvent) => { | |
| const id = pick(e.clientX, e.clientY); | |
| if (!id) { clearSelection(); return; } | |
| if (id !== selId) selectMotion(id); | |
| const item = useStore.getState().motion.find((x) => x.id === id); | |
| if (!item || !item.template || !isMovable(item.template)) return; // overlays + Impact scenes move on drag | |
| e.preventDefault(); | |
| const a2 = OV_ANCHORS[anchorPos(item)] || OV_ANCHORS.bc; | |
| const cxOff = COMP_W / 2 - a2.x * COMP_W, cyOff = COMP_H / 2 - a2.y * COMP_H; | |
| const startX = e.clientX, startY = e.clientY, o = { ox: item.ox || 0, oy: item.oy || 0 }; | |
| let moved = false; | |
| const onMove = (ev: PointerEvent) => { | |
| if (!moved) { if (Math.abs(ev.clientX - startX) < 3 && Math.abs(ev.clientY - startY) < 3) return; moved = true; pushHistory(); } | |
| let nx = o.ox + (ev.clientX - startX) / disp; | |
| let ny = o.oy + (ev.clientY - startY) / disp; | |
| const nearV = Math.abs(nx - cxOff) < SNAP, nearH = Math.abs(ny - cyOff) < SNAP; | |
| if (nearV) nx = cxOff; if (nearH) ny = cyOff; | |
| setGuide({ v: nearV, h: nearH }); | |
| nx = Math.max(-COMP_W, Math.min(COMP_W, nx)); ny = Math.max(-COMP_H, Math.min(COMP_H, ny)); | |
| updateMotion(id, { ox: Math.round(nx), oy: Math.round(ny) }); | |
| }; | |
| const onUp = () => { setGuide({ v: false, h: false }); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; | |
| window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); | |
| }; | |
| // corner-resize → uniform scale about the overlay anchor (matches overlayOrigin in SceneContent), so the | |
| // graphic grows exactly in place. Scale = ratio of pointer→anchor distance now vs. at gesture start. | |
| const resize = (e: React.PointerEvent) => { | |
| if (!m) return; | |
| e.preventDefault(); e.stopPropagation(); | |
| const rect = stageRef.current?.getBoundingClientRect(); if (!rect) return; | |
| const px = rect.left + pvx, py = rect.top + pvy; | |
| const d0 = Math.max(24, Math.hypot(e.clientX - px, e.clientY - py)); | |
| const startScale = sc, id = m.id; | |
| let moved = false; setSizing(true); | |
| const onMove = (ev: PointerEvent) => { | |
| if (!moved) { if (Math.abs(ev.clientX - e.clientX) < 2 && Math.abs(ev.clientY - e.clientY) < 2) return; moved = true; pushHistory(); } | |
| const d = Math.hypot(ev.clientX - px, ev.clientY - py); | |
| updateMotion(id, { scale: Math.round(clampOverlayScale(startScale * (d / d0)) * 100) / 100 }); | |
| }; | |
| const onUp = () => { setSizing(false); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; | |
| window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); | |
| }; | |
| if (box.w < 2) return null; | |
| const onScreen = !!m && playheadMs >= m.startMs && playheadMs < m.startMs + m.durationMs; | |
| const vX = cl + disp * (COMP_W / 2), hY = ct + disp * (COMP_H / 2); | |
| const HS = 14; | |
| const handle = (c: Corner, left: number, top: number, cur: string) => ( | |
| <div key={c} onPointerDown={resize} title="Drag to resize" | |
| style={{ position: "absolute", left: left - HS / 2, top: top - HS / 2, width: HS, height: HS, borderRadius: 4, background: "#fff", border: "2px solid #E7723B", boxShadow: "0 1px 4px rgba(0,0,0,0.55)", cursor: cur, pointerEvents: "auto", touchAction: "none" }} /> | |
| ); | |
| return ( | |
| <div className="absolute inset-0" style={{ pointerEvents: "none" }}> | |
| {/* click-capture surface over the video (minus the transport strip) — select / deselect / move */} | |
| <div onPointerDown={onSurfaceDown} | |
| style={{ position: "absolute", left: cl, top: ct, width: cw, height: Math.max(0, ch - CONTROLS), cursor: "default", pointerEvents: "auto", touchAction: "none" }} /> | |
| {/* MEASURED selection box hugging the graphic + (overlay) corner resize handles */} | |
| {onScreen && sel && ( | |
| <> | |
| <div style={{ position: "absolute", left: sel.left - 5, top: sel.top - 5, width: sel.w + 10, height: sel.h + 10, border: `1.6px ${isOv ? "solid" : "dashed"} #E7723B`, borderRadius: 9, boxShadow: "0 0 0 1px rgba(0,0,0,0.4), 0 0 18px rgba(231,114,59,0.28)", pointerEvents: "none" }} /> | |
| {isOv && ( | |
| <> | |
| {handle("tl", sel.left - 5, sel.top - 5, "nwse-resize")} | |
| {handle("tr", sel.left + sel.w + 5, sel.top - 5, "nesw-resize")} | |
| {handle("bl", sel.left - 5, sel.top + sel.h + 5, "nesw-resize")} | |
| {handle("br", sel.left + sel.w + 5, sel.top + sel.h + 5, "nwse-resize")} | |
| </> | |
| )} | |
| </> | |
| )} | |
| {/* in-context quick toolbar pinned to the selection */} | |
| {onScreen && sel && m && <SceneQuickBar item={m} sel={sel} stage={box} isOverlay={isOv} sizing={sizing} sizePct={Math.round(sc * 100)} />} | |
| {/* centre-snap guides while moving */} | |
| {guide.v && <div style={{ position: "absolute", left: vX - 0.5, top: ct, width: 1, height: ch, background: "#ff3ca0", boxShadow: "0 0 7px rgba(255,60,160,0.85)", pointerEvents: "none" }} />} | |
| {guide.h && <div style={{ position: "absolute", left: cl, top: hY - 0.5, width: cw, height: 1, background: "#ff3ca0", boxShadow: "0 0 7px rgba(255,60,160,0.85)", pointerEvents: "none" }} />} | |
| </div> | |
| ); | |
| } | |
| const QB_BTN: React.CSSProperties = { display: "grid", placeItems: "center", width: 28, height: 28, borderRadius: 7, background: "transparent", border: "none", color: "inherit", cursor: "pointer" }; | |
| const QB_ACCENTS = (MG_ACCENTS as string[]).slice(0, 6); | |
| // Floating in-context toolbar pinned just above (or below) the selected graphic — the quick edits without | |
| // a trip to the side panel: accent swatches + custom colour, size −/+ (overlays), duplicate, delete, and | |
| // a jump to the full inspector. A little arrow points at the graphic. Clamped to stay inside the stage. | |
| function SceneQuickBar({ item, sel, stage, isOverlay, sizing, sizePct }: { item: MotionItem; sel: SelRect; stage: { w: number; h: number }; isOverlay: boolean; sizing: boolean; sizePct: number }) { | |
| const updateMotion = useStore((s) => s.updateMotion); | |
| const duplicateMotion = useStore((s) => s.duplicateMotion); | |
| const removeMotion = useStore((s) => s.removeMotion); | |
| const selectMotion = useStore((s) => s.selectMotion); | |
| const pushHistory = useStore((s) => s.pushHistory); | |
| const commit = (patch: Partial<MotionItem>) => { pushHistory(); updateMotion(item.id, patch); }; | |
| const BARW = isOverlay ? 384 : 250, BARH = 46; | |
| const cxOnBar = sel.left + sel.w / 2; | |
| let left = Math.max(8, Math.min(stage.w - BARW - 8, cxOnBar - BARW / 2)); | |
| let top = sel.top - BARH - 16; | |
| const below = top < 6; | |
| if (below) top = Math.min(stage.h - BARH - 8, sel.top + sel.h + 16); | |
| const sc = item.scale && item.scale > 0 ? item.scale : 1; | |
| const bump = (d: number) => commit({ scale: Math.round(clampOverlayScale(sc + d) * 100) / 100 }); | |
| const cur = (item.accent || (MG_ACCENTS as string[])[0]).toLowerCase(); | |
| const arrowLeft = Math.max(14, Math.min(BARW - 14, cxOnBar - left)) - 6; | |
| return ( | |
| <div style={{ position: "absolute", left, top, width: BARW, height: BARH, pointerEvents: "auto", zIndex: 5 }} | |
| onPointerDown={(e) => e.stopPropagation()}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 9, height: "100%", padding: "0 12px", borderRadius: 13, background: "rgba(20,20,26,0.93)", border: "1px solid rgba(255,255,255,0.14)", backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)", boxShadow: "0 10px 30px rgba(0,0,0,0.6)", color: "#fff" }}> | |
| {/* accent swatches + custom colour */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 5 }}> | |
| {QB_ACCENTS.map((c) => ( | |
| <button key={c} onClick={() => commit({ accent: c })} title={c} | |
| style={{ width: 18, height: 18, borderRadius: "50%", background: c, border: cur === c.toLowerCase() ? "2px solid #fff" : "2px solid rgba(255,255,255,0.18)", boxShadow: cur === c.toLowerCase() ? "0 0 0 1.5px rgba(0,0,0,0.5)" : "none", cursor: "pointer", padding: 0 }} /> | |
| ))} | |
| <label title="Custom colour" style={{ position: "relative", width: 18, height: 18, borderRadius: "50%", display: "block", cursor: "pointer", background: "conic-gradient(from 0deg,#ff0033,#ffae00,#37d67a,#2bb0ff,#7c5cff,#ff0033)", border: "2px solid rgba(255,255,255,0.5)" }}> | |
| <input type="color" value={/^#[0-9a-fA-F]{6}$/.test(item.accent || "") ? (item.accent as string) : "#E7723B"} onChange={(e) => commit({ accent: e.target.value })} style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer", width: "100%", height: "100%" }} /> | |
| </label> | |
| </div> | |
| {isOverlay && ( | |
| <> | |
| <span style={{ width: 1, height: 24, background: "rgba(255,255,255,0.14)" }} /> | |
| <div style={{ display: "flex", alignItems: "center", gap: 3 }}> | |
| <button onClick={() => bump(-0.1)} title="Smaller" style={QB_BTN}><Minus size={15} /></button> | |
| <span style={{ width: 40, textAlign: "center", fontSize: 11.5, fontVariantNumeric: "tabular-nums", color: sizing ? "#E7723B" : "#fff" }}>{sizePct}%</span> | |
| <button onClick={() => bump(0.1)} title="Bigger" style={QB_BTN}><Plus size={15} /></button> | |
| </div> | |
| </> | |
| )} | |
| <span style={{ width: 1, height: 24, background: "rgba(255,255,255,0.14)" }} /> | |
| <button onClick={() => duplicateMotion(item.id)} title="Duplicate" style={QB_BTN}><Copy size={15} /></button> | |
| <button onClick={() => selectMotion(item.id)} title="All edits (open inspector)" style={QB_BTN}><SlidersHorizontal size={15} /></button> | |
| <button onClick={() => removeMotion(item.id)} title="Delete" style={{ ...QB_BTN, color: "#ff6b6b" }}><Trash2 size={15} /></button> | |
| </div> | |
| {/* arrow pointing at the graphic */} | |
| <div style={{ position: "absolute", left: arrowLeft, [below ? "top" : "bottom"]: -5, width: 10, height: 10, background: "rgba(20,20,26,0.93)", borderRight: "1px solid rgba(255,255,255,0.14)", borderBottom: "1px solid rgba(255,255,255,0.14)", transform: below ? "rotate(225deg)" : "rotate(45deg)" } as React.CSSProperties} /> | |
| </div> | |
| ); | |
| } | |
| // Interactive transform handles for the selected floating-overlay (PiP) clip, drawn over the Player. | |
| // Maps the composition's 1920×1080 space to the on-screen (letterboxed) video rect so drag = move | |
| // and corner handles = resize, writing straight back to the clip's layout. | |
| function PipTransformOverlay({ stageRef }: { stageRef: React.RefObject<HTMLDivElement | null> }) { | |
| const selId = useStore((s) => s.selectedVideoId); | |
| const v = useStore((s) => (selId ? s.videos.find((x) => x.id === selId) : undefined)); | |
| const playheadMs = useStore((s) => s.playheadMs); | |
| const updateVideo = useStore((s) => s.updateVideo); | |
| const pushHistory = useStore((s) => s.pushHistory); | |
| const [box, setBox] = useState({ w: 0, h: 0 }); | |
| useEffect(() => { | |
| const el = stageRef.current; if (!el) return; | |
| const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight }); | |
| measure(); | |
| const ro = new ResizeObserver(measure); ro.observe(el); | |
| return () => ro.disconnect(); | |
| }, [stageRef]); | |
| const L = v?.layout; | |
| if (!v || !L || box.w < 2) return null; | |
| const dur = Math.max(1, (v.durationMs / 1000) * FPS); | |
| const frameInClip = ((playheadMs - v.startMs) / 1000) * FPS; | |
| if (frameInClip < -0.5 || frameInClip >= dur) return null; // clip not on screen now | |
| // live geometry (tracks the morph) — handles always wrap the actual rendered video | |
| const g = pipBox(v, Math.max(0, frameInClip), dur, FPS); | |
| const settled = g.t > 0.85; | |
| if (!settled) return null; // hide the selection box entirely while the clip is morphing in/out | |
| const zoom = Math.max(1, v.zoom || 1); | |
| // letterboxed 16:9 content rect inside the stage box | |
| const cw = Math.min(box.w, (box.h * COMP_W) / COMP_H); | |
| const ch = (cw * COMP_H) / COMP_W; | |
| const cl = (box.w - cw) / 2, ct = (box.h - ch) / 2; | |
| const scale = cw / COMP_W; | |
| const toPx = (n: number) => n * scale; | |
| const circle = v.shape === "circle"; | |
| // move / resize → write the overlay layout | |
| const drag = (mode: "move" | Corner) => (e: React.PointerEvent) => { | |
| e.preventDefault(); e.stopPropagation(); | |
| const sx = e.clientX, sy = e.clientY, o = { ...L }; | |
| let moved = false; | |
| const onMove = (ev: PointerEvent) => { | |
| if (!moved) { if (Math.abs(ev.clientX - sx) < 2 && Math.abs(ev.clientY - sy) < 2) return; moved = true; pushHistory(); } | |
| const dx = (ev.clientX - sx) / scale, dy = (ev.clientY - sy) / scale; | |
| let { x, y, w, h } = o; | |
| if (mode === "move") { x = o.x + dx; y = o.y + dy; } | |
| else { | |
| const left = mode === "tl" || mode === "bl", top = mode === "tl" || mode === "tr"; | |
| let nw = left ? o.w - dx : o.w + dx; | |
| let nh = top ? o.h - dy : o.h + dy; | |
| if (circle) { const d = Math.max(MIN_PX, Math.max(nw, nh)); nw = d; nh = d; } | |
| nw = Math.max(MIN_PX, nw); nh = Math.max(MIN_PX, nh); | |
| w = nw; h = nh; | |
| if (left) x = o.x + (o.w - nw); | |
| if (top) y = o.y + (o.h - nh); | |
| } | |
| x = Math.max(-w + 40, Math.min(COMP_W - 40, x)); | |
| y = Math.max(-h + 40, Math.min(COMP_H - 40, y)); | |
| updateVideo(v.id, { layout: { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) } }); | |
| }; | |
| const onUp = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; | |
| window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); | |
| }; | |
| // drag the content inside the frame → free pan (frame any subject, even off-centre) | |
| const panDrag = (e: React.PointerEvent) => { | |
| e.preventDefault(); e.stopPropagation(); | |
| const sx = e.clientX, sy = e.clientY, ox = v.pan?.x || 0, oy = v.pan?.y || 0; | |
| const bw = Math.max(1, toPx(g.w)), bh = Math.max(1, toPx(g.h)); | |
| let moved = false; | |
| const cl2 = (n: number) => Math.max(-0.6, Math.min(0.6, n)); | |
| const onMove = (ev: PointerEvent) => { | |
| if (!moved) { if (Math.abs(ev.clientX - sx) < 2 && Math.abs(ev.clientY - sy) < 2) return; moved = true; pushHistory(); } | |
| updateVideo(v.id, { pan: { x: cl2(ox + (ev.clientX - sx) / bw), y: cl2(oy + (ev.clientY - sy) / bh) } }); | |
| }; | |
| const onUp = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; | |
| window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); | |
| }; | |
| const handle = (c: Corner, cur: string, pos: React.CSSProperties) => ( | |
| <div onPointerDown={drag(c)} style={{ position: "absolute", width: 16, height: 16, borderRadius: 4, background: "#fff", border: "2px solid #E7723B", boxShadow: "0 1px 4px rgba(0,0,0,0.4)", cursor: cur, pointerEvents: "auto", touchAction: "none", ...pos }} /> | |
| ); | |
| return ( | |
| <div className="absolute inset-0" style={{ pointerEvents: "none" }}> | |
| <div | |
| onPointerDown={settled ? drag("move") : undefined} | |
| style={{ position: "absolute", left: cl + toPx(g.x), top: ct + toPx(g.y), width: toPx(g.w), height: toPx(g.h), border: "1.5px solid #E7723B", borderRadius: circle ? "50%" : g.radius > 1 ? toPx(g.radius) : 2, boxShadow: "0 0 0 1px rgba(0,0,0,0.35)", cursor: settled ? "move" : "default", pointerEvents: settled ? "auto" : "none", touchAction: "none", opacity: settled ? 1 : 0.7 }} | |
| > | |
| {settled && <>{handle("tl", "nwse-resize", { left: -8, top: -8 })}{handle("tr", "nesw-resize", { right: -8, top: -8 })}{handle("bl", "nesw-resize", { left: -8, bottom: -8 })}{handle("br", "nwse-resize", { right: -8, bottom: -8 })}</>} | |
| {settled && zoom > 1.01 && ( | |
| <div onPointerDown={panDrag} title="Drag to position the subject (pan)" style={{ position: "absolute", left: "50%", top: "50%", transform: "translate(-50%,-50%)", width: 34, height: 34, borderRadius: "50%", background: "rgba(231,114,59,0.85)", border: "2px solid #fff", boxShadow: "0 2px 8px rgba(0,0,0,0.5)", cursor: "move", pointerEvents: "auto", touchAction: "none", display: "grid", placeItems: "center" }}> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20" /></svg> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Crop gizmo: when a full-frame clip is in crop mode (store.croppingId), draw a draggable | |
| // crop rectangle over the letterboxed video. The box is LOCKED to the frame aspect (16:9) | |
| // so whatever is kept fills the output with no bars; corner handles resize, the interior | |
| // drags, and the area outside is dimmed. Writes v.crop (normalized 0..1 of the frame). | |
| function CropOverlay({ stageRef }: { stageRef: React.RefObject<HTMLDivElement | null> }) { | |
| const cropId = useStore((s) => s.croppingId); | |
| const v = useStore((s) => (cropId ? s.videos.find((x) => x.id === cropId) : undefined)); | |
| const locked = useStore((s) => s.cropLock); | |
| const updateVideo = useStore((s) => s.updateVideo); | |
| const pushHistory = useStore((s) => s.pushHistory); | |
| const [box, setBox] = useState({ w: 0, h: 0 }); | |
| useEffect(() => { | |
| const el = stageRef.current; if (!el) return; | |
| const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight }); | |
| measure(); | |
| const ro = new ResizeObserver(measure); ro.observe(el); | |
| return () => ro.disconnect(); | |
| }, [stageRef]); | |
| if (!v || v.layout || box.w < 2) return null; // crop is for full-frame clips | |
| const c = v.crop && v.crop.w > 0 ? v.crop : { x: 0, y: 0, w: 1, h: 1 }; | |
| // letterboxed 16:9 content rect inside the stage (same mapping as the PiP overlay) | |
| const cw = Math.min(box.w, (box.h * COMP_W) / COMP_H); | |
| const ch = (cw * COMP_H) / COMP_W; | |
| const cl = (box.w - cw) / 2, ct = (box.h - ch) / 2; | |
| // crop rect → on-screen px | |
| const rx = cl + c.x * cw, ry = ct + c.y * ch, rw = c.w * cw, rh = c.h * ch; | |
| const MINW = 0.12; // min crop width (fraction of frame) | |
| // A frame-aspect (16:9) region of a 16:9 frame has EQUAL width/height FRACTIONS, | |
| // so locking the aspect = keeping w==h in frame-fraction space. | |
| const drag = (mode: "move" | Corner) => (e: React.PointerEvent) => { | |
| e.preventDefault(); e.stopPropagation(); | |
| const sx = e.clientX, sy = e.clientY, o = { ...c }; | |
| let moved = false; | |
| const onMove = (ev: PointerEvent) => { | |
| if (!moved) { if (Math.abs(ev.clientX - sx) < 2 && Math.abs(ev.clientY - sy) < 2) return; moved = true; pushHistory(); } | |
| const dx = (ev.clientX - sx) / cw, dy = (ev.clientY - sy) / ch; // frame fractions | |
| let { x, y, w, h } = o; | |
| if (mode === "move") { | |
| x = Math.max(0, Math.min(1 - o.w, o.x + dx)); | |
| y = Math.max(0, Math.min(1 - o.h, o.y + dy)); | |
| } else { | |
| const left = mode === "tl" || mode === "bl", top = mode === "tl" || mode === "tr"; | |
| const fixedX = left ? o.x + o.w : o.x; // the vertical edge that stays put | |
| const fixedY = top ? o.y + o.h : o.y; // the horizontal edge that stays put | |
| let nw = Math.max(MINW, Math.min(1, left ? o.w - dx : o.w + dx)); | |
| let nh = Math.max(MINW, Math.min(1, top ? o.h - dy : o.h + dy)); | |
| // LOCKED: a 16:9 region of a 16:9 frame ⇒ equal w/h FRACTIONS (drive both together) | |
| if (locked) { const m = Math.max(nw, nh); nw = m; nh = m; } | |
| // clamp so the box stays inside the frame from the fixed corner | |
| nw = left ? Math.min(nw, fixedX) : Math.min(nw, 1 - o.x); | |
| nh = top ? Math.min(nh, fixedY) : Math.min(nh, 1 - o.y); | |
| if (locked) { const m = Math.min(nw, nh); nw = m; nh = m; } | |
| w = nw; h = nh; | |
| x = left ? fixedX - nw : o.x; | |
| y = top ? fixedY - nh : o.y; | |
| } | |
| updateVideo(v.id, { crop: { x: +x.toFixed(4), y: +y.toFixed(4), w: +w.toFixed(4), h: +h.toFixed(4) } }); | |
| }; | |
| const onUp = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; | |
| window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); | |
| }; | |
| const handle = (cn: Corner, cur: string, pos: React.CSSProperties) => ( | |
| <div onPointerDown={drag(cn)} style={{ position: "absolute", width: 16, height: 16, borderRadius: 4, background: "#fff", border: "2px solid #36C2B8", boxShadow: "0 1px 4px rgba(0,0,0,0.5)", cursor: cur, pointerEvents: "auto", touchAction: "none", ...pos }} /> | |
| ); | |
| const dim = (s: React.CSSProperties) => <div style={{ position: "absolute", background: "rgba(8,10,14,0.62)", pointerEvents: "none", ...s }} />; | |
| return ( | |
| <div className="absolute inset-0" style={{ pointerEvents: "none" }}> | |
| {/* dim the discarded area (four panels around the kept region) */} | |
| {dim({ left: cl, top: ct, width: cw, height: Math.max(0, ry - ct) })} | |
| {dim({ left: cl, top: ry + rh, width: cw, height: Math.max(0, ct + ch - (ry + rh)) })} | |
| {dim({ left: cl, top: ry, width: Math.max(0, rx - cl), height: rh })} | |
| {dim({ left: rx + rw, top: ry, width: Math.max(0, cl + cw - (rx + rw)), height: rh })} | |
| <div onPointerDown={drag("move")} style={{ position: "absolute", left: rx, top: ry, width: rw, height: rh, border: "1.5px solid #36C2B8", boxShadow: "0 0 0 1px rgba(0,0,0,0.4)", cursor: "move", pointerEvents: "auto", touchAction: "none" }}> | |
| <div style={{ position: "absolute", inset: 0, pointerEvents: "none", backgroundImage: "linear-gradient(rgba(255,255,255,0.18) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.18) 1px,transparent 1px)", backgroundSize: "33.33% 33.33%" }} /> | |
| {handle("tl", "nwse-resize", { left: -8, top: -8 })} | |
| {handle("tr", "nesw-resize", { right: -8, top: -8 })} | |
| {handle("bl", "nesw-resize", { left: -8, bottom: -8 })} | |
| {handle("br", "nwse-resize", { right: -8, bottom: -8 })} | |
| </div> | |
| </div> | |
| ); | |
| } | |