Temporal style auto picks a style per shot from its words; registry node name back to ComfyUI-FunPack (display name stays FunUI)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjYZhHqk196LykrWy3T
This commit is contained in:
Claude
2026-10-27 10:20:50 +00:00
parent cd0ef39cb1
commit 478d9d3af7
6 changed files with 82 additions and 7 deletions
+28
View File
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import hub from "./index.js";
import { styleFor } from "./intent.js";
test("words choose the style", () => {
assert.equal(styleFor(""), "natural");
assert.equal(styleFor("a woman walks"), "natural");
assert.equal(styleFor("a fast chase, running"), "decelerate");
assert.equal(styleFor("a calm, still portrait"), "freeze");
assert.equal(styleFor("a slow pan"), "accelerate");
assert.equal(styleFor("a seamless loop of waves"), "loop");
});
const make = (values, off = false) => { const app = { pipeline: { currentValues: () => values, allOff: () => off }, inputHooks: [] }; hub.setup({ app }); return app.inputHooks[0]; };
const slots = [{ id: "5", node: "FunUIModifierSettings" }];
test("auto becomes the shot's own style in that run's settings, other values kept", async () => {
const hook = make({ temporal_style: { style: "auto" }, other: { a: 1 } });
const out = await hook({ scene: { text: "calm still portrait" }, slots });
assert.deepEqual(JSON.parse(out.inputs[5].settings), { temporal_style: { style: "freeze" }, other: { a: 1 } });
});
test("nothing happens for a fixed style, with everything off, or without a settings node", async () => {
assert.deepEqual(await make({ temporal_style: { style: "pulse" } })({ scene: { text: "x" }, slots }), {});
assert.deepEqual(await make({ temporal_style: { style: "auto" } }, true)({ scene: { text: "x" }, slots }), {});
assert.deepEqual(await make({ temporal_style: { style: "auto" } })({ scene: { text: "x" }, slots: [] }), {});
});
+24
View File
@@ -0,0 +1,24 @@
// Temporal style "auto": each shot gets the style its own words ask for. The choice is made here, per shot, and sent as the concrete
// style in that run's modifier settings; the module itself only knows concrete styles.
import { styleFor } from "./intent.js";
export default {
id: "temporal",
mount: "menubar.menus",
needs: ["pipeline", "inputHooks"],
modules: ["temporal_style"],
setup({ app }) {
const hook = async ({ scene, slots }) => {
if (app.pipeline.allOff?.(slots)) return {};
const values = app.pipeline.currentValues(slots) || {};
if ((values.temporal_style || {}).style !== "auto") return {};
const slot = (slots || []).find((s) => s.node === "FunUIModifierSettings");
if (!slot) return {};
const style = styleFor(scene.text);
const settings = JSON.stringify({ ...values, temporal_style: { ...values.temporal_style, style } });
return { inputs: { [slot.id]: { settings } }, notes: [`Temporal style auto: ${style} for this shot.`] };
};
app.inputHooks.push(hook);
return () => { const i = app.inputHooks.indexOf(hook); if (i >= 0) app.inputHooks.splice(i, 1); };
},
};
+22
View File
@@ -0,0 +1,22 @@
// Temporal style "auto": reads a shot's words and picks how its motion should feel (ported from v4's director).
// Energy words want punchier steps, stillness words want a hold, loop words want the end to join the start.
const ENERGY = ["run", "running", "sprint", "chase", "fight", "punch", "kick", "explosion", "explode", "burst", "jump", "leap", "dash", "race",
"fast", "rapid", "quick", "frantic", "spin", "whirl", "fall", "crash", "swing", "throw", "action", "dance", "dancing", "gallop", "charge",
"shatter", "blast", "speeding"];
const STILL = ["still", "stillness", "motionless", "static", "frozen", "freeze", "portrait", "calm", "serene", "quiet", "slow", "slowly", "gentle",
"gradual", "drift", "drifting", "float", "floating", "hover", "stare", "gaze", "meditat", "sleep", "resting", "rest", "standing", "posed", "pose",
"close-up", "closeup", "macro", "landscape", "ambient", "tranquil", "lingering"];
const LOOP = ["loop", "looping", "seamless", "cycle", "cyclic", "repeat", "repeating", "endless", "perpetual", "continuous loop"];
const count = (text, words) => words.filter((w) => text.includes(w)).length;
/** -> one of the concrete Temporal styles for these words. */
export function styleFor(words) {
const t = String(words || "").toLowerCase();
if (!t.trim()) return "natural";
if (count(t, LOOP)) return "loop";
const net = count(t, STILL) - count(t, ENERGY);
if (net === 0) return "natural";
if (net < 0) return "decelerate";
return Math.min(2.0, 1.0 + 0.35 * net) >= 1.7 ? "freeze" : "accelerate";
}
+1
View File
@@ -39,6 +39,7 @@ export default [
"./features/generate/warn.js",
"./features/generate/progress.js",
"./features/continuity/index.js",
"./features/temporal/index.js",
"./features/composer/index.js",
"./features/settings/index.js",
"./features/welcome/index.js",
+6 -6
View File
@@ -3,9 +3,9 @@
Everything but `loop` tells the model a different frame rate than the clip's (see style.py);
`loop` rolls the latent in time so the clip's end joins its start (loop.py).
Not here: v4's `auto`, which read each scene's prompt and chose per scene. That choice is a
conditioning-side decision (the prompt lives there); it is still to be ported with the scene-chain
work, and a style set here applies to every scene of the run alike.
`auto` reads each shot's words and picks one of these styles per shot. The module cannot see the words,
so the app makes that choice when it builds each run (app/ui/features/temporal) and sends the concrete
style; an `auto` that reaches here unresolved does nothing.
"""
from ..._core import patching
@@ -20,14 +20,14 @@ CATEGORY = "sampling"
STATUS = "experimental"
REQUIRES = ["ltx_av"]
STYLES = ["natural", "accelerate", "decelerate", "freeze", "pulse",
STYLES = ["natural", "auto", "accelerate", "decelerate", "freeze", "pulse",
"rapid_start", "rapid_end", "rapid_start_end", "loop"]
SETTINGS = {
"style": {
"type": "enum", "default": "natural", "label": "Temporal style",
"options": [{"value": v, "label": v.replace("_", " ")} for v in STYLES],
"hint": "How motion feels. natural: unchanged. accelerate, freeze: smoother, held motion (freeze most). decelerate: punchier, bigger steps. "
"hint": "How motion feels. natural: unchanged. auto: each shot gets the style its own words ask for (motion words punch, calm words hold). accelerate, freeze: smoother, held motion (freeze most). decelerate: punchier, bigger steps. "
"pulse: repeated punch-then-ease. rapid start/end: punchy at the start and/or end. loop: the end joins the start.",
},
}
@@ -53,7 +53,7 @@ def install(patcher, values, key):
if "context_handler" in patcher.model_options:
note += " (context windows are on: each WINDOW is looped, not the clip -- turn them off for a seamless clip)"
else:
return None
return None # natural, or an auto the app did not resolve
patcher.set_model_unet_function_wrapper(patching.tag(wrapper, key))
installed = patcher.model_options["model_function_wrapper"]
installed._funui_prev = old # so stripping ours puts the earlier one back
+1 -1
View File
@@ -1,5 +1,5 @@
[project]
name = "FunUI" # Unique identifier for your node. Immutable after creation.
name = "ComfyUI-FunPack" # Unique identifier for your node. Immutable after creation.
description = "An AI movie studio inside ComfyUI: a browser-based studio with timeline, per-scene prompts and one-click montage generation, plus a self-improving engine that learns from your ratings. Every feature is a module, so a broken one is simply absent."
version = "5.0.3" # Custom Node version. Must be semantically versioned.
# Release codename ("Classy Chopper" for 5.x) lives in core/update.py:CODENAMES, keyed by major, shown in Settings > About.