466 lines
18 KiB
JavaScript
466 lines
18 KiB
JavaScript
import { app } from "/scripts/app.js";
|
|
|
|
const NODE_NAME = "DetailDaemonSamplerGUINode";
|
|
const GRAPH_HEIGHT = 250;
|
|
const MIN_WIDTH = 420;
|
|
const PARAMETER_NAMES = [
|
|
"detail_amount",
|
|
"start",
|
|
"end",
|
|
"bias",
|
|
"exponent",
|
|
"start_offset",
|
|
"end_offset",
|
|
"fade",
|
|
"smooth",
|
|
];
|
|
|
|
function isSamplerGUINode(node) {
|
|
return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME;
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function round(value, places = 2) {
|
|
const factor = 10 ** places;
|
|
return Math.round(value * factor) / factor;
|
|
}
|
|
|
|
function formatTooltipValue(value) {
|
|
return Number(value.toFixed(2)).toString();
|
|
}
|
|
|
|
function getHandleTooltip(drag, parameters) {
|
|
const values = {
|
|
start: [
|
|
["start", parameters.start],
|
|
["start_offset", parameters.startOffset],
|
|
],
|
|
exponent_start: [["exponent", parameters.exponent]],
|
|
peak: [
|
|
["detail_amount", parameters.detailAmount],
|
|
["bias", parameters.bias],
|
|
],
|
|
exponent_end: [["exponent", parameters.exponent]],
|
|
end: [
|
|
["end", parameters.end],
|
|
["end_offset", parameters.endOffset],
|
|
],
|
|
}[drag];
|
|
|
|
return values?.map(([name, value]) => `${name}:${formatTooltipValue(value)}`).join(" / ") ?? "";
|
|
}
|
|
|
|
function findWidget(node, name) {
|
|
return node.widgets?.find((widget) => widget.name === name);
|
|
}
|
|
|
|
function readParameters(node) {
|
|
const value = (name, fallback) => {
|
|
const current = Number(findWidget(node, name)?.value);
|
|
return Number.isFinite(current) ? current : fallback;
|
|
};
|
|
|
|
const end = clamp(value("end", 0.8), 0, 1);
|
|
const start = Math.min(clamp(value("start", 0.2), 0, 1), end);
|
|
return {
|
|
detailAmount: clamp(value("detail_amount", 0.1), -5, 5),
|
|
start,
|
|
end,
|
|
bias: clamp(value("bias", 0.5), 0, 1),
|
|
exponent: clamp(value("exponent", 1), 0, 10),
|
|
startOffset: clamp(value("start_offset", 0), -1, 1),
|
|
endOffset: clamp(value("end_offset", 0), -1, 1),
|
|
fade: clamp(value("fade", 0), 0, 1),
|
|
smooth: Boolean(findWidget(node, "smooth")?.value ?? true),
|
|
};
|
|
}
|
|
|
|
function makeSchedule(parameters, steps = 121) {
|
|
const schedule = new Float64Array(steps);
|
|
const mid = parameters.start + parameters.bias * (parameters.end - parameters.start);
|
|
const startIndex = Math.round(parameters.start * (steps - 1));
|
|
const midIndex = Math.round(mid * (steps - 1));
|
|
const endIndex = Math.round(parameters.end * (steps - 1));
|
|
|
|
schedule.fill(parameters.startOffset, 0, startIndex);
|
|
for (let index = startIndex; index <= midIndex; index++) {
|
|
const length = midIndex - startIndex;
|
|
let value = length ? (index - startIndex) / length : 0;
|
|
if (parameters.smooth) value = 0.5 * (1 - Math.cos(value * Math.PI));
|
|
value **= parameters.exponent;
|
|
schedule[index] = value * (parameters.detailAmount - parameters.startOffset) + parameters.startOffset;
|
|
}
|
|
for (let index = midIndex; index <= endIndex; index++) {
|
|
const length = endIndex - midIndex;
|
|
let value = length ? 1 - (index - midIndex) / length : 1;
|
|
if (parameters.smooth) value = 0.5 * (1 - Math.cos(value * Math.PI));
|
|
value **= parameters.exponent;
|
|
schedule[index] = value * (parameters.detailAmount - parameters.endOffset) + parameters.endOffset;
|
|
}
|
|
schedule.fill(parameters.endOffset, endIndex + 1);
|
|
|
|
const fadeScale = 1 - parameters.fade;
|
|
for (let index = 0; index < schedule.length; index++) schedule[index] *= fadeScale;
|
|
return schedule;
|
|
}
|
|
|
|
function setWidgetValue(node, name, value) {
|
|
const widget = findWidget(node, name);
|
|
if (!widget || Object.is(widget.value, value)) return;
|
|
widget.value = value;
|
|
widget.callback?.(value, app.canvas, node);
|
|
node.graph?.setDirtyCanvas?.(true, true);
|
|
node.setDirtyCanvas?.(true, true);
|
|
}
|
|
|
|
function chainCallback(object, name, callback) {
|
|
const original = object[name];
|
|
object[name] = function (...args) {
|
|
const result = original?.apply(this, args);
|
|
callback.apply(this, args);
|
|
return result;
|
|
};
|
|
}
|
|
|
|
function createGraph(node) {
|
|
if (node._detailDaemonGraph) return;
|
|
|
|
const root = document.createElement("div");
|
|
root.style.cssText = 'width:100%;min-height:200px;box-sizing:border-box;padding:6px 0;overflow:hidden;'
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.style.cssText =
|
|
'display:block;width:100%;height:100%;border:1px solid rgba(255,255,255,.16);border-radius:6px;background:#17191c;touch-action:none;cursor:default;'
|
|
canvas.title = "Drag the start, exponent handles, peak, and end. Double-click the graph to restore the default curve.";
|
|
root.appendChild(canvas);
|
|
|
|
const state = {
|
|
drag: null,
|
|
dragStartY: 0,
|
|
dragStartExponent: 1,
|
|
dragYMax: 1,
|
|
frame: 0,
|
|
layout: null,
|
|
removed: false,
|
|
};
|
|
node._detailDaemonGraph = state;
|
|
|
|
const requestDraw = (force = false) => {
|
|
if (state.removed) return;
|
|
if (state.frame) {
|
|
if (!force) return;
|
|
cancelAnimationFrame(state.frame);
|
|
state.frame = 0;
|
|
}
|
|
state.frame = requestAnimationFrame(() => {
|
|
state.frame = 0;
|
|
draw();
|
|
});
|
|
};
|
|
node._detailDaemonRequestDraw = requestDraw;
|
|
|
|
const draw = () => {
|
|
const width = canvas.clientWidth;
|
|
const height = canvas.clientHeight;
|
|
if (width < 1 || height < 1) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
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);
|
|
|
|
const parameters = readParameters(node);
|
|
const schedule = makeSchedule(parameters);
|
|
const fadeScale = 1 - parameters.fade;
|
|
const peakX = parameters.start + parameters.bias * (parameters.end - parameters.start);
|
|
const exponentStrength = 0.5 ** parameters.exponent;
|
|
const exponentStartY = (parameters.startOffset + (parameters.detailAmount - parameters.startOffset) * exponentStrength) * fadeScale;
|
|
const exponentEndY = (parameters.endOffset + (parameters.detailAmount - parameters.endOffset) * exponentStrength) * fadeScale;
|
|
const handles = [
|
|
{ name: "start", x: parameters.start, y: parameters.startOffset * fadeScale, color: "#5ac8fa" },
|
|
{ name: "exponent_start", x: (parameters.start + peakX) / 2, y: exponentStartY, color: "#bf5af2", label: "E" },
|
|
{ name: "peak", x: peakX, y: parameters.detailAmount * fadeScale, color: "#ffcc00" },
|
|
{ name: "exponent_end", x: (peakX + parameters.end) / 2, y: exponentEndY, color: "#bf5af2", label: "E" },
|
|
{ name: "end", x: parameters.end, y: parameters.endOffset * fadeScale, color: "#ff6b6b" },
|
|
];
|
|
const largest = Math.max(1, ...schedule.map(Math.abs), ...handles.map((handle) => Math.abs(handle.y)));
|
|
const yMax = state.drag ? state.dragYMax : Math.min(5.5, Math.max(1, largest * 1.2));
|
|
const padding = { left: 42, right: 12, top: 25, bottom: 27 };
|
|
const plotWidth = Math.max(1, width - padding.left - padding.right);
|
|
const plotHeight = Math.max(1, height - padding.top - padding.bottom);
|
|
const pointX = (value) => padding.left + value * plotWidth;
|
|
const pointY = (value) => padding.top + (1 - (value + yMax) / (2 * yMax)) * plotHeight;
|
|
state.layout = { padding, plotWidth, plotHeight, yMax, handles };
|
|
|
|
context.fillStyle = "#17191c";
|
|
context.fillRect(0, 0, width, height);
|
|
context.font = "11px sans-serif";
|
|
context.lineWidth = 1;
|
|
|
|
for (let tick = 0; tick <= 4; tick++) {
|
|
const ratio = tick / 4;
|
|
const x = pointX(ratio);
|
|
context.strokeStyle = "rgba(255,255,255,.09)";
|
|
context.beginPath();
|
|
context.moveTo(x, padding.top);
|
|
context.lineTo(x, padding.top + plotHeight);
|
|
context.stroke();
|
|
context.fillStyle = "rgba(255,255,255,.55)";
|
|
context.textAlign = "center";
|
|
context.fillText(`${Math.round(ratio * 100)}%`, x, height - 8);
|
|
}
|
|
|
|
for (let tick = -2; tick <= 2; tick++) {
|
|
const value = tick * yMax / 2;
|
|
const y = pointY(value);
|
|
context.strokeStyle = tick === 0 ? "rgba(255,255,255,.28)" : "rgba(255,255,255,.09)";
|
|
context.beginPath();
|
|
context.moveTo(padding.left, y);
|
|
context.lineTo(padding.left + plotWidth, y);
|
|
context.stroke();
|
|
context.fillStyle = "rgba(255,255,255,.55)";
|
|
context.textAlign = "right";
|
|
context.fillText(value.toFixed(yMax < 1 ? 2 : 1), padding.left - 6, y + 4);
|
|
}
|
|
|
|
const gradient = context.createLinearGradient(padding.left, 0, padding.left + plotWidth, 0);
|
|
gradient.addColorStop(0, "#5ac8fa");
|
|
gradient.addColorStop(0.5, "#ffcc00");
|
|
gradient.addColorStop(1, "#ff6b6b");
|
|
context.strokeStyle = gradient;
|
|
context.lineWidth = 2.5;
|
|
context.beginPath();
|
|
schedule.forEach((value, index) => {
|
|
const x = pointX(index / (schedule.length - 1));
|
|
const y = pointY(value);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.stroke();
|
|
|
|
for (const handle of handles) {
|
|
const x = pointX(handle.x);
|
|
const y = pointY(handle.y);
|
|
handle.canvasX = x;
|
|
handle.canvasY = y;
|
|
context.fillStyle = handle.color;
|
|
context.strokeStyle = "#101214";
|
|
context.lineWidth = 2;
|
|
context.beginPath();
|
|
context.arc(x, y, state.drag === handle.name ? 7 : 6, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.stroke();
|
|
if (handle.label) {
|
|
context.fillStyle = "#fff";
|
|
context.font = "bold 9px sans-serif";
|
|
context.textAlign = "center";
|
|
context.fillText(handle.label, x, y + 3);
|
|
}
|
|
}
|
|
|
|
if (state.drag) {
|
|
const activeHandle = handles.find((handle) => handle.name === state.drag);
|
|
const tooltip = getHandleTooltip(state.drag, parameters);
|
|
if (activeHandle && tooltip) {
|
|
context.font = "bold 11px sans-serif";
|
|
const tooltipPadding = 8;
|
|
const tooltipHeight = 24;
|
|
const tooltipWidth = context.measureText(tooltip).width + tooltipPadding * 2;
|
|
const tooltipGap = 12;
|
|
const tooltipX = clamp(activeHandle.canvasX - tooltipWidth / 2, 4, width - tooltipWidth - 4);
|
|
let tooltipY = activeHandle.canvasY - tooltipHeight - tooltipGap;
|
|
if (tooltipY < 4) tooltipY = activeHandle.canvasY + tooltipGap;
|
|
tooltipY = clamp(tooltipY, 4, height - tooltipHeight - 4);
|
|
|
|
context.fillStyle = "rgba(8,10,12,.94)";
|
|
context.strokeStyle = activeHandle.color;
|
|
context.lineWidth = 1;
|
|
context.beginPath();
|
|
if (typeof context.roundRect === "function") context.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 5);
|
|
else context.rect(tooltipX, tooltipY, tooltipWidth, tooltipHeight);
|
|
context.fill();
|
|
context.stroke();
|
|
|
|
context.fillStyle = "rgba(255,255,255,.92)";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillText(tooltip, tooltipX + tooltipWidth / 2, tooltipY + tooltipHeight / 2);
|
|
context.textBaseline = "alphabetic";
|
|
}
|
|
}
|
|
|
|
context.fillStyle = "rgba(255,255,255,.78)";
|
|
context.textAlign = "left";
|
|
context.font = "12px sans-serif";
|
|
context.fillText("Detail adjustment schedule", padding.left, 16);
|
|
context.textAlign = "right";
|
|
context.fillStyle = "rgba(255,255,255,.48)";
|
|
context.font = "10px sans-serif";
|
|
context.fillText("drag handles", width - padding.right, 16);
|
|
};
|
|
|
|
const pointerPosition = (event) => {
|
|
const bounds = canvas.getBoundingClientRect();
|
|
const scaleX = bounds.width ? canvas.clientWidth / bounds.width : 1;
|
|
const scaleY = bounds.height ? canvas.clientHeight / bounds.height : 1;
|
|
return {
|
|
x: (event.clientX - bounds.left) * scaleX,
|
|
y: (event.clientY - bounds.top) * scaleY,
|
|
};
|
|
};
|
|
|
|
const updateFromPointer = (event) => {
|
|
if (!state.drag || !state.layout) return;
|
|
const position = pointerPosition(event);
|
|
const { padding, plotWidth, plotHeight, yMax } = state.layout;
|
|
const x = clamp((position.x - padding.left) / plotWidth, 0, 1);
|
|
const displayedY = clamp((1 - (position.y - padding.top) / plotHeight) * 2 * yMax - yMax, -yMax, yMax);
|
|
const parameters = readParameters(node);
|
|
const fadeScale = Math.max(0.01, 1 - parameters.fade);
|
|
|
|
if (state.drag.startsWith("exponent_")) {
|
|
const delta = (position.y - state.dragStartY) / plotHeight * 10;
|
|
const exponent = Math.round(clamp(state.dragStartExponent + delta, 0, 10) / 0.05) * 0.05;
|
|
setWidgetValue(node, "exponent", round(exponent));
|
|
} else if (state.drag === "start") {
|
|
setWidgetValue(node, "start", round(clamp(x, 0, parameters.end)));
|
|
setWidgetValue(node, "start_offset", round(clamp(displayedY / fadeScale, -1, 1)));
|
|
} else if (state.drag === "peak") {
|
|
const span = parameters.end - parameters.start;
|
|
const bias = span > 0 ? (x - parameters.start) / span : 0;
|
|
setWidgetValue(node, "bias", round(clamp(bias, 0, 1)));
|
|
setWidgetValue(node, "detail_amount", round(clamp(displayedY / fadeScale, -5, 5)));
|
|
} else if (state.drag === "end") {
|
|
setWidgetValue(node, "end", round(clamp(x, parameters.start, 1)));
|
|
setWidgetValue(node, "end_offset", round(clamp(displayedY / fadeScale, -1, 1)));
|
|
}
|
|
requestDraw();
|
|
};
|
|
|
|
canvas.addEventListener("pointerdown", (event) => {
|
|
if (event.button !== 0 || !state.layout) return;
|
|
const position = pointerPosition(event);
|
|
let closest = null;
|
|
let distance = 14;
|
|
for (const handle of state.layout.handles) {
|
|
const current = Math.hypot(position.x - handle.canvasX, position.y - handle.canvasY);
|
|
if (current < distance) {
|
|
closest = handle;
|
|
distance = current;
|
|
}
|
|
}
|
|
if (!closest) return;
|
|
node.graph?.beforeChange?.();
|
|
state.drag = closest.name;
|
|
state.dragStartY = position.y;
|
|
state.dragStartExponent = readParameters(node).exponent;
|
|
state.dragYMax = state.layout.yMax;
|
|
canvas.setPointerCapture(event.pointerId);
|
|
canvas.style.cursor = "grabbing";
|
|
requestDraw();
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
canvas.addEventListener("pointermove", (event) => {
|
|
if (state.drag) {
|
|
updateFromPointer(event);
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (!state.layout) return;
|
|
const position = pointerPosition(event);
|
|
canvas.style.cursor = state.layout.handles.some((handle) => Math.hypot(position.x - handle.canvasX, position.y - handle.canvasY) < 14) ? "grab" : "default";
|
|
});
|
|
const endDrag = (event) => {
|
|
if (!state.drag) return;
|
|
state.drag = null;
|
|
canvas.style.cursor = "default";
|
|
try {
|
|
canvas.releasePointerCapture(event.pointerId);
|
|
} catch (_) {
|
|
}
|
|
node.graph?.afterChange?.();
|
|
node.graph?.change?.();
|
|
requestDraw();
|
|
};
|
|
canvas.addEventListener("pointerup", endDrag);
|
|
canvas.addEventListener("pointercancel", endDrag);
|
|
canvas.addEventListener("dblclick", (event) => {
|
|
node.graph?.beforeChange?.();
|
|
setWidgetValue(node, "detail_amount", 0.1);
|
|
setWidgetValue(node, "start", 0.2);
|
|
setWidgetValue(node, "end", 0.8);
|
|
setWidgetValue(node, "bias", 0.5);
|
|
setWidgetValue(node, "exponent", 1);
|
|
setWidgetValue(node, "start_offset", 0);
|
|
setWidgetValue(node, "end_offset", 0);
|
|
setWidgetValue(node, "fade", 0);
|
|
setWidgetValue(node, "smooth", true);
|
|
node.graph?.afterChange?.();
|
|
node.graph?.change?.();
|
|
requestDraw();
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
|
|
for (const name of PARAMETER_NAMES) {
|
|
const widget = findWidget(node, name);
|
|
if (widget) chainCallback(widget, "callback", requestDraw);
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(requestDraw);
|
|
resizeObserver.observe(root);
|
|
const graphWidget = node.addDOMWidget("detail_daemon_schedule", "detail_daemon_schedule", root, {
|
|
serialize: false,
|
|
getMinHeight: () => GRAPH_HEIGHT,
|
|
getMaxHeight: () => GRAPH_HEIGHT,
|
|
});
|
|
graphWidget.serialize = false;
|
|
graphWidget.options.serialize = false;
|
|
|
|
chainCallback(node, "onConfigure", () => requestDraw(true));
|
|
chainCallback(node, "onRemoved", () => {
|
|
state.removed = true;
|
|
resizeObserver.disconnect();
|
|
if (state.frame) cancelAnimationFrame(state.frame);
|
|
node._detailDaemonGraph = null;
|
|
node._detailDaemonRequestDraw = null;
|
|
});
|
|
|
|
if (!app.configuringGraph && node.size?.[0] < MIN_WIDTH) {
|
|
const computed = node.computeSize?.() ?? node.size;
|
|
node.setSize?.([MIN_WIDTH, Math.max(node.size[1], computed?.[1] ?? 0)]);
|
|
}
|
|
requestDraw();
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "DetailDaemon.SamplerGUI",
|
|
|
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== NODE_NAME) return;
|
|
|
|
const originalCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const result = originalCreated?.apply(this, arguments);
|
|
createGraph(this);
|
|
return result;
|
|
};
|
|
},
|
|
|
|
loadedGraphNode(node) {
|
|
if (!isSamplerGUINode(node)) return;
|
|
setTimeout(() => node._detailDaemonRequestDraw?.(true), 0);
|
|
},
|
|
});
|