feat: measure what the shader does to H3's audio

drive.py saves H3's decoded audio as FLAC, takes --prompt to pick material with a
percussive, tonal or broadband sound, and --shade-non-spatial to paint the audio
stream too. analyze_audio.py reads each run against its own prompt and seed's
strength-0 control and separates what painting the audio latent did from what
reached the sound through the picture alone. Prompt and audio painting now reach
run names, so a rerun under either does not skip a run it already holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-09-23 07:13:19 -07:00
co-authored by Claude Opus 5.5
parent 18a909b88b
commit dd8b599fd2
3 changed files with 299 additions and 28 deletions
+196
View File
@@ -0,0 +1,196 @@
"""
Measure what shader noise did to H3's audio stream, from runs recorded by drive.py.
python verification/blend/analyze_audio.py MANIFEST [MANIFEST ...] [--json FILE]
Every run is read against the strength-0 control for its own prompt and seed, because
level and brightness vary far more between prompts than between strengths.
H3 denoises both streams in one packed sequence, so a shader painted only on the
picture reaches the sound through joint attention anyway. Runs of both arms -- the
same strengths with and without shade_non_spatial -- therefore belong in one manifest,
and the summary keeps them apart: only the gap between the two arms is what painting
the audio latent itself did.
rms dB loudness, over both channels, so it does not move with stereo width
centroid spectral centre of mass in Hz: how bright the sound is
flatness geometric over arithmetic mean of the power spectrum, per frame. A pure
tone reads 0; white noise reads about 0.56, not 1, because a single frame's
periodogram is exponentially distributed. A fall means the model was pushed
toward tonal content and away from broadband texture
L/R corr correlation between the two channels. The shader reads the audio latent's
stereo pair as the height of a 2 x T grid, and create_coordinate_grid
samples height 2 at linspace(0, 1, 2) -- the two opposite edges of the
field -- so the two channels are drawn as far apart as the pattern allows.
If that matters, this falls as strength rises.
env Hz the dominant rate of the amplitude envelope, with the peak's height over
the band median in brackets. Unmodulated noise stays under about 5 at these
clip lengths, while a shallow 30% modulation already reads near 50 -- so
under 5 the rate is whatever noise won and means nothing. This is the
measure that separates the shader imposing structure over time from it
only recolouring timbre.
Spectral measures are taken per channel and averaged, so stereo decorrelation cannot
leak into them.
"""
import argparse
import json
import sys
from collections import defaultdict
from pathlib import Path
import numpy as np
import soundfile
sys.path.insert(0, str(Path(__file__).resolve().parent))
import common # noqa: E402
FRAME, HOP = 2048, 512
# The envelope is read in windows far longer than the hop, so the carrier's own ripple
# is smoothed away instead of aliasing down into the band. ENV_WINDOW is 32 ms and the
# hop leaves 125 Hz of envelope, well above twice the top of the band. The band itself
# is the range a listener hears as pulse or swell rather than as pitch or as one fade.
ENV_WINDOW, ENV_HOP = 1024, 256
ENV_BAND = (0.5, 20.0)
def framed(x, size, hop):
count = 1 + max(0, (len(x) - size) // hop)
index = np.arange(size)[None, :] + hop * np.arange(count)[:, None]
return x[index] * np.hanning(size)
def spectral(x, rate):
"""Centroid in Hz and spectral flatness, averaged over frames."""
power = np.abs(np.fft.rfft(framed(x, FRAME, HOP), axis=-1)) ** 2 + 1e-12
freqs = np.fft.rfftfreq(FRAME, 1.0 / rate)
centroid = (power * freqs).sum(axis=-1) / power.sum(axis=-1)
flatness = np.exp(np.log(power).mean(axis=-1)) / power.mean(axis=-1)
return float(centroid.mean()), float(flatness.mean())
def envelope_rate(x, rate):
"""Dominant modulation rate in Hz, and how far its peak stands above the band."""
env = np.sqrt((framed(x, ENV_WINDOW, ENV_HOP) ** 2).mean(axis=-1))
if len(env) < 8:
return 0.0, 0.0
level = env.mean()
if level <= 0:
return 0.0, 0.0
# Against its own level, so the rate does not depend on how loud the run came out.
env = env / level - 1.0
if env.std() < 1e-6: # a steady envelope has no rate, and its peak would be noise
return 0.0, 0.0
magnitude = np.abs(np.fft.rfft(env * np.hanning(len(env))))
freqs = np.fft.rfftfreq(len(env), ENV_HOP / rate)
band = (freqs >= ENV_BAND[0]) & (freqs <= ENV_BAND[1])
if not band.any():
return 0.0, 0.0
magnitude, freqs = magnitude[band], freqs[band]
peak = int(magnitude.argmax())
return float(freqs[peak]), float(magnitude[peak] / (np.median(magnitude) + 1e-12))
def measure(path):
audio, rate = soundfile.read(path, dtype="float32", always_2d=True)
rms = float(np.sqrt((audio ** 2).mean()))
per_channel = [spectral(audio[:, c], rate) for c in range(audio.shape[1])]
rates = [envelope_rate(audio[:, c], rate) for c in range(audio.shape[1])]
if audio.shape[1] == 2:
left, right = audio[:, 0] - audio[:, 0].mean(), audio[:, 1] - audio[:, 1].mean()
lr = float((left * right).sum() / (np.linalg.norm(left) * np.linalg.norm(right) + 1e-12))
else:
lr = 1.0
return {
"rms_db": 20 * np.log10(rms + 1e-12),
"centroid": float(np.mean([c for c, _ in per_channel])),
"flatness": float(np.mean([f for _, f in per_channel])),
"lr_corr": lr,
"env_hz": float(np.mean([h for h, _ in rates])),
"env_peak": float(np.mean([p for _, p in rates])),
}
# A control's env Hz is whatever noise won, so a change in it means nothing; the rate
# is only worth reading on its own, next to the peak that says whether it is real.
DELTAS = ("rms_db", "centroid", "flatness", "lr_corr")
ABSOLUTE = ("env_hz", "env_peak")
def line(label, m):
return (f" {label:22s} {m['rms_db']:7.1f} {m['centroid']:10.0f} {m['flatness']:10.4f} "
f"{m['lr_corr']:10.2f} {m['env_hz']:8.1f} ({m['env_peak']:.1f})")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("manifests", nargs="+")
ap.add_argument("--json", default=None, help="write every measurement here as well")
args = ap.parse_args()
rows = {}
for path in args.manifests:
for row in common.read_manifest(path):
rows.setdefault(row["name"], row)
runs = [r for r in rows.values() if r.get("audio")]
if not runs:
raise SystemExit("no run in these manifests saved audio; only H3 does, and only since "
"drive.py started writing FLAC")
measured = {}
for row in runs:
if not Path(row["audio"]).exists():
raise SystemExit(f"{row['name']}: {row['audio']} is gone")
measured[row["name"]] = measure(row["audio"])
groups = defaultdict(list)
for row in runs:
groups[(row.get("prompt_kind", "forge"), row["seed"])].append(row)
deltas = defaultdict(list)
for (prompt, seed), members in sorted(groups.items()):
members.sort(key=lambda r: (r["strength"], r["name"]))
controls = [r for r in members if r["strength"] == 0]
if not controls:
raise SystemExit(f"{prompt}, seed {seed}: no strength-0 control to read against")
control = measured[controls[0]["name"]]
print(f"\n {prompt}, seed {seed}" + " " * 9 + "rms dB centroid flatness L/R corr env Hz (peak)")
for row in members:
m = measured[row["name"]]
painted = "audio" if row.get("shade_non_spatial") else "picture only"
print(line(f"{row['strength']:<5g} {painted}", m))
if row["strength"] != 0:
summary = {k: m[k] - control[k] for k in DELTAS}
summary.update({k: m[k] for k in ABSOLUTE})
deltas[(prompt, row["strength"], bool(row.get("shade_non_spatial")))].append(summary)
if deltas:
print("\n averaged over seeds: change from each one's own control, and the rate as it stands")
print(" prompt strength painted n d rms dB d centroid d flatness d L/R corr env Hz (peak)")
means = {}
for key, items in sorted(deltas.items()):
prompt, strength, painted = key
means[key] = {k: float(np.mean([d[k] for d in items])) for k in DELTAS + ABSOLUTE}
m = means[key]
print(f" {prompt:10s} {strength:<9g} {'audio' if painted else 'picture':9s} {len(items):<4d} "
f"{m['rms_db']:+8.1f} {m['centroid']:+11.0f} {m['flatness']:+11.4f} "
f"{m['lr_corr']:+11.2f} {m['env_hz']:8.1f} ({m['env_peak']:.1f})")
paired = [k for k in means if k[2] and (k[0], k[1], False) in means]
if paired:
print("\n what painting the audio latent did, over painting the picture alone")
print(" prompt strength d rms dB d centroid d flatness d L/R corr d env Hz")
for key in sorted(paired):
audio, picture = means[key], means[(key[0], key[1], False)]
gap = {k: audio[k] - picture[k] for k in DELTAS + ABSOLUTE}
print(f" {key[0]:10s} {key[1]:<14g} {gap['rms_db']:+8.1f} {gap['centroid']:+11.0f} "
f"{gap['flatness']:+11.4f} {gap['lr_corr']:+11.2f} {gap['env_hz']:+11.1f}")
if args.json:
Path(args.json).write_text(json.dumps(measured, indent=1) + "\n")
print("\nwrote", args.json)
if __name__ == "__main__":
main()
+29 -5
View File
@@ -5,6 +5,10 @@ drift apart.
Every run holds the shader to one configuration and varies six things: the seed,
the strength, the travel mode, phase_shift and noise_scale for the "streets", and
the shader type. A strength-0 run has no shader, so it is shared by every type.
The prompt and whether the audio stream is painted are fixed for a whole matrix
rather than varied within one, but they still change the output, so run_name carries
them too.
"""
import json
import sys
@@ -34,7 +38,8 @@ def load_pack():
import helpers # noqa: F401
def shader_inputs(seed, strength, travel, phase, scale, shader=DEFAULT_SHADER):
def shader_inputs(seed, strength, travel, phase, scale, shader=DEFAULT_SHADER,
shade_non_spatial=False):
"""Direct node inputs shared by every run. Sampler settings are per model."""
return {
"seed": seed, "denoise": 1.0, "sequential_stages": 1, "injection_stages": 0,
@@ -43,7 +48,8 @@ def shader_inputs(seed, strength, travel, phase, scale, shader=DEFAULT_SHADER):
"color_scheme": "none", "noise_scale": scale, "octaves": 2.0, "warp_strength": 0.7,
"shape_mask_strength": 1.0, "phase_shift": phase, "color_intensity": 0.8,
"sampling_mode": "standard", "preset": "custom", "travel_mode": travel,
"normalize_strength": True, "stage_progression": "uniform", "shade_non_spatial": False,
"normalize_strength": True, "stage_progression": "uniform",
"shade_non_spatial": shade_non_spatial,
}
@@ -52,18 +58,36 @@ def shader_of(strength, shader):
return "none" if strength == 0 else shader
def audio_of(strength, shade_non_spatial):
"""Same reason: with no shader to paint, the control is shared with the picture runs."""
return bool(shade_non_spatial) and strength != 0
def run_key(row):
return (row["seed"], row["strength"], row["travel"], row["phase"], row["scale"],
shader_of(row["strength"], row.get("shader", DEFAULT_SHADER)))
def run_name(seed, strength, travel, phase, scale, shader=DEFAULT_SHADER):
def run_name(seed, strength, travel, phase, scale, shader=DEFAULT_SHADER,
shade_non_spatial=False, prompt=None):
"""
What a run is filed under. Anything that changes the output has to reach this
name, or a rerun skips the run it already holds and one setting stands in for
another. A setting every earlier run shared is left out, so those runs keep the
names they were recorded under: the default shader type, the model's own prompt,
and painting the picture alone.
"""
# Two decimals unless that would merge distinct strengths, so 0.001 is not named 0.00.
shown = f"{strength:.2f}" if round(strength, 2) == strength else f"{strength:g}"
name = f"s{seed}_{travel}_{shown}_p{phase:.1f}_n{scale:.1f}"
# The default type keeps the names its runs were recorded under.
shader = shader_of(strength, shader)
return name if shader in ("none", DEFAULT_SHADER) else f"{name}_{shader}"
if shader not in ("none", DEFAULT_SHADER):
name = f"{name}_{shader}"
if prompt:
name = f"{name}_{prompt}"
if audio_of(strength, shade_non_spatial):
name = f"{name}_audio"
return name
def read_manifest(path):
+74 -23
View File
@@ -2,12 +2,17 @@
Run a blend-measurement matrix through a running ComfyUI server.
python verification/blend/drive.py {sd15,h3,krea2} --label NAME [--strengths 0.25,0.5]
[--street-strengths 0.5] [--jump 0.5] [--seeds 8888,1234] [--shader-types a,b] [--dry-run]
[--street-strengths 0.5] [--jump 0.5] [--seeds 8888,1234] [--shader-types a,b]
[--prompt forge] [--shade-non-spatial] [--length 124] [--dry-run]
Strength 0 is always included for every seed: it is what every distance is read
against. Each run appends a line to <out>/<model>_<label>.jsonl with where its latent
and image or video landed, and a rerun skips what the manifest already holds.
H3 runs also save the decoded audio as FLAC, for analyze_audio.py and for listening.
`--shade-non-spatial` paints the audio stream as well as the picture, and `--prompt`
picks material with a different audio character to hear it against.
The server has to have the pack loaded, SD 1.5, MiniMax H3 or Krea 2 installed
under the file names below, and nothing else queued: interleaving models makes it
swap them for every prompt.
@@ -28,15 +33,31 @@ FORGE = ("Close-up of a blacksmith hammering a glowing orange horseshoe on an an
"Loud rhythmic metallic clangs of hammer on steel, crackling fire, faint bellows hiss.")
RIDER = ("A dramatic digital painting of a Persian woman in her 40s, with warm olive-brown skin, "
"dark hair, and an amused smirking expression, seated atop a big red elephant.")
# H3 generates sound with the picture, so its prompts come in three audio characters:
# percussive, tonal and broadband. Each still has to give a scene worth judging.
CELLO = ("A cellist alone in a candlelit stone chapel, bow drawn slowly across the strings, "
"dust drifting through a shaft of light from a high window. "
"One sustained cello note, low and resonant, swelling and fading in the hall.")
RAIN = ("Heavy rain hammering a corrugated tin roof over a deserted night market, water "
"sheeting off the edges into puddles under a single bare bulb. "
"A dense unbroken roar of rainfall on metal, no music and no voices.")
PROMPTS = {"sd15": {"fisherman": PORTRAIT},
"h3": {"forge": FORGE, "cello": CELLO, "rain": RAIN},
"krea2": {"rider": RIDER}}
# The prompt every run of a model used before there was a choice, left out of run names.
DEFAULT_PROMPT = {"sd15": "fisherman", "h3": "forge", "krea2": "rider"}
# er_sde/beta57 for the shader work; euler/normal is what SD 1.5 was measured with.
SAMPLER = {"sd15": ("euler", "normal"), "h3": ("er_sde", "beta57"), "krea2": ("er_sde", "beta57")}
def sd15_graph(prefix, seed, strength, travel, phase, scale, shader, size):
s = dict(common.shader_inputs(seed, strength, travel, phase, scale, shader), steps=20, cfg=7.0,
sampler_name="euler", scheduler="normal",
def sd15_graph(prefix, seed, strength, travel, phase, scale, shader, size, opts):
s = dict(common.shader_inputs(seed, strength, travel, phase, scale, shader, opts["shade_non_spatial"]),
steps=20, cfg=7.0, sampler_name=opts["sampler"], scheduler=opts["scheduler"],
model=["1", 0], positive=["2", 0], negative=["3", 0], latent_image=["4", 0])
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["1", 1], "text": PORTRAIT}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["1", 1], "text": opts["prompt"]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["1", 1], "text": "blurry, low quality, watermark"}},
"4": {"class_type": "EmptyLatentImage", "inputs": {"width": size["width"], "height": size["height"], "batch_size": 1}},
"5": {"class_type": "ShaderNoiseKSamplerDirect", "inputs": s},
@@ -46,9 +67,9 @@ def sd15_graph(prefix, seed, strength, travel, phase, scale, shader, size):
}
def h3_graph(prefix, seed, strength, travel, phase, scale, shader, size):
s = dict(common.shader_inputs(seed, strength, travel, phase, scale, shader), steps=8, cfg=1.0,
sampler_name="res_multistep", scheduler="simple",
def h3_graph(prefix, seed, strength, travel, phase, scale, shader, size, opts):
s = dict(common.shader_inputs(seed, strength, travel, phase, scale, shader, opts["shade_non_spatial"]),
steps=8, cfg=1.0, sampler_name=opts["sampler"], scheduler=opts["scheduler"],
model=["2", 0], positive=["6", 0], negative=["7", 0], latent_image=["6", 1])
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "10Eros_Max_h3_TURBO-hybrid_beta5_int8.safetensors", "weight_dtype": "default"}},
@@ -56,7 +77,7 @@ def h3_graph(prefix, seed, strength, travel, phase, scale, shader, size):
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", "type": "minimax", "device": "default"}},
"4": {"class_type": "VAELoader", "inputs": {"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"}},
"5": {"class_type": "VAELoader", "inputs": {"vae_name": "minimax_h3_audio_vae_fp32.safetensors"}},
"6": {"class_type": "MiniMaxH3ImageToVideo", "inputs": {"clip": ["3", 0], "vae": ["4", 0], "prompt": FORGE,
"6": {"class_type": "MiniMaxH3ImageToVideo", "inputs": {"clip": ["3", 0], "vae": ["4", 0], "prompt": opts["prompt"],
"width": size["width"], "height": size["height"], "length": size["length"]}},
"7": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}},
"8": {"class_type": "ShaderNoiseKSamplerDirect", "inputs": s},
@@ -67,19 +88,21 @@ def h3_graph(prefix, seed, strength, travel, phase, scale, shader, size):
"12": {"class_type": "VAEDecodeAudio", "inputs": {"samples": ["8", 0], "vae": ["5", 0]}},
"13": {"class_type": "CreateVideo", "inputs": {"images": ["11", 0], "fps": 24.0, "audio": ["12", 0]}},
"14": {"class_type": "SaveVideo", "inputs": {"video": ["13", 0], "filename_prefix": prefix, "format": "auto", "format.codec": "auto"}},
# The muxed mp4 is for watching; the FLAC is what analyze_audio.py measures.
"15": {"class_type": "SaveAudioAdvanced", "inputs": {"audio": ["12", 0], "filename_prefix": prefix, "format": "flac"}},
}
def krea2_graph(prefix, seed, strength, travel, phase, scale, shader, size):
def krea2_graph(prefix, seed, strength, travel, phase, scale, shader, size, opts):
# The user's own Krea 2 workflow, with the Direct node in the KSampler's place.
s = dict(common.shader_inputs(seed, strength, travel, phase, scale, shader), steps=8, cfg=1.0,
sampler_name="er_sde", scheduler="beta57",
s = dict(common.shader_inputs(seed, strength, travel, phase, scale, shader, opts["shade_non_spatial"]),
steps=8, cfg=1.0, sampler_name=opts["sampler"], scheduler=opts["scheduler"],
model=["1", 0], positive=["5", 0], negative=["6", 0], latent_image=["7", 0])
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "lustifyNSFWCheckpoint_v10Krea2_2996235.safetensors", "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "krea2UncensoredLLMCLIP_v10_int8.safetensors", "type": "krea2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen_image_vae.safetensors"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": RIDER}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": opts["prompt"]}},
"5": {"class_type": "ConditioningKrea2Rebalance", "inputs": {"conditioning": ["4", 0], "multiplier": 3.0,
"per_layer_weights": "1.0,1.0,1.0,1.0,1.0,1.0,1.0,2.5,5.0,1.1,4.0,1.0"}},
"6": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
@@ -91,7 +114,7 @@ def krea2_graph(prefix, seed, strength, travel, phase, scale, shader, size):
}
GRAPHS = {"sd15": (sd15_graph, PORTRAIT), "h3": (h3_graph, FORGE), "krea2": (krea2_graph, RIDER)}
GRAPHS = {"sd15": sd15_graph, "h3": h3_graph, "krea2": krea2_graph}
def floats(text):
@@ -124,7 +147,7 @@ def submit(server, graph):
def wait(server, prompt_id, output_dir):
"""Block until the prompt finishes; return its saved files by kind."""
kinds = {".latent": "latent", ".png": "image", ".mp4": "video"}
kinds = {".latent": "latent", ".png": "image", ".mp4": "video", ".flac": "audio"}
while True:
time.sleep(1.5)
history = json.load(urllib.request.urlopen(f"{server}/history/{prompt_id}", timeout=60))
@@ -154,38 +177,66 @@ def main():
ap.add_argument("--jump", default="", help="strengths to also run with travel_mode jump")
ap.add_argument("--seeds", default="", help="default: " + ", ".join(f"{m} {common.SEEDS[m]}" for m in common.SEEDS))
ap.add_argument("--shader-types", default=common.DEFAULT_SHADER, help="comma-separated shader types to run")
ap.add_argument("--prompt", default=None, help="prompt key; default: "
+ ", ".join(f"{m} {DEFAULT_PROMPT[m]} of {sorted(PROMPTS[m])}" for m in sorted(PROMPTS)))
ap.add_argument("--shade-non-spatial", action="store_true",
help="paint the streams with no picture in them too -- on H3 that is the audio")
ap.add_argument("--length", type=int, default=None, help="frame count, overriding the model's default")
ap.add_argument("--sampler", default=None, help="default: " + ", ".join(f"{m} {SAMPLER[m][0]}" for m in sorted(SAMPLER)))
ap.add_argument("--scheduler", default=None, help="default: " + ", ".join(f"{m} {SAMPLER[m][1]}" for m in sorted(SAMPLER)))
ap.add_argument("--server", default="http://127.0.0.1:8188")
ap.add_argument("--comfy-output", default=os.path.expanduser("~/ComfyUI/output"))
ap.add_argument("--out", default=None, help="manifest directory (default: <comfy-output>/snk_measure)")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
prompt_kind = args.prompt or DEFAULT_PROMPT[args.model]
if prompt_kind not in PROMPTS[args.model]:
raise SystemExit(f"{args.model} has no prompt {prompt_kind!r}; "
f"try {', '.join(sorted(PROMPTS[args.model]))}")
sampler, scheduler = SAMPLER[args.model]
opts = {"prompt": PROMPTS[args.model][prompt_kind], "shade_non_spatial": args.shade_non_spatial,
"sampler": args.sampler or sampler, "scheduler": args.scheduler or scheduler}
seeds = [int(s) for s in args.seeds.split(",") if s.strip()] or list(common.SEEDS[args.model])
shaders = [s.strip() for s in args.shader_types.split(",") if s.strip()]
runs = plan(args.model, floats(args.strengths), floats(args.street_strengths), floats(args.jump), seeds, shaders)
# Fixed for the whole matrix, but still part of what a run is: a rerun under a
# different prompt or with the audio painted must not skip the picture run it
# already holds. The model's own prompt and picture-only runs keep their old names.
def named(run):
return common.run_name(*run, shade_non_spatial=args.shade_non_spatial,
prompt=None if prompt_kind == DEFAULT_PROMPT[args.model] else prompt_kind)
out = Path(args.out or os.path.join(args.comfy_output, "snk_measure"))
manifest = out / f"{args.model}_{args.label}.jsonl"
done = {r["name"] for r in common.read_manifest(manifest)} if manifest.exists() else set()
todo = [r for r in runs if common.run_name(*r) not in done]
todo = [r for r in runs if named(r) not in done]
print(f"{args.model}: {len(runs)} runs planned, {len(todo)} to do -> {manifest}")
print(f" {prompt_kind}, {opts['sampler']}/{opts['scheduler']}, "
f"audio {'painted' if args.shade_non_spatial else 'left alone'}")
if args.dry_run:
for r in todo:
print(" ", common.run_name(*r))
print(" ", named(r))
return
out.mkdir(parents=True, exist_ok=True)
graph, prompt = GRAPHS[args.model]
size = common.SIZE[args.model]
graph = GRAPHS[args.model]
size = dict(common.SIZE[args.model])
if args.length:
size["length"] = args.length
for run in todo:
name = common.run_name(*run)
name = named(run)
started = time.time()
prefix = f"snk_measure/{args.model}/{args.label}/{name}"
files = wait(args.server, submit(args.server, graph(prefix, *run, size)), args.comfy_output)
files = wait(args.server, submit(args.server, graph(prefix, *run, size, opts)), args.comfy_output)
seed, strength, travel, phase, scale, shader = run
row = dict(name=name, model=args.model, seed=seed, strength=strength, travel=travel, phase=phase,
scale=scale, shader=common.shader_of(strength, shader), prompt=prompt, **size,
scale=scale, shader=common.shader_of(strength, shader), prompt=opts["prompt"],
prompt_kind=prompt_kind, shade_non_spatial=common.audio_of(strength, args.shade_non_spatial),
sampler=opts["sampler"], scheduler=opts["scheduler"], **size,
latent=files.get("latent"), image=files.get("image"), video=files.get("video"),
seconds=round(time.time() - started, 1))
audio=files.get("audio"), seconds=round(time.time() - started, 1))
with open(manifest, "a") as fh:
fh.write(json.dumps(row) + "\n")
print(name, row["seconds"], flush=True)