The padlock: - Reset and tapping the lit ratio turn it off. - It does nothing on the untouched picture (the row says Source), so a trim there only trims. - The row says Held while it is on, and a locked drag splits the new bands evenly (each move is solved from where the drag started). - Reset crop also removes the bands the lock added. - The lit ratio and the lock share the pack's solid teal "on" look; hover is a light outline. Shapes and sizes: - A picture within about 1% of a ratio counts as that ratio: tapping 9:16 on a nearly 9:16 photo adds no 1 px band. - While a ratio is lit, the line under the picture says what it did, and "Already 9:16: nothing to add. Pick another ratio or turn it." when there was nothing to do (a saved ratio re-applies to every new picture). - The resize warning reads "1.5% taller: each side rounds to 32 px"; its tooltip names a Step that avoids it, and in pad mode Even out adds a few pixels of padding so nothing stretches. - Fit dims and says "pick a ratio first" until a ratio is lit. - Over the picture, the cursor and small arrows show only the ways it can move; with nowhere to go it is a plain pointer. Drawing: - Added space is drawn in the real fill colour with a faint hatch, and only the picture the crop cuts away is darkened, so black bands on the black stage show. - The rotate knob keeps clear of the padding diamonds and crop squares. - On a big node the controls stay at most 460 px wide, centred, and only the picture grows. A new picture fits the picture area to its shape, never taller than the height you gave it. Words: Align is "Round canvas to", Local path is "Server file", Resize is an off | on switch, and the canvas row labels read like the others. The full editor is built from the node's own controls: the ratio row with its turn button and padlock, the Fit switch, and number boxes for the rotation (Shift for 0.1 degree) in place of the coarse slider. Ratios from ausboss_presets.json that no button shows sit in a More list. The size box keeps clear of the handles, and the editor's button style no longer reaches the number boxes.
588 lines
30 KiB
JavaScript
588 lines
30 KiB
JavaScript
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, 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("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);
|
||
});
|