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); }, });