2026-08-16 00:02:58 +08:00
|
|
|
#!/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:
|
2026-08-16 22:19:43 +08:00
|
|
|
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
|
2026-08-16 00:02:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-16 22:19:43 +08:00
|
|
|
frames_per_step = SOURCE_FPS // TASK_FPS
|
|
|
|
|
media_steps = total_frames // frames_per_step
|
2026-08-16 00:02:58 +08:00
|
|
|
if target_steps is not None and media_steps != target_steps:
|
|
|
|
|
raise ValueError(
|
2026-08-16 22:19:43 +08:00
|
|
|
f"Dense media gives {media_steps} complete 5 Hz steps from "
|
|
|
|
|
f"{total_frames} frames, but target has {target_steps}"
|
2026-08-16 00:02:58 +08:00
|
|
|
)
|
2026-08-16 22:19:43 +08:00
|
|
|
# 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))
|
2026-08-16 00:02:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_ffmpeg_command(
|
|
|
|
|
clips: list[ClipInfo],
|
|
|
|
|
output: Path,
|
|
|
|
|
*,
|
|
|
|
|
fps: float,
|
|
|
|
|
dense_duration: float,
|
|
|
|
|
) -> list[str]:
|
2026-08-16 22:19:43 +08:00
|
|
|
expected_frames = fixed_grid_frame_count(
|
|
|
|
|
fps=fps, dense_duration=dense_duration
|
|
|
|
|
)
|
2026-08-16 00:02:58 +08:00
|
|
|
# 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]"
|
|
|
|
|
)
|
2026-08-16 22:19:43 +08:00
|
|
|
# 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.
|
2026-08-16 00:02:58 +08:00
|
|
|
filters.append(
|
2026-08-16 22:19:43 +08:00
|
|
|
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]"
|
2026-08-16 00:02:58 +08:00
|
|
|
)
|
|
|
|
|
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"])
|
2026-08-16 22:19:43 +08:00
|
|
|
expected_frames = fixed_grid_frame_count(
|
|
|
|
|
fps=fps, dense_duration=dense_duration
|
|
|
|
|
)
|
2026-08-16 00:02:58 +08:00
|
|
|
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}")
|
2026-08-16 22:19:43 +08:00
|
|
|
if actual_frames != expected_frames:
|
2026-08-16 00:02:58 +08:00
|
|
|
raise RuntimeError(
|
2026-08-16 22:19:43 +08:00
|
|
|
f"Prepared frame count is {actual_frames}, expected "
|
|
|
|
|
f"{expected_frames}: {path}"
|
2026-08-16 00:02:58 +08:00
|
|
|
)
|
|
|
|
|
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,
|
2026-08-16 22:19:43 +08:00
|
|
|
"expected_video_frames": expected_frames,
|
2026-08-16 00:02:58 +08:00
|
|
|
"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
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-16 22:19:43 +08:00
|
|
|
source_frames = sum(clip.frames for clip in clips)
|
|
|
|
|
used_source_frames = num_steps * (SOURCE_FPS // TASK_FPS)
|
2026-08-16 00:02:58 +08:00
|
|
|
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),
|
2026-08-16 22:19:43 +08:00
|
|
|
"source_frames_20hz": source_frames,
|
|
|
|
|
"used_source_frames_20hz": used_source_frames,
|
|
|
|
|
"discarded_tail_frames_20hz": source_frames - used_source_frames,
|
2026-08-16 00:02:58 +08:00
|
|
|
"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()
|