Every card and DOM panel was added with options.serialize = false, which only keeps a widget out of the queued prompt. The frontend saves each widget whose own serialize is not false and hands saved values back by position, so each card and panel saved an empty value of its own after the node's real values, and an input appended to the node later opened holding it (Stitch Inpaint's seam, Crop For Inpaint's keep_inside). Every DOM widget now also sets widget.serialize = false, as the frontend's own previews do, so new saves carry only real values. Older saves still end in those empty values, and loading them now skips the cards and panels. On every node that saves by position they come after all real values (Save Image's card sits mid-list, but Save Image saves by name), so nothing moves. On frontend 1.53.6 the examples (the 21 from before the trim and the 7 now) and 14 older saved workflows queue byte-identical prompts before and after, keep every value, and save and reopen cleanly. An input appended later still needs a fallback for those older saves. tests/saved_widget_values.test.mjs checks that every DOM widget sets the flag, that the examples keep their values with the panels skipped, and that each input appended to a node whose older saves end in an empty value is declared and, as a widget input, has a card resetUnknown fallback that fires. tests/test_card_fallbacks.py (ComfyUI's Python) checks each fallback is the input's own default. AGENTS.md states the rule.
2147 lines
117 KiB
JavaScript
2147 lines
117 KiB
JavaScript
import { api } from "/scripts/api.js";
|
|
import { app } from "/scripts/app.js";
|
|
import { hideInputsInDef, hideWidget } from "./widget_visibility.mjs";
|
|
import { mountTransformTrim } from "./transform_trim.mjs";
|
|
import { clipOutputRate, inputNumber } from "./clip_rate.mjs";
|
|
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "./index.mjs";
|
|
import { fillNodeHeight } from "./panel_layout.mjs";
|
|
import { mediaViewQuery } from "./media_list.mjs";
|
|
import { createMediaPicker } from "./media_picker.mjs";
|
|
import { normalizeFillColor } from "./fill_color.mjs";
|
|
import { makeScrubInput } from "./scrub_input.mjs";
|
|
import { confirmDiscard } from "./discard_prompt.mjs";
|
|
import { featherGeneratedMask, overlayPlan, stitchBlendFromMask } from "./stitch_preview.mjs";
|
|
import {
|
|
INPUT_FOLDER_MODE,
|
|
LOCAL_PATH_MODE,
|
|
normalizeVideoOptions,
|
|
mediaSourceState,
|
|
videoSourceState,
|
|
} from "./video_source_card.mjs";
|
|
import {
|
|
canvasLocalPoint,
|
|
clamp,
|
|
cropForRotation,
|
|
declaredTransformDefaults,
|
|
fitSourceToAspect,
|
|
cropHandleCenters,
|
|
lockPadding,
|
|
lockedPadMinimum,
|
|
nearestHandle,
|
|
paddingAxis,
|
|
paddingHandleCenters,
|
|
parseAspectRatio,
|
|
resetTransformValues,
|
|
resizeCrop,
|
|
resolveCrop,
|
|
resolvePadding,
|
|
rotatedSize,
|
|
scaleToMegapixels,
|
|
sizeChain,
|
|
sizeChainTokens,
|
|
sourceChanged,
|
|
sourceResetValues,
|
|
stageHandleLayout,
|
|
zoomAround,
|
|
} from "./transform_geometry.mjs";
|
|
import { clampFrame, clipInfo, frameTime } from "./timeline_math.mjs";
|
|
import { liftSocket } from "./widget_card.mjs";
|
|
|
|
const HIDDEN_WIDGETS = [
|
|
"image", "upload",
|
|
"video", "source_mode", "local_path",
|
|
"rotation_degrees", "crop_aspect_ratio", "crop_x", "crop_y", "crop_width", "crop_height",
|
|
"pad_left", "pad_top", "pad_right", "pad_bottom", "feather", "canvas_multiple", "fill_color",
|
|
"seek_mode", "frame_index", "frame_time",
|
|
"start_seconds", "end_seconds", "every_nth", "max_frames", "frame_snap", "fixed_frames",
|
|
// Stitch settings (clip and image nodes), driven by the editor's Inpaint & Stitch section.
|
|
"stitch_blend", "stitch_grow",
|
|
// Image-node resize block; hideWidget on a missing widget is a no-op, so
|
|
// the video node sharing this list is unaffected.
|
|
"resize_to_megapixels", "megapixels", "resize_method", "resolution_steps",
|
|
];
|
|
const trimInputDriven = (node, name) => node.inputs?.some(
|
|
(input) => (input.name === name || input.widget?.name === name) && input.link != null,
|
|
) ?? false;
|
|
|
|
const RESIZE_METHODS = ["lanczos", "area", "bicubic", "bilinear", "nearest-exact"];
|
|
const TRANSFORM_DEFAULTS = resetTransformValues(false);
|
|
const CORE_IMAGE_PREVIEW_WIDGET = "$$canvas-image-preview";
|
|
|
|
function installStyles() {
|
|
if (document.getElementById("ausboss-transform-styles")) return;
|
|
const style = document.createElement("style");
|
|
style.id = "ausboss-transform-styles";
|
|
style.textContent = `
|
|
.ausboss-transform-panel{display:flex;flex-direction:column;gap:8px;padding:8px;color:#ddd;font:12px system-ui;box-sizing:border-box;width:100%;height:100%;overflow:hidden}
|
|
.ausboss-transform-preview{width:100%;flex:1 1 180px;min-height:0;border:1px solid #50555b;border-radius:8px;background:#111;display:block;touch-action:none}
|
|
.ausboss-transform-source{display:flex;flex-direction:column;gap:7px;flex:0 0 auto;padding:8px;border:1px solid rgba(0,184,174,.28);border-radius:8px;background:rgba(0,0,0,.24)}
|
|
.ausboss-transform-source-heading{color:${BRAND};font:600 10px system-ui;letter-spacing:.08em;text-transform:uppercase}
|
|
.ausboss-transform-source-mode{display:grid;grid-template-columns:1fr 1fr;height:30px;padding:3px;border:1px solid #2a3437;border-radius:7px;background:#0f1516}
|
|
.ausboss-transform-source-mode button{border:0;border-radius:5px;background:transparent;color:#8ba3a1;font:600 12px system-ui;cursor:pointer}
|
|
.ausboss-transform-source-mode button:hover{color:#fff}.ausboss-transform-source-mode button.on{background:${BRAND};color:#04201d}
|
|
.ausboss-transform-source-field{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}
|
|
.ausboss-transform-source-field select,.ausboss-transform-source-field input{box-sizing:border-box;width:100%;height:34px;min-width:0;padding:0 10px;border:1px solid #2a3437;border-radius:7px;outline:0;background:#0b0f10;color:#dce9e8;font:12px ui-monospace,"SF Mono",Menlo,Consolas,monospace}
|
|
.ausboss-transform-source-field select:focus,.ausboss-transform-source-field input:focus{border-color:${BRAND}}
|
|
.ausboss-transform-source-field .ausboss-media-pick{box-sizing:border-box;width:100%;height:34px;min-width:0;padding:0 28px 0 10px;border:1px solid #2a3437;border-radius:7px;background:#0b0f10 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238ba3a1' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 10px center;color:#dce9e8;font:12px ui-monospace,"SF Mono",Menlo,Consolas,monospace}
|
|
.ausboss-transform-source-field .ausboss-media-pick:hover,.ausboss-transform-source-field .ausboss-media-pick:focus-visible{border-color:${BRAND};outline:0}
|
|
.ausboss-transform-source-action{height:34px;box-sizing:border-box;white-space:nowrap}
|
|
.ausboss-transform-source-field:has(input[type=text]){grid-template-columns:minmax(0,1fr)}
|
|
.ausboss-transform-source-hint{overflow:hidden;color:#6f8886;font-size:10.5px;line-height:1.25;white-space:nowrap;text-overflow:ellipsis}
|
|
.lg-node:has(.ausboss-transform-panel) .image-preview{display:none!important}
|
|
.ausboss-transform-row{display:flex;gap:7px;align-items:center;flex:0 0 auto}.ausboss-transform-row>*{min-width:0;flex:1}
|
|
.ausboss-transform-canvas-row{justify-content:space-between}
|
|
.ausboss-transform-canvas-row>label{flex:0 0 auto;display:flex;align-items:center;gap:6px;color:#aeb4ba;font-size:11px;white-space:nowrap;cursor:default;user-select:none}
|
|
.ausboss-transform-canvas-row>label>span{color:#8ca8a5;font-weight:600;font-size:10px;letter-spacing:.06em;text-transform:uppercase}
|
|
.ausboss-transform-swatch{width:30px;height:22px;padding:1px;border:1px solid #555b63;border-radius:5px;background:#23272c;cursor:pointer}
|
|
.ausboss-transform-swatch::-webkit-color-swatch-wrapper{padding:1px}.ausboss-transform-swatch::-webkit-color-swatch{border:0;border-radius:3px}
|
|
.ausboss-transform-canvas-row input[type=checkbox]{accent-color:${BRAND};margin:0;cursor:pointer}
|
|
.ausboss-transform-resize-row{justify-content:flex-start;gap:14px}
|
|
.ausboss-transform-readout{flex:0 0 auto;margin-top:-3px;color:#c9d0d6;font:11px/14px system-ui;font-variant-numeric:tabular-nums;text-align:center;overflow:hidden;max-height:42px;cursor:default;user-select:none}
|
|
.ausboss-transform-readout:empty{display:none}
|
|
.ausboss-transform-readout span{color:#8ca8a5}.ausboss-transform-readout b{color:#fff;font-weight:600}
|
|
.ausboss-transform-readout i{display:block;color:#ffc46b;font-style:normal}
|
|
.ausboss-transform-aspects{display:flex;gap:5px;align-items:center;flex:0 0 auto}
|
|
.ausboss-transform-aspects>span{flex:0 0 auto;color:#8ca8a5;font-size:10px;padding:0 3px;user-select:none}
|
|
.ausboss-transform-aspect{flex:1 1 0;min-width:0;background:#262a30;color:#cfd6dc;border:1px solid #4a5058;border-radius:4px;height:28px;padding:3px 2px;font:600 10px system-ui;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:pointer;text-align:center}
|
|
.ausboss-transform-aspect-flip{flex:0 0 30px;display:flex;align-items:center;justify-content:center;margin-right:3px}
|
|
.ausboss-transform-aspect-glyph{display:block;border:1px solid currentColor;border-radius:1px;box-sizing:border-box}
|
|
.ausboss-transform-aspect:hover{border-color:${BRAND};color:#fff}
|
|
.ausboss-transform-aspect.active{background:rgba(0,184,174,.18);border-color:${BRAND};color:#e5fffc}
|
|
.ausboss-transform-aspect.locked{background:rgba(0,184,174,.34);border-color:#e5fffc;color:#fff}
|
|
.ausboss-transform-aspect-lock{display:inline-block;margin-left:3px;vertical-align:-1px;line-height:0}
|
|
.ausboss-transform-button,.ausboss-transform-modal button{background:#30343a;color:#eee;border:1px solid #555b63;border-radius:5px;padding:7px 10px;cursor:pointer}
|
|
.ausboss-transform-button:hover,.ausboss-transform-modal button:hover{border-color:${BRAND};background:#383e44}
|
|
.ausboss-transform-file{position:relative;text-align:center;overflow:hidden}.ausboss-transform-file input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
|
|
.ausboss-transform-modal{position:fixed;inset:0;z-index:100000;background:#101214;color:#e6e8ea;font:13px system-ui;display:grid;grid-template-rows:42px minmax(0,1fr) auto}
|
|
.ausboss-transform-header{display:flex;align-items:center;gap:12px;padding:0 12px;border-bottom:1px solid #30343a;background:#17191c}
|
|
.ausboss-transform-header strong{color:#fff}.ausboss-transform-header .spacer{flex:1}
|
|
.ausboss-transform-close{background:${BRAND}!important;border-color:${BRAND}!important;color:#06231f!important;font-weight:600}
|
|
.ausboss-transform-close:hover{filter:brightness(1.15)}
|
|
.ausboss-transform-danger{background:#4a1717!important;border-color:#a13a3a!important;color:#ffd9d9!important}
|
|
.ausboss-transform-danger:hover{background:#6b1f1f!important;border-color:#c74e4e!important}
|
|
.ausboss-transform-body{display:grid;grid-template-columns:270px minmax(320px,1fr) 250px;min-height:0}
|
|
.ausboss-transform-sidebar{padding:12px;border-right:1px solid #30343a;overflow:auto;background:#181b1e}
|
|
.ausboss-transform-sidebar.right{border-right:0;border-left:1px solid #30343a}
|
|
.ausboss-transform-section{border-bottom:1px solid #34383d;padding:0 0 13px;margin:0 0 13px}
|
|
.ausboss-transform-section h3{font-size:11px;color:${BRAND};text-transform:uppercase;margin:0 0 8px;display:flex;align-items:center;gap:7px}
|
|
.ausboss-legend{display:inline-block;flex:0 0 auto;width:10px;height:10px}
|
|
canvas.ausboss-legend{width:16px;height:16px}
|
|
.ausboss-legend-crop{background:#4bd8ef;border:1px solid #08272d}
|
|
.ausboss-legend-pad{background:#ff9d42;border:1px solid #3b2108;width:9px;height:9px;transform:rotate(45deg)}
|
|
.ausboss-final-preview{display:block;max-width:100%;margin:2px auto 0;border:1px solid #34383d;border-radius:6px;background:#0c0e10}
|
|
.ausboss-transform-section label{display:grid;grid-template-columns:88px 1fr 58px;gap:7px;align-items:center;margin:7px 0}
|
|
.ausboss-transform-section label>.ausboss-scrub:last-child{width:58px}
|
|
.ausboss-transform-section label>*{min-width:0}
|
|
.ausboss-transform-section input,.ausboss-transform-section select{box-sizing:border-box;width:100%;background:#0e1012;color:#eee;border:1px solid #454b52;border-radius:4px;padding:5px}
|
|
.ausboss-transform-stage{position:relative;min-width:0;min-height:0;background-color:#0c0e10;background-image:radial-gradient(#292d31 1px,transparent 1px);background-size:18px 18px;overflow:hidden}
|
|
.ausboss-transform-canvas{width:100%;height:100%;display:block;touch-action:none}
|
|
.ausboss-transform-status{line-height:1.55;color:#b8bec5;white-space:pre-wrap}.ausboss-transform-help{line-height:1.55;color:#aeb4ba}
|
|
.ausboss-transform-sidebar.right .ausboss-transform-section{margin-top:13px}
|
|
.ausboss-transform-section details{margin:8px 0 0}.ausboss-transform-section summary{cursor:pointer;color:#8de0da;font-size:11px;text-transform:uppercase;letter-spacing:.06em;list-style:none;user-select:none}
|
|
.ausboss-transform-section summary::before{content:"▸";display:inline-block;width:12px;transition:transform .12s}.ausboss-transform-section details[open] summary::before{transform:rotate(90deg)}
|
|
.ausboss-transform-section input[type=checkbox]{width:auto;justify-self:start;accent-color:${BRAND}}
|
|
.ausboss-transform-timeline{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:8px 12px;border-top:1px solid #30343a;background:#17191c}
|
|
.ausboss-transform-timeline>.ausboss-transform-trim{flex-basis:100%}
|
|
.ausboss-transform-transport{display:flex;flex-wrap:wrap;align-items:center;gap:8px;flex-basis:100%}
|
|
.ausboss-transform-steps{display:flex;gap:4px;flex-wrap:wrap}.ausboss-transform-steps button{padding:5px 7px}
|
|
.ausboss-transform-badge{padding:3px 7px;border-radius:99px;background:#263034;color:#8de0da;font-size:11px}
|
|
@media(max-width:900px){.ausboss-transform-body{grid-template-columns:220px minmax(260px,1fr)}.ausboss-transform-sidebar.right{display:none}}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function widget(node, name) { return node.widgets?.find((item) => item.name === name); }
|
|
function value(node, name, fallback = 0) { return widget(node, name)?.value ?? fallback; }
|
|
function setValue(node, name, next) {
|
|
const target = widget(node, name);
|
|
if (!target) return;
|
|
target.value = next;
|
|
target.callback?.(next);
|
|
}
|
|
function values(node) {
|
|
return Object.fromEntries(Object.keys(TRANSFORM_DEFAULTS).map((name) => [name, value(node, name, TRANSFORM_DEFAULTS[name])]));
|
|
}
|
|
|
|
function suppressCoreImagePreview(node) {
|
|
const previewIndex = node.widgets?.findIndex((item) => item.name === CORE_IMAGE_PREVIEW_WIDGET) ?? -1;
|
|
if (previewIndex >= 0) {
|
|
node.widgets[previewIndex].onRemove?.();
|
|
node.widgets.splice(previewIndex, 1);
|
|
}
|
|
if (node.__ausbossImgsSuppressed) return;
|
|
node.__ausbossImgsSuppressed = true;
|
|
node.__ausbossAddCustomWidget = node.addCustomWidget;
|
|
if (typeof node.addCustomWidget === "function") {
|
|
node.addCustomWidget = function (customWidget) {
|
|
if (customWidget?.name === CORE_IMAGE_PREVIEW_WIDGET) hideWidget(customWidget);
|
|
return node.__ausbossAddCustomWidget.call(this, customWidget);
|
|
};
|
|
}
|
|
node.__ausbossImgsDescriptor = Object.getOwnPropertyDescriptor(node, "imgs");
|
|
Object.defineProperty(node, "imgs", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() { return undefined; },
|
|
set() {},
|
|
});
|
|
}
|
|
|
|
function sourceKey(node, kind) {
|
|
if (kind === "image") return String(value(node, "image", ""));
|
|
return videoSourceState(
|
|
value(node, "source_mode", INPUT_FOLDER_MODE),
|
|
value(node, "video", ""),
|
|
value(node, "local_path", ""),
|
|
).key;
|
|
}
|
|
|
|
function parseInputReference(selection) {
|
|
const normalized = String(selection || "").replaceAll("\\", "/");
|
|
const parts = normalized.split("/");
|
|
const filename = parts.pop() || "";
|
|
return { filename, subfolder: parts.join("/"), type: "input" };
|
|
}
|
|
|
|
function imageSourceUrl(selection) {
|
|
const reference = parseInputReference(selection);
|
|
return api.apiURL(`/view?${new URLSearchParams(reference)}`);
|
|
}
|
|
|
|
function videoParams(node, maxSize = 1600) {
|
|
return new URLSearchParams({
|
|
source_mode: String(value(node, "source_mode", "input folder")),
|
|
video: String(value(node, "video", "")),
|
|
local_path: String(value(node, "local_path", "")),
|
|
seek_mode: String(value(node, "seek_mode", "frame index")),
|
|
frame_index: String(Math.max(0, Math.round(Number(value(node, "frame_index", 0)) || 0))),
|
|
frame_time: String(Math.max(0, Number(value(node, "frame_time", 0)) || 0)),
|
|
max_width: String(maxSize),
|
|
max_height: String(maxSize),
|
|
});
|
|
}
|
|
|
|
async function uploadMedia(node, kind, file) {
|
|
const body = new FormData();
|
|
body.append("image", file, file.name);
|
|
body.append("type", "input");
|
|
const route = kind === "video" ? "/ausboss/transform/video/upload" : "/upload/image";
|
|
const response = await api.fetchApi(route, { method: "POST", body });
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
let message = text;
|
|
try { message = JSON.parse(text).error || text; } catch {}
|
|
throw new Error(message || "Upload failed.");
|
|
}
|
|
const result = await response.json();
|
|
const selection = result.subfolder ? `${result.subfolder}/${result.name}` : result.name;
|
|
const target = widget(node, kind);
|
|
if (Array.isArray(target?.options?.values) && !target.options.values.includes(selection)) target.options.values.push(selection);
|
|
if (kind === "video") setValue(node, "source_mode", "input folder");
|
|
setValue(node, kind, selection);
|
|
return selection;
|
|
}
|
|
|
|
function resetTransform(node, includeTimeline = false) {
|
|
if (node.properties) { delete node.properties.ausboss_fit_aspect; delete node.properties.ausboss_aspect_lock; }
|
|
for (const [name, next] of Object.entries(declaredTransformDefaults(node.constructor?.nodeData, includeTimeline))) setValue(node, name, next);
|
|
node.setDirtyCanvas?.(true, true);
|
|
}
|
|
|
|
// The node face's Reset: only the shape - rotation, crop, padding. Fill,
|
|
// feather and Align stay, as they do when the source changes.
|
|
function resetGeometry(node) {
|
|
if (node.properties) { delete node.properties.ausboss_fit_aspect; delete node.properties.ausboss_aspect_lock; }
|
|
for (const [name, next] of Object.entries(sourceResetValues(false))) setValue(node, name, next);
|
|
node.setDirtyCanvas?.(true, true);
|
|
}
|
|
|
|
function createElement(tag, className, text) {
|
|
const element = document.createElement(tag);
|
|
if (className) element.className = className;
|
|
if (text !== undefined) element.textContent = text;
|
|
return element;
|
|
}
|
|
|
|
function addLabeledControl(section, title, control, suffix = "") {
|
|
const label = createElement("label");
|
|
label.append(createElement("span", "", title), control, createElement("span", "", suffix));
|
|
section.append(label);
|
|
return label;
|
|
}
|
|
|
|
function buildMediaSourceCard(state) {
|
|
const { node, kind } = state;
|
|
const root = createElement("div", "ausboss-transform-source");
|
|
const modes = createElement("div", "ausboss-transform-source-mode");
|
|
const uploadsMode = createElement("button", "", "Uploads");
|
|
const localMode = createElement("button", "", "Local path");
|
|
uploadsMode.type = localMode.type = "button";
|
|
uploadsMode.title = "Choose a video already in ComfyUI's input folder or upload another.";
|
|
localMode.title = "Read a video inside ComfyUI's input, output or temp folder in place, without copying it.";
|
|
modes.append(uploadsMode, localMode);
|
|
|
|
const field = createElement("div", "ausboss-transform-source-field");
|
|
// The media picker previews the hovered file, which a native <select> cannot.
|
|
const picker = createMediaPicker({
|
|
kind: kind === "video" ? "video" : "image",
|
|
placeholder: `Choose an uploaded ${kind}…`,
|
|
label: `Uploaded ${kind}`,
|
|
viewUrl: (name) => api.apiURL(`/view?${mediaViewQuery(name)}`),
|
|
getOptions: () => currentOptions(),
|
|
getValue: () => value(node, kind, ""),
|
|
onChange: (name) => {
|
|
setValue(node, kind, name);
|
|
sync();
|
|
notifyAusbossChange();
|
|
},
|
|
});
|
|
const localPath = createElement("input");
|
|
localPath.type = "text";
|
|
localPath.spellcheck = false;
|
|
localPath.placeholder = "/absolute/path/to/video.mp4";
|
|
localPath.setAttribute("aria-label", "Local video path");
|
|
const upload = createElement("label", "ausboss-transform-button ausboss-transform-file ausboss-transform-source-action");
|
|
const uploadText = createElement("span", "", "Upload");
|
|
upload.append(uploadText);
|
|
const fileInput = createElement("input");
|
|
fileInput.type = "file";
|
|
fileInput.accept = `${kind}/*`;
|
|
fileInput.setAttribute("aria-label", `Upload ${kind}`);
|
|
upload.append(fileInput);
|
|
const hint = createElement("div", "ausboss-transform-source-hint");
|
|
field.append(picker.element, upload);
|
|
root.append(kind === "image" ? createElement("div", "ausboss-transform-source-heading", "Image source") : modes, field, hint);
|
|
|
|
const currentOptions = () => {
|
|
const target = widget(node, kind);
|
|
let options = target?.options?.values;
|
|
if (typeof options === "function") options = options(target, node);
|
|
return normalizeVideoOptions(options, target?.value);
|
|
};
|
|
const sync = () => {
|
|
const source = mediaSourceState(
|
|
kind,
|
|
value(node, "source_mode", INPUT_FOLDER_MODE),
|
|
value(node, kind, ""),
|
|
value(node, "local_path", ""),
|
|
);
|
|
uploadsMode.classList.toggle("on", source.mode === INPUT_FOLDER_MODE);
|
|
localMode.classList.toggle("on", source.mode === LOCAL_PATH_MODE);
|
|
picker.refresh(value(node, kind, ""));
|
|
localPath.value = String(value(node, "local_path", ""));
|
|
field.replaceChildren();
|
|
if (source.mode === LOCAL_PATH_MODE) field.append(localPath);
|
|
else field.append(picker.element, upload);
|
|
hint.textContent = source.hint;
|
|
hint.title = source.mode === LOCAL_PATH_MODE
|
|
? `${source.hint} Only videos inside ComfyUI's input, output or temp folder can be read.`
|
|
: source.hint;
|
|
};
|
|
const chooseMode = (mode) => {
|
|
if (value(node, "source_mode", INPUT_FOLDER_MODE) === mode) return;
|
|
setValue(node, "source_mode", mode);
|
|
sync();
|
|
notifyAusbossChange();
|
|
};
|
|
uploadsMode.addEventListener("click", () => chooseMode(INPUT_FOLDER_MODE));
|
|
localMode.addEventListener("click", () => chooseMode(LOCAL_PATH_MODE));
|
|
const commitLocalPath = () => {
|
|
const next = localPath.value.trim();
|
|
if (next !== String(value(node, "local_path", ""))) {
|
|
setValue(node, "local_path", next);
|
|
notifyAusbossChange();
|
|
}
|
|
sync();
|
|
};
|
|
localPath.addEventListener("keydown", (event) => {
|
|
event.stopPropagation();
|
|
if (event.key === "Enter") localPath.blur();
|
|
if (event.key === "Escape") { localPath.value = String(value(node, "local_path", "")); localPath.blur(); }
|
|
});
|
|
localPath.addEventListener("blur", commitLocalPath);
|
|
fileInput.addEventListener("change", async () => {
|
|
if (!fileInput.files?.[0]) return;
|
|
fileInput.disabled = true;
|
|
uploadText.textContent = "Uploading…";
|
|
upload.setAttribute("aria-busy", "true");
|
|
try {
|
|
await uploadMedia(node, kind, fileInput.files[0]);
|
|
sync();
|
|
notifyAusbossChange();
|
|
} catch (error) {
|
|
showToast({ severity: "error", summary: "Crop + Rotate + Pad \u{1F18E}", detail: error.message, life: 8000 });
|
|
} finally {
|
|
fileInput.value = "";
|
|
fileInput.disabled = false;
|
|
uploadText.textContent = "Upload";
|
|
upload.removeAttribute("aria-busy");
|
|
}
|
|
});
|
|
root.addEventListener("pointerdown", (event) => {
|
|
if (event.target.closest("button,select,input,label")) event.stopPropagation();
|
|
});
|
|
state.syncSourceCard = sync;
|
|
sync();
|
|
return root;
|
|
}
|
|
|
|
export function installTransformNode(node, kind, mountPanel = null) {
|
|
installStyles();
|
|
const state = {
|
|
node, kind, image: null, sourceWidth: 0, sourceHeight: 0, metadata: null,
|
|
modal: null, canvas: null, previewCanvas: null, render: null, panelRender: null, drag: null,
|
|
view: { zoom: 1, panX: 0, panY: 0 }, grid: false, ready: false,
|
|
source: sourceKey(node, kind), frameController: null, frameObjectUrl: null,
|
|
playbackTimer: null, playing: false, playbackSession: 0, disposed: false, loadSerial: 0,
|
|
imageIndex: null, imageTime: null,
|
|
};
|
|
node.__ausbossTransformState = state;
|
|
state.isClip = Boolean(widget(node, "start_seconds") && widget(node, "end_seconds"));
|
|
state.trimViews = new Set();
|
|
if (state.isClip) {
|
|
// The executor includes every input in its cache key, even when
|
|
// IS_CHANGED ignores it. Preview position is UI state, not clip input.
|
|
for (const [name, constant] of [["seek_mode", "frame index"], ["frame_index", 0], ["frame_time", 0]]) {
|
|
widget(node, name).serializeValue = () => constant;
|
|
}
|
|
}
|
|
if (kind === "image") suppressCoreImagePreview(node);
|
|
if (kind === "video") installVideoDrop(state);
|
|
for (const name of HIDDEN_WIDGETS) hideWidget(widget(node, name));
|
|
liftSocket(node, "fixed_frames");
|
|
|
|
const panel = createElement("div", "ausboss-transform-panel");
|
|
const preview = createElement("canvas", "ausboss-transform-preview");
|
|
const row = createElement("div", "ausboss-transform-row");
|
|
const open = createElement("button", "ausboss-transform-button", "Open editor");
|
|
const resetCrop = createElement("button", "ausboss-transform-button", "Reset crop");
|
|
resetCrop.title = "Restore the full source crop; keep rotation, padding and timeline.";
|
|
resetCrop.addEventListener("click", () => {
|
|
setValue(node, "crop_aspect_ratio", "free");
|
|
if (node.properties) { delete node.properties.ausboss_fit_aspect; node.properties.ausboss_aspect_lock = false; }
|
|
fitCrop(state); updateModalInfo(state); notifyAusbossChange();
|
|
});
|
|
const reset = createElement("button", "ausboss-transform-button", "Reset");
|
|
reset.title = "Reset rotation, crop and padding. Fill, feather, Align and the timeline stay.";
|
|
reset.addEventListener("click", () => {
|
|
resetGeometry(node);
|
|
fitCrop(state); updateModalInfo(state); notifyAusbossChange();
|
|
});
|
|
row.append(open, resetCrop, reset);
|
|
panel.append(buildMediaSourceCard(state));
|
|
const readout = createElement("div", "ausboss-transform-readout");
|
|
state.readout = readout;
|
|
panel.append(preview, readout);
|
|
panel.append(buildAspectChipRow(state), buildAspectModeRow(state));
|
|
// Every transform node shows its canvas on the face - fill, feather and,
|
|
// where the node has it, the resize budget and step - so a wrong value is
|
|
// seen on the node, not discovered in the render. Both video nodes also
|
|
// get the timeline: the clip node trims with it, the frame picker scrubs.
|
|
panel.append(...buildCanvasRow(state));
|
|
if (kind === "video") panel.append(buildTrim(state));
|
|
panel.append(row);
|
|
state.previewCanvas = preview;
|
|
open.addEventListener("click", () => openEditor(state));
|
|
|
|
if (typeof node.addDOMWidget === "function" && mountPanel) {
|
|
mountPanel(node, panel);
|
|
} else if (typeof node.addDOMWidget === "function") {
|
|
const domWidget = node.addDOMWidget("ausboss_transform_preview", "ausboss_transform_preview", panel, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
});
|
|
keepDomWidgetWidthAuto(domWidget);
|
|
// Not saved with the workflow either: options.serialize only keeps it out
|
|
// of the prompt, and saved values come back by position.
|
|
domWidget.serialize = false;
|
|
fillNodeHeight(domWidget, { minWidth: 330, minHeight: state.isClip ? 602 : kind === "video" ? 510 : 296, minNodeSize: [330, state.isClip ? 802 : kind === "video" ? 570 : 456] });
|
|
} else {
|
|
node.addWidget?.("button", "Open editor", null, () => openEditor(state), { serialize: false });
|
|
}
|
|
const baseHeight = state.isClip ? 842 : kind === "video" ? 635 : 511;
|
|
node.setSize?.([
|
|
Math.max(330, Math.min(520, node.size?.[0] || 330)),
|
|
Math.max(baseHeight, node.computeSize?.()[1] || 0),
|
|
]);
|
|
if (typeof node.addDOMWidget === "function") {
|
|
// Redraw on wrapper size changes (node resize, zoom relayout);
|
|
// node.onResize is unreliable across frontends.
|
|
//
|
|
// This is not only about hit-testing. prepareCanvas() sizes the backing
|
|
// store from the element at DRAW time, and the canvas is CSS-stretched to
|
|
// its box, so a box that changes shape without a redraw displays the last
|
|
// frame at the wrong aspect. Both kinds need it: the video panel is a
|
|
// passive preview and has no other reason to redraw, which is exactly why
|
|
// it was the one that came out stretched.
|
|
state.panelResizeObserver = new ResizeObserver(() => draw(state));
|
|
state.panelResizeObserver.observe(preview);
|
|
// The graph scales the DOM widget with its zoom, so the backing store
|
|
// sized at one zoom turns to mush at another: redraw when it changes.
|
|
chainCallback(node, "onDrawForeground", function () {
|
|
if (state.isClip && !state.disposed) {
|
|
const rate = clipOutputRate(node, state.metadata?.fps, value(node, "every_nth", 1));
|
|
const linked = ["fixed_frames", "frame_load_cap", "max_frames", "start_frame", "end_frame", "start_seconds", "end_seconds", "every_nth"];
|
|
const signature = JSON.stringify([rate, ...linked.map(name => inputNumber(node, name, value(node, name, 0)))]);
|
|
if (signature !== state.trimRate) {
|
|
state.trimRate = signature;
|
|
for (const trim of state.trimViews) trim.sync();
|
|
}
|
|
}
|
|
if (state.disposed || state.zoomRedraw || Math.abs(panelOversample() - (state.panelOversample ?? 1)) < 0.01) return;
|
|
state.zoomRedraw = requestAnimationFrame(() => { state.zoomRedraw = null; if (!state.disposed) draw(state); });
|
|
});
|
|
}
|
|
if (typeof node.addDOMWidget === "function") {
|
|
// All transform nodes share inline handles. Wheel/middle-drag still
|
|
// belong to the graph; only direct handle gestures are captured.
|
|
state.panelInteractive = true;
|
|
state.panelAbort = new AbortController();
|
|
attachStageHandlers(state, preview, state.panelAbort.signal);
|
|
}
|
|
|
|
const watched = kind === "image" ? ["image"] : ["video", "source_mode", "local_path"];
|
|
for (const name of watched) {
|
|
const target = widget(node, name);
|
|
if (!target) continue;
|
|
chainCallback(target, "callback", function () {
|
|
state.syncSourceCard?.();
|
|
if (state.ready) onSourceChanged(state, true);
|
|
});
|
|
}
|
|
chainCallback(node, "onConnectionsChange", () => queueMicrotask(() => {
|
|
for (const trim of state.trimViews) trim.sync();
|
|
}));
|
|
chainCallback(node, "onConfigure", () => queueMicrotask(() => {
|
|
if (state.disposed) return;
|
|
for (const name of HIDDEN_WIDGETS) hideWidget(widget(node, name));
|
|
liftSocket(node, "fixed_frames");
|
|
state.syncSourceCard?.();
|
|
for (const trim of state.trimViews) trim.sync();
|
|
if (state.ready) onSourceChanged(state, false);
|
|
}));
|
|
queueMicrotask(async () => {
|
|
// Core's upload helper can add its button after our creation hook.
|
|
for (const name of HIDDEN_WIDGETS) hideWidget(widget(node, name));
|
|
liftSocket(node, "fixed_frames");
|
|
state.ready = true;
|
|
await onSourceChanged(state, false);
|
|
});
|
|
return state;
|
|
}
|
|
|
|
// Dropping a video file on the node uploads it and makes it the source,
|
|
// as core's upload widgets do for their own nodes. Core's canvas drop
|
|
// handler asks the node under the cursor first; a handled drop keeps it
|
|
// from spawning a separate Load Video node for the file.
|
|
const VIDEO_FILE_PATTERN = /\.(avi|m2ts|m4v|mkv|mov|mp4|mpeg|mpg|mts|webm)$/i;
|
|
|
|
function installVideoDrop(state) {
|
|
const node = state.node;
|
|
node.onDragOver = (event) => {
|
|
const items = event?.dataTransfer?.items;
|
|
return Boolean(items && Array.from(items).some((item) => item.kind === "file"));
|
|
};
|
|
node.onDragDrop = async (event) => {
|
|
const file = Array.from(event?.dataTransfer?.files ?? []).find(
|
|
(candidate) => String(candidate.type).startsWith("video/") || VIDEO_FILE_PATTERN.test(candidate.name),
|
|
);
|
|
if (!file) return false;
|
|
try {
|
|
await uploadMedia(node, "video", file);
|
|
state.syncSourceCard?.();
|
|
if (state.ready) await onSourceChanged(state, true);
|
|
notifyAusbossChange();
|
|
} catch (error) {
|
|
showToast({ severity: "error", summary: "Crop + Rotate + Pad \u{1F18E}", detail: error.message, life: 8000 });
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
// Format chips right under the preview: one tap pads the whole source to
|
|
// that aspect with centered fill bands (the editor's Pad to aspect), so an
|
|
// outpaint canvas is a single click. Tapping the lit chip clears it again.
|
|
const ASPECT_CHIP_ORDER = ["1:1", "4:3", "3:2", "16:9", "21:9"];
|
|
|
|
function buildAspectChipRow(state) {
|
|
const node = state.node;
|
|
const row = createElement("div", "ausboss-transform-aspects");
|
|
const flip = createElement("button", "ausboss-transform-aspect ausboss-transform-aspect-flip");
|
|
flip.type = "button";
|
|
const glyph = createElement("span", "ausboss-transform-aspect-glyph");
|
|
flip.append(glyph);
|
|
row.append(flip);
|
|
const caption = createElement("span", "", "Ratio");
|
|
caption.title = "Choose the target ratio, then use Crop or Pad below. Crop locks the crop shape without adding padding.";
|
|
row.append(caption);
|
|
const portrait = () => {
|
|
const [w, h] = String(node.properties?.ausboss_fit_aspect ?? "").split(":").map(Number);
|
|
return w && h && w !== h ? h > w : Boolean(node.properties?.ausboss_pad_portrait);
|
|
};
|
|
const oriented = (aspect) => portrait() ? aspect.split(":").reverse().join(":") : aspect;
|
|
flip.addEventListener("click", () => {
|
|
const next = !portrait();
|
|
const current = String(node.properties?.ausboss_fit_aspect ?? "");
|
|
node.properties ??= {};
|
|
node.properties.ausboss_pad_portrait = next;
|
|
if (state.image && /^\d+:\d+$/.test(current) && current !== "1:1") {
|
|
fitAspect(state, current.split(":").reverse().join(":"), aspectMode(state));
|
|
}
|
|
sync();
|
|
notifyAusbossChange();
|
|
});
|
|
// Three taps on one chip: pad to the format, lock it, clear it.
|
|
const chips = [];
|
|
for (const aspect of ASPECT_CHIP_ORDER) {
|
|
const chip = createElement("button", "ausboss-transform-aspect", aspect);
|
|
chip.type = "button";
|
|
chip.addEventListener("click", () => {
|
|
if (!state.image) return;
|
|
node.properties ??= {};
|
|
node.properties.ausboss_pad_portrait = portrait();
|
|
const ratio = oriented(aspect);
|
|
if (String(node.properties.ausboss_fit_aspect ?? "") !== ratio) fitAspect(state, ratio, aspectMode(state));
|
|
else if (!node.properties.ausboss_aspect_lock) setAspectLock(state, true);
|
|
else clearAspect(state);
|
|
sync();
|
|
});
|
|
chips.push({ chip, aspect }); row.append(chip);
|
|
}
|
|
const sync = () => {
|
|
const current = String(node.properties?.ausboss_fit_aspect ?? "");
|
|
const locked = Boolean(node.properties?.ausboss_aspect_lock);
|
|
flip.title = `${portrait() ? "Portrait" : "Landscape"} — click to flip to ${portrait() ? "landscape" : "portrait"}`;
|
|
flip.setAttribute("aria-label", flip.title);
|
|
flip.setAttribute("aria-pressed", String(portrait()));
|
|
glyph.style.width = portrait() ? "10px" : "16px";
|
|
glyph.style.height = portrait() ? "16px" : "10px";
|
|
for (const { chip, aspect } of chips) {
|
|
const ratio = oriented(aspect);
|
|
const active = ratio === current;
|
|
chip.replaceChildren(document.createTextNode(ratio));
|
|
if (active && locked) chip.append(lockGlyph());
|
|
chip.title = !active
|
|
? aspectMode(state) === "crop" ? `Crop to ${ratio}: lock the crop shape without padding.` : `Pad to ${ratio}: keep every source pixel and add centered fill bands.`
|
|
: locked
|
|
? `Locked to ${ratio} in ${aspectMode(state)} mode. Tap to clear.`
|
|
: `Padded to ${ratio}. Tap again to lock the format for crop and padding drags.`;
|
|
chip.classList.toggle("active", active);
|
|
chip.classList.toggle("locked", active && locked);
|
|
chip.setAttribute("aria-pressed", String(active));
|
|
}
|
|
};
|
|
sync();
|
|
state.syncAspectChips = sync;
|
|
return row;
|
|
}
|
|
|
|
function aspectMode(state) {
|
|
return state.node.properties?.ausboss_aspect_mode === "crop" ? "crop" : "pad";
|
|
}
|
|
|
|
function buildAspectModeRow(state) {
|
|
const row = createElement("div", "ausboss-transform-row ausboss-transform-aspect-modes");
|
|
const buttons = [];
|
|
for (const mode of ["crop", "pad"]) {
|
|
const button = createElement("button", "ausboss-transform-aspect", mode === "crop" ? "Crop" : "Pad");
|
|
button.type = "button";
|
|
button.title = mode === "crop" ? "Fit and lock the crop to the selected ratio; no padding." : "Keep the whole source and pad to the selected ratio.";
|
|
button.addEventListener("click", () => {
|
|
state.node.properties ??= {};
|
|
state.node.properties.ausboss_aspect_mode = mode;
|
|
const ratio = state.node.properties.ausboss_fit_aspect;
|
|
if (ratio && ratio !== "free") fitAspect(state, ratio, mode);
|
|
draw(state); notifyAusbossChange();
|
|
});
|
|
buttons.push([mode, button]); row.append(button);
|
|
}
|
|
const alignment = createElement("label", "ausboss-transform-alignment");
|
|
alignment.style.cssText = "display:flex;align-items:center;gap:5px;flex:0 0 118px";
|
|
const multiple = makeScrubInput({ value: value(state.node, "canvas_multiple", 1),
|
|
min: 1, max: 4096, step: 8, fineStep: 1, decimals: 0, width: 82, unit: "px",
|
|
title: "Align the output canvas to a pixel multiple (1 disables). Adds pixels on the right/bottom when needed.",
|
|
onChange: (amount) => { setValue(state.node, "canvas_multiple", amount); draw(state); updateModalInfo(state); },
|
|
onSettle: notifyAusbossChange });
|
|
alignment.append(createElement("span", "", "Align"), multiple.root);
|
|
row.append(alignment);
|
|
state.syncAspectMode = () => {
|
|
multiple.set(value(state.node, "canvas_multiple", 1));
|
|
for (const [mode, button] of buttons) {
|
|
button.classList.toggle("active", aspectMode(state) === mode);
|
|
button.setAttribute("aria-pressed", String(aspectMode(state) === mode));
|
|
}
|
|
};
|
|
state.syncAspectMode();
|
|
return row;
|
|
}
|
|
|
|
// A padlock drawn by hand: shackle arc over a filled body.
|
|
function lockGlyph() {
|
|
const glyph = createElement("span", "ausboss-transform-aspect-lock");
|
|
glyph.innerHTML = '<svg width="9" height="11" viewBox="0 0 9 11" aria-hidden="true"><path d="M2.2 5V3.4a2.3 2.3 0 0 1 4.6 0V5" fill="none" stroke="currentColor" stroke-width="1.3"/><rect x="1" y="4.8" width="7" height="5.4" rx="1.2" fill="currentColor"/></svg>';
|
|
return glyph;
|
|
}
|
|
|
|
// --- Aspect lock ----------------------------------------------------------
|
|
// The locked format is the chip row's / target select's aspect
|
|
// (properties.ausboss_fit_aspect) with properties.ausboss_aspect_lock on.
|
|
// Every handle gesture then re-solves the padding so crop plus padding keep
|
|
// that ratio (lockPadding, transform_geometry.mjs).
|
|
function lockRatio(state) {
|
|
const properties = state.node.properties;
|
|
if (aspectMode(state) === "crop" || !properties?.ausboss_aspect_lock || !state.sourceWidth || !state.sourceHeight) return null;
|
|
const source = rotatedSize(state.sourceWidth, state.sourceHeight, value(state.node, "rotation_degrees", 0));
|
|
return parseAspectRatio(String(properties.ausboss_fit_aspect ?? ""), source);
|
|
}
|
|
|
|
function applyAspectLock(state, driver = "x") {
|
|
const ratio = lockRatio(state);
|
|
if (!ratio) return false;
|
|
const source = rotatedSize(state.sourceWidth, state.sourceHeight, value(state.node, "rotation_degrees", 0));
|
|
const current = values(state.node);
|
|
const pads = lockPadding(current, resolveCrop(current, source), ratio, driver);
|
|
if (!pads) return false;
|
|
for (const [name, next] of Object.entries(pads)) setValue(state.node, name, next);
|
|
return true;
|
|
}
|
|
|
|
function setAspectLock(state, on) {
|
|
state.node.properties ??= {};
|
|
state.node.properties.ausboss_aspect_lock = Boolean(on);
|
|
if (aspectMode(state) === "crop") setValue(state.node, "crop_aspect_ratio", on ? state.node.properties.ausboss_fit_aspect : "free");
|
|
else if (on) applyAspectLock(state, "x");
|
|
draw(state); updateModalInfo(state); notifyAusbossChange();
|
|
}
|
|
|
|
function clearAspect(state) {
|
|
fitAspect(state, "free", aspectMode(state));
|
|
if (state.node.properties) { delete state.node.properties.ausboss_fit_aspect; state.node.properties.ausboss_aspect_lock = false; }
|
|
draw(state); updateModalInfo(state); notifyAusbossChange();
|
|
}
|
|
|
|
// Which axis a crop drag drove, for the lock: the one that changed more,
|
|
// the handle's own axis on a tie.
|
|
function cropDriver(before, after, handle) {
|
|
const dx = Math.abs(after.width - before.width);
|
|
const dy = Math.abs(after.height - before.height);
|
|
if (dx !== dy) return dx > dy ? "x" : "y";
|
|
return /[ns]/.test(handle) && !/[ew]/.test(handle) ? "y" : "x";
|
|
}
|
|
|
|
// The video nodes' canvas row under the format chips: fill swatch, feather
|
|
// amount and the resize budget - the three values a video outpaint model
|
|
// keys on (LTX's IC-LoRA wants pure black, a hard edge and 32-px sizes),
|
|
// editable on the face and mirrored from the hidden widgets on every draw.
|
|
function buildCanvasRow(state) {
|
|
const node = state.node;
|
|
const video = state.kind === "video";
|
|
const row = createElement("div", "ausboss-transform-row ausboss-transform-canvas-row");
|
|
const fillLabel = createElement("label");
|
|
const fill = createElement("input"); fill.type = "color"; fill.className = "ausboss-transform-swatch";
|
|
fill.title = video
|
|
? "Fill colour of the padding and rotation corners. Video outpaint models key on it: the LTX IC-LoRA paints pure black (#000000) and leaves other colours alone."
|
|
: "Fill colour of the padding and rotation corners.";
|
|
fill.addEventListener("input", () => { setValue(node, "fill_color", fill.value); draw(state); });
|
|
fill.addEventListener("change", () => notifyAusbossChange());
|
|
fillLabel.append(createElement("span", "", "Fill"), fill);
|
|
const featherLabel = createElement("label");
|
|
const feather = makeScrubInput({ value: value(node, "feather", 0), min: 0, max: 4096, step: 1, decimals: 0, width: 62, unit: "px",
|
|
title: video
|
|
? "Feather of the mask into the kept frames, in pixels. The frames keep a hard edge against the fill whatever the feather, so a black-band outpaint still sees its bars."
|
|
: "Feather of the mask into the kept pixels; the image keeps a hard edge against the fill.",
|
|
onChange: (amount) => { setValue(node, "feather", amount); draw(state); updateModalInfo(state); }, onSettle: notifyAusbossChange });
|
|
featherLabel.append(createElement("span", "", "Feather"), feather.root);
|
|
const resizeLabel = createElement("label");
|
|
const resize = createElement("input"); resize.type = "checkbox";
|
|
resize.title = "Resize the output to a megapixel budget, each side rounded to the step (32 for LTX and Wan). The budget and step open in a row below.";
|
|
resize.addEventListener("change", () => {
|
|
setValue(node, "resize_to_megapixels", resize.checked);
|
|
draw(state); updateModalInfo(state); notifyAusbossChange();
|
|
});
|
|
resizeLabel.append(createElement("span", "", "Resize"), resize);
|
|
row.append(fillLabel, featherLabel);
|
|
// The resize budget and its step share a row that only shows while
|
|
// Resize is on: the step decides the final size as much as the budget
|
|
// (LTX wants 32), so it belongs on the face, not only in the editor.
|
|
const resizeRow = createElement("div", "ausboss-transform-row ausboss-transform-canvas-row ausboss-transform-resize-row");
|
|
const budgetLabel = createElement("label");
|
|
const budget = makeScrubInput({ value: value(node, "megapixels", 1), min: 0.01, max: 16, step: 0.05, fineStep: 0.01, decimals: 2, width: 72, unit: "MP",
|
|
title: "Output budget in megapixels (x 1024x1024). The size readout under the picture shows where it lands.",
|
|
onChange: (amount) => { setValue(node, "megapixels", amount); draw(state); updateModalInfo(state); }, onSettle: notifyAusbossChange });
|
|
budgetLabel.append(createElement("span", "", "Megapixels"), budget.root);
|
|
const stepLabel = createElement("label");
|
|
const steps = makeScrubInput({ value: value(node, "resolution_steps", 1), min: 1, max: 256, step: 8, fineStep: 1, decimals: 0, width: 66, unit: "px",
|
|
title: "Rounds each resized side to a multiple of this (32 for LTX and Wan, 8 or 64 for image models). Each side rounds on its own, so a large step can stretch the picture slightly; the readout warns above 1%.",
|
|
onChange: (amount) => { setValue(node, "resolution_steps", amount); draw(state); updateModalInfo(state); }, onSettle: notifyAusbossChange });
|
|
stepLabel.append(createElement("span", "", "Step"), steps.root);
|
|
resizeRow.append(budgetLabel, stepLabel);
|
|
const hasResize = Boolean(widget(node, "resize_to_megapixels"));
|
|
if (hasResize) row.append(resizeLabel);
|
|
const sync = () => {
|
|
fill.value = normalizeColor(value(node, "fill_color", "#808080"));
|
|
fill.title = `${fill.title.split(" Now ")[0]} Now ${fill.value}.`;
|
|
feather.set(value(node, "feather", 0));
|
|
resize.checked = Boolean(value(node, "resize_to_megapixels", false));
|
|
budget.set(value(node, "megapixels", 1));
|
|
steps.set(value(node, "resolution_steps", 1));
|
|
resizeRow.style.display = hasResize && resize.checked ? "" : "none";
|
|
};
|
|
sync();
|
|
state.syncCanvasRow = sync;
|
|
chainCallback(node, "onConfigure", () => queueMicrotask(sync));
|
|
for (const element of [row, resizeRow]) {
|
|
element.addEventListener("pointerdown", (event) => { if (event.target.closest("input,label")) event.stopPropagation(); });
|
|
}
|
|
return [row, resizeRow];
|
|
}
|
|
|
|
// The size chain in words under the stage: every step that sets the output
|
|
// size, the one the run emits last and brightest, and an amber line when the
|
|
// steps stretch the picture or the resize undoes Align. Its tooltip spells
|
|
// the whole chain out. A DOM line rather than canvas text, so it wraps on a
|
|
// narrow node and never covers the picture being judged.
|
|
function syncReadout(state) {
|
|
const readout = state.readout;
|
|
if (!readout) return;
|
|
if (!state.image || !state.sourceWidth || !state.sourceHeight) { readout.replaceChildren(); readout.title = ""; return; }
|
|
const current = values(state.node);
|
|
const source = rotatedSize(state.sourceWidth, state.sourceHeight, current.rotation_degrees);
|
|
const { tokens, warnings } = sizeChainTokens(sizeChain(current, source, resizeRequest(state.node)));
|
|
const parts = [];
|
|
tokens.forEach((token, index) => {
|
|
if (index) parts.push(" → ");
|
|
if (token.label) parts.push(createElement("span", "", `${token.label} `));
|
|
parts.push(index === tokens.length - 1 ? createElement("b", "", token.text) : token.text);
|
|
});
|
|
if (warnings.length) parts.push(createElement("i", "", `⚠ ${warnings.join(" · ")}`));
|
|
readout.replaceChildren(...parts);
|
|
readout.title = sizeLines(state).join("\n");
|
|
}
|
|
|
|
async function onSourceChanged(state, reset) {
|
|
const key = sourceKey(state.node, state.kind);
|
|
const changed = reset && sourceChanged(state.source, key, state.ready);
|
|
if (changed) {
|
|
// Geometry measured against the old pixels goes; fill, feather, the
|
|
// resize budget and the lit format chip stay - swapping the clip in an
|
|
// outpaint workflow used to reset the fill to grey and the feather to
|
|
// 24, which is exactly what the model cannot work with.
|
|
for (const [name, next] of Object.entries(sourceResetValues(state.kind === "video"))) setValue(state.node, name, next);
|
|
if (state.isClip) { setValue(state.node, "start_seconds", 0); setValue(state.node, "end_seconds", 0); }
|
|
resetView(state);
|
|
}
|
|
if (key) state.source = key;
|
|
await loadSource(state);
|
|
if (changed && state.image) refitAspect(state);
|
|
}
|
|
|
|
// A lit format chip is a standing request: the new source gets padded to
|
|
// it as well, so the canvas keeps its format across clips.
|
|
function refitAspect(state) {
|
|
const aspect = String(state.node.properties?.ausboss_fit_aspect ?? "");
|
|
if (!/^\d+:\d+$/.test(aspect)) return;
|
|
fitAspect(state, aspect, aspectMode(state));
|
|
}
|
|
|
|
async function loadSource(state) {
|
|
const serial = ++state.loadSerial;
|
|
try {
|
|
if (state.kind === "image") {
|
|
const selection = value(state.node, "image", "");
|
|
if (!selection) {
|
|
state.image = null; state.sourceWidth = state.sourceHeight = 0;
|
|
drawEmpty(state, "Choose a source to begin"); return;
|
|
}
|
|
const image = new Image();
|
|
await new Promise((resolve, reject) => {
|
|
image.onload = resolve; image.onerror = () => reject(new Error("Could not load image preview."));
|
|
image.src = imageSourceUrl(selection);
|
|
});
|
|
if (serial !== state.loadSerial || state.disposed) return;
|
|
state.image = image; state.sourceWidth = image.naturalWidth; state.sourceHeight = image.naturalHeight;
|
|
// Core's image-upload helper also installs a source preview. This node has
|
|
// its own transformed preview, so keep only the useful one.
|
|
suppressCoreImagePreview(state.node);
|
|
state.node.imageIndex = null;
|
|
} else {
|
|
const key = sourceKey(state.node, "video");
|
|
if (!key) { state.image = null; state.metadata = null; state.metadataKey = null; drawEmpty(state, "Choose a source to begin"); return; }
|
|
if (state.metadataKey !== key) {
|
|
const metaResponse = await api.fetchApi(`/ausboss/transform/video/metadata?${videoParams(state.node)}`);
|
|
const metadata = await metaResponse.json();
|
|
if (!metaResponse.ok) throw new Error(metadata.error || "Could not read video metadata.");
|
|
if (serial !== state.loadSerial) return;
|
|
state.metadata = metadata; state.metadataKey = key;
|
|
state.sourceWidth = metadata.width; state.sourceHeight = metadata.height;
|
|
state.storyboard = null; state.scrubPreviewTile = null; state.imageIndex = null; state.imageTime = null;
|
|
syncTimelineRange(state);
|
|
requestStoryboard(state, key);
|
|
}
|
|
await loadVideoFrame(state, serial);
|
|
}
|
|
draw(state); updateModalInfo(state);
|
|
} catch (error) {
|
|
if (serial !== state.loadSerial || state.disposed) return;
|
|
if (error?.name === "AbortError") return;
|
|
state.image = null;
|
|
drawEmpty(state, error.message);
|
|
}
|
|
}
|
|
|
|
function syncTimelineRange(state) {
|
|
for (const trim of state.trimViews) trim.sync();
|
|
if (!state.timelineLabel) return;
|
|
const info = clipInfo(state.metadata);
|
|
state.timelineLabel.textContent = `${clampFrame(value(state.node, "frame_index", 0), info)} / ${Math.max(0, info.count - 1)}`;
|
|
}
|
|
|
|
// Storyboard: a keyframe thumbnail strip the server builds once per file in
|
|
// the background. While it exists, dragging shows the nearest tile with zero
|
|
// network latency and the exact decoded frame replaces it a beat later.
|
|
// Best-effort — scrubbing works without it, just without the instant ghost.
|
|
async function requestStoryboard(state, key, attempt = 0) {
|
|
if (state.disposed || state.kind !== "video") return;
|
|
try {
|
|
const response = await api.fetchApi(`/ausboss/transform/video/storyboard?${videoParams(state.node)}`);
|
|
const payload = await response.json();
|
|
if (!response.ok || state.disposed || sourceKey(state.node, "video") !== key) return;
|
|
if (payload.status === "building") {
|
|
if (attempt < 40) setTimeout(() => requestStoryboard(state, key, attempt + 1), 1200);
|
|
return;
|
|
}
|
|
if (payload.status !== "ready") return;
|
|
const image = new Image();
|
|
await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = payload.sprite; });
|
|
if (state.disposed || sourceKey(state.node, "video") !== key) return;
|
|
state.storyboard = {
|
|
image, times: payload.times, count: payload.count,
|
|
tileWidth: payload.tile_width, tileHeight: payload.tile_height,
|
|
};
|
|
} catch { /* storyboard is an enhancement, never an error */ }
|
|
}
|
|
|
|
function showScrubGhost(state, frameIndex) {
|
|
const storyboard = state.storyboard;
|
|
if (!storyboard) return;
|
|
const info = clipInfo(state.metadata);
|
|
const moment = frameTime(frameIndex, info);
|
|
let tile = 0;
|
|
for (let index = 0; index < storyboard.times.length; index += 1) {
|
|
if (Math.abs(storyboard.times[index] - moment) < Math.abs(storyboard.times[tile] - moment)) tile = index;
|
|
}
|
|
// The tile stands in only while it is nearer the target than the frame
|
|
// already on the stage. A keyframe seconds away is worse than a slightly
|
|
// stale picture, and swapping between the two on every pointer move was
|
|
// the flicker: with one keyframe per clip the ghost was always frame 0.
|
|
const tileGap = Math.abs(storyboard.times[tile] - moment);
|
|
const imageGap = Number.isFinite(state.imageTime) ? Math.abs(state.imageTime - moment) : Infinity;
|
|
const next = tileGap + 0.5 / Math.max(1, info.fps || 30) < imageGap ? tile : null;
|
|
if (next === state.scrubPreviewTile) return;
|
|
state.scrubPreviewTile = next;
|
|
draw(state);
|
|
}
|
|
|
|
// Frame-only refresh for discrete jumps (step buttons, drag release):
|
|
// full-resolution fetch that also snaps the widgets to the decoded frame.
|
|
async function seekFrame(state) {
|
|
try {
|
|
await loadVideoFrame(state);
|
|
draw(state); updateModalInfo(state);
|
|
} catch (error) {
|
|
if (error?.name === "AbortError") return;
|
|
drawEmpty(state, error.message);
|
|
}
|
|
}
|
|
|
|
// Live scrubbing pump. Video players feel responsive because they always
|
|
// render *something* for the newest position instead of waiting for quiet.
|
|
// This keeps exactly one request in flight, fires the first one immediately
|
|
// (no debounce delay), and when a response lands it re-reads the widgets so
|
|
// the next fetch always targets the latest slider position — intermediate
|
|
// positions are skipped, never queued. Scrub frames are fetched at reduced
|
|
// size for fast decode+encode; the drag-release handler does one full-size
|
|
// fetch at the end.
|
|
const SCRUB_PREVIEW_SIZE = 640;
|
|
|
|
function requestScrubFrame(state) {
|
|
state.scrubPending = true;
|
|
if (state.scrubActive) return;
|
|
state.scrubActive = true;
|
|
(async () => {
|
|
while (state.scrubPending && !state.disposed) {
|
|
state.scrubPending = false;
|
|
try {
|
|
await loadVideoFrame(state, ++state.loadSerial, { maxSize: SCRUB_PREVIEW_SIZE, syncWidgets: false });
|
|
draw(state); updateModalInfo(state);
|
|
} catch (error) {
|
|
if (error?.name !== "AbortError") { drawEmpty(state, error.message); break; }
|
|
}
|
|
}
|
|
state.scrubActive = false;
|
|
})();
|
|
}
|
|
|
|
async function loadVideoFrame(state, serial = ++state.loadSerial, options = {}) {
|
|
const { maxSize = 1600, syncWidgets = true } = options;
|
|
state.frameController?.abort();
|
|
const controller = new AbortController();
|
|
state.frameController = controller;
|
|
const response = await api.fetchApi(`/ausboss/transform/video/frame?${videoParams(state.node, maxSize)}`, { signal: controller.signal });
|
|
if (!response.ok) {
|
|
const payload = await response.json().catch(() => ({}));
|
|
throw new Error(payload.error || "Could not decode video preview frame.");
|
|
}
|
|
const blob = await response.blob();
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const image = new Image();
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
image.onload = resolve; image.onerror = () => reject(new Error("Could not display video frame.")); image.src = objectUrl;
|
|
});
|
|
if (serial !== state.loadSerial) return;
|
|
if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
|
|
state.frameObjectUrl = objectUrl; state.image = image;
|
|
state.scrubPreviewTile = null; // real frame arrived; drop the ghost tile
|
|
const actualIndex = Number(response.headers.get("X-AusBoss-Frame-Index"));
|
|
const actualTime = Number(response.headers.get("X-AusBoss-Frame-Time"));
|
|
// Where the picture on the stage comes from: the ghost rule compares
|
|
// storyboard tiles against it.
|
|
state.imageIndex = Number.isFinite(actualIndex) ? actualIndex : null;
|
|
state.imageTime = Number.isFinite(actualTime) ? actualTime : null;
|
|
// Writing the decoded position back is only safe when the user is not
|
|
// mid-scrub: a stale response overwriting frame_index would rubber-band
|
|
// the playhead to an older frame.
|
|
if (syncWidgets) {
|
|
if (Number.isFinite(actualIndex)) setValue(state.node, "frame_index", actualIndex);
|
|
if (Number.isFinite(actualTime)) setValue(state.node, "frame_time", actualTime);
|
|
syncTimelineRange(state);
|
|
}
|
|
} catch (error) {
|
|
URL.revokeObjectURL(objectUrl);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function openEditor(state) {
|
|
if (state.modal) return;
|
|
const modal = createElement("div", "ausboss-transform-modal");
|
|
const header = createElement("div", "ausboss-transform-header");
|
|
header.append(createElement("strong", "", `${state.kind === "video" ? "Video" : "Image"} Crop + Rotate + Pad`), createElement("span", "ausboss-transform-badge", "AusBoss"));
|
|
header.append(createElement("span", "spacer"));
|
|
const cancel = createElement("button", "", "Cancel");
|
|
cancel.title = "Close without saving: puts back everything as it was when the editor opened (Esc)";
|
|
const close = createElement("button", "ausboss-transform-close", "Save & close"); header.append(cancel, close);
|
|
const body = createElement("div", "ausboss-transform-body");
|
|
const left = createElement("aside", "ausboss-transform-sidebar");
|
|
const stage = createElement("main", "ausboss-transform-stage");
|
|
const canvas = createElement("canvas", "ausboss-transform-canvas"); stage.append(canvas);
|
|
const right = createElement("aside", "ausboss-transform-sidebar right");
|
|
body.append(left, stage, right); modal.append(header, body);
|
|
state.modal = modal; state.canvas = canvas; state.openSnapshot = editorSnapshot(state.node);
|
|
buildControls(state, left);
|
|
const status = createElement("div", "ausboss-transform-status"); status.dataset.ausbossStatus = ""; right.append(status);
|
|
right.append(createElement("div", "ausboss-transform-help", "Drag cyan squares to crop. Drag inside the crop to move it. Orange diamonds add padding. The rotate knob at the top-right corner rotates; hold Shift to snap to 15 degrees. Wheel zooms. Middle mouse or Alt-drag pans."));
|
|
if (widget(state.node, "stitch_blend")) right.append(buildStitchSection(state));
|
|
if (state.kind === "video") modal.append(buildTimeline(state));
|
|
document.body.append(modal);
|
|
|
|
const abort = new AbortController(); state.modalAbort = abort;
|
|
close.addEventListener("click", () => closeEditor(state), { signal: abort.signal });
|
|
cancel.addEventListener("click", () => void cancelEditor(state), { signal: abort.signal });
|
|
attachStageHandlers(state, canvas, abort.signal);
|
|
canvas.addEventListener("wheel", (event) => wheelZoom(state, event), { signal: abort.signal, passive: false });
|
|
window.addEventListener("keydown", (event) => keyDown(state, event), { signal: abort.signal });
|
|
window.addEventListener("keyup", (event) => keyUp(state, event), { signal: abort.signal });
|
|
state.resizeObserver = new ResizeObserver(() => draw(state)); state.resizeObserver.observe(stage);
|
|
requestAnimationFrame(() => { resetView(state); draw(state); updateModalInfo(state); });
|
|
}
|
|
|
|
function closeEditor(state) {
|
|
const hadModal = Boolean(state.modal);
|
|
stopPlayback(state);
|
|
state.scrubPending = false;
|
|
state.modalAbort?.abort(); state.resizeObserver?.disconnect(); state.modal?.remove();
|
|
if (state.modalTrim) state.trimViews.delete(state.modalTrim);
|
|
state.modalTrim = null; state.timelineLabel = null;
|
|
state.modal = null; state.openSnapshot = null; state.canvas = null; state.finalPreviewCanvas = null; state.drag = null; state.grid = false; state.syncEditorControls = null; state.syncStitchControls = null; state.blendOverlay = null;
|
|
draw(state); state.node.setDirtyCanvas?.(true, true);
|
|
// Sidebar and timeline controls write widgets without a canvas drag, so a
|
|
// closing editor is their commit point. The disposal path (node removed,
|
|
// possibly mid-load teardown) must never trigger a capture.
|
|
if (hadModal && !state.disposed) notifyAusbossChange();
|
|
}
|
|
|
|
// Cancel is the editor's undo-everything: the controls write the node's
|
|
// widgets live, so the snapshot taken at open is what a cancel puts back.
|
|
// Properties carry the aspect lock and pad/crop mode; the preview DOM widget
|
|
// is display only.
|
|
function editorSnapshot(node) {
|
|
const widgets = {};
|
|
for (const item of node.widgets ?? []) {
|
|
if (!item.name || item.name === "ausboss_transform_preview") continue;
|
|
const current = item.value;
|
|
if (current === null || ["string", "number", "boolean"].includes(typeof current)) widgets[item.name] = current;
|
|
}
|
|
const properties = {};
|
|
for (const [key, current] of Object.entries(node.properties ?? {})) {
|
|
if (key.startsWith("ausboss_")) properties[key] = JSON.stringify(current);
|
|
}
|
|
return { widgets, properties };
|
|
}
|
|
|
|
// The clip node's playhead never reaches its output, so looking around the
|
|
// clip alone is not an edit worth a prompt. A discard still puts it back.
|
|
const CLIP_PLAYHEAD_WIDGETS = new Set(["seek_mode", "frame_index", "frame_time"]);
|
|
|
|
function editorChanged(state) {
|
|
const before = state.openSnapshot; if (!before) return false;
|
|
const now = editorSnapshot(state.node);
|
|
const keys = (a, b) => new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
for (const name of keys(before.widgets, now.widgets)) {
|
|
if (state.isClip && CLIP_PLAYHEAD_WIDGETS.has(name)) continue;
|
|
if (!Object.is(before.widgets[name], now.widgets[name])) return true;
|
|
}
|
|
for (const key of keys(before.properties, now.properties)) if (before.properties[key] !== now.properties[key]) return true;
|
|
return false;
|
|
}
|
|
|
|
function restoreSnapshot(state, snapshot) {
|
|
const node = state.node;
|
|
node.properties ??= {};
|
|
for (const key of Object.keys(node.properties)) {
|
|
if (key.startsWith("ausboss_") && !(key in snapshot.properties)) delete node.properties[key];
|
|
}
|
|
for (const [key, text] of Object.entries(snapshot.properties)) node.properties[key] = JSON.parse(text);
|
|
// Only changed widgets go back, through their callbacks, so a source that
|
|
// never changed is not reloaded.
|
|
for (const [name, previous] of Object.entries(snapshot.widgets)) {
|
|
if (!Object.is(value(node, name, undefined), previous)) setValue(node, name, previous);
|
|
}
|
|
}
|
|
|
|
async function cancelEditor(state) {
|
|
if (!state.modal || state.discardPending) return;
|
|
if (editorChanged(state)) {
|
|
state.discardPending = true;
|
|
stopPlayback(state);
|
|
const discard = await confirmDiscard();
|
|
state.discardPending = false;
|
|
if (!discard || !state.modal) return;
|
|
restoreSnapshot(state, state.openSnapshot);
|
|
}
|
|
closeEditor(state);
|
|
}
|
|
|
|
// Inpaint & Stitch (clip and image nodes): the stitcher this node emits pastes
|
|
// the source back over the generated result. Blend is the ramp where generated
|
|
// pixels take over; it is deliberately separate from the padding feather,
|
|
// which shapes the mask the model sees. Grow moves the paste boundary and
|
|
// sits behind a disclosure - most outpaints never touch it.
|
|
function buildStitchSection(state) {
|
|
const node = state.node;
|
|
const section = createElement("section", "ausboss-transform-section");
|
|
section.append(sectionHeading("Inpaint & Stitch"));
|
|
section.append(createElement("div", "ausboss-transform-help", "Outside the paste mask the stitcher puts the source back bit-for-bit. Inside it - the padded and rotated-in area plus the blend ramp - the generation takes over."));
|
|
const blend = makeScrubInput({ value: value(node, "stitch_blend", 32), min: 0, max: 512, step: 1, decimals: 0,
|
|
title: "Ramp where generated pixels fade over the source, in pixels of the output. Separate from the padding feather.",
|
|
onChange: (amount) => { setValue(node, "stitch_blend", amount); draw(state); updateModalInfo(state); }, onSettle: notifyAusbossChange });
|
|
addLabeledControl(section, "Blend", blend.root, "px");
|
|
const show = createElement("input"); show.type = "checkbox"; show.checked = Boolean(state.showBlend);
|
|
show.title = "Tint the paste mask the stitcher will use - the same mask math as the backend, at preview resolution.";
|
|
show.addEventListener("change", () => { state.showBlend = show.checked; draw(state); });
|
|
addLabeledControl(section, "Show blend", show);
|
|
const advanced = createElement("details"); advanced.append(createElement("summary", "", "Advanced"));
|
|
const grow = makeScrubInput({ value: value(node, "stitch_grow", 0), min: -256, max: 256, step: 1, decimals: 0,
|
|
title: "Moves the paste boundary before the ramp. Positive lets the generation replace a strip of the source next to the seam; negative keeps more source.",
|
|
onChange: (amount) => { setValue(node, "stitch_grow", amount); draw(state); updateModalInfo(state); }, onSettle: notifyAusbossChange });
|
|
addLabeledControl(advanced, "Grow paste", grow.root, "px");
|
|
advanced.append(createElement("div", "ausboss-transform-help", "Use a few pixels of grow when a seam still shows: the generation then repaints the source edge too."));
|
|
section.append(advanced);
|
|
state.syncStitchControls = () => { blend.set(value(node, "stitch_blend", 32)); grow.set(value(node, "stitch_grow", 0)); };
|
|
return section;
|
|
}
|
|
|
|
// Show blend: the stitcher's paste mask, computed the way the backend
|
|
// computes it, at preview resolution. The generated-area mask (padding and
|
|
// rotation voids, everything the rotated source does not cover inside the
|
|
// crop) is rasterised on the pre-resize canvas, feathered like the
|
|
// transform, then grown and blurred by the stitch settings converted from
|
|
// output pixels through any resize - the same steps as
|
|
// stitch_blend_from_mask, mirrored in stitch_preview.mjs and tested against
|
|
// the Python helper. Cached on its inputs: a drag that changes geometry
|
|
// rebuilds it, a pan or zoom does not.
|
|
function blendOverlayCanvas(state, render) {
|
|
const node = state.node;
|
|
const { source, crop, padding } = render;
|
|
const rotation = Number(value(node, "rotation_degrees", 0)) || 0;
|
|
const feather = Math.max(0, Number(value(node, "feather", 0)) || 0);
|
|
const blend = Math.max(0, Number(value(node, "stitch_blend", 32)) || 0);
|
|
const grow = Number(value(node, "stitch_grow", 0)) || 0;
|
|
const resize = value(node, "resize_to_megapixels", false)
|
|
? scaleToMegapixels(padding.outputWidth, padding.outputHeight, value(node, "megapixels", 1), value(node, "resolution_steps", 1))
|
|
: null;
|
|
const key = JSON.stringify([state.sourceWidth, state.sourceHeight, rotation, crop, padding, feather, blend, grow, resize]);
|
|
if (state.blendOverlay?.key === key) return state.blendOverlay.canvas;
|
|
const plan = overlayPlan(padding.outputWidth, padding.outputHeight, resize);
|
|
const { width, height, k } = plan;
|
|
const raster = document.createElement("canvas"); raster.width = width; raster.height = height;
|
|
const rc = raster.getContext("2d", { willReadFrequently: true });
|
|
rc.fillStyle = "#fff"; rc.fillRect(0, 0, width, height);
|
|
rc.save();
|
|
rc.beginPath(); rc.rect(padding.left * k, padding.top * k, crop.width * k, crop.height * k); rc.clip();
|
|
rc.translate((padding.left - crop.x) * k + source.width * k / 2, (padding.top - crop.y) * k + source.height * k / 2);
|
|
rc.rotate(rotation * Math.PI / 180);
|
|
rc.fillStyle = "#000"; rc.fillRect(-state.sourceWidth * k / 2, -state.sourceHeight * k / 2, state.sourceWidth * k, state.sourceHeight * k);
|
|
rc.restore();
|
|
const pixels = rc.getImageData(0, 0, width, height).data;
|
|
let mask = new Float32Array(width * height);
|
|
for (let i = 0; i < mask.length; i++) mask[i] = pixels[i * 4] / 255;
|
|
mask = featherGeneratedMask(mask, width, height, feather * k);
|
|
mask = stitchBlendFromMask(mask, width, height, blend * plan.unit, grow * plan.unit);
|
|
const overlay = document.createElement("canvas"); overlay.width = width; overlay.height = height;
|
|
const oc = overlay.getContext("2d"); const image = oc.createImageData(width, height); const data = image.data;
|
|
for (let i = 0; i < mask.length; i++) { data[i * 4] = 0; data[i * 4 + 1] = 184; data[i * 4 + 2] = 174; data[i * 4 + 3] = Math.round(mask[i] * 150); }
|
|
oc.putImageData(image, 0, 0);
|
|
state.blendOverlay = { key, canvas: overlay, mask, width, height };
|
|
return overlay;
|
|
}
|
|
|
|
function drawBlendOverlay(context, state, render) {
|
|
const overlay = blendOverlayCanvas(state, render);
|
|
const { outputRect } = render;
|
|
context.save(); context.imageSmoothingEnabled = true;
|
|
context.drawImage(overlay, outputRect.x, outputRect.y, outputRect.width, outputRect.height);
|
|
context.restore();
|
|
}
|
|
|
|
// Section headers carry a small marker matching the on-canvas handle for
|
|
// that group (cyan square = crop, green knob = rotate, orange diamond =
|
|
// padding), teaching the editor's color language without a word of text.
|
|
function legendMarker(kind) {
|
|
if (kind === "rotate") {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = canvas.height = 32; // drawn 2x, displayed at 16px for crispness
|
|
canvas.className = "ausboss-legend";
|
|
const context = canvas.getContext("2d");
|
|
context.fillStyle = "#73e36a";
|
|
context.beginPath(); context.arc(16, 16, 15, 0, Math.PI * 2); context.fill();
|
|
drawRotateGlyph(context, 16, 16, 7, "#0c2210");
|
|
return canvas;
|
|
}
|
|
return createElement("span", `ausboss-legend ausboss-legend-${kind}`);
|
|
}
|
|
|
|
function sectionHeading(title, markerKind = null) {
|
|
const heading = createElement("h3");
|
|
if (markerKind) heading.append(legendMarker(markerKind));
|
|
heading.append(createElement("span", "", title));
|
|
return heading;
|
|
}
|
|
|
|
function buildControls(state, sidebar) {
|
|
const node = state.node;
|
|
const cropSection = createElement("section", "ausboss-transform-section"); cropSection.append(sectionHeading("Crop", "crop"));
|
|
const ratio = createElement("select");
|
|
// Options come from the widget the backend registered, so custom presets
|
|
// from ausboss_presets.json appear here automatically.
|
|
const ratioWidget = widget(node, "crop_aspect_ratio");
|
|
let ratioValues = ratioWidget?.options?.values;
|
|
if (typeof ratioValues === "function") ratioValues = ratioValues(ratioWidget, node);
|
|
if (!Array.isArray(ratioValues) || !ratioValues.length) ratioValues = ["free", "source", "1:1", "9:16", "16:9", "2:3", "3:2", "3:4", "4:3", "9:21", "21:9"];
|
|
// The target is a UI preference, separate from the inner crop lock.
|
|
// Pad to aspect deliberately unlocks that inner crop to keep all pixels.
|
|
const currentRatio = String(node.properties?.ausboss_fit_aspect ?? value(node, "crop_aspect_ratio", "free"));
|
|
if (!ratioValues.includes(currentRatio)) ratioValues = [...ratioValues, currentRatio];
|
|
for (const optionValue of ratioValues) {
|
|
const option = createElement("option", "", optionValue); option.value = optionValue; ratio.append(option);
|
|
}
|
|
ratio.value = currentRatio;
|
|
ratio.setAttribute("aria-label", "Target aspect");
|
|
ratio.title = "Choose a target, then Crop or Pad. Changing the target alone does not alter your framing.";
|
|
ratio.addEventListener("change", () => {
|
|
node.properties ??= {}; node.properties.ausboss_fit_aspect = ratio.value;
|
|
if (node.properties.ausboss_aspect_lock) {
|
|
if (aspectMode(state) === "crop") {
|
|
setValue(node, "crop_aspect_ratio", ratio.value);
|
|
if (ratio.value === "free") node.properties.ausboss_aspect_lock = false;
|
|
} else if (ratio.value === "free") node.properties.ausboss_aspect_lock = false;
|
|
else applyAspectLock(state, "x");
|
|
}
|
|
draw(state); updateModalInfo(state);
|
|
notifyAusbossChange();
|
|
});
|
|
addLabeledControl(cropSection, "Target aspect", ratio);
|
|
const lock = createElement("input"); lock.type = "checkbox";
|
|
lock.title = "Crop mode locks the crop rectangle without padding. Pad mode adjusts padding to keep the output ratio.";
|
|
lock.addEventListener("change", () => {
|
|
if (lock.checked && ratio.value === "free") { lock.checked = false; return; }
|
|
node.properties ??= {};
|
|
if (lock.checked) node.properties.ausboss_fit_aspect = ratio.value;
|
|
setAspectLock(state, lock.checked);
|
|
});
|
|
addLabeledControl(cropSection, "Lock aspect", lock);
|
|
const fitRow = createElement("div", "ausboss-transform-row");
|
|
for (const [mode, title, tip] of [
|
|
["crop", "Crop to aspect", "Center the largest crop inside the rotated source. Removes pixels and resets padding."],
|
|
["pad", "Pad to aspect", "Keep the full rotated source. Add centered fill-color bands to reach the target aspect; no stretching or cropping."],
|
|
]) {
|
|
const button = createElement("button", "", title); button.title = tip;
|
|
button.addEventListener("click", () => fitAspect(state, ratio.value, mode)); fitRow.append(button);
|
|
}
|
|
cropSection.append(fitRow, createElement("div", "ausboss-transform-help", "Crop trims. Pad keeps the whole source. Both replace the current crop and padding; rotation stays. Free restores the full source."));
|
|
|
|
const rotateSection = createElement("section", "ausboss-transform-section"); rotateSection.append(sectionHeading("Rotate", "rotate"));
|
|
const rotation = createElement("input"); rotation.type = "range"; rotation.min = "-180"; rotation.max = "180"; rotation.step = "0.1"; rotation.value = value(node, "rotation_degrees", 0);
|
|
const rotationNumber = makeScrubInput({ value: Number(rotation.value), min: -180, max: 180, step: 1, fineStep: 0.1, decimals: 1,
|
|
title: "Rotation in degrees. Shift scrubs in tenths. The crop stays over the same part of the picture.",
|
|
onChange: (degrees) => setRotation(state, degrees), onSettle: () => { settleRotation(state); notifyAusbossChange(); } });
|
|
rotation.addEventListener("input", () => setRotation(state, Number(rotation.value)));
|
|
rotation.addEventListener("change", () => { settleRotation(state); notifyAusbossChange(); });
|
|
addLabeledControl(rotateSection, "Degrees", rotation, ""); rotateSection.append(rotationNumber.root);
|
|
const zeroRotation = createElement("button", "", "Reset rotation");
|
|
zeroRotation.addEventListener("click", () => { setRotation(state, 0); settleRotation(state); notifyAusbossChange(); });
|
|
rotateSection.append(zeroRotation);
|
|
|
|
const padSection = createElement("section", "ausboss-transform-section"); padSection.append(sectionHeading("Padding & mask", "pad"));
|
|
const color = createElement("input"); color.type = "color"; color.value = normalizeColor(value(node, "fill_color", "#808080")); color.addEventListener("input", () => { setValue(node, "fill_color", color.value); draw(state); });
|
|
addLabeledControl(padSection, "Fill", color);
|
|
// Feather: slider for coarse sweeps plus a number box (with up/down
|
|
// arrows) for granular single-pixel control.
|
|
const feather = createElement("input"); feather.type = "range"; feather.min = "0"; feather.max = "512"; feather.step = "1"; feather.value = value(node, "feather", 24);
|
|
const applyFeather = (raw) => {
|
|
const amount = Math.max(0, Math.min(4096, Math.round(Number(raw) || 0)));
|
|
setValue(node, "feather", amount); draw(state);
|
|
};
|
|
const featherNumber = makeScrubInput({ value: Number(feather.value), min: 0, max: 4096, step: 1, decimals: 0,
|
|
title: "Mask feather in pixels.", onChange: applyFeather, onSettle: notifyAusbossChange });
|
|
feather.addEventListener("input", () => applyFeather(feather.value));
|
|
const featherLabel = addLabeledControl(padSection, "Feather", feather); featherLabel.lastElementChild.replaceWith(featherNumber.root);
|
|
const multiple = makeScrubInput({ value: value(node, "canvas_multiple", 1), min: 1, max: 4096, step: 1, decimals: 0,
|
|
title: "Round the outer canvas up to a pixel multiple. This can slightly change the fitted aspect.",
|
|
onChange: (amount) => { setValue(node, "canvas_multiple", amount); draw(state); }, onSettle: notifyAusbossChange });
|
|
addLabeledControl(padSection, "Multiple", multiple.root, "px");
|
|
const resetPad = createElement("button", "", "Reset padding"); resetPad.title = "Remove all padding; a locked format is released.";
|
|
resetPad.addEventListener("click", () => { for (const name of ["pad_left", "pad_top", "pad_right", "pad_bottom"]) setValue(node, name, 0); if (node.properties) node.properties.ausboss_aspect_lock = false; draw(state); updateModalInfo(state); notifyAusbossChange(); }); padSection.append(resetPad);
|
|
|
|
// Resize to a pixel budget (image and clip nodes): mirrors the core Scale
|
|
// Image to Total Pixels trio - megapixels, method, resolution steps -
|
|
// so the output lands render-ready without another node.
|
|
let resizeSection = null;
|
|
if (widget(node, "resize_to_megapixels")) {
|
|
resizeSection = createElement("section", "ausboss-transform-section");
|
|
resizeSection.append(sectionHeading("Resize output"));
|
|
const enable = createElement("input"); enable.type = "checkbox";
|
|
enable.checked = Boolean(value(node, "resize_to_megapixels", false));
|
|
const applyResize = () => {
|
|
setValue(node, "resize_to_megapixels", enable.checked);
|
|
draw(state);
|
|
updateModalInfo(state);
|
|
};
|
|
enable.addEventListener("change", applyResize);
|
|
addLabeledControl(resizeSection, "Resize", enable);
|
|
const budget = makeScrubInput({
|
|
value: value(node, "megapixels", 1),
|
|
min: 0.01, max: 16, step: 0.05, fineStep: 0.01, decimals: 2,
|
|
title: "Output budget in megapixels (x 1024x1024).",
|
|
onChange: (amount) => {
|
|
setValue(node, "megapixels", amount);
|
|
draw(state);
|
|
updateModalInfo(state);
|
|
},
|
|
});
|
|
addLabeledControl(resizeSection, "Megapixels", budget.root, "MP");
|
|
const method = createElement("select");
|
|
for (const name of RESIZE_METHODS) {
|
|
const option = createElement("option", "", name); option.value = name; method.append(option);
|
|
}
|
|
method.value = String(value(node, "resize_method", "lanczos"));
|
|
if (!RESIZE_METHODS.includes(method.value)) method.value = "lanczos";
|
|
method.addEventListener("change", () => { setValue(node, "resize_method", method.value); });
|
|
addLabeledControl(resizeSection, "Method", method);
|
|
const steps = makeScrubInput({
|
|
value: value(node, "resolution_steps", 1),
|
|
min: 1, max: 256, step: 1, decimals: 0,
|
|
title: "Rounds each resized dimension to a multiple of this.",
|
|
onChange: (step) => {
|
|
setValue(node, "resolution_steps", step);
|
|
draw(state);
|
|
updateModalInfo(state);
|
|
},
|
|
});
|
|
addLabeledControl(resizeSection, "Steps", steps.root, "px");
|
|
}
|
|
|
|
const actions = createElement("section", "ausboss-transform-section"); actions.append(sectionHeading("View & reset"));
|
|
const resetViewButton = createElement("button", "", "Reset view"); resetViewButton.addEventListener("click", () => { resetView(state); draw(state); });
|
|
const resetAll = createElement("button", "ausboss-transform-danger", "Reset transform");
|
|
resetAll.title = "Reset rotation, crop, padding, fill, feather and Align. Keep the source, current frame, trim window, fixed length, resize and stitch settings.";
|
|
resetAll.addEventListener("click", () => { resetTransform(node); resetView(state); draw(state); updateModalInfo(state); });
|
|
actions.append(resetViewButton, resetAll);
|
|
|
|
// Live preview of the actual output composite (no overlays), so the final
|
|
// result is always visible while adjusting handles.
|
|
const previewSection = createElement("section", "ausboss-transform-section");
|
|
previewSection.append(sectionHeading("Preview"));
|
|
const finalPreview = createElement("canvas", "ausboss-final-preview");
|
|
previewSection.append(finalPreview);
|
|
state.finalPreviewCanvas = finalPreview;
|
|
|
|
sidebar.append(cropSection, rotateSection, padSection);
|
|
if (resizeSection) sidebar.append(resizeSection);
|
|
sidebar.append(actions, previewSection);
|
|
state.syncEditorControls = () => {
|
|
rotation.value = value(node, "rotation_degrees", 0); rotationNumber.set(Number(rotation.value));
|
|
feather.value = Math.min(512, value(node, "feather", 24)); featherNumber.set(value(node, "feather", 24));
|
|
multiple.set(value(node, "canvas_multiple", 1)); color.value = normalizeColor(value(node, "fill_color", "#808080"));
|
|
ratio.value = node.properties?.ausboss_fit_aspect ?? value(node, "crop_aspect_ratio", "free");
|
|
lock.checked = Boolean(node.properties?.ausboss_aspect_lock);
|
|
lock.disabled = ratio.value === "free";
|
|
};
|
|
}
|
|
|
|
// Renders what the node will actually output: fill background, the rotated
|
|
// source clipped to the crop, placed inside the padded canvas. No handles,
|
|
// no dashes - the composite itself.
|
|
function drawFinalPreview(state) {
|
|
const canvas = state.finalPreviewCanvas;
|
|
if (!canvas || !state.image || !state.sourceWidth || !state.sourceHeight) return;
|
|
const current = values(state.node);
|
|
const source = rotatedSize(state.sourceWidth, state.sourceHeight, current.rotation_degrees);
|
|
const crop = resolveCrop(current, source);
|
|
const padding = resolvePadding(current, crop);
|
|
const scale = Math.min(226 / padding.outputWidth, 260 / padding.outputHeight);
|
|
const width = Math.max(1, Math.round(padding.outputWidth * scale));
|
|
const height = Math.max(1, Math.round(padding.outputHeight * scale));
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
|
|
const pixelWidth = Math.round(width * dpr); const pixelHeight = Math.round(height * dpr);
|
|
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; }
|
|
const context = canvas.getContext("2d");
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
context.fillStyle = normalizeColor(current.fill_color);
|
|
context.fillRect(0, 0, width, height);
|
|
context.save();
|
|
context.beginPath();
|
|
context.rect(padding.left * scale, padding.top * scale, crop.width * scale, crop.height * scale);
|
|
context.clip();
|
|
context.translate(
|
|
(padding.left - crop.x) * scale + source.width * scale / 2,
|
|
(padding.top - crop.y) * scale + source.height * scale / 2
|
|
);
|
|
context.rotate((Number(current.rotation_degrees) || 0) * Math.PI / 180);
|
|
drawSourceImage(context, state, scale);
|
|
context.restore();
|
|
}
|
|
|
|
function buildTimeline(state) {
|
|
const timeline = createElement("div", "ausboss-transform-timeline");
|
|
const transport = createElement("div", "ausboss-transform-transport");
|
|
const label = createElement("span", "ausboss-transform-badge", "0 / 0");
|
|
label.title = "Playhead frame / last frame";
|
|
const steps = createElement("div", "ausboss-transform-steps");
|
|
const commands = [["|<", "first"], ["-100", -100], ["-50", -50], ["-25", -25], ["-1", -1], ["Play", "play"], ["+1", 1], ["+25", 25], ["+50", 50], ["+100", 100], [">|", "last"]];
|
|
for (const [text, command] of commands) {
|
|
const button = createElement("button", "", text); if (command === "play") state.playButton = button;
|
|
button.title = command === "play" ? "Play / pause (Space)" : typeof command === "number" ? `Step ${command > 0 ? "+" : ""}${command} frames (Arrow keys step 1, Shift 10)` : command === "first" ? "First frame (Home)" : "Last frame (End)";
|
|
button.addEventListener("click", () => timelineCommand(state, command)); steps.append(button);
|
|
}
|
|
transport.append(label, steps);
|
|
if (state.isClip) {
|
|
const marks = createElement("div", "ausboss-transform-steps");
|
|
for (const [text, command, tip] of [["Set IN", "setIn", "Put IN at the playhead (I)"], ["Set OUT", "setOut", "Put OUT at the playhead (O)"]]) {
|
|
const button = createElement("button", "", text); button.title = tip;
|
|
button.addEventListener("click", () => timelineCommand(state, command)); marks.append(button);
|
|
}
|
|
transport.append(marks);
|
|
}
|
|
timeline.append(transport, buildTrim(state));
|
|
state.timelineLabel = label;
|
|
state.modalTrim = [...state.trimViews].at(-1);
|
|
syncTimelineRange(state);
|
|
return timeline;
|
|
}
|
|
|
|
// One timeline component on every surface (node face, editor): the clip
|
|
// node's with IN/OUT handles, the frame picker's with the playhead alone.
|
|
function buildTrim(state) {
|
|
const control = mountTransformTrim({
|
|
get: (name, fallback) => value(state.node, name, fallback),
|
|
set: (name, next) => {
|
|
setValue(state.node, name, next);
|
|
for (const view of state.trimViews) if (view !== control) view.sync();
|
|
},
|
|
has: (name) => Boolean(widget(state.node, name)),
|
|
driven: (name) => trimInputDriven(state.node, name),
|
|
number: (name, fallback) => inputNumber(state.node, name, value(state.node, name, fallback)),
|
|
outputRate: () => clipOutputRate(state.node, state.metadata?.fps, value(state.node, "every_nth", 1)),
|
|
metadata: () => state.metadata,
|
|
trim: state.isClip,
|
|
onSeek: (frame, settled) => scrubTo(state, frame, settled),
|
|
onCommit: notifyAusbossChange,
|
|
});
|
|
state.trimViews.add(control);
|
|
return control.root;
|
|
}
|
|
|
|
function setPlayhead(state, index) {
|
|
const info = clipInfo(state.metadata);
|
|
const frame = clampFrame(index, info);
|
|
setValue(state.node, "seek_mode", "frame index");
|
|
setValue(state.node, "frame_index", frame);
|
|
setValue(state.node, "frame_time", frameTime(frame, info));
|
|
return frame;
|
|
}
|
|
|
|
// The playhead moved. While a gesture is live the pump fetches a reduced
|
|
// frame (one request in flight, the latest position wins) and a storyboard
|
|
// tile stands in when it is nearer; on release, one full-size fetch that
|
|
// also snaps the widgets to the decoded frame.
|
|
function scrubTo(state, index, settled) {
|
|
const frame = setPlayhead(state, index);
|
|
syncTimelineRange(state);
|
|
if (settled) { void seekFrame(state); return; }
|
|
showScrubGhost(state, frame);
|
|
requestScrubFrame(state);
|
|
}
|
|
|
|
// Light variant for continuous motion (playback, held arrow keys): reduced
|
|
// preview size, widgets still snapped since only the caller writes position.
|
|
async function seekFrameLight(state) {
|
|
try {
|
|
await loadVideoFrame(state, ++state.loadSerial, { maxSize: SCRUB_PREVIEW_SIZE });
|
|
draw(state); updateModalInfo(state);
|
|
} catch (error) {
|
|
if (error?.name === "AbortError") return;
|
|
drawEmpty(state, error.message);
|
|
}
|
|
}
|
|
|
|
async function timelineCommand(state, command, light = false) {
|
|
if (command === "play") { state.playing ? stopPlayback(state) : startPlayback(state); return; }
|
|
const info = clipInfo(state.metadata);
|
|
if (!info.count) return;
|
|
const current = clampFrame(value(state.node, "frame_index", 0), info);
|
|
if (command === "setIn" || command === "setOut") {
|
|
if (!state.isClip) return;
|
|
// The rail knows which way the length is set (OUT, a Length, a
|
|
// connected input) and what that edge may do.
|
|
const trim = [...state.trimViews][0];
|
|
if (!trim) return;
|
|
const edge = command === "setIn" ? "start" : "end";
|
|
const window = trim.window();
|
|
// In free trim a mark past the other edge takes that edge along.
|
|
if (trim.plan()?.mode === "free") {
|
|
if (edge === "start" && current > window.last) trim.moveEdge("end", current, false);
|
|
if (edge === "end" && current < window.first) trim.moveEdge("start", current, false);
|
|
}
|
|
trim.moveEdge(edge, current, true);
|
|
syncTimelineRange(state);
|
|
return;
|
|
}
|
|
let next = current;
|
|
if (command === "first") next = 0; else if (command === "last") next = info.count - 1; else next += Number(command);
|
|
setPlayhead(state, next);
|
|
syncTimelineRange(state);
|
|
await (light ? seekFrameLight(state) : seekFrame(state));
|
|
}
|
|
|
|
// Playback correctness note: each tick awaits a frame fetch, and a Pause
|
|
// press usually lands during that await. The tick must therefore re-check
|
|
// after the await — against a session counter, not just a boolean — so a
|
|
// paused (or paused-then-restarted) loop's in-flight tick dies instead of
|
|
// re-scheduling itself as a zombie that can no longer be stopped.
|
|
function startPlayback(state) {
|
|
state.playButton.textContent = "Pause";
|
|
state.playing = true;
|
|
const session = (state.playbackSession = (state.playbackSession || 0) + 1);
|
|
const delay = Math.max(20, Math.round(1000 / Math.max(1, state.metadata?.fps || 30)));
|
|
const tick = async () => {
|
|
if (!state.playing || state.playbackSession !== session) return;
|
|
const before = Number(value(state.node, "frame_index", 0));
|
|
await timelineCommand(state, 1, true);
|
|
if (!state.playing || state.playbackSession !== session) return;
|
|
if (Number(value(state.node, "frame_index", 0)) === before) { stopPlayback(state); return; }
|
|
state.playbackTimer = window.setTimeout(tick, delay);
|
|
};
|
|
state.playbackTimer = window.setTimeout(tick, delay);
|
|
}
|
|
function stopPlayback(state) {
|
|
const wasPlaying = Boolean(state.playing);
|
|
state.playing = false;
|
|
state.playbackSession = (state.playbackSession || 0) + 1; // orphan in-flight ticks
|
|
if (state.playbackTimer) clearTimeout(state.playbackTimer);
|
|
state.playbackTimer = null;
|
|
if (state.playButton) state.playButton.textContent = "Play";
|
|
// Land on a full-resolution frame after light playback previews.
|
|
if (wasPlaying) void seekFrame(state);
|
|
}
|
|
|
|
function keyDown(state, event) {
|
|
if (!state.modal || ["INPUT", "SELECT", "TEXTAREA"].includes(event.target?.tagName)) return;
|
|
if (state.discardPending) return;
|
|
if (event.key === "Escape") { void cancelEditor(state); return; }
|
|
if (state.kind === "video" && event.code === "Space") { event.preventDefault(); timelineCommand(state, "play"); }
|
|
if (state.kind === "video" && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
|
|
// Light fetches while the key repeats; keyup lands a full-size frame.
|
|
event.preventDefault(); timelineCommand(state, (event.shiftKey ? 10 : 1) * (event.key === "ArrowLeft" ? -1 : 1), true);
|
|
}
|
|
if (state.kind === "video" && (event.key === "Home" || event.key === "End")) {
|
|
event.preventDefault(); timelineCommand(state, event.key === "Home" ? "first" : "last");
|
|
}
|
|
if (state.isClip && (event.key === "i" || event.key === "I" || event.key === "o" || event.key === "O") && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
event.preventDefault(); timelineCommand(state, event.key.toLowerCase() === "i" ? "setIn" : "setOut");
|
|
}
|
|
}
|
|
|
|
function keyUp(state, event) {
|
|
if (!state.modal || state.kind !== "video") return;
|
|
if (event.key === "ArrowLeft" || event.key === "ArrowRight") void seekFrame(state);
|
|
}
|
|
|
|
function fitCrop(state) {
|
|
setValue(state.node, "crop_x", 0); setValue(state.node, "crop_y", 0); setValue(state.node, "crop_width", 0); setValue(state.node, "crop_height", 0); draw(state);
|
|
}
|
|
function fitAspect(state, aspect, mode) {
|
|
if (!state.image || !state.sourceWidth || !state.sourceHeight) return;
|
|
const source = rotatedSize(state.sourceWidth, state.sourceHeight, value(state.node, "rotation_degrees", 0));
|
|
for (const [name, next] of Object.entries(fitSourceToAspect(source, aspect, mode))) setValue(state.node, name, next);
|
|
state.node.properties ??= {}; state.node.properties.ausboss_fit_aspect = aspect;
|
|
state.node.properties.ausboss_aspect_mode = mode;
|
|
state.node.properties.ausboss_aspect_lock = mode === "crop" && aspect !== "free";
|
|
if (aspect === "free") state.node.properties.ausboss_aspect_lock = false;
|
|
resetView(state); draw(state); updateModalInfo(state); notifyAusbossChange();
|
|
}
|
|
// Every rotation control (slider, number box, Reset rotation, the knob)
|
|
// comes through rotateTo, so they all keep the crop the same way
|
|
// (cropForRotation). The crop is carried from where the gesture began, not
|
|
// step by step: a sweep out and back returns the same crop, and a crop
|
|
// squeezed at a canvas edge mid-sweep grows back. The start is re-taken
|
|
// whenever anything but rotateTo changed the geometry since its last write.
|
|
const ROTATION_KEYS = [
|
|
"rotation_degrees", "crop_aspect_ratio", "crop_x", "crop_y", "crop_width", "crop_height",
|
|
"pad_left", "pad_top", "pad_right", "pad_bottom",
|
|
];
|
|
function rotationBase(state) {
|
|
const now = values(state.node);
|
|
const base = state.rotationBase;
|
|
if (base?.written && ROTATION_KEYS.every((name) => now[name] === base.written[name])) return base;
|
|
state.rotationBase = { rotation: Number(now.rotation_degrees) || 0, values: now, written: null };
|
|
return state.rotationBase;
|
|
}
|
|
function settleRotation(state) { state.rotationBase = null; }
|
|
function rotateTo(state, degrees) {
|
|
const base = rotationBase(state);
|
|
const next = Math.round(clamp(degrees, -180, 180) * 10) / 10;
|
|
setValue(state.node, "rotation_degrees", next);
|
|
if (state.sourceWidth && state.sourceHeight) {
|
|
const crop = cropForRotation(base.values, state.sourceWidth, state.sourceHeight, base.rotation, next);
|
|
for (const [name, amount] of Object.entries(crop)) setValue(state.node, name, amount);
|
|
// The lock re-solves padding from the gesture's starting pads.
|
|
for (const name of ["pad_left", "pad_top", "pad_right", "pad_bottom"]) setValue(state.node, name, base.values[name]);
|
|
}
|
|
applyAspectLock(state, "x");
|
|
base.written = values(state.node);
|
|
}
|
|
function setRotation(state, degrees) {
|
|
rotateTo(state, degrees); draw(state); updateModalInfo(state);
|
|
}
|
|
function resetView(state) { state.view = { zoom: 1, panX: 0, panY: 0 }; }
|
|
|
|
// Resolve CSS color names with the browser's own parser. An invalid
|
|
// assignment leaves fillStyle unchanged, so probing twice from different
|
|
// starting values separates "parsed" from "ignored".
|
|
let colorProbeContext = null;
|
|
function resolveCssColorName(name) {
|
|
try {
|
|
colorProbeContext ??= document.createElement("canvas").getContext("2d");
|
|
const context = colorProbeContext;
|
|
context.fillStyle = "#000000"; context.fillStyle = name;
|
|
const first = String(context.fillStyle);
|
|
context.fillStyle = "#ffffff"; context.fillStyle = name;
|
|
return first === String(context.fillStyle) && /^#[0-9a-f]{6}$/i.test(first) ? first : null;
|
|
} catch { return null; }
|
|
}
|
|
function normalizeColor(value) { return normalizeFillColor(value, resolveCssColorName); }
|
|
|
|
// The panel never zooms or pans: fit-only, so it cannot fight graph zoom.
|
|
const PANEL_VIEW = Object.freeze({ zoom: 1, panX: 0, panY: 0 });
|
|
|
|
// Geometry (source, crop, padding) is always read live from the widgets;
|
|
// the screen mapping is either fitted fresh or, mid-drag, the frozen map
|
|
// captured at pointerdown — a live refit would change the scale under the
|
|
// pointer and make the grabbed handle slip.
|
|
function renderGeometry(state, width, height, view, map = null) {
|
|
const source = rotatedSize(state.sourceWidth, state.sourceHeight, value(state.node, "rotation_degrees", 0));
|
|
const crop = resolveCrop(values(state.node), source); const padding = resolvePadding(values(state.node), crop);
|
|
const layout = map?.layout ?? stageHandleLayout(width, height);
|
|
let scale, originX, originY;
|
|
if (map) {
|
|
({ scale, originX, originY } = map);
|
|
} else {
|
|
const margin = layout.margin; const union = { x: Math.min(0, crop.x - padding.left), y: Math.min(0, crop.y - padding.top) };
|
|
union.width = Math.max(source.width, crop.x - padding.left + padding.outputWidth) - union.x;
|
|
union.height = Math.max(source.height, crop.y - padding.top + padding.outputHeight) - union.y;
|
|
const fit = Math.max(0.01, Math.min((width - margin * 2) / union.width, (height - margin * 2) / union.height));
|
|
scale = fit * view.zoom;
|
|
originX = (width - union.width * fit) / 2 - union.x * fit + view.panX;
|
|
originY = (height - union.height * fit) / 2 - union.y * fit + view.panY;
|
|
}
|
|
const rect = (x, y, w, h) => ({ x: originX + x * scale, y: originY + y * scale, width: w * scale, height: h * scale });
|
|
const sourceRect = rect(0, 0, source.width, source.height);
|
|
const cropRect = rect(crop.x, crop.y, crop.width, crop.height);
|
|
const outputRect = rect(crop.x - padding.left, crop.y - padding.top, padding.outputWidth, padding.outputHeight);
|
|
return { source, crop, padding, scale, originX, originY, layout, sourceRect, cropRect, outputRect };
|
|
}
|
|
|
|
// The node preview sits in a DOM widget the graph scales with its zoom, so
|
|
// its backing store is sized for the zoom in play (capped) - the same
|
|
// sharpness core's canvas-drawn image previews get for free.
|
|
const PREVIEW_MAX_OVERSAMPLE = 4;
|
|
function panelOversample() {
|
|
const zoom = Number(app.canvas?.ds?.scale) || 1;
|
|
return clamp(zoom, 1, PREVIEW_MAX_OVERSAMPLE);
|
|
}
|
|
|
|
function prepareCanvas(canvas, oversample = 1) {
|
|
const width = Math.max(1, canvas.clientWidth || 1); const height = Math.max(1, canvas.clientHeight || 1); const dpr = (window.devicePixelRatio || 1) * oversample;
|
|
const pixelWidth = Math.round(width * dpr); const pixelHeight = Math.round(height * dpr);
|
|
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; }
|
|
const context = canvas.getContext("2d"); context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); return { context, width, height };
|
|
}
|
|
|
|
function draw(state) {
|
|
syncReadout(state);
|
|
state.syncCanvasRow?.();
|
|
state.syncAspectChips?.();
|
|
state.syncAspectMode?.();
|
|
state.syncEditorControls?.();
|
|
state.syncStitchControls?.();
|
|
for (const canvas of [state.canvas, state.previewCanvas]) {
|
|
if (!canvas) continue;
|
|
const compact = canvas === state.previewCanvas;
|
|
if (compact) state.panelOversample = panelOversample();
|
|
const { context, width, height } = prepareCanvas(canvas, compact ? state.panelOversample : 1);
|
|
if (!state.image || !state.sourceWidth || !state.sourceHeight) {
|
|
if (compact) state.panelRender = null; else state.render = null;
|
|
drawEmptyCanvas(context, width, height, "Choose a source to begin"); continue;
|
|
}
|
|
const frozen = state.drag?.canvas === canvas ? state.drag.map : null;
|
|
const render = renderGeometry(state, width, height, compact ? PANEL_VIEW : state.view, frozen);
|
|
if (compact) state.panelRender = render; else state.render = render;
|
|
drawScene(context, state, render, compact, !compact || Boolean(state.panelInteractive));
|
|
}
|
|
drawFinalPreview(state);
|
|
keepStageRoom(state);
|
|
}
|
|
|
|
// The face's size readout and its resize row must not squeeze the stage:
|
|
// when the picture's box drops under this floor, the node grows by the
|
|
// difference, so a toggled Resize or a readout that wraps costs node
|
|
// height, not picture. Never shrinks a node.
|
|
const STAGE_FLOOR = 130;
|
|
function keepStageRoom(state) {
|
|
const height = state.previewCanvas?.clientHeight ?? 0;
|
|
const node = state.node;
|
|
if (state.disposed || !height || !node.size) return;
|
|
if (height >= STAGE_FLOOR) { state.stageGrowth = null; return; }
|
|
// The DOM follows a new node size a frame later, so a second draw before
|
|
// then reads the same short stage: wait for it instead of growing twice.
|
|
const pending = state.stageGrowth;
|
|
if (pending && pending.height === height && node.size[1] >= pending.target) return;
|
|
const target = node.size[1] + (STAGE_FLOOR - height);
|
|
state.stageGrowth = { height, target };
|
|
node.setSize?.([node.size[0], target]);
|
|
node.setDirtyCanvas?.(true, true);
|
|
}
|
|
|
|
function drawScene(context, state, render, compact, interactive) {
|
|
const { sourceRect, cropRect, outputRect } = render; context.save();
|
|
context.fillStyle = normalizeColor(value(state.node, "fill_color", "#808080")); context.fillRect(outputRect.x, outputRect.y, outputRect.width, outputRect.height);
|
|
context.save(); context.translate(sourceRect.x + sourceRect.width / 2, sourceRect.y + sourceRect.height / 2); context.rotate((Number(value(state.node, "rotation_degrees", 0)) || 0) * Math.PI / 180);
|
|
drawSourceImage(context, state, render.scale); context.restore();
|
|
context.save(); context.globalCompositeOperation = "source-over"; context.fillStyle = "rgba(8,10,12,.62)";
|
|
const full = { x: 0, y: 0, width: context.canvas.width, height: context.canvas.height }; context.beginPath(); context.rect(full.x, full.y, full.width, full.height); context.rect(cropRect.x, cropRect.y, cropRect.width, cropRect.height); context.fill("evenodd"); context.restore();
|
|
if (!compact && state.showBlend && widget(state.node, "stitch_blend")) drawBlendOverlay(context, state, render);
|
|
context.strokeStyle = "#4bd8ef"; context.lineWidth = compact ? 1 : 2; context.setLineDash([7, 5]); context.strokeRect(cropRect.x, cropRect.y, cropRect.width, cropRect.height);
|
|
context.strokeStyle = "#ff9d42"; context.setLineDash([5, 5]); context.strokeRect(outputRect.x, outputRect.y, outputRect.width, outputRect.height); context.setLineDash([]);
|
|
if (interactive) {
|
|
if (state.grid) drawGrid(context, cropRect);
|
|
drawCropHandles(context, cropRect, state.drag?.kind === "crop" ? state.drag.name : null);
|
|
drawPaddingHandles(context, outputRect, render.layout.padOffset, state.drag?.kind === "padding" ? state.drag.name : null);
|
|
drawRotationHandle(context, state, render, state.drag?.kind === "rotation");
|
|
// The face has its readout line under the stage (syncReadout).
|
|
if (!compact) drawOutputSize(context, state, render);
|
|
}
|
|
context.restore();
|
|
}
|
|
|
|
function resizeRequest(node) {
|
|
return value(node, "resize_to_megapixels", false)
|
|
? { megapixels: value(node, "megapixels", 1), steps: value(node, "resolution_steps", 1) }
|
|
: null;
|
|
}
|
|
|
|
// The size readout, drawn OUTSIDE the image: under the output rect, above
|
|
// it when the bottom would run off the stage, so it never sits on the
|
|
// pixels being judged. It names every step that sets the size - crop,
|
|
// padding, the Align rounding, the resize - wrapping at the arrows on a
|
|
// narrow stage, with the size the run emits last and brightest, and an
|
|
// amber line when the steps stretch the picture or the resize undoes Align.
|
|
const READOUT_LINE = 15;
|
|
function drawOutputSize(context, state, render) {
|
|
const { tokens, warnings } = sizeChainTokens(sizeChain(values(state.node), render.source, resizeRequest(state.node)));
|
|
const { outputRect } = render;
|
|
context.save();
|
|
const viewWidth = context.canvas.clientWidth || context.canvas.width;
|
|
const viewHeight = context.canvas.clientHeight || context.canvas.height;
|
|
const maxWidth = Math.max(80, viewWidth - 24);
|
|
const muted = "11px system-ui";
|
|
const strong = "600 12px system-ui";
|
|
const measure = (font, text) => { context.font = font; return context.measureText(text).width; };
|
|
// Every piece after the first starts with its arrow, also at the start of
|
|
// a wrapped line, so a line's width is the sum of its pieces.
|
|
const arrow = "→ ";
|
|
const pieces = tokens.map((token, index) => {
|
|
const last = index === tokens.length - 1;
|
|
const lead = index ? arrow : "";
|
|
const label = token.label ? `${token.label} ` : "";
|
|
const gap = last ? 0 : measure(muted, " ");
|
|
return { lead, label, text: token.text, last, width: measure(muted, lead + label) + measure(last ? strong : muted, token.text) + gap };
|
|
});
|
|
const lines = [[]];
|
|
let lineWidth = 0;
|
|
for (const piece of pieces) {
|
|
if (lines.at(-1).length && lineWidth + piece.width > maxWidth) { lines.push([]); lineWidth = 0; }
|
|
lines.at(-1).push(piece);
|
|
lineWidth += piece.width;
|
|
}
|
|
const warningText = warnings.length ? `⚠ ${warnings.join(" · ")}` : "";
|
|
const widths = lines.map((line) => line.reduce((sum, piece) => sum + piece.width, 0));
|
|
if (warningText) widths.push(measure(muted, warningText));
|
|
const boxWidth = Math.min(viewWidth - 8, Math.max(...widths) + 12);
|
|
const boxHeight = (lines.length + (warningText ? 1 : 0)) * READOUT_LINE + 5;
|
|
const x = clamp(outputRect.x + outputRect.width / 2 - boxWidth / 2, 4, Math.max(4, viewWidth - boxWidth - 4));
|
|
let top = outputRect.y + outputRect.height + 6;
|
|
if (top + boxHeight > viewHeight - 4) top = outputRect.y - 6 - boxHeight;
|
|
if (top < 4) top = Math.max(4, viewHeight - boxHeight - 4);
|
|
context.fillStyle = "rgba(8,10,12,0.82)";
|
|
context.beginPath(); context.roundRect(x, top, boxWidth, boxHeight, 6); context.fill();
|
|
const write = (font, color, text, cursor, baseline) => {
|
|
context.font = font; context.fillStyle = color; context.fillText(text, cursor, baseline);
|
|
return cursor + context.measureText(text).width;
|
|
};
|
|
lines.forEach((line, row) => {
|
|
const baseline = top + 1 + (row + 1) * READOUT_LINE;
|
|
let cursor = x + 6;
|
|
for (const piece of line) {
|
|
cursor = write(muted, "#7f8b93", piece.lead, cursor, baseline);
|
|
cursor = write(muted, "#8ca8a5", piece.label, cursor, baseline);
|
|
cursor = write(piece.last ? strong : muted, piece.last ? "#ffffff" : "#c9d0d6", piece.text, cursor, baseline);
|
|
cursor += piece.last ? 0 : measure(muted, " ");
|
|
}
|
|
});
|
|
if (warningText) write(muted, "#ffc46b", warningText, x + 6, top + 1 + (lines.length + 1) * READOUT_LINE);
|
|
context.restore();
|
|
}
|
|
|
|
// Draws the current source frame centered on the (already translated and
|
|
// rotated) origin. During a scrub, the nearest storyboard tile stands in for
|
|
// the real frame until its decode lands.
|
|
function drawSourceImage(context, state, scale) {
|
|
const width = state.sourceWidth * scale;
|
|
const height = state.sourceHeight * scale;
|
|
const storyboard = state.storyboard;
|
|
if (state.scrubPreviewTile != null && storyboard) {
|
|
context.drawImage(
|
|
storyboard.image,
|
|
state.scrubPreviewTile * storyboard.tileWidth, 0, storyboard.tileWidth, storyboard.tileHeight,
|
|
-width / 2, -height / 2, width, height
|
|
);
|
|
return;
|
|
}
|
|
context.drawImage(state.image, -width / 2, -height / 2, width, height);
|
|
}
|
|
|
|
function drawGrid(context, rect) {
|
|
context.save(); context.strokeStyle = "rgba(255,255,255,.35)"; context.lineWidth = 1;
|
|
for (const fraction of [1 / 3, 1 / 2, 2 / 3]) {
|
|
context.beginPath(); context.moveTo(rect.x + rect.width * fraction, rect.y); context.lineTo(rect.x + rect.width * fraction, rect.y + rect.height); context.stroke();
|
|
context.beginPath(); context.moveTo(rect.x, rect.y + rect.height * fraction); context.lineTo(rect.x + rect.width, rect.y + rect.height * fraction); context.stroke();
|
|
}
|
|
context.restore();
|
|
}
|
|
function drawCropHandles(context, rect, active) {
|
|
for (const handle of cropHandleCenters(rect)) { context.fillStyle = handle.name === active ? "#fff" : "#4bd8ef"; context.fillRect(handle.x - 6, handle.y - 6, 12, 12); context.strokeStyle = "#08272d"; context.strokeRect(handle.x - 6, handle.y - 6, 12, 12); }
|
|
}
|
|
function drawPaddingHandles(context, rect, offset, active) {
|
|
for (const handle of paddingHandleCenters(rect, offset)) { context.save(); context.translate(handle.x, handle.y); context.rotate(Math.PI / 4); context.fillStyle = handle.name === active ? "#fff" : "#ff9d42"; context.fillRect(-8, -8, 16, 16); context.strokeStyle = "#3b2108"; context.strokeRect(-8, -8, 16, 16); context.restore(); }
|
|
}
|
|
// The knob rides the actual top-right corner of the image being rotated
|
|
// (the rotated quad's corner, not any bounding box), so it stays physically
|
|
// attached and orbits with the image as the angle changes - the same mental
|
|
// model as grabbing an object's corner in a design tool.
|
|
function rotationAnchor(state, render) {
|
|
const angle = (Number(value(state.node, "rotation_degrees", 0)) || 0) * Math.PI / 180;
|
|
const centerX = render.sourceRect.x + render.sourceRect.width / 2;
|
|
const centerY = render.sourceRect.y + render.sourceRect.height / 2;
|
|
const halfWidth = state.sourceWidth * render.scale / 2;
|
|
const halfHeight = state.sourceHeight * render.scale / 2;
|
|
const cos = Math.cos(angle);
|
|
const sin = Math.sin(angle);
|
|
// Unrotated top-right corner (+hw, -hh) rotated about the image center.
|
|
const corner = {
|
|
x: centerX + halfWidth * cos + halfHeight * sin,
|
|
y: centerY + halfWidth * sin - halfHeight * cos,
|
|
};
|
|
const length = Math.hypot(corner.x - centerX, corner.y - centerY) || 1;
|
|
const direction = { x: (corner.x - centerX) / length, y: (corner.y - centerY) / length };
|
|
const arm = render.layout?.rotateArm ?? 34;
|
|
return { corner, handle: { x: corner.x + direction.x * arm, y: corner.y + direction.y * arm } };
|
|
}
|
|
function rotationHandle(state, render) {
|
|
return rotationAnchor(state, render).handle;
|
|
}
|
|
function drawRotationHandle(context, state, render, active) {
|
|
const { corner, handle } = rotationAnchor(state, render);
|
|
context.strokeStyle = "#73e36a";
|
|
context.beginPath(); context.moveTo(corner.x, corner.y); context.lineTo(handle.x, handle.y); context.stroke();
|
|
context.fillStyle = active ? "#fff" : "#73e36a";
|
|
context.beginPath(); context.arc(handle.x, handle.y, 13, 0, Math.PI * 2); context.fill();
|
|
context.strokeStyle = "#173516"; context.stroke();
|
|
drawRotateGlyph(context, handle.x, handle.y, 6, "#0c2210");
|
|
if (!active) return;
|
|
// Live readout while the knob is held, nudged to stay inside the stage.
|
|
const degrees = Number(value(state.node, "rotation_degrees", 0)) || 0;
|
|
const text = `${degrees.toFixed(1)}°`;
|
|
context.save();
|
|
context.font = "12px system-ui";
|
|
const textWidth = context.measureText(text).width;
|
|
const viewWidth = context.canvas.clientWidth || context.canvas.width;
|
|
const viewHeight = context.canvas.clientHeight || context.canvas.height;
|
|
const x = clamp(handle.x + 20, 8, Math.max(8, viewWidth - textWidth - 10));
|
|
const y = clamp(handle.y - 20, 18, Math.max(18, viewHeight - 8));
|
|
context.fillStyle = "rgba(8,10,12,0.85)";
|
|
context.beginPath(); context.roundRect(x - 5, y - 13, textWidth + 10, 18, 6); context.fill();
|
|
context.fillStyle = "#c9f2c4";
|
|
context.fillText(text, x, y);
|
|
context.restore();
|
|
}
|
|
|
|
// Vector rotate-arrow glyph (circular arc + arrowhead), crisp at any zoom
|
|
// and identical on every platform — no emoji font involved.
|
|
function drawRotateGlyph(context, x, y, radius, color) {
|
|
const startAngle = -0.4 * Math.PI;
|
|
const endAngle = 1.1 * Math.PI;
|
|
context.save();
|
|
context.strokeStyle = color;
|
|
context.fillStyle = color;
|
|
context.lineWidth = 2;
|
|
context.lineCap = "round";
|
|
context.beginPath();
|
|
context.arc(x, y, radius, startAngle, endAngle);
|
|
context.stroke();
|
|
// Arrowhead at the arc's end, pointing along the direction of travel.
|
|
const tipBase = { x: x + radius * Math.cos(endAngle), y: y + radius * Math.sin(endAngle) };
|
|
const tangent = { x: -Math.sin(endAngle), y: Math.cos(endAngle) };
|
|
const normal = { x: Math.cos(endAngle), y: Math.sin(endAngle) };
|
|
context.beginPath();
|
|
context.moveTo(tipBase.x + tangent.x * 4.6, tipBase.y + tangent.y * 4.6);
|
|
context.lineTo(tipBase.x - tangent.x * 1.2 + normal.x * 3.1, tipBase.y - tangent.y * 1.2 + normal.y * 3.1);
|
|
context.lineTo(tipBase.x - tangent.x * 1.2 - normal.x * 3.1, tipBase.y - tangent.y * 1.2 - normal.y * 3.1);
|
|
context.closePath();
|
|
context.fill();
|
|
context.restore();
|
|
}
|
|
|
|
// One wiring for both stages (editor canvas and compact panel): pointer
|
|
// capture starts on handle hits only, empty presses fall through so the
|
|
// node itself can still drag, and pointercancel plus mouseleave both end a
|
|
// gesture in case the capture was refused.
|
|
function attachStageHandlers(state, canvas, signal) {
|
|
canvas.addEventListener("pointerdown", (event) => pointerDown(state, canvas, event), { signal });
|
|
canvas.addEventListener("pointermove", (event) => pointerMove(state, canvas, event), { signal });
|
|
canvas.addEventListener("pointerup", (event) => pointerUp(state, canvas, event), { signal });
|
|
canvas.addEventListener("pointercancel", (event) => pointerUp(state, canvas, event), { signal });
|
|
canvas.addEventListener("mouseleave", (event) => {
|
|
if (state.drag?.canvas === canvas) pointerUp(state, canvas, event);
|
|
else canvas.style.cursor = "default";
|
|
}, { signal });
|
|
}
|
|
|
|
function surfaceRender(state, canvas) {
|
|
return canvas === state.previewCanvas ? state.panelRender : state.render;
|
|
}
|
|
|
|
// The single hit-test order both surfaces share; hit radii are ~2-3x the
|
|
// drawn handle so handles stay grabbable on the compact panel.
|
|
function handleGroups(state, render) {
|
|
return [
|
|
{ kind: "rotation", priority: 0, radius: 24, handles: [{ name: "rotation", ...rotationHandle(state, render) }] },
|
|
{ kind: "padding", priority: 1, radius: 24, handles: paddingHandleCenters(render.outputRect, render.layout.padOffset) },
|
|
{ kind: "crop", priority: 2, radius: 22, handles: cropHandleCenters(render.cropRect) },
|
|
];
|
|
}
|
|
|
|
function pointerDown(state, canvas, event) {
|
|
if (state.drag) return;
|
|
const render = surfaceRender(state, canvas);
|
|
if (canvas === state.canvas && (event.button === 1 || event.altKey)) {
|
|
state.drag = { kind: "pan", canvas, start: canvasLocalPoint(canvas, event), view: { ...state.view } };
|
|
} else if (event.button === 0 && render) {
|
|
const point = canvasLocalPoint(canvas, event);
|
|
const selected = nearestHandle(point, handleGroups(state, render));
|
|
const base = {
|
|
canvas, start: point,
|
|
// Everything a drag computes against, frozen at grab time (map plus
|
|
// world snapshots) so a mid-gesture refit cannot move the target.
|
|
map: { scale: render.scale, originX: render.originX, originY: render.originY, layout: render.layout },
|
|
source: { ...render.source },
|
|
center: { x: render.sourceRect.x + render.sourceRect.width / 2, y: render.sourceRect.y + render.sourceRect.height / 2 },
|
|
crop: { ...render.crop },
|
|
padding: { ...render.padding },
|
|
rotation: Number(value(state.node, "rotation_degrees", 0)),
|
|
};
|
|
if (selected) state.drag = { ...selected, ...base };
|
|
else if (inside(point, render.cropRect)) state.drag = { kind: "move", ...base };
|
|
}
|
|
// No hit: no capture and no preventDefault, so an empty press on the
|
|
// panel falls through and the node drags as usual.
|
|
if (!state.drag) return;
|
|
if (state.drag.kind === "rotation") settleRotation(state);
|
|
event.preventDefault(); event.stopPropagation();
|
|
try { canvas.setPointerCapture(event.pointerId); } catch { /* mouse fallback */ }
|
|
state.grid = state.drag.kind === "rotation";
|
|
draw(state);
|
|
}
|
|
|
|
function pointerMove(state, canvas, event) {
|
|
const point = canvasLocalPoint(canvas, event);
|
|
const drag = state.drag;
|
|
if (!drag || drag.canvas !== canvas) { updateCursor(state, canvas, point); return; }
|
|
event.preventDefault();
|
|
const dxScreen = point.x - drag.start.x; const dyScreen = point.y - drag.start.y;
|
|
if (drag.kind === "pan") { state.view.panX = drag.view.panX + dxScreen; state.view.panY = drag.view.panY + dyScreen; }
|
|
else if (drag.kind === "rotation") {
|
|
const startAngle = Math.atan2(drag.start.y - drag.center.y, drag.start.x - drag.center.x);
|
|
const nextAngle = Math.atan2(point.y - drag.center.y, point.x - drag.center.x);
|
|
let degrees = drag.rotation + (nextAngle - startAngle) * 180 / Math.PI;
|
|
if (event.shiftKey) degrees = Math.round(degrees / 15) * 15;
|
|
rotateTo(state, degrees);
|
|
} else if (drag.kind === "crop") {
|
|
const ratio = parseAspectRatio(value(state.node, "crop_aspect_ratio", "free"), drag.source);
|
|
const next = resizeCrop(drag.crop, drag.name, dxScreen / drag.map.scale, dyScreen / drag.map.scale, drag.source, ratio);
|
|
setCrop(state.node, next);
|
|
applyAspectLock(state, cropDriver(drag.crop, next, drag.name));
|
|
} else if (drag.kind === "move") {
|
|
const crop = drag.crop;
|
|
setCrop(state.node, { ...crop, x: Math.round(clamp(crop.x + dxScreen / drag.map.scale, 0, drag.source.width - crop.width)), y: Math.round(clamp(crop.y + dyScreen / drag.map.scale, 0, drag.source.height - crop.height)) });
|
|
} else if (drag.kind === "padding") {
|
|
const delta = (drag.name === "pad_left" || drag.name === "pad_right" ? dxScreen : dyScreen) / drag.map.scale;
|
|
const sign = drag.name === "pad_left" || drag.name === "pad_top" ? -1 : 1;
|
|
let next = Math.max(0, Math.round(drag.padding[drag.name.replace("pad_", "")] + delta * sign));
|
|
// Under a lock the handle stops where the other axis would need
|
|
// negative padding; the other axis then follows.
|
|
const ratio = lockRatio(state);
|
|
if (ratio) next = Math.max(next, lockedPadMinimum(values(state.node), resolveCrop(values(state.node), drag.source), ratio, drag.name));
|
|
setValue(state.node, drag.name, next);
|
|
if (ratio) applyAspectLock(state, paddingAxis(drag.name));
|
|
}
|
|
draw(state); updateModalInfo(state);
|
|
}
|
|
|
|
function pointerUp(state, canvas, event) {
|
|
const drag = state.drag;
|
|
if (!drag || drag.canvas !== canvas) return;
|
|
const kind = drag.kind;
|
|
state.drag = null; state.grid = false;
|
|
if (kind === "rotation") settleRotation(state);
|
|
try { canvas.releasePointerCapture(event.pointerId); } catch {}
|
|
draw(state); state.node.setDirtyCanvas?.(true, true);
|
|
// Widgets were written throughout the drag; tell the tracker once, on
|
|
// release. A pan only moves the view and serializes nothing.
|
|
if (kind !== "pan") notifyAusbossChange();
|
|
}
|
|
function inside(point, rect) { return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; }
|
|
function setCrop(node, crop) { setValue(node, "crop_x", crop.x); setValue(node, "crop_y", crop.y); setValue(node, "crop_width", crop.width); setValue(node, "crop_height", crop.height); }
|
|
function updateCursor(state, canvas, point) {
|
|
const render = surfaceRender(state, canvas);
|
|
if (!render) return;
|
|
const selected = nearestHandle(point, handleGroups(state, render));
|
|
canvas.style.cursor = selected?.kind === "rotation" ? "crosshair" : selected ? "grab" : inside(point, render.cropRect) ? "move" : "default";
|
|
}
|
|
function wheelZoom(state, event) { event.preventDefault(); const point = canvasLocalPoint(state.canvas, event); state.view = zoomAround(state.view, state.view.zoom * Math.exp(-event.deltaY * 0.0015), point); draw(state); }
|
|
|
|
function updateModalInfo(state) {
|
|
if (!state.modal || !state.sourceWidth) return; const status = state.modal.querySelector("[data-ausboss-status]"); if (!status) return;
|
|
const frame = state.kind === "video" ? `\nFrame ${value(state.node, "frame_index", 0)} at ${Number(value(state.node, "frame_time", 0)).toFixed(3)}s` : "";
|
|
const stitch = widget(state.node, "stitch_blend") ? `\nStitch blend ${value(state.node, "stitch_blend", 32)} px${Number(value(state.node, "stitch_grow", 0)) ? `, grow ${value(state.node, "stitch_grow", 0)} px` : ""}` : "";
|
|
status.textContent = `${sizeLines(state).join("\n")}${stitch}${frame}`;
|
|
}
|
|
|
|
// The output size in the order the run builds it, one step per line: the
|
|
// editor's status panel and the face readout's tooltip.
|
|
function sizeLines(state) {
|
|
const current = values(state.node);
|
|
const source = rotatedSize(state.sourceWidth, state.sourceHeight, current.rotation_degrees);
|
|
const resize = resizeRequest(state.node);
|
|
const chain = sizeChain(current, source, resize);
|
|
const pad = resolvePadding(current, chain.crop);
|
|
const lines = [
|
|
`Source ${state.sourceWidth} x ${state.sourceHeight}`,
|
|
`Rotated ${source.width} x ${source.height}`,
|
|
`Crop ${chain.crop.x}, ${chain.crop.y}, ${chain.crop.width} x ${chain.crop.height}`,
|
|
`Padding ${current.pad_left} / ${current.pad_top} / ${current.pad_right} / ${current.pad_bottom} → ${chain.padded.width} x ${chain.padded.height}`,
|
|
];
|
|
const extraRight = pad.right - Math.max(0, Math.round(Number(current.pad_right) || 0));
|
|
const extraBottom = pad.bottom - Math.max(0, Math.round(Number(current.pad_bottom) || 0));
|
|
lines.push(extraRight || extraBottom
|
|
? `Align ${chain.multiple} → ${chain.canvas.width} x ${chain.canvas.height} (+${extraRight} right, +${extraBottom} bottom)`
|
|
: `Canvas ${chain.canvas.width} x ${chain.canvas.height}`);
|
|
if (chain.resized) {
|
|
const megapixels = (chain.resized.width * chain.resized.height / 1048576).toFixed(2);
|
|
lines.push(`Resize ${Number(resize.megapixels).toFixed(2)} MP, steps ${resize.steps} → ${chain.resized.width} x ${chain.resized.height} (${megapixels} MP)`);
|
|
}
|
|
for (const warning of sizeChainTokens(chain).warnings) lines.push(`⚠ ${warning}`);
|
|
if (chain.resized && (extraRight || extraBottom)) {
|
|
lines.push("Align's strip is fill the model paints. With Resize on, Step already rounds the size, so Align 1 leaves no strip.");
|
|
}
|
|
return lines;
|
|
}
|
|
function drawEmpty(state, text) { state.render = null; state.panelRender = null; syncReadout(state); for (const canvas of [state.canvas, state.previewCanvas]) { if (!canvas) continue; const prepared = prepareCanvas(canvas); drawEmptyCanvas(prepared.context, prepared.width, prepared.height, text); } }
|
|
function drawEmptyCanvas(context, width, height, text) { context.fillStyle = "#111"; context.fillRect(0, 0, width, height); context.fillStyle = "#9ba2aa"; context.font = "13px system-ui"; context.textAlign = "center"; context.fillText(text, width / 2, height / 2); context.textAlign = "left"; }
|
|
|
|
// True when the node is an AusBoss transform node whose editor can open
|
|
// (installed by installTransformNode). Used by the pack-wide command.
|
|
export function openTransformEditorForNode(node) {
|
|
const state = node?.__ausbossTransformState;
|
|
if (!state) return false;
|
|
openEditor(state);
|
|
return true;
|
|
}
|
|
|
|
export function disposeTransformNode(node) {
|
|
const state = node.__ausbossTransformState; if (!state) return; state.disposed = true; closeEditor(state); state.panelAbort?.abort(); state.panelResizeObserver?.disconnect(); state.frameController?.abort(); if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
|
|
if (node.__ausbossImgsSuppressed) {
|
|
const descriptor = node.__ausbossImgsDescriptor;
|
|
if (descriptor) Object.defineProperty(node, "imgs", descriptor); else delete node.imgs;
|
|
if (node.__ausbossAddCustomWidget) node.addCustomWidget = node.__ausbossAddCustomWidget;
|
|
delete node.__ausbossImgsSuppressed;
|
|
delete node.__ausbossImgsDescriptor;
|
|
delete node.__ausbossAddCustomWidget;
|
|
}
|
|
delete node.__ausbossTransformState;
|
|
}
|
|
|
|
export function registerTransformExtension(nodeClass, kind, mountPanel = null) {
|
|
app.registerExtension({
|
|
name: `ausboss.transform.${nodeClass}`,
|
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== nodeClass) return;
|
|
hideInputsInDef(nodeData, HIDDEN_WIDGETS);
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () { installTransformNode(this, kind, mountPanel); });
|
|
chainCallback(nodeType.prototype, "onRemoved", function () { disposeTransformNode(this); });
|
|
},
|
|
});
|
|
}
|