import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, Loader2, X } 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 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 Frac = { x: number; y: number; w: number; h: number }; type Drag = { type: "seek" } | { type: "boundary"; bi: number }; 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"; const numCls = "w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-accent/40"; 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); // 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 }]); 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: ["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(); }, []); const onLoaded = () => { const v = videoRef.current; if (!v) return; const d = v.duration && isFinite(v.duration) ? v.duration : srcDur; setDuration(d); setSegments((s) => (s.length === 1 && s[0].end <= 0 ? [{ id: 0, start: 0, end: d, keep: true }] : s)); }; const onTimeUpdate = () => { const v = videoRef.current; if (v) setCurrent(v.currentTime); }; 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; v.paused ? v.play() : v.pause(); }; // --- cut-list mutations --- const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]); const splitAtPlayhead = () => { 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]; 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 = (bi: number, time: number) => setSegments((segs) => { if (bi < 0 || bi >= segs.length - 1) return segs; 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 (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) => 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]); // --- 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; // --- 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 || (willJoin ? accurate : 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; 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 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; for (let i = 0; i < N; i++) { const s = kept[i]; 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 = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined; await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn }); } return { files: N }; }, onSuccess: ({ files }) => { qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: ["download-usage"] }); notify({ level: "success", message: t("editor.created", { count: files }) }); onClose(); }, onError: () => notify({ level: "error", message: t("editor.failed") }), }); const sel = segments.find((s) => s.id === selId) ?? segments[0]; const selIdx = segments.findIndex((s) => s.id === sel?.id); return (
{/* video + crop overlay */}