fix(editor): a crop of a video with wide pixels keeps what its frame showed

The browser reports such a video by its shown size and ffmpeg's crop counts stored pixels, so a
frame over the middle half of a 320 stored, 640 shown video kept the whole width. A crop now carries
the size it was measured on and is scaled to the file's own pixels. A spec from before it did is cut
as it always was. Also drops a stale note in downloads/quota.py: edit jobs are held to the cap.
This commit is contained in:
2026-11-04 19:01:49 +01:00
parent f7e036abef
commit 2bdf1ec76f
6 changed files with 206 additions and 7 deletions
+3 -1
View File
@@ -70,7 +70,9 @@ export interface ClipRequest {
export interface EditSpec {
trim?: { start_s: number; end_s?: number };
segments?: { start_s: number; end_s: number }[];
crop?: { x: number; y: number; w: number; h: number };
// In the picture's size as the browser reports it, which `frame_w` x `frame_h` says: the server
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean;
}
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
//
// A crop's numbers count the picture as the browser reports it, its shown size, which differs from the
// file's own on a video with wide pixels. The size goes along, or the server cuts the wrong part.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } 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" } }),
}));
import VideoEditor from "./VideoEditor";
import { api, type DownloadJob } from "../../lib/api";
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();
});
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor crop", () => {
it("sends the size the crop was measured on with it", async () => {
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValue({ id: 8 } as unknown as DownloadJob);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "editor.cropOff" }));
fireEvent.click(screen.getByRole("button", { name: "editor.create" }));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1));
const crop = enqueue.mock.calls[0]?.[0].edit_spec.crop;
expect(crop).toMatchObject({ frame_w: 1920, frame_h: 1080, w: 1536, h: 864 });
});
});
+4 -2
View File
@@ -226,14 +226,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | 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.
return cropToSourcePx(crop, vw, vh) ?? undefined;
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({