E32 S4 — the burn-down from 29 skipped functions to 1: - try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into their own function (push, the setup wizard's next, the share page's unlock). - A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo does not see it: the value is computed before the try, or the try lives in a module-level helper (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times). - The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through one effect event, read once when the player is built; PlexPlayer's unmount relies on the leaving-the-item cleanup for the generation bump it duplicated. - What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias, the loadSession self-reference (declared early, set after commit); PlayerModal's session-position read during render (now where the player starts), navRef/modeRef writes, and a description helper that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default among same-filter views, so the seed needs no sticky write — test seen red on the old helper). - A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old contract test claimed the call was safe inside any `use…` function, which was false — replaced by a render test that is red under the old name. - PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer stack: removed. react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid (IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
735 lines
28 KiB
TypeScript
735 lines
28 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, X } from "lucide-react";
|
|
import clsx from "clsx";
|
|
import Modal, { ModalFooter } from "./ui/Modal";
|
|
import { notify } from "../lib/notifications";
|
|
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
|
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
|
import { Button } from "./ui/Button";
|
|
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
|
import { markReported } from "../lib/api/core";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { Input } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// 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 Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
|
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
|
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
|
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
|
const [box, setBox] = useState({ w: 0, h: 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 }]);
|
|
// 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 (deep-review E11 round 3).
|
|
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<Map<number, string> | null>(null);
|
|
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();
|
|
}, []);
|
|
|
|
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
|
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
|
useEffect(() => {
|
|
const v = videoRef.current;
|
|
if (!v || typeof ResizeObserver === "undefined") return;
|
|
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
|
|
ro.observe(v);
|
|
setBox({ w: v.clientWidth, h: v.clientHeight });
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
|
|
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
|
|
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
|
const picRect = contentRect(box.w, box.h, va);
|
|
|
|
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]);
|
|
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 drag (seek / boundary) + hover preview ---
|
|
const [drag, setDrag] = useState<Drag | null>(null);
|
|
// The hover point, measured in the 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 the editor, E32).
|
|
const [hover, setHover] = useState<{ x: number; time: 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, seek, moveBoundary]);
|
|
|
|
// --- 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 = 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;
|
|
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 || 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.
|
|
return cropToSourcePx(crop, vw, 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: +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;
|
|
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: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
|
|
};
|
|
if (cp) spec.crop = cp;
|
|
else 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) {
|
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
|
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
|
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 (deep-review E11 round 3).
|
|
notify({
|
|
level: "error",
|
|
title: t("editor.partlyCreated", { done: done.length, total: N }),
|
|
message: failureReason(e),
|
|
});
|
|
markReported(e);
|
|
}
|
|
throw e;
|
|
}
|
|
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: (e) => notifyActionError(e, t("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);
|
|
|
|
return (
|
|
<Modal
|
|
initialFocus="field"
|
|
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-[42dvh] mx-auto block object-contain"
|
|
playsInline
|
|
/>
|
|
{cropOn && picRect && (
|
|
<div
|
|
ref={overlayRef}
|
|
className="absolute"
|
|
style={{
|
|
left: picRect.left,
|
|
top: picRect.top,
|
|
width: picRect.width,
|
|
height: picRect.height,
|
|
}}
|
|
>
|
|
<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">
|
|
<IconButton variant="outline" size="md" onClick={playPause} label={t("editor.playPause")}>
|
|
{playing ? <Pause /> : <Play />}
|
|
</IconButton>
|
|
<div className="text-xs text-muted tabular-nums">
|
|
{tc(current)} / {tc(duration)}
|
|
</div>
|
|
<div className="flex-1" />
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={splitAtPlayhead}
|
|
icon={<SplitSquareHorizontal />}
|
|
>
|
|
{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) =>
|
|
setHover({
|
|
x: e.clientX - e.currentTarget.getBoundingClientRect().left,
|
|
time: timeAt(e.clientX),
|
|
})
|
|
}
|
|
onPointerLeave={() => setHover(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 */}
|
|
<Pressable
|
|
aria-pressed={s.keep}
|
|
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" />}
|
|
</Pressable>
|
|
</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 });
|
|
}}
|
|
/>
|
|
<Pressable
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation();
|
|
deleteBoundary(bi);
|
|
}}
|
|
className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
|
|
title={t("editor.removeCut")}
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</Pressable>
|
|
</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-3xs text-center text-muted py-0.5 tabular-nums">
|
|
{tc(hoverTime)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* selected-segment editor */}
|
|
{sel && (
|
|
<div className={`${SURFACE.card} flex flex-wrap items-center gap-2 text-sm px-3 py-2`}>
|
|
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
pressed={sel.keep}
|
|
onClick={() => toggleKeep(sel.id)}
|
|
icon={sel.keep ? <Eye /> : <EyeOff />}
|
|
>
|
|
{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
|
|
className="w-24 tabular-nums"
|
|
size="sm"
|
|
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);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center gap-1 text-muted">
|
|
{t("editor.out")}
|
|
<Input
|
|
className="w-24 tabular-nums"
|
|
size="sm"
|
|
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);
|
|
}}
|
|
/>
|
|
</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}
|
|
variant="outline"
|
|
size="sm"
|
|
pressed={output === m}
|
|
onClick={() => setOutput(m)}
|
|
disabled={m === "join" && kept.length < 2}
|
|
>
|
|
{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
|
|
variant="outline"
|
|
pressed={cropOn}
|
|
onClick={() => setCropOn((v) => !v)}
|
|
icon={<Crop />}
|
|
className="justify-start"
|
|
>
|
|
{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}
|
|
variant="outline"
|
|
pressed={on}
|
|
onClick={() => setAccurate(m === "accurate")}
|
|
title={t(`editor.${m}.hint`)}
|
|
className="flex-1"
|
|
>
|
|
{t(`editor.${m}.label`)}
|
|
</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
|
|
className="w-full"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={defaultName}
|
|
/>
|
|
</div>
|
|
|
|
<ModalFooter className="flex items-center justify-between gap-2">
|
|
<span className="text-xs text-muted">
|
|
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => create.mutate()}
|
|
disabled={!valid}
|
|
loading={create.isPending}
|
|
icon={<Scissors className="w-4 h-4" />}
|
|
>
|
|
{willJoin
|
|
? t("editor.createJoin")
|
|
: kept.length > 1
|
|
? t("editor.createN", { count: kept.length })
|
|
: t("editor.create")}
|
|
</Button>
|
|
</div>
|
|
</ModalFooter>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|