fix(import): bypassed/muted subgraphs, primitives through reroutes, one control to every node of a kind, no binding to a lookalike, scoped Set/Get, widget alignment from the export, 400 on bad shape
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
a8f5163822
commit
972a5e97b4
@@ -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]}` : "");
|
||||
|
||||
@@ -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")),
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
+68
-37
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user