+ {(
+ [
+ ["x", ts("crop.x")],
+ ["y", ts("crop.y")],
+ ["w", ts("crop.width")],
+ ["h", ts("crop.height")],
+ ] as const
+ ).map(([field, label]) => {
+ const crop = { x: cropXPct, y: cropYPct, w: cropWPct, h: cropHPct };
+ const usable = isUsableFrameSize(videoSize);
+ return (
+ handleCropFieldChange(field, px)}
+ />
+ );
+ })}
+
);
}
+/** One of the crop's pixel fields. It keeps what is being typed until it loses focus, so a
+ * field can be cleared and retyped; every valid number is applied to the crop as it is typed,
+ * and the field shows the clamped value again on blur. */
+function CropPxField({
+ label,
+ value,
+ range,
+ onChange,
+}: {
+ label: string;
+ value: number | null;
+ range: { min: number; max: number } | null;
+ onChange: (px: number) => void;
+}) {
+ const id = useId();
+ const [draft, setDraft] = useState(null);
+ return (
+
+
+ {
+ setDraft(e.target.value);
+ if (e.target.value.trim() !== "") onChange(Number(e.target.value));
+ }}
+ onBlur={() => setDraft(null)}
+ style={{ flex: 1, minWidth: 0, fontVariantNumeric: "tabular-nums" }}
+ />
+
+ );
+}
+
function RangeStat({ label, value, testId }: { label: string; value: string; testId?: string }) {
return (
diff --git a/src/components/ai-edition/cropDraft.test.ts b/src/components/ai-edition/cropDraft.test.ts
index 740b879ce..8fc3154f5 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 337e0243e..ac0bcb601 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 0810409f1..f112a1490 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 80eb9b268..10301b0e4 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 715d3c9bb..88462649b 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 be8b13a69..a896ad4c4 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 f9efcfab3..c89cff6da 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 cbd053699..cbcfe38ab 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 fd37547e2..fd1208664 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 734999204..d88d45728 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 c464581da..459400e68 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 53a4c89de..8a27aa042 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 08c1a76c9..1dc9e1d14 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 2f3fd2c67..8ff7da7ab 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 ece372040..da820d9ae 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 3aa4b4b48..c18fda731 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 f33f5439d..94bb8a4ce 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": {