Files
exportAnything-ComfyUI-Diff…/tools/build_advertisement_music3_workflow.py

805 lines
37 KiB
Python

#!/usr/bin/env python3
"""Build the separate, user-editable Advertisement + Music 3 workflow.
The production graph is deliberately flattened: it does not reuse either V6
subgraph UUID, so edits to this Advertisement workflow cannot mutate the
known-good music-video workflow. Node socket metadata is read from a live
ComfyUI ``/object_info`` endpoint only while generating the checked-in UI
artifact; the resulting workflow is self-contained.
The native Music 3 route is the default. The uploaded-song and legacy ACE
branches are also present and connected through the lazy Advertisement source
router, but remain dormant unless selected.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import math
import os
from pathlib import Path
import re
import tempfile
from typing import Any, Mapping
from urllib.request import urlopen
import uuid
REPOSITORY = Path(__file__).resolve().parents[1]
KNOWN_GOOD_UI = REPOSITORY / "examples" / "15_minimax_h3_ref2va_music_video_v6.json"
KNOWN_GOOD_UI_SHA256 = "594f6aef94531f87b74659e1e8004cccdff00716d0cfe263cee6bf2149545c83"
FIELD_TEST_API = (
REPOSITORY / "examples" / "api" / "16_minimax_h3_ref2va_advertisement_glowbloom_v1_api.json"
)
FIELD_TEST_API_CANONICAL_SHA256 = "6296d86a7640ce1babe307f68c3ddab845341e5e1185cb2216924097e9b4f743"
MUSIC3_API = (
REPOSITORY / "examples" / "api" / "17_minimax_h3_ref2va_advertisement_music3_glowbloom_v1_api.json"
)
MUSIC3_API_CANONICAL_SHA256 = "d6f8d8de431f233c00a1172afca39815b8bd8bd619b633e8e5162db310fffc82"
OUTPUT = REPOSITORY / "examples" / "16_minimax_h3_ref2va_advertisement_music3_v1.json"
SCHEMA = "diffusiongemma.advertisement_music3_workflow"
VERSION = 1
WORKFLOW_UUID = "d9118b98-cc97-5cf5-81d8-86eff1336120"
FORBIDDEN_RUNTIME_CLASSES = frozenset(
{
"EasyCache",
"TorchCompileModel",
"PathchSageAttentionKJ",
"PatchSageAttentionKJ",
"easy cleanGpuUsed",
"FL_UnloadAllModels",
}
)
class WorkflowError(RuntimeError):
"""Raised when workflow lineage or live node metadata is invalid."""
def _sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _canonical_sha256(value: Any) -> str:
return _sha256_bytes(
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
)
def _json_bytes(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
def _load_guarded(path: Path, *, byte_hash: str = "", canonical_hash: str = "") -> Any:
data = path.read_bytes()
if byte_hash and _sha256_bytes(data) != byte_hash:
raise WorkflowError(f"Immutable source changed: {path}")
try:
parsed = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WorkflowError(f"Invalid UTF-8 JSON source: {path}") from exc
if canonical_hash and _canonical_sha256(parsed) != canonical_hash:
raise WorkflowError(f"Immutable canonical graph changed: {path}")
return parsed
def _object_info(url: str) -> dict[str, Any]:
endpoint = url.rstrip("/") + "/object_info"
try:
with urlopen(endpoint, timeout=30) as response: # noqa: S310 - explicit local endpoint
payload = json.loads(response.read().decode("utf-8"))
except Exception as exc: # pragma: no cover - environment-specific transport
raise WorkflowError(f"Could not read live ComfyUI node metadata from {endpoint}: {exc}") from exc
if not isinstance(payload, dict):
raise WorkflowError("ComfyUI /object_info did not return a JSON object.")
return payload
def _is_connection(value: Any) -> bool:
return (
isinstance(value, list)
and len(value) == 2
and isinstance(value[0], str)
and isinstance(value[1], int)
)
def _api_node(class_type: str, title: str, **inputs: Any) -> dict[str, Any]:
return {"class_type": class_type, "inputs": inputs, "_meta": {"title": title}}
def _copy_ancestors(
source: Mapping[str, Any], target: dict[str, Any], roots: list[str]
) -> None:
pending = list(roots)
while pending:
node_id = pending.pop()
if node_id in target:
continue
node = source.get(node_id)
if not isinstance(node, Mapping):
raise WorkflowError(f"Legacy fallback source is missing node {node_id}.")
target[node_id] = copy.deepcopy(node)
for value in node.get("inputs", {}).values():
if _is_connection(value):
pending.append(value[0])
def _with_lazy_fallbacks(
music3: dict[str, Any], field_test: Mapping[str, Any]
) -> dict[str, Any]:
graph = copy.deepcopy(music3)
# Reference preparation must not wait for Director resolution because the
# Director itself consumes the prepared reference batch. The saved 9:16
# advertisement baseline uses the contract node's native 480x864 defaults;
# H3's final generation dimensions remain Director-controlled downstream.
graph["803"]["inputs"]["generation_width"] = 480
graph["803"]["inputs"]["generation_height"] = 864
ace_candidates = ["623:567", "623:597", "623:602", "623:607"]
_copy_ancestors(field_test, graph, ace_candidates + ["562"])
upload = field_test.get("736")
if not isinstance(upload, Mapping):
raise WorkflowError("Field-test graph has no uploaded-song loader.")
graph["736"] = copy.deepcopy(upload)
graph["736"]["inputs"]["audio_file"] = ""
graph["736"]["_meta"]["title"] = "UPLOAD SONG — used only when Upload song is selected"
# Force-input primitives make fallback controls visible and editable in the
# UI while keeping the router's selected branch truly lazy.
graph["846"] = _api_node("SeedNode", "Music 3 candidate count — one", seed=1)
graph["847"] = _api_node("PrimitiveFloat", "Uploaded-song expected BPM — zero means measure", value=0.0)
graph["848"] = _api_node("PrimitiveStringMultiline", "Uploaded-song lyrics — optional outside Lyrics mode", value="")
graph["850"] = _api_node("SeedNode", "Legacy ACE candidate count — four", seed=4)
graph["851"] = _api_node(
"PrimitiveStringMultiline",
"Advertisement performance guidance — music-only H3 motion authority",
value=graph["675"]["inputs"]["base_audio_guidance"],
)
graph["675"]["inputs"]["base_audio_guidance"] = ["851", 0]
# The old ACE audition plan depended on the H3 Director context, while the
# Director waits for the selected soundtrack. Reuse only the proven ACE
# model/sampler branch and feed it from the already-governed Advertisement
# Soundtrack contract. This keeps ACE a real lazy fallback without a
# circular song<->Director dependency.
for node_id in ("623:561", "623:594", "623:599", "623:604"):
inputs = graph[node_id]["inputs"]
inputs["tags"] = ["805", 0]
inputs["lyrics"] = ["805", 1]
inputs["bpm"] = ["805", 5]
inputs["timesignature"] = "4"
inputs["language"] = "unknown"
inputs["keyscale"] = "C major"
# These nodes existed only to derive the former Director-dependent ACE
# blueprint or to preview intermediate audio. They are not production
# outputs and must not leak into the independent Advertisement workflow.
for node_id in ("183", "556", "578", "609"):
graph.pop(node_id, None)
router = graph["814"]["inputs"]
router.update(
{
"music3_candidate_count": ["846", 0],
"uploaded_audio": ["736", 0],
"uploaded_duration_seconds": ["736", 1],
"uploaded_expected_bpm": ["847", 0],
"uploaded_lyrics": ["848", 0],
"uploaded_waveform_sha256": ["736", 2],
"uploaded_status": ["736", 3],
"uploaded_ready": ["736", 4],
"ace_candidate_count": ["850", 0],
"ace_expected_bpm": ["805", 3],
"ace_lyrics": ["805", 1],
"ace_duration_seconds": ["562", 0],
"ace_candidate_1": [ace_candidates[0], 0],
"ace_candidate_2": [ace_candidates[1], 0],
"ace_candidate_3": [ace_candidates[2], 0],
"ace_candidate_4": [ace_candidates[3], 0],
}
)
graph["818"]["inputs"]["source_policy"] = ["814", 8]
inherited = sorted(
(node_id, str(node.get("class_type", "")))
for node_id, node in graph.items()
if str(node.get("class_type", "")) in FORBIDDEN_RUNTIME_CLASSES
)
if inherited:
raise WorkflowError(f"Forbidden inherited runtime nodes entered Advertisement UI source: {inherited}")
return graph
def _definition(info: Mapping[str, Any], name: str) -> tuple[Any, dict[str, Any]] | None:
for section in ("required", "optional"):
raw = info.get("input", {}).get(section, {}).get(name)
if isinstance(raw, list) and raw:
config = raw[1] if len(raw) > 1 and isinstance(raw[1], Mapping) else {}
return raw[0], dict(config)
if "." in name:
group, _child = name.split(".", 1)
raw = info.get("input", {}).get("optional", {}).get(group)
if isinstance(raw, list) and len(raw) > 1 and isinstance(raw[1], Mapping):
template = raw[1].get("template", {})
required = template.get("input", {}).get("required", {})
if isinstance(required, Mapping) and required:
first = next(iter(required.values()))
if isinstance(first, list) and first:
config = first[1] if len(first) > 1 and isinstance(first[1], Mapping) else {}
return first[0], dict(config)
return None
def _ordered_inputs(info: Mapping[str, Any], api_inputs: Mapping[str, Any]) -> list[str]:
values: list[str] = []
order = info.get("input_order", {})
for section in ("required", "optional"):
for name in order.get(section, []):
if name in api_inputs:
values.append(str(name))
elif any(str(item).startswith(str(name) + ".") for item in api_inputs):
values.extend(
str(item) for item in api_inputs if str(item).startswith(str(name) + ".")
)
for name in api_inputs:
if str(name) not in values:
values.append(str(name))
return values
def _widget_compatible(type_name: Any, config: Mapping[str, Any]) -> bool:
if bool(config.get("forceInput")):
return False
if isinstance(type_name, list):
return True
return str(type_name) in {"STRING", "INT", "FLOAT", "BOOLEAN", "COMBO"}
def _default_widget(type_name: Any, config: Mapping[str, Any]) -> Any:
if "default" in config:
return copy.deepcopy(config["default"])
if isinstance(type_name, list):
return copy.deepcopy(type_name[0]) if type_name else ""
if str(type_name) == "COMBO":
options = config.get("options", [])
return copy.deepcopy(options[0]) if options else ""
return {"STRING": "", "INT": 0, "FLOAT": 0.0, "BOOLEAN": False}.get(str(type_name), "")
def _socket_type(type_name: Any, config: Mapping[str, Any]) -> str:
if isinstance(type_name, list) or str(type_name) == "COMBO":
return "COMBO"
return str(type_name)
def _cnr_id(info: Mapping[str, Any]) -> str:
module = str(info.get("python_module", ""))
if "ComfyUI-DiffusionGemmaPromptBuilder" in module:
return "diffusiongemma-prompt-builder"
if "easy" in module.casefold():
return "comfyui-easy-use"
if module.startswith("comfy") or module == "nodes":
return "comfy-core"
return module.rsplit(".", 1)[-1] or "comfy-core"
def _size_for(class_type: str, widget_count: int, input_count: int) -> list[float]:
explicit = {
"LoadImage": [430.0, 520.0],
"DiffusionGemmaAdvertisementCampaignContract": [620.0, 720.0],
"DiffusionGemmaAdvertisementReferenceContract": [620.0, 600.0],
"DiffusionGemmaAdvertisementReferenceAssetPrep": [620.0, 500.0],
"DiffusionGemmaAdvertisementSoundtrackContract": [620.0, 660.0],
"DiffusionGemmaAdvertisementSoundtrackSourceRouter": [650.0, 850.0],
"DiffusionGemmaAdvertisementMultiShotPlanner": [700.0, 880.0],
"MiniMaxH3ReferenceToVideo": [560.0, 620.0],
"DiffusionGemmaCoTGenerator": [520.0, 420.0],
"DiffusionGemmaH3ReferenceContext": [620.0, 620.0],
"DiffusionGemmaAdvertisementMediaQAGate": [620.0, 430.0],
"SaveVideo": [420.0, 170.0],
}
if class_type in explicit:
return explicit[class_type]
return [380.0, float(max(90, min(620, 70 + widget_count * 28 + input_count * 22)))]
def _position(api_id: str, class_type: str, index: int) -> list[float]:
fixed: dict[str, tuple[float, float]] = {
"179": (-3850, -2600), "721": (-3850, -1450), "800": (-3850, -300),
"801": (-3200, -2600), "802": (-3200, -1450), "803": (-2450, -1600),
"804": (-3200, 650), "805": (-2450, 650), "806": (-2450, 1120),
"807": (-2450, 1280), "808": (-2450, 1440), "809": (-1900, 900),
"810": (-1400, 1120), "811": (-1400, 1280), "812": (-980, 980),
"813": (-520, 1020), "814": (0, 650), "815": (760, 800),
"816": (1450, 900), "817": (1900, 760), "818": (2440, 800),
"819": (2950, 850), "844": (3400, 650), "845": (3400, 900),
"183": (-1850, -2600), "674": (-1150, -2600), "185": (-1850, -1800),
"182": (-1850, -1400), "673": (-1150, -1850), "675": (-500, -1850),
"186": (-350, -2600), "820": (300, -2600), "187": (950, -2600), "188": (1550, -2600),
"676": (1450, -2600), "821": (2200, -2600), "822": (2950, -2600),
"823": (3700, -2600), "418": (3050, -1750), "678": (3650, -1650),
"661": (2800, -1250), "713": (3250, -1250), "715": (3700, -1250),
"716": (4150, -1250), "662": (2800, -1050), "653": (2800, -850),
"654": (2800, -650), "710:657": (4550, -1250), "710:658": (4550, -1050),
"831": (6600, -2500), "832": (6600, -1750), "833": (7300, -2300),
"834": (7300, -1600), "835": (7950, -2300), "836": (8650, -2300),
"837": (8650, -1700), "838": (9150, -1700), "839": (8650, -1350),
"840": (9150, -1350), "841": (8650, -1000), "842": (9150, -1000),
"843": (9300, -2300), "844": (9300, 650), "845": (9300, 900),
"900": (1400, 1450), "901": (900, -2050), "902": (200, -2050),
"736": (-3200, 1600), "846": (-2450, 1850), "847": (-3200, 1850),
"848": (-3200, 2020), "851": (-500, -1450),
"849": (-2350, -3000),
}
if api_id in fixed:
return [float(fixed[api_id][0]), float(fixed[api_id][1])]
lane_map = {
"710:667": 0, "710:663": 0, "710:660": 0, "710:717": 0,
"710:659": 0, "710:656": 0,
"710:684": 1, "710:685": 1, "710:686": 1, "710:718": 1,
"710:687": 1, "710:688": 1,
"710:692": 2, "710:693": 2, "710:694": 2, "710:719": 2,
"710:695": 2, "710:696": 2,
"710:700": 3, "710:701": 3, "710:702": 3, "710:720": 3,
"710:703": 3, "710:704": 3,
}
if api_id in lane_map:
lane = lane_map[api_id]
y = -500 + lane * 1050
column = {
"MiniMaxH3ReferenceToVideo": 4300,
"RandomNoise": 4900,
"BasicGuider": 4900,
"FL_UnloadAllModels": 4900,
"SamplerCustomAdvanced": 5350,
"VAEDecode": 5750,
}.get(class_type, 5200)
if class_type in {"BasicGuider", "FL_UnloadAllModels"}:
y += 260 if class_type == "BasicGuider" else 500
return [float(column), float(y)]
relay_positions = {
"681": (3800, -300), "683": (3800, 750), "691": (3800, 1800), "699": (3800, 2850),
"825": (6150, 250), "826": (6600, 250), "827": (6150, 1300), "828": (6600, 1300),
"829": (6150, 2350), "830": (6600, 2350),
}
if api_id in relay_positions:
x, y = relay_positions[api_id]
return [float(x), float(y)]
if api_id.startswith("623:"):
try:
inner = int(api_id.split(":", 1)[1])
except ValueError:
inner = index
return [-1600.0 + float((inner % 4) * 420), 1650.0 + float(((inner // 4) % 6) * 280)]
if api_id in {"556", "558", "559", "560", "562", "565", "578", "589", "590", "591", "592", "609"}:
slot = sorted(["556", "558", "559", "560", "562", "565", "578", "589", "590", "591", "592", "609"]).index(api_id)
return [-2450.0 + float((slot % 4) * 430), 1650.0 + float((slot // 4) * 310)]
return [float(9600 + (index % 4) * 420), float(-2500 + (index // 4) * 260)]
def _api_sort_key(value: str) -> tuple[int, int, str]:
match = re.fullmatch(r"(\d+)(?::(\d+))?", value)
if match:
return int(match.group(1)), int(match.group(2) or -1), value
return 1_000_000, 0, value
def _topological_order(graph: Mapping[str, Any]) -> list[str]:
pending = set(graph)
ordered: list[str] = []
while pending:
ready = sorted(
[
node_id
for node_id in pending
if all(
not _is_connection(value) or value[0] not in pending
for value in graph[node_id].get("inputs", {}).values()
)
],
key=_api_sort_key,
)
if not ready:
raise WorkflowError("API graph contains a dependency cycle.")
ordered.extend(ready)
pending.difference_update(ready)
return ordered
def build_workflow(graph: dict[str, Any], object_info: Mapping[str, Any]) -> dict[str, Any]:
api_ids = sorted(graph, key=_api_sort_key)
numeric_ids = {api_id: index + 1 for index, api_id in enumerate(api_ids)}
topo = _topological_order(graph)
topo_order = {api_id: index for index, api_id in enumerate(topo)}
nodes: list[dict[str, Any]] = []
records: dict[str, dict[str, Any]] = {}
for index, api_id in enumerate(api_ids):
api_node = graph[api_id]
class_type = str(api_node.get("class_type", ""))
info = object_info.get(class_type)
if not isinstance(info, Mapping):
raise WorkflowError(f"Live ComfyUI has no node metadata for {class_type} ({api_id}).")
api_inputs = api_node.get("inputs", {})
input_records: list[dict[str, Any]] = []
widget_values: list[Any] = []
ordered_names = _ordered_inputs(info, api_inputs)
for name in ordered_names:
definition = _definition(info, name)
if definition is None:
# Some custom nodes persist frontend-only scalar widgets that
# are intentionally absent from the execution contract. A
# connected socket is different: ComfyUI forwards it to the
# node function, so an unknown connected name is a runtime
# TypeError waiting to happen. Fail that case closed while
# preserving established frontend-only widget state.
if _is_connection(api_inputs.get(name)):
raise WorkflowError(
f"Live ComfyUI {class_type} has no connected input {name} ({api_id})."
)
if name in api_inputs:
widget_values.append(copy.deepcopy(api_inputs[name]))
continue
type_name, config = definition
value = api_inputs.get(name)
socket_type = _socket_type(type_name, config)
if _is_connection(value):
item: dict[str, Any] = {"name": name, "type": socket_type, "link": None}
if _widget_compatible(type_name, config):
item["widget"] = {"name": name}
if "." in name:
item["localized_name"] = name
item["label"] = name.split(".", 1)[1]
input_records.append(item)
elif bool(config.get("forceInput")):
# Guarded above by explicit primitive nodes in this workflow.
if value is not None:
raise WorkflowError(f"Force-input scalar {api_id}:{name} needs a primitive connection.")
if _widget_compatible(type_name, config):
widget_values.append(
copy.deepcopy(value) if value is not None and not _is_connection(value) else _default_widget(type_name, config)
)
if bool(config.get("control_after_generate")):
widget_values.append("fixed")
# Current ComfyUI serializes both LoadImage's ordinary image widget and
# its frontend-only upload widget as input descriptors. Widget values
# alone preview correctly but the media validator reports a false
# missing-image error unless these exact descriptors are also present.
if class_type == "LoadImage":
input_records = [
{
"localized_name": "image",
"name": "image",
"type": "COMBO",
"widget": {"name": "image"},
"link": None,
},
{
"localized_name": "choose file to upload",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {"name": "upload"},
"link": None,
},
]
if len(widget_values) == 1:
widget_values.append("image")
output_types = list(info.get("output", []))
output_names = list(info.get("output_name", []))
outputs = []
for slot, output_type in enumerate(output_types):
name = str(output_names[slot] if slot < len(output_names) else output_type)
outputs.append({"name": name, "type": _socket_type(output_type, {}), "links": []})
size = _size_for(class_type, len(widget_values), len(input_records))
title = str(api_node.get("_meta", {}).get("title") or info.get("display_name") or class_type)
node = {
"id": numeric_ids[api_id],
"type": class_type,
"pos": _position(api_id, class_type, index),
"size": size,
"flags": {},
"order": topo_order[api_id],
"mode": 0,
"inputs": input_records,
"outputs": outputs,
"title": title,
"properties": {
"cnr_id": _cnr_id(info),
"Node name for S&R": class_type,
"diffusiongemma_api_node_id": api_id,
},
"widgets_values": widget_values,
}
nodes.append(node)
records[api_id] = node
links: list[list[Any]] = []
next_link = 0
for target_api_id in api_ids:
target = records[target_api_id]
api_inputs = graph[target_api_id].get("inputs", {})
for input_name, value in api_inputs.items():
if not _is_connection(value):
continue
origin_api_id, origin_slot = value
if origin_api_id not in records:
raise WorkflowError(f"Dangling API origin {origin_api_id} -> {target_api_id}:{input_name}.")
origin = records[origin_api_id]
if int(origin_slot) >= len(origin["outputs"]):
raise WorkflowError(f"Invalid output slot {origin_api_id}:{origin_slot}.")
target_slots = [
slot for slot, item in enumerate(target["inputs"]) if item.get("name") == input_name
]
if len(target_slots) != 1:
raise WorkflowError(f"UI socket reconstruction failed for {target_api_id}:{input_name}.")
target_slot = target_slots[0]
next_link += 1
link_type = str(origin["outputs"][int(origin_slot)].get("type") or target["inputs"][target_slot].get("type") or "*")
links.append(
[
next_link,
int(origin["id"]),
int(origin_slot),
int(target["id"]),
int(target_slot),
link_type,
]
)
target["inputs"][target_slot]["link"] = next_link
origin["outputs"][int(origin_slot)]["links"].append(next_link)
note_id = len(nodes) + 1
note = {
"id": note_id,
"type": "MarkdownNote",
"pos": [-3850.0, -3300.0],
"size": [1850.0, 560.0],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "START HERE — separate Advertisement system / MiniMax Music 3 default",
"properties": {},
"widgets_values": [
"# DiffusionGemma Advertisement — MiniMax Music 3 + MiniMax H3\n\n"
"This is a new, flattened workflow with its own workflow UUID. The known-good V6 music-video workflow is not a parent graph and is never overwritten.\n\n"
"1. Picture 1 is the performer hero. Picture 2 is a same-performer identity sheet. Picture 3 is a product-only contact sheet. Picture 4 is created automatically from the exact retained prior-lane tail and is continuity evidence only.\n"
"2. Edit CAMPAIGN, REFERENCES, and SOUNDTRACK. MiniMax Music 3 is the default. SONG SOURCE also exposes Upload song and Legacy ACE-Step; unselected branches remain lazy.\n"
"3. Use ADVERTISEMENT CONTROLS as the single duration, native-shot, and performance authority. The default 30-second master uses eight native H3 shots inside two 15-second generation lanes. EasyCache and TorchCompile are absent from the baseline because they provided no measured gain.\n"
"4. The motion guide is music-only. Optional non-diegetic VO is mixed only into the delivery master and never fed to H3.\n"
"5. Exact campaign copy is rendered in the deterministic three-second end card. The workflow saves review drafts of the master plus real 15s and 6s cutdowns. Requested 1:1/16:9 adaptations remain explicitly unrendered until a semantic reframe renderer is connected.\n"
"6. QA defaults visual identity, copy legibility, and sync to not_measured. Saved files are review drafts; every delivery pass requires named evidence."
],
}
nodes.append(note)
groups = [
{"id": 1, "title": "1. Campaign + independent performer/product references", "bounding": [-3950, -3400, 4200, 3250], "color": "#d66b3d", "font_size": 36, "flags": {}},
{"id": 2, "title": "2. MiniMax Music 3 default + lazy Upload / ACE fallbacks", "bounding": [-3300, 500, 7300, 3400], "color": "#4f8cc9", "font_size": 36, "flags": {}},
{"id": 3, "title": "3. Advertisement Director + typed H3 plan", "bounding": [-1950, -2800, 6500, 1750], "color": "#8064b4", "font_size": 36, "flags": {}},
{"id": 4, "title": "4. Four-reference H3 lanes + persistent retained-tail relay", "bounding": [2700, -1400, 4300, 5200], "color": "#b88632", "font_size": 36, "flags": {}},
{"id": 5, "title": "5. Deterministic end card, exact master, real cutdowns, honest QA", "bounding": [6500, -2700, 3400, 2050], "color": "#4d9a75", "font_size": 36, "flags": {}},
]
workflow = {
"id": WORKFLOW_UUID,
"revision": 1,
"last_node_id": note_id,
"last_link_id": next_link,
"nodes": nodes,
"links": links,
"groups": groups,
"config": {},
"extra": {
SCHEMA: {
"version": VERSION,
"known_good_v6_file_sha256": KNOWN_GOOD_UI_SHA256,
"known_good_v6_is_never_overwritten": True,
"field_test_api_canonical_sha256": FIELD_TEST_API_CANONICAL_SHA256,
"music3_api_canonical_sha256": MUSIC3_API_CANONICAL_SHA256,
"workflow_uuid_is_fresh": True,
"graph_form": "flattened_independent_no_shared_subgraph_uuid",
"audio_source_default": "MiniMax Music 3",
"fallbacks": ["Upload song", "Legacy ACE-Step"],
"easycache_default": "absent_measured_1.00x",
"torchcompile_default": "absent_outside_baseline",
"advertisement_memory_barriers": 5,
},
"workflow_note": (
"Separate Advertisement workflow: MiniMax Music 3 default; independent P1/P2 performer and P3 product roles; "
"persistent P4 retained-tail relay; deterministic copy finishing; exact 30/15/6 deliverables; V6 untouched."
),
},
"version": 0.4,
}
validate_workflow(workflow)
return workflow
def _origin(workflow: Mapping[str, Any], node: Mapping[str, Any], input_name: str) -> Mapping[str, Any]:
slots = [index for index, item in enumerate(node.get("inputs", [])) if item.get("name") == input_name]
if len(slots) != 1:
raise WorkflowError(f"Expected one input {node.get('id')}:{input_name}.")
link_id = node["inputs"][slots[0]].get("link")
link = next((item for item in workflow.get("links", []) if int(item[0]) == int(link_id)), None)
if link is None:
raise WorkflowError(f"Missing link for {node.get('id')}:{input_name}.")
origin = next((item for item in workflow.get("nodes", []) if int(item["id"]) == int(link[1])), None)
if origin is None:
raise WorkflowError("Workflow link has a missing origin node.")
return origin
def validate_workflow(workflow: Mapping[str, Any]) -> None:
if workflow.get("id") != WORKFLOW_UUID or int(workflow.get("revision", 0)) != 1:
raise WorkflowError("Advertisement workflow must keep its fresh revision-1 UUID.")
if workflow.get("id") == _load_guarded(KNOWN_GOOD_UI, byte_hash=KNOWN_GOOD_UI_SHA256).get("id"):
raise WorkflowError("Advertisement workflow reused the known-good V6 UUID.")
nodes = list(workflow.get("nodes", []))
types = [str(node.get("type", "")) for node in nodes]
for required in (
"DiffusionGemmaAdvertisementWorkflowControls",
"DiffusionGemmaAdvertisementCampaignContract",
"DiffusionGemmaAdvertisementReferenceAssetPrep",
"DiffusionGemmaAdvertisementSoundtrackSourceRouter",
"MiniMaxMusic3TextEncode",
"DiffusionGemmaAdvertisementMultiShotPlanner",
"DiffusionGemmaAdvertisementRelayArtifact",
"DiffusionGemmaAdvertisementEndCardRenderer",
"DiffusionGemmaAdvertisementCutdownRenderer",
"DiffusionGemmaAdvertisementMediaQAGate",
):
if required not in types:
raise WorkflowError(f"Advertisement UI workflow is missing {required}.")
forbidden = sorted(set(types).intersection(FORBIDDEN_RUNTIME_CLASSES))
if forbidden:
raise WorkflowError(f"Forbidden acceleration/convenience runtime nodes entered the Advertisement UI baseline: {forbidden}")
if types.count("DiffusionGemmaAdvertisementMemoryBarrier") != 5:
raise WorkflowError("Advertisement UI workflow must retain five self-contained memory barriers.")
if types.count("SaveVideo") != 3:
raise WorkflowError("Advertisement UI workflow must save master, 15s, and 6s videos.")
if types.count("DiffusionGemmaAdvertisementWorkflowControls") != 1:
raise WorkflowError("Advertisement UI workflow must have one shared settings authority.")
by_api_id = {
str(node.get("properties", {}).get("diffusiongemma_api_node_id", "")): node
for node in nodes
}
by_id = {int(node["id"]): node for node in nodes}
for link in workflow.get("links", []):
origin = by_id.get(int(link[1]))
target = by_id.get(int(link[3]))
if origin is None or target is None:
raise WorkflowError("Advertisement UI link references a missing node.")
origin_type = str(origin["outputs"][int(link[2])].get("type", "*"))
target_type = str(target["inputs"][int(link[4])].get("type", "*"))
link_type = str(link[5])
if origin_type != "*" and target_type != "*" and origin_type != target_type:
raise WorkflowError(
f"Advertisement UI link type mismatch: {origin_type} -> {target_type} "
f"at {origin.get('type')}:{link[2]} -> {target.get('type')}:{link[4]}."
)
if link_type != origin_type and origin_type != "*":
raise WorkflowError("Advertisement UI link metadata does not match its origin socket type.")
for api_id, input_name in (
("801", "production_duration_seconds"),
("804", "target_duration_seconds"),
("673", "target_duration_seconds"),
("676", "production_duration_seconds"),
("815", "excerpt_duration_seconds"),
("816", "duration"),
("817", "target_duration_seconds"),
("818", "excerpt_duration_seconds"),
("673", "custom_shot_count"),
("821", "native_shot_count"),
("675", "performance_mode"),
("823", "performance_mode"),
("835", "cutdown_15_start_seconds"),
("835", "cutdown_6_start_seconds"),
("801", "deliverables_json"),
("676", "deliverables_json"),
("801", "aspect_ratio"),
("676", "aspect_ratio"),
("673", "generation_mode"),
("673", "shot_count"),
("673", "shot_count_override"),
("673", "audio_mode"),
("673", "dialogue_mode"),
("676", "excerpt_start_seconds"),
("676", "generation_model"),
("676", "max_h3_shot_seconds"),
("187", "resolution_aspect_ratio_override"),
):
if api_id not in by_api_id or _origin(workflow, by_api_id[api_id], input_name).get("type") != "DiffusionGemmaAdvertisementWorkflowControls":
raise WorkflowError(f"Advertisement UI shared control drifted at {api_id}:{input_name}.")
router = next(node for node in nodes if node.get("type") == "DiffusionGemmaAdvertisementSoundtrackSourceRouter")
if not router.get("widgets_values") or router["widgets_values"][0] != "MiniMax Music 3":
raise WorkflowError("MiniMax Music 3 must remain the saved soundtrack default.")
for name in ("music3_candidate_1", "uploaded_audio", "ace_candidate_1"):
_origin(workflow, router, name)
h3_nodes = [node for node in nodes if node.get("type") == "MiniMaxH3ReferenceToVideo"]
if len(h3_nodes) != 4:
raise WorkflowError("Advertisement UI workflow must retain four lazy H3 generation lanes.")
h3_nodes.sort(key=lambda node: float(node.get("pos", [0, 0])[1]))
for lane, node in enumerate(h3_nodes, start=1):
for name in ("ref_images.ref_image_0", "ref_images.ref_image_1", "ref_images.ref_image_2"):
_origin(workflow, node, name)
relay_slots = [item for item in node.get("inputs", []) if item.get("name") == "ref_images.ref_image_3"]
if lane == 1 and relay_slots:
raise WorkflowError("Lane 1 cannot consume relay Picture 4.")
if lane > 1 and len(relay_slots) != 1:
raise WorkflowError(f"Lane {lane} must consume persistent relay Picture 4.")
marker = workflow.get("extra", {}).get(SCHEMA, {})
if marker.get("known_good_v6_file_sha256") != KNOWN_GOOD_UI_SHA256 or not marker.get("known_good_v6_is_never_overwritten"):
raise WorkflowError("Advertisement UI lineage guard is incomplete.")
def _write_guarded(path: Path, payload: bytes, *, force: bool) -> str:
path = path.resolve()
path.parent.mkdir(parents=True, exist_ok=True)
existed = path.exists()
if existed:
current = path.read_bytes()
if current == payload:
return "verified"
if not force:
raise WorkflowError(f"Refusing to overwrite a different Advertisement workflow: {path}")
try:
existing = json.loads(current.decode("utf-8"))
marker = existing.get("extra", {}).get(SCHEMA, {})
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
raise WorkflowError(f"Refusing to replace an unrecognized artifact: {path}") from exc
if marker.get("version") != VERSION or existing.get("id") != WORKFLOW_UUID:
raise WorkflowError(f"Refusing to replace an artifact without Advertisement ownership: {path}")
descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return "replaced" if existed else "created"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--object-info-url", default="http://127.0.0.1:8191")
parser.add_argument("--output", type=Path, default=OUTPUT)
parser.add_argument("--check", action="store_true")
parser.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
_load_guarded(KNOWN_GOOD_UI, byte_hash=KNOWN_GOOD_UI_SHA256)
field_test = _load_guarded(FIELD_TEST_API, canonical_hash=FIELD_TEST_API_CANONICAL_SHA256)
music3 = _load_guarded(MUSIC3_API, canonical_hash=MUSIC3_API_CANONICAL_SHA256)
graph = _with_lazy_fallbacks(music3, field_test)
workflow = build_workflow(graph, _object_info(args.object_info_url))
payload = _json_bytes(workflow)
summary = {
"workflow_id": workflow["id"],
"nodes": len(workflow["nodes"]),
"links": len(workflow["links"]),
"sha256": _sha256_bytes(payload),
"canonical_sha256": _canonical_sha256(workflow),
"known_good_v6_sha256": KNOWN_GOOD_UI_SHA256,
}
if args.check:
print(json.dumps(summary, sort_keys=True))
return 0
summary["action"] = _write_guarded(args.output, payload, force=args.force)
summary["path"] = str(args.output.resolve())
print(json.dumps(summary, sort_keys=True))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())