From fba861a5abc400979f5696235b12abd2a5307dbc Mon Sep 17 00:00:00 2001 From: 0xmyst3ry <130799986+0xmyst3ry@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:40:59 +0530 Subject: [PATCH 1/2] feat(editor): add pixel X / Y / Width / Height fields to the crop The Edit clip dialog could only crop by dragging handles. Add four number fields (X, Y, Width, Height) in source pixels under the ratio row. They show the crop as whole pixels of the source frame, follow the handles and the arrow keys, and write back to the same crop draft when typed into, rounded to a pixel and clamped to the frame. The clamp and the percent/pixel conversion are pure helpers in cropDraft.ts with unit tests. A typed width or height re-detects the ratio preset, so a preset no longer claims a shape the crop does not have. Labels are added to all 15 locales. --- .../ai-edition/EditClipModal.test.tsx | 95 +++++++++++++++ src/components/ai-edition/Modals.tsx | 108 +++++++++++++++++- src/components/ai-edition/cropDraft.test.ts | 94 ++++++++++++++- src/components/ai-edition/cropDraft.ts | 79 +++++++++++++ src/i18n/locales/ar/settings.json | 7 +- src/i18n/locales/cs/settings.json | 7 +- src/i18n/locales/de/settings.json | 7 +- src/i18n/locales/en/settings.json | 7 +- src/i18n/locales/es/settings.json | 7 +- src/i18n/locales/fr/settings.json | 7 +- src/i18n/locales/it/settings.json | 7 +- src/i18n/locales/ja-JP/settings.json | 7 +- src/i18n/locales/ko-KR/settings.json | 7 +- src/i18n/locales/pt-BR/settings.json | 7 +- src/i18n/locales/ru/settings.json | 7 +- src/i18n/locales/tr/settings.json | 7 +- src/i18n/locales/vi/settings.json | 7 +- src/i18n/locales/zh-CN/settings.json | 7 +- src/i18n/locales/zh-TW/settings.json | 7 +- 19 files changed, 462 insertions(+), 19 deletions(-) diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index 9d5c5ad075..e8d93e5486 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -357,3 +357,98 @@ describe("EditClipModal playhead for framing the crop", () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +describe("EditClipModal crop in pixels", () => { + beforeAll(() => { + // jsdom has no media pipeline: report a loaded 1920x1080 source. + Object.defineProperty(HTMLMediaElement.prototype, "readyState", { + configurable: true, + get: () => 1, + }); + Object.defineProperty(HTMLMediaElement.prototype, "currentTime", { + configurable: true, + get: () => 0, + set: vi.fn(), + }); + HTMLMediaElement.prototype.pause = vi.fn(); + Object.defineProperty(HTMLVideoElement.prototype, "videoWidth", { + configurable: true, + get: () => 1920, + }); + Object.defineProperty(HTMLVideoElement.prototype, "videoHeight", { + configurable: true, + get: () => 1080, + }); + }); + + function renderWithVideo(videoSources: Array<{ id: string; src: string; label: string }>) { + const onApply = vi.fn(); + renderWithI18n( + , + ); + return { onApply }; + } + const withVideo = [{ id: "asset_1", src: "file:///rec.mp4", label: "rec" }]; + const field = (name: string) => screen.getByLabelText(name) as HTMLInputElement; + + it("shows the whole frame in pixels, labelled for each side", () => { + renderWithVideo(withVideo); + expect(field("X (px)").value).toBe("0"); + expect(field("Y (px)").value).toBe("0"); + expect(field("Width (px)").value).toBe("1920"); + expect(field("Height (px)").value).toBe("1080"); + }); + + it("stays off until the source size is known", () => { + renderWithVideo([]); + expect(field("Width (px)")).toBeDisabled(); + expect(field("X (px)").value).toBe(""); + }); + + it("applies typed pixels as a crop, clamped to the frame", () => { + const { onApply } = renderWithVideo(withVideo); + fireEvent.change(field("Width (px)"), { target: { value: "960" } }); + fireEvent.change(field("X (px)"), { target: { value: "5000" } }); + expect(field("X (px)").value).toBe("5000"); // what is being typed stays until blur + fireEvent.blur(field("X (px)")); + expect(field("X (px)").value).toBe("960"); + + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(onApply).toHaveBeenCalledWith( + 20, + 105, + expect.objectContaining({ x: 0.5, y: 0, width: 0.5, height: 1 }), + ); + }); + + it("lets a field be cleared and retyped", () => { + renderWithVideo(withVideo); + fireEvent.change(field("Height (px)"), { target: { value: "" } }); + expect(field("Height (px)").value).toBe(""); + fireEvent.change(field("Height (px)"), { target: { value: "540" } }); + expect(field("Height (px)").value).toBe("540"); + }); + + it("follows the crop when it is moved with the keyboard", () => { + renderWithVideo(withVideo); + fireEvent.change(field("Width (px)"), { target: { value: "960" } }); + fireEvent.keyDown(screen.getByRole("slider", { name: "Crop" }), { key: "ArrowRight" }); + expect(field("X (px)").value).toBe("19"); // 1% of 1920, rounded + expect(field("Width (px)").value).toBe("960"); + }); + + it("drops a ratio preset when a typed size no longer has that shape", () => { + renderWithVideo(withVideo); + fireEvent.click(screen.getByRole("button", { name: "1:1" })); + expect(screen.getByRole("button", { name: "1:1" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.change(field("Width (px)"), { target: { value: "1000" } }); + expect(screen.getByRole("button", { name: "1:1" })).toHaveAttribute("aria-pressed", "false"); + }); +}); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index abe259c12f..413f6a2144 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -4,6 +4,7 @@ import { type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, + useId, useRef, useState, } from "react"; @@ -11,7 +12,19 @@ import type { CropRegion } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutClip } from "@/lib/ai-edition/schema"; import { formatSeconds } from "@/lib/ai-edition/timeline/format"; -import { cropDraftFromRegion, cropDraftToPct, previewBoxStyle } from "./cropDraft"; +import { + type CropField, + type CropPct, + cropDraftFromRegion, + cropDraftToPct, + cropFieldRangePx, + cropPctToPx, + type FrameSize, + isUsableFrameSize, + MIN_CROP_PCT, + previewBoxStyle, + setCropFieldPx, +} from "./cropDraft"; import styles from "./NewEditorShell.module.css"; import { ChoiceRow } from "./RightPanes"; import type { VideoSource } from "./VirtualPreview"; @@ -614,11 +627,10 @@ function centeredFitPct(fr: number): { x: number; y: number; w: number; h: numbe return { x: (100 - w) / 2, y: 0, w, h: 100 }; } -const MIN_PCT = 4; +const MIN_PCT = MIN_CROP_PCT; const clampPct = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type ResizeEdges = { left?: boolean; right?: boolean; top?: boolean; bottom?: boolean }; -type CropPct = { x: number; y: number; w: number; h: number }; /** `start` with its `edges` moved by (dxPct, dyPct), kept inside the frame. With a locked * fraction-space ratio `fr`, the opposite dimension follows to keep width/height locked. */ @@ -731,6 +743,8 @@ export function EditClipModal({ // fraction-of-frame width/height. 16/9 is just a placeholder until the // crop