from __future__ import annotations
import copy
import hashlib
import json
import math
import random
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from h3_audio_intent import plan_h3_audio
from h3_audio_prompt import normalized_h3_audio_prompt
WORKFLOW_DIR = Path(__file__).resolve().parent / "workflows"
MAX_VIDEO_DURATION_SECONDS = 15
CAPABILITY_BY_MODE = {"t2i": "image", "i2i": "image", "t2i_i2i": "image", "text": "video", "image": "video"}
VISIBLE_TEXT_CONFLICTS = {
"text", "words", "readable words", "readable text", "letters", "digits", "numbers",
"chinese text", "chinese characters", "random text", "garbled text", "gibberish text",
"pseudo-text", "pseudo-chinese glyphs", "malformed han characters", "mojibake",
"corrupted glyphs", "labels", "signage", "typography", "text overlay", "logo",
"brand logo", "brand name", "trademark", "printed words", "printed numbers",
}
H3_DIALOGUE_TAG = re.compile(r"\[(?P[^\]]+)](?P.*?)", re.IGNORECASE | re.DOTALL)
H3_SPEECH_PREFIX = r"(?P(?:口播(?:内容|文案)?|台词|对白|旁白|朗读|说(?:道|出)?|讲(?:道)?|\b(?:says?|said|speaks?|spoke|narrates?)\b)\s*[::]?\s*)"
H3_QUOTED_DIALOGUE = tuple(
re.compile(
H3_SPEECH_PREFIX + re.escape(left) + r"(?P(?:\\.|[^" + re.escape(right) + r"\\]"
+ (r"|(?<=[A-Za-z])'(?=[A-Za-z])" if right == "'" else "") + r")*)" + re.escape(right),
re.IGNORECASE,
)
for left, right in (("“", "”"), ('"', '"'), ("‘", "’"), ("'", "'"), ("「", "」"), ("『", "』"))
)
H3_BARE_DIALOGUE = re.compile(r"(?:^|(?<=\t))(?P[ ]*(?:口播(?:内容|文案)?|台词|对白|旁白|dialogue|voiceover|narration)[ ]*[::][ ]*)(?P[^\t\r\n]*)(?=[\t\r\n]|$)", re.IGNORECASE | re.MULTILINE)
H3_QUOTED_SPEECH_START = re.compile(H3_SPEECH_PREFIX + r"[\"“‘'「『]", re.IGNORECASE)
H3_COMPLETE_SILENCE = re.compile(
r"(?:全程|完全|绝对)[ \t]*(?:绝对)?[ \t]*静音"
r"|(?:不要|无需|不需要|禁止|关闭|移除|不生成|不输出)[ \t]*(?:任何|全部|所有)?[ \t]*(?:音频|声音|音轨)"
r"|\b(?:no audio|mute all audio|complete silence|silent video)\b",
re.IGNORECASE,
)
H3_AUDIO_OUTPUT_POLICY_VERSION = "h3-audio-plan-verified-v25"
H3_CAPTION_PIPELINE_VERSION = "native-full-attention-realism04-manju-v24"
H3_VERIFIED_TEXT_ENCODER = "qwen3vl_32b_minimax_h3_int8_convrot.safetensors"
H3_CLEAR_FRAME_RULE = (
"No text, no subtitles, no captions, no watermark anywhere in the frame."
)
# Visual mouth movements or chatting are not consent to invent spoken words.
H3_SPEECH_INTENT = re.compile(r"口播|台词|对白|旁白|朗读|配音|(?:输出|生成|需要|播放)[^,。!?;\n]{0,12}(?:语音|人声)|(?:说(?:道|出)?|讲(?:道)?)\s*[::\"“‘'「『]|\b(?:narrates?|narrator|dialogue|narration|voice[- ]?over|spoken words|spoken audio)\b", re.IGNORECASE)
H3_SILENT_CLAUSE = re.compile(
r"(?:无|没有|不要|不许|禁止|无需|不需要|不)(?:(?:任何|额外的?|其他|背景|人物|角色|进行|出现|加入|添加|自动|输出|生成|播放|允许|需要|加|再)){0,3}(?:口播|台词|对白|旁白|说话|讲话|聊天|交谈|朗读|配音|人声|语音)"
r"|\b(?:no|not|without|never)\s+(?:(?:additional|other|background|characters?|one|any|human|audible)\s+){0,4}(?:speech|dialogue|narration|voiceover|talking|speaking|speaks?|talks?)(?:\s*(?:,|or|and)\s*(?:speech|dialogue|narration|voiceover|talking|speaking|voices?))*",
re.IGNORECASE,
)
H3_NO_SPEECH_RULE = (
"H3 audio compliance: Background-only audio. Preserve the requested environmental ambience, "
"physical sound effects and instrumental background music. Do not add music unless requested. "
"No character speaks audibly. No voiceover, narration, dialogue, lyrics, chanting, "
"background conversation or invented language is audible. Do not generate human words or syllables. "
"Keep the visual actions in the prompt unchanged."
)
H3_DIALOGUE_RULE = (
"H3 audio compliance: Keep the speaker identities, on-screen/off-screen positions and audio sources described above. "
"Render the tagged dialogue in its declared language and written order. "
"Do not translate, paraphrase, replace, repeat, or invent any syllable beyond the written dialogue. "
"Keep all non-speech audio requirements unchanged."
)
@dataclass(frozen=True)
class WorkflowSpec:
key: str
name: str
mode: str
file: str
raw: dict[str, Any]
revision: str
@property
def capability(self) -> str:
return CAPABILITY_BY_MODE[self.mode]
@property
def path(self) -> Path:
return WORKFLOW_DIR / self.file
def load_registry() -> dict[str, WorkflowSpec]:
manifest = json.loads((WORKFLOW_DIR / "manifest.json").read_text(encoding="utf-8"))
result: dict[str, WorkflowSpec] = {}
for row in manifest.get("workflows") or []:
if not row.get("enabled", True):
continue
mode = str(row.get("mode") or "")
if mode not in CAPABILITY_BY_MODE:
raise ValueError(f"unsupported workflow mode: {mode}")
path = WORKFLOW_DIR / str(row["file"])
payload = path.read_bytes()
revision_material = payload + b"\0" + str(row.get("revision_salt") or "").encode("utf-8")
if row.get("speech_prompt_policy") == "minimax_h3":
revision_material += b"\0" + H3_AUDIO_OUTPUT_POLICY_VERSION.encode("utf-8")
revision_material += b"\0" + H3_CAPTION_PIPELINE_VERSION.encode("utf-8")
key = str(row["key"])
result[key] = WorkflowSpec(
key=key,
name=str(row.get("name") or key),
mode=mode,
file=str(row["file"]),
raw=dict(row),
revision=hashlib.sha256(revision_material).hexdigest()[:16],
)
return result
def public_workflow(spec: WorkflowSpec) -> dict[str, Any]:
return {
"key": spec.key,
"name": spec.name,
"mode": spec.mode,
"capability": spec.capability,
"revision": spec.revision,
"supportsReferences": bool(spec.raw.get("load_image_node") or spec.raw.get("reference_node") or spec.raw.get("image_refs_node")),
}
def public_model(spec: WorkflowSpec) -> dict[str, Any]:
supports_references = bool(spec.raw.get("load_image_node") or spec.raw.get("reference_node") or spec.raw.get("image_refs_node"))
if spec.mode == "t2i":
operations = ["text_to_image"]
min_images, max_images = 0, 0
elif spec.mode == "i2i":
operations = ["image_to_image"]
min_images, max_images = 1, 1
elif spec.mode == "t2i_i2i":
operations = ["text_to_image", "image_to_image"]
min_images, max_images = 0, int(spec.raw.get("max_reference_images") or 6)
elif spec.mode == "text":
operations = ["text_to_video"]
if supports_references:
operations.append("image_to_video")
min_images, max_images = 0, 1 if supports_references else 0
else:
operations = ["image_to_video"]
if spec.raw.get("reference_node"):
operations.append("reference_to_video")
min_images, max_images = 1, 9
else:
min_images, max_images = 1, 1
aspect_ratios = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16", "21:9"]
endpoint_types = ["comfyui-workflow", *operations]
item: dict[str, Any] = {
"id": spec.key,
"object": "model",
"owned_by": "comfyui-workflow",
"display_name": spec.name,
"model_type": spec.capability,
"supported_endpoint_types": endpoint_types,
"supports_images": supports_references,
"min_images": min_images,
"max_images": max_images,
"default_parameters": {"aspect_ratio": "1:1" if spec.capability == "image" else "16:9"},
"options": {
"aspect_ratio": [{"value": value, "label": value} for value in aspect_ratios],
},
}
if spec.capability == "video":
item["default_parameters"].update({"duration_seconds": "5", "resolution": "720p"})
item["options"].update({
"duration_seconds": [{"value": str(value), "label": f"{value} 秒"} for value in range(1, MAX_VIDEO_DURATION_SECONDS + 1)],
"resolution": [{"value": value, "label": value.upper()} for value in ("480p", "720p", "1080p")],
})
return item
def _patch_node(workflow: dict[str, Any], node_id: str | None, values: dict[str, Any]) -> None:
if not node_id:
return
if node_id not in workflow:
raise ValueError(f"workflow is missing configured node {node_id}")
workflow[node_id].setdefault("inputs", {}).update(values)
def _fit_aligned_resolution(
width: int,
height: int,
*,
max_megapixels: float | None,
align: int,
) -> tuple[int, int]:
"""Preserve aspect ratio while fitting the first pass to the workflow budget."""
align = max(1, int(align))
target_width = max(align, int(width))
target_height = max(align, int(height))
if max_megapixels is not None and float(max_megapixels) > 0:
max_pixels = float(max_megapixels) * 1024 * 1024
area = target_width * target_height
if area > max_pixels:
scale = math.sqrt(max_pixels / area)
target_width = max(align, round(target_width * scale))
target_height = max(align, round(target_height * scale))
target_width = max(align, round(target_width / align) * align)
target_height = max(align, round(target_height / align) * align)
if max_megapixels is not None and float(max_megapixels) > 0:
max_pixels = float(max_megapixels) * 1024 * 1024
while target_width * target_height > max_pixels:
if target_width >= target_height and target_width > align:
target_width -= align
elif target_height > align:
target_height -= align
else:
break
return target_width, target_height
def _negative_prompt(spec: WorkflowSpec, current: str, requested: str) -> str:
parts: list[str] = []
seen: set[str] = set()
for value in (current, requested):
for raw in value.split(","):
term = raw.strip(" .")
normalized = " ".join(term.casefold().split())
if not term or normalized in seen:
continue
if spec.raw.get("preserve_visible_text") and normalized in VISIBLE_TEXT_CONFLICTS:
continue
seen.add(normalized)
parts.append(term)
return ", ".join(parts)
def _spoken_language(value: str) -> str:
if re.search(r"[\u3040-\u30ff]", value):
return "Japanese"
if re.search(r"[\uac00-\ud7af]", value):
return "Korean"
if re.search(r"[\u3400-\u9fff]", value):
return "Chinese"
return "English"
def _h3_audio_prompt(prompt: str, duration: float) -> str:
return normalized_h3_audio_prompt(prompt, duration)
def _h3_without_native_caption_instructions(prompt: str) -> str:
instruction = re.compile(
r"(?:请|需要|要求|同时|并且|并|自动|不要|不需要|无需|禁止|关闭|不显示|不加|不添加|无|添加|显示|生成|加上|加入|配上|带|烧录)+"
r"(?:任何|中文|英文|双语|同步|口播|对白|底部|居中|的)*字幕"
r"(?:[::][^,,。;;\n]*)?"
r"|\b(?:add|show|generate|include|burn(?: in)?|with|no|without|disable)\s+"
r"(?:(?:chinese|english|bilingual|synchronized|any)\s+)?(?:subtitles|captions)\b", re.IGNORECASE)
protected = re.compile(H3_DIALOGUE_TAG.pattern + r'|“[^”]*”|"[^"\n]*"|「[^」]*」|『[^』]*』', re.IGNORECASE | re.DOTALL)
parts, cursor = [], 0
for match in protected.finditer(prompt):
parts.extend([instruction.sub("", prompt[cursor:match.start()]), match.group(0)])
cursor = match.end()
parts.append(instruction.sub("", prompt[cursor:]))
return "".join(parts)
def _apply_h3_generation_stage(graph: dict[str, Any]) -> None:
stage_id = "h3_generation_stage"
if stage_id in graph:
raise ValueError("H3_SUBTITLE_GRAPH_UNSUPPORTED: 生成阶段节点标识冲突")
loaders = [
("unet", "127", "UNETLoader", {"unet_name", "weight_dtype"}, set()),
("clip", "128", "CLIPLoader", {"clip_name", "type"}, {"device"}),
("video_vae", "119", "VAELoader", {"vae_name"}, set()),
("audio_vae", "120", "VAELoader", {"vae_name"}, set()),
]
models = {}
ports = {}
for index, (name, node_id, kind, required, optional) in enumerate(loaders):
node = graph.get(node_id, {})
inputs = node.get("inputs")
if (node.get("class_type") != kind or not isinstance(inputs, dict)
or not required <= set(inputs) or not set(inputs) <= required | optional
or any(not isinstance(v, str) or not v for v in inputs.values())):
raise ValueError("H3_SUBTITLE_GRAPH_UNSUPPORTED: 生成模型入口与已验证的阶段协议不一致")
models[name] = dict(inputs)
ports[node_id] = index
def rewire(value: Any) -> Any:
if isinstance(value, list):
if len(value) == 2 and isinstance(value[0], str) and value[0] in ports:
if type(value[1]) is not int or value[1] != 0:
raise ValueError("H3_SUBTITLE_GRAPH_UNSUPPORTED: 生成模型输出端口无效")
return [stage_id, ports[value[0]]]
return [rewire(item) for item in value]
if isinstance(value, dict):
return {key: rewire(item) for key, item in value.items()}
return value
# The gate receives filenames, not already-loaded GPU objects. Loader
# expansion occurs only after the private vision service confirms sleep.
for node in graph.values():
if "inputs" in node:
node["inputs"] = rewire(node["inputs"])
for node_id in ports:
del graph[node_id]
graph[stage_id] = {"class_type": "H3GenerationModelStage",
"inputs": {"models_json": json.dumps(models, ensure_ascii=False)}}
def _h3_manju_background_layout(value: str, references: int) -> str | None:
# Keep the GPU-verified layout narrow: literal speech, explicit sound/music
# and multi-reference identity assignments retain the original path.
if not value.endswith(H3_NO_SPEECH_RULE) or H3_DIALOGUE_TAG.search(value) or references > 1:
return None
scene = value[:-len(H3_NO_SPEECH_RULE)].rstrip()
if re.search(r'音乐|配乐|音效|环境音|声音|静音|声响|\b(?:music|BGM|sound|audio|silent|silence)\b', scene, re.IGNORECASE):
return None
reference = (
'Picture 1 is the reference for the people, setting and product. Preserve their appearance, '
'including the existing printing on the product packaging.\n\n'
) if references == 1 else ''
return (reference + 'integrated_multimodal_description:\n' + scene
+ '\n\noverall_soundscape: Environmental ambience only. The people remain silent throughout.'
+ '\n\nnon_diegetic_music: N/A'
+ '\n\nNo added text, no subtitles, no captions, no watermark anywhere in the frame.')
def _apply_h3_frame_policy(spec: WorkflowSpec, graph: dict[str, Any], request: dict[str, Any]) -> None:
metadata = request.get("metadata") or {}
if not isinstance(metadata, dict):
raise ValueError("H3 metadata must be an object")
for key in ("h3_native_subtitles", "h3_suppress_subtitles", "h3_subtitles"):
if key in metadata and not isinstance(metadata[key], bool):
raise ValueError(f"{key} must be a boolean")
if metadata.get("h3_native_subtitles") is True or metadata.get("h3_suppress_subtitles") is False:
raise ValueError("原生字幕已禁用,当前仅保留生成阶段字幕抑制,字幕烧录已暂停")
encoder = graph.get("128", {})
if encoder.get("class_type") != "CLIPLoader" or encoder.get("inputs", {}).get("type") != "minimax":
raise ValueError("H3_SUBTITLE_GRAPH_UNSUPPORTED: 当前工作流缺少已验证的 H3 文本编码器入口")
# The identical-seed P2 comparison passed literal speech validation only
# with the reference-quality encoder. Keep sampler, LoRA and audio untouched.
encoder["inputs"]["clip_name"] = H3_VERIFIED_TEXT_ENCODER
save = graph[str(spec.raw.get("save_node"))]
if save.get("class_type") != "VHS_VideoCombine" or "images" not in save.get("inputs", {}):
raise ValueError("H3_SUBTITLE_GRAPH_UNSUPPORTED: 当前工作流缺少可校验的视频帧输出")
if any(n.get("class_type") in {"BurnChineseSubtitles", "SoftChineseSubtitles", "H3VerifiedSubtitleFrames"} for n in graph.values()):
raise ValueError("H3_SUBTITLE_LEGACY_NODE: 字幕烧录已暂停,工作流仍包含旧字幕节点")
if any(n.get("class_type") in {"H3CaptionPipeline", "H3CaptionFinalize", "H3STTNRepair"}
for n in graph.values()):
raise ValueError("H3_SUBTITLE_GRAPH_UNSUPPORTED: 字幕后处理已暂停,工作流仍包含后处理节点")
# Operator policy: even a stored h3_subtitles=True does not re-enable the
# expensive cleanup/burn-in chain. Leave the raw decoded frames and the
# existing opt-in audio connection intact; this is suppression, not erasure.
visited_prompt_fields = set()
for node_key, input_key in (("prompt_node", "prompt_input"), ("motion_node", "motion_input")):
node = spec.raw.get(node_key)
if node:
key = str(spec.raw.get(input_key) or "text")
field = (str(node), key)
if field in visited_prompt_fields:
continue
visited_prompt_fields.add(field)
value = str(graph[str(node)]["inputs"].get(key) or "")
value = _h3_without_native_caption_instructions(value).rstrip()
structured = None
if spec.raw.get('h3_background_prompt_layout') == 'manju-v1':
reference_count = sum(k.startswith('canvas_reference_') for k in graph)
structured = _h3_manju_background_layout(value, reference_count)
graph[str(node)]["inputs"][key] = structured or (value + "\n\n" + H3_CLEAR_FRAME_RULE)
_apply_h3_generation_stage(graph)
def validate_h3_references(spec: WorkflowSpec, count: int) -> None:
if spec.raw.get("speech_prompt_policy") != "minimax_h3":
return
catalog = public_model(spec)
if catalog["max_images"] > 0 and count > catalog["max_images"]:
raise ValueError(f"H3_AUDIO_CONDITIONING_INVALID: this workflow supports at most {catalog['max_images']} reference images")
if not catalog["min_images"] <= count <= catalog["max_images"]:
raise ValueError("H3_AUDIO_CONDITIONING_INVALID: 参考图片数量与工作流不匹配,请选择对应图生或文生视频工作流;未忽略参考图")
def compile_workflow(spec: WorkflowSpec, request: dict[str, Any], uploaded_images: list[str], job_id: str, *, preserve_requested_audio: bool = False) -> dict[str, Any]:
if spec.capability == "video":
duration = float(request.get("duration") if request.get("duration") is not None else 5)
if not math.isfinite(duration) or duration <= 0 or duration > MAX_VIDEO_DURATION_SECONDS:
raise ValueError("视频时长必须大于 0 且最多 15 秒")
validate_h3_references(spec, len(uploaded_images))
workflow = copy.deepcopy(json.loads(spec.path.read_text(encoding="utf-8")))
prompt = str(request.get("prompt") or "").strip()
if not prompt:
raise ValueError("prompt is required")
native_audio_control = bool(spec.raw.get("native_audio_control"))
generate_audio = request.get("generate_audio")
if native_audio_control and generate_audio is None:
generate_audio = bool(spec.raw.get("native_audio_default", True))
if native_audio_control and generate_audio and spec.raw.get("speech_prompt_policy") == "minimax_h3":
audio_plan = plan_h3_audio(prompt, float(request.get("duration") or 5))
prompt = _h3_audio_prompt(prompt, float(request.get("duration") or 5))
# Explicit effects/music use the native track only behind the new ASR
# output gate. Unrequested audio remains disconnected as in v24.
generate_audio = not audio_plan["silent"] if preserve_requested_audio else audio_plan["audio_mode"] in {"dialogue", "effects"}
negative_prompt = str(request.get("negative_prompt") or "").strip()
seed = request.get("seed")
if seed is None:
seed = random.SystemRandom().randint(0, 2**63 - 1)
_patch_node(workflow, spec.raw.get("prompt_node"), {str(spec.raw.get("prompt_input") or "text"): prompt})
_patch_node(workflow, spec.raw.get("motion_node"), {str(spec.raw.get("motion_input") or "text"): prompt})
_patch_node(workflow, spec.raw.get("seed_node"), {str(spec.raw.get("seed_input") or "noise_seed"): int(seed)})
if negative_prompt and spec.raw.get("negative_node"):
node_id = str(spec.raw["negative_node"])
input_key = str(spec.raw.get("negative_input") or "text")
current = str((workflow.get(node_id, {}).get("inputs") or {}).get(input_key) or "").strip()
_patch_node(workflow, node_id, {input_key: _negative_prompt(spec, current, negative_prompt)})
requested_width = request.get("width")
requested_height = request.get("height")
if spec.raw.get("h3_native_optimized"):
frame = workflow[str(spec.raw["width_node"])]["inputs"]
width = int(requested_width if requested_width is not None else frame[str(spec.raw["width_input"])])
height = int(requested_height if requested_height is not None else frame[str(spec.raw["height_input"])])
width, height = _fit_aligned_resolution(width, height, max_megapixels=None,
align=int(spec.raw.get("dimension_align") or 32))
if width * height > 4096 * 2160:
raise ValueError("H3 字幕处理支持的最大画面面积为 4096×2160,请调整分辨率后提交")
_patch_node(workflow, spec.raw["width_node"], {str(spec.raw["width_input"]): width})
_patch_node(workflow, spec.raw["height_node"], {str(spec.raw["height_input"]): height})
elif requested_width is not None and requested_height is not None and spec.raw.get("base_max_megapixels") is not None:
align = max(1, int(spec.raw.get("dimension_align") or 1))
base_width, base_height = _fit_aligned_resolution(
int(requested_width),
int(requested_height),
max_megapixels=float(spec.raw["base_max_megapixels"]),
align=align,
)
_patch_node(workflow, spec.raw.get("width_node"), {str(spec.raw.get("width_input") or "value"): base_width})
_patch_node(workflow, spec.raw.get("height_node"), {str(spec.raw.get("height_input") or "value"): base_height})
if spec.raw.get("upscale_node"):
final_width, final_height = _fit_aligned_resolution(
int(requested_width),
int(requested_height),
max_megapixels=None,
align=align,
)
upscale_values: dict[str, Any] = {
str(spec.raw.get("upscale_mode_input") or "mode"): str(spec.raw.get("upscale_mode") or "target dimensions"),
str(spec.raw.get("upscale_width_input") or "mode.width"): final_width,
str(spec.raw.get("upscale_height_input") or "mode.height"): final_height,
}
chunking_input = spec.raw.get("upscale_chunking_input")
threshold = spec.raw.get("upscale_chunking_threshold_sec")
if chunking_input and threshold is not None:
upscale_values[str(chunking_input)] = float(request.get("duration") or 0) > float(threshold)
_patch_node(workflow, str(spec.raw["upscale_node"]), upscale_values)
else:
if requested_width is not None:
_patch_node(workflow, spec.raw.get("width_node"), {str(spec.raw.get("width_input") or "value"): int(requested_width)})
if requested_height is not None:
_patch_node(workflow, spec.raw.get("height_node"), {str(spec.raw.get("height_input") or "value"): int(requested_height)})
if request.get("duration") is not None:
_patch_node(workflow, spec.raw.get("duration_node"), {"value": float(request["duration"])})
count = max(1, min(int(request.get("batch_size") or 1), 4))
if spec.raw.get("batch_node"):
_patch_node(workflow, str(spec.raw["batch_node"]), {str(spec.raw.get("batch_input") or "batch_size"): count})
elif count > 1 and spec.raw.get("batch_source_node") and spec.raw.get("batch_target_node"):
source_node = str(spec.raw["batch_source_node"])
target_node = str(spec.raw["batch_target_node"])
if source_node not in workflow or target_node not in workflow:
raise ValueError("workflow batch source or target node is missing")
batch_node = "canvas_repeat_latent_batch"
while batch_node in workflow:
batch_node = "_" + batch_node
workflow[batch_node] = {"class_type": "RepeatLatentBatch", "inputs": {"samples": [source_node, 0], "amount": count}, "_meta": {"title": "Canvas Batch Output"}}
_patch_node(workflow, target_node, {str(spec.raw.get("batch_target_input") or "latent_image"): [batch_node, 0]})
if spec.raw.get("enhance_node") is not None and spec.raw.get("enhance_default") is not None:
_patch_node(workflow, str(spec.raw["enhance_node"]), {str(spec.raw.get("enhance_input") or "value"): bool(spec.raw["enhance_default"])})
if spec.raw.get("denoise_node") and (request.get("denoise") is not None or spec.raw.get("denoise_default") is not None):
denoise = request.get("denoise") if request.get("denoise") is not None else spec.raw.get("denoise_default")
_patch_node(workflow, str(spec.raw["denoise_node"]), {str(spec.raw.get("denoise_input") or "denoise"): float(denoise)})
if spec.raw.get("max_dimension_node") and (request.get("width") is not None or request.get("height") is not None):
maximum = max(int(request.get("width") or 0), int(request.get("height") or 0), 64)
_patch_node(workflow, str(spec.raw["max_dimension_node"]), {str(spec.raw.get("max_dimension_input") or "largest_size"): maximum})
if spec.raw.get("image_refs_node"):
max_reference_images = int(spec.raw.get("max_reference_images") or 6)
if len(uploaded_images) > max_reference_images:
raise ValueError(f"this workflow supports at most {max_reference_images} reference images")
if uploaded_images:
encode_node = str(spec.raw["image_refs_node"])
if encode_node not in workflow:
raise ValueError("workflow image reference node is missing")
if requested_width is not None and requested_height is not None:
aligned_width, aligned_height = _fit_aligned_resolution(
int(requested_width), int(requested_height),
max_megapixels=float(spec.raw.get("base_max_megapixels") or 1), align=32,
)
reference_resolution = max(32, round(math.sqrt(aligned_width * aligned_height) / 32) * 32)
_patch_node(workflow, encode_node, {"resolution": reference_resolution})
prefix = str(spec.raw.get("image_refs_input_prefix") or "images.image_")
for index, image_name in enumerate(uploaded_images, start=1):
node_id = f"canvas_reference_{index}"
while node_id in workflow:
node_id = "_" + node_id
workflow[node_id] = {"class_type": "LoadImage", "inputs": {"image": image_name}, "_meta": {"title": f"Canvas reference {index}"}}
_patch_node(workflow, encode_node, {f"{prefix}{index}": [node_id, 0]})
vae_node = str(spec.raw["image_refs_vae_node"])
_patch_node(workflow, encode_node, {"vae": [vae_node, 0]})
sampler_node = str(spec.raw["image_refs_sampler_node"])
latent_source = [encode_node, 2]
if count > 1:
batch_node = "canvas_repeat_reference_latent"
workflow[batch_node] = {"class_type": "RepeatLatentBatch", "inputs": {"samples": latent_source, "amount": count}, "_meta": {"title": "Canvas reference batch"}}
latent_source = [batch_node, 0]
_patch_node(workflow, sampler_node, {"latent_image": latent_source})
elif spec.raw.get("reference_node"):
if not uploaded_images:
raise ValueError("this workflow requires at least one reference image")
max_reference_images = int(spec.raw.get("max_reference_images") or 9)
if len(uploaded_images) > max_reference_images:
raise ValueError(f"this workflow supports at most {max_reference_images} reference images")
reference_inputs: dict[str, Any] = {}
prefix = str(spec.raw.get("reference_input_prefix") or "ref_images.ref_image_")
for index, image_name in enumerate(uploaded_images):
node_id = f"canvas_reference_{index + 1}"
while node_id in workflow:
node_id = "_" + node_id
workflow[node_id] = {"class_type": "LoadImage", "inputs": {"image": image_name}, "_meta": {"title": f"Canvas reference {index + 1}"}}
reference_inputs[f"{prefix}{index}"] = [node_id, 0]
_patch_node(workflow, str(spec.raw["reference_node"]), reference_inputs)
elif spec.raw.get("load_image_node"):
if spec.mode in {"i2i", "image"} and not uploaded_images:
raise ValueError("this workflow requires an input image")
if uploaded_images:
load_node = str(spec.raw["load_image_node"])
if load_node not in workflow and spec.mode == "text":
workflow[load_node] = {"class_type": "LoadImage", "inputs": {"image": uploaded_images[0]}, "_meta": {"title": "Canvas Reference Image"}}
else:
_patch_node(workflow, load_node, {"image": uploaded_images[0]})
if spec.raw.get("image_resize_node"):
resize_node = str(spec.raw["image_resize_node"])
if resize_node in workflow:
_patch_node(workflow, resize_node, {"input": [load_node, 0]})
if spec.raw.get("t2v_switch_node"):
_patch_node(workflow, str(spec.raw["t2v_switch_node"]), {"value": False})
elif spec.raw.get("t2v_switch_node"):
_patch_node(workflow, str(spec.raw["t2v_switch_node"]), {"value": True})
save_node = spec.raw.get("save_node")
if save_node:
prefix = str(spec.raw.get("save_prefix") or spec.key)
_patch_node(workflow, str(save_node), {"filename_prefix": f"{prefix}_{job_id}"})
if native_audio_control and not generate_audio:
workflow[str(save_node)].setdefault("inputs", {}).pop("audio", None)
if spec.raw.get("speech_prompt_policy") == "minimax_h3":
_apply_h3_frame_policy(spec, workflow, request)
return workflow