2709 lines
145 KiB
JavaScript
2709 lines
145 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 { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } 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 { suppressCoreVideoPreview } from "./core_preview.mjs";
|
||
import {
|
||
INPUT_FOLDER_MODE,
|
||
LOCAL_PATH_MODE,
|
||
normalizeVideoOptions,
|
||
mediaSourceState,
|
||
videoSourceState,
|
||
} from "./video_source_card.mjs";
|
||
import {
|
||
aspectMatches,
|
||
canvasLocalPoint,
|
||
canvasSize,
|
||
clamp,
|
||
cropForRotation,
|
||
declaredTransformDefaults,
|
||
fitSourceToAspect,
|
||
cropHandleCenters,
|
||
isUntouched,
|
||
evenOutPadding,
|
||
lockPadding,
|
||
lockedPadMinimum,
|
||
moveCursor,
|
||
moveRoom,
|
||
nearestHandle,
|
||
padAround,
|
||
paddingAxis,
|
||
KNOB_CLEARANCE,
|
||
paddingHandleCenters,
|
||
parseAspectRatio,
|
||
placeKnob,
|
||
ratioLabel,
|
||
rememberedSource,
|
||
resetTransformValues,
|
||
resizeCrop,
|
||
resolveCrop,
|
||
resolvePadding,
|
||
rotatedSize,
|
||
scaleToMegapixels,
|
||
sizeChain,
|
||
sizeChainTokens,
|
||
slidePadding,
|
||
sourceChanged,
|
||
sourceResetValues,
|
||
stageHandleLayout,
|
||
stageHeightForWidth,
|
||
stepWithoutStretch,
|
||
tightLockPadding,
|
||
turnAspect,
|
||
turnedCrop,
|
||
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"];
|
||
// The widest the control rows get on a big node; the picture takes the rest.
|
||
const CONTROL_MAX_WIDTH = 460;
|
||
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-panel>:not(.ausboss-transform-preview){width:100%;max-width:${CONTROL_MAX_WIDTH}px;align-self:center;box-sizing:border-box}
|
||
.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-size:11px}
|
||
.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/15px system-ui;font-variant-numeric:tabular-nums;text-align:center;overflow:hidden;max-height:62px;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-readout em{display:block;color:#8ca8a5;font-style:normal}
|
||
.ausboss-transform-readout em.note{color:#ffc46b}
|
||
.ausboss-transform-readout button{margin-left:6px;height:18px;padding:0 7px;border:1px solid #6b5a33;border-radius:4px;background:#2a2417;color:#ffd79a;font:600 10px system-ui;cursor:pointer;vertical-align:1px}
|
||
.ausboss-transform-readout button:hover{border-color:#ffc46b;color:#fff}
|
||
.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:#9aa5ad;color:#fff}
|
||
.ausboss-transform-aspect.active,.ausboss-transform-aspect-hold.on{background:${BRAND};border-color:${BRAND};color:#04201d}
|
||
.ausboss-transform-aspect.active:hover,.ausboss-transform-aspect-hold.on:hover{border-color:#e5fffc;color:#04201d}
|
||
.ausboss-transform-aspects>.ausboss-transform-aspect-caption{min-width:34px;text-align:center}
|
||
.ausboss-transform-aspects>.ausboss-transform-aspect-caption.custom{color:#e3e8ec}
|
||
.ausboss-transform-aspects>.ausboss-transform-aspect-caption.held{color:${BRAND};font-weight:600}
|
||
.ausboss-transform-aspect-hold{flex:0 0 28px;display:flex;align-items:center;justify-content:center;color:#8d9aa2}
|
||
.ausboss-transform-aspect-hold.idle{opacity:.4;cursor:default}
|
||
.ausboss-transform-aspect-hold.idle:hover{border-color:#4a5058;color:#8d9aa2}
|
||
.ausboss-transform-aspect-lock{display:inline-block;line-height:0}
|
||
.ausboss-transform-fit-label{display:flex;align-items:center;gap:6px;color:#aeb4ba;font-size:11px;cursor:default;user-select:none}
|
||
.ausboss-transform-fit{display:grid;grid-template-columns:1fr 1fr;flex:1 1 auto;height:26px;padding:2px;border:1px solid #2a3437;border-radius:6px;background:#0f1516;box-sizing:border-box}
|
||
.ausboss-transform-fit button{border:0;border-radius:4px;background:transparent;color:#8ba3a1;font:600 11px system-ui;cursor:pointer}
|
||
.ausboss-transform-fit button:hover{color:#fff}.ausboss-transform-fit button.on{background:${BRAND};color:#04201d}
|
||
.ausboss-transform-fit.idle{opacity:.45}
|
||
.ausboss-transform-fit-hint{flex:0 1 auto;min-width:0;color:#6f8886;font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.ausboss-transform-aspect-modes{flex-wrap:wrap;row-gap:6px}
|
||
.ausboss-transform-aspect-modes>.ausboss-transform-fit-label{flex:1 0 auto}
|
||
.ausboss-transform-aspect-modes .ausboss-transform-fit{min-width:96px}
|
||
.ausboss-transform-aspect-modes>.ausboss-transform-alignment{margin-left:auto}
|
||
.ausboss-transform-switch{flex:0 0 76px;height:24px}
|
||
.ausboss-transform-button,.ausboss-transform-modal button:not(.ausboss-scrub-step>button):not(.ausboss-transform-aspect):not(.ausboss-transform-fit>button):not(.ausboss-transform-trim-reset):not(.ausboss-transform-trim-pill>button):not(.ausboss-transform-trim-handle){background:#30343a;color:#eee;border:1px solid #555b63;border-radius:5px;padding:7px 10px;cursor:pointer}
|
||
.ausboss-transform-button:hover,.ausboss-transform-modal button:not(.ausboss-scrub-step>button):not(.ausboss-transform-aspect):not(.ausboss-transform-fit>button):not(.ausboss-transform-trim-reset):not(.ausboss-transform-trim-pill>button):not(.ausboss-transform-trim-handle):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:304px 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.ausboss-transform-field{grid-template-columns:108px minmax(0,1fr)}
|
||
.ausboss-transform-section label.ausboss-transform-field>.ausboss-scrub{width:100%}
|
||
.ausboss-transform-section .ausboss-transform-aspects{margin:4px 0 8px}
|
||
.ausboss-transform-section .ausboss-transform-aspect-modes{margin:0 0 8px}
|
||
.ausboss-transform-section .ausboss-transform-aspects{gap:4px}
|
||
.ausboss-transform-section .ausboss-transform-aspect{padding:3px 0}
|
||
.ausboss-transform-section label>*{min-width:0}
|
||
.ausboss-transform-section input:not(.ausboss-scrub-input):not(.ausboss-transform-swatch),.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:290px 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; node.properties.ausboss_aspect_lock = false; }
|
||
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; node.properties.ausboss_aspect_lock = false; }
|
||
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", "", "Server file");
|
||
uploadsMode.type = localMode.type = "button";
|
||
uploadsMode.title = "Choose a video already in ComfyUI's input folder or upload another.";
|
||
localMode.title = "A video already on the ComfyUI server, inside its input, output or temp folder, read 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", "Server file 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;
|
||
// "Choose an uploaded video" has done its job once one is chosen; its
|
||
// line goes to the picture. The Server file note stays while you type.
|
||
hint.style.display = source.mode !== LOCAL_PATH_MODE && source.selection ? "none" : "";
|
||
};
|
||
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;
|
||
}
|
||
}
|
||
// No transform node sends a picture of its own, so a ComfyUI preview on
|
||
// one is always somebody else's: outputs are filed by node id, and a run
|
||
// queued in another workflow tab lands on the node with the same id here.
|
||
// The stage is the node's only picture, in both renderers (the video
|
||
// suppression also sets Nodes 2.0's hideOutputImages).
|
||
suppressCoreImagePreview(node);
|
||
suppressCoreVideoPreview(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 = "Show the whole picture again; rotation, padding and the timeline stay. With the padlock on, the bands it added to keep the shape go too.";
|
||
resetCrop.addEventListener("click", () => resetCropKeepingShape(state));
|
||
const reset = createElement("button", "ausboss-transform-button", "Reset");
|
||
reset.title = "Reset rotation, crop and padding, and turn the padlock off. Fill, feather, Round canvas to and the timeline stay.";
|
||
reset.addEventListener("click", () => {
|
||
resetGeometry(node);
|
||
fitCrop(state); updateModalInfo(state); notifyAusbossChange();
|
||
});
|
||
row.append(open, resetCrop, reset);
|
||
state.editorSyncs = [];
|
||
panel.append(buildMediaSourceCard(state));
|
||
const readout = createElement("div", "ausboss-transform-readout");
|
||
state.readout = readout;
|
||
// Its Even out button is a click, not a node drag.
|
||
readout.addEventListener("pointerdown", (event) => { if (event.target.closest("button")) event.stopPropagation(); });
|
||
panel.append(preview, readout);
|
||
const chipRow = buildAspectChipRow(state);
|
||
const modeRow = buildAspectModeRow(state);
|
||
state.syncAspectChips = chipRow.sync;
|
||
state.syncAspectMode = modeRow.sync;
|
||
panel.append(chipRow.row, modeRow.row);
|
||
// 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;
|
||
// exactMinWidth: 330 is the node's real floor, without the frontend's
|
||
// number-widget padding that made the first corner drag jump to 434.
|
||
fillNodeHeight(domWidget, { minWidth: TRANSFORM_MIN_WIDTH, minHeight: () => transformPanelFloor(node), minNodeSize: [TRANSFORM_MIN_WIDTH, state.isClip ? 802 : kind === "video" ? 570 : 456], exactMinWidth: true });
|
||
} else {
|
||
node.addWidget?.("button", "Open editor", null, () => openEditor(state), { serialize: false });
|
||
}
|
||
// A fresh node opens at its floor: the stage as tall as its width asks.
|
||
node.setSize?.([
|
||
Math.max(TRANSFORM_MIN_WIDTH, Math.min(520, node.size?.[0] || TRANSFORM_MIN_WIDTH)),
|
||
node.computeSize?.()[1] || (state.isClip ? 842 : kind === "video" ? 635 : 511),
|
||
]);
|
||
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(() => { noteStageHeight(state); 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;
|
||
};
|
||
}
|
||
|
||
// Ratio chips right under the preview, one state each: a chip is lit while
|
||
// the canvas has its shape. Tap one to pad the picture to it (crop, with
|
||
// Fit on crop), centred; tap the lit one to go back to the whole picture.
|
||
// The padlock at the end of the row holds the shape while you drag a
|
||
// handle, and the orientation button at the start turns the shape on its
|
||
// side. A drag that leaves the lit shape turns the chip off, and the row
|
||
// says Custom with the real ratio on the size line. The same row is built
|
||
// again in the full editor, so both places work the same way.
|
||
const ASPECT_CHIP_ORDER = ["1:1", "4:3", "3:2", "16:9", "21:9"];
|
||
|
||
function sourceSize(state) {
|
||
return rotatedSize(state.sourceWidth, state.sourceHeight, value(state.node, "rotation_degrees", 0));
|
||
}
|
||
|
||
function hasPicture(state) {
|
||
return Boolean(state.image && state.sourceWidth && state.sourceHeight);
|
||
}
|
||
|
||
function currentCanvas(state) {
|
||
return canvasSize(values(state.node), sourceSize(state));
|
||
}
|
||
|
||
// The ratio you picked (properties.ausboss_fit_aspect) while the canvas
|
||
// still has it, or while there is no picture yet to have it: the lit chip,
|
||
// and the shape a new source is fitted to.
|
||
function liveRequest(state) {
|
||
const request = String(state.node.properties?.ausboss_fit_aspect ?? "");
|
||
if (!/^\d+:\d+$/.test(request)) return null;
|
||
if (!hasPicture(state)) return request;
|
||
const canvas = currentCanvas(state);
|
||
return aspectMatches(canvas.width, canvas.height, request) ? request : null;
|
||
}
|
||
|
||
// The row says Source: a picture nothing has been done to, with no ratio
|
||
// lit. There is no shape to hold then, so the padlock has nothing to do.
|
||
function sourceState(state) {
|
||
return hasPicture(state) && !liveRequest(state) && isUntouched(values(state.node), sourceSize(state));
|
||
}
|
||
|
||
// After a gesture, a pick the canvas no longer has is dropped, so the next
|
||
// clip is not fitted to a shape you dragged away from. A lock left on a
|
||
// picture that is back to its own shape goes off with it: it would hold
|
||
// nothing, and a hidden mode waiting for the next drag is what surprised
|
||
// people.
|
||
function settleRequest(state) {
|
||
const properties = state.node.properties;
|
||
if (properties?.ausboss_fit_aspect && hasPicture(state) && !liveRequest(state)) delete properties.ausboss_fit_aspect;
|
||
if (properties?.ausboss_aspect_lock && sourceState(state)) properties.ausboss_aspect_lock = false;
|
||
}
|
||
|
||
function lockOn(state) {
|
||
return Boolean(state.node.properties?.ausboss_aspect_lock);
|
||
}
|
||
|
||
// The lock does something only while there is a shape to hold.
|
||
function lockActive(state) {
|
||
return lockOn(state) && hasPicture(state) && !sourceState(state);
|
||
}
|
||
|
||
// Which way the chips read: the lit shape's own way, then the canvas's
|
||
// once you have changed it, then the way you last turned them.
|
||
function chipsPortrait(state) {
|
||
const request = liveRequest(state);
|
||
if (request) {
|
||
const [width, height] = request.split(":").map(Number);
|
||
if (width !== height) return height > width;
|
||
}
|
||
if (hasPicture(state) && !isUntouched(values(state.node), sourceSize(state))) {
|
||
const canvas = currentCanvas(state);
|
||
if (canvas.width !== canvas.height) return canvas.height > canvas.width;
|
||
}
|
||
return Boolean(state.node.properties?.ausboss_pad_portrait);
|
||
}
|
||
|
||
// Tap a ratio: pad (or crop) to it, or back to the whole picture when it is
|
||
// the lit one. The chip row, the editor's copy of it and its extra-ratio
|
||
// list all come through here.
|
||
function tapRatio(state, ratio) {
|
||
const node = state.node;
|
||
node.properties ??= {};
|
||
if (!hasPicture(state)) {
|
||
// No picture yet: the pick waits, and the first one is fitted to it.
|
||
if (liveRequest(state) === ratio) delete node.properties.ausboss_fit_aspect;
|
||
else node.properties.ausboss_fit_aspect = ratio;
|
||
draw(state); notifyAusbossChange();
|
||
} else if (liveRequest(state) === ratio) clearAspect(state);
|
||
else fitAspect(state, ratio, aspectMode(state));
|
||
}
|
||
|
||
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", "ausboss-transform-aspect-caption", "Ratio");
|
||
row.append(caption);
|
||
const oriented = (aspect) => chipsPortrait(state) ? turnAspect(aspect) : aspect;
|
||
flip.addEventListener("click", () => { turnCanvas(state); draw(state); });
|
||
const chips = [];
|
||
for (const aspect of ASPECT_CHIP_ORDER) {
|
||
const chip = createElement("button", "ausboss-transform-aspect", aspect);
|
||
chip.type = "button";
|
||
chip.addEventListener("click", () => {
|
||
node.properties ??= {};
|
||
const ratio = oriented(aspect);
|
||
node.properties.ausboss_pad_portrait = chipsPortrait(state);
|
||
tapRatio(state, ratio);
|
||
draw(state);
|
||
});
|
||
chips.push({ chip, aspect }); row.append(chip);
|
||
}
|
||
const hold = createElement("button", "ausboss-transform-aspect ausboss-transform-aspect-hold");
|
||
hold.type = "button";
|
||
hold.append(lockGlyph());
|
||
hold.addEventListener("click", () => {
|
||
// Nothing to hold on the untouched picture: the lock stays off.
|
||
if (!lockOn(state) && hasPicture(state) && sourceState(state)) return;
|
||
setAspectLock(state, !lockOn(state)); draw(state);
|
||
});
|
||
row.append(hold);
|
||
const sync = () => {
|
||
const request = liveRequest(state);
|
||
const portrait = chipsPortrait(state);
|
||
const picture = hasPicture(state);
|
||
const canvas = picture ? currentCanvas(state) : null;
|
||
const untouched = picture && isUntouched(values(node), sourceSize(state));
|
||
const square = request ? turnAspect(request) === request : canvas ? canvas.width === canvas.height : false;
|
||
const pad = aspectMode(state) === "pad";
|
||
const held = lockActive(state);
|
||
flip.title = request && !square
|
||
? `Turn ${request} into ${turnAspect(request)}`
|
||
: picture && !untouched && !square
|
||
? "Turn this shape on its side"
|
||
: `Show the ${portrait ? "landscape" : "portrait"} ratios`;
|
||
flip.setAttribute("aria-label", flip.title);
|
||
flip.setAttribute("aria-pressed", String(portrait));
|
||
glyph.style.width = portrait ? "10px" : "16px";
|
||
glyph.style.height = portrait ? "16px" : "10px";
|
||
if (held) {
|
||
caption.textContent = "Held";
|
||
caption.title = `The padlock holds this shape (${request ?? ratioLabel(canvas.width, canvas.height)}) while you drag a handle.`;
|
||
} else if (request || !picture) {
|
||
caption.textContent = "Ratio";
|
||
caption.title = "Tap a ratio to pad the picture to it (or crop it, with Fit on crop). Tap the lit one to go back to the whole picture.";
|
||
} else {
|
||
caption.textContent = untouched ? "Source" : "Custom";
|
||
caption.title = `${untouched ? "The picture's own shape" : "No ratio button has this shape"}: ${canvas.width}×${canvas.height} (${ratioLabel(canvas.width, canvas.height)}). Tap a ratio to use one.`;
|
||
}
|
||
caption.classList.toggle("custom", Boolean(picture && !request && !untouched && !held));
|
||
caption.classList.toggle("held", held);
|
||
for (const { chip, aspect } of chips) {
|
||
const ratio = oriented(aspect);
|
||
const lit = ratio === request;
|
||
chip.textContent = ratio;
|
||
chip.title = lit && !picture
|
||
? `${ratio} is picked: the picture you choose is ${pad ? "padded" : "cropped"} to it. Tap again to unpick it.`
|
||
: lit
|
||
? `${pad ? "Padded" : "Cropped"} to ${ratio}. Tap again to go back to the whole picture.`
|
||
: pad ? `Pad to ${ratio}: keep every pixel and add fill around it.` : `Crop to ${ratio}: trim the picture to that shape.`;
|
||
chip.classList.toggle("active", lit);
|
||
chip.setAttribute("aria-pressed", String(lit));
|
||
}
|
||
const idle = picture && sourceState(state);
|
||
hold.classList.toggle("on", held);
|
||
hold.classList.toggle("idle", idle);
|
||
hold.setAttribute("aria-pressed", String(held));
|
||
hold.setAttribute("aria-disabled", String(idle));
|
||
hold.title = held
|
||
? `Shape held${request ? ` at ${request}` : ""}: dragging a handle keeps this shape, and the padding on the other side follows, split evenly. Tap to drag freely.`
|
||
: idle
|
||
? "Nothing to hold yet: pick a ratio, or change the shape, then lock it here."
|
||
: "Drag freely. Tap to hold this shape while you drag a handle.";
|
||
hold.setAttribute("aria-label", held ? "Shape held" : "Hold the shape");
|
||
};
|
||
sync();
|
||
return { row, sync };
|
||
}
|
||
|
||
function aspectMode(state) {
|
||
return state.node.properties?.ausboss_aspect_mode === "crop" ? "crop" : "pad";
|
||
}
|
||
|
||
// A two-part pill (off | on, crop | pad): the pack's one "on" look.
|
||
function segmentedPill(options, onPick, { className = "" } = {}) {
|
||
const pill = createElement("div", `ausboss-transform-fit ${className}`.trim());
|
||
const buttons = new Map();
|
||
for (const [key, text, title] of options) {
|
||
const button = createElement("button", "", text);
|
||
button.type = "button";
|
||
if (title) button.title = title;
|
||
button.addEventListener("click", () => onPick(key));
|
||
buttons.set(key, button); pill.append(button);
|
||
}
|
||
const set = (key) => {
|
||
for (const [name, button] of buttons) {
|
||
button.classList.toggle("on", name === key);
|
||
button.setAttribute("aria-pressed", String(name === key));
|
||
}
|
||
};
|
||
return { pill, set };
|
||
}
|
||
|
||
// Fit: how a ratio chip gets its shape. It only acts on a lit ratio, so it
|
||
// dims (and says so) until one is lit; picking a side still sets how the
|
||
// next ratio is reached. Round canvas to sits at the end of the row on the
|
||
// node face.
|
||
function buildAspectModeRow(state, { alignment: withAlignment = true } = {}) {
|
||
const row = createElement("div", "ausboss-transform-row ausboss-transform-aspect-modes");
|
||
const fit = createElement("div", "ausboss-transform-fit-label");
|
||
const { pill, set } = segmentedPill([
|
||
["crop", "crop", "Ratio buttons crop the picture to their shape."],
|
||
["pad", "pad", "Ratio buttons pad the picture to their shape and keep every pixel."],
|
||
], (mode) => {
|
||
if (aspectMode(state) === mode) return;
|
||
state.node.properties ??= {};
|
||
// A lit ratio is reached again the new way; otherwise only the next
|
||
// tap changes.
|
||
const request = liveRequest(state);
|
||
state.node.properties.ausboss_aspect_mode = mode;
|
||
if (request && hasPicture(state)) fitAspect(state, request, mode);
|
||
draw(state); notifyAusbossChange();
|
||
});
|
||
const hint = createElement("span", "ausboss-transform-fit-hint", "pick a ratio first");
|
||
fit.append(createElement("span", "", "Fit"), pill, hint);
|
||
row.append(fit);
|
||
let multiple = null;
|
||
if (withAlignment) {
|
||
const alignment = createElement("label", "ausboss-transform-alignment");
|
||
alignment.style.cssText = "display:flex;align-items:center;gap:5px;flex:0 0 auto";
|
||
multiple = makeScrubInput({ value: value(state.node, "canvas_multiple", 1),
|
||
min: 1, max: 4096, step: 8, fineStep: 1, decimals: 0, width: 72, unit: "px",
|
||
title: "Round the canvas up to a multiple of this many pixels (1 = off). The extra goes on the right and bottom as fill.",
|
||
onChange: (amount) => { setValue(state.node, "canvas_multiple", amount); draw(state); updateModalInfo(state); },
|
||
onSettle: notifyAusbossChange });
|
||
alignment.append(createElement("span", "", "Round canvas to"), multiple.root);
|
||
row.append(alignment);
|
||
}
|
||
const sync = () => {
|
||
multiple?.set(value(state.node, "canvas_multiple", 1));
|
||
set(aspectMode(state));
|
||
const idle = !liveRequest(state);
|
||
pill.classList.toggle("idle", idle);
|
||
hint.style.display = idle ? "" : "none";
|
||
pill.title = idle
|
||
? "Pick a ratio first: Fit decides how a ratio button gets its shape (crop trims the picture, pad adds fill around it)."
|
||
: "How the lit ratio gets its shape: crop trims the picture, pad adds fill around it and keeps every pixel.";
|
||
};
|
||
sync();
|
||
return { row, sync };
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// --- Holding the shape -----------------------------------------------------
|
||
// With the padlock on (properties.ausboss_aspect_lock), a handle drag keeps
|
||
// the canvas at the ratio it had when you grabbed it: the lit chip's, or the
|
||
// canvas's own when no chip is lit. Padding drags and crop drags in pad
|
||
// mode re-solve the padding (lockPadding, transform_geometry.mjs) from the
|
||
// padding the drag started with, so new bands split evenly; crop drags in
|
||
// crop mode keep the crop box's own ratio. On the untouched picture there
|
||
// is no shape to hold and the lock does nothing.
|
||
function heldRatio(state) {
|
||
if (!lockActive(state)) return null;
|
||
const request = liveRequest(state);
|
||
if (request) return parseAspectRatio(request, sourceSize(state));
|
||
const canvas = currentCanvas(state);
|
||
return canvas.width > 0 && canvas.height > 0 ? canvas.width / canvas.height : null;
|
||
}
|
||
|
||
function heldCropRatio(state) {
|
||
if (!lockActive(state) || aspectMode(state) !== "crop") return null;
|
||
const crop = resolveCrop(values(state.node), sourceSize(state));
|
||
return crop.width / crop.height;
|
||
}
|
||
|
||
// `base` is the padding a gesture started with: solving from it every move,
|
||
// instead of from the last move's result, keeps the split even.
|
||
function applyLock(state, ratio, driver = "x", base = null) {
|
||
if (!ratio || !hasPicture(state)) return false;
|
||
const current = { ...values(state.node), ...(base ?? {}) };
|
||
const pads = lockPadding(current, resolveCrop(current, sourceSize(state)), ratio, driver);
|
||
if (!pads) return false;
|
||
for (const [name, next] of Object.entries(pads)) setValue(state.node, name, next);
|
||
return true;
|
||
}
|
||
|
||
// The crop as the plain box it resolves to, for a crop that should drag
|
||
// freely: a chip crop leaves one side open for the backend to fill in.
|
||
function explicitCrop(current, source) {
|
||
const crop = resolveCrop(current, source);
|
||
return { crop_aspect_ratio: "free", crop_x: crop.x, crop_y: crop.y, crop_width: crop.width, crop_height: crop.height };
|
||
}
|
||
|
||
function setAspectLock(state, on) {
|
||
state.node.properties ??= {};
|
||
state.node.properties.ausboss_aspect_lock = Boolean(on);
|
||
// In crop mode a lit ratio goes onto the crop itself, so the run keeps it
|
||
// too; unlocked, the crop is a plain box again.
|
||
if (aspectMode(state) === "crop" && hasPicture(state)) {
|
||
const request = liveRequest(state);
|
||
if (on && request) setValue(state.node, "crop_aspect_ratio", request);
|
||
else if (!on) for (const [name, next] of Object.entries(explicitCrop(values(state.node), sourceSize(state)))) setValue(state.node, name, next);
|
||
}
|
||
draw(state); updateModalInfo(state); notifyAusbossChange();
|
||
}
|
||
|
||
// Tapping the lit ratio: back to the whole picture, and the lock goes off
|
||
// with the shape it was holding.
|
||
function clearAspect(state) {
|
||
state.node.properties ??= {};
|
||
state.node.properties.ausboss_aspect_lock = false;
|
||
fitAspect(state, "free", aspectMode(state));
|
||
delete state.node.properties.ausboss_fit_aspect;
|
||
draw(state); updateModalInfo(state); notifyAusbossChange();
|
||
}
|
||
|
||
// Reset crop: the whole picture again. Rotation and padding stay, except
|
||
// that with the padlock on, the bands it added while the crop was trimmed
|
||
// go too - the canvas holds its shape around the whole picture in the
|
||
// smallest size that does, instead of growing taller than the picture.
|
||
function resetCropKeepingShape(state) {
|
||
const node = state.node;
|
||
const ratio = hasPicture(state) ? heldRatio(state) : null;
|
||
const request = liveRequest(state);
|
||
setValue(node, "crop_aspect_ratio", "free");
|
||
fitCrop(state);
|
||
if (ratio && hasPicture(state)) {
|
||
if (request && aspectMode(state) === "crop") {
|
||
fitAspect(state, request, "crop");
|
||
} else {
|
||
const current = values(node);
|
||
const pads = tightLockPadding(current, resolveCrop(current, sourceSize(state)), ratio);
|
||
if (pads) for (const [name, next] of Object.entries(pads)) setValue(node, name, next);
|
||
}
|
||
}
|
||
settleRequest(state); draw(state); updateModalInfo(state); notifyAusbossChange();
|
||
}
|
||
|
||
// The orientation button: the lit or custom shape turned on its side. Pad
|
||
// mode pads the current crop to the turned ratio, centred; crop mode turns
|
||
// the crop box about its own centre, shrinking it evenly if it would leave
|
||
// the picture. The pixels never rotate. With nothing to turn (no picture
|
||
// yet, the untouched picture, a square) only the chips turn, so the next
|
||
// tap goes that way; a pick made before a picture arrived turns with them.
|
||
function turnCanvas(state) {
|
||
const node = state.node;
|
||
node.properties ??= {};
|
||
const portrait = chipsPortrait(state);
|
||
const request = liveRequest(state);
|
||
node.properties.ausboss_pad_portrait = !portrait;
|
||
if (!hasPicture(state)) {
|
||
if (request) node.properties.ausboss_fit_aspect = turnAspect(request);
|
||
draw(state); notifyAusbossChange();
|
||
return;
|
||
}
|
||
const source = sourceSize(state);
|
||
const current = values(node);
|
||
const canvas = currentCanvas(state);
|
||
if ((!request && isUntouched(current, source)) || canvas.width === canvas.height) {
|
||
draw(state); notifyAusbossChange();
|
||
return;
|
||
}
|
||
const crop = resolveCrop(current, source);
|
||
if (aspectMode(state) === "crop") {
|
||
const box = turnedCrop(crop, source);
|
||
const turned = request ? turnAspect(request) : null;
|
||
const keep = lockActive(state) && turned ? turned : "free";
|
||
setValue(node, "crop_aspect_ratio", keep);
|
||
setCrop(node, box);
|
||
if (turned) node.properties.ausboss_fit_aspect = turned;
|
||
} else {
|
||
const ratio = request ? parseAspectRatio(turnAspect(request), source) : canvas.height / canvas.width;
|
||
for (const [name, next] of Object.entries(padAround(crop, ratio))) setValue(node, name, next);
|
||
if (request) node.properties.ausboss_fit_aspect = turnAspect(request);
|
||
}
|
||
settleRequest(state);
|
||
resetView(state); 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 = resizeSwitch(state);
|
||
resizeLabel.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.";
|
||
resizeLabel.append(createElement("span", "", "Resize"), resize.pill);
|
||
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.sync();
|
||
budget.set(value(node, "megapixels", 1));
|
||
steps.set(value(node, "resolution_steps", 1));
|
||
resizeRow.style.display = hasResize && value(node, "resize_to_megapixels", false) ? "" : "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,button")) event.stopPropagation(); });
|
||
}
|
||
return [row, resizeRow];
|
||
}
|
||
|
||
// Resize as an off | on pill, on the node face and in the editor.
|
||
function resizeSwitch(state) {
|
||
const node = state.node;
|
||
const { pill, set } = segmentedPill([
|
||
["off", "off", "Keep the canvas size."],
|
||
["on", "on", "Resize the output to the megapixel budget, each side rounded to the step."],
|
||
], (key) => {
|
||
const on = key === "on";
|
||
if (Boolean(value(node, "resize_to_megapixels", false)) === on) return;
|
||
setValue(node, "resize_to_megapixels", on);
|
||
draw(state); updateModalInfo(state); notifyAusbossChange();
|
||
}, { className: "ausboss-transform-switch" });
|
||
const sync = () => set(value(node, "resize_to_megapixels", false) ? "on" : "off");
|
||
sync();
|
||
return { pill, sync };
|
||
}
|
||
|
||
// 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(); state.readoutKey = ""; 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);
|
||
});
|
||
// No chip names this shape: say what it is (the chip row says Custom).
|
||
if (!liveRequest(state) && !isUntouched(current, source)) {
|
||
const canvas = canvasSize(current, source);
|
||
parts.push(createElement("span", "", ` · ${ratioLabel(canvas.width, canvas.height)}`));
|
||
}
|
||
// What the lit ratio did - it re-applies to every new picture, so say it,
|
||
// and say plainly when there was nothing for it to do.
|
||
const note = ratioNote(state);
|
||
if (note) parts.push(createElement("em", note.warn ? "note" : "", note.text));
|
||
if (warnings.length) {
|
||
const line = createElement("i", "", `⚠ ${warnings.join(" · ")}`);
|
||
const fix = stretchFix(state);
|
||
line.title = fix.tip;
|
||
if (fix.pads) {
|
||
const even = createElement("button", "", "Even out");
|
||
even.type = "button";
|
||
even.title = "Add a few pixels of padding so the rounding stretches nothing.";
|
||
even.addEventListener("click", () => {
|
||
for (const [name, next] of Object.entries(fix.pads)) setValue(state.node, name, next);
|
||
settleRequest(state); draw(state); updateModalInfo(state); notifyAusbossChange();
|
||
});
|
||
line.append(even);
|
||
}
|
||
parts.push(line);
|
||
}
|
||
// Rebuilt only when it says something new, so a redraw between the press
|
||
// and the click on Even out never swaps the button out from under it.
|
||
const key = parts.map((part) => (typeof part === "string" ? part : part.outerHTML)).join("");
|
||
if (key !== state.readoutKey) {
|
||
state.readoutKey = key;
|
||
readout.replaceChildren(...parts);
|
||
}
|
||
readout.title = sizeLines(state).join("\n");
|
||
}
|
||
|
||
// The line under the size chain while a ratio is lit.
|
||
function ratioNote(state) {
|
||
const request = liveRequest(state);
|
||
if (!request || !hasPicture(state)) return null;
|
||
const pad = aspectMode(state) === "pad";
|
||
if (isUntouched(values(state.node), sourceSize(state))) {
|
||
return { warn: true, text: `Already ${request}: nothing to ${pad ? "add" : "trim"}. Pick another ratio or turn it.` };
|
||
}
|
||
return { warn: false, text: `${pad ? "Padded" : "Cropped"} to ${request}, the lit ratio.` };
|
||
}
|
||
|
||
// What to do about the resize stretching the picture: the step that would
|
||
// not, and in pad mode the padding that takes the rounding instead.
|
||
function stretchFix(state) {
|
||
const current = values(state.node);
|
||
const source = sourceSize(state);
|
||
const resize = resizeRequest(state.node);
|
||
const chain = sizeChain(current, source, resize);
|
||
const amount = `${(Math.abs(chain.stretch) * 100).toFixed(1)}%`;
|
||
const step = stepWithoutStretch(current, source, resize);
|
||
const pads = aspectMode(state) === "pad" ? evenOutPadding(current, source, resize) : null;
|
||
const tip = [
|
||
`The resize rounds the width and the height to a multiple of the Step (${resize?.steps ?? 1} px) separately, so the picture comes out ${amount} ${chain.stretch > 0 ? "wider" : "taller"} than it is.`,
|
||
step ? `Step ${step} keeps it under 1%.` : "",
|
||
pads ? "Or press Even out: it adds a few pixels of padding so the rounding stretches nothing." : "",
|
||
].filter(Boolean).join(" ");
|
||
return { tip, pads };
|
||
}
|
||
|
||
async function onSourceChanged(state, reset) {
|
||
const key = sourceKey(state.node, state.kind);
|
||
const changed = reset && sourceChanged(state.source, key, state.ready);
|
||
// The pick the old picture still had (or the one waiting for a first
|
||
// picture) is fitted to the new one; a shape dragged away from is not.
|
||
if (changed) state.refitTo = liveRequest(state);
|
||
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);
|
||
}
|
||
state.source = rememberedSource(state.source, key, reset);
|
||
await loadSource(state);
|
||
if (changed && state.image) { refitAspect(state); fitStageToPicture(state); }
|
||
}
|
||
|
||
// A new picture gets a picture area of its own shape: a wide photo swapped
|
||
// into a node sized for a tall one no longer sits small in a tall empty
|
||
// box. The stage never grows past the height you gave it and never drops
|
||
// under its floor, so nodes below are never covered.
|
||
function fitStageToPicture(state) {
|
||
const stage = state.previewCanvas;
|
||
const node = state.node;
|
||
if (state.disposed || !stage?.isConnected || !node.size || !hasPicture(state)) return;
|
||
const width = stage.clientWidth;
|
||
const height = stage.clientHeight;
|
||
if (!(width > 0) || !(height > 0)) return;
|
||
const current = values(node);
|
||
const source = sourceSize(state);
|
||
const crop = resolveCrop(current, source);
|
||
const padding = resolvePadding(current, crop);
|
||
const unionWidth = Math.max(source.width, crop.x - padding.left + padding.outputWidth) - Math.min(0, crop.x - padding.left);
|
||
const unionHeight = Math.max(source.height, crop.y - padding.top + padding.outputHeight) - Math.min(0, crop.y - padding.top);
|
||
const margin = stageHandleLayout(width, height).margin;
|
||
const ideal = Math.round((width - margin * 2) * unionHeight / Math.max(1, unionWidth) + margin * 2);
|
||
const ceiling = Math.max(state.stagePreference ?? height, stageHeightForWidth(width));
|
||
const target = clamp(ideal, stageHeightForWidth(width), ceiling);
|
||
if (Math.abs(target - height) < 6) return;
|
||
state.stageFit = target;
|
||
node.setSize?.([node.size[0], node.size[1] + (target - height)]);
|
||
node.setDirtyCanvas?.(true, true);
|
||
}
|
||
|
||
// The stage height you chose (by the corner, or the workflow's size), which
|
||
// a new picture may fill but never exceed.
|
||
function noteStageHeight(state) {
|
||
const height = state.previewCanvas?.clientHeight ?? 0;
|
||
if (!(height > 0)) return;
|
||
if (state.stageFit != null && Math.abs(height - state.stageFit) <= 3) return;
|
||
state.stageFit = null;
|
||
state.stagePreference = height;
|
||
}
|
||
|
||
// A lit chip is a standing request: the new source gets padded (or
|
||
// cropped) to it as well, so the canvas keeps its format across clips.
|
||
function refitAspect(state) {
|
||
const aspect = state.refitTo;
|
||
state.refitTo = null;
|
||
if (aspect) fitAspect(state, aspect, aspectMode(state));
|
||
else if (state.node.properties) delete state.node.properties.ausboss_fit_aspect;
|
||
// A new picture with no ratio lit is back to Source: a lock left from the
|
||
// old one would hold nothing.
|
||
settleRequest(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; state.editorSyncs = [];
|
||
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 = segmentedPill([["off", "off"], ["on", "on"]], (key) => { state.showBlend = key === "on"; show.set(key); draw(state); }, { className: "ausboss-transform-switch" });
|
||
show.set(state.showBlend ? "on" : "off");
|
||
show.pill.title = "Tint the paste mask the stitcher will use - the same mask math as the backend, at preview resolution.";
|
||
addLabeledControl(section, "Show blend", show.pill);
|
||
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;
|
||
}
|
||
|
||
// A sidebar row with one of the pack's number boxes, the unit inside it as
|
||
// on the node face.
|
||
function addScrubField(section, title, control) {
|
||
const label = createElement("label", "ausboss-transform-field");
|
||
label.append(createElement("span", "", title), control);
|
||
section.append(label);
|
||
return label;
|
||
}
|
||
|
||
// The editor's sidebar is built from the node's own controls: the same
|
||
// ratio row with its turn button and padlock, the same Fit pill, the same
|
||
// number boxes (Shift is the fine step everywhere, 0.1 of a degree for the
|
||
// rotation) and the same off | on switches.
|
||
function buildControls(state, sidebar) {
|
||
const node = state.node;
|
||
const cropSection = createElement("section", "ausboss-transform-section"); cropSection.append(sectionHeading("Crop & shape", "crop"));
|
||
const chipRow = buildAspectChipRow(state);
|
||
const modeRow = buildAspectModeRow(state, { alignment: false });
|
||
state.editorSyncs.push(chipRow.sync, modeRow.sync);
|
||
cropSection.append(chipRow.row, modeRow.row);
|
||
// Ratios from ausboss_presets.json that no button shows, in either
|
||
// orientation, stay one pick away.
|
||
const ratioWidget = widget(node, "crop_aspect_ratio");
|
||
let ratioValues = ratioWidget?.options?.values;
|
||
if (typeof ratioValues === "function") ratioValues = ratioValues(ratioWidget, node);
|
||
const shown = new Set(ASPECT_CHIP_ORDER.flatMap((aspect) => [aspect, turnAspect(aspect)]));
|
||
const extra = (Array.isArray(ratioValues) ? ratioValues : []).filter((item) => /^\d+:\d+$/.test(item) && !shown.has(item));
|
||
let more = null;
|
||
if (extra.length) {
|
||
more = createElement("select");
|
||
more.setAttribute("aria-label", "More ratios");
|
||
const first = createElement("option", "", "More ratios…"); first.value = ""; more.append(first);
|
||
for (const item of extra) { const option = createElement("option", "", item); option.value = item; more.append(option); }
|
||
more.title = "Your own ratios from ausboss_presets.json. Picking one works like a ratio button.";
|
||
more.addEventListener("change", () => { if (more.value) tapRatio(state, more.value); draw(state); updateModalInfo(state); });
|
||
addLabeledControl(cropSection, "More", more);
|
||
}
|
||
cropSection.append(createElement("div", "ausboss-transform-help", "Tap a ratio to pad the picture to it (or crop it, with Fit on crop); tap the lit one to go back to the whole picture. The padlock holds the shape while you drag. Drag the picture itself to move it inside its padding."));
|
||
|
||
const rotateSection = createElement("section", "ausboss-transform-section"); rotateSection.append(sectionHeading("Rotate", "rotate"));
|
||
const rotationNumber = makeScrubInput({ value: Number(value(node, "rotation_degrees", 0)) || 0, min: -180, max: 180, step: 1, fineStep: 0.1, decimals: 1, unit: "°", unitWidth: 22,
|
||
title: "Rotation in degrees: 1° per step, Shift for 0.1°. The crop stays over the same part of the picture.",
|
||
onChange: (degrees) => setRotation(state, degrees), onSettle: () => { settleRotation(state); notifyAusbossChange(); } });
|
||
addScrubField(rotateSection, "Degrees", 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.className = "ausboss-transform-swatch";
|
||
color.value = normalizeColor(value(node, "fill_color", "#808080"));
|
||
color.addEventListener("input", () => { setValue(node, "fill_color", color.value); draw(state); });
|
||
color.addEventListener("change", () => notifyAusbossChange());
|
||
addLabeledControl(padSection, "Fill", color);
|
||
const featherNumber = makeScrubInput({ value: value(node, "feather", 24), min: 0, max: 4096, step: 1, decimals: 0, unit: "px", unitWidth: 22,
|
||
title: "Mask feather in pixels.", onChange: (amount) => { setValue(node, "feather", amount); draw(state); }, onSettle: notifyAusbossChange });
|
||
addScrubField(padSection, "Feather", featherNumber.root);
|
||
const multiple = makeScrubInput({ value: value(node, "canvas_multiple", 1), min: 1, max: 4096, step: 8, fineStep: 1, decimals: 0, unit: "px", unitWidth: 22,
|
||
title: "Round the canvas up to a multiple of this many pixels (1 = off). The extra goes on the right and bottom as fill.",
|
||
onChange: (amount) => { setValue(node, "canvas_multiple", amount); draw(state); }, onSettle: notifyAusbossChange });
|
||
addScrubField(padSection, "Round canvas to", multiple.root);
|
||
const resetPad = createElement("button", "", "Reset padding"); resetPad.title = "Remove all padding.";
|
||
resetPad.addEventListener("click", () => { for (const name of ["pad_left", "pad_top", "pad_right", "pad_bottom"]) setValue(node, name, 0); settleRequest(state); 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;
|
||
let resize = null;
|
||
let budget = null;
|
||
let steps = null;
|
||
let method = null;
|
||
if (widget(node, "resize_to_megapixels")) {
|
||
resizeSection = createElement("section", "ausboss-transform-section");
|
||
resizeSection.append(sectionHeading("Resize output"));
|
||
resize = resizeSwitch(state);
|
||
addLabeledControl(resizeSection, "Resize", resize.pill);
|
||
budget = makeScrubInput({
|
||
value: value(node, "megapixels", 1),
|
||
min: 0.01, max: 16, step: 0.05, fineStep: 0.01, decimals: 2, unit: "MP", unitWidth: 22,
|
||
title: "Output budget in megapixels (x 1024x1024).",
|
||
onChange: (amount) => { setValue(node, "megapixels", amount); draw(state); updateModalInfo(state); },
|
||
onSettle: notifyAusbossChange,
|
||
});
|
||
addScrubField(resizeSection, "Megapixels", budget.root);
|
||
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); notifyAusbossChange(); });
|
||
addLabeledControl(resizeSection, "Method", method);
|
||
steps = makeScrubInput({
|
||
value: value(node, "resolution_steps", 1),
|
||
min: 1, max: 256, step: 8, fineStep: 1, decimals: 0, unit: "px", unitWidth: 22,
|
||
title: "Rounds each resized side to a multiple of this (32 for LTX and Wan).",
|
||
onChange: (step) => { setValue(node, "resolution_steps", step); draw(state); updateModalInfo(state); },
|
||
onSettle: notifyAusbossChange,
|
||
});
|
||
addScrubField(resizeSection, "Step", steps.root);
|
||
}
|
||
|
||
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 Round canvas to. 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);
|
||
sidebar.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||
state.syncEditorControls = () => {
|
||
rotationNumber.set(Number(value(node, "rotation_degrees", 0)) || 0);
|
||
featherNumber.set(value(node, "feather", 24));
|
||
multiple.set(value(node, "canvas_multiple", 1)); color.value = normalizeColor(value(node, "fill_color", "#808080"));
|
||
resize?.sync(); budget?.set(value(node, "megapixels", 1)); steps?.set(value(node, "resolution_steps", 1));
|
||
if (more) more.value = extra.includes(liveRequest(state) ?? "") ? liveRequest(state) : "";
|
||
};
|
||
}
|
||
|
||
// 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 (!hasPicture(state)) return;
|
||
const source = sourceSize(state);
|
||
const patch = fitSourceToAspect(source, aspect, mode);
|
||
// A crop reached by a chip keeps its ratio through drags only while the
|
||
// padlock is on; otherwise it is a plain box, free to drag anywhere.
|
||
if (mode === "crop" && aspect !== "free" && !lockOn(state)) Object.assign(patch, explicitCrop(patch, source));
|
||
for (const [name, next] of Object.entries(patch)) setValue(state.node, name, next);
|
||
state.node.properties ??= {};
|
||
if (aspect === "free" || aspect === "source") delete state.node.properties.ausboss_fit_aspect;
|
||
else state.node.properties.ausboss_fit_aspect = aspect;
|
||
state.node.properties.ausboss_aspect_mode = mode;
|
||
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;
|
||
// Rotation turns the picture, not the ratio you picked: a lit chip's shape
|
||
// is kept through the turn, and with the padlock on so is a custom one.
|
||
const request = liveRequest(state);
|
||
const hold = aspectMode(state) === "pad" && hasPicture(state)
|
||
? request ? parseAspectRatio(request, sourceSize(state)) : heldRatio(state)
|
||
: null;
|
||
state.rotationBase = { rotation: Number(now.rotation_degrees) || 0, values: now, written: null, hold };
|
||
return state.rotationBase;
|
||
}
|
||
function settleRotation(state) { state.rotationBase = null; settleRequest(state); }
|
||
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 held shape 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]);
|
||
}
|
||
if (base.hold) applyLock(state, base.hold, "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, view: { x: 0, y: 0, width, height } };
|
||
}
|
||
|
||
// 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?.();
|
||
for (const sync of state.editorSyncs ?? []) sync();
|
||
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 picture is what the node face is for, so the stage never gets shorter
|
||
// than stageHeightForWidth for its width: the panel's floor is the rows
|
||
// around the stage plus that, and a corner drag stops there. The rows are
|
||
// measured on every draw; before the panel is on screen, these stand in:
|
||
// the rows of a node with no file picked yet, as the examples open (the
|
||
// canvas row's Resize adds the budget row under it).
|
||
const PANEL_CHROME_ESTIMATE = { image: 262, video: 365, clip: 451 };
|
||
const RESIZE_ROW = 30;
|
||
// Node width minus stage width: the DOM widget frame, panel padding, borders.
|
||
const PANEL_SIDE_INSET = 38;
|
||
// The narrowest face the chip row and the button rows fit on.
|
||
export const TRANSFORM_MIN_WIDTH = 330;
|
||
|
||
function stageWidth(state) {
|
||
return state.previewCanvas?.clientWidth || Math.max(0, (Number(state.node.size?.[0]) || TRANSFORM_MIN_WIDTH) - PANEL_SIDE_INSET);
|
||
}
|
||
|
||
// Everything in the panel but the stage: rows, the gaps between the shown
|
||
// ones, and the panel's own padding. Null while the panel is not laid out.
|
||
function measureChrome(state) {
|
||
const stage = state.previewCanvas;
|
||
const panel = stage?.parentElement;
|
||
if (!panel?.isConnected || !panel.offsetHeight) return state.chromeHeight ?? null;
|
||
const style = getComputedStyle(panel);
|
||
const gap = parseFloat(style.rowGap) || 0;
|
||
let height = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
|
||
let shown = 0;
|
||
for (const child of panel.children) {
|
||
if (getComputedStyle(child).display === "none") continue;
|
||
shown += 1;
|
||
if (child !== stage) height += child.offsetHeight;
|
||
}
|
||
state.chromeHeight = Math.round(height + Math.max(0, shown - 1) * gap);
|
||
return state.chromeHeight;
|
||
}
|
||
|
||
// The panel's minimum height: its rows, the stage floor with the stage's
|
||
// border, and the frame the frontend insets a DOM widget by.
|
||
export function transformPanelFloor(node) {
|
||
const state = node?.__ausbossTransformState;
|
||
if (!state) return 0;
|
||
const estimate = PANEL_CHROME_ESTIMATE[state.isClip ? "clip" : state.kind] + (value(node, "resize_to_megapixels", false) ? RESIZE_ROW : 0);
|
||
return (state.chromeHeight ?? estimate) + stageHeightForWidth(stageWidth(state)) + 2 + WIDGET_FRAME;
|
||
}
|
||
|
||
// A row that appears later (Resize on, a readout that wraps) would squeeze
|
||
// the stage under its floor: the node grows by the difference instead, so
|
||
// it costs node height, not picture. Never shrinks a node.
|
||
function keepStageRoom(state) {
|
||
const height = state.previewCanvas?.clientHeight ?? 0;
|
||
const node = state.node;
|
||
if (state.disposed || !height || !node.size) return;
|
||
// Nodes 2.0 has no layout floor for width: lend it the face's own, or a
|
||
// corner drag squeezes the chips and buttons down to 225px.
|
||
holdVueNodeMinWidth(state.previewCanvas.parentElement, TRANSFORM_MIN_WIDTH);
|
||
measureChrome(state);
|
||
const floor = stageHeightForWidth(stageWidth(state));
|
||
// Nodes 2.0 sizes a node from its content, so the floor also has to be
|
||
// the stage's own minimum there; in the classic view the layout floor
|
||
// already keeps the panel this tall.
|
||
const minimum = `${floor}px`;
|
||
if (state.previewCanvas.style.minHeight !== minimum) state.previewCanvas.style.minHeight = minimum;
|
||
if (height >= floor - 1) { 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] + (floor - height);
|
||
state.stageGrowth = { height, target };
|
||
node.setSize?.([node.size[0], target]);
|
||
node.setDirtyCanvas?.(true, true);
|
||
}
|
||
|
||
// The stage shows the output as it will be: the kept picture inside the
|
||
// crop, and all added space - padding and the corners a turn opens - in the
|
||
// real fill colour under a faint hatch, so black bands on a black stage
|
||
// still read as bands. Only the picture the crop cuts away is darkened.
|
||
function drawScene(context, state, render, compact, interactive) {
|
||
const { sourceRect, cropRect, outputRect } = render; context.save();
|
||
const fill = normalizeColor(value(state.node, "fill_color", "#808080"));
|
||
const picture = () => {
|
||
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();
|
||
};
|
||
picture();
|
||
context.save(); context.fillStyle = "rgba(8,10,12,.66)";
|
||
context.beginPath(); context.rect(0, 0, context.canvas.width, context.canvas.height); context.rect(outputRect.x, outputRect.y, outputRect.width, outputRect.height); context.fill("evenodd"); context.restore();
|
||
context.fillStyle = fill; context.fillRect(outputRect.x, outputRect.y, outputRect.width, outputRect.height);
|
||
drawHatch(context, outputRect, fill);
|
||
context.save(); context.beginPath(); context.rect(cropRect.x, cropRect.y, cropRect.width, cropRect.height); context.clip(); picture(); 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);
|
||
const moving = state.drag?.kind === "move" && state.drag.canvas === context.canvas;
|
||
const hover = state.hoverMove?.canvas === context.canvas ? state.hoverMove.room : null;
|
||
if (moving || (hover && !state.drag)) drawMoveArrows(context, cropRect, moving ? moveRoomFor(state, render) : hover);
|
||
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 note = ratioNote(state);
|
||
const noteText = note?.text ?? "";
|
||
const widths = lines.map((line) => line.reduce((sum, piece) => sum + piece.width, 0));
|
||
if (warningText) widths.push(measure(muted, warningText));
|
||
if (noteText) widths.push(measure(muted, noteText));
|
||
const boxWidth = Math.min(viewWidth - 8, Math.max(...widths) + 12);
|
||
const boxHeight = (lines.length + (warningText ? 1 : 0) + (noteText ? 1 : 0)) * READOUT_LINE + 5;
|
||
const { x, top } = sizeBoxPlace(state, render, boxWidth, boxHeight, viewWidth, viewHeight);
|
||
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, " ");
|
||
}
|
||
});
|
||
let extra = lines.length;
|
||
if (noteText) { extra += 1; write(muted, note.warn ? "#ffc46b" : "#8ca8a5", noteText, x + 6, top + 1 + extra * READOUT_LINE); }
|
||
if (warningText) { extra += 1; write(muted, "#ffc46b", warningText, x + 6, top + 1 + extra * READOUT_LINE); }
|
||
context.restore();
|
||
}
|
||
|
||
// Where the editor's size box goes: under the bottom diamond, else over the
|
||
// top one, else a corner of the stage - the first spot that covers no
|
||
// handle and stays on the stage.
|
||
function sizeBoxPlace(state, render, width, height, viewWidth, viewHeight) {
|
||
const { outputRect } = render;
|
||
const offset = render.layout?.padOffset ?? 38;
|
||
const centred = clamp(outputRect.x + outputRect.width / 2 - width / 2, 4, Math.max(4, viewWidth - width - 4));
|
||
const handles = [
|
||
...paddingHandleCenters(outputRect, offset).map((point) => ({ ...point, r: 13 })),
|
||
...cropHandleCenters(render.cropRect).map((point) => ({ ...point, r: 8 })),
|
||
{ ...rotationHandle(state, render), r: 15 },
|
||
];
|
||
const candidates = [
|
||
{ x: centred, top: outputRect.y + outputRect.height + offset + 14 },
|
||
{ x: centred, top: outputRect.y - offset - 14 - height },
|
||
{ x: 6, top: viewHeight - height - 6 },
|
||
{ x: viewWidth - width - 6, top: viewHeight - height - 6 },
|
||
{ x: 6, top: 6 },
|
||
{ x: centred, top: outputRect.y + outputRect.height + 6 },
|
||
];
|
||
const fits = ({ x, top }) => x >= 2 && top >= 2 && x + width <= viewWidth - 2 && top + height <= viewHeight - 2
|
||
&& handles.every((handle) => handle.x + handle.r < x || handle.x - handle.r > x + width || handle.y + handle.r < top || handle.y - handle.r > top + height);
|
||
const spot = candidates.find(fits) ?? candidates[0];
|
||
return { x: spot.x, top: clamp(spot.top, 4, Math.max(4, viewHeight - height - 4)) };
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Faint diagonal lines over added space: light on a dark fill, dark on a
|
||
// light one.
|
||
function drawHatch(context, rect, fill) {
|
||
if (!(rect.width > 0) || !(rect.height > 0)) return;
|
||
const hex = /^#([0-9a-f]{6})$/i.exec(fill)?.[1] ?? "808080";
|
||
const [r, g, b] = [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16));
|
||
const light = (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.45;
|
||
context.save();
|
||
context.beginPath(); context.rect(rect.x, rect.y, rect.width, rect.height); context.clip();
|
||
context.strokeStyle = light ? "rgba(0,0,0,.17)" : "rgba(255,255,255,.14)";
|
||
context.lineWidth = 1;
|
||
context.beginPath();
|
||
const step = 9;
|
||
for (let d = -rect.height; d < rect.width; d += step) {
|
||
context.moveTo(rect.x + d, rect.y + rect.height);
|
||
context.lineTo(rect.x + d + rect.height, rect.y);
|
||
}
|
||
context.stroke();
|
||
context.restore();
|
||
}
|
||
|
||
// Small arrows inside the picture's edges, toward each side it can move.
|
||
function drawMoveArrows(context, rect, room) {
|
||
if (!room) return;
|
||
const inset = Math.min(24, rect.width / 4, rect.height / 4);
|
||
const size = 6;
|
||
const cx = rect.x + rect.width / 2;
|
||
const cy = rect.y + rect.height / 2;
|
||
const arrows = [
|
||
[room.left, rect.x + inset, cy, -1, 0],
|
||
[room.right, rect.x + rect.width - inset, cy, 1, 0],
|
||
[room.up, cx, rect.y + inset, 0, -1],
|
||
[room.down, cx, rect.y + rect.height - inset, 0, 1],
|
||
];
|
||
context.save();
|
||
context.lineWidth = 2.5; context.lineCap = "round"; context.lineJoin = "round";
|
||
for (const [on, x, y, dx, dy] of arrows) {
|
||
if (!on) continue;
|
||
const tip = { x: x + dx * size, y: y + dy * size };
|
||
const a = { x: x - dx * size + dy * size, y: y - dy * size + dx * size };
|
||
const b = { x: x - dx * size - dy * size, y: y - dy * size - dx * size };
|
||
for (const [color, width] of [["rgba(0,0,0,.65)", 4.5], ["#ffffff", 2.2]]) {
|
||
context.strokeStyle = color; context.lineWidth = width;
|
||
context.beginPath(); context.moveTo(a.x, a.y); context.lineTo(tip.x, tip.y); context.lineTo(b.x, b.y); context.stroke();
|
||
}
|
||
}
|
||
context.restore();
|
||
}
|
||
|
||
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 arm = render.layout?.rotateArm ?? 34;
|
||
// While you turn it, the knob stays on its arm; at rest it keeps clear of
|
||
// the padding diamonds and crop squares, or the nearer one would take
|
||
// every press.
|
||
if (state.drag?.kind === "rotation") return { corner, handle: placeKnob(corner, { x: centerX, y: centerY }, arm) };
|
||
const obstacles = [
|
||
...paddingHandleCenters(render.outputRect, render.layout?.padOffset).map((point) => ({ ...point, clearance: KNOB_CLEARANCE.padding })),
|
||
...cropHandleCenters(render.cropRect).map((point) => ({ ...point, clearance: KNOB_CLEARANCE.crop })),
|
||
];
|
||
return { corner, handle: placeKnob(corner, { x: centerX, y: centerY }, arm, obstacles, render.view) };
|
||
}
|
||
function rotationHandle(state, render) {
|
||
return rotationAnchor(state, render).handle;
|
||
}
|
||
function drawRotationHandle(context, state, render, active) {
|
||
const { corner, handle } = rotationAnchor(state, render);
|
||
render.knob = handle;
|
||
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";
|
||
if (state.hoverMove?.canvas === canvas) { state.hoverMove = null; draw(state); }
|
||
}, { 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 },
|
||
pads: Object.fromEntries(["pad_left", "pad_top", "pad_right", "pad_bottom"].map((name) => [name, value(state.node, name, 0)])),
|
||
rotation: Number(value(state.node, "rotation_degrees", 0)),
|
||
// The shape the padlock holds for this gesture, taken as you grab.
|
||
hold: heldRatio(state),
|
||
cropHold: heldCropRatio(state),
|
||
};
|
||
if (selected) state.drag = { ...selected, ...base };
|
||
else if (inside(point, render.cropRect) && anyRoom(moveRoomFor(state, render))) 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 = drag.cropHold ?? 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);
|
||
if (aspectMode(state) === "pad") applyLock(state, drag.hold, cropDriver(drag.crop, next, drag.name), drag.pads);
|
||
} else if (drag.kind === "move") {
|
||
const crop = drag.crop;
|
||
const dx = dxScreen / drag.map.scale; const dy = dyScreen / drag.map.scale;
|
||
setCrop(state.node, { ...crop, x: Math.round(clamp(crop.x + dx, 0, drag.source.width - crop.width)), y: Math.round(clamp(crop.y + dy, 0, drag.source.height - crop.height)) });
|
||
// Where the crop spans the whole picture it has nowhere to go: the
|
||
// picture slides inside its padding instead, in a canvas that keeps
|
||
// its size.
|
||
const spans = { x: crop.width >= drag.source.width, y: crop.height >= drag.source.height };
|
||
if (spans.x || spans.y) for (const [name, next] of Object.entries(slidePadding(drag.pads, dx, dy, spans))) setValue(state.node, name, next);
|
||
} 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));
|
||
// Holding the shape, the handle stops where the other axis would need
|
||
// negative padding; the other axis then follows.
|
||
const ratio = drag.hold;
|
||
// Solved from the padding the drag started with, so the other axis's
|
||
// new bands split evenly however many moves the drag takes.
|
||
const start = { ...values(state.node), ...drag.pads };
|
||
if (ratio) next = Math.max(next, lockedPadMinimum(start, resolveCrop(start, drag.source), ratio, drag.name));
|
||
setValue(state.node, drag.name, next);
|
||
if (ratio) applyLock(state, ratio, paddingAxis(drag.name), { ...drag.pads, [drag.name]: next });
|
||
}
|
||
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);
|
||
// A shape the drag left is no longer the pick (the chip went dark).
|
||
if (kind !== "pan") settleRequest(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));
|
||
const room = !selected && inside(point, render.cropRect) ? moveRoomFor(state, render) : null;
|
||
canvas.style.cursor = selected?.kind === "rotation" ? "crosshair" : selected ? "grab" : room ? moveCursor(room) : "default";
|
||
// Over the picture, small arrows point to the sides it can move toward.
|
||
const hover = room && anyRoom(room) ? { canvas, room } : null;
|
||
if (JSON.stringify(hover?.room ?? null) !== JSON.stringify(state.hoverMove?.room ?? null) || hover?.canvas !== state.hoverMove?.canvas) {
|
||
state.hoverMove = hover;
|
||
draw(state);
|
||
}
|
||
}
|
||
function moveRoomFor(state, render) {
|
||
return moveRoom(render.crop, render.source, values(state.node));
|
||
}
|
||
function anyRoom(room) { return Boolean(room && (room.left || room.right || room.up || room.down)); }
|
||
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
|
||
? `Round canvas to ${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("Round canvas to adds a strip of fill the model paints. With Resize on, Step already rounds the size, so 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); });
|
||
},
|
||
});
|
||
}
|