fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic

Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
This commit is contained in:
2026-10-23 13:45:40 +02:00
parent ad05932217
commit b443ac0c84
51 changed files with 561 additions and 290 deletions
@@ -1,275 +0,0 @@
// @vitest-environment jsdom
//
// A split export that fails part-way. Every clip is its own job,
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify,
}));
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../../lib/api";
import { markReported } from "../../lib/api/core";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
notify.mockReset();
});
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor — a split export that fails part-way", () => {
it("says how many clips made it, and a retry queues only the rest", async () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
let refuse: (e: unknown) => void = () => {};
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValueOnce({} as never)
.mockImplementationOnce(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
)
.mockResolvedValue({} as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
// The playhead is where the video element says it is.
const video = document.querySelector("video")!;
const playhead = (s: number) => {
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
fireEvent.timeUpdate(video);
};
playhead(50);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
// While the clips are queued the cut list holds still: a split now would be a range never queued.
playhead(75);
fireEvent.click(screen.getByText("editor.splitHere"));
// The job cap, as `req` delivers it: already shown in the error dialog, and marked reported.
const refused = new HttpError(422, "errors.generic", { code: "quota" });
markReported(refused);
await act(async () => refuse(refused));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
),
);
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
// The queued first clip is deselected: one clip left to create, and only it is sent again — as
// the second of the same two.
fireEvent.click(screen.getByText("editor.create"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 });
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
});
});
// A time typed into the selected segment's field belongs to that segment: selecting another one
// drops it, and never commits it to the new selection's cut.
describe("VideoEditor — a typed time and another segment", () => {
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
for (const at of [30, 60]) {
Object.defineProperty(video, "currentTime", {
value: at,
configurable: true,
writable: true,
});
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
}
const segs = () => screen.getAllByTitle("editor.keptSeg");
fireEvent.pointerDown(segs()[1]!);
const inField = screen.getByRole("textbox", { name: "editor.in" });
act(() => inField.focus());
fireEvent.change(inField, { target: { value: "0:40" } });
// Another segment is selected before the field loses the focus (a press on it).
fireEvent.pointerDown(segs()[2]!);
fireEvent.blur(inField);
const cuts = screen
.getAllByRole("slider", { name: "editor.cutN" })
.map((c) => c.getAttribute("aria-valuenow"));
expect(cuts).toEqual(["30", "60"]);
});
});
// An unreadable time is said on a line under the segment's row: outside the field's label, so the
// field's name stays "Out" and the line is read once, as its description.
describe("VideoEditor — an unreadable time", () => {
it("is said under the row, outside the field's label", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!);
const out = screen.getByRole("textbox", { name: "editor.out" });
fireEvent.change(out, { target: { value: "abc" } });
fireEvent.keyDown(out, { key: "Enter" });
const line = screen.getByRole("alert");
expect(line.textContent).toBe("common.timecodeInvalid");
expect(line.closest("label")).toBeNull();
expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out);
expect(out.getAttribute("aria-describedby")).toBe(line.id);
});
});
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
// tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => {
it("steps a focused cut with the arrows and stops it before its neighbours", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(cut.getAttribute("aria-valuenow")).toBe("50");
fireEvent.keyDown(cut, { key: "ArrowRight", shiftKey: true });
expect(cut.getAttribute("aria-valuenow")).toBe("51");
fireEvent.keyDown(cut, { key: "End" });
expect(cut.getAttribute("aria-valuenow")).toBe("99.9");
});
});
// A cut's remove button and the keep toggle of the segment after it: the button is a button a click
// or a key works (it sat inside the cut's slider, pointer-only, its arrows moving the cut), and it
// sits at the cut's foot, clear of the toggle at the segment's head; the toggle sits over the cut's
// hit area that reaches into its segment, so a press on it is the toggle's.
describe("VideoEditor — a cut's remove button", () => {
const split = () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
};
it("removes the cut on a click (Enter on a focused button is one), and is not part of the slider", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
});
it("hands the focus on to a cut beside it, or to Split here, never to <body>", async () => {
split();
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1);
act(() => second!.focus());
fireEvent.click(second!);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })),
);
const last = screen.getByRole("button", { name: "editor.removeCut" });
act(() => last.focus());
fireEvent.click(last);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")),
);
});
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
let refuse: (e: unknown) => void = () => {};
vi.spyOn(api, "enqueueEdit").mockImplementation(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
);
split();
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
// The cut list holds still while the clips are queued: the remove is refused.
const remove = screen.getByRole("button", { name: "editor.removeCut" });
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
await act(async () => refuse(new Error("refused")));
// The create failed and the editor stays; a later split keeps the focus where it was.
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
act(() => splitButton.focus());
fireEvent.click(splitButton);
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
expect(document.activeElement).toBe(splitButton);
});
it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
const track = screen.getByRole("group", { name: "editor.timeline" });
// A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track.
expect(track.contains(remove)).toBe(false);
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(remove.closest<HTMLElement>("[style]")?.style.left).toBe(cut.style.left);
const toggles = within(track).getAllByRole("button", { pressed: true });
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
});
});
@@ -1,638 +0,0 @@
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 { 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 { Button } from "../../components/ui/Button";
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";
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);
}
}
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 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 [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;
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) {
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: t("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: 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);
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(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) => (
<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>
);
}