Files
the-silent-gods-hearthfire/06_AI动漫制作/05_生产素材/视频测试/2026-09-29_泉堂夜景/render_test.py
T

230 lines
12 KiB
Python

"""One-shot Wan 2.2 test on the owner's existing ComfyUI. No server installs."""
import argparse
import copy
import json
import secrets
import sys
import time
from pathlib import Path
from urllib.parse import quote
import requests
BASE = 'http://192.168.10.51:8188'
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
SOURCE = ROOT / '03_分镜与镜头/S01E01_V05_制作包/06_参考图/BG-EP01-CHAPEL-NIGHT-V01-REVIEW.png'
TEMPLATE = 'workflows/production_pack_20260904/03_generation_cinematic/09_wan22_i2v.json'
NAME = '2026-09-29_EP01_CHAPEL_NIGHT_WAN22_TEST_V01'
POSITIVE = ('A single uninterrupted wide establishing shot. The camera makes a very slow, '
'gentle dolly forward down the central aisle, keeping the stone statue centered. '
'The small candle flames and the distant hearth fire flicker softly, casting faint '
'warm moving light on the stone. The statue is inanimate stone and stays completely '
'rigid, with an unchanged face and hands. Wooden pews, masonry and hanging cloth remain '
'stable. Preserve the reference image composition, delicate drawn contours, richly '
'hand-painted anime background, muted colors, cool blue window light and warm amber '
'candlelight. Restrained movement, quiet nighttime atmosphere, stable exposure.')
NEGATIVE = ('people, living statue, blinking statue, moving statue, speaking, supernatural glow, '
'magic particles, new objects, flickering geometry, morphing, melting, warped architecture, '
'camera shake, fast zoom, orbit, cuts, oversaturated, overexposed, photorealistic, '
'3d render, text, subtitles, watermark, blurry, low quality')
def get(path):
r = requests.get(BASE + path, timeout=30)
r.raise_for_status()
return r.json()
def save(name, obj):
(HERE / name).write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding='utf-8')
def build():
assert SOURCE.is_file(), SOURCE
if (HERE / 'job.json').exists():
raise RuntimeError('Job already exists. Use --collect; do not accidentally submit twice.')
q = get('/queue')
if q['queue_running'] or q['queue_pending']:
raise RuntimeError('Server is busy; no job submitted.')
stats = get('/system_stats')
save('server_stats.json', stats)
if VARIANT == '5b':
r = requests.get('https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_ti2v.json', timeout=30)
r.raise_for_status()
source_template = r.json()
else:
source_template = get('/userdata/' + quote(TEMPLATE, safe=''))
save('source_template.json', source_template)
schema = get('/object_info')
seed = secrets.randbelow(2**48)
with SOURCE.open('rb') as f:
r = requests.post(BASE + '/upload/image', files={'image': (NAME + '.png', f, 'image/png')},
data={'type': 'input', 'subfolder': 'hearthfire_tests', 'overwrite': 'false'}, timeout=60)
r.raise_for_status()
uploaded = r.json()
input_name = '/'.join(filter(None, [uploaded.get('subfolder'), uploaded['name']]))
graph = {}
def node(i, cls, **inputs):
graph[str(i)] = {'class_type': cls, 'inputs': inputs}
node(1, 'LoadImage', image=input_name)
node(2, 'UNETLoader', unet_name='wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors', weight_dtype='default')
node(3, 'UNETLoader', unet_name='wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors', weight_dtype='default')
node(4, 'LoraLoaderModelOnly', model=['2', 0], lora_name='wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors', strength_model=1.0)
node(5, 'LoraLoaderModelOnly', model=['3', 0], lora_name='wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors', strength_model=1.0)
node(6, 'ModelSamplingSD3', model=['4', 0], shift=5.0)
node(7, 'ModelSamplingSD3', model=['5', 0], shift=5.0)
node(8, 'CLIPLoader', clip_name='umt5_xxl_fp8_e4m3fn_scaled.safetensors', type='wan', device='default')
node(9, 'VAELoader', vae_name='wan_2.1_vae.safetensors')
node(10, 'CLIPTextEncode', clip=['8', 0], text=POSITIVE)
node(11, 'CLIPTextEncode', clip=['8', 0], text=NEGATIVE)
node(12, 'WanImageToVideo', positive=['10', 0], negative=['11', 0], vae=['9', 0],
start_image=['1', 0], width=768, height=432, length=81, batch_size=1)
common = dict(positive=['12', 0], negative=['12', 1], steps=4, cfg=1.0, sampler_name='euler', scheduler='simple')
node(13, 'KSamplerAdvanced', model=['6', 0], latent_image=['12', 2], add_noise='enable', noise_seed=seed,
start_at_step=0, end_at_step=2, return_with_leftover_noise='enable', **common)
node(14, 'KSamplerAdvanced', model=['7', 0], latent_image=['13', 0], add_noise='disable', noise_seed=0,
start_at_step=2, end_at_step=4, return_with_leftover_noise='disable', **common)
node(15, 'VAEDecode', samples=['14', 0], vae=['9', 0])
node(16, 'VHS_VideoCombine', images=['15', 0], frame_rate=16.0, loop_count=0,
filename_prefix='hearthfire_tests/' + NAME, format='video/h264-mp4', pingpong=False,
save_output=True, pix_fmt='yuv420p', crf=18, save_metadata=True, trim_to_audio=False)
if VARIANT == '5b':
graph = {k: v for k, v in graph.items() if k in ('1', '8', '10', '11', '15', '16')}
node(2, 'UNETLoader', unet_name='wan2.2_ti2v_5B_fp16.safetensors', weight_dtype='default')
node(6, 'ModelSamplingSD3', model=['2', 0], shift=8.0)
node(9, 'VAELoader', vae_name='wan2.2_vae.safetensors')
node(12, 'Wan22ImageToVideoLatent', vae=['9', 0], start_image=['1', 0],
width=768, height=448, length=121, batch_size=1)
node(14, 'KSampler', model=['6', 0], positive=['10', 0], negative=['11', 0],
latent_image=['12', 0], seed=seed, steps=20, cfg=5.0,
sampler_name='uni_pc', scheduler='simple', denoise=1.0)
graph['16']['inputs']['frame_rate'] = 24.0
# Live type, required-input, model-name and connection validation.
for nid, n in graph.items():
s = schema[n['class_type']]
assert not s.get('api_node'), 'Paid/API nodes are out of scope'
specs = {**s['input'].get('required', {}), **s['input'].get('optional', {})}
assert set(s['input'].get('required', {})) <= n['inputs'].keys()
for key, value in n['inputs'].items():
if key not in specs:
assert n['class_type'] == 'VHS_VideoCombine'
continue
kind = specs[key][0]
if isinstance(value, list):
origin, slot = value
assert schema[graph[origin]['class_type']]['output'][slot] == kind, (nid, key)
elif isinstance(kind, list) and key != 'image':
assert value in kind, (nid, key, value)
# Emit a flat GUI graph from the exact API graph, retaining widget order.
ui = {'last_node_id': 16, 'last_link_id': 0, 'nodes': [], 'links': [], 'groups': [], 'config': {},
'extra': {'ds': {'scale': 0.4, 'offset': [0, 0]}}, 'version': 0.4}
for nid, n in graph.items():
s = schema[n['class_type']]
slots, widgets = [], []
specs = {**s['input'].get('required', {}), **s['input'].get('optional', {})}
for key, spec in specs.items():
val = n['inputs'].get(key)
kind = spec[0]
if isinstance(val, list) or (isinstance(kind, str) and kind not in ('INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO')):
slots.append({'name': key, 'type': kind, 'link': None})
elif key in n['inputs']:
widgets.append(val)
if len(spec) > 1 and spec[1].get('control_after_generate'):
widgets.append('fixed')
if n['class_type'] == 'LoadImage':
widgets.append('image')
if n['class_type'] == 'VHS_VideoCombine':
widgets = {k: v for k, v in n['inputs'].items() if not isinstance(v, list)}
ui['nodes'].append({'id': int(nid), 'type': n['class_type'], 'pos': [0, 0], 'size': [340, 260],
'flags': {}, 'order': int(nid)-1, 'mode': 0, 'inputs': slots,
'outputs': [{'name': name, 'type': typ, 'links': []} for name, typ in zip(s['output_name'], s['output'])],
'properties': {'Node name for S&R': n['class_type']}, 'widgets_values': widgets})
indexed = {str(n['id']): n for n in ui['nodes']}
for nid, n in graph.items():
for key, val in n['inputs'].items():
if not isinstance(val, list):
continue
origin, oslot = val
slot = next(i for i, item in enumerate(indexed[nid]['inputs']) if item['name'] == key)
ui['last_link_id'] += 1
link = ui['last_link_id']
kind = indexed[origin]['outputs'][oslot]['type']
ui['links'].append([link, int(origin), oslot, int(nid), slot, kind])
indexed[nid]['inputs'][slot]['link'] = link
indexed[origin]['outputs'][oslot]['links'].append(link)
sys.path.insert(0, 'C:/Users/zhang/.codex/skills/comfyui')
from workflow_layout import auto_layout, inspect, fit_group
ui = auto_layout(ui)
fit_group(ui, 'EP01 chapel - Wan 2.2 - 5s visual test')
layout = inspect(ui)
save('layout_check.json', layout)
print('Layout:', layout['summary'], flush=True)
save('workflow_api.json', graph)
save('workflow_gui.json', ui)
target = 'workflows/hearthfire_tests/' + NAME + '.json'
r = requests.post(BASE + '/userdata/' + quote(target, safe='') + '?overwrite=false', json=ui, timeout=30)
r.raise_for_status()
assert get('/userdata/' + quote(target, safe='')) == ui
r = requests.post(BASE + '/prompt', json={'prompt': graph, 'client_id': NAME,
'extra_data': {'extra_pnginfo': {'workflow': ui}}}, timeout=30)
save('submission.json', r.json())
r.raise_for_status()
job = {**r.json(), 'seed': seed, 'workflow': target, 'source': str(SOURCE), 'input': uploaded,
'width': 768, 'height': 448 if VARIANT == '5b' else 432,
'frames': 121 if VARIANT == '5b' else 81, 'fps': 24 if VARIANT == '5b' else 16,
'variant': VARIANT, 'submitted_at': time.time()}
save('job.json', job)
print(json.dumps(job, ensure_ascii=False), flush=True)
def collect():
job = json.loads((HERE / 'job.json').read_text(encoding='utf-8'))
pid = job['prompt_id']
for attempt in range(160):
history = get('/history/' + pid).get(pid)
if history:
save('history.json', history)
if history['status']['status_str'] != 'success':
errors = [data for kind, data in history['status']['messages'] if kind == 'execution_error']
print(json.dumps([{k: e.get(k) for k in ('node_id', 'node_type', 'exception_type', 'exception_message')} for e in errors], ensure_ascii=False), flush=True)
raise RuntimeError('Generation failed; inspect history.json')
outputs = []
for result in history.get('outputs', {}).values():
for entry in result.get('gifs', []) + result.get('videos', []) + result.get('images', []):
if not entry.get('filename', '').endswith('.mp4'):
continue
r = requests.get(BASE + '/view', params={k: entry[k] for k in ('filename', 'subfolder', 'type')}, timeout=90)
r.raise_for_status()
dest = HERE / entry['filename']
dest.write_bytes(r.content)
outputs.append(str(dest))
assert outputs, 'Successful job has no MP4 output'
save('outputs.json', outputs)
print(json.dumps({'outputs': outputs, 'elapsed_s': round(time.time()-job['submitted_at'])}, ensure_ascii=False), flush=True)
return
if attempt % 2 == 0:
print('Waiting for job', pid, 'elapsed', round(time.time()-job['submitted_at']), 'seconds', flush=True)
time.sleep(15)
raise TimeoutError('Job retained on server; run --collect to resume waiting.')
if __name__ == '__main__':
p = argparse.ArgumentParser()
p.add_argument('--collect', action='store_true')
p.add_argument('--variant', choices=['14b', '5b'], default='14b')
args = p.parse_args()
VARIANT = args.variant
if VARIANT == '5b':
HERE = HERE / 'V02_5B'
HERE.mkdir(exist_ok=True)
NAME = NAME.replace('V01', 'V02_5B')
if not args.collect:
build()
collect()