diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index caede137..b928556c 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -131,7 +131,7 @@ export const models = (app) => function mount() { let got; try { got = await api.importWorkflow(JSON.parse(await file.text())); } catch (err) { return say(err instanceof SyntaxError ? "That file is not valid JSON." : err.message); } const names = { prompt: "Prompt", negative: "Negative prompt", seed: "Seed", width: "Width", height: "Height", frames: "Length", fps: "FPS", image: "Start picture" }; - const lines = [`${got.slots.length} nodes.`, ...Object.entries(names).map(([k, label]) => `${label}: ${got.bound[k] ? "→ " + got.bound[k] : "not connected (set on the node itself)"}`), ...got.notes]; + const lines = [`${got.slots.length} nodes.`, ...Object.entries(names).map(([k, label]) => `${label}: ${got.bound[k] ? "→ " + got.bound[k].join(", ") : "not connected (set on the node itself)"}`), ...got.notes]; if (!(await confirm("Use this workflow", `${lines.join("\n")}\n\nReplace the whole pipeline with it? Revert puts the old one back until you leave this page.`, "danger"))) return; const r = await ps.restore({ slots: got.slots, removed: [], unwired: {} }); specs = {}; await describe(); say(r.refused.length ? `Not changed: ${r.refused[0]}` : ""); diff --git a/core/projects.py b/core/projects.py index b0cd3a20..3cdb1776 100644 --- a/core/projects.py +++ b/core/projects.py @@ -426,6 +426,8 @@ class Project: #: What each scene last rendered ({media, inSec, promptId}) and the removed scenes whose #: clip still previews: a reload shows the timeline the person left. scene_renders: dict = field(default_factory=dict) + #: Earlier takes of a scene: {scene id: [{media, promptId, rating}]}, newest last; the one on the clip is scene_renders'. + scene_variants: dict = field(default_factory=dict) scene_ghosts: list = field(default_factory=list) #: Cut order (scene ids in TIMELINE order), empty = follow the plan; True once reordered by hand. timeline_order: list = field(default_factory=list) @@ -484,6 +486,7 @@ class Project: continuity_settings=_blob(d.get("continuity_settings"), dict), generation_meta=_blob(d.get("generation_meta"), dict), scene_renders=_blob(d.get("scene_renders"), dict), + scene_variants=_blob(d.get("scene_variants"), dict), scene_ghosts=_dicts(d.get("scene_ghosts")), timeline_order=[i for i in _blob(d.get("timeline_order"), list) if is_token(i)], timeline_manually_ordered=_bool(d.get("timeline_manually_ordered")), diff --git a/core/routes.py b/core/routes.py index e5920cd0..13405273 100644 --- a/core/routes.py +++ b/core/routes.py @@ -254,8 +254,8 @@ def register(routes, prefix=None): except ValueError: return web.json_response({"why": "the request was not JSON"}, status=400) try: - return web.json_response(workflow_import.convert(body.get("workflow"), graph_mod.from_comfyui())) - except ValueError as exc: + return web.json_response(workflow_import.convert(body.get("workflow") if isinstance(body, dict) else None, graph_mod.from_comfyui())) + except (ValueError, TypeError, KeyError, AttributeError, IndexError) as exc: # an export that is not shaped like one return web.json_response({"why": str(exc)}, status=400) @routes.post(P + "/api/settings-card") diff --git a/core/tests/test_routes_pipeline.py b/core/tests/test_routes_pipeline.py index 732814ed..77190df4 100644 --- a/core/tests/test_routes_pipeline.py +++ b/core/tests/test_routes_pipeline.py @@ -528,6 +528,7 @@ def test_a_comfyui_workflow_imports_into_slots_that_build_a_real_graph(server, r status, body = _request(server, "POST", "/funpack/api/pipeline/import", {"workflow": wf}) assert status == 200 assert {"prompt", "negative", "seed", "width", "height"} <= set(body["bound"]) + assert _request(server, "POST", "/funpack/api/pipeline/import", [1, 2])[0] == 400 prompt, problems = graph.build(body["slots"]) assert set(prompt) >= {"w1", "w2", "w3", "w4"} assert not [p for p in problems if "w4" in p and "positive" in p] diff --git a/core/tests/test_workflow_import.py b/core/tests/test_workflow_import.py index 74a4150f..a20ab60f 100644 --- a/core/tests/test_workflow_import.py +++ b/core/tests/test_workflow_import.py @@ -45,8 +45,8 @@ def test_ui_export_becomes_slots_with_values_and_links_stepping_over_reroutes_an def test_the_apps_controls_find_their_nodes(): out = wi.convert(ui(), S) - assert out["bound"] == {"prompt": "CLIPTextEncode #1.text", "negative": "CLIPTextEncode #2.text", "seed": "KSampler #5.seed", - "width": "EmptyLatentImage #4.width", "height": "EmptyLatentImage #4.height"} + assert out["bound"] == {"prompt": ["CLIPTextEncode #1.text"], "negative": ["CLIPTextEncode #2.text"], "seed": ["KSampler #5.seed"], + "width": ["EmptyLatentImage #4.width"], "height": ["EmptyLatentImage #4.height"]} roles = {s["id"]: s.get("roles") for s in out["slots"]} assert roles["w1"] == [{"at": "generation.prompt", "label": "Prompt", "input": "text"}] assert roles["w2"][0]["at"] == "project.negative" @@ -59,7 +59,7 @@ def test_api_export_works_and_a_load_image_becomes_the_start_picture(): by = {s["id"]: s for s in out["slots"]} assert by["w1"]["node"] == "FunPackLoadMedia" and by["w1"]["roles"][0]["at"] == "assets.source_image" assert by["w2"]["inputs"]["image"] == ["w1", 0] - assert out["bound"]["image"] == "FunPackLoadMedia #1.media_id" + assert out["bound"]["image"] == ["FunPackLoadMedia #1.media_id"] and any("start picture" in n for n in out["notes"]) def test_a_load_image_whose_mask_is_used_is_left_alone(): @@ -119,3 +119,74 @@ def test_not_installed_nodes_are_kept_and_named_and_a_non_workflow_is_refused(): wi.convert({"hello": 1}, S) with pytest.raises(ValueError): wi.convert({"nodes": [node(1, "Note")], "links": []}, S) + + +NODES["Conditioning2"] = {"inputs": {"positive": "CONDITIONING", "negative": "CONDITIONING"}, "outputs": ["CONDITIONING", "CONDITIONING"]} +NODES["Int"] = {"inputs": {"value": "INT"}, "outputs": ["INT"]} + + +def test_a_bypassed_or_muted_subgraph_instance_does_not_run(): + sub = {"id": "sg-1", "name": "Upscale", "nodes": [node(7, "ImageScale", [("image", None)], [64])], + "inputs": [{"name": "img", "type": "IMAGE"}], "outputs": [{"name": "out", "type": "IMAGE"}], + "links": [{"id": 1, "origin_id": wi.SG_IN, "origin_slot": 0, "target_id": 7, "target_slot": 0, "type": "IMAGE"}, + {"id": 2, "origin_id": 7, "origin_slot": 0, "target_id": wi.SG_OUT, "target_slot": 0, "type": "IMAGE"}]} + for mode in (4, 2): + w = {"definitions": {"subgraphs": [sub]}, + "nodes": [node(1, "LoadImage", [], ["a.png"]), + {"id": 2, "type": "sg-1", "mode": mode, "inputs": [{"name": "img", "type": "IMAGE", "link": 50}], "outputs": [{"type": "IMAGE"}], "widgets_values": []}, + node(3, "VAEDecode", [("samples", 51)])], + "links": [link(50, 1, 0, 2, 0, "IMAGE"), link(51, 2, 0, 3, 0, "IMAGE")]} + got = {s["id"]: s for s in wi.convert(w, S)["slots"]} + assert not any(i.startswith("wsg") for i in got), "the disabled stage is not queued" + assert (got["w3"]["inputs"].get("samples") == ["w1", 0]) == (mode == 4) # bypass passes the picture on, mute leaves it unwired + + +def test_a_primitive_seed_reaches_its_node_through_a_reroute(): + w = {"nodes": [node(1, "KSampler", [("seed", 60)], [0, "fixed", 5, 1.0, "euler"]), node(2, "Reroute", [("", 61)]), node(3, "PrimitiveNode", [], [1234])], + "links": [link(60, 2, 0, 1, 0), link(61, 3, 0, 2, 0)]} + assert {s["id"]: s for s in wi.convert(w, S)["slots"]}["w1"]["inputs"]["seed"] == 1234 + + +def test_an_input_the_obvious_node_takes_from_a_wire_is_not_given_to_another_node_and_the_note_says_so(): + api = {"1": {"class_type": "Int", "inputs": {"value": 512}}, + "2": {"class_type": "EmptyLatentImage", "inputs": {"width": ["1", 0], "height": 512, "batch_size": 1}}, + "3": {"class_type": "ImageScale", "inputs": {"image": ["9", 0], "width": 100}}} + out = wi.convert(api, S) + assert "width" not in out["bound"] and "height" in out["bound"] + assert any("Width" in n and "EmptyLatentImage" in n for n in out["notes"]) + + +def test_one_control_drives_every_node_of_the_kind_and_each_sampler_gets_its_own_prompt(): + api = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "hi", "clip": ["9", 0]}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": "lo", "clip": ["9", 0]}}, + "3": {"class_type": "KSampler", "inputs": {"positive": ["1", 0], "seed": 1, "steps": 1}}, + "4": {"class_type": "KSampler", "inputs": {"positive": ["2", 0], "seed": 1, "steps": 1}}} + out = wi.convert(api, S) + assert out["bound"]["seed"] == ["KSampler #3.seed", "KSampler #4.seed"] + assert out["bound"]["prompt"] == ["CLIPTextEncode #1.text", "CLIPTextEncode #2.text"] + + +def test_positive_and_negative_are_not_swapped_when_one_node_carries_both(): + api = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg", "clip": ["9", 0]}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos", "clip": ["9", 0]}}, + "3": {"class_type": "Conditioning2", "inputs": {"negative": ["1", 0], "positive": ["2", 0]}}, + "4": {"class_type": "KSampler", "inputs": {"positive": ["3", 0], "negative": ["3", 1], "seed": 1}}} + out = wi.convert(api, S) + assert out["bound"]["prompt"] == ["CLIPTextEncode #2.text"] and out["bound"]["negative"] == ["CLIPTextEncode #1.text"] + + +def test_widget_values_follow_the_sockets_that_are_widgets_not_the_schema_guess(): + NODES["Odd"] = {"inputs": {"text": "STRING", "n": "INT"}, "outputs": []} # the schema says both are widgets... + n = {"id": 1, "type": "Odd", "inputs": [{"name": "text", "link": None}, {"name": "n", "link": None, "widget": {"name": "n"}}], "widgets_values": [4]} + assert wi.convert({"nodes": [n], "links": []}, S)["slots"][0]["inputs"] == {"n": 4} # ...the export says only n is + NODES["Plain"] = {"inputs": {"label": "STRING", "mode": "COMBO"}, "outputs": []} + p = {"id": 1, "type": "Plain", "inputs": [], "widgets_values": ["fixed", "x"]} # a string that merely looks like a seed control + assert wi.convert({"nodes": [p], "links": []}, S)["slots"][0]["inputs"] == {"label": "fixed", "mode": "x"} + + +def test_a_set_inside_one_subgraph_copy_is_not_read_by_the_other_copy(): + w = {"nodes": [node("sg1_1", "EmptyLatentImage", [], [1, 1, 1]), node("sg1_2", "SetNode", [("L", 70)], ["lat"]), node("sg1_3", "GetNode", [], ["lat"]), node("sg1_4", "KSampler", [("latent_image", 71)], [1, "fixed", 1, 1.0, "euler"]), + node("sg2_1", "EmptyLatentImage", [], [2, 2, 1]), node("sg2_2", "SetNode", [("L", 72)], ["lat"]), node("sg2_3", "GetNode", [], ["lat"]), node("sg2_4", "KSampler", [("latent_image", 73)], [1, "fixed", 1, 1.0, "euler"])], + "links": [link(70, "sg1_1", 0, "sg1_2", 0), link(71, "sg1_3", 0, "sg1_4", 0), link(72, "sg2_1", 0, "sg2_2", 0), link(73, "sg2_3", 0, "sg2_4", 0)]} + got = {s["id"]: s for s in wi.convert(w, S)["slots"]} + assert got["wsg2_4"]["inputs"]["latent_image"] == ["wsg2_1", 0] and got["wsg1_4"]["inputs"]["latent_image"] == ["wsg1_1", 0] diff --git a/core/workflow_import.py b/core/workflow_import.py index 19532a8c..99541fd5 100644 --- a/core/workflow_import.py +++ b/core/workflow_import.py @@ -91,7 +91,7 @@ def flatten_subgraphs(wf: dict, schemas) -> dict: out_nodes, out_rows, again = [], list(rows), False for inst in nodes: defn = defs.get(str(inst.get("type"))) - if not defn: + if not defn or inst.get("mode") in (2, 4): # a bypassed or muted instance stays one node: origin() passes it through or drops it out_nodes.append(inst) continue again = True @@ -151,8 +151,20 @@ def flatten_subgraphs(wf: dict, schemas) -> dict: return wf -def _widget_names(schemas, cls): - return [n for n, t in schemas.inputs(cls).items() if t in comfy_types.PRIMITIVE] +def _scope(node_id): + """The subgraph instance a flattened node came from ("sg2_7" -> "sg2"), "" for the top level.""" + text = str(node_id) + return text.rsplit("_", 1)[0] if text.startswith("sg") and "_" in text else "" + + +def _widgets(schemas, node): + """(name, type) of the node's widgets, in the order widgets_values lists them. The export's own sockets say + which inputs are widgets (they carry a "widget" key); the node's schema is the fallback for older exports.""" + types = schemas.inputs(node.get("type")) + own = [i.get("name") for i in node.get("inputs") or [] if isinstance(i, dict) and i.get("widget")] + if own: + return [(n, types.get(n)) for n in own] + return [(n, t) for n, t in types.items() if t in comfy_types.PRIMITIVE] def _values(schemas, node): @@ -162,11 +174,11 @@ def _values(schemas, node): out = {k: v for k, v in raw.items()} elif isinstance(raw, list): out, i = {}, 0 - for name in _widget_names(schemas, node.get("type")): + for name, kind in _widgets(schemas, node): if i >= len(raw): break out[name] = raw[i]; i += 1 - if i < len(raw) and isinstance(out[name], (int, float)) and not isinstance(out[name], bool) and raw[i] in CONTROL: + if i < len(raw) and kind in ("INT", "FLOAT") and raw[i] in CONTROL: i += 1 # control_after_generate rides after a number else: out = {} @@ -193,6 +205,8 @@ def _from_ui(wf, schemas, notes): if n is None or (nid, slot) in seen: return None kind, mode = n.get("type"), n.get("mode", 0) + if mode == 2: + return None # muted: nothing comes out of it if kind in PASS or kind == "SetNode" or (mode == 4 and kind not in VIRTUAL): socks = n.get("inputs") or [] pick = 0 @@ -209,7 +223,9 @@ def _from_ui(wf, schemas, notes): nid, slot = row[1], row[2] elif kind == "GetNode": name = (n.get("widgets_values") or [""])[0] - setter = next((m for m in nodes.values() if m.get("type") == "SetNode" and (m.get("widgets_values") or [""])[0] == name), None) + scope = _scope(nid) # a Set inside the same subgraph instance wins over one elsewhere + setters = [m for m in nodes.values() if m.get("type") == "SetNode" and m.get("mode", 0) != 2 and (m.get("widgets_values") or [""])[0] == name] + setter = next((m for m in setters if _scope(m.get("id")) == scope), setters[0] if setters else None) if setter is None: return None row = next((r for r in rows if r[3] == setter.get("id") and r[4] == 0), None) @@ -238,13 +254,13 @@ def _from_ui(wf, schemas, notes): row = into.get((node.get("id"), i)) if row is None: continue - src = nodes.get(row[1]) - if src is not None and src.get("type") == "PrimitiveNode": # a Primitive hands over its value + hit = origin(row[1], row[2]) + src = nodes.get(hit[0]) if hit else None + if src is not None and src.get("type") == "PrimitiveNode": # a Primitive hands over its value, even through a Reroute or Set/Get vals = src.get("widgets_values") or [] if vals: s["inputs"][name] = vals[0] continue - hit = origin(row[1], row[2]) if hit and sid(hit[0]) in keep: s["inputs"][name] = [sid(hit[0]), hit[1]] else: @@ -265,33 +281,41 @@ def _from_api(wf, notes): # ── giving the app's controls a home ───────────────────────────────────────── -def _upstream(slots_by_id, start): - """Slots feeding `start`, nearest first.""" +def _upstream(slots_by_id, start, prefer=None): + """Slots feeding `start`, nearest first. Through a node that takes both a positive and a negative, only + the side named `prefer` is followed, so one prompt is never read off the other's encoder.""" seen, order, queue = {start}, [], [start] while queue: cur = queue.pop(0) - for v in (slots_by_id[cur]["inputs"] or {}).values(): + ins = slots_by_id[cur]["inputs"] or {} + if prefer and "positive" in ins and "negative" in ins and prefer in ins: + ins = {prefer: ins[prefer]} + for v in ins.values(): if is_link(v) and v[0] in slots_by_id and v[0] not in seen: seen.add(v[0]); order.append(v[0]); queue.append(v[0]) return order -def bind(slots: List[dict], schemas) -> Tuple[List[dict], Dict[str, str]]: - """(slots with roles added, what was bound: control -> 'node #id.input').""" +def bind(slots: List[dict], schemas, notes: List[str] = None) -> Tuple[List[dict], Dict[str, List[str]]]: + """(slots with roles added, what was bound: control -> ['node #id.input', ...]). + + A control goes to EVERY node of the kind that first takes it (two samplers both get the seed), but only when + that kind's input is typed in: if the obvious node takes it from another node, the control is left unbound and + a note says so, rather than landing on some other node that happens to share the input name.""" + notes = notes if notes is not None else [] by_id = {s["id"]: s for s in slots} - bound: Dict[str, str] = {} + bound: Dict[str, List[str]] = {} def give(control, slot, name, role): slot.setdefault("roles", []).append(dict(role, input=name)) - bound[control] = f"{slot['node']} #{slot['id'][1:]}.{name}" + bound.setdefault(control, []).append(f"{slot['node']} #{slot['id'][1:]}.{name}") - def text_home(slot_id): - """The first unlinked text input on the node a sampler's conditioning comes from, or upstream of it.""" - taken = {b.split("#")[-1] for b in bound.values()} - for sid_ in [slot_id, *_upstream(by_id, slot_id)]: + def text_home(slot_id, prefer): + taken = {x for v in bound.values() for x in v} + for sid_ in [slot_id, *_upstream(by_id, slot_id, prefer)]: s = by_id[sid_] for name in TEXT_NAMES: - if schemas.inputs(s["node"]).get(name) == "STRING" and not is_link(s["inputs"].get(name)) and f"{sid_[1:]}.{name}" not in taken: + if schemas.inputs(s["node"]).get(name) == "STRING" and not is_link(s["inputs"].get(name)) and f"{s['node']} #{sid_[1:]}.{name}" not in taken: return s, name return None @@ -299,26 +323,32 @@ def bind(slots: List[dict], schemas) -> Tuple[List[dict], Dict[str, str]]: ("negative", "negative", {"at": "project.negative", "label": "Negative prompt"})): for s in slots: link = (s["inputs"] or {}).get(want) - found = text_home(link[0]) if is_link(link) and link[0] in by_id else None + found = text_home(link[0], want) if is_link(link) and link[0] in by_id else None if found: give(key, found[0], found[1], role) - break - seeds = [(s, n) for s in slots for n in s["inputs"] if "seed" in n.lower() and schemas.inputs(s["node"]).get(n) == "INT" and not is_link(s["inputs"][n])] - if seeds: - give("seed", seeds[0][0], seeds[0][1], {"at": "generation.seed", "label": "Seed"}) + + def numeric(control, label, names, kinds, role, match): + """Bind `control` to every unlinked input of the first matching node class; say when that class takes it from a wire.""" + first = next(((s, n) for s in slots for n in s["inputs"] if match(n, names) and schemas.inputs(s["node"]).get(n) in kinds), None) + if first is None: + return + cls = first[0]["node"] + mine = [(s, n) for s in slots if s["node"] == cls for n in s["inputs"] if match(n, names) and schemas.inputs(cls).get(n) in kinds] + free = [(s, n) for s, n in mine if not is_link(s["inputs"][n])] + for s, n in free: + give(control, s, n, dict(role, **({"drives": control} if control in ("frames", "fps") else {}))) + if not free: + notes.append(f"{label}: the nodes that take it ({cls}) get it from other nodes, so the app's {label.lower()} control is not connected. Set it where it comes from.") + + numeric("seed", "Seed", None, ("INT",), {"at": "generation.seed", "label": "Seed"}, lambda n, _: "seed" in n.lower()) for control, names in SIZE_NAMES.items(): - for s in slots: - name = next((n for n in names if schemas.inputs(s["node"]).get(n) in ("INT", "FLOAT") and not is_link(s["inputs"].get(n))), None) - if name: - role = {"at": "project.video", "label": {"width": "Width", "height": "Height", "frames": "Length", "fps": "FPS"}[control]} - if control in ("frames", "fps"): - role["drives"] = control - give(control, s, name, role) - break + label = {"width": "Width", "height": "Height", "frames": "Length", "fps": "FPS"}[control] + numeric(control, label, names, ("INT", "FLOAT"), {"at": "project.video", "label": label}, lambda n, ns: n in ns) for s in slots: # a LoadImage becomes the scene's start picture if s["node"] == "LoadImage" and "image" not in bound: if any(is_link(v) and v == [s["id"], 1] for o in slots for v in o["inputs"].values()): continue # its mask is used: leave it as it is + notes.append(f"LoadImage #{s['id'][1:]} now takes the scene's start picture; the file it named is not kept. With no picture on the scene it cannot run.") s["node"], s["inputs"] = "FunPackLoadMedia", {"media_id": ""} give("image", s, "media_id", {"at": "assets.source_image"}) return slots, bound @@ -327,7 +357,8 @@ def bind(slots: List[dict], schemas) -> Tuple[List[dict], Dict[str, str]]: def convert(workflow: Any, schemas) -> dict: """{"slots", "bound", "notes"} for a workflow in either export format.""" notes: List[str] = [] - if is_api(workflow): + api = is_api(workflow) + if api: slots = _from_api(workflow, notes) elif isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list): slots = _from_ui(workflow, schemas, notes) @@ -337,6 +368,6 @@ def convert(workflow: Any, schemas) -> dict: raise ValueError("the workflow has no nodes that run") missing = sorted({s["node"] for s in slots if not schemas.of(s["node"])}) if missing: - notes.append("Not installed here: " + ", ".join(missing) + ". Those nodes are kept but cannot run until their pack is installed.") - slots, bound = bind(slots, schemas) + notes.append("Not installed here: " + ", ".join(missing) + ". Those nodes are kept but cannot run until their pack is installed" + ("" if api else "; their typed-in values could not be read from this export") + ".") + slots, bound = bind(slots, schemas, notes) return {"slots": slots, "bound": bound, "notes": notes}