feat(downloads): editor v2 — cut-list, join, aspect filmstrip + hover scrub
Rework VideoEditor around a segment cut-list: N draggable cut markers → segments, per-segment keep/drop (eye toggle + dropped hatch), output = Separate files (one job per kept segment) or Join into one (segments cut-list → one concatenated file). Per-segment numeric Start/End inputs. Filmstrip fixed: aspect-correct tiles (no vertical squish) + YouTube-style hover-scrub thumbnail from the sprite. EditSpec.segments type; editor v2 i18n (en/hu/de). Verified end-to-end in a real browser (3-segment split, drop middle, join → 0:12 clip from a 0:19 source).
This commit is contained in:
parent
b372d48ced
commit
2dccae0e54
5 changed files with 382 additions and 200 deletions
|
|
@ -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<HTMLVideoElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(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<Seg[]>([{ 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<Frac>({ 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<Drag | null>(null);
|
||||
const [hoverX, setHoverX] = useState<number | null>(null);
|
||||
const timeAt = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = trackRef.current;
|
||||
|
|
@ -101,25 +148,14 @@ export default function VideoEditor({
|
|||
},
|
||||
[duration]
|
||||
);
|
||||
|
||||
const [drag, setDrag] = useState<null | "start" | "end" | "seek">(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<HTMLDivElement>(null);
|
||||
const cropDrag = useRef<null | {
|
||||
mode: "move" | "resize";
|
||||
px: number;
|
||||
py: number;
|
||||
orig: Frac;
|
||||
}>(null);
|
||||
const cropDrag = useRef<null | { mode: "move" | "resize"; px: number; py: number; orig: Frac }>(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 (
|
||||
<Modal title={t("editor.title", { name: defaultName })} onClose={onClose} maxWidth="max-w-3xl">
|
||||
|
|
@ -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 && (
|
||||
<div ref={overlayRef} className="absolute inset-0">
|
||||
{/* the box's 9999px boxShadow dims everything outside the crop rectangle */}
|
||||
<div
|
||||
className="absolute border-2 border-accent cursor-move bg-transparent"
|
||||
className="absolute border-2 border-accent cursor-move"
|
||||
style={{
|
||||
left: `${crop.x * 100}%`,
|
||||
top: `${crop.y * 100}%`,
|
||||
|
|
@ -270,79 +318,181 @@ export default function VideoEditor({
|
|||
<button onClick={playPause} className="p-2 rounded-lg bg-surface hover:bg-card transition" title={t("editor.playPause")}>
|
||||
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="text-xs text-muted tabular-nums">
|
||||
{tc(current)} / {tc(duration)}
|
||||
</div>
|
||||
<div className="text-xs text-muted tabular-nums">{tc(current)} / {tc(duration)}</div>
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setStart(Math.min(current, end - 0.1))} className={clsx(btn, "bg-surface hover:bg-card")}>
|
||||
{t("editor.setIn")}
|
||||
</button>
|
||||
<button onClick={() => setEnd(Math.max(current, start + 0.1))} className={clsx(btn, "bg-surface hover:bg-card")}>
|
||||
{t("editor.setOut")}
|
||||
<button onClick={splitAtPlayhead} className={clsx(btn, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}>
|
||||
<SplitSquareHorizontal className="w-4 h-4" /> {t("editor.splitHere")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* timeline: filmstrip + selection + handles + playhead */}
|
||||
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-12 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
|
||||
onPointerDown={(e) => onTrackDown(e, "seek")}
|
||||
className="relative h-14 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
|
||||
onPointerDown={(e) => {
|
||||
setDrag({ type: "seek" });
|
||||
seek(timeAt(e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) => setHoverX(e.clientX)}
|
||||
onPointerLeave={() => setHoverX(null)}
|
||||
>
|
||||
{strip && (
|
||||
<div className="absolute inset-0 flex">
|
||||
{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 (
|
||||
{/* aspect-correct filmstrip */}
|
||||
{stripCells > 0 && (
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{Array.from({ length: stripCells }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-full flex-1"
|
||||
style={{
|
||||
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}%`,
|
||||
}}
|
||||
style={tileStyle(Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* dim outside selection */}
|
||||
<div className="absolute inset-y-0 left-0 bg-black/60" style={{ width: pct(start) }} />
|
||||
<div className="absolute inset-y-0 right-0 bg-black/60" style={{ left: pct(end) }} />
|
||||
{/* selection border */}
|
||||
{/* segment tint: dropped = dimmed + hatched */}
|
||||
{segments.map((s, i) => (
|
||||
<div
|
||||
className="absolute inset-y-0 border-x-2 border-accent pointer-events-none"
|
||||
style={{ left: pct(start), right: `calc(100% - ${pct(end)})` }}
|
||||
key={s.id}
|
||||
className={clsx(
|
||||
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
|
||||
s.keep ? "bg-accent/10" : "bg-black/60",
|
||||
s.id === selId && "ring-2 ring-inset ring-white/70"
|
||||
)}
|
||||
style={{
|
||||
left: pct(s.start),
|
||||
width: pct(s.end - s.start),
|
||||
...(s.keep
|
||||
? {}
|
||||
: {
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
||||
}),
|
||||
}}
|
||||
onPointerDown={() => setSelId(s.id)}
|
||||
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
|
||||
>
|
||||
{/* keep/drop toggle */}
|
||||
<button
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleKeep(s.id);
|
||||
}}
|
||||
className={clsx(
|
||||
"absolute top-1 left-1 p-0.5 rounded bg-black/50 backdrop-blur-sm",
|
||||
s.keep ? "text-accent" : "text-muted"
|
||||
)}
|
||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||
>
|
||||
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* draggable boundary markers */}
|
||||
{boundaries.map((b, bi) => (
|
||||
<div key={bi} className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-10" style={{ left: pct(b) }}>
|
||||
<div
|
||||
className="w-1 h-full bg-white/90 cursor-ew-resize rounded"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setDrag({ type: "boundary", bi });
|
||||
}}
|
||||
/>
|
||||
{/* handles */}
|
||||
<div
|
||||
className="absolute inset-y-0 -ml-1.5 w-3 cursor-ew-resize flex items-center justify-center"
|
||||
style={{ left: pct(start) }}
|
||||
onPointerDown={(e) => onTrackDown(e, "start")}
|
||||
<button
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBoundary(bi);
|
||||
}}
|
||||
className="absolute -top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400"
|
||||
title={t("editor.removeCut")}
|
||||
>
|
||||
<div className="w-1 h-6 rounded bg-accent" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-y-0 -ml-1.5 w-3 cursor-ew-resize flex items-center justify-center"
|
||||
style={{ left: pct(end) }}
|
||||
onPointerDown={(e) => onTrackDown(e, "end")}
|
||||
>
|
||||
<div className="w-1 h-6 rounded bg-accent" />
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* playhead */}
|
||||
<div className="absolute inset-y-0 w-0.5 bg-white/80 pointer-events-none" style={{ left: pct(current) }} />
|
||||
<div className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-20" style={{ left: pct(current) }} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>{t("editor.in")}: {tc(start)}</span>
|
||||
<span className="text-fg font-medium">{t("editor.selected")}: {tc(selDur)}</span>
|
||||
<span>{t("editor.out")}: {tc(end)}</span>
|
||||
{/* hover-scrub thumbnail */}
|
||||
{strip && hoverTime != null && (
|
||||
<div
|
||||
className="absolute bottom-full mb-2 -translate-x-1/2 pointer-events-none z-30 rounded-md overflow-hidden border border-border shadow-xl bg-black"
|
||||
style={{ left: hoverX! - (trackRef.current?.getBoundingClientRect().left ?? 0) }}
|
||||
>
|
||||
<div style={{ width: 148, height: 148 / aspect, ...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))) }} />
|
||||
<div className="text-[10px] text-center text-muted py-0.5 tabular-nums">{tc(hoverTime)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* options */}
|
||||
{/* selected-segment editor */}
|
||||
{sel && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg bg-card/40 px-3 py-2">
|
||||
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
|
||||
<button
|
||||
onClick={() => toggleKeep(sel.id)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium",
|
||||
sel.keep ? "bg-accent/15 text-accent" : "bg-surface text-muted"
|
||||
)}
|
||||
>
|
||||
{sel.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
{sel.keep ? t("editor.kept") : t("editor.dropped")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
{t("editor.in")}
|
||||
<input
|
||||
defaultValue={tc(sel.start)}
|
||||
key={`s-${sel.id}-${sel.start}`}
|
||||
disabled={selIdx === 0}
|
||||
onBlur={(e) => {
|
||||
const v = parseTc(e.target.value);
|
||||
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
|
||||
}}
|
||||
className={numCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
{t("editor.out")}
|
||||
<input
|
||||
defaultValue={tc(sel.end)}
|
||||
key={`e-${sel.id}-${sel.end}`}
|
||||
disabled={selIdx === segments.length - 1}
|
||||
onBlur={(e) => {
|
||||
const v = parseTc(e.target.value);
|
||||
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
|
||||
}}
|
||||
className={numCls}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-xs text-muted tabular-nums w-16 text-right">{tc(sel.end - sel.start)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* output mode + summary */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted">{t("editor.output.label")}:</span>
|
||||
{(["separate", "join"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setOutput(m)}
|
||||
disabled={m === "join" && kept.length < 2}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg border transition disabled:opacity-40",
|
||||
output === m ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
|
||||
)}
|
||||
>
|
||||
{t(`editor.output.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted">
|
||||
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* crop + precision */}
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setCropOn((v) => !v)}
|
||||
|
|
@ -353,24 +503,8 @@ export default function VideoEditor({
|
|||
>
|
||||
<Crop className="w-4 h-4" /> {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
||||
</button>
|
||||
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm">
|
||||
<Scissors className="w-4 h-4 text-muted shrink-0" />
|
||||
<span className="text-muted">{t("editor.split")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={split}
|
||||
onChange={(e) => setSplit(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||
className="w-16 rounded-md bg-surface border border-border px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* cut precision (only meaningful for a trim-only edit; a crop always re-encodes) */}
|
||||
{!cropOn ? (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<div className="flex gap-2">
|
||||
{(["accurate", "fast"] as const).map((m) => {
|
||||
const on = (m === "accurate") === accurate;
|
||||
return (
|
||||
|
|
@ -381,31 +515,25 @@ export default function VideoEditor({
|
|||
"flex-1 px-3 py-2 rounded-lg border text-left transition",
|
||||
on ? "border-accent bg-accent/10" : "border-border hover:border-muted"
|
||||
)}
|
||||
title={t(`editor.${m}.hint`)}
|
||||
>
|
||||
<div className={clsx("font-medium", on ? "text-accent" : "text-fg")}>{t(`editor.${m}.label`)}</div>
|
||||
<div className="text-xs text-muted mt-0.5">{t(`editor.${m}.hint`)}</div>
|
||||
<div className={clsx("text-sm font-medium", on ? "text-accent" : "text-fg")}>{t(`editor.${m}.label`)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted">{t("editor.cropReencode")}</p>
|
||||
<div className="flex items-center px-3 text-xs text-muted">{t("editor.cropReencode")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("editor.nameLabel")}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={defaultName}
|
||||
className={inputCls}
|
||||
/>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={defaultName} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-muted">
|
||||
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{reencode ? t("editor.willReencode") : t("editor.willCopy")}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}>
|
||||
{t("common.cancel")}
|
||||
|
|
@ -415,8 +543,12 @@ export default function VideoEditor({
|
|||
disabled={!valid || create.isPending}
|
||||
className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5")}
|
||||
>
|
||||
{create.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{split > 1 ? t("editor.createN", { count: Math.round(split) }) : t("editor.create")}
|
||||
{create.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Scissors className="w-4 h-4" />}
|
||||
{willJoin
|
||||
? t("editor.createJoin")
|
||||
: kept.length > 1
|
||||
? t("editor.createN", { count: kept.length })
|
||||
: t("editor.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue