Add reproducible EasyCom causal head pipeline

This commit is contained in:
Xin Wang
2026-08-16 22:19:43 +08:00
parent bde72487bb
commit 080c45f049
8 changed files with 1451 additions and 470 deletions

View File

@@ -42,10 +42,22 @@ class ClipInfo:
def _run_json(command: list[str]) -> dict:
completed = subprocess.run(
command, check=True, capture_output=True, text=True
)
return json.loads(completed.stdout)
try:
completed = subprocess.run(
command, check=True, capture_output=True, text=True
)
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
detail = f"\n{stderr}" if stderr else ""
raise RuntimeError(
f"Command failed ({exc.returncode}): {shlex.join(command)}{detail}"
) from exc
try:
return json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Command returned invalid JSON: {shlex.join(command)}"
) from exc
def probe_clip(path: Path) -> ClipInfo:
@@ -108,16 +120,22 @@ def validate_inventory(clips: list[ClipInfo], target_steps: int | None) -> int:
f"{clip.width}x{clip.height}: {clip.path}"
)
total_frames = sum(clip.frames for clip in clips)
if total_frames % (SOURCE_FPS // TASK_FPS):
raise ValueError(
f"Total source frames {total_frames} are not divisible into 5 Hz steps"
)
media_steps = total_frames // (SOURCE_FPS // TASK_FPS)
frames_per_step = SOURCE_FPS // TASK_FPS
media_steps = total_frames // frames_per_step
if target_steps is not None and media_steps != target_steps:
raise ValueError(
f"Dense media gives {media_steps} steps but target has {target_steps}"
f"Dense media gives {media_steps} complete 5 Hz steps from "
f"{total_frames} frames, but target has {target_steps}"
)
return media_steps
# A session may end with 1--3 source frames, which cannot form another
# complete 5 Hz target interval. The target length is authoritative; the
# final video/audio trim below discards only this sub-step tail.
return target_steps if target_steps is not None else media_steps
def fixed_grid_frame_count(*, fps: float, dense_duration: float) -> int:
"""Number of samples on the grid ``n / fps < dense_duration``."""
return int(math.ceil(dense_duration * fps - 1e-7))
def build_ffmpeg_command(
@@ -127,6 +145,9 @@ def build_ffmpeg_command(
fps: float,
dense_duration: float,
) -> list[str]:
expected_frames = fixed_grid_frame_count(
fps=fps, dense_duration=dense_duration
)
# Trim every decoded stream independently before concatenation. Released
# AAC clips can contain roughly one codec-padding frame beyond their video
# duration; concatenating them first would accumulate AV drift.
@@ -158,11 +179,19 @@ def build_ffmpeg_command(
"".join(concat_inputs)
+ f"concat=n={len(clips)}:v=1:a=1[vcat][acat]"
)
# fps=start_time=0 establishes one global visual lattice after the clips
# have become a dense timeline. The final atrim is a defensive guard
# against decoder rounding at the last sample.
# Establish one global visual lattice after the clips have become a dense
# timeline. Some FFmpeg versions omit the last grid point when the input
# ends exactly on an integer-second boundary. Add two disposable clones,
# then trim by frame index, so EOF timestamp rounding cannot change the
# number of samples. The clones are retained only if a real final grid
# sample was missing.
filters.append(
f"[vcat]fps=fps={fps}:start_time=0:round=down,format=yuv420p[vout]"
f"[vcat]trim=start=0:end={dense_duration:.9f},"
f"setpts=PTS-STARTPTS,"
f"fps=fps={fps}:start_time=0:round=down,"
f"tpad=stop_mode=clone:stop=2,"
f"trim=start_frame=0:end_frame={expected_frames},"
f"setpts=N/({fps}*TB),format=yuv420p[vout]"
)
filters.append(
f"[acat]atrim=start=0:end={dense_duration:.9f},"
@@ -220,20 +249,15 @@ def validate_output(path: Path, *, fps: float, dense_duration: float) -> dict:
raise RuntimeError(f"Prepared file must contain video and audio: {path}")
actual_fps = float(Fraction(video["avg_frame_rate"]))
actual_frames = int(video["nb_frames"])
# FFmpeg may either retain or discard the final partial visual interval,
# depending on timestamps delivered by the concat filter. Both are valid
# fixed grids; for 1388.4 s at 2 FPS this permits 2776 or 2777 frames.
frame_product = dense_duration * fps
expected_frames = {
int(math.floor(frame_product + 1e-7)),
int(math.ceil(frame_product - 1e-7)),
}
expected_frames = fixed_grid_frame_count(
fps=fps, dense_duration=dense_duration
)
if not math.isclose(actual_fps, fps, rel_tol=0.0, abs_tol=1e-6):
raise RuntimeError(f"Prepared FPS is {actual_fps}, expected {fps}: {path}")
if actual_frames not in expected_frames:
if actual_frames != expected_frames:
raise RuntimeError(
f"Prepared frame count is {actual_frames}, expected one of "
f"{sorted(expected_frames)}: {path}"
f"Prepared frame count is {actual_frames}, expected "
f"{expected_frames}: {path}"
)
audio_duration = float(audio["duration"])
# AAC frame padding can shift the container duration by a few milliseconds.
@@ -245,7 +269,7 @@ def validate_output(path: Path, *, fps: float, dense_duration: float) -> dict:
return {
"video_fps": actual_fps,
"video_frames": actual_frames,
"allowed_video_frames": sorted(expected_frames),
"expected_video_frames": expected_frames,
"video_duration_seconds": float(video["duration"]),
"audio_duration_seconds": audio_duration,
"audio_sample_rate": int(audio["sample_rate"]),
@@ -322,12 +346,16 @@ def main() -> None:
output, fps=fps, dense_duration=duration
)
source_frames = sum(clip.frames for clip in clips)
used_source_frames = num_steps * (SOURCE_FPS // TASK_FPS)
metadata = {
"session": session,
"source_session_dir": str(args.session_dir.resolve()),
"source_clip_order": [asdict(clip) for clip in clips],
"source_num_clips": len(clips),
"source_frames_20hz": sum(clip.frames for clip in clips),
"source_frames_20hz": source_frames,
"used_source_frames_20hz": used_source_frames,
"discarded_tail_frames_20hz": source_frames - used_source_frames,
"task_fps": TASK_FPS,
"num_steps": num_steps,
"dense_duration_seconds": duration,