* feat(nodes): add Easy_Utility — INT → padded STRING dispatcher First mode `int_to_padded_string`: takes an INT widget (with up/down arrows) and emits a zero-padded STRING (default `001`, no `v` prefix). The downstream `koolook_versioning.resolve_version_token` prepends `v` and re-pads, so wiring this into `EasyAIPipeline.version` / `Easy_VideoCombine.version` yields the standard `vNNN` token at execution. Designed for extension: the `mode` widget shipped from day one so adding further conversions (string→int, formatting variants, etc.) doesn't rewire saved workflows. Note: the previously-attempted JS preview-resolver refactor that aimed to fix the "preview shows stale version" bug was reverted in this worktree before commit — it didn't actually fix the bug (the real cause is the JS resolver not descending into ComfyUI subgraphs) and it broke shot_name resolution in the process. The preview-resolver work is NOT in this commit; only the Python node + tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nodes): guard version widget against pysssss .replace crash PR #180 added a STRING `version` widget to EasyAIPipeline and Easy_VideoCombine. Three workflow states leave widget.value undefined or non-string when graphToPrompt iterates widgets: - Pre-#180 workflows replay an INT (e.g. `1`) into the new STRING slot. - Widget-to-input conversion voids the value in newer LiteGraph. - Easy_VideoCombine's dict-based widgets_values omits the new key entirely on workflows saved before #180. With pysssss/ComfyUI-Custom-Scripts installed, presetText.js patches every STRING TextWidget.serializeValue to run callbacks that do `value.replace(...)` for `{variable}` substitution. Any of the states above crashes graphToPrompt and turns Run into a no-op for the whole workflow ("Cannot read properties of undefined (reading 'replace')"). Fix: trap `widget.value` reads/writes on the version widget via `Object.defineProperty` so the property is always coerced to a string regardless of caller. `configurable: true` so downstream code can still override the descriptor if it needs to. Tests: 231 passing; both JS files parse OK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai-pipeline): resolve subgraph version preview * feat(video): add path-aware load video node * fix(ai-pipeline): preview connected text names * fix(pr184): address review cleanup --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
7.4 KiB
JavaScript
187 lines
7.4 KiB
JavaScript
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// ComfyUI-Koolook — Easy Video Combine frontend extension
|
|
// Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
|
|
//
|
|
// VHS's frontend extension (web/js/VHS.core.js:2030) hard-codes
|
|
// `nodeData?.name == "VHS_VideoCombine"` to gate its format-widget
|
|
// machinery — picking a format like ProRes or h264 dynamically adds
|
|
// format-specific widgets (`profile`, `crf`, `pix_fmt`, …). Because
|
|
// our subclass is registered under a different node ID
|
|
// (`Easy_VideoCombine`), none of that code runs for our node, and
|
|
// the format-specific widgets never render. The backend then warns
|
|
// "Missing input for <widget> has been set to <default>" on every
|
|
// execution and the user has no way to choose anything other than
|
|
// the default.
|
|
//
|
|
// This extension replicates the relevant bit of VHS's logic
|
|
// (`addFormatWidgets`) for our node, so the format dropdown's
|
|
// callback adds/removes per-format widgets the same way as upstream.
|
|
// Self-contained — does NOT import from VHS's module (their helpers
|
|
// aren't exported). Only ~80 lines including comments.
|
|
|
|
import { app } from "../../scripts/app.js";
|
|
import { setWidgetConfig } from "../../extensions/core/widgetInputs.js";
|
|
import { bulletproofStringWidget } from "./widget_utils.js";
|
|
|
|
function chainCallback(object, property, callback) {
|
|
if (object == undefined) {
|
|
console.error("[Koolook EasyVideoCombine] chainCallback on undefined object");
|
|
return;
|
|
}
|
|
if (property in object && object[property]) {
|
|
const original = object[property];
|
|
object[property] = function () {
|
|
const r = original.apply(this, arguments);
|
|
return callback.apply(this, arguments) ?? r;
|
|
};
|
|
} else {
|
|
object[property] = callback;
|
|
}
|
|
}
|
|
|
|
function fitHeight(node) {
|
|
node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]]);
|
|
node?.graph?.setDirtyCanvas(true);
|
|
}
|
|
|
|
function setWidgetValue(widget, value) {
|
|
widget.value = value;
|
|
widget.callback?.(widget.value);
|
|
}
|
|
|
|
function coerceBoolValue(value, fallback) {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "number") return value !== 0;
|
|
if (value == null) return fallback;
|
|
const s = String(value).trim().toLowerCase();
|
|
if (["true", "1", "yes", "on"].includes(s)) return true;
|
|
if (["false", "0", "no", "off", ""].includes(s)) return false;
|
|
return fallback;
|
|
}
|
|
|
|
const BOOLEAN_WIDGETS = new Set([
|
|
"pingpong",
|
|
"create_path_if_missing",
|
|
"save_metadata_png",
|
|
"save_metadata_json",
|
|
"keep_silent_intermediate",
|
|
]);
|
|
|
|
function setSavedWidgetValue(node, name, value) {
|
|
const widget = node.widgets?.find((w) => w.name === name);
|
|
if (!widget || widget.type === "button") return;
|
|
if (BOOLEAN_WIDGETS.has(name)) {
|
|
setWidgetValue(widget, coerceBoolValue(value, widget.value));
|
|
} else {
|
|
setWidgetValue(widget, value);
|
|
}
|
|
const input = node.inputs?.find((i) => i.name === name);
|
|
if (input && widget.config) setWidgetConfig(input, widget.config);
|
|
}
|
|
|
|
function useNamedWidgetState(nodeType) {
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
|
chainCallback(this, "onConfigure", function (info) {
|
|
if (!this.widgets || info?.widgets_values == null) return;
|
|
|
|
const saved = info.widgets_values;
|
|
if (!Array.isArray(saved) && typeof saved === "object") {
|
|
for (const name of ["frame_rate", "loop_count", "filename_prefix"]) {
|
|
if (Object.hasOwn(saved, name)) setSavedWidgetValue(this, name, saved[name]);
|
|
}
|
|
// Restore format before the rest; its callback creates the
|
|
// format-specific widgets that also need named restoration.
|
|
if (Object.hasOwn(saved, "format")) {
|
|
setSavedWidgetValue(this, "format", saved.format);
|
|
}
|
|
for (const [name, value] of Object.entries(saved)) {
|
|
setSavedWidgetValue(this, name, value);
|
|
}
|
|
}
|
|
});
|
|
|
|
chainCallback(this, "onSerialize", function (info) {
|
|
if (!this.widgets) return;
|
|
info.widgets_values = {};
|
|
for (const widget of this.widgets) {
|
|
if (widget.type === "button") continue;
|
|
info.widgets_values[widget.name] = widget.value;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function addFormatWidgets(nodeType) {
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
|
let formatWidget = null;
|
|
let formatWidgetIndex = -1;
|
|
for (let i = 0; i < this.widgets.length; i++) {
|
|
if (this.widgets[i].name === "format") {
|
|
formatWidget = this.widgets[i];
|
|
formatWidgetIndex = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
if (!formatWidget) return;
|
|
let formatWidgetsCount = 0;
|
|
chainCallback(formatWidget, "callback", (value) => {
|
|
const formats = LiteGraph.registered_node_types[this.type]
|
|
?.nodeData?.input?.required?.format?.[1]?.formats;
|
|
const newWidgets = [];
|
|
if (formats?.[value]) {
|
|
for (const wDef of formats[value]) {
|
|
let type = wDef[2]?.widgetType ?? wDef[1];
|
|
if (Array.isArray(type)) type = "COMBO";
|
|
app.widgets[type](this, wDef[0], wDef.slice(1), app);
|
|
const w = this.widgets.pop();
|
|
w.config = wDef.slice(1);
|
|
newWidgets.push(w);
|
|
}
|
|
}
|
|
const removed = this.widgets.splice(formatWidgetIndex, formatWidgetsCount, ...newWidgets);
|
|
const newNames = new Set(newWidgets.map((w) => w.name));
|
|
for (const w of removed) {
|
|
w?.onRemove?.();
|
|
if (newNames.has(w.name)) continue;
|
|
const slot = this.inputs.findIndex((i) => i.name == w.name);
|
|
if (slot >= 0) this.removeInput(slot);
|
|
}
|
|
for (const w of newWidgets) {
|
|
const existing = this.inputs.find((i) => i.name == w.name);
|
|
if (existing) {
|
|
setWidgetConfig(existing, w.config);
|
|
} else {
|
|
this.addInput(w.name, w.config[0], { widget: { name: w.name } });
|
|
}
|
|
}
|
|
fitHeight(this);
|
|
formatWidgetsCount = newWidgets.length;
|
|
});
|
|
});
|
|
}
|
|
|
|
function bulletproofVersionWidget(nodeType) {
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
|
// Run after onConfigure replays widgets_values. Linked-input version
|
|
// widgets get value=undefined from LiteGraph; pre-PR#180 workflows
|
|
// have no entry for version in the dict at all. Either trips pysssss.
|
|
chainCallback(this, "onConfigure", function () {
|
|
bulletproofStringWidget(this.widgets?.find((w) => w.name === "version"), "");
|
|
});
|
|
// Also bulletproof on plain creation (new node, no onConfigure).
|
|
bulletproofStringWidget(this.widgets?.find((w) => w.name === "version"), "");
|
|
});
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Koolook.EasyVideoCombine",
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
|
if (nodeData?.name === "Easy_VideoCombine") {
|
|
useNamedWidgetState(nodeType);
|
|
addFormatWidgets(nodeType);
|
|
bulletproofVersionWidget(nodeType);
|
|
}
|
|
},
|
|
});
|