Review fixes verified against live ComfyUI 0.28.0: - rotatedSize now reproduces Pillow's expand=True output exactly (transpose fast path at axis angles, corner extents elsewhere). The previous formula was 1px short on 34 of 78 sampled size/angle combos, so editor-predicted output dimensions disagreed with queued results at most free angles. Verified 0 mismatches across 6776 combinations and end-to-end on the server (predicted 848x496 == queued image and mask at 77.7 degrees). - decode_video_frame seeks to the nearest keyframe and decodes forward instead of scanning from frame 0, with the sequential scan kept as the fallback for files without usable timestamps. Scrubbing long videos no longer decodes the entire prefix per preview request. - Preview routes no longer serve arbitrary local paths to any HTTP client: local-path previews outside ComfyUI's input/output/temp folders now require AUSBOSS_TRANSFORM_LOCAL_PREVIEW=1, checked before touching the filesystem so denied requests cannot probe path existence. Queued workflows are unaffected. - Editor: metadata is cached per source instead of refetched every scrub tick, timeline seeks fetch only the frame with a 70ms trailing debounce, AbortError from superseded requests no longer paints the error state, and switching source mode before picking a file no longer resets the transform. - Tests: 13 -> 18 Python (seek vs sequential ground truth, time-mode seek, out-of-range clamp, preview gate), 8 -> 9 JS (Pillow-verified rotated-size fixtures including the odd/even transpose edge).
189 lines
7.7 KiB
JavaScript
189 lines
7.7 KiB
JavaScript
export const MIN_CROP_SIZE = 8;
|
|
|
|
export const IDENTITY_TRANSFORM = Object.freeze({
|
|
rotation_degrees: 0,
|
|
crop_aspect_ratio: "free",
|
|
crop_x: 0,
|
|
crop_y: 0,
|
|
crop_width: 0,
|
|
crop_height: 0,
|
|
pad_left: 0,
|
|
pad_top: 0,
|
|
pad_right: 0,
|
|
pad_bottom: 0,
|
|
feather: 0,
|
|
canvas_multiple: 1,
|
|
fill_color: "#808080",
|
|
});
|
|
|
|
export function resetTransformValues(includeTimeline = false) {
|
|
return includeTimeline
|
|
? { ...IDENTITY_TRANSFORM, seek_mode: "frame index", frame_index: 0, frame_time: 0 }
|
|
: { ...IDENTITY_TRANSFORM };
|
|
}
|
|
|
|
export function sourceChanged(previousKey, nextKey, ready = true) {
|
|
return Boolean(ready && nextKey && previousKey !== nextKey);
|
|
}
|
|
|
|
export function clamp(value, minimum, maximum) {
|
|
return Math.max(minimum, Math.min(maximum, Number(value) || 0));
|
|
}
|
|
|
|
// Matches Pillow's Image.rotate(expand=True) output size exactly, verified
|
|
// against PIL across 6776 size/angle combinations. Pillow transposes at the
|
|
// axis angles (no ceil/floor growth) and otherwise takes ceil(max)-floor(min)
|
|
// of the corner extents using cos/sin rounded to 15 decimals. Keep in sync
|
|
// with nodes/_transform_engine.py, which delegates to Pillow.
|
|
export function rotatedSize(width, height, degrees) {
|
|
const normalized = ((Number(degrees) || 0) % 360 + 360) % 360;
|
|
if (normalized === 0 || normalized === 180) return { width: Math.max(1, width), height: Math.max(1, height) };
|
|
if (normalized === 90 || normalized === 270) return { width: Math.max(1, height), height: Math.max(1, width) };
|
|
const radians = normalized * Math.PI / 180;
|
|
const round15 = (value) => Math.round(value * 1e15) / 1e15;
|
|
const cosine = round15(Math.cos(radians));
|
|
const sine = round15(Math.sin(radians));
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const xs = [];
|
|
const ys = [];
|
|
for (const [x, y] of [[0, 0], [width, 0], [width, height], [0, height]]) {
|
|
xs.push(centerX + (x - centerX) * cosine - (y - centerY) * sine);
|
|
ys.push(centerY + (x - centerX) * sine + (y - centerY) * cosine);
|
|
}
|
|
return {
|
|
width: Math.max(1, Math.ceil(Math.max(...xs)) - Math.floor(Math.min(...xs))),
|
|
height: Math.max(1, Math.ceil(Math.max(...ys)) - Math.floor(Math.min(...ys))),
|
|
};
|
|
}
|
|
|
|
export function parseAspectRatio(value, source) {
|
|
if (!value || value === "free") return null;
|
|
if (value === "source") return source.width / source.height;
|
|
const parts = String(value).split(":").map(Number);
|
|
if (parts.length !== 2 || parts.some((part) => !Number.isFinite(part) || part <= 0)) return null;
|
|
return parts[0] / parts[1];
|
|
}
|
|
|
|
export function resolveCrop(values, source) {
|
|
const x = Math.round(clamp(values.crop_x, 0, Math.max(0, source.width - 1)));
|
|
const y = Math.round(clamp(values.crop_y, 0, Math.max(0, source.height - 1)));
|
|
let width = Number(values.crop_width) > 0 ? Number(values.crop_width) : source.width - x;
|
|
let height = Number(values.crop_height) > 0 ? Number(values.crop_height) : source.height - y;
|
|
width = Math.max(1, Math.min(Math.round(width), source.width - x));
|
|
height = Math.max(1, Math.min(Math.round(height), source.height - y));
|
|
const ratio = parseAspectRatio(values.crop_aspect_ratio, source);
|
|
if (ratio) {
|
|
if (width / height > ratio) width = Math.max(1, Math.floor(height * ratio));
|
|
else height = Math.max(1, Math.floor(width / ratio));
|
|
}
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
export function resolvePadding(values, crop) {
|
|
const left = Math.max(0, Math.round(Number(values.pad_left) || 0));
|
|
const top = Math.max(0, Math.round(Number(values.pad_top) || 0));
|
|
const right = Math.max(0, Math.round(Number(values.pad_right) || 0));
|
|
const bottom = Math.max(0, Math.round(Number(values.pad_bottom) || 0));
|
|
const multiple = Math.max(1, Math.round(Number(values.canvas_multiple) || 1));
|
|
const requestedWidth = crop.width + left + right;
|
|
const requestedHeight = crop.height + top + bottom;
|
|
const outputWidth = Math.ceil(requestedWidth / multiple) * multiple;
|
|
const outputHeight = Math.ceil(requestedHeight / multiple) * multiple;
|
|
return {
|
|
left,
|
|
top,
|
|
right: right + outputWidth - requestedWidth,
|
|
bottom: bottom + outputHeight - requestedHeight,
|
|
outputWidth,
|
|
outputHeight,
|
|
};
|
|
}
|
|
|
|
export function canvasLocalPoint(canvas, event) {
|
|
const bounds = canvas.getBoundingClientRect();
|
|
const width = Math.max(1, canvas.clientWidth || bounds.width || 1);
|
|
const height = Math.max(1, canvas.clientHeight || bounds.height || 1);
|
|
return {
|
|
x: ((event.clientX - bounds.left) / Math.max(1, bounds.width || width)) * width,
|
|
y: ((event.clientY - bounds.top) / Math.max(1, bounds.height || height)) * height,
|
|
};
|
|
}
|
|
|
|
export function cropHandleCenters(rect) {
|
|
const middleX = rect.x + rect.width / 2;
|
|
const middleY = rect.y + rect.height / 2;
|
|
return [
|
|
{ name: "nw", x: rect.x, y: rect.y },
|
|
{ name: "n", x: middleX, y: rect.y },
|
|
{ name: "ne", x: rect.x + rect.width, y: rect.y },
|
|
{ name: "e", x: rect.x + rect.width, y: middleY },
|
|
{ name: "se", x: rect.x + rect.width, y: rect.y + rect.height },
|
|
{ name: "s", x: middleX, y: rect.y + rect.height },
|
|
{ name: "sw", x: rect.x, y: rect.y + rect.height },
|
|
{ name: "w", x: rect.x, y: middleY },
|
|
];
|
|
}
|
|
|
|
export function paddingHandleCenters(rect, offset = 38) {
|
|
return [
|
|
{ name: "pad_top", x: rect.x + rect.width / 2, y: rect.y - offset },
|
|
{ name: "pad_right", x: rect.x + rect.width + offset, y: rect.y + rect.height / 2 },
|
|
{ name: "pad_bottom", x: rect.x + rect.width / 2, y: rect.y + rect.height + offset },
|
|
{ name: "pad_left", x: rect.x - offset, y: rect.y + rect.height / 2 },
|
|
];
|
|
}
|
|
|
|
export function nearestHandle(point, groups) {
|
|
let selected = null;
|
|
for (const group of groups) {
|
|
for (const handle of group.handles) {
|
|
const distance = Math.hypot(point.x - handle.x, point.y - handle.y);
|
|
if (distance > group.radius) continue;
|
|
if (!selected || distance < selected.distance || (distance === selected.distance && group.priority < selected.priority)) {
|
|
selected = { ...handle, kind: group.kind, distance, priority: group.priority };
|
|
}
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
export function resizeCrop(start, handle, dx, dy, source, aspectRatio = null) {
|
|
let left = start.x;
|
|
let top = start.y;
|
|
let right = start.x + start.width;
|
|
let bottom = start.y + start.height;
|
|
if (handle.includes("w")) left = clamp(left + dx, 0, right - MIN_CROP_SIZE);
|
|
if (handle.includes("e")) right = clamp(right + dx, left + MIN_CROP_SIZE, source.width);
|
|
if (handle.includes("n")) top = clamp(top + dy, 0, bottom - MIN_CROP_SIZE);
|
|
if (handle.includes("s")) bottom = clamp(bottom + dy, top + MIN_CROP_SIZE, source.height);
|
|
if (aspectRatio) {
|
|
let width = right - left;
|
|
let height = bottom - top;
|
|
if (Math.abs(dx) >= Math.abs(dy)) height = width / aspectRatio;
|
|
else width = height * aspectRatio;
|
|
if (handle.includes("w")) left = right - width; else right = left + width;
|
|
if (handle.includes("n")) top = bottom - height; else bottom = top + height;
|
|
if (left < 0) { right -= left; left = 0; }
|
|
if (top < 0) { bottom -= top; top = 0; }
|
|
if (right > source.width) { left -= right - source.width; right = source.width; }
|
|
if (bottom > source.height) { top -= bottom - source.height; bottom = source.height; }
|
|
}
|
|
return {
|
|
x: Math.round(clamp(left, 0, source.width - MIN_CROP_SIZE)),
|
|
y: Math.round(clamp(top, 0, source.height - MIN_CROP_SIZE)),
|
|
width: Math.round(Math.max(MIN_CROP_SIZE, Math.min(right - left, source.width))),
|
|
height: Math.round(Math.max(MIN_CROP_SIZE, Math.min(bottom - top, source.height))),
|
|
};
|
|
}
|
|
|
|
export function zoomAround(view, nextZoom, anchor) {
|
|
const zoom = clamp(nextZoom, 0.2, 6);
|
|
const ratio = zoom / view.zoom;
|
|
return {
|
|
zoom,
|
|
panX: anchor.x - (anchor.x - view.panX) * ratio,
|
|
panY: anchor.y - (anchor.y - view.panY) * ratio,
|
|
};
|
|
}
|