v1.3: VHS glitch reaches the master (worker port + transition sidecar); v2a_grad_scale note corrected
Browse files- autofinish_worker.py +178 -0
autofinish_worker.py
CHANGED
|
@@ -56,6 +56,118 @@ def api(base, path, data=None, timeout=30):
|
|
| 56 |
return json.loads(r.read())
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def main() -> int:
|
| 60 |
ap = argparse.ArgumentParser()
|
| 61 |
ap.add_argument("--shots-dir", required=True)
|
|
@@ -65,6 +177,12 @@ def main() -> int:
|
|
| 65 |
ap.add_argument("--quality", default="ULTRA")
|
| 66 |
ap.add_argument("--batch-size", type=int, default=4)
|
| 67 |
ap.add_argument("--comfy", default="http://127.0.0.1:8188")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
a = ap.parse_args()
|
| 69 |
|
| 70 |
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
@@ -103,6 +221,25 @@ def main() -> int:
|
|
| 103 |
stale = len(glob.glob(os.path.join(a.shots_dir, "shot_[0-9][0-9][0-9].mp4"))) - len(shots)
|
| 104 |
say(f"current run = {len(shots)} shots (shot_000..shot_{len(shots)-1:03d}); "
|
| 105 |
f"{stale} stale leftover(s) excluded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
if not shots:
|
| 107 |
say(f"FATAL: no current-run shot masters in {a.shots_dir}"); return 1
|
| 108 |
|
|
@@ -203,6 +340,36 @@ def main() -> int:
|
|
| 203 |
allv = os.path.join(snap, "allv.mp4")
|
| 204 |
subprocess.run([ff, "-y", "-v", "error", "-f", "concat", "-safe", "0",
|
| 205 |
"-i", vtxt, "-c", "copy", "-an", allv], check=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
final = os.path.join(a.shots_dir, f"{a.name}_{stamp}_MASTER.mp4")
|
| 207 |
if alist:
|
| 208 |
atxt = os.path.join(snap, "a.txt"); open(atxt, "w").write("\n".join(alist))
|
|
@@ -218,6 +385,17 @@ def main() -> int:
|
|
| 218 |
alla_p = os.path.join(snap, "alla_pad.wav")
|
| 219 |
subprocess.run([ff, "-y", "-v", "error", "-i", alla, "-af", "apad",
|
| 220 |
"-t", vdur, "-c:a", "pcm_f32le", alla_p], check=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
subprocess.run([ff, "-y", "-v", "error", "-i", allv, "-i", alla_p,
|
| 222 |
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", final],
|
| 223 |
check=True)
|
|
|
|
| 56 |
return json.loads(r.read())
|
| 57 |
|
| 58 |
|
| 59 |
+
def _probe_dur(fp, path):
|
| 60 |
+
out = subprocess.run([fp, "-v", "error", "-select_streams", "v:0",
|
| 61 |
+
"-show_entries", "stream=duration",
|
| 62 |
+
"-of", "default=noprint_wrappers=1:nokey=1", path],
|
| 63 |
+
capture_output=True, text=True).stdout.strip()
|
| 64 |
+
return float(out)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _glitch_master_video(ff, fp, allv, snap, boundaries_f, n, amt_base, say):
|
| 68 |
+
"""Port of the in-graph VHS glitch (nodes.py vhs_glitch transition) so the
|
| 69 |
+
MASTER carries it too. The graph applies it to the concatenated tensor
|
| 70 |
+
that SaveVideo writes; this worker rebuilds the master from the per-shot
|
| 71 |
+
files, which are PRE-glitch, so without this pass the master is the one
|
| 72 |
+
file that never glitches (found 2026-07-22). Same recipe: snow mix,
|
| 73 |
+
horizontal tearing bands, dropout scanlines, triangular envelope over n
|
| 74 |
+
frames centered on each boundary. Deterministic per boundary."""
|
| 75 |
+
import numpy as np
|
| 76 |
+
pr = subprocess.run([fp, "-v", "error", "-select_streams", "v:0",
|
| 77 |
+
"-show_entries", "stream=width,height,r_frame_rate",
|
| 78 |
+
"-of", "csv=p=0", allv],
|
| 79 |
+
capture_output=True, text=True).stdout.strip().split(",")
|
| 80 |
+
W, H = int(pr[0]), int(pr[1])
|
| 81 |
+
num, den = pr[2].split("/")
|
| 82 |
+
fps = float(num) / float(den or 1)
|
| 83 |
+
out = os.path.join(snap, "allv_glitch.mp4")
|
| 84 |
+
dec = subprocess.Popen([ff, "-v", "error", "-i", allv, "-f", "rawvideo",
|
| 85 |
+
"-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
|
| 86 |
+
enc = subprocess.Popen([ff, "-y", "-v", "error", "-f", "rawvideo",
|
| 87 |
+
"-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(fps),
|
| 88 |
+
"-i", "-", "-c:v", "libx264", "-crf", "16",
|
| 89 |
+
"-preset", "medium", "-pix_fmt", "yuv420p", "-an", out],
|
| 90 |
+
stdin=subprocess.PIPE)
|
| 91 |
+
plan = {}
|
| 92 |
+
for bi, b in enumerate(boundaries_f):
|
| 93 |
+
start = max(0, b - n // 2)
|
| 94 |
+
end = start + n
|
| 95 |
+
span = max(1, end - start - 1)
|
| 96 |
+
for k, fidx in enumerate(range(start, end)):
|
| 97 |
+
plan[fidx] = (bi, k, span)
|
| 98 |
+
fsz = W * H * 3
|
| 99 |
+
idx = 0
|
| 100 |
+
while True:
|
| 101 |
+
buf = dec.stdout.read(fsz)
|
| 102 |
+
if len(buf) < fsz:
|
| 103 |
+
break
|
| 104 |
+
if idx in plan:
|
| 105 |
+
bi, k, span = plan[idx]
|
| 106 |
+
rng = np.random.RandomState(1009 * (bi + 1) + k)
|
| 107 |
+
env = 1.0 - abs((k - span / 2.0) / (span / 2.0 or 1.0))
|
| 108 |
+
amt = amt_base * (0.35 + 0.65 * max(0.0, env))
|
| 109 |
+
f = np.frombuffer(buf, dtype=np.uint8).reshape(H, W, 3).astype(np.float32) / 255.0
|
| 110 |
+
snow = rng.rand(H, W, 1).astype(np.float32)
|
| 111 |
+
f = f * (1.0 - amt * 0.8) + snow * (amt * 0.8)
|
| 112 |
+
for _ in range(int(1 + amt * 6)):
|
| 113 |
+
y0 = rng.randint(0, max(1, H - 8))
|
| 114 |
+
bh = rng.randint(2, max(3, H // 20))
|
| 115 |
+
dx = rng.randint(-W // 6, W // 6 + 1)
|
| 116 |
+
f[y0:y0 + bh] = np.roll(f[y0:y0 + bh], dx, axis=1)
|
| 117 |
+
for _ in range(int(amt * 4)):
|
| 118 |
+
y = rng.randint(0, H)
|
| 119 |
+
f[y:y + 1] = rng.rand()
|
| 120 |
+
buf = (np.clip(f, 0.0, 1.0) * 255.0).astype(np.uint8).tobytes()
|
| 121 |
+
enc.stdin.write(buf)
|
| 122 |
+
idx += 1
|
| 123 |
+
dec.stdout.close()
|
| 124 |
+
enc.stdin.close()
|
| 125 |
+
dec.wait()
|
| 126 |
+
enc.wait()
|
| 127 |
+
if enc.returncode != 0 or not os.path.isfile(out):
|
| 128 |
+
say("WARNING: glitch video encode failed; master left clean")
|
| 129 |
+
return None
|
| 130 |
+
return out
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _glitch_master_audio(ff, fp, wav_in, snap, boundaries_t, n, fps, amt_base, say):
|
| 134 |
+
"""Tape-static bed at each boundary, ported from the graph: window is the
|
| 135 |
+
WIDER of the glitch burst or 1.2s (JoyEcho room tone fades at shot edges;
|
| 136 |
+
the static must span that dead seam), raised-cosine envelope."""
|
| 137 |
+
import numpy as np
|
| 138 |
+
pr = subprocess.run([fp, "-v", "error", "-select_streams", "a:0",
|
| 139 |
+
"-show_entries", "stream=sample_rate,channels",
|
| 140 |
+
"-of", "csv=p=0", wav_in],
|
| 141 |
+
capture_output=True, text=True).stdout.strip().split(",")
|
| 142 |
+
sr, ch = int(pr[0]), int(pr[1])
|
| 143 |
+
raw = subprocess.run([ff, "-v", "error", "-i", wav_in, "-f", "f32le", "-"],
|
| 144 |
+
capture_output=True).stdout
|
| 145 |
+
x = np.frombuffer(raw, dtype=np.float32).reshape(-1, ch).copy()
|
| 146 |
+
for bi, bt in enumerate(boundaries_t):
|
| 147 |
+
rng = np.random.RandomState(2027 * (bi + 1))
|
| 148 |
+
c = int(round(bt * sr))
|
| 149 |
+
n_s = max(int(round(n / fps * sr)), int(round(1.2 * sr)))
|
| 150 |
+
s0 = max(0, c - n_s // 2)
|
| 151 |
+
s1 = min(len(x), s0 + n_s)
|
| 152 |
+
if s1 <= s0:
|
| 153 |
+
continue
|
| 154 |
+
ln = s1 - s0
|
| 155 |
+
t = np.linspace(0.0, 1.0, ln, dtype=np.float32)
|
| 156 |
+
env = (0.5 - 0.5 * np.cos(t * 2.0 * np.pi)).astype(np.float32)[:, None]
|
| 157 |
+
noise = rng.rand(ln, ch).astype(np.float32) * 2.0 - 1.0
|
| 158 |
+
x[s0:s1] = np.clip(x[s0:s1] * (1.0 - 0.35 * amt_base * env)
|
| 159 |
+
+ noise * (0.10 * amt_base) * env, -1.0, 1.0)
|
| 160 |
+
out = os.path.join(snap, "alla_glitch.wav")
|
| 161 |
+
p = subprocess.Popen([ff, "-y", "-v", "error", "-f", "f32le", "-ar", str(sr),
|
| 162 |
+
"-ac", str(ch), "-i", "-", "-c:a", "pcm_f32le", out],
|
| 163 |
+
stdin=subprocess.PIPE)
|
| 164 |
+
p.communicate(x.tobytes())
|
| 165 |
+
if p.returncode != 0 or not os.path.isfile(out):
|
| 166 |
+
say("WARNING: glitch audio encode failed; audio left clean")
|
| 167 |
+
return None
|
| 168 |
+
return out
|
| 169 |
+
|
| 170 |
+
|
| 171 |
def main() -> int:
|
| 172 |
ap = argparse.ArgumentParser()
|
| 173 |
ap.add_argument("--shots-dir", required=True)
|
|
|
|
| 177 |
ap.add_argument("--quality", default="ULTRA")
|
| 178 |
ap.add_argument("--batch-size", type=int, default=4)
|
| 179 |
ap.add_argument("--comfy", default="http://127.0.0.1:8188")
|
| 180 |
+
# Mirror the Generate node's transition defaults. The AutoFinish node does
|
| 181 |
+
# not see those widgets, so these are worker-side settings; pass
|
| 182 |
+
# --transition cut to keep the master clean.
|
| 183 |
+
ap.add_argument("--transition", default="vhs_glitch")
|
| 184 |
+
ap.add_argument("--glitch-frames", type=int, default=8)
|
| 185 |
+
ap.add_argument("--glitch-intensity", type=float, default=0.7)
|
| 186 |
a = ap.parse_args()
|
| 187 |
|
| 188 |
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
| 221 |
stale = len(glob.glob(os.path.join(a.shots_dir, "shot_[0-9][0-9][0-9].mp4"))) - len(shots)
|
| 222 |
say(f"current run = {len(shots)} shots (shot_000..shot_{len(shots)-1:03d}); "
|
| 223 |
f"{stale} stale leftover(s) excluded")
|
| 224 |
+
|
| 225 |
+
# Transition sidecar written by the Generate node: overrides the CLI
|
| 226 |
+
# defaults so the master always matches the graph's actual widgets.
|
| 227 |
+
# Same-run guard: ignore a sidecar older than this run's shot_000.
|
| 228 |
+
sidecar = os.path.join(a.shots_dir, "_transition.json")
|
| 229 |
+
if os.path.isfile(sidecar) and os.path.getmtime(sidecar) >= t_anchor:
|
| 230 |
+
try:
|
| 231 |
+
with open(sidecar, "r", encoding="utf-8") as fh:
|
| 232 |
+
tj = json.load(fh)
|
| 233 |
+
a.transition = str(tj.get("transition", a.transition))
|
| 234 |
+
a.glitch_frames = int(tj.get("frames", a.glitch_frames))
|
| 235 |
+
a.glitch_intensity = float(tj.get("intensity", a.glitch_intensity))
|
| 236 |
+
say(f"transition sidecar: {a.transition}, {a.glitch_frames} frames, "
|
| 237 |
+
f"intensity {a.glitch_intensity}")
|
| 238 |
+
except Exception as e: # noqa: BLE001
|
| 239 |
+
say(f"WARNING: transition sidecar unreadable ({e}); using defaults")
|
| 240 |
+
else:
|
| 241 |
+
say(f"no same-run transition sidecar; defaults: {a.transition}, "
|
| 242 |
+
f"{a.glitch_frames} frames, intensity {a.glitch_intensity}")
|
| 243 |
if not shots:
|
| 244 |
say(f"FATAL: no current-run shot masters in {a.shots_dir}"); return 1
|
| 245 |
|
|
|
|
| 340 |
allv = os.path.join(snap, "allv.mp4")
|
| 341 |
subprocess.run([ff, "-y", "-v", "error", "-f", "concat", "-safe", "0",
|
| 342 |
"-i", vtxt, "-c", "copy", "-an", allv], check=True)
|
| 343 |
+
|
| 344 |
+
# 3b. VHS glitch at shot boundaries. The graph glitches the stream
|
| 345 |
+
# SaveVideo writes; this master is rebuilt from pre-glitch shot files
|
| 346 |
+
# and needs its own pass or it comes out clean (found 2026-07-22).
|
| 347 |
+
boundaries_t = []
|
| 348 |
+
if a.transition == "vhs_glitch" and len(ups) > 1:
|
| 349 |
+
try:
|
| 350 |
+
durs = [_probe_dur(fp, up) for up in ups]
|
| 351 |
+
acc = 0.0
|
| 352 |
+
for d in durs[:-1]:
|
| 353 |
+
acc += d
|
| 354 |
+
boundaries_t.append(acc)
|
| 355 |
+
prf = subprocess.run([fp, "-v", "error", "-select_streams", "v:0",
|
| 356 |
+
"-show_entries", "stream=r_frame_rate",
|
| 357 |
+
"-of", "csv=p=0", allv],
|
| 358 |
+
capture_output=True, text=True).stdout.strip()
|
| 359 |
+
num, den = prf.split("/")
|
| 360 |
+
fps_v = float(num) / float(den or 1)
|
| 361 |
+
boundaries_f = [int(round(t * fps_v)) for t in boundaries_t]
|
| 362 |
+
gv = _glitch_master_video(ff, fp, allv, snap, boundaries_f,
|
| 363 |
+
max(1, a.glitch_frames),
|
| 364 |
+
max(0.1, min(1.0, a.glitch_intensity)), say)
|
| 365 |
+
if gv:
|
| 366 |
+
allv = gv
|
| 367 |
+
say(f"VHS glitch applied to master video at {len(boundaries_f)} "
|
| 368 |
+
f"boundaries ({a.glitch_frames} frames, intensity {a.glitch_intensity})")
|
| 369 |
+
except Exception as e: # noqa: BLE001
|
| 370 |
+
say(f"WARNING: glitch pass skipped ({type(e).__name__}: {e}); master left clean")
|
| 371 |
+
boundaries_t = []
|
| 372 |
+
|
| 373 |
final = os.path.join(a.shots_dir, f"{a.name}_{stamp}_MASTER.mp4")
|
| 374 |
if alist:
|
| 375 |
atxt = os.path.join(snap, "a.txt"); open(atxt, "w").write("\n".join(alist))
|
|
|
|
| 385 |
alla_p = os.path.join(snap, "alla_pad.wav")
|
| 386 |
subprocess.run([ff, "-y", "-v", "error", "-i", alla, "-af", "apad",
|
| 387 |
"-t", vdur, "-c:a", "pcm_f32le", alla_p], check=True)
|
| 388 |
+
if boundaries_t:
|
| 389 |
+
try:
|
| 390 |
+
ga = _glitch_master_audio(ff, fp, alla_p, snap, boundaries_t,
|
| 391 |
+
max(1, a.glitch_frames), 25.0,
|
| 392 |
+
max(0.1, min(1.0, a.glitch_intensity)), say)
|
| 393 |
+
if ga:
|
| 394 |
+
alla_p = ga
|
| 395 |
+
say(f"tape-static bed applied to master audio at "
|
| 396 |
+
f"{len(boundaries_t)} boundaries")
|
| 397 |
+
except Exception as e: # noqa: BLE001
|
| 398 |
+
say(f"WARNING: audio glitch skipped ({type(e).__name__}: {e})")
|
| 399 |
subprocess.run([ff, "-y", "-v", "error", "-i", allv, "-i", alla_p,
|
| 400 |
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", final],
|
| 401 |
check=True)
|