diff --git a/app/ui/features/temporal/hub.test.js b/app/ui/features/temporal/hub.test.js new file mode 100644 index 00000000..2405c2d2 --- /dev/null +++ b/app/ui/features/temporal/hub.test.js @@ -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: [] }), {}); +}); diff --git a/app/ui/features/temporal/index.js b/app/ui/features/temporal/index.js new file mode 100644 index 00000000..f9a5495d --- /dev/null +++ b/app/ui/features/temporal/index.js @@ -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); }; + }, +}; diff --git a/app/ui/features/temporal/intent.js b/app/ui/features/temporal/intent.js new file mode 100644 index 00000000..99fea7ec --- /dev/null +++ b/app/ui/features/temporal/intent.js @@ -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"; +} diff --git a/app/ui/modules.js b/app/ui/modules.js index 56b8389e..df7addaa 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -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", diff --git a/modules/sampling/temporal_style/__init__.py b/modules/sampling/temporal_style/__init__.py index 58bbd6d9..365be65c 100644 --- a/modules/sampling/temporal_style/__init__.py +++ b/modules/sampling/temporal_style/__init__.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 8085d509..6cfc1239 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.