diff --git a/src/components/ai-edition/cropDraft.test.ts b/src/components/ai-edition/cropDraft.test.ts
index 740b879ce2..8fc3154f50 100644
--- a/src/components/ai-edition/cropDraft.test.ts
+++ b/src/components/ai-edition/cropDraft.test.ts
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
-import { cropDraftFromRegion, PREVIEW_MAX_HEIGHT_PX, previewBoxStyle } from "./cropDraft";
+import {
+ type CropPct,
+ cropDraftFromRegion,
+ cropFieldRangePx,
+ cropPctToPx,
+ isUsableFrameSize,
+ MIN_CROP_PCT,
+ PREVIEW_MAX_HEIGHT_PX,
+ previewBoxStyle,
+ setCropFieldPx,
+} from "./cropDraft";
describe("crop draft helpers", () => {
it("gives the preview box the video's own aspect ratio", () => {
@@ -28,3 +38,85 @@ describe("crop draft helpers", () => {
expect(draft.x).not.toBe(33);
});
});
+
+describe("crop pixel fields", () => {
+ const frame = { width: 1920, height: 1080 };
+ const crop: CropPct = { x: 10, y: 20, w: 50, h: 40 };
+
+ it("converts the percent crop to whole source pixels", () => {
+ expect(cropPctToPx(crop, frame)).toEqual({ x: 192, y: 216, w: 960, h: 432 });
+ expect(cropPctToPx({ x: 0, y: 0, w: 100, h: 100 }, frame)).toEqual({
+ x: 0,
+ y: 0,
+ w: 1920,
+ h: 1080,
+ });
+ });
+
+ it("rounds fractional pixels to the nearest pixel", () => {
+ const px = cropPctToPx({ x: 33.333, y: 0, w: 33.333, h: 100 }, frame);
+ expect(px.x).toBe(640);
+ expect(px.w).toBe(640);
+ });
+
+ it("round-trips a typed pixel value through the stored percent", () => {
+ for (const [field, value] of [
+ ["x", 123],
+ ["y", 77],
+ ["w", 801],
+ ["h", 333],
+ ] as const) {
+ const next = setCropFieldPx(crop, field, value, frame);
+ expect(cropPctToPx(next, frame)[field]).toBe(value);
+ }
+ });
+
+ it("changes only the field that was typed into", () => {
+ const next = setCropFieldPx(crop, "w", 1000, frame);
+ expect(next).toEqual({ ...crop, w: (1000 / 1920) * 100 });
+ });
+
+ it("keeps the crop inside the frame", () => {
+ // x can move up to the point where the right edge meets the frame.
+ expect(setCropFieldPx(crop, "x", 5000, frame).x).toBe(50);
+ expect(setCropFieldPx(crop, "x", -40, frame).x).toBe(0);
+ expect(setCropFieldPx(crop, "y", 5000, frame).y).toBe(60);
+ // Width and height can grow only to the frame edge from where the crop starts.
+ expect(setCropFieldPx(crop, "w", 5000, frame).w).toBe(90);
+ expect(setCropFieldPx(crop, "h", 5000, frame).h).toBe(80);
+ });
+
+ it("does not let a side shrink below the minimum", () => {
+ expect(setCropFieldPx(crop, "w", 0, frame).w).toBe(MIN_CROP_PCT);
+ expect(setCropFieldPx(crop, "h", -5, frame).h).toBe(MIN_CROP_PCT);
+ });
+
+ it("ignores a value that is not a number", () => {
+ expect(setCropFieldPx(crop, "x", Number.NaN, frame)).toBe(crop);
+ expect(setCropFieldPx(crop, "w", Number.POSITIVE_INFINITY, frame)).toBe(crop);
+ });
+
+ it("rounds a typed decimal to a whole pixel", () => {
+ const next = setCropFieldPx(crop, "x", 100.4, frame);
+ expect(cropPctToPx(next, frame).x).toBe(100);
+ });
+
+ it("reports the range each field accepts, matching what the setter allows", () => {
+ expect(cropFieldRangePx(crop, "x", frame)).toEqual({ min: 0, max: 960 });
+ expect(cropFieldRangePx(crop, "y", frame)).toEqual({ min: 0, max: 648 });
+ expect(cropFieldRangePx(crop, "w", frame)).toEqual({ min: 77, max: 1728 });
+ expect(cropFieldRangePx(crop, "h", frame)).toEqual({ min: 43, max: 864 });
+ for (const field of ["x", "y", "w", "h"] as const) {
+ const { min, max } = cropFieldRangePx(crop, field, frame);
+ expect(cropPctToPx(setCropFieldPx(crop, field, max, frame), frame)[field]).toBe(max);
+ expect(cropPctToPx(setCropFieldPx(crop, field, min, frame), frame)[field]).toBe(min);
+ }
+ });
+
+ it("only trusts a frame size with real dimensions", () => {
+ expect(isUsableFrameSize(frame)).toBe(true);
+ expect(isUsableFrameSize(null)).toBe(false);
+ expect(isUsableFrameSize({ width: 0, height: 1080 })).toBe(false);
+ expect(isUsableFrameSize({ width: 1920, height: Number.NaN })).toBe(false);
+ });
+});
diff --git a/src/components/ai-edition/cropDraft.ts b/src/components/ai-edition/cropDraft.ts
index 337e0243e5..ac0bcb601b 100644
--- a/src/components/ai-edition/cropDraft.ts
+++ b/src/components/ai-edition/cropDraft.ts
@@ -60,3 +60,82 @@ export function previewBoxStyle(videoAspectRatio: number): {
overflow: "hidden",
};
}
+
+/** The crop rectangle in percent of the frame (0-100), the unit the dialog edits in. */
+export type CropPct = { x: number; y: number; w: number; h: number };
+
+/** Smallest crop side, in percent of the frame, that the handles and the typed fields allow. */
+export const MIN_CROP_PCT = 4;
+
+export type CropField = keyof CropPct;
+
+/** The source frame in pixels. */
+export interface FrameSize {
+ width: number;
+ height: number;
+}
+
+export const isUsableFrameSize = (frame: FrameSize | null): frame is FrameSize =>
+ !!frame &&
+ Number.isFinite(frame.width) &&
+ Number.isFinite(frame.height) &&
+ frame.width > 0 &&
+ frame.height > 0;
+
+/** The crop in whole source pixels, as shown in the X / Y / Width / Height fields. */
+export function cropPctToPx(crop: CropPct, frame: FrameSize): CropPct {
+ return {
+ x: Math.round((crop.x / 100) * frame.width),
+ y: Math.round((crop.y / 100) * frame.height),
+ w: Math.round((crop.w / 100) * frame.width),
+ h: Math.round((crop.h / 100) * frame.height),
+ };
+}
+
+/**
+ * `crop` with one field set to a typed pixel value. The value is rounded to a whole pixel
+ * and clamped so the crop stays inside the frame and no side drops under MIN_CROP_PCT; the
+ * other three fields keep their exact stored values. A value that is not a number leaves the
+ * crop as it was.
+ */
+export function setCropFieldPx(
+ crop: CropPct,
+ field: CropField,
+ px: number,
+ frame: FrameSize,
+): CropPct {
+ if (!Number.isFinite(px)) return crop;
+ const horizontal = field === "x" || field === "w";
+ const pct = (Math.round(px) / (horizontal ? frame.width : frame.height)) * 100;
+ switch (field) {
+ case "x":
+ return { ...crop, x: Math.min(Math.max(0, 100 - crop.w), Math.max(0, pct)) };
+ case "y":
+ return { ...crop, y: Math.min(Math.max(0, 100 - crop.h), Math.max(0, pct)) };
+ case "w":
+ return { ...crop, w: Math.min(100 - crop.x, Math.max(MIN_CROP_PCT, pct)) };
+ case "h":
+ return { ...crop, h: Math.min(100 - crop.y, Math.max(MIN_CROP_PCT, pct)) };
+ }
+}
+
+/** The pixel range a field accepts right now: what the stepper and the arrow keys stay within. */
+export function cropFieldRangePx(
+ crop: CropPct,
+ field: CropField,
+ frame: FrameSize,
+): { min: number; max: number } {
+ const horizontal = field === "x" || field === "w";
+ const size = horizontal ? frame.width : frame.height;
+ const toPx = (pct: number) => Math.round((pct / 100) * size);
+ switch (field) {
+ case "x":
+ return { min: 0, max: toPx(100 - crop.w) };
+ case "y":
+ return { min: 0, max: toPx(100 - crop.h) };
+ case "w":
+ return { min: toPx(MIN_CROP_PCT), max: toPx(100 - crop.x) };
+ case "h":
+ return { min: toPx(MIN_CROP_PCT), max: toPx(100 - crop.y) };
+ }
+}
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index 0810409f12..f112a1490f 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -351,7 +351,12 @@
"done": "تم",
"ratio": "النسبة",
"cropVideo": "اقتصاص الفيديو",
- "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع"
+ "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
+ "x": "X (بكسل)",
+ "y": "Y (بكسل)",
+ "width": "العرض (بكسل)",
+ "height": "الارتفاع (بكسل)",
+ "position": "موضع الاقتصاص وحجمه بالبكسل"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json
index 80eb9b2687..10301b0e46 100644
--- a/src/i18n/locales/cs/settings.json
+++ b/src/i18n/locales/cs/settings.json
@@ -351,7 +351,12 @@
"done": "Hotovo",
"ratio": "Poměr",
"cropVideo": "Oříznout video",
- "lockAspectRatio": "Zamknout poměr stran"
+ "lockAspectRatio": "Zamknout poměr stran",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Šířka (px)",
+ "height": "Výška (px)",
+ "position": "Poloha a velikost ořezu v pixelech"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json
index 715d3c9bb9..88462649bc 100644
--- a/src/i18n/locales/de/settings.json
+++ b/src/i18n/locales/de/settings.json
@@ -351,7 +351,12 @@
"done": "Fertig",
"ratio": "Verhältnis",
"cropVideo": "Video zuschneiden",
- "lockAspectRatio": "Seitenverhältnis sperren"
+ "lockAspectRatio": "Seitenverhältnis sperren",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Breite (px)",
+ "height": "Höhe (px)",
+ "position": "Position und Größe des Ausschnitts in Pixeln"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index be8b13a694..a896ad4c49 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -351,7 +351,12 @@
"done": "Done",
"ratio": "Ratio",
"cropVideo": "Crop Video",
- "lockAspectRatio": "Lock aspect ratio"
+ "lockAspectRatio": "Lock aspect ratio",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Width (px)",
+ "height": "Height (px)",
+ "position": "Crop position and size in pixels"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index f9efcfab37..c89cff6da3 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -351,7 +351,12 @@
"done": "Listo",
"ratio": "Proporción",
"cropVideo": "Recortar video",
- "lockAspectRatio": "Bloquear relación de aspecto"
+ "lockAspectRatio": "Bloquear relación de aspecto",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Ancho (px)",
+ "height": "Alto (px)",
+ "position": "Posición y tamaño del recorte en píxeles"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index cbd0536996..cbcfe38ab4 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -351,7 +351,12 @@
"done": "Terminer",
"ratio": "Ratio",
"cropVideo": "Recadrer la vidéo",
- "lockAspectRatio": "Verrouiller le ratio"
+ "lockAspectRatio": "Verrouiller le ratio",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Largeur (px)",
+ "height": "Hauteur (px)",
+ "position": "Position et taille du recadrage en pixels"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index fd37547e2f..fd1208664d 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -351,7 +351,12 @@
"done": "Fatto",
"ratio": "Proporzioni",
"cropVideo": "Ritaglia video",
- "lockAspectRatio": "Blocca proporzioni"
+ "lockAspectRatio": "Blocca proporzioni",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Larghezza (px)",
+ "height": "Altezza (px)",
+ "position": "Posizione e dimensione del ritaglio in pixel"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 7349992049..d88d457285 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -351,7 +351,12 @@
"done": "完了",
"ratio": "比率",
"cropVideo": "動画をクロップ",
- "lockAspectRatio": "アスペクト比を固定"
+ "lockAspectRatio": "アスペクト比を固定",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "幅 (px)",
+ "height": "高さ (px)",
+ "position": "クロップの位置とサイズ(ピクセル)"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index c464581da8..459400e68c 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -351,7 +351,12 @@
"done": "완료",
"ratio": "비율",
"cropVideo": "비디오 자르기",
- "lockAspectRatio": "화면 비율 고정"
+ "lockAspectRatio": "화면 비율 고정",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "너비 (px)",
+ "height": "높이 (px)",
+ "position": "자르기 위치와 크기(픽셀)"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 53a4c89de0..8a27aa0420 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -351,7 +351,12 @@
"done": "Concluir",
"ratio": "Proporção",
"cropVideo": "Cortar Vídeo",
- "lockAspectRatio": "Bloquear proporção"
+ "lockAspectRatio": "Bloquear proporção",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Largura (px)",
+ "height": "Altura (px)",
+ "position": "Posição e tamanho do corte em pixels"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index 08c1a76c9d..1dc9e1d143 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -351,7 +351,12 @@
"done": "Готово",
"ratio": "Соотношение сторон",
"cropVideo": "Обрезать видео",
- "lockAspectRatio": "Заблокировать соотношение сторон"
+ "lockAspectRatio": "Заблокировать соотношение сторон",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Ширина (px)",
+ "height": "Высота (px)",
+ "position": "Положение и размер обрезки в пикселях"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 2f3fd2c676..8ff7da7ab2 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -351,7 +351,12 @@
"done": "Tamam",
"ratio": "Oran",
"cropVideo": "Videoyu Kırp",
- "lockAspectRatio": "En boy oranını kilitle"
+ "lockAspectRatio": "En boy oranını kilitle",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Genişlik (px)",
+ "height": "Yükseklik (px)",
+ "position": "Kırpmanın konumu ve boyutu (piksel)"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index ece372040f..da820d9aef 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -351,7 +351,12 @@
"done": "Hoàn tất",
"ratio": "Tỷ lệ",
"cropVideo": "Cắt xén video",
- "lockAspectRatio": "Khóa tỷ lệ khung hình"
+ "lockAspectRatio": "Khóa tỷ lệ khung hình",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "Chiều rộng (px)",
+ "height": "Chiều cao (px)",
+ "position": "Vị trí và kích thước vùng cắt tính bằng pixel"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 3aa4b4b485..c18fda7318 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -351,7 +351,12 @@
"done": "完成",
"ratio": "比例",
"cropVideo": "裁剪视频",
- "lockAspectRatio": "锁定宽高比"
+ "lockAspectRatio": "锁定宽高比",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "宽度 (px)",
+ "height": "高度 (px)",
+ "position": "裁剪位置和大小(像素)"
},
"zoom": {
"position": {
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index f33f5439d9..94bb8a4cee 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -351,7 +351,12 @@
"done": "完成",
"ratio": "比例",
"cropVideo": "裁剪影片",
- "lockAspectRatio": "鎖定長寬比"
+ "lockAspectRatio": "鎖定長寬比",
+ "x": "X (px)",
+ "y": "Y (px)",
+ "width": "寬度 (px)",
+ "height": "高度 (px)",
+ "position": "裁切位置與大小(像素)"
},
"zoom": {
"position": {
From 349567c21e25f4d2be0c2a67718ee59144b9d716 Mon Sep 17 00:00:00 2001
From: 0xmyst3ry <130799986+0xmyst3ry@users.noreply.github.com>
Date: Tue, 27 Oct 2026 14:17:11 +0530
Subject: [PATCH 2/2] fix(editor): put crop pixel labels inline so the fields
fit above the footer
The stacked label-over-input layout pushed the X/Y/Width/Height row below the fold at common window heights. Inline labels make the row one line tall.
---
src/components/ai-edition/Modals.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx
index 413f6a2144..8001c60535 100644
--- a/src/components/ai-edition/Modals.tsx
+++ b/src/components/ai-edition/Modals.tsx
@@ -1274,7 +1274,7 @@ export function EditClipModal({
{(
[
@@ -1319,8 +1319,8 @@ function CropPxField({
const id = useId();
const [draft, setDraft] = useState
(null);
return (
-