Files
siftlode/frontend/src/components/VideoEditor.tsx
T
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.

- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
  JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
  classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
  ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
  autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
  runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
  site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
  "rounded-sm" inside a releaseNotes.ts prose sentence.

Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
2026-08-08 00:50:05 +02:00

655 lines
25 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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,
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";
import { inputCls, btnCls } from "./ui/form";
// 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 numCls =
"w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-hidden 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<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);
// 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 [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: 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();
}, []);
const onLoaded = () => {
const v = videoRef.current;
if (!v) return;
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
setDuration(d);
// 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 = (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]!; // 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 = (bi: number, time: number) =>
setSegments((segs) => {
if (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 (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;
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<HTMLDivElement>(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();
(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;
// 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 =
hoverX != null
? Math.max(
0,
Math.min(
Math.max(0, (trackW || HOVER_W) - HOVER_W),
hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - 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;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1;
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;
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]!; // i < N === kept.length, so kept[i] exists
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: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
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 (
<Modal title={t("editor.title", { name: defaultName })} onClose={onClose} maxWidth="max-w-3xl">
<div className="space-y-3">
{/* video + crop overlay */}
<div className="relative bg-black rounded-lg overflow-hidden select-none">
<video
ref={videoRef}
src={fileUrl}
onLoadedMetadata={onLoaded}
onTimeUpdate={onTimeUpdate}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="w-full max-h-[42vh] mx-auto block"
playsInline
/>
{cropOn && (
<div ref={overlayRef} className="absolute inset-0">
<div
className="absolute border-2 border-accent cursor-move"
style={{
left: `${crop.x * 100}%`,
top: `${crop.y * 100}%`,
width: `${crop.w * 100}%`,
height: `${crop.h * 100}%`,
boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)",
}}
onPointerDown={(e) => onCropDown(e, "move")}
>
<div
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-xs bg-accent cursor-nwse-resize"
onPointerDown={(e) => onCropDown(e, "resize")}
/>
</div>
</div>
)}
</div>
{/* transport */}
<div className="flex items-center gap-3">
<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="flex-1" />
<button
onClick={splitAtPlayhead}
className={clsx(btnCls, "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 + segments (keep/drop tint) + markers + playhead + hover preview */}
<div className="relative">
<div
ref={trackRef}
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)}
>
{/* 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={tileStyle(
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
)}
/>
))}
</div>
)}
{/* segment tint: dropped = dimmed + hatched */}
{segments.map((s) => (
<div
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-sm bg-black/50 backdrop-blur-xs",
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-base"
style={{ left: pct(b) }}
>
<div
className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm"
onPointerDown={(e) => {
e.stopPropagation();
setDrag({ type: "boundary", bi });
}}
/>
<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")}
>
<X className="w-3 h-3" />
</button>
</div>
))}
{/* playhead */}
<div
className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu"
style={{ left: pct(current) }}
/>
</div>
{/* hover-scrub thumbnail */}
{strip && hoverTime != null && (
<div
className="absolute bottom-full mb-2 pointer-events-none z-chrome rounded-md overflow-hidden border border-border shadow-xl bg-black"
style={{ left: hoverLeft, width: HOVER_W }}
>
<div
style={{
width: HOVER_W,
height: HOVER_W / 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>
{/* selected-segment editor */}
{sel && (
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg glass-card 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)}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition",
cropOn
? "border-accent text-accent bg-accent/10"
: "border-border text-muted hover:text-fg",
)}
>
<Crop className="w-4 h-4" /> {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</button>
{!cropOn ? (
<div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate;
return (
<button
key={m}
onClick={() => setAccurate(m === "accurate")}
className={clsx(
"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("text-sm font-medium", on ? "text-accent" : "text-fg")}>
{t(`editor.${m}.label`)}
</div>
</button>
);
})}
</div>
) : (
<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}
/>
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted">
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
</span>
<div className="flex gap-2">
<button
onClick={onClose}
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
>
{t("common.cancel")}
</button>
<button
onClick={() => create.mutate()}
disabled={!valid || create.isPending}
className={clsx(
btnCls,
"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" />
) : (
<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>
</div>
</Modal>
);
}