Files
malkuthro-ComfyUI-Koolook/web/easy_video_combine.js
malkuthroandClaude Opus 4.7 4739dd4203 feat: Easy_Utility, Easy_LoadVideo, and preview fixes (#184)
* 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>
2026-05-29 15:59:27 +02:00

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