Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
37.8 kB
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 tagsthe 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>
);
}