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 } | 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(null); const stageRef = useRef(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 (
{empty ? (
Nothing to preview yet
Let the AI agent build {projectName ? “{projectName}” : "your video"} from the script — it’ll outline the sections and add the scenes.
) : (
{/* 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. */}
)}
1920×1080·{Math.round(stats.durationMs / 1000)}s· {stats.kept} words·{stats.motionCount} scene{stats.motionCount === 1 ? "" : "s"}
{/* 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. */}
{([["full", "Full"], ["balanced", "Balanced"], ["smooth", "Smooth"]] as const).map(([q, label]) => ( ))}
); } 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 }) { 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(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) => (
); return (
{/* click-capture surface over the video (minus the transport strip) — select / deselect / move */}
{/* MEASURED selection box hugging the graphic + (overlay) corner resize handles */} {onScreen && sel && ( <>
{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 && } {/* centre-snap guides while moving */} {guide.v &&
} {guide.h &&
}
); } 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) => { 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 (
e.stopPropagation()}>
{/* accent swatches + custom colour */}
{QB_ACCENTS.map((c) => (
{isOverlay && ( <>
{sizePct}%
)}
{/* arrow pointing at the graphic */}
); } // 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 }) { 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) => (
); return (
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 && (
)}
); } // 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 }) { 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) => (
); const dim = (s: React.CSSProperties) =>
; return (
{/* 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 })}
{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 })}
); }