Record panel 7 against the art, and the A/V gap it hid
Somebody watched chapter.mp4 for the first time. Two failures came out of it that no stage counter could see. chapter.mp4 is video 436.39s over audio 363.67s, so narration finishes 72.7s before the picture. The 49 clips are clean: all 25fps, video and audio agree to 0.03s, summing to 363.6s. A per-round probe puts the loss in the final round of _assemble_batched, which turns 359s of video into 100s while the audio survives. Round 0 is correct. Round 1 differs by holding a 7th input, the leftover clip that skips encoding, so the tree mixes concat output, xfade output and a raw clip. Not fixed. worker_render.py gains an FPS constant, fps normalization in the xfade branch to match concat, _stream_dur, and a self-check that compares video against audio instead of asserting the file is non-empty. That old check is how a 20% sync failure shipped. The fps inconsistency is real but not proven to be the shipped cause. Pinning -r on the output was tried and reverted: it drops frames to force CFR, which the concat branch comment already warned about. Panel 7 checked against the art has zero correct identity bindings out of two, and Seonho, the one character who matters, is unbound. bbox values are consumed as absolute pixels; on a 900x1650 panel that puts all six boxes in the top third, two inside a speech balloon. Identity therefore embeds crops of balloon edges and window frames, which is how confidence 0.9 lands on the wrong person. The colleague has no name in the story and was labelled Choi Haeseon; that row holds 25 of 26 assignments, so it is the label the pipeline stamps on any unnamed woman. Four caveats added. Two earlier claims are withdrawn in place: rescaling bbox by 1000 does not make the boxes correct, and the constraint is not 16 nameless rows needing names. Cast profiles already exist, since all 53 rows populate ref_image_uris and embedding_uri, but they are enrolled from the wrong crops. worker_render.py self-check passes. No pipeline ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+49
-8
@@ -116,8 +116,25 @@ def _audio_dur(path: str) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _stream_dur(path: str, kind: str) -> float:
|
||||
"""duration of one stream. `format=duration` is max(video,audio) and so hides A/V drift."""
|
||||
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", f"{kind}:0",
|
||||
"-show_entries", "stream=duration", "-of", "default=nk=1:nw=1", path],
|
||||
capture_output=True, text=True)
|
||||
try:
|
||||
return float(r.stdout.strip())
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
ZMAX, ZPAN = 1.15, 1.18 # ken-burns zoom ceiling; constant zoom that gives pans room to travel
|
||||
|
||||
# Every clip and every assembly stage MUST agree on this. xfade does not resample: it reinterprets the
|
||||
# second input's frames at the first input's rate, so a 30fps input joined onto a 25fps one plays 1.2x
|
||||
# too slow with the audio untouched -- the video ends minutes long and the narration runs ahead of the
|
||||
# picture. That is exactly what a 25fps clip pipeline plus a `fps=30` concat branch produced.
|
||||
FPS = 25
|
||||
|
||||
|
||||
def _motion(camera: dict, frames: int) -> str:
|
||||
"""#8 content-aware motion: map the vision `camera` block to a zoompan z/x/y expression.
|
||||
@@ -155,7 +172,7 @@ def _motion(camera: dict, frames: int) -> str:
|
||||
x, y = f"{xc}+(iw*0.03)*sin(6.283*on/{T})", f"{yc}+(ih*0.03)*cos(6.283*on/{T})"
|
||||
else: # zoom_in (default ken burns)
|
||||
z, x, y = f"1+{ZMAX-1:.3f}*on/{T}", xc, yc
|
||||
return f"zoompan=z='{z}':x='{x}':y='{y}':d={frames}:s={W}x{H}:fps=25"
|
||||
return f"zoompan=z='{z}':x='{x}':y='{y}':d={frames}:s={W}x{H}:fps={FPS}"
|
||||
|
||||
|
||||
def scene_cmd(img: str, audio: str, ass: str, out: str, dur: float, camera: dict = None,
|
||||
@@ -163,8 +180,7 @@ def scene_cmd(img: str, audio: str, ass: str, out: str, dur: float, camera: dict
|
||||
"""ffmpeg: still panel over a blurred fill of itself + content-aware motion, burned subs, 9:16.
|
||||
#5 blurred bg replaces black bars: one copy scaled to COVER + blurred, the fitted panel on top.
|
||||
#13 pad seconds of trailing silence (last frame held) give the panel a beat before the next."""
|
||||
fps = 25
|
||||
frames = max(1, int((dur + pad) * fps)) # hold the last frame through the pad
|
||||
frames = max(1, int((dur + pad) * FPS)) # hold the last frame through the pad
|
||||
# overlay's W/H/w/h are ffmpeg's main/overlay dims -- kept literal (no f-string braces).
|
||||
fc = (
|
||||
f"[0:v]split=2[bg][fg];"
|
||||
@@ -483,7 +499,7 @@ def beat_cmd(imgs: list, audio: str, ass: str, out: str, D: float, cameras: list
|
||||
burned subtitle (in `ass`) spans the whole beat. 185: per-image screen-time is content-weighted
|
||||
(see _beat_slices), equal split when weights are absent. pure -> testable without S3."""
|
||||
cameras = cameras or []
|
||||
n, fps = len(imgs), 25
|
||||
n, fps = len(imgs), FPS
|
||||
slices = _beat_slices(D, n, weights) # 185: content-weighted, equal-split fallback
|
||||
# one frame per image (no -loop): zoompan d=frames expands that single frame to exactly `frames`
|
||||
# output frames = seg seconds. looping instead would feed many frames and zoompan multiplies each.
|
||||
@@ -675,12 +691,15 @@ def _xfade_chain(durs: list, trans: list):
|
||||
# accumulator would run BACKWARDS (cum += dur - td), swallowing every later clip into a frozen
|
||||
# overlap near the middle. Floor to a small positive length so the timeline stays monotonic.
|
||||
durs = [d if (d and d > 0.1) else 0.1 for d in durs]
|
||||
parts, vlast, alast, cum = [], "[0:v]", "[0:a]", durs[0]
|
||||
# Normalize every input to FPS/SAR before it reaches xfade, exactly as the concat branch does. Both
|
||||
# branches feed the same tree, so an un-normalized xfade input is what stretched the chapter 1.2x.
|
||||
parts = [f"[{i}:v]setsar=1,fps={FPS}[n{i}]" for i in range(len(durs))]
|
||||
vlast, alast, cum = "[n0]", "[0:a]", durs[0]
|
||||
for i in range(1, len(durs)):
|
||||
name, td = XFADE.get(trans[i - 1] if i - 1 < len(trans) else "cut", XFADE["cut"])
|
||||
td = max(0.05, min(td, durs[i - 1] - 0.05, durs[i] - 0.05)) # overlap fits in both clips
|
||||
off = max(cum - td, 0)
|
||||
parts.append(f"{vlast}[{i}:v]xfade=transition={name}:duration={td:.3f}:offset={off:.3f}[v{i}]")
|
||||
parts.append(f"{vlast}[n{i}]xfade=transition={name}:duration={td:.3f}:offset={off:.3f}[v{i}]")
|
||||
parts.append(f"{alast}[{i}:a]acrossfade=d={td:.3f}[a{i}]")
|
||||
vlast, alast, cum = f"[v{i}]", f"[a{i}]", cum + durs[i] - td
|
||||
return ";".join(parts), vlast, alast
|
||||
@@ -713,7 +732,7 @@ def _assemble_once(inputs: list[str], trans: list[str], out: str):
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
for p in inputs:
|
||||
cmd += ["-i", p]
|
||||
pre = "".join(f"[{i}:v]setsar=1,fps=30[v{i}];" for i in range(n))
|
||||
pre = "".join(f"[{i}:v]setsar=1,fps={FPS}[v{i}];" for i in range(n))
|
||||
fg = pre + "".join(f"[v{i}][{i}:a]" for i in range(n)) + f"concat=n={n}:v=1:a=1[v][a]"
|
||||
cmd += ["-filter_complex", fg, "-map", "[v]", "-map", "[a]",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
@@ -945,7 +964,29 @@ if __name__ == "__main__":
|
||||
"-map", vmap, "-map", amap, "-c:v", "libx264", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", out], check=True, capture_output=True)
|
||||
assert os.path.getsize(out) > 0
|
||||
for p in (img, c0, c1):
|
||||
# A/V drift: the tree must not stretch video. The concat branch normalizes fps and the xfade
|
||||
# branch used not to, so a chapter mixing both played 1.2x slow with the audio untouched and the
|
||||
# narration ran ahead of the picture. Batch=2 over 4 clips forces BOTH branches plus a second
|
||||
# round -- the shipped bug's exact shape. Compare the streams, not the file size.
|
||||
c2, c3 = f"{SHM}/c2.mp4", f"{SHM}/c3.mp4"
|
||||
subprocess.run(scene_cmd(img, aud, f"{SHM}/t.ass", c2, 1.0), check=True, capture_output=True)
|
||||
subprocess.run(scene_cmd(img, aud, f"{SHM}/t.ass", c3, 1.0), check=True, capture_output=True)
|
||||
_saved_batch, cl = ASSEMBLE_BATCH, []
|
||||
globals()["ASSEMBLE_BATCH"] = 2
|
||||
try:
|
||||
# ["crossfade","crossfade","cut","cut"] with batch=2 is the one shape that mixes branches:
|
||||
# round 1 sends [c0,c1] through xfade and [c2,c3] through concat, then round 2 xfades those
|
||||
# two intermediates together. A cut-only first group would keep both on the concat branch and
|
||||
# the rates would agree by accident, which is why this case has to be spelled out.
|
||||
_assemble_batched([c0, c1, c2, c3], ["crossfade", "crossfade", "cut", "cut"],
|
||||
out, "selfck", cl)
|
||||
finally:
|
||||
globals()["ASSEMBLE_BATCH"] = _saved_batch
|
||||
for p in cl:
|
||||
if os.path.exists(p): os.remove(p)
|
||||
vd, ad = _stream_dur(out, "v"), _stream_dur(out, "a")
|
||||
assert abs(vd - ad) < 0.25, f"A/V drift: video {vd:.2f}s vs audio {ad:.2f}s"
|
||||
for p in (img, c0, c1, c2, c3):
|
||||
os.remove(p)
|
||||
# #6 composite: 2 panels + 2 audios -> one stacked clip; duration = sum, subs timed per row.
|
||||
a2 = f"{SHM}/a2.wav"
|
||||
|
||||
Reference in New Issue
Block a user