import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { invalidateDownloads } from "../../lib/downloads"; 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 "../../components/ui/Modal"; import { msg, notify } from "../../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../../lib/api"; import { cropToSourcePx, type Frac } from "../../lib/cropGeometry"; import { CropFrame } from "../../components/ui/CropFrame"; import SegmentedToggle from "../../components/ui/SegmentedToggle"; import { Button } from "../../components/ui/Button"; import Tooltip from "../../components/ui/Tooltip"; import { unavailable } from "../../components/ui/unavailable"; import { failureReason, notifyActionError } from "../../lib/actionErrors"; import { markReported } from "../../lib/api/core"; import { IconButton } from "../../components/ui/IconButton"; import { Pressable } from "../../components/ui/Pressable"; import { Input } from "../../components/ui/Input"; import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField"; import { useTimecodeField } from "../../components/ui/useTimecodeField"; import { SURFACE } from "../../components/ui/surfaces"; import { RangeTrack } from "../../components/ui/RangeTrack"; import { trackLeft } from "../../components/ui/trackPosition"; import { formatTimecode } from "../../lib/timecode"; import { formatNumber } from "../../lib/format"; import { atMs, estimateGif, gifEstimateText, gifShownLength, gifVerdict, GIF_DEFAULT_SIDE, GIF_MAX_S, GIF_SIDES, type GifSide, type GifVerdict, } from "../../lib/gif"; type Seg = { id: number; start: number; end: number; keep: boolean }; const MIN_SEG = 0.1; /** Queues each clip as its own job, in order; `done` collects the ids that made it, so the caller's * catch can say how many did when one fails part-way. */ async function enqueueClips( jobId: number, clips: { id: number; spec: EditSpec; dn: string | undefined }[], done: number[], ) { for (const c of clips) { await api.enqueueEdit({ source_job_id: jobId, edit_spec: c.spec, display_name: c.dn }); done.push(c.id); } } // A GIF is cut per kept segment (the server makes one GIF of one range, never a joined cut-list), so // what the editor says before Create is said per segment: the worst first — one too long stops // Create — then one that may pass the server's cap, then one too big to move in a link preview. const GIF_NOTE_ORDER: GifVerdict[] = ["too_long", "may_fail", "no_preview"]; const GIF_NOTE_KEY = { too_long: "editor.format.segTooLong", may_fail: "editor.format.segMayFail", no_preview: "editor.format.segNoPreview", } as const; export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); const videoRef = useRef(null); const trackRef = useRef(null); const splitRef = 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: the crop frame's picture shape, and the crop's source pixels. // From the asset's metadata, so the frame sizes before the media decodes. const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 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. 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 [format, setFormat] = useState<"video" | "gif">("video"); const [side, setSide] = useState(GIF_DEFAULT_SIDE); // A GIF is made of the picture: an audio file has none (or only its cover), so it is offered for // a video alone, as the server holds it. const canGif = job.asset?.media_kind === "video"; const gif = canGif && format === "gif"; const gifNoteId = useId(); const [name, setName] = useState(""); const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]); const tc = (sec: number) => formatTimecode(sec, duration >= 3600); 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(); }, []); // The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's // box itself: max-h can pillarbox it). const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect; 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; // play() rejects when a pause or a source change interrupts it; the button state follows the // element's own events, so there is nothing to undo. if (v.paused) v.play().catch(() => {}); else v.pause(); }; // --- cut-list mutations --- const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]); // A cut removed by its button takes the button with it: once it is gone, the focus moves on to the // cut before it (or the one now first), else to Split here — not to , out of the dialog. const refocusAfterRemove = useRef(null); useEffect(() => { const bi = refocusAfterRemove.current; if (bi == null) return; refocusAfterRemove.current = null; const cut = trackRef.current?.querySelector( `[data-handle="cut-${Math.max(0, bi - 1)}"]`, ); (cut ?? splitRef.current)?.focus(); }, [boundaries.length]); 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 hover preview --- // The hover point, measured in the track's pointer handler (track-relative x and its time), so // render never reads the track's geometry (a DOM read in render made the React Compiler skip it). const [hover, setHover] = useState<{ x: number; time: number } | null>(null); // --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) --- 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 = hover?.time ?? 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 = hover != null ? Math.max(0, Math.min(Math.max(0, (trackW || HOVER_W) - HOVER_W), hover.x - 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; // A GIF of the whole video is no no-op: the format changes. const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle); const willJoin = !gif && output === "join" && kept.length > 1; const reencode = cropOn || accurate || gif; // The GIF's shape is the crop's when there is one (the long side is capped, so the shape decides // how many pixels it holds). const gifShape = cropOn ? (crop.w * va) / crop.h : va; const gifSegs = kept.map((s) => { const est = estimateGif(s.end - s.start, side, gifShape); return { n: segments.indexOf(s) + 1, length: gifShownLength(s.start, s.end), est, verdict: gifVerdict(s.start, s.end, est), }; }); const gifTotal = gifSegs.reduce((a, g) => ({ lo: a.lo + g.est.lo, hi: a.hi + g.est.hi }), { lo: 0, hi: 0, }); const gifTotalText = gifEstimateText(gifTotal, i18n.language); const gifNotes = GIF_NOTE_ORDER.flatMap((v) => gifSegs.filter((g) => g.verdict === v)); const gifBlocked = gif && gifSegs.some((g) => g.verdict === "too_long"); const joinReason = gif ? t("editor.output.joinGif") : kept.length < 2 ? t("editor.output.joinNeedsTwo") : undefined; const cropPx = (): EditSpec["crop"] => { 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. const px = cropToSourcePx(crop, vw, vh); // The size they count in goes along: it is the shown one, and the file's own can differ. return px ? { ...px, frame_w: vw, frame_h: 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: atMs(s.start), end_s: atMs(s.end) })), 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; // Every clip's request is built before the try, and queued by a helper: the React Compiler // cannot lower a conditional or a loop inside one yet, and skipped the whole editor for it. const clips = kept.map((s) => { const spec: EditSpec = { trim: { start_s: atMs(s.start), end_s: atMs(s.end) }, }; if (cp) spec.crop = cp; if (gif) spec.gif = { side }; else if (!cp) spec.accurate = accurate; const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined; return { id: s.id, spec, dn }; }); const done: number[] = []; try { await enqueueClips(job.id, clips, done); } 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) { invalidateDownloads(qc); 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. notify({ level: "error", title: msg(gif ? "editor.partlyCreatedGif" : "editor.partlyCreated", { done: done.length, total: N, }), message: failureReason(e), }); markReported(e); } throw e; } return { files: N }; }, onSuccess: ({ files }) => { // The whole library moves with it: an edit the user already made is done at once, and lists. invalidateDownloads(qc); notify({ level: "success", message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }), }); onClose(); }, onError: (e) => notifyActionError(e, msg(gif ? "editor.failedGif" : "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); const hours = duration >= 3600; const inField = useTimecodeField( sel?.start ?? 0, hours, (v) => selIdx > 0 && moveBoundary(selIdx - 1, v), ); const outField = useTimecodeField( sel?.end ?? 0, hours, (v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v), ); const timeErrorId = useId(); return (
{/* video + crop overlay */}
{/* transport */}
{playing ? : }
{tc(current)} / {tc(duration)}
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
{ setCurrent(time); seek(time); }} onHover={setHover} className="h-14 rounded-lg bg-surface border border-border cursor-pointer" background={ stripCells > 0 && (
{Array.from({ length: stripCells }).map((_, i) => (
))}
) } regions={segments.map((s) => ({ key: s.id, start: s.start, end: s.end, // segment tint: dropped = dimmed + hatched className: clsx( s.end < duration && "border-r border-black/40", s.keep ? "bg-accent/10" : "bg-black/60", s.id === selId && "ring-2 ring-inset ring-white/70", ), style: s.keep ? undefined : { backgroundImage: "repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)", }, title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"), onPointerDown: () => setSelId(s.id), children: ( // keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the // segment): a press on the eye is the eye's, never a drag of the cut { e.stopPropagation(); toggleKeep(s.id); }} className={clsx( "absolute top-1 left-1 z-menu p-0.5 rounded-sm bg-black/50 backdrop-blur-xs", s.keep ? "text-accent" : "text-muted", )} title={s.keep ? t("editor.drop") : t("editor.keep")} > {s.keep ? : } ), }))} // The cuts between segments: dragged, or stepped with the arrows once focused. handles={boundaries.map((b, bi) => ({ id: `cut-${bi}`, label: t("editor.cutN", { n: bi + 1 }), time: b, min: segments[bi]!.start + MIN_SEG, max: segments[bi + 1]!.end - MIN_SEG, valueText: tc(b), step: 0.1, bigStep: 1, onChange: (time: number) => moveBoundary(bi, time), }))} />
{/* Each cut's remove button, under the track at the cut: a real button a click or Enter works, off the cut's grab area, where a press drags it (a removal has no undo). */} {boundaries.length > 0 && (
{boundaries.map((b, bi) => (
{ // Refused while the clips are queued (the list holds still): then nothing is // removed, and no focus is left waiting to be taken by a later change. if (creating.current) return; refocusAfterRemove.current = bi; deleteBoundary(bi); }} // 24px, the target floor; white on its black disc in either theme. className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white" aria-label={t("editor.removeCut", { n: bi + 1 })} title={t("editor.removeCut", { n: bi + 1 })} >
))}
)} {/* hover-scrub thumbnail */} {strip && hoverTime != null && (
{tc(hoverTime)}
)}
{/* selected-segment editor */} {sel && (
{t("editor.segment", { n: selIdx + 1 })}
{tc(gif ? gifShownLength(sel.start, sel.end) : sel.end - sel.start)} {(inField.invalid || outField.invalid) && (
)}
)} {/* output mode + summary */}
{t("editor.output.label")}: {(["separate", "join"] as const).map((m) => { // Join is unavailable with its reason in its tooltip (a GIF is never joined; a join // needs two kept segments), shown on focus too, and stays focusable so the reason // reaches the keyboard. const reason = m === "join" ? joinReason : undefined; return ( ); })}
{t("editor.keptSummary", { n: kept.length, // One GIF: its length as the notes say it (a refused one never reads as allowed). dur: tc( gif && kept.length === 1 ? gifShownLength(kept[0]!.start, kept[0]!.end) : keptDur, ), })}
{/* format: a video, or each kept segment a GIF */} {canGif && (
{t("editor.format.label")}: {gif && ( <> setSide(Number(v) as GifSide)} options={GIF_SIDES.map((s) => ({ value: String(s), label: t("clip.gif.side", { n: formatNumber(s, i18n.language) }), }))} /> )} {/* The live regions stay mounted whatever the format (laid out as their content alone): a live region announces what changes inside it, not what arrives together with it, so a note already true when GIF is picked is said too. */} {gif && kept.length > 0 && ( {t( gifTotalText.single ? "editor.format.estimateOne" : "editor.format.estimate", gifTotalText, )} )}
)}
{gif && gifNotes.length > 0 && (
    {gifNotes.map((g) => (
  • {t(GIF_NOTE_KEY[g.verdict as keyof typeof GIF_NOTE_KEY], { n: g.n, length: tc(g.length), seconds: GIF_MAX_S, })}
  • ))}
)}
{/* crop + precision */}
{gif ? (
{t("editor.format.reencode")}
) : !cropOn ? (
{(["accurate", "fast"] as const).map((m) => { const on = (m === "accurate") === accurate; return ( ); })}
) : (
{t("editor.cropReencode")}
)}
setName(e.target.value)} placeholder={defaultName} />
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
); }