import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Crop, Pause, Play, Scissors, Loader2 } from "lucide-react"; import clsx from "clsx"; import Modal from "./Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; // mm:ss(.d) — a compact timecode for the trim readout. function tc(s: number): string { if (!isFinite(s) || s < 0) s = 0; const m = Math.floor(s / 60); const sec = s - m * 60; return `${m}:${sec.toFixed(1).padStart(4, "0")}`; } type Frac = { x: number; y: number; w: number; h: number }; // crop as 0..1 fractions of the frame const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50"; const inputCls = "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"; 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 srcDur = job.asset?.duration_s ?? 0; const [duration, setDuration] = useState(srcDur); const [current, setCurrent] = useState(0); const [playing, setPlaying] = useState(false); const [start, setStart] = useState(0); const [end, setEnd] = useState(srcDur); 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 [split, setSplit] = useState(1); const [name, setName] = useState(""); const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]); const sb = useQuery({ queryKey: ["storyboard", job.id], queryFn: () => api.downloadStoryboard(job.id), staleTime: Infinity, retry: false, }); // Keep end pinned to the real duration until the user narrows it. const onLoaded = () => { const v = videoRef.current; if (!v) return; const d = v.duration && isFinite(v.duration) ? v.duration : srcDur; setDuration(d); setEnd((e) => (e <= 0 || e > d ? d : e)); }; // Pause at the out-point when previewing the selection. const onTimeUpdate = () => { const v = videoRef.current; if (!v) return; setCurrent(v.currentTime); if (playing && v.currentTime >= end) { v.pause(); v.currentTime = end; } }; const seek = (s: number) => { const v = videoRef.current; if (v) v.currentTime = Math.max(0, Math.min(duration, s)); }; const playPause = () => { const v = videoRef.current; if (!v) return; if (v.paused) { if (v.currentTime < start || v.currentTime >= end) v.currentTime = start; v.play(); } else { v.pause(); } }; // --- timeline drag (in/out handles + scrub) --- 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] ); const [drag, setDrag] = useState(null); const onTrackDown = (e: React.PointerEvent, which: "start" | "end" | "seek") => { e.preventDefault(); e.stopPropagation(); (e.target as Element).setPointerCapture?.(e.pointerId); setDrag(which); if (which === "seek") seek(timeAt(e.clientX)); }; useEffect(() => { if (!drag) return; const move = (e: PointerEvent) => { const tt = timeAt(e.clientX); if (drag === "start") setStart(Math.min(tt, end - 0.1)); else if (drag === "end") setEnd(Math.max(tt, start + 0.1)); else { setCurrent(tt); seek(tt); } }; const up = () => setDrag(null); window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, [drag, timeAt, start, end]); // --- crop drag (move body + resize corner), all in 0..1 frame fractions --- 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); }; }, []); // --- create --- const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`; const selDur = Math.max(0, end - start); const reencode = cropOn || accurate; const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => { const v = videoRef.current; const vw = v?.videoWidth || job.asset?.width || 0; const vh = v?.videoHeight || job.asset?.height || 0; if (!cropOn || !vw || !vh) return undefined; // Force even dimensions — libx264 requires them. const even = (n: number) => Math.max(2, Math.round(n / 2) * 2); return { x: even(crop.x * vw), y: even(crop.y * vh), w: even(crop.w * vw), h: even(crop.h * vh), }; }; const create = useMutation({ mutationFn: async () => { const n = Math.max(1, Math.min(20, Math.round(split))); const cp = cropPx(); const seg = selDur / n; const base = name.trim(); for (let i = 0; i < n; i++) { const s = start + seg * i; const e = i === n - 1 ? end : s + seg; const spec: EditSpec = { trim: { start_s: +s.toFixed(3), end_s: +e.toFixed(3) } }; if (cp) spec.crop = cp; else spec.accurate = accurate; const dn = n > 1 ? `${base || defaultName} (${i + 1}/${n})` : base || undefined; await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn }); } return n; }, onSuccess: (n) => { qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: ["download-usage"] }); notify({ level: "success", message: t("editor.created", { count: n }) }); onClose(); }, onError: () => notify({ level: "error", message: t("editor.failed") }), }); const defaultName = job.display_name || job.asset?.title || ""; const valid = selDur >= 0.1 && (cropOn || start > 0 || end < duration - 0.01 || split > 1); // filmstrip cell backgrounds via percentage sprite positioning (no pixel math needed). const strip = sb.data?.available ? sb.data : null; return (
{/* video + crop overlay */}