diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index 8a6f82d..91726d4 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -1,97 +1,144 @@ 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 { 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) — a compact timecode for the trim readout. +// mm:ss.d timecode <-> seconds. 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")}`; + 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 Frac = { x: number; y: number; w: number; h: number }; // crop as 0..1 fractions of the frame +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 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; -}) { +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); - const [start, setStart] = useState(0); - const [end, setEnd] = useState(srcDur); + // 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 [split, setSplit] = useState(1); 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(); + }, []); - // 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)); + setSegments((s) => (s.length === 1 && s[0].end <= 0 ? [{ id: 0, start: 0, end: d, keep: true }] : s)); }; - - // 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; - } + 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; - if (v.paused) { - if (v.currentTime < start || v.currentTime >= end) v.currentTime = start; - v.play(); - } else { - v.pause(); - } + v.paused ? v.play() : v.pause(); }; - // --- timeline drag (in/out handles + scrub) --- + // --- 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; @@ -101,25 +148,14 @@ export default function VideoEditor({ }, [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 { + if (drag.type === "seek") { setCurrent(tt); seek(tt); - } + } else moveBoundary(drag.bi, tt); }; const up = () => setDrag(null); window.addEventListener("pointermove", move); @@ -128,16 +164,11 @@ export default function VideoEditor({ window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; - }, [drag, timeAt, start, end]); + }, [drag, timeAt]); - // --- crop drag (move body + resize corner), all in 0..1 frame fractions --- + // --- crop drag --- const overlayRef = useRef(null); - const cropDrag = useRef(null); + const cropDrag = useRef(null); const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => { e.preventDefault(); e.stopPropagation(); @@ -152,19 +183,18 @@ export default function VideoEditor({ const r = el.getBoundingClientRect(); const dx = (e.clientX - d.px) / r.width; const dy = (e.clientY - d.py) / r.height; - if (d.mode === "move") { + 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 { + 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); @@ -175,57 +205,76 @@ export default function VideoEditor({ }; }, []); - // --- create --- + // --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) --- const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`; - const selDur = Math.max(0, end - start); - const reencode = cropOn || accurate; + 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; - // 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), - }; + 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) } }; + 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 || defaultName} (${i + 1}/${n})` : base || undefined; + 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 n; + return { files: N }; }, - onSuccess: (n) => { + onSuccess: ({ files }) => { qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: ["download-usage"] }); - notify({ level: "success", message: t("editor.created", { count: n }) }); + notify({ level: "success", message: t("editor.created", { count: files }) }); 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; + const sel = segments.find((s) => s.id === selId) ?? segments[0]; + const selIdx = segments.findIndex((s) => s.id === sel?.id); return ( @@ -239,14 +288,13 @@ export default function VideoEditor({ onTimeUpdate={onTimeUpdate} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} - className="w-full max-h-[46vh] mx-auto block" + className="w-full max-h-[42vh] mx-auto block" playsInline /> {cropOn && (
- {/* the box's 9999px boxShadow dims everything outside the crop rectangle */}
{playing ? : } -
- {tc(current)} / {tc(duration)} -
+
{tc(current)} / {tc(duration)}
- -
- {/* timeline: filmstrip + selection + handles + playhead */} -
onTrackDown(e, "seek")} - > - {strip && ( -
- {Array.from({ length: strip.count ?? 0 }).map((_, i) => { - const cols = strip.cols ?? 12; - const rows = strip.rows ?? 1; - const col = i % cols; - const row = Math.floor(i / cols); - return ( + {/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */} +
+
{ + setDrag({ type: "seek" }); + seek(timeAt(e.clientX)); + }} + onPointerMove={(e) => setHoverX(e.clientX)} + onPointerLeave={() => setHoverX(null)} + > + {/* aspect-correct filmstrip */} + {stripCells > 0 && ( +
+ {Array.from({ length: stripCells }).map((_, i) => (
1 ? (col / (cols - 1)) * 100 : 0}% ${rows > 1 ? (row / (rows - 1)) * 100 : 0}%`, - }} + style={tileStyle(Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)))} /> - ); - })} + ))} +
+ )} + {/* segment tint: dropped = dimmed + hatched */} + {segments.map((s, i) => ( +
setSelId(s.id)} + title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")} + > + {/* keep/drop toggle */} + +
+ ))} + {/* draggable boundary markers */} + {boundaries.map((b, bi) => ( +
+
{ + e.stopPropagation(); + setDrag({ type: "boundary", bi }); + }} + /> + +
+ ))} + {/* playhead */} +
+
+ + {/* hover-scrub thumbnail */} + {strip && hoverTime != null && ( +
+
+
{tc(hoverTime)}
)} - {/* dim outside selection */} -
-
- {/* selection border */} -
- {/* handles */} -
onTrackDown(e, "start")} - > -
-
-
onTrackDown(e, "end")} - > -
-
- {/* playhead */} -
-
- {t("editor.in")}: {tc(start)} - {t("editor.selected")}: {tc(selDur)} - {t("editor.out")}: {tc(end)} + {/* selected-segment editor */} + {sel && ( +
+ {t("editor.segment", { n: selIdx + 1 })} + +
+ + + {tc(sel.end - sel.start)} +
+ )} + + {/* output mode + summary */} +
+ {t("editor.output.label")}: + {(["separate", "join"] as const).map((m) => ( + + ))} +
+ + {t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })} +
- {/* options */} + {/* crop + precision */}
- - + {!cropOn ? ( +
+ {(["accurate", "fast"] as const).map((m) => { + const on = (m === "accurate") === accurate; + return ( + + ); + })} +
+ ) : ( +
{t("editor.cropReencode")}
+ )}
- {/* cut precision (only meaningful for a trim-only edit; a crop always re-encodes) */} - {!cropOn ? ( -
- {(["accurate", "fast"] as const).map((m) => { - const on = (m === "accurate") === accurate; - return ( - - ); - })} -
- ) : ( -

{t("editor.cropReencode")}

- )} -
- setName(e.target.value)} - placeholder={defaultName} - className={inputCls} - /> + setName(e.target.value)} placeholder={defaultName} className={inputCls} />
- - {reencode ? t("editor.willReencode") : t("editor.willCopy")} - + {reencode ? t("editor.willReencode") : t("editor.willCopy")}
diff --git a/frontend/src/i18n/locales/de/editor.json b/frontend/src/i18n/locales/de/editor.json index 5068943..e82903c 100644 --- a/frontend/src/i18n/locales/de/editor.json +++ b/frontend/src/i18n/locales/de/editor.json @@ -25,5 +25,21 @@ "createN": "{{count}} Clips erstellen", "created_one": "{{count}} Clip zu deinen Downloads hinzugefügt", "created_other": "{{count}} Clips zu deinen Downloads hinzugefügt", - "failed": "Clip konnte nicht erstellt werden." + "failed": "Clip konnte nicht erstellt werden.", + "splitHere": "Hier teilen", + "output": { + "label": "Ausgabe", + "separate": "Einzelne Dateien", + "join": "Zu einer zusammenfügen" + }, + "segment": "Segment {{n}}", + "keep": "Behalten", + "drop": "Verwerfen", + "kept": "Behalten", + "dropped": "Verworfen", + "keptSeg": "Behaltenes Segment", + "droppedSeg": "Verworfenes Segment", + "removeCut": "Schnitt entfernen", + "keptSummary": "{{n}} behalten · {{dur}}", + "createJoin": "Zusammengefügten Clip erstellen" } diff --git a/frontend/src/i18n/locales/en/editor.json b/frontend/src/i18n/locales/en/editor.json index 172f6b3..2429f0a 100644 --- a/frontend/src/i18n/locales/en/editor.json +++ b/frontend/src/i18n/locales/en/editor.json @@ -25,5 +25,21 @@ "createN": "Create {{count}} clips", "created_one": "{{count}} clip added to your downloads", "created_other": "{{count}} clips added to your downloads", - "failed": "Couldn’t create the clip." + "failed": "Couldn’t create the clip.", + "splitHere": "Split here", + "output": { + "label": "Output", + "separate": "Separate files", + "join": "Join into one" + }, + "segment": "Segment {{n}}", + "keep": "Keep", + "drop": "Drop", + "kept": "Kept", + "dropped": "Dropped", + "keptSeg": "Kept segment", + "droppedSeg": "Dropped segment", + "removeCut": "Remove cut", + "keptSummary": "{{n}} kept · {{dur}}", + "createJoin": "Create joined clip" } diff --git a/frontend/src/i18n/locales/hu/editor.json b/frontend/src/i18n/locales/hu/editor.json index a844ea3..2030538 100644 --- a/frontend/src/i18n/locales/hu/editor.json +++ b/frontend/src/i18n/locales/hu/editor.json @@ -25,5 +25,21 @@ "createN": "{{count}} klip létrehozása", "created_one": "{{count}} klip a letöltésekhez adva", "created_other": "{{count}} klip a letöltésekhez adva", - "failed": "A klip létrehozása nem sikerült." + "failed": "A klip létrehozása nem sikerült.", + "splitHere": "Vágás itt", + "output": { + "label": "Kimenet", + "separate": "Külön fájlok", + "join": "Egy fájlba fűzve" + }, + "segment": "{{n}}. szegmens", + "keep": "Megtart", + "drop": "Eldob", + "kept": "Megtartva", + "dropped": "Eldobva", + "keptSeg": "Megtartott szegmens", + "droppedSeg": "Eldobott szegmens", + "removeCut": "Vágás törlése", + "keptSummary": "{{n}} megtartva · {{dur}}", + "createJoin": "Összefűzött klip" } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b0592db..1840e31 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -667,9 +667,11 @@ export type DownloadStatus = | "error" | "canceled"; -// Phase-2 editor recipe. `accurate` only matters for a trim-only cut (a crop always re-encodes). +// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into +// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes). export interface EditSpec { trim?: { start_s: number; end_s?: number }; + segments?: { start_s: number; end_s: number }[]; crop?: { x: number; y: number; w: number; h: number }; accurate?: boolean; }