Modal takes a required initialFocus (field / close / action), a field that loads late takes the focus while it still rests on the close button, and ModalFooter portals a dialog's commit row under the scrolling body from any depth. The header and footer fades follow the body's scroll timeline, and the viewport's interactive-widget=resizes-content keeps the footer above an on-screen keyboard.
717 lines
28 KiB
TypeScript
717 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;
|
|
|
|
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;
|
|
v.paused ? v.play() : 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);
|
|
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, 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 = 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 || 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;
|
|
const done: number[] = [];
|
|
try {
|
|
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 = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
|
|
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
|
done.push(s.id);
|
|
}
|
|
} 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) => 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 */}
|
|
<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>
|
|
);
|
|
}
|