302 lines
11 KiB
Python
302 lines
11 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import shutil
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
SOURCE = (
|
||
ROOT.parent
|
||
/ "ComfyUI-LTXVideo"
|
||
/ "example_workflows"
|
||
/ "2.3"
|
||
/ "LTX-2.3_ICLoRA_Outpaint_Two_Stage_Distilled.json"
|
||
)
|
||
OUTPUT = ROOT / "examples" / "08_ltx23_reframe_iclora_outpaint_two_stage.json"
|
||
INSTALLED_OUTPUT = (
|
||
ROOT.parents[1]
|
||
/ "user"
|
||
/ "default"
|
||
/ "workflows"
|
||
/ "LTX 2.3 Reframe IC-LoRA Outpaint - Verified.json"
|
||
)
|
||
|
||
REMOVED_NODE_IDS = {5168, 5363, 5371, 5393}
|
||
REMOVED_LINK_IDS = {14383, 14395, 14445, 14446}
|
||
|
||
|
||
def node_by_id(workflow: dict[str, Any], node_id: int) -> dict[str, Any]:
|
||
return next(node for node in workflow["nodes"] if node["id"] == node_id)
|
||
|
||
|
||
def build_reframe_node() -> dict[str, Any]:
|
||
return {
|
||
"id": 5368,
|
||
"type": "LTXReframeLayout",
|
||
"pos": [-4225.854240441995, 3547.938320763212],
|
||
"size": [600, 760],
|
||
"flags": {},
|
||
"order": 12,
|
||
"mode": 0,
|
||
"inputs": [],
|
||
"outputs": [
|
||
{"name": "padded_frames", "type": "IMAGE", "links": [14364]},
|
||
{"name": "outpaint_mask", "type": "MASK", "links": [14369]},
|
||
{"name": "source_mask", "type": "MASK", "links": []},
|
||
{"name": "source_frames", "type": "IMAGE", "links": []},
|
||
{"name": "audio", "type": "AUDIO", "links": [14422]},
|
||
{"name": "fps", "type": "FLOAT", "links": [13889, 13968]},
|
||
{"name": "source_video", "type": "VIDEO", "links": []},
|
||
{"name": "target_width", "type": "INT", "links": []},
|
||
{"name": "target_height", "type": "INT", "links": []},
|
||
{"name": "placement_json", "type": "STRING", "links": []},
|
||
],
|
||
"title": "1. LTX Reframe Layout · Source & Framing",
|
||
"properties": {"Node name for S&R": "LTXReframeLayout"},
|
||
"widgets_values": [
|
||
"LTX-2_00180_.mp4",
|
||
"9:16",
|
||
576,
|
||
1024,
|
||
0.5,
|
||
0.5,
|
||
0.7,
|
||
32,
|
||
],
|
||
"color": "#1f4d52",
|
||
"bgcolor": "#16383c",
|
||
}
|
||
|
||
|
||
def build_mask_batch_node(
|
||
node_id: int,
|
||
*,
|
||
pos: list[float],
|
||
mask_link: int,
|
||
size_link: int,
|
||
output_link: int,
|
||
title: str,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"id": node_id,
|
||
"type": "MaskExpandBatch+",
|
||
"pos": pos,
|
||
"size": [270, 106],
|
||
"flags": {},
|
||
"order": 63 if node_id == 5400 else 64,
|
||
"mode": 0,
|
||
"inputs": [
|
||
{"name": "mask", "type": "MASK", "link": mask_link},
|
||
{
|
||
"name": "size",
|
||
"type": "INT",
|
||
"widget": {"name": "size"},
|
||
"link": size_link,
|
||
},
|
||
],
|
||
"outputs": [{"name": "MASK", "type": "MASK", "links": [output_link]}],
|
||
"title": title,
|
||
"properties": {
|
||
"cnr_id": "comfyui_essentials",
|
||
"Node name for S&R": "MaskExpandBatch+",
|
||
},
|
||
"widgets_values": [121, "expand"],
|
||
"color": "#5b4636",
|
||
"bgcolor": "#3c3027",
|
||
}
|
||
|
||
|
||
def clean_graph_references(workflow: dict[str, Any]) -> None:
|
||
valid_node_ids = {node["id"] for node in workflow["nodes"]}
|
||
workflow["links"] = [
|
||
link
|
||
for link in workflow["links"]
|
||
if link[0] not in REMOVED_LINK_IDS
|
||
and link[1] in valid_node_ids
|
||
and link[3] in valid_node_ids
|
||
]
|
||
valid_link_ids = {link[0] for link in workflow["links"]}
|
||
|
||
for node in workflow["nodes"]:
|
||
for item in node.get("inputs", []):
|
||
if item.get("link") not in valid_link_ids:
|
||
item["link"] = None
|
||
for item in node.get("outputs", []):
|
||
item["links"] = [
|
||
link_id for link_id in item.get("links") or [] if link_id in valid_link_ids
|
||
]
|
||
|
||
extra = workflow.get("extra", {})
|
||
reroutes = extra.get("reroutes", [])
|
||
for reroute in reroutes:
|
||
reroute["linkIds"] = [
|
||
link_id for link_id in reroute.get("linkIds", []) if link_id in valid_link_ids
|
||
]
|
||
extra["reroutes"] = [reroute for reroute in reroutes if reroute.get("linkIds")]
|
||
extra["linkExtensions"] = [
|
||
extension
|
||
for extension in extra.get("linkExtensions", [])
|
||
if extension.get("id") in valid_link_ids
|
||
]
|
||
|
||
for group in workflow.get("groups", []):
|
||
group["nodes"] = [node_id for node_id in group.get("nodes", []) if node_id in valid_node_ids]
|
||
|
||
workflow["last_node_id"] = max(valid_node_ids)
|
||
workflow["last_link_id"] = max(valid_link_ids)
|
||
|
||
|
||
def validate_graph(workflow: dict[str, Any]) -> None:
|
||
nodes = {node["id"]: node for node in workflow["nodes"]}
|
||
links = {link[0]: link for link in workflow["links"]}
|
||
if len(nodes) != len(workflow["nodes"]):
|
||
raise ValueError("Duplicate node IDs remain in the generated workflow.")
|
||
if len(links) != len(workflow["links"]):
|
||
raise ValueError("Duplicate link IDs remain in the generated workflow.")
|
||
|
||
for link_id, link in links.items():
|
||
if link[1] not in nodes or link[3] not in nodes:
|
||
raise ValueError(f"Link {link_id} has a missing endpoint: {link}")
|
||
source_output = nodes[link[1]]["outputs"][link[2]]
|
||
destination_input = nodes[link[3]]["inputs"][link[4]]
|
||
if link_id not in (source_output.get("links") or []):
|
||
raise ValueError(f"Link {link_id} is absent from its source output socket.")
|
||
if destination_input.get("link") != link_id:
|
||
raise ValueError(f"Link {link_id} is absent from its destination input socket.")
|
||
|
||
reframe = nodes.get(5368)
|
||
if reframe is None or reframe["type"] != "LTXReframeLayout":
|
||
raise ValueError("The generated workflow is missing LTXReframeLayout node 5368.")
|
||
|
||
expected = {
|
||
14364: (5368, 0, 5360, 0, "IMAGE"),
|
||
14369: (5368, 1, 5365, 0, "MASK"),
|
||
13889: (5368, 5, 1241, 2, "FLOAT"),
|
||
13968: (5368, 5, 5227, 2, "FLOAT"),
|
||
14422: (5368, 4, 5382, 0, "AUDIO"),
|
||
14376: (5365, 0, 5400, 0, "MASK"),
|
||
14377: (5364, 0, 5401, 0, "MASK"),
|
||
14447: (5054, 2, 5400, 1, "INT"),
|
||
14448: (5054, 2, 5401, 1, "INT"),
|
||
14449: (5400, 0, 5226, 2, "MASK"),
|
||
14450: (5401, 0, 5266, 2, "MASK"),
|
||
}
|
||
for link_id, contract in expected.items():
|
||
actual = tuple(links[link_id][1:])
|
||
if actual != contract:
|
||
raise ValueError(f"Link {link_id} is {actual}, expected {contract}.")
|
||
|
||
|
||
def build() -> dict[str, Any]:
|
||
workflow = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||
workflow["id"] = str(
|
||
uuid.uuid5(uuid.NAMESPACE_URL, "ltx-2.3-reframe-iclora-outpaint-two-stage")
|
||
)
|
||
workflow["nodes"] = [
|
||
node for node in workflow["nodes"] if node["id"] not in REMOVED_NODE_IDS | {5368}
|
||
]
|
||
workflow["nodes"].append(build_reframe_node())
|
||
workflow["nodes"].extend(
|
||
[
|
||
build_mask_batch_node(
|
||
5400,
|
||
pos=[1050.0, 3261.0],
|
||
mask_link=14376,
|
||
size_link=14447,
|
||
output_link=14449,
|
||
title="Full-Res Mask · Match Video Frame Count",
|
||
),
|
||
build_mask_batch_node(
|
||
5401,
|
||
pos=[-660.0, 3432.0],
|
||
mask_link=14377,
|
||
size_link=14448,
|
||
output_link=14450,
|
||
title="Half-Res Mask · Match Video Frame Count",
|
||
),
|
||
]
|
||
)
|
||
|
||
links = {link[0]: link for link in workflow["links"]}
|
||
links[14364] = [14364, 5368, 0, 5360, 0, "IMAGE"]
|
||
links[14369] = [14369, 5368, 1, 5365, 0, "MASK"]
|
||
links[13889] = [13889, 5368, 5, 1241, 2, "FLOAT"]
|
||
links[13968] = [13968, 5368, 5, 5227, 2, "FLOAT"]
|
||
links[14422] = [14422, 5368, 4, 5382, 0, "AUDIO"]
|
||
links[14376] = [14376, 5365, 0, 5400, 0, "MASK"]
|
||
links[14377] = [14377, 5364, 0, 5401, 0, "MASK"]
|
||
links[14447] = [14447, 5054, 2, 5400, 1, "INT"]
|
||
links[14448] = [14448, 5054, 2, 5401, 1, "INT"]
|
||
links[14449] = [14449, 5400, 0, 5226, 2, "MASK"]
|
||
links[14450] = [14450, 5401, 0, 5266, 2, "MASK"]
|
||
workflow["links"] = list(links.values())
|
||
|
||
node_by_id(workflow, 5226)["inputs"][2]["link"] = 14449
|
||
node_by_id(workflow, 5266)["inputs"][2]["link"] = 14450
|
||
node_by_id(workflow, 5054)["outputs"][2]["links"].extend([14447, 14448])
|
||
|
||
node_by_id(workflow, 5023)["widgets_values"][0] = (
|
||
"gemma_3_12B_it_fp4_mixed.safetensors"
|
||
)
|
||
node_by_id(workflow, 4922)["widgets_values"][0] = (
|
||
"ltx-2.3-22b-distilled-lora-384-1.1.safetensors"
|
||
)
|
||
node_by_id(workflow, 5011)["widgets_values"][0] = (
|
||
"ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors"
|
||
)
|
||
node_by_id(workflow, 2483)["widgets_values"][0] = (
|
||
"A cinematic live-action scene continuing naturally beyond the source frame, "
|
||
"with a coherent environment, realistic lighting, consistent subjects and camera "
|
||
"motion, seamless edges, and stable fine detail."
|
||
)
|
||
node_by_id(workflow, 5228)["widgets_values"][0] = "video/LTX_Reframe_Outpaint"
|
||
node_by_id(workflow, 5356)["widgets_values"][0] = (
|
||
"### Reframe Setup\n\nUse **LTX Reframe Layout** to choose the final canvas and place "
|
||
"the source. `source_scale < 1` zooms out for outpainting; `source_scale > 1` "
|
||
"zooms in and crops. The example starts at **576 × 1024**, 121 frames, with "
|
||
"audio, so it is suitable for a first end-to-end test."
|
||
)
|
||
|
||
for group in workflow.get("groups", []):
|
||
if group["id"] == 164:
|
||
group["title"] = "1. Reframe Source Video"
|
||
group["bounding"] = [-4235.854240441995, 3507.938320763212, 620, 800]
|
||
elif group["id"] == 168:
|
||
group["title"] = "2. Prepare Two-Stage Outpaint Conditions"
|
||
elif group["id"] == 178:
|
||
group["title"] = "Stage 2 Full-Resolution Mask"
|
||
elif group["id"] == 179:
|
||
group["title"] = "Stage 1 Half-Resolution Mask"
|
||
|
||
workflow.setdefault("extra", {})["ltx_reframe_workflow"] = {
|
||
"schema": "ltx_reframe_iclora_outpaint/v1",
|
||
"based_on": SOURCE.name,
|
||
"source_node": "LTXReframeLayout",
|
||
"default_source": "LTX-2_00180_.mp4",
|
||
"default_target": [576, 1024],
|
||
"required_lora": "ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors",
|
||
"mask_batch_safety": "MaskExpandBatch+ before both Laplacian blends",
|
||
}
|
||
|
||
clean_graph_references(workflow)
|
||
validate_graph(workflow)
|
||
return workflow
|
||
|
||
|
||
def main() -> None:
|
||
workflow = build()
|
||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||
OUTPUT.write_text(json.dumps(workflow, indent=2) + "\n", encoding="utf-8")
|
||
INSTALLED_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(OUTPUT, INSTALLED_OUTPUT)
|
||
print(f"Wrote {OUTPUT}")
|
||
print(f"Installed {INSTALLED_OUTPUT}")
|
||
print(f"Nodes: {len(workflow['nodes'])}; links: {len(workflow['links'])}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|