import assert from "node:assert/strict"; import test from "node:test"; import { canvasLocalPoint, fitSourceToAspect, cropHandleCenters, nearestHandle, paddingHandleCenters, resetTransformValues, resizeCrop, resolveCrop, resolvePadding, rotatedSize, sourceChanged, stageHandleLayout, stageHeightForWidth, zoomAround, scaleToMegapixels, } from "../js/shared/transform_geometry.mjs"; test("rotated size handles positive and negative angles", () => { assert.deepEqual(rotatedSize(100, 50, 90), { width: 50, height: 100 }); assert.deepEqual(rotatedSize(100, 50, -90), { width: 50, height: 100 }); // Mixed odd/even sides hit Pillow's exact transpose fast path. assert.deepEqual(rotatedSize(2, 3, -90), { width: 3, height: 2 }); }); test("rotated size matches Pillow expand output exactly", () => { // Expected values generated with Pillow's Image.rotate(expand=True); see // the note on rotatedSize. The old width*cos+height*sin formula was 1px // short on most free angles (e.g. 512@45 gave 725, Pillow produces 726). assert.deepEqual(rotatedSize(512, 512, 45), { width: 726, height: 726 }); assert.deepEqual(rotatedSize(512, 512, -12.5), { width: 612, height: 612 }); assert.deepEqual(rotatedSize(512, 512, 179.9), { width: 514, height: 514 }); assert.deepEqual(rotatedSize(1920, 1080, 15), { width: 2136, height: 1542 }); assert.deepEqual(rotatedSize(1920, 1080, 60), { width: 1896, height: 2204 }); }); test("crop clamps and honors ratios", () => { assert.deepEqual(resolveCrop({ crop_x: 95, crop_y: 45, crop_width: 99, crop_height: 99, crop_aspect_ratio: "free" }, { width: 100, height: 50 }), { x: 95, y: 45, width: 5, height: 5 }); assert.deepEqual(resolveCrop({ crop_x: 0, crop_y: 0, crop_width: 0, crop_height: 0, crop_aspect_ratio: "16:9" }, { width: 100, height: 100 }), { x: 0, y: 0, width: 100, height: 56 }); }); test("canvas multiple adds only right and bottom", () => { assert.deepEqual(resolvePadding({ pad_left: 0, pad_top: 0, pad_right: 0, pad_bottom: 0, canvas_multiple: 8 }, { width: 101, height: 99 }), { left: 0, top: 0, right: 3, bottom: 5, outputWidth: 104, outputHeight: 104 }); }); test("pad to aspect preserves portrait and centers new landscape canvas", () => { const source = { width: 576, height: 1024 }; const patch = fitSourceToAspect(source, "16:9", "pad"); const crop = resolveCrop(patch, source); assert.deepEqual(crop, { x: 0, y: 0, ...source }); assert.equal(patch.crop_aspect_ratio, "free"); assert.deepEqual(resolvePadding(patch, crop), { left: 622, right: 623, top: 0, bottom: 0, outputWidth: 1821, outputHeight: 1024, }); }); test("pad to portrait and rotation preserve all source pixels", () => { const source = rotatedSize(576, 1024, 90); const patch = fitSourceToAspect(source, "9:16", "pad"); assert.equal(patch.pad_left, 0); assert.equal(patch.pad_right, 0); assert.equal(patch.pad_top, 622); assert.equal(patch.pad_bottom, 623); assert.deepEqual(resolveCrop(patch, source), { x: 0, y: 0, ...source }); }); test("crop to aspect centers the largest crop and clears old padding", () => { for (const [source, aspect, expected] of [ [{ width: 576, height: 1024 }, "16:9", { x: 0, y: 350, width: 576, height: 324 }], [{ width: 1024, height: 576 }, "9:16", { x: 350, y: 0, width: 324, height: 576 }], ]) { const patch = fitSourceToAspect(source, aspect); assert.deepEqual(resolveCrop(patch, source), expected); assert.equal(patch.pad_left + patch.pad_right + patch.pad_top + patch.pad_bottom, 0); } }); test("free, source and already-matching aspects do not add padding", () => { const source = { width: 1920, height: 1080 }; for (const aspect of ["free", "source", "16:9"]) { const patch = fitSourceToAspect(source, aspect, "pad"); assert.deepEqual(resolveCrop(patch, source), { x: 0, y: 0, ...source }); assert.equal(patch.pad_left + patch.pad_right + patch.pad_top + patch.pad_bottom, 0); } }); test("handle priority and closest distance are deterministic", () => { const selection = nearestHandle({ x: 20, y: 20 }, [ { kind: "padding", priority: 1, radius: 30, handles: [{ name: "pad_left", x: 20, y: 20 }] }, { kind: "crop", priority: 2, radius: 30, handles: [{ name: "nw", x: 20, y: 20 }] }, ]); assert.equal(selection.kind, "padding"); }); test("crop resize never leaves the source", () => { const resized = resizeCrop({ x: 10, y: 10, width: 40, height: 30 }, "se", 1000, 1000, { width: 100, height: 80 }); assert.deepEqual(resized, { x: 10, y: 10, width: 90, height: 70 }); }); test("coordinate conversion accounts for CSS scaling", () => { const canvas = { clientWidth: 400, clientHeight: 200, getBoundingClientRect: () => ({ left: 10, top: 20, width: 200, height: 100 }) }; assert.deepEqual(canvasLocalPoint(canvas, { clientX: 110, clientY: 70 }), { x: 200, y: 100 }); }); test("zoom remains anchored under the pointer", () => { assert.deepEqual(zoomAround({ zoom: 1, panX: 0, panY: 0 }, 2, { x: 100, y: 50 }), { zoom: 2, panX: -100, panY: -50 }); }); test("the stage floor tracks the stage width within its clamps", () => { assert.equal(stageHeightForWidth(100), 200); // floor assert.equal(stageHeightForWidth(307), 200); // a fresh clip node assert.equal(stageHeightForWidth(442), 265); // the LTX example's node assert.equal(stageHeightForWidth(500), 300); assert.equal(stageHeightForWidth(2000), 340); // ceiling assert.equal(stageHeightForWidth(undefined), 200); }); test("handle layout keeps the editor's classic geometry on large stages", () => { // A full-screen editor stage must render exactly as before the panel // re-home: classic offsets and the min(90, w/10, h/10) margin. const layout = stageHandleLayout(1246, 758); assert.equal(layout.padOffset, 38); assert.equal(layout.rotateArm, 34); assert.ok(Math.abs(layout.margin - 75.8) < 1e-9); }); test("handle layout pulls handles inward but keeps them visible on the panel", () => { const layout = stageHandleLayout(312, 214); assert.ok(layout.padOffset < 38 && layout.padOffset >= 16); assert.ok(layout.rotateArm < 34 && layout.rotateArm >= 14); // The fit margin always covers the outboard handles: the pad diamond's // half-diagonal (~11px) past its offset, the knob radius (13px) past the // rotate arm — otherwise the panel would clip its own controls. assert.ok(layout.margin >= layout.padOffset + 11); assert.ok(layout.margin >= layout.rotateArm + 13); }); test("handle layout stays finite on degenerate stage sizes", () => { const layout = stageHandleLayout(0, 0); assert.ok(Number.isFinite(layout.margin)); assert.ok(layout.padOffset >= 16 && layout.rotateArm >= 14); }); test("reset and source change restore identity including timeline", () => { const reset = resetTransformValues(true); assert.equal(reset.canvas_multiple, 1); assert.equal(reset.pad_bottom, 0); assert.equal(reset.feather, 24); // feather defaults on; no-op until padding/rotation exists assert.equal(reset.frame_index, 0); assert.equal(sourceChanged("a", "b", true), true); assert.equal(sourceChanged("a", "b", false), false); assert.equal(sourceChanged("a", "a", true), false); }); test("scaleToMegapixels mirrors the backend fixtures", () => { assert.deepEqual(scaleToMegapixels(1024, 1024, 1.0, 1), { width: 1024, height: 1024 }); assert.deepEqual(scaleToMegapixels(512, 512, 4.0, 1), { width: 2048, height: 2048 }); assert.deepEqual(scaleToMegapixels(1920, 1080, 1.0, 64), { width: 1344, height: 768 }); assert.deepEqual(scaleToMegapixels(1000, 707, 1.0, 8), { width: 1216, height: 864 }); assert.deepEqual(scaleToMegapixels(100, 100, 0.01, 64), { width: 128, height: 128 }); }); // --- Aspect lock ------------------------------------------------------------ import { lockPadding, lockedPadMinimum, paddingAxis } from "../js/shared/transform_geometry.mjs"; const RATIO_16_9 = 16 / 9; function canvasOf(pads, crop) { return { width: crop.width + pads.pad_left + pads.pad_right, height: crop.height + pads.pad_top + pads.pad_bottom, }; } function assertRatio(pads, crop, ratio) { const { width, height } = canvasOf(pads, crop); assert.ok(Math.abs(width / height - ratio) * height <= 1.01, `${width}x${height} is not ${ratio}`); for (const value of Object.values(pads)) assert.ok(value >= 0); } test("lock: a taller padding drag widens the side bands symmetrically", () => { // The 16:9 pad of a 9:16 source, then pad_top pulled up by 100. const crop = { width: 576, height: 1024 }; const pads = lockPadding({ pad_left: 622, pad_right: 623, pad_top: 100, pad_bottom: 0 }, crop, RATIO_16_9, "y"); assertRatio(pads, crop, RATIO_16_9); assert.equal(pads.pad_top, 100); assert.equal(pads.pad_bottom, 0); assert.ok(Math.abs(pads.pad_right - pads.pad_left) <= 2); // the 622/623 split stays centred assert.equal(canvasOf(pads, crop).height, 1124); }); test("lock: a side pad cannot shrink below what the crop's height needs", () => { const crop = { width: 576, height: 1024 }; const values = { pad_left: 622, pad_right: 623, pad_top: 0, pad_bottom: 0 }; // 16:9 around a 1024-tall crop is 1821 wide: left can go no lower than // 1821 - 576 - 623 = 622, i.e. not at all. assert.equal(lockedPadMinimum(values, crop, RATIO_16_9, "pad_left"), 622); // With bands above and below there is room to narrow. assert.equal(lockedPadMinimum({ ...values, pad_top: 200, pad_bottom: 200 }, crop, RATIO_16_9, "pad_left"), 622); assert.equal(lockedPadMinimum({ ...values, pad_top: 200 }, crop, RATIO_16_9, "pad_top"), 0); assert.equal(paddingAxis("pad_left"), "x"); assert.equal(paddingAxis("pad_bottom"), "y"); }); test("lock: cropping the height in narrows the bands", () => { const crop = { width: 576, height: 924 }; const pads = lockPadding({ pad_left: 622, pad_right: 623, pad_top: 0, pad_bottom: 0 }, crop, RATIO_16_9, "y"); assertRatio(pads, crop, RATIO_16_9); assert.equal(pads.pad_top + pads.pad_bottom, 0); assert.ok(pads.pad_left < 622 && pads.pad_right < 623); }); test("lock: cropping the width in gives the strip back as fill", () => { // No vertical bands to shrink, so the canvas keeps its width: the 100 // cropped pixels come back as padding split over left and right. const crop = { width: 476, height: 1024 }; const pads = lockPadding({ pad_left: 622, pad_right: 623, pad_top: 0, pad_bottom: 0 }, crop, RATIO_16_9, "x"); assertRatio(pads, crop, RATIO_16_9); const canvas = canvasOf(pads, crop); assert.equal(canvas.height, 1024); assert.ok(canvas.width === 1820 || canvas.width === 1821, `width ${canvas.width}`); assert.equal(pads.pad_top + pads.pad_bottom, 0); }); test("lock: the canvas a format chip padded is already a fixed point", () => { const source = { width: 576, height: 1024 }; const padded = fitSourceToAspect(source, "16:9", "pad"); for (const driver of ["x", "y"]) { assert.deepEqual(lockPadding(padded, source, RATIO_16_9, driver), { pad_left: 622, pad_top: 0, pad_right: 623, pad_bottom: 0, }); } }); test("lock: with nothing to shrink the canvas refits around the crop", () => { // A square lock on a crop with zero padding on both axes: the only way is // to grow the short axis around the crop. const crop = { width: 1000, height: 400 }; const pads = lockPadding({ pad_left: 0, pad_right: 0, pad_top: 0, pad_bottom: 0 }, crop, 1, "y"); assert.deepEqual(pads, { pad_left: 0, pad_right: 0, pad_top: 300, pad_bottom: 300 }); // A rotation that made the crop taller than the 16:9 canvas can hold: // side bands cannot go negative, so the bands regrow around the crop. const tall = lockPadding({ pad_left: 10, pad_right: 10, pad_top: 0, pad_bottom: 0 }, { width: 500, height: 1000 }, RATIO_16_9, "x"); assertRatio(tall, { width: 500, height: 1000 }, RATIO_16_9); assert.equal(tall.pad_top + tall.pad_bottom, 0); }); test("lock: an invalid ratio or crop is a no-op", () => { assert.equal(lockPadding({}, { width: 10, height: 10 }, null), null); assert.equal(lockPadding({}, { width: 0, height: 10 }, 1), null); assert.equal(lockedPadMinimum({}, { width: 10, height: 10 }, 0, "pad_left"), 0); }); test("lock: the result already satisfies a second pass", () => { const crop = { width: 640, height: 360 }; for (const ratio of [1, 4 / 3, RATIO_16_9, 9 / 16, 21 / 9]) { for (const driver of ["x", "y"]) { const first = lockPadding({ pad_left: 30, pad_right: 5, pad_top: 80, pad_bottom: 0 }, crop, ratio, driver); assertRatio(first, crop, ratio); assert.deepEqual(lockPadding(first, crop, ratio, driver), first); } } }); // --- Source changes --------------------------------------------------------- import { SOURCE_GEOMETRY_KEYS, declaredTransformDefaults, rememberedSource, sourceResetValues } from "../js/shared/transform_geometry.mjs"; test("a source change resets geometry but never the canvas style", () => { const reset = sourceResetValues(true); for (const name of ["fill_color", "feather", "canvas_multiple"]) assert.ok(!(name in reset), `${name} must survive a source swap`); for (const name of SOURCE_GEOMETRY_KEYS) assert.ok(name in reset); assert.equal(reset.pad_left, 0); assert.equal(reset.rotation_degrees, 0); assert.equal(reset.crop_aspect_ratio, "free"); assert.equal(reset.frame_index, 0); assert.ok(!("frame_index" in sourceResetValues(false))); }); test("a node saved blank takes a pick of the first file as a new picture", () => { // Created on the combo's default, the first file in the input folder, // then loaded from a workflow that saved the loader blank. let remembered = rememberedSource("a_first.png", ""); assert.equal(sourceChanged(remembered, "a_first.png"), true); assert.equal(sourceChanged(remembered, "b_other.png"), true); // Saved with a file: that file is no change, any other is new. remembered = rememberedSource("a_first.png", "b_saved.png"); assert.equal(sourceChanged(remembered, "b_saved.png"), false); assert.equal(sourceChanged(remembered, "a_first.png"), true); // A blank the user makes (Local path, nothing typed yet) keeps the last // source, so going back to it is no change; a real pick is remembered. remembered = rememberedSource("input:clip.mp4", "", true); assert.equal(sourceChanged(remembered, "input:clip.mp4"), false); assert.equal(rememberedSource(remembered, "local:clip.mp4", true), "local:clip.mp4"); }); test("reset returns to the node's declared defaults over the shared identity", () => { // The clip node declares a black fill and feather 0 (video outpaint); // the image nodes keep the shared grey / 24. const clipDef = { input: { required: { feather: ["INT", { default: 0, hidden: true }], fill_color: ["STRING", { default: "#000000" }] } } }; const reset = declaredTransformDefaults(clipDef, true); assert.equal(reset.feather, 0); assert.equal(reset.fill_color, "#000000"); assert.equal(reset.pad_left, 0); assert.equal(reset.frame_index, 0); assert.deepEqual(declaredTransformDefaults(undefined, false), resetTransformValues(false)); assert.deepEqual(declaredTransformDefaults({ input: { required: {} } }, false), resetTransformValues(false)); // A declared default only overrides keys the transform owns. const stray = declaredTransformDefaults({ input: { optional: { feather: ["INT", { default: 8 }], every_nth: ["INT", { default: 2 }] } } }); assert.equal(stray.feather, 8); assert.ok(!("every_nth" in stray)); }); // --- Integer aspect maths --------------------------------------------------- import { ratioBox, cropForRotation, sizeChain, sizeChainTokens } from "../js/shared/transform_geometry.mjs"; // The same table sits in tests/test_transform_engine.py (_ratio_box): the // readout on the node must name the size the run produces. const RATIO_BOX_FIXTURES = [ [[3813, 1961, 21, 9], [3813, 1634]], [[1000, 562, 16, 9], [1000, 562]], [[1000, 1000, 16, 9], [1000, 562]], [[1000, 500, 16, 9], [888, 500]], [[888, 500, 16, 9], [888, 500]], [[1920, 1080, 16, 9], [1920, 1080]], [[832, 1216, 1, 1], [832, 832]], [[239, 1284, 936, 1284], [239, 327]], ]; test("ratio box matches the backend fixtures", () => { for (const [[width, height, rw, rh], [ew, eh]] of RATIO_BOX_FIXTURES) { assert.deepEqual(ratioBox(width, height, [rw, rh]), { width: ew, height: eh }, `${width}x${height} at ${rw}:${rh}`); } // The float maths this replaced lost the limiting side's last pixel here. assert.deepEqual(resolveCrop({ crop_aspect_ratio: "21:9" }, { width: 3813, height: 1961 }), { x: 0, y: 0, width: 3813, height: 1634 }); }); test("a resolved crop written back resolves to itself", () => { let seed = 11; const random = (limit) => { seed = (seed * 1103515245 + 12345) % 2147483648; return 1 + (seed % limit); }; for (let index = 0; index < 4000; index += 1) { const source = { width: random(4000), height: random(4000) }; const ratio = `${random(39)}:${random(39)}`; const values = { crop_x: random(source.width) - 1, crop_y: random(source.height) - 1, crop_aspect_ratio: ratio }; const crop = resolveCrop(values, source); const again = resolveCrop({ ...values, crop_x: crop.x, crop_y: crop.y, crop_width: crop.width, crop_height: crop.height }, source); assert.deepEqual(again, crop); } }); // --- Rotation keeps the crop ------------------------------------------------- const CROP_KEYS = ["crop_x", "crop_y", "crop_width", "crop_height"]; const pickCrop = (values) => Object.fromEntries(CROP_KEYS.map((name) => [name, values[name]])); test("rotating keeps a centred crop centred and its size", () => { // A centred 1:1 crop of a 576x1024 clip. const values = { crop_aspect_ratio: "1:1", crop_x: 0, crop_y: 224, crop_width: 576, crop_height: 0 }; const next = cropForRotation(values, 576, 1024, 0, 10); const canvas = rotatedSize(576, 1024, 10); assert.equal(next.crop_width, 576); assert.equal(next.crop_height, 576); assert.ok(Math.abs(next.crop_x + 288 - canvas.width / 2) <= 1); assert.ok(Math.abs(next.crop_y + 288 - canvas.height / 2) <= 1); }); test("an off-centre crop turns with the picture and comes back exactly", () => { const values = { crop_aspect_ratio: "free", crop_x: 800, crop_y: 60, crop_width: 400, crop_height: 300 }; const turned = cropForRotation(values, 1280, 720, 0, 90); // Its centre sits up and right of the picture's; a quarter turn clockwise // carries that point to the lower right of the new portrait canvas. assert.equal(turned.crop_width, 400); assert.equal(turned.crop_height, 300); assert.ok(turned.crop_x + 200 > 360, "right of centre"); assert.ok(turned.crop_y + 150 > 640, "below centre"); assert.deepEqual(cropForRotation({ ...values, ...turned }, 1280, 720, 90, 0), pickCrop(values)); assert.deepEqual(cropForRotation(values, 1280, 720, 0, 0), pickCrop(values)); }); test("an uncropped canvas stays open so it grows with the rotation", () => { const open = { crop_aspect_ratio: "free", crop_x: 0, crop_y: 0, crop_width: 0, crop_height: 0 }; assert.deepEqual(cropForRotation(open, 832, 1216, 0, 10), pickCrop(open)); // A Pad chip writes the whole source explicitly; it still counts as // uncropped instead of cutting the grown canvas's right and bottom off. const padded = { crop_aspect_ratio: "free", crop_x: 0, crop_y: 0, crop_width: 832, crop_height: 1216 }; assert.deepEqual(cropForRotation(padded, 832, 1216, 0, 10), pickCrop(open)); }); test("a crop that no longer fits shrinks evenly at its ratio", () => { const values = { crop_aspect_ratio: "16:9", crop_x: 0, crop_y: 0, crop_width: 1600, crop_height: 890 }; const next = cropForRotation(values, 1600, 900, 0, 90); const canvas = rotatedSize(1600, 900, 90); assert.ok(next.crop_width <= canvas.width && next.crop_height <= canvas.height); assert.deepEqual(resolveCrop({ ...values, ...next }, canvas), { x: next.crop_x, y: next.crop_y, width: next.crop_width, height: next.crop_height }); assert.ok(Math.abs(next.crop_width / next.crop_height - 16 / 9) < 0.01); }); // --- Size chain readout ------------------------------------------------------ test("the size chain names every step and the stretch the steps cause", () => { // LTX example: 576x1024 padded to 16:9, 0.86 MP at steps of 32. const values = { crop_aspect_ratio: "free", pad_left: 622, pad_right: 623, pad_top: 0, pad_bottom: 0, canvas_multiple: 1 }; const chain = sizeChain(values, { width: 576, height: 1024 }, { megapixels: 0.86, steps: 32 }); assert.deepEqual(chain.canvas, { width: 1821, height: 1024 }); assert.deepEqual(chain.resized, { width: 1280, height: 704 }); const { tokens, warnings } = sizeChainTokens(chain); assert.deepEqual(tokens.map((token) => `${token.label} ${token.text}`.trim()), ["576×1024", "pad 1821×1024", "resize 1280×704"]); assert.deepEqual(warnings, ["2.2% wider: each side rounds to 32 px"]); }); test("the size chain shows Align and names its strip ahead of a resize", () => { // The review's example A: 10 degrees, 16:9 crop, 64 px pads, Align 64, 1 MP. const source = rotatedSize(1920, 1080, 10); const values = { crop_aspect_ratio: "16:9", pad_left: 64, pad_top: 64, pad_right: 64, pad_bottom: 64, canvas_multiple: 64 }; const { tokens, warnings } = sizeChainTokens(sizeChain(values, source, { megapixels: 1, steps: 1 })); assert.deepEqual(tokens.map((token) => `${token.label} ${token.text}`.trim()), ["crop 2080×1170", "pad 2208×1298", "round to 64 2240×1344", "resize 1322×793"]); assert.deepEqual(warnings, ["rounding to 64 adds 32 px on the right and 46 px at the bottom of fill"]); // Without a resize the strip is what Align is for: no warning. assert.deepEqual(sizeChainTokens(sizeChain(values, source, null)).warnings, []); // The report's case: a 1824-wide picture, Align 64, Resize at Step 64. // The resized size is a multiple of 64, but the strip is still there. const arcade = sizeChainTokens(sizeChain({ canvas_multiple: 64 }, { width: 1824, height: 2304 }, { megapixels: 1, steps: 64 })); assert.deepEqual(arcade.tokens.map((token) => `${token.label} ${token.text}`.trim()), ["1824×2304", "round to 64 1856×2304", "resize 896×1152"]); assert.deepEqual(arcade.warnings, ["3.4% taller: each side rounds to 64 px", "rounding to 64 adds 32 px on the right of fill"]); // Align that adds nothing but a resize that breaks the multiple. const lost = sizeChainTokens(sizeChain({ canvas_multiple: 64 }, { width: 1280, height: 768 }, { megapixels: 0.5, steps: 1 })); assert.deepEqual(lost.warnings, ["the resize undoes rounding to 64"]); }); // --- Ratio chips ------------------------------------------------------------ import { IDENTITY_TRANSFORM, aspectMatches, canvasSize, isUntouched, padAround, ratioLabel, slidePadding, turnAspect, turnedCrop, } from "../js/shared/transform_geometry.mjs"; const portraitClip = { width: 720, height: 1280 }; test("a chip is lit only while the canvas has its shape", () => { // Pad to 16:9 is lit; drag the right padding in and it is custom. const padded = { ...fitSourceToAspect(portraitClip, "16:9", "pad"), canvas_multiple: 1 }; const canvas = canvasSize(padded, portraitClip); assert.deepEqual(canvas, { width: 2276, height: 1280 }); assert.equal(aspectMatches(canvas.width, canvas.height, "16:9"), true); const dragged = canvasSize({ ...padded, pad_right: 405 }, portraitClip); assert.equal(aspectMatches(dragged.width, dragged.height, "16:9"), false); assert.equal(ratioLabel(dragged.width, dragged.height), "1.49:1"); // A pixel of rounding either way still counts as the chip's shape. assert.equal(aspectMatches(2275, 1280, "16:9"), true); assert.equal(aspectMatches(2277, 1280, "16:9"), true); // Align's strip is not part of the shape. assert.deepEqual(canvasSize({ ...padded, canvas_multiple: 64 }, portraitClip), canvas); }); test("the untouched picture is neither a pick nor custom", () => { assert.equal(isUntouched({ ...IDENTITY_TRANSFORM }, portraitClip), true); assert.equal(isUntouched({ ...IDENTITY_TRANSFORM, pad_left: 4 }, portraitClip), false); assert.equal(isUntouched({ ...IDENTITY_TRANSFORM, rotation_degrees: 3 }, portraitClip), false); assert.equal(isUntouched({ ...IDENTITY_TRANSFORM, crop_width: 700 }, portraitClip), false); }); test("the orientation turn swaps the shape, never the pixels", () => { assert.equal(turnAspect("16:9"), "9:16"); assert.equal(turnAspect("9:21"), "21:9"); assert.equal(turnAspect("1:1"), "1:1"); assert.equal(turnAspect("free"), "free"); assert.equal(ratioLabel(861, 1280), "1:1.49"); assert.equal(ratioLabel(500, 500), "1:1"); // Pad mode: the crop padded, centred, into the turned ratio's canvas. const crop = { x: 0, y: 0, width: 720, height: 1280 }; const pads = padAround(crop, 16 / 9); assert.deepEqual(pads, { pad_left: 778, pad_top: 0, pad_right: 778, pad_bottom: 0 }); // Crop mode: the box turns about its own centre and stays in the picture. const box = turnedCrop({ x: 0, y: 370, width: 720, height: 540 }, portraitClip); assert.deepEqual(box, { x: 90, y: 280, width: 540, height: 720 }); // A turn the picture cannot hold shrinks evenly: the turned ratio holds. const landscape = { width: 1280, height: 720 }; const shrunk = turnedCrop({ x: 0, y: 0, width: 1280, height: 720 }, landscape); assert.equal(shrunk.height, 720); assert.ok(Math.abs(shrunk.width / shrunk.height - 9 / 16) < 0.01); assert.ok(shrunk.x >= 0 && shrunk.x + shrunk.width <= landscape.width); }); test("dragging the picture slides it inside its padding", () => { const start = { pad_left: 778, pad_top: 0, pad_right: 778, pad_bottom: 0 }; // Left 300 px: the left band shrinks, the right grows, the canvas keeps its size. const moved = slidePadding(start, -300, 25, { x: true }); assert.deepEqual(moved, { pad_left: 478, pad_top: 0, pad_right: 1078, pad_bottom: 0 }); // It stops at the canvas edge. assert.deepEqual(slidePadding(start, -5000, 0, { x: true }), { pad_left: 0, pad_top: 0, pad_right: 1556, pad_bottom: 0 }); // An axis where the crop can still move keeps its padding. assert.deepEqual(slidePadding(start, 120, 40, { y: true }), start); }); // --- Fixes from the recorded first-use sessions --------------------------------- import { KNOB_CLEARANCE, NEAR_RATIO, evenOutPadding, moveCursor, moveRoom, nearRatio, placeKnob, stepWithoutStretch, tightLockPadding, } from "../js/shared/transform_geometry.mjs"; test("a picture within about 1% of a ratio is already that ratio", () => { // The recorded 800x1424 photo is 0.12% off 9:16: tapping 9:16 used to add // a 1 px band on one side. Now nothing is added or trimmed, and it is lit. const photo = { width: 800, height: 1424 }; for (const mode of ["pad", "crop"]) { const patch = fitSourceToAspect(photo, "9:16", mode); assert.equal(patch.pad_left + patch.pad_right + patch.pad_top + patch.pad_bottom, 0); assert.deepEqual(resolveCrop(patch, photo), { x: 0, y: 0, ...photo }); } assert.equal(aspectMatches(800, 1424, "9:16"), true); assert.equal(nearRatio(800, 1424, 9 / 16), true); // Past the tolerance a band is added as before. assert.equal(nearRatio(1000, 1000 * (1 + NEAR_RATIO * 3), 1), false); assert.ok(fitSourceToAspect({ width: 780, height: 1424 }, "9:16", "pad").pad_right > 0); // The orientation button follows the same rule. assert.deepEqual(padAround({ x: 0, y: 0, width: 1280, height: 721 }, 16 / 9), { pad_left: 0, pad_top: 0, pad_right: 0, pad_bottom: 0 }); }); test("a locked drag solved from its start pads splits the new bands evenly", () => { // Every pointer move re-solves from the pads the drag began with, so an // odd delta can no longer pile its extra pixel on one side move by move. const crop = { width: 800, height: 1424 }; const start = { pad_left: 866, pad_right: 866, pad_top: 0, pad_bottom: 0 }; let pads = null; for (let right = 867; right <= 1246; right += 3) { pads = lockPadding({ ...start, pad_right: right }, crop, 16 / 9, "x"); } assert.ok(Math.abs(pads.pad_top - pads.pad_bottom) <= 1, JSON.stringify(pads)); assert.ok(pads.pad_top > 90); }); test("Reset crop under the lock drops the bands the lock added", () => { // 16:9 held on a 16:9 picture: trimming the right edge made the lock add // side bands. With the whole picture back, the smallest 16:9 canvas that // holds it has none. const whole = { width: 1280, height: 720 }; const pads = tightLockPadding({ pad_left: 109, pad_right: 110, pad_top: 0, pad_bottom: 0 }, whole, 16 / 9); assert.deepEqual(pads, { pad_left: 0, pad_top: 0, pad_right: 0, pad_bottom: 0 }); // A tall picture padded to 16:9 whose crop was trimmed and put back gets // its side bands back to the plain fit, not top and bottom bands. const tall = tightLockPadding({ pad_left: 688, pad_right: 688, pad_top: 0, pad_bottom: 0 }, { width: 800, height: 1424 }, 16 / 9); assert.deepEqual([tall.pad_top, tall.pad_bottom], [0, 0]); assert.ok(Math.abs(800 + tall.pad_left + tall.pad_right - Math.round(1424 * 16 / 9)) <= 1); }); test("the move cursor only offers the ways the picture can go", () => { const source = { width: 1280, height: 720 }; const whole = { x: 0, y: 0, width: 1280, height: 720 }; // Untouched: nowhere to go. assert.deepEqual(moveRoom(whole, source, {}), { left: false, right: false, up: false, down: false }); assert.equal(moveCursor(moveRoom(whole, source, {})), "default"); // Padding on the left only: the picture can slide left, along one axis. const left = moveRoom(whole, source, { pad_left: 157 }); assert.deepEqual(left, { left: true, right: false, up: false, down: false }); assert.equal(moveCursor(left), "ew-resize"); // A crop trimmed at the top can move up; with side padding, both axes. const both = moveRoom({ x: 0, y: 40, width: 1280, height: 680 }, source, { pad_left: 157 }); assert.deepEqual(both, { left: true, right: false, up: true, down: false }); assert.equal(moveCursor(both), "move"); assert.equal(moveCursor({ up: true }), "ns-resize"); }); test("the rotate knob keeps clear of the other handles", () => { // A narrow picture centred in a wide canvas: the corner is right under // the top diamond, so the knob's usual spot sits on it. const corner = { x: 110, y: 60 }; const center = { x: 100, y: 110 }; const diamond = { x: 100, y: 42, clearance: KNOB_CLEARANCE.padding }; const plain = placeKnob(corner, center, 18); assert.ok(Math.hypot(plain.x - diamond.x, plain.y - diamond.y) < KNOB_CLEARANCE.padding); const placed = placeKnob(corner, center, 18, [diamond], { x: 0, y: 0, width: 300, height: 220 }); assert.ok(Math.hypot(placed.x - diamond.x, placed.y - diamond.y) >= KNOB_CLEARANCE.padding); // Nothing in the way: the knob stays where it always was. assert.deepEqual(placeKnob(corner, center, 18, [], null), plain); }); test("the rounding stretch names a step that avoids it and pad mode can even it out", () => { // The LTX example: 576x1024 padded to 16:9 at 0.86 MP, Step 32. const values = { crop_aspect_ratio: "free", pad_left: 622, pad_right: 623, pad_top: 0, pad_bottom: 0, canvas_multiple: 1 }; const source = { width: 576, height: 1024 }; const resize = { megapixels: 0.86, steps: 32 }; assert.equal(stepWithoutStretch(values, source, resize), 8); const pads = evenOutPadding(values, source, resize); const even = sizeChain({ ...values, ...pads }, source, resize); assert.ok(Math.abs(even.stretch) <= 0.01, String(even.stretch)); assert.deepEqual(even.resized, { width: 1280, height: 704 }); // Every pixel stays: only padding grew, split evenly. assert.ok(pads.pad_left >= 622 && pads.pad_right >= 623 && Math.abs(pads.pad_left - pads.pad_right) <= 2); // Nothing to even out without a stretch. assert.equal(evenOutPadding({ ...values, pad_left: 0, pad_right: 0 }, { width: 1024, height: 1024 }, { megapixels: 1, steps: 32 }), null); });