Files
icassp2027-experiment/prepare_easycom_session.py
T

386 lines
13 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Prepare one EasyCom session for causal Qwen2.5-Omni extraction.
The released EasyCom session consists of retained, approximately one-minute
MP4 clips. EgoSpeak removes source-timeline gaps by concatenating those clips
and crops the left participant-ID banner. This script reproduces that dense
timeline and writes fixed-global-rate 2 FPS and/or 5 FPS AV files.
It intentionally never fills gaps inferred from clip filenames. The number
of 5 Hz task steps is derived from ``target_perframe`` (preferred) or from the
sum of the released 20 FPS video frames.
"""
from __future__ import annotations
import argparse
import json
import math
import shlex
import subprocess
from dataclasses import asdict, dataclass
from fractions import Fraction
from pathlib import Path
from easycom_manifest import parse_timecode
SOURCE_FPS = 20
TASK_FPS = 5
KEEP_WIDTH = 1920
KEEP_HEIGHT = 1080
@dataclass(frozen=True)
class ClipInfo:
path: str
timecode_seconds: float
width: int
height: int
fps: float
frames: int
def _run_json(command: list[str]) -> dict:
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:
payload = _run_json(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,avg_frame_rate,nb_frames",
"-of",
"json",
str(path),
]
)
streams = payload.get("streams", [])
if not streams:
raise RuntimeError(f"No video stream in {path}")
stream = streams[0]
if stream.get("nb_frames") in (None, "N/A"):
raise RuntimeError(f"Exact nb_frames is required for {path}")
fps = float(Fraction(stream["avg_frame_rate"]))
return ClipInfo(
path=str(path.resolve()),
timecode_seconds=parse_timecode(path.stem),
width=int(stream["width"]),
height=int(stream["height"]),
fps=fps,
frames=int(stream["nb_frames"]),
)
def load_target_steps(path: Path) -> int:
try:
import numpy as np
except ImportError as exc:
raise RuntimeError("numpy is required when --target is used") from exc
target = np.load(path, mmap_mode="r")
if target.ndim != 2 or target.shape[1] != 3:
raise ValueError(f"Expected target shape [T,3], got {target.shape}")
return int(target.shape[0])
def ffconcat_quote(path: str) -> str:
# ffconcat uses single-quoted paths; escape an embedded quote as '\''.
return "'" + path.replace("'", "'\\''") + "'"
def validate_inventory(clips: list[ClipInfo], target_steps: int | None) -> int:
if not clips:
raise ValueError("No clips found")
for clip in clips:
if not abs(clip.fps - SOURCE_FPS) < 1e-6:
raise ValueError(f"Expected 20 FPS, got {clip.fps} in {clip.path}")
if clip.width < KEEP_WIDTH or clip.height != KEEP_HEIGHT:
raise ValueError(
f"Cannot take rightmost {KEEP_WIDTH}x{KEEP_HEIGHT} from "
f"{clip.width}x{clip.height}: {clip.path}"
)
total_frames = sum(clip.frames for clip in clips)
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} complete 5 Hz steps from "
f"{total_frames} frames, but target has {target_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(
clips: list[ClipInfo],
output: Path,
*,
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.
command = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-y",
]
for clip in clips:
command.extend(["-i", clip.path])
filters: list[str] = []
concat_inputs: list[str] = []
for index, clip in enumerate(clips):
duration = clip.frames / SOURCE_FPS
filters.append(
f"[{index}:v:0]trim=start=0:end={duration:.9f},"
f"setpts=PTS-STARTPTS,"
f"crop={KEEP_WIDTH}:{KEEP_HEIGHT}:iw-{KEEP_WIDTH}:0[v{index}]"
)
filters.append(
f"[{index}:a:0]atrim=start=0:end={duration:.9f},"
f"asetpts=PTS-STARTPTS[a{index}]"
)
concat_inputs.extend([f"[v{index}]", f"[a{index}]"])
filters.append(
"".join(concat_inputs)
+ f"concat=n={len(clips)}:v=1:a=1[vcat][acat]"
)
# 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]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},"
"asetpts=PTS-STARTPTS[aout]"
)
command.extend(
[
"-filter_complex",
";".join(filters),
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
"18",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(output),
]
)
return command
def validate_output(path: Path, *, fps: float, dense_duration: float) -> dict:
payload = _run_json(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"stream=index,codec_type,avg_frame_rate,nb_frames,duration,sample_rate,channels",
"-of",
"json",
str(path),
]
)
video = next(
(stream for stream in payload.get("streams", []) if stream["codec_type"] == "video"),
None,
)
audio = next(
(stream for stream in payload.get("streams", []) if stream["codec_type"] == "audio"),
None,
)
if video is None or audio is None:
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"])
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 != expected_frames:
raise RuntimeError(
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.
if abs(audio_duration - dense_duration) > 0.05:
raise RuntimeError(
f"Prepared audio duration {audio_duration:.6f}s differs from dense "
f"timeline {dense_duration:.6f}s: {path}"
)
return {
"video_fps": actual_fps,
"video_frames": actual_frames,
"expected_video_frames": expected_frames,
"video_duration_seconds": float(video["duration"]),
"audio_duration_seconds": audio_duration,
"audio_sample_rate": int(audio["sample_rate"]),
"audio_channels": int(audio["channels"]),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session-dir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--target", type=Path)
parser.add_argument(
"--fps",
type=float,
nargs="+",
default=[2.0, 5.0],
help="Fixed global visual rates to generate (default: 2 5).",
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--reuse-existing",
action="store_true",
help="Validate and reuse an existing output MP4 instead of re-encoding it.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
clip_paths = sorted(
args.session_dir.glob("*.mp4"), key=lambda path: parse_timecode(path.stem)
)
clips = [probe_clip(path) for path in clip_paths]
target_steps = load_target_steps(args.target) if args.target else None
num_steps = validate_inventory(clips, target_steps)
session = args.session_dir.name
duration = num_steps / TASK_FPS
args.output_dir.mkdir(parents=True, exist_ok=True)
concat_path = args.output_dir / f"{session}.ffconcat"
concat_text = "ffconcat version 1.0\n" + "".join(
f"file {ffconcat_quote(clip.path)}\n" for clip in clips
)
concat_path.write_text(concat_text)
outputs: dict[str, str] = {}
output_media: dict[str, dict] = {}
commands: list[list[str]] = []
for fps in args.fps:
if fps <= 0:
raise ValueError("Every --fps value must be positive")
fps_label = f"{fps:g}".replace(".", "p")
output = args.output_dir / f"{session}_{fps_label}fps.mp4"
outputs[f"{fps:g}"] = str(output.resolve())
command = build_ffmpeg_command(
clips,
output,
fps=fps,
dense_duration=duration,
)
commands.append(command)
if args.dry_run:
print(shlex.join(command))
elif args.reuse_existing and output.is_file():
print(f"Reusing {output} ...", flush=True)
output_media[f"{fps:g}"] = validate_output(
output, fps=fps, dense_duration=duration
)
else:
print(f"Preparing {output} ...", flush=True)
subprocess.run(command, check=True)
output_media[f"{fps:g}"] = validate_output(
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": 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,
"crop": {
"policy": "rightmost_1920x1080",
"width": KEEP_WIDTH,
"height": KEEP_HEIGHT,
},
"visual_sampling": "fixed_session_global_grid",
"outputs": outputs,
"output_media": output_media,
"target": str(args.target.resolve()) if args.target else None,
"dry_run": bool(args.dry_run),
"reuse_existing": bool(args.reuse_existing),
"commands": [shlex.join(command) for command in commands],
}
metadata_path = args.output_dir / f"{session}.json"
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
print(
f"{session}: {len(clips)} clips, {num_steps} steps, "
f"{duration:.3f}s dense timeline"
)
print(f"Saved metadata to {metadata_path}")
if __name__ == "__main__":
main()