Add reproducible EasyCom causal head pipeline
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user