* Group crop controls and put video scrubbing below the preview * Stop IN where the connected clip length still fits * Add play and pause beside the crop scrubber and clarify resize padding * Preview the current brush beside Size in Image Compare * Center the crop and its padding together on each axis * Polish crop controls and load images from Outputs * Add crop controls preview
941 lines
50 KiB
JavaScript
941 lines
50 KiB
JavaScript
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, ["Resize makes the picture 2.2% wider"]);
|
||
});
|
||
|
||
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, ["Resize makes the picture 1.1% wider", "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,
|
||
centredTransform,
|
||
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));
|
||
});
|
||
|
||
test("Center slides a crop without padding and preserves an open-ended crop's size", () => {
|
||
const source = { width: 832, height: 1216 };
|
||
const values = { crop_x: 149, crop_y: 0, crop_width: 0, crop_height: 1200 };
|
||
const moved = centredTransform(values, source, "x");
|
||
assert.deepEqual(moved, { crop_x: 74, crop_width: 683 });
|
||
const centred = { ...values, ...moved };
|
||
assert.deepEqual(resolveCrop(centred, source), { x: 74, y: 0, width: 683, height: 1200 });
|
||
assert.equal(centredTransform(centred, source, "x"), null);
|
||
assert.deepEqual(centredTransform(centred, source, "y"), { crop_y: 8 });
|
||
});
|
||
|
||
test("Center balances crop and padding together and leaves the other axis alone", () => {
|
||
const source = { width: 832, height: 1216 };
|
||
const values = { crop_x: 10, crop_y: 15, crop_width: 683, crop_height: 1200, pad_right: 105, pad_top: 12 };
|
||
const moved = centredTransform(values, source, "x");
|
||
assert.deepEqual(moved, { crop_x: 74, pad_left: 52, pad_right: 53 });
|
||
const next = { ...values, ...moved };
|
||
assert.deepEqual(resolvePadding(next, resolveCrop(next, source)).outputWidth, 788);
|
||
assert.equal(centredTransform(next, source, "x"), null);
|
||
assert.equal(centredTransform({}, source, "x"), null);
|
||
});
|