A refused GIF's length reads rounded up everywhere the editor shows it (the segment and the kept summary of one), and the GIF notes and estimates sit in live regions that are there before GIF is picked, so a note already true then is said. Tests hold the clip panel's readout and which segment a note names; DESIGN.md claims only what the contract test holds.
789 lines
32 KiB
TypeScript
789 lines
32 KiB
TypeScript
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
import { invalidateDownloads } from "../../lib/downloads";
|
|
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 "../../components/ui/Modal";
|
|
import { msg, notify } from "../../lib/notifications";
|
|
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
|
|
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
|
|
import { CropFrame } from "../../components/ui/CropFrame";
|
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
|
import { Button } from "../../components/ui/Button";
|
|
import Tooltip from "../../components/ui/Tooltip";
|
|
import { unavailable } from "../../components/ui/unavailable";
|
|
import { failureReason, notifyActionError } from "../../lib/actionErrors";
|
|
import { markReported } from "../../lib/api/core";
|
|
import { IconButton } from "../../components/ui/IconButton";
|
|
import { Pressable } from "../../components/ui/Pressable";
|
|
import { Input } from "../../components/ui/Input";
|
|
import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField";
|
|
import { useTimecodeField } from "../../components/ui/useTimecodeField";
|
|
import { SURFACE } from "../../components/ui/surfaces";
|
|
import { RangeTrack } from "../../components/ui/RangeTrack";
|
|
import { trackLeft } from "../../components/ui/trackPosition";
|
|
import { formatTimecode } from "../../lib/timecode";
|
|
import { formatNumber } from "../../lib/format";
|
|
import {
|
|
atMs,
|
|
estimateGif,
|
|
gifEstimateText,
|
|
gifShownLength,
|
|
gifVerdict,
|
|
GIF_DEFAULT_SIDE,
|
|
GIF_MAX_S,
|
|
GIF_SIDES,
|
|
type GifSide,
|
|
type GifVerdict,
|
|
} from "../../lib/gif";
|
|
|
|
type Seg = { id: number; start: number; end: number; keep: boolean };
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// A GIF is cut per kept segment (the server makes one GIF of one range, never a joined cut-list), so
|
|
// what the editor says before Create is said per segment: the worst first — one too long stops
|
|
// Create — then one that may pass the server's cap, then one too big to move in a link preview.
|
|
const GIF_NOTE_ORDER: GifVerdict[] = ["too_long", "may_fail", "no_preview"];
|
|
const GIF_NOTE_KEY = {
|
|
too_long: "editor.format.segTooLong",
|
|
may_fail: "editor.format.segMayFail",
|
|
no_preview: "editor.format.segNoPreview",
|
|
} as const;
|
|
|
|
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
|
const { t, i18n } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const splitRef = useRef<HTMLButtonElement>(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: the crop frame's picture shape, and the crop's source pixels.
|
|
// From the asset's metadata, so the frame sizes before the media decodes.
|
|
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 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.
|
|
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 [format, setFormat] = useState<"video" | "gif">("video");
|
|
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
|
|
// A GIF is made of the picture: an audio file has none (or only its cover), so it is offered for
|
|
// a video alone, as the server holds it.
|
|
const canGif = job.asset?.media_kind === "video";
|
|
const gif = canGif && format === "gif";
|
|
const gifNoteId = useId();
|
|
const [name, setName] = useState("");
|
|
|
|
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
|
|
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
|
|
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();
|
|
}, []);
|
|
|
|
// The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's
|
|
// box itself: max-h can pillarbox it).
|
|
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
|
|
|
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]);
|
|
// A cut removed by its button takes the button with it: once it is gone, the focus moves on to the
|
|
// cut before it (or the one now first), else to Split here — not to <body>, out of the dialog.
|
|
const refocusAfterRemove = useRef<number | null>(null);
|
|
useEffect(() => {
|
|
const bi = refocusAfterRemove.current;
|
|
if (bi == null) return;
|
|
refocusAfterRemove.current = null;
|
|
const cut = trackRef.current?.querySelector<HTMLElement>(
|
|
`[data-handle="cut-${Math.max(0, bi - 1)}"]`,
|
|
);
|
|
(cut ?? splitRef.current)?.focus();
|
|
}, [boundaries.length]);
|
|
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 hover preview ---
|
|
// The hover point, measured in the track's 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 it).
|
|
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
|
|
|
|
// --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
|
|
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;
|
|
// A GIF of the whole video is no no-op: the format changes.
|
|
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle);
|
|
const willJoin = !gif && output === "join" && kept.length > 1;
|
|
const reencode = cropOn || accurate || gif;
|
|
|
|
// The GIF's shape is the crop's when there is one (the long side is capped, so the shape decides
|
|
// how many pixels it holds).
|
|
const gifShape = cropOn ? (crop.w * va) / crop.h : va;
|
|
const gifSegs = kept.map((s) => {
|
|
const est = estimateGif(s.end - s.start, side, gifShape);
|
|
return {
|
|
n: segments.indexOf(s) + 1,
|
|
length: gifShownLength(s.start, s.end),
|
|
est,
|
|
verdict: gifVerdict(s.start, s.end, est),
|
|
};
|
|
});
|
|
const gifTotal = gifSegs.reduce((a, g) => ({ lo: a.lo + g.est.lo, hi: a.hi + g.est.hi }), {
|
|
lo: 0,
|
|
hi: 0,
|
|
});
|
|
const gifTotalText = gifEstimateText(gifTotal, i18n.language);
|
|
const gifNotes = GIF_NOTE_ORDER.flatMap((v) => gifSegs.filter((g) => g.verdict === v));
|
|
const gifBlocked = gif && gifSegs.some((g) => g.verdict === "too_long");
|
|
const joinReason = gif
|
|
? t("editor.output.joinGif")
|
|
: kept.length < 2
|
|
? t("editor.output.joinNeedsTwo")
|
|
: undefined;
|
|
|
|
const cropPx = (): EditSpec["crop"] => {
|
|
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.
|
|
const px = cropToSourcePx(crop, vw, vh);
|
|
// The size they count in goes along: it is the shown one, and the file's own can differ.
|
|
return px ? { ...px, frame_w: vw, frame_h: 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: atMs(s.start), end_s: atMs(s.end) })),
|
|
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: atMs(s.start), end_s: atMs(s.end) },
|
|
};
|
|
if (cp) spec.crop = cp;
|
|
if (gif) spec.gif = { side };
|
|
else if (!cp) 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) {
|
|
invalidateDownloads(qc);
|
|
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.
|
|
notify({
|
|
level: "error",
|
|
title: msg(gif ? "editor.partlyCreatedGif" : "editor.partlyCreated", {
|
|
done: done.length,
|
|
total: N,
|
|
}),
|
|
message: failureReason(e),
|
|
});
|
|
markReported(e);
|
|
}
|
|
throw e;
|
|
}
|
|
return { files: N };
|
|
},
|
|
onSuccess: ({ files }) => {
|
|
// The whole library moves with it: an edit the user already made is done at once, and lists.
|
|
invalidateDownloads(qc);
|
|
notify({
|
|
level: "success",
|
|
message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }),
|
|
});
|
|
onClose();
|
|
},
|
|
onError: (e) => notifyActionError(e, msg(gif ? "editor.failedGif" : "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);
|
|
const hours = duration >= 3600;
|
|
const inField = useTimecodeField(
|
|
sel?.start ?? 0,
|
|
hours,
|
|
(v) => selIdx > 0 && moveBoundary(selIdx - 1, v),
|
|
);
|
|
const outField = useTimecodeField(
|
|
sel?.end ?? 0,
|
|
hours,
|
|
(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v),
|
|
);
|
|
const timeErrorId = useId();
|
|
|
|
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 && <CropFrame aspect={va} rect={crop} ratio={null} onChange={setCrop} />}
|
|
</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"
|
|
ref={splitRef}
|
|
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}>
|
|
<RangeTrack
|
|
label={t("editor.timeline")}
|
|
duration={duration}
|
|
playhead={current}
|
|
onSeek={(time) => {
|
|
setCurrent(time);
|
|
seek(time);
|
|
}}
|
|
onHover={setHover}
|
|
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
|
|
background={
|
|
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>
|
|
)
|
|
}
|
|
regions={segments.map((s) => ({
|
|
key: s.id,
|
|
start: s.start,
|
|
end: s.end,
|
|
// segment tint: dropped = dimmed + hatched
|
|
className: clsx(
|
|
s.end < duration && "border-r border-black/40",
|
|
s.keep ? "bg-accent/10" : "bg-black/60",
|
|
s.id === selId && "ring-2 ring-inset ring-white/70",
|
|
),
|
|
style: s.keep
|
|
? undefined
|
|
: {
|
|
backgroundImage:
|
|
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
|
},
|
|
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
|
|
onPointerDown: () => setSelId(s.id),
|
|
children: (
|
|
// keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the
|
|
// segment): a press on the eye is the eye's, never a drag of the cut
|
|
<Pressable
|
|
aria-pressed={s.keep}
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation();
|
|
toggleKeep(s.id);
|
|
}}
|
|
className={clsx(
|
|
"absolute top-1 left-1 z-menu 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>
|
|
),
|
|
}))}
|
|
// The cuts between segments: dragged, or stepped with the arrows once focused.
|
|
handles={boundaries.map((b, bi) => ({
|
|
id: `cut-${bi}`,
|
|
label: t("editor.cutN", { n: bi + 1 }),
|
|
time: b,
|
|
min: segments[bi]!.start + MIN_SEG,
|
|
max: segments[bi + 1]!.end - MIN_SEG,
|
|
valueText: tc(b),
|
|
step: 0.1,
|
|
bigStep: 1,
|
|
onChange: (time: number) => moveBoundary(bi, time),
|
|
}))}
|
|
/>
|
|
</div>
|
|
{/* Each cut's remove button, under the track at the cut: a real button a click or Enter
|
|
works, off the cut's grab area, where a press drags it (a removal has no undo). */}
|
|
{boundaries.length > 0 && (
|
|
<div className="relative mt-1 h-6">
|
|
{boundaries.map((b, bi) => (
|
|
<div
|
|
key={`cut-${bi}`}
|
|
className="absolute top-0 -translate-x-1/2"
|
|
style={{ left: trackLeft(b, { from: 0, to: duration }) }}
|
|
>
|
|
<Pressable
|
|
onClick={() => {
|
|
// Refused while the clips are queued (the list holds still): then nothing is
|
|
// removed, and no focus is left waiting to be taken by a later change.
|
|
if (creating.current) return;
|
|
refocusAfterRemove.current = bi;
|
|
deleteBoundary(bi);
|
|
}}
|
|
// 24px, the target floor; white on its black disc in either theme.
|
|
className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white"
|
|
aria-label={t("editor.removeCut", { n: bi + 1 })}
|
|
title={t("editor.removeCut", { n: bi + 1 })}
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</Pressable>
|
|
</div>
|
|
))}
|
|
</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")}
|
|
<TimecodeField
|
|
className="w-24 tabular-nums"
|
|
size="sm"
|
|
field={inField}
|
|
errorId={timeErrorId}
|
|
disabled={selIdx === 0}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center gap-1 text-muted">
|
|
{t("editor.out")}
|
|
<TimecodeField
|
|
className="w-24 tabular-nums"
|
|
size="sm"
|
|
field={outField}
|
|
errorId={timeErrorId}
|
|
disabled={selIdx === segments.length - 1}
|
|
/>
|
|
</label>
|
|
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
|
{tc(gif ? gifShownLength(sel.start, sel.end) : sel.end - sel.start)}
|
|
</span>
|
|
{(inField.invalid || outField.invalid) && (
|
|
<div className="basis-full">
|
|
<TimecodeError id={timeErrorId} hours={hours} />
|
|
</div>
|
|
)}
|
|
</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) => {
|
|
// Join is unavailable with its reason in its tooltip (a GIF is never joined; a join
|
|
// needs two kept segments), shown on focus too, and stays focusable so the reason
|
|
// reaches the keyboard.
|
|
const reason = m === "join" ? joinReason : undefined;
|
|
return (
|
|
<Tooltip key={m} hint={reason ?? ""} always={!!reason} stable>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
pressed={(willJoin ? "join" : "separate") === m}
|
|
onClick={() => setOutput(m)}
|
|
{...unavailable(!!reason, reason)}
|
|
>
|
|
{t(`editor.output.${m}`)}
|
|
</Button>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
<div className="flex-1" />
|
|
<span className="text-xs text-muted">
|
|
{t("editor.keptSummary", {
|
|
n: kept.length,
|
|
// One GIF: its length as the notes say it (a refused one never reads as allowed).
|
|
dur: tc(
|
|
gif && kept.length === 1 ? gifShownLength(kept[0]!.start, kept[0]!.end) : keptDur,
|
|
),
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
{/* format: a video, or each kept segment a GIF */}
|
|
{canGif && (
|
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
<span className="text-muted">{t("editor.format.label")}:</span>
|
|
<SegmentedToggle
|
|
ariaLabel={t("editor.format.label")}
|
|
value={format}
|
|
onChange={setFormat}
|
|
options={[
|
|
{ value: "video", label: t("editor.format.video") },
|
|
{ value: "gif", label: t("editor.format.gif") },
|
|
]}
|
|
/>
|
|
{gif && (
|
|
<>
|
|
<span className="ml-2 text-muted" aria-hidden="true">
|
|
{t("clip.gif.sizeLabel")}
|
|
</span>
|
|
<SegmentedToggle
|
|
ariaLabel={t("clip.gif.size")}
|
|
value={String(side)}
|
|
onChange={(v) => setSide(Number(v) as GifSide)}
|
|
options={GIF_SIDES.map((s) => ({
|
|
value: String(s),
|
|
label: t("clip.gif.side", { n: formatNumber(s, i18n.language) }),
|
|
}))}
|
|
/>
|
|
</>
|
|
)}
|
|
{/* The live regions stay mounted whatever the format (laid out as their content
|
|
alone): a live region announces what changes inside it, not what arrives together
|
|
with it, so a note already true when GIF is picked is said too. */}
|
|
<span className="contents" aria-live="polite">
|
|
{gif && kept.length > 0 && (
|
|
<span className="text-xs text-muted tabular-nums">
|
|
{t(
|
|
gifTotalText.single ? "editor.format.estimateOne" : "editor.format.estimate",
|
|
gifTotalText,
|
|
)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div id={gifNoteId} className="contents" aria-live="polite">
|
|
{gif && gifNotes.length > 0 && (
|
|
<ul className="space-y-0.5 text-xs">
|
|
{gifNotes.map((g) => (
|
|
<li key={g.n} className={g.verdict === "too_long" ? "text-danger" : "text-warn"}>
|
|
{t(GIF_NOTE_KEY[g.verdict as keyof typeof GIF_NOTE_KEY], {
|
|
n: g.n,
|
|
length: tc(g.length),
|
|
seconds: GIF_MAX_S,
|
|
})}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</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>
|
|
{gif ? (
|
|
<div className="flex items-center px-3 text-xs text-muted">
|
|
{t("editor.format.reencode")}
|
|
</div>
|
|
) : !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}
|
|
// A GIF segment too long: unavailable with its reason (the notes above), focusable.
|
|
aria-disabled={gifBlocked || undefined}
|
|
aria-describedby={gifBlocked ? gifNoteId : undefined}
|
|
loading={create.isPending}
|
|
icon={<Scissors className="w-4 h-4" />}
|
|
>
|
|
{gif
|
|
? t("editor.createGif", { count: kept.length })
|
|
: willJoin
|
|
? t("editor.createJoin")
|
|
: kept.length > 1
|
|
? t("editor.createN", { count: kept.length })
|
|
: t("editor.create")}
|
|
</Button>
|
|
</div>
|
|
</ModalFooter>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|