import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react"; import clsx from "clsx"; import Modal, { ModalFooter } from "./ui/Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; import { Button } from "./ui/Button"; import { failureReason, notifyActionError } from "../lib/actionErrors"; import { markReported } from "../lib/api/core"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; // mm:ss.d timecode <-> seconds. function tc(s: number): string { if (!isFinite(s) || s < 0) s = 0; const m = Math.floor(s / 60); return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`; } function parseTc(v: string): number | null { v = v.trim(); if (!v) return null; if (v.includes(":")) { const [m, s] = v.split(":"); const mm = Number(m), ss = Number(s); if (!isFinite(mm) || !isFinite(ss)) return null; return mm * 60 + ss; } const n = Number(v); return isFinite(n) ? n : null; } type Seg = { id: number; start: number; end: number; keep: boolean }; type Drag = { type: "seek" } | { type: "boundary"; bi: number }; const MIN_SEG = 0.1; export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const videoRef = useRef(null); const trackRef = useRef(null); const sid = useRef(1); const nid = () => sid.current++; const srcDur = job.asset?.duration_s ?? 0; const [duration, setDuration] = useState(srcDur); const [current, setCurrent] = useState(0); const [playing, setPlaying] = useState(false); const [trackW, setTrackW] = useState(0); // Intrinsic (source) video dims and the video element's rendered box, both needed to map the // crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay // can size before the media decodes; `box` is measured off the element (max-h can clamp it). const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 }); const [box, setBox] = useState({ w: 0, h: 0 }); // The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept. const [segments, setSegments] = useState([{ id: 0, start: 0, end: srcDur, keep: true }]); // Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids // Create started with, so the cut list holds still until it is done: a split or a merge in between // would deselect a range that was never queued, or keep one that was (deep-review E11 round 3). const creating = useRef(false); // The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way // failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)"). const series = useRef | null>(null); const [selId, setSelId] = useState(0); const [output, setOutput] = useState<"separate" | "join">("separate"); const [cropOn, setCropOn] = useState(false); const [crop, setCrop] = useState({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 }); const [accurate, setAccurate] = useState(true); const [name, setName] = useState(""); const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]); const aspect = (job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9; const sb = useQuery({ queryKey: qk.storyboard(job.id), queryFn: () => api.downloadStoryboard(job.id), staleTime: Infinity, retry: false, }); const strip = sb.data?.available ? sb.data : null; const defaultName = job.display_name || job.asset?.title || ""; // Measure the track for aspect-correct filmstrip tiling. useEffect(() => { const el = trackRef.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(() => setTrackW(el.clientWidth)); ro.observe(el); setTrackW(el.clientWidth); return () => ro.disconnect(); }, []); // Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which // then pillarboxes the picture). The crop overlay is anchored to the picture, not this box. useEffect(() => { const v = videoRef.current; if (!v || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight })); ro.observe(v); setBox({ w: v.clientWidth, h: v.clientHeight }); return () => ro.disconnect(); }, []); // The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's // frame, so a crop fraction is a fraction of the real picture, not of the black bars. const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect; const picRect = contentRect(box.w, box.h, va); const onLoaded = () => { const v = videoRef.current; if (!v) return; const d = v.duration && isFinite(v.duration) ? v.duration : srcDur; setDuration(d); if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight }); // Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial // segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know // the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so // the no-op guard (isFullSingle: end >= duration) reads false and enables a "Create" that files a // clip of an unmodified video and drops its tail. Only touches the untouched segment (end===srcDur). setSegments((s) => (s.length === 1 && s[0]!.end === srcDur ? [{ ...s[0]!, end: d }] : s)); }; const onTimeUpdate = () => { const v = videoRef.current; if (v) setCurrent(v.currentTime); }; const seek = useCallback( (s: number) => { const v = videoRef.current; if (v) v.currentTime = Math.max(0, Math.min(duration, s)); }, [duration], ); const playPause = () => { const v = videoRef.current; if (!v) return; v.paused ? v.play() : v.pause(); }; // --- cut-list mutations --- const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]); const splitAtPlayhead = () => { if (creating.current) return; const time = current; setSegments((segs) => { const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG); if (i < 0) return segs; const s = segs[i]!; // i >= 0 from findIndex, so segs[i] exists const b: Seg = { id: nid(), start: time, end: s.end, keep: s.keep }; const next = [...segs.slice(0, i), { ...s, end: time }, b, ...segs.slice(i + 1)]; return next; }); }; const moveBoundary = useCallback( (bi: number, time: number) => setSegments((segs) => { if (creating.current || bi < 0 || bi >= segs.length - 1) return segs; // bi is in [0, length-2] here, so bi and bi+1 both index existing segments. const lo = segs[bi]!.start + MIN_SEG; const hi = segs[bi + 1]!.end - MIN_SEG; const tt = Math.max(lo, Math.min(hi, time)); const copy = [...segs]; copy[bi] = { ...copy[bi]!, end: tt }; copy[bi + 1] = { ...copy[bi + 1]!, start: tt }; return copy; }), [], ); const deleteBoundary = (bi: number) => setSegments((segs) => { if (creating.current || segs.length < 2) return segs; const merged = { ...segs[bi]!, end: segs[bi + 1]!.end }; return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)]; }); const toggleKeep = (id: number) => setSegments((segs) => creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)), ); // --- timeline drag (seek / boundary) + hover preview --- const [drag, setDrag] = useState(null); const [hoverX, setHoverX] = useState(null); const timeAt = useCallback( (clientX: number) => { const el = trackRef.current; if (!el) return 0; const r = el.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration; }, [duration], ); useEffect(() => { if (!drag) return; const move = (e: PointerEvent) => { const tt = timeAt(e.clientX); if (drag.type === "seek") { setCurrent(tt); seek(tt); } else moveBoundary(drag.bi, tt); }; const up = () => setDrag(null); window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, [drag, timeAt, seek, moveBoundary]); // --- crop drag --- const overlayRef = useRef(null); const cropDrag = useRef( null, ); const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => { e.preventDefault(); e.stopPropagation(); (e.target as Element).setPointerCapture?.(e.pointerId); cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } }; }; useEffect(() => { const move = (e: PointerEvent) => { const d = cropDrag.current; const el = overlayRef.current; if (!d || !el) return; const r = el.getBoundingClientRect(); const dx = (e.clientX - d.px) / r.width; const dy = (e.clientY - d.py) / r.height; if (d.mode === "move") setCrop({ ...d.orig, x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)), y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)), }); else setCrop({ ...d.orig, w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)), h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)), }); }; const up = () => (cropDrag.current = null); window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, []); // --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) --- const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`; const tileStyle = (idx: number): React.CSSProperties => { const cols = strip?.cols ?? 12; const rows = strip?.rows ?? 1; const i = Math.max(0, Math.min((strip?.count ?? 1) - 1, idx)); const col = i % cols; const row = Math.floor(i / cols); return { backgroundImage: `url(${api.storyboardImageUrl(job.id)})`, backgroundSize: `${cols * 100}% ${rows * 100}%`, backgroundPosition: `${cols > 1 ? (col / (cols - 1)) * 100 : 0}% ${rows > 1 ? (row / (rows - 1)) * 100 : 0}%`, }; }; const stripCells = strip && trackW ? Math.max(1, Math.floor(trackW / (44 * aspect))) : 0; const hoverTime = hoverX != null ? timeAt(hoverX) : null; // Clamp the hover-scrub popover inside the track so it never overflows the modal (which would // add a horizontal scrollbar): center it on the cursor, but keep both edges within [0, trackW]. const HOVER_W = 148; const hoverLeft = hoverX != null ? Math.max( 0, Math.min( Math.max(0, (trackW || HOVER_W) - HOVER_W), hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - HOVER_W / 2, ), ) : 0; // --- create --- const kept = segments.filter((s) => s.keep); const keptDur = kept.reduce((a, s) => a + (s.end - s.start), 0); const isFullSingle = segments.length === 1 && kept.length === 1 && kept[0]!.start <= 0.01 && kept[0]!.end >= duration - 0.01; const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle); const willJoin = output === "join" && kept.length > 1; const reencode = cropOn || accurate; const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => { const v = videoRef.current; const vw = v?.videoWidth || vdim.w; const vh = v?.videoHeight || vdim.h; if (!cropOn) return undefined; // crop fractions are relative to the picture (the overlay is anchored to it), so this is a // straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp. return cropToSourcePx(crop, vw, vh) ?? undefined; }; const create = useMutation({ mutationFn: async () => { creating.current = true; const cp = cropPx(); const base = name.trim() || defaultName; if (willJoin) { const spec: EditSpec = { segments: kept.map((s) => ({ start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) })), accurate, }; if (cp) spec.crop = cp; await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: name.trim() || undefined, }); return { files: 1 }; } const N = kept.length; const prev = series.current; const suffix = prev && kept.every((s) => prev.has(s.id)) ? prev : new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""])); series.current = suffix; const done: number[] = []; try { for (let i = 0; i < N; i++) { const s = kept[i]!; // i < N === kept.length, so kept[i] exists const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) }, }; if (cp) spec.crop = cp; else spec.accurate = accurate; const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined; await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn }); done.push(s.id); } } catch (e) { // Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left // clips 1 and 2 queued while the editor reported a plain failure and kept all three selected, // so trying again queued the first two a second time. Say how many made it, and deselect them // so another try adds only the rest. if (done.length) { qc.invalidateQueries({ queryKey: qk.downloads() }); qc.invalidateQueries({ queryKey: qk.downloadUsage() }); setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s))); // Its own toast, not notifyActionError: a quota or edit refusal has already opened req's // error dialog and marked the error, so that helper would say nothing — and the count is // what the dialog does not tell (deep-review E11 round 3). notify({ level: "error", title: t("editor.partlyCreated", { done: done.length, total: N }), message: failureReason(e), }); markReported(e); } throw e; } return { files: N }; }, onSuccess: ({ files }) => { qc.invalidateQueries({ queryKey: qk.downloads() }); qc.invalidateQueries({ queryKey: qk.downloadUsage() }); notify({ level: "success", message: t("editor.created", { count: files }) }); onClose(); }, onError: (e) => notifyActionError(e, t("editor.failed")), onSettled: () => { creating.current = false; }, }); const sel = segments.find((s) => s.id === selId) ?? segments[0]; const selIdx = segments.findIndex((s) => s.id === sel?.id); return (
{/* video + crop overlay */}