Files
exportAnything-ComfyUI-Diff…/scripts/build_ltx_reframe_outpaint_workflow.py
T

302 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()