diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index 3ee6ac77..17d4f9c0 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -18,6 +18,13 @@ export async function buildInputs({ project, scene, slots, expand, frames, hooks for (const slot of (slots || []).filter((s) => s.node === "FunPackShotCamera")) put(slot, "project", project.id || ""); // what the node keeps per project (the words focus infusion draws on), also for pipelines saved before it was asked for const source = rolesAt(slots, "assets.source_image")[0]; if (source) put(source.slot, "media_id", project.generation_mode === "t2v" ? "" : scene.source_image || ""); // "from a prompt" starts every shot without a picture + const like = source && source.role.blank_like; + if (like && !raw[source.slot.id].media_id) { // text-only: a plain picture of the size the pipeline asks for stands in + const target = (slots || []).find((s) => s.id === like.slot) || { inputs: {} }; + const size = (name) => (raw[target.id] || {})[name] ?? (target.inputs || {})[name]; + put(source.slot, "blank_width", Number(size(like.width)) || 0); + put(source.slot, "blank_height", Number(size(like.height)) || 0); + } const plan = sceneReferences(scene, project.fixed_references); const { overrides, unwired } = wireReferences(plan.ids, slots, source ? [source.slot.id] : [], plan.pins); for (const [id, fields] of Object.entries(overrides)) raw[id] = { ...raw[id], ...fields }; diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index c8e1718f..054728de 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -82,3 +82,13 @@ test("the shot camera node is told which project this is, whatever roles the sav const { inputs } = await buildInputs({ project: { id: "proj1", scenes: [], video: {} }, scene: { id: "s", text: "x", references: [] }, slots, expand: async ({ text }) => ({ text }) }); assert.equal(inputs.shot_camera.project, "proj1"); }); + +test("text-only: a slot that can stand in for the picture is told the size of the slot that shapes it, and a picked picture is sent as is", async () => { + const like = { slot: "t", width: "target_width", height: "target_height" }; + const s = [{ id: "src", roles: [{ at: "assets.source_image", input: "media_id", blank_like: like }] }, + { id: "t", inputs: { target_width: 1344, target_height: 768 }, roles: [{ at: "project.video", input: "target_width" }] }]; + const run = (project, scene) => buildInputs({ project, scene, slots: s, expand: async () => null }); + assert.deepEqual((await run({ generation_mode: "t2v", video: { target_width: 640 } }, { source_image: "m1" })).inputs.src, { media_id: "", blank_width: 640, blank_height: 768 }); + assert.deepEqual((await run({ generation_mode: "i2v" }, { source_image: "m1" })).inputs.src, { media_id: "m1" }); + assert.deepEqual((await run({ generation_mode: "i2v" }, {})).inputs.src, { media_id: "", blank_width: 1344, blank_height: 768 }); +}); diff --git a/core/tests/test_shipped_nodes.py b/core/tests/test_shipped_nodes.py index b4b7ea84..a4f1a057 100644 --- a/core/tests/test_shipped_nodes.py +++ b/core/tests/test_shipped_nodes.py @@ -81,7 +81,7 @@ FROZEN = { "model", "lora_name", "strength_model", "clip", "strength_clip", ], "FunPackVAELoader": ["vae_name", "dtype"], - "FunPackLoadMedia": ["media_id"], + "FunPackLoadMedia": ["media_id", "blank_width", "blank_height"], } diff --git a/modules/media/load/nodes.py b/modules/media/load/nodes.py index fd1632aa..1b09c11e 100644 --- a/modules/media/load/nodes.py +++ b/modules/media/load/nodes.py @@ -10,6 +10,7 @@ a run queues, the same way a scene's prompt text lands on CLIPTextEncode's `text`), not from someone typing a file name into this node by hand. """ +import torch from comfy_api.latest import InputImpl, io from ..._core import log, media as media_mod @@ -27,13 +28,23 @@ class FunPackLoadMedia(io.ComfyNode): io.String.Input( "media_id", default="", tooltip="A media library id, picked in the Media bin."), + io.Int.Input( + "blank_width", default=0, min=0, max=16384, optional=True, + tooltip="With nothing picked, make a plain grey picture this wide instead of refusing. 0 = refuse."), + io.Int.Input( + "blank_height", default=0, min=0, max=16384, optional=True, + tooltip="With nothing picked, make a plain grey picture this tall instead of refusing. 0 = refuse."), ], outputs=[io.Image.Output(display_name="image")], ) @classmethod - def execute(cls, media_id: str) -> io.NodeOutput: + def execute(cls, media_id: str, blank_width: int = 0, blank_height: int = 0) -> io.NodeOutput: mid = (media_id or "").strip() + if not mid and blank_width > 0 and blank_height > 0: + # A text-only start: the picture exists only to give the pipeline a SHAPE (see the H3 preset). + log.info("FunPack Load Media", f"nothing picked: a plain {blank_width}x{blank_height} picture stands in") + return io.NodeOutput(torch.full((1, blank_height, blank_width, 3), 0.5)) if not mid: raise RuntimeError( "FunPack Load Media: nothing is picked for this input. " diff --git a/modules/media/load/tests/test_load_media.py b/modules/media/load/tests/test_load_media.py index da2b1518..497ddaea 100644 --- a/modules/media/load/tests/test_load_media.py +++ b/modules/media/load/tests/test_load_media.py @@ -59,3 +59,14 @@ def test_deleted_media_is_refused_even_with_a_well_formed_id(store): media.delete(entry["id"]) with pytest.raises(RuntimeError, match="not in the library"): FunPackLoadMedia.execute(entry["id"]).result + + +def test_a_blank_size_stands_in_for_nothing_picked_and_a_picked_image_still_wins(store): + from modules.media.load.nodes import FunPackLoadMedia + images, = FunPackLoadMedia.execute("", blank_width=64, blank_height=32).result + assert tuple(images.shape) == (1, 32, 64, 3) + entry = media.save_upload("ref.png", _png_bytes()) + images, = FunPackLoadMedia.execute(entry["id"], blank_width=64, blank_height=32).result + assert images.shape[1:3] == (6, 4) + with pytest.raises(RuntimeError, match="nothing is picked"): + FunPackLoadMedia.execute("", blank_width=64, blank_height=0).result diff --git a/modules/models/minimax_h3/pipeline.py b/modules/models/minimax_h3/pipeline.py index 3fd10d7a..058a4cfa 100644 --- a/modules/models/minimax_h3/pipeline.py +++ b/modules/models/minimax_h3/pipeline.py @@ -61,8 +61,11 @@ def h3_reference_to_video(): # role's name and sets `media_id` from the scene's own picked image, # the same way "generation.prompt" is found by name and set from the # scene's own text -- neither is addressed by this preset's slot id. - "roles": [{"at": "assets.source_image", "input": "media_id"}], - "inputs": {"media_id": ""}}, + # `blank_like`: with no picture (text-only), buildInputs gives this node the size of that + # slot's target_width/height, so a plain grey stand-in carries the project's own shape. + "roles": [{"at": "assets.source_image", "input": "media_id", + "blank_like": {"slot": "image_transform", "width": "target_width", "height": "target_height"}}], + "inputs": {"media_id": "", "blank_width": 0, "blank_height": 0}}, {"id": "image_transform", "group": "Reference media", "node": "ImageTransformKJ", # target_width/height are the PIXEL BUDGET, set in Project settings # like any other project resolution -- not the dropped picture's own