Files
ausboss-ComfyUI-AusBoss/tests/transform_geometry.test.mjs
ausboss 5ec02ef356 Divisible by: no strip of fill on an edge without padding
Image Crop + Rotate + Pad and both Video Crop + Rotate + Pad nodes rounded
the canvas up to Divisible by and put the pixels left over on the right and
bottom, whatever was padded. A 1916 x 821 picture with 3 px of padding on
the left and Divisible by 24 came to 1920 wide, with 1 px of fill on the
right edge, which had no padding. With Resize on, that strip was smeared
into the picture's last column, and the stitched result kept it as a light
line.

The canvas now rounds one axis at a time by Load Image + Pad's rule
(_pad_helpers._axis_layout): the leftover joins a side that was padded, the
right or bottom one when both are, and an axis nobody padded has its crop
trimmed evenly to the multiple below. A picture smaller than one multiple
still grows on the far side. With no padding on an axis the size therefore
rounds down, not up: 1000 x 750 at 64 gives 960 x 704, was 1024 x 768.
Inputs and outputs are unchanged.

The editor uses the same rule (axisLayout, resolvePadding and keptCrop in
js/shared/transform_geometry.mjs), so the picture on the node, the size
line and the editor match the run. The size line names fill added on any
side and pixels trimmed, and the centre buttons count fill that sits on the
left or top. The Divisible by tooltip and help pages describe the rule, and
the Krea 2 Outpaint Model Patch warning no longer says Divisible by rounds
the other axis up.

tests/test_transform_engine.py and tests/transform_geometry.test.mjs share
one fixture table, so the editor and the run are held to the same numbers.
2026-10-11 20:41:52 -05:00

918 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import {
canvasLocalPoint,
fitSourceToAspect,
cropHandleCenters,
keptCrop,
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 });
});
// [crop width, crop height, pad left, top, right, bottom, multiple] ->
// [pad left, top, right, bottom, trim left, trim top, width, height]. The
// same table sits in tests/test_transform_engine.py (CANVAS_FIXTURES): the
// editor draws and reports the canvas the run builds.
const CANVAS_FIXTURES = [
// The report: 1 px left over, and the right edge has no padding.
[[1916, 821, 3, 246, 0, 336, 24], [4, 246, 0, 349, 0, 0, 1920, 1416]],
// Nothing padded: each axis is trimmed evenly to the multiple below.
[[101, 99, 0, 0, 0, 0, 8], [0, 0, 0, 0, 2, 1, 96, 96]],
[[1824, 2304, 0, 0, 0, 0, 64], [0, 0, 0, 0, 16, 0, 1792, 2304]],
// Both sides padded: the far one takes the leftover.
[[2080, 1170, 64, 64, 64, 64, 64], [64, 64, 96, 110, 0, 0, 2240, 1344]],
// One side padded: it takes the leftover, whichever side it is.
[[1000, 750, 0, 0, 37, 0, 16], [0, 0, 40, 0, 0, 7, 1040, 736]],
[[1000, 750, 0, 20, 0, 0, 16], [0, 34, 0, 0, 4, 0, 992, 784]],
// A picture smaller than one multiple has nothing to trim to.
[[5, 5, 0, 0, 0, 0, 8], [0, 0, 3, 3, 0, 0, 8, 8]],
// Already a multiple, or the multiple off: nothing moves.
[[1024, 768, 0, 0, 0, 0, 64], [0, 0, 0, 0, 0, 0, 1024, 768]],
[[1001, 333, 7, 0, 0, 5, 1], [7, 0, 0, 5, 0, 0, 1008, 338]],
];
test("canvas multiple resolves exactly as the backend does", () => {
for (const [[width, height, left, top, right, bottom, multiple], expected] of CANVAS_FIXTURES) {
const values = { pad_left: left, pad_top: top, pad_right: right, pad_bottom: bottom, canvas_multiple: multiple };
const pad = resolvePadding(values, { x: 0, y: 0, width, height });
assert.deepEqual(
[pad.left, pad.top, pad.right, pad.bottom, pad.trimLeft, pad.trimTop, pad.outputWidth, pad.outputHeight],
expected,
JSON.stringify(values),
);
// What is kept, the padding and the canvas always add up.
const kept = keptCrop({ x: 0, y: 0, width, height }, pad);
assert.equal(pad.left + kept.width + pad.right, pad.outputWidth);
assert.equal(pad.top + kept.height + pad.bottom, pad.outputHeight);
}
});
test("canvas multiple never adds fill on a side nobody padded", () => {
// The leftover used to go to the right and bottom whatever was padded: a
// strip of fill on an edge with no padding, for the model to paint.
let seed = 7;
const random = (limit) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % limit; };
for (let run = 0; run < 4000; run += 1) {
const multiple = [2, 8, 16, 24, 32, 64][random(6)];
const crop = { x: 0, y: 0, width: multiple + random(3000), height: multiple + random(3000) };
const asked = [0, 1, 2, 3].map(() => (random(2) ? random(400) : 0));
const values = { pad_left: asked[0], pad_top: asked[1], pad_right: asked[2], pad_bottom: asked[3], canvas_multiple: multiple };
const pad = resolvePadding(values, crop);
const label = JSON.stringify({ crop, values });
assert.equal(pad.outputWidth % multiple, 0, label);
assert.equal(pad.outputHeight % multiple, 0, label);
for (const [index, side] of ["left", "top", "right", "bottom"].entries()) {
if (asked[index]) assert.ok(pad[side] >= asked[index], label);
else assert.equal(pad[side], 0, label);
}
// A trim only ever happens on an axis with no padding at all.
if (asked[0] || asked[2]) assert.equal(pad.trimLeft + pad.trimRight, 0, label);
if (asked[1] || asked[3]) assert.equal(pad.trimTop + pad.trimBottom, 0, label);
assert.ok(pad.trimLeft + pad.trimRight < multiple && pad.trimTop + pad.trimBottom < multiple, label);
}
});
test("a trimmed crop keeps its own centre", () => {
const crop = { x: 10, y: 4, width: 53, height: 41 };
const pad = resolvePadding({ canvas_multiple: 16 }, crop);
assert.deepEqual(keptCrop(crop, pad), { x: 12, y: 8, width: 48, height: 32 });
});
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,
trimLeft: 0, trimTop: 0, trimRight: 0, trimBottom: 0,
});
});
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 what it does 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, []);
// A report's case: a 1824-wide picture, Align 64, Resize at Step 64. It
// has no padding, so Align adds no strip of fill for the model to paint:
// the width is trimmed to 1792 instead, and the line says so.
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 1792×2304", "resize 896×1152"]);
assert.deepEqual(arcade.warnings, ["rounding to 64 trims 32 px off the width"]);
assert.deepEqual(sizeChainTokens(sizeChain({ canvas_multiple: 64 }, { width: 1824, height: 2304 }, null)).warnings, []);
// Another report's run: 3 px on the left, bands top and bottom, Align 24.
// The pixel left over joins the left band, not the unpadded right edge.
const thin = sizeChainTokens(sizeChain(
{ pad_left: 3, pad_top: 246, pad_right: 0, pad_bottom: 336, canvas_multiple: 24 },
{ width: 1916, height: 821 }, { megapixels: 1, steps: 32 },
));
assert.deepEqual(thin.tokens.map((token) => `${token.label} ${token.text}`.trim()), ["1916×821", "pad 1919×1403", "round to 24 1920×1416", "resize 1184×864"]);
assert.deepEqual(thin.warnings, ["1.1% wider: each side rounds to 32 px", "rounding to 24 adds 1 px on the left and 13 px at the bottom of fill"]);
// Fill on one axis and a trim on the other, each named.
const mixed = sizeChainTokens(sizeChain({ pad_top: 20, canvas_multiple: 16 }, { width: 1000, height: 750 }, { megapixels: 1, steps: 8 }));
assert.deepEqual(mixed.warnings, ["rounding to 16 adds 14 px at the top of fill", "rounding to 16 trims 8 px off the width"]);
// 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);
});
// --- Turning the knob past upside down --------------------------------------------
import { knobStep, wrapDegrees } from "../js/shared/transform_geometry.mjs";
// The editor's knob gesture: pointer angles (degrees, as atan2 gives them)
// followed move by move from the rotation the drag began with.
function sweepKnob(rotation, angles) {
const radians = angles.map((angle) => angle * Math.PI / 180);
let turned = 0;
const seen = [];
for (let index = 1; index < radians.length; index += 1) {
turned += knobStep(radians[index - 1], radians[index]);
seen.push(Math.round(wrapDegrees(rotation + turned * 180 / Math.PI) * 10) / 10);
}
return seen;
}
// Pointer angles from `start` in 6 degree steps, wrapped the way atan2 wraps.
const arc = (start, sweep) => Array.from({ length: Math.abs(sweep) / 6 + 1 }, (_, index) => {
const angle = start + Math.sign(sweep) * index * 6;
return ((((angle + 180) % 360) + 360) % 360) - 180;
});
test("the knob turns on past upside down, either way", () => {
// The recorded report: at -180 the picture could not be turned back up.
// Counter-clockwise it stayed at -180; clockwise it snapped back to -180
// once the pointer passed straight left of the centre.
const back = sweepKnob(-180, arc(140, -120));
assert.equal(back.at(-1), 60);
const on = sweepKnob(-180, arc(140, 120));
assert.equal(on.at(-1), -60);
// Clockwise from upright through 180 it carries on from -180, with no jump.
const through = sweepKnob(0, arc(-40, 240));
assert.equal(through.at(-1), -120);
for (const seen of [back, on, through]) {
for (let index = 1; index < seen.length; index += 1) {
const step = Math.abs(wrapDegrees(seen[index] - seen[index - 1]));
assert.ok(step <= 6.05, `a ${step} degree jump in ${seen.join(" ")}`);
}
}
});
test("a knob step is the short way round and the rotation wraps", () => {
const degrees = (value) => value * 180 / Math.PI;
const radians = (value) => value * Math.PI / 180;
assert.ok(Math.abs(degrees(knobStep(radians(176), radians(-178))) - 6) < 1e-9);
assert.ok(Math.abs(degrees(knobStep(radians(-178), radians(176))) + 6) < 1e-9);
assert.ok(Math.abs(degrees(knobStep(radians(10), radians(40))) - 30) < 1e-9);
assert.equal(wrapDegrees(181), -179);
assert.equal(wrapDegrees(-181), 179);
assert.equal(wrapDegrees(540), -180);
// Both ends of the range, and everything inside it, stay as typed.
assert.equal(wrapDegrees(180), 180);
assert.equal(wrapDegrees(-180), -180);
assert.equal(wrapDegrees(-37.5), -37.5);
});
// --- Corner handles and centring --------------------------------------------------
import {
MAX_PADDING,
axisBands,
canvasCornerCenters,
centredPadding,
cornerScale,
knobAt,
knobOffset,
paddingRingGaps,
scaleCanvasPadding,
} from "../js/shared/transform_geometry.mjs";
test("corner handles ring the canvas as far out as the side diamonds", () => {
const rect = { x: 100, y: 50, width: 300, height: 200 };
const corners = canvasCornerCenters(rect, 20);
assert.deepEqual(corners.map(({ corner, x, y }) => [corner, x, y]), [
["nw", 80, 30], ["ne", 420, 30], ["se", 420, 270], ["sw", 80, 270],
]);
// On the line of the top and right diamonds, and clear of the crop squares
// on the picture's own corners when the canvas has no padding.
const [top, right] = paddingHandleCenters(rect, 20);
assert.equal(corners[1].y, top.y);
assert.equal(corners[1].x, right.x);
for (const [corner, square] of corners.map((handle, index) => [handle, cropHandleCenters(rect)[index * 2]])) {
assert.ok(Math.abs(Math.hypot(corner.x - square.x, corner.y - square.y) - 20 * Math.SQRT2) < 1e-9);
}
// The gaps sit halfway between each corner handle and its neighbouring diamond.
assert.deepEqual(paddingRingGaps(rect, 20)[1], { x: 335, y: 30 });
});
test("a corner drag follows the pointer along the canvas diagonal", () => {
// Straight out along the diagonal by its own length doubles the canvas.
assert.equal(cornerScale("se", 1600, 900, 1600, 900), 2);
assert.equal(cornerScale("nw", -1600, -900, 1600, 900), 2);
// Across the diagonal does nothing.
assert.ok(Math.abs(cornerScale("se", 900, -1600, 1600, 900) - 1) < 1e-12);
// About the centre the grabbed corner is half as far from what holds
// still, so the same move scales twice as much.
assert.equal(cornerScale("ne", 160, -90, 1600, 900, true), 1.2);
assert.equal(cornerScale("ne", 160, -90, 1600, 900), 1.1);
});
test("a corner keeps the canvas shape and holds the opposite corner", () => {
const crop = { width: 832, height: 468 };
const start = { pad_left: 0, pad_top: 0, pad_right: 0, pad_bottom: 0 };
const pads = scaleCanvasPadding(start, crop, "se", 1.25);
assert.deepEqual(pads, { pad_left: 0, pad_top: 0, pad_right: 208, pad_bottom: 117 });
const canvas = canvasOf(pads, crop);
assert.equal(aspectMatches(canvas.width, canvas.height, "832:468"), true);
// The top-left corner only moves its own two sides.
const nw = scaleCanvasPadding({ pad_left: 10, pad_top: 20, pad_right: 30, pad_bottom: 40 }, crop, "nw", 1.5);
assert.equal(nw.pad_right, 30);
assert.equal(nw.pad_bottom, 40);
assert.ok(nw.pad_left > 10 && nw.pad_top > 20);
// Not moving changes nothing, whatever the rounding.
const odd = { pad_left: 3, pad_top: 7, pad_right: 11, pad_bottom: 2 };
for (const corner of ["nw", "ne", "se", "sw"]) {
assert.deepEqual(scaleCanvasPadding(odd, { width: 1001, height: 563 }, corner, 1), odd);
assert.deepEqual(scaleCanvasPadding(odd, { width: 1001, height: 563 }, corner, 1, { centre: true }), odd);
}
});
test("a corner stops where its sides run out of padding", () => {
const crop = { width: 1280, height: 720 };
// The untouched picture has nothing to shrink.
assert.deepEqual(scaleCanvasPadding({}, crop, "ne", 0.5), { pad_left: 0, pad_top: 0, pad_right: 0, pad_bottom: 0 });
// Pulled far inward, the grabbed corner's sides stop at zero; the side
// with the least room sets the stop, so the shape still holds.
const start = { pad_left: 50, pad_top: 100, pad_right: 400, pad_bottom: 180 };
const pads = scaleCanvasPadding(start, crop, "se", 0.1);
assert.equal(pads.pad_left, 50);
assert.equal(pads.pad_top, 100);
assert.equal(Math.min(pads.pad_right, pads.pad_bottom), 0);
// Every side stays inside the backend's limit.
const huge = scaleCanvasPadding({}, crop, "se", 1000);
assert.ok(huge.pad_right <= MAX_PADDING && huge.pad_bottom <= MAX_PADDING);
});
test("Alt scales about the centre: all four sides, the middle holds", () => {
const crop = { width: 832, height: 1216 };
const start = { pad_left: 100, pad_top: 0, pad_right: 100, pad_bottom: 0 };
const pads = scaleCanvasPadding(start, crop, "sw", 1.2, { centre: true });
const before = canvasOf(start, crop);
const after = canvasOf(pads, crop);
assert.ok(Math.abs(after.width / after.height - before.width / before.height) < 0.002);
// The picture keeps its place in the middle.
assert.ok(Math.abs(pads.pad_left - pads.pad_right) <= 1);
assert.ok(Math.abs(pads.pad_top - pads.pad_bottom) <= 1);
// Shrinking an off-centre picture: the side that runs out first passes
// the rest of the shrink to the side across from it.
const off = { pad_left: 0, pad_top: 50, pad_right: 300, pad_bottom: 50 };
const shrunk = scaleCanvasPadding(off, { width: 1000, height: 1000 }, "ne", 0.9, { centre: true });
assert.equal(shrunk.pad_left, 0);
assert.ok(shrunk.pad_right < 300 && shrunk.pad_right > 0);
});
test("centre evens out the padding and keeps the canvas size", () => {
const crop = { x: 0, y: 0, width: 832, height: 468 };
const values = { pad_left: 0, pad_top: 10, pad_right: 147, pad_bottom: 73, canvas_multiple: 1 };
assert.deepEqual(centredPadding(values, crop, "x"), { pad_left: 73, pad_right: 74 });
assert.deepEqual(centredPadding(values, crop, "y"), { pad_top: 41, pad_bottom: 42 });
// Already in the middle, or nothing to move: nothing to do.
assert.equal(centredPadding({ pad_left: 73, pad_right: 74 }, crop, "x"), null);
assert.equal(centredPadding({}, crop, "y"), null);
// Round canvas to adds its strip on the right: the bands you see come out
// even, and the canvas the ratio buttons measure keeps its size.
const tall = { x: 0, y: 0, width: 832, height: 1216 };
const rounded = { pad_left: 807, pad_right: 982, pad_top: 0, pad_bottom: 0, canvas_multiple: 64 };
const centred = { ...rounded, ...centredPadding(rounded, tall, "x") };
assert.equal(centred.pad_left + centred.pad_right, 807 + 982);
const bands = axisBands(centred, tall, "x");
assert.ok(Math.abs(bands.near - bands.far) <= 1, JSON.stringify(bands));
// Padding on the left only: the strip sits there too, so the bands are
// 320 and 0. Centring still evens them out.
const left = { pad_left: 300, pad_right: 0, canvas_multiple: 64 };
assert.deepEqual(axisBands(left, tall, "x"), { near: 320, far: 0 });
assert.deepEqual(axisBands({ ...left, ...centredPadding(left, tall, "x") }, tall, "x"), { near: 160, far: 160 });
// Less padding than the strip: a pixel stays on the right for the strip
// to join, which is as close as it gets, and a second press does nothing.
const little = { pad_left: 10, pad_right: 0, canvas_multiple: 64 };
assert.deepEqual(centredPadding(little, tall, "x"), { pad_left: 9, pad_right: 1 });
assert.equal(centredPadding({ ...little, pad_left: 9, pad_right: 1 }, tall, "x"), null);
// A move that would only swap the sides is not made.
assert.equal(centredPadding({ pad_left: 1, pad_right: 0, canvas_multiple: 64 }, tall, "x"), null);
// No padding on the axis: Divisible by trims it, and there is nothing to move.
assert.equal(centredPadding({ canvas_multiple: 64 }, tall, "y"), null);
});
// The corner handles and the knob on the smallest node face, laid out the
// way the stage draws an untouched picture, each have room to be aimed at:
// the nearest other handle is at least 24 px away. (The side diamonds keep
// their own long-standing distance from the crop squares.)
function faceHandles(source, stage = { width: 292, height: 205 }) {
const layout = stageHandleLayout(stage.width, stage.height);
const fit = Math.min((stage.width - layout.margin * 2) / source.width, (stage.height - layout.margin * 2) / source.height);
const rect = {
x: (stage.width - source.width * fit) / 2, y: (stage.height - source.height * fit) / 2,
width: source.width * fit, height: source.height * fit,
};
const corner = { x: rect.x + rect.width, y: rect.y };
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
const handles = [
...cropHandleCenters(rect).map((point) => ({ ...point, kind: "crop" })),
...paddingHandleCenters(rect, layout.padOffset).map((point) => ({ ...point, kind: "padding" })),
...canvasCornerCenters(rect, layout.padOffset).map((point) => ({ ...point, kind: "corner" })),
];
const obstacles = handles.map((point) => ({ ...point, clearance: point.kind === "crop" ? KNOB_CLEARANCE.crop : KNOB_CLEARANCE.padding }));
const knob = placeKnob(corner, center, layout.rotateArm, obstacles, { x: 0, y: 0, ...stage }, paddingRingGaps(rect, layout.padOffset));
return [...handles, { name: "rotation", kind: "rotation", ...knob }];
}
test("on the smallest face the corner handles and the knob have room to be aimed at", () => {
for (const source of [{ width: 1280, height: 720 }, { width: 832, height: 1216 }, { width: 1000, height: 1000 }, { width: 2100, height: 900 }]) {
const handles = faceHandles(source);
for (const handle of handles.filter((item) => item.kind === "corner" || item.kind === "rotation")) {
const nearest = Math.min(...handles.filter((other) => other !== handle).map((other) => Math.hypot(other.x - handle.x, other.y - handle.y)));
assert.ok(nearest >= 24, `${source.width}x${source.height} ${handle.kind} ${handle.name}: ${nearest.toFixed(1)} px`);
}
// The knob kept clear of the corner handle that took its usual spot.
const knob = handles.at(-1);
const ne = handles.find((handle) => handle.name === "corner_ne");
assert.ok(Math.hypot(knob.x - ne.x, knob.y - ne.y) >= KNOB_CLEARANCE.padding);
}
});
test("the knob keeps its spot on its arm while it turns", () => {
const corner = { x: 200, y: 40 };
const center = { x: 100, y: 100 };
const knob = { x: 214, y: 80 };
const offset = knobOffset(corner, center, knob);
const back = knobAt(corner, center, offset);
assert.ok(Math.abs(back.x - knob.x) < 1e-9 && Math.abs(back.y - knob.y) < 1e-9);
// Turned a quarter about the centre, the knob turns with the corner.
const turned = knobAt({ x: 160, y: 200 }, center, offset);
assert.ok(Math.abs(turned.x - 120) < 1e-9 && Math.abs(turned.y - 214) < 1e-9, JSON.stringify(turned));
});