Files

1090 lines
41 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Extract strictly causal MiniCPM-o 4.5 states from original AV media.
MiniCPM-o is used only as a frozen input feature extractor. This script never
calls ``streaming_generate`` and therefore never inserts model-generated
listen/speak/text/audio tokens into the recorded conversation.
Two representations are implemented:
``native-1hz-hold``
Commit one native one-second AV chunk at each integer-second boundary and
forward-hold the newest completed state on the 5 Hz label grid. Rows
before the first completed chunk use a frozen all-zero initialization.
``cached-partial-5hz``
Keep completed one-second chunks in the official streaming cache. At
every non-boundary 200-ms cutoff, snapshot the complete MiniCPM streaming
state, append only the current causal partial AV tail with
``is_last_chunk=True``, read its state, and restore the snapshot. At a
one-second boundary, append the complete chunk to the main cache. The
branch is admitted as ``mode=causal-prefix`` only after mandatory cache
replay, future-audio intervention, and native-boundary checks pass.
The saved payload follows the existing global-feature contract consumed by
``easycom_head.py``: dense zero-based ``label_indices`` and ``states`` aligned
to the 5 Hz target rows.
"""
from __future__ import annotations
import argparse
import importlib.metadata
import math
from contextlib import AbstractContextManager
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Sequence
import numpy as np
import torch
from tqdm import tqdm
import streaming_turn_model_directly as direct_media
MODEL_ID = "openbmb/MiniCPM-o-4_5"
TESTED_TRANSFORMERS_VERSION = "4.51.0"
FEATURE_FORMAT = "minicpmo-causal-global-v1"
EPSILON = 1e-7
@dataclass(frozen=True)
class MiniCPMStreamConfig:
"""Frozen extraction and verification settings."""
step_seconds: float = 0.2
native_chunk_seconds: float = 1.0
system_prompt: str = "Streaming Omni Conversation."
max_slice_nums: int = 1
boundary_max_abs: float = 1e-3
boundary_min_cosine: float = 0.9999
replay_max_abs: float = 1e-3
replay_min_cosine: float = 0.9999
def validate(self) -> None:
if self.step_seconds <= 0:
raise ValueError("step_seconds must be positive")
if self.native_chunk_seconds <= 0:
raise ValueError("native_chunk_seconds must be positive")
ratio = self.native_chunk_seconds / self.step_seconds
if not math.isclose(ratio, round(ratio), abs_tol=1e-9):
raise ValueError("native chunk / step must be integral")
if self.max_slice_nums <= 0:
raise ValueError("max_slice_nums must be positive")
for name, value in (
("boundary_max_abs", self.boundary_max_abs),
("replay_max_abs", self.replay_max_abs),
):
if value < 0:
raise ValueError(f"{name} must be non-negative")
for name, value in (
("boundary_min_cosine", self.boundary_min_cosine),
("replay_min_cosine", self.replay_min_cosine),
):
if not -1.0 <= value <= 1.0:
raise ValueError(f"{name} must be in [-1,1]")
@dataclass
class ExtractionResult:
times: torch.Tensor
states: torch.Tensor
state_source_times: torch.Tensor
native_update_mask: torch.Tensor
initialization_mask: torch.Tensor
verification: list[dict[str, Any]]
partial_tail_admissible: bool
def _time_grid(start: float, end: float, step: float) -> list[float]:
"""Return right-edge cutoffs ``start + k*step`` through ``end``."""
count_f = (end - start) / step
count = int(round(count_f))
if not math.isclose(count_f, count, rel_tol=0.0, abs_tol=1e-6):
raise ValueError(
f"duration must be an integer number of {step:g}s steps: "
f"start={start:g}, end={end:g}"
)
return [start + (index + 1) * step for index in range(count)]
def _is_native_boundary(
current_time: float,
*,
origin: float,
chunk_seconds: float,
) -> bool:
units = (current_time - origin) / chunk_seconds
return math.isclose(units, round(units), rel_tol=0.0, abs_tol=1e-6)
def _comparison(
cached: torch.Tensor,
reference: torch.Tensor,
*,
kind: str,
current_time: float,
max_abs_tolerance: float,
min_cosine: float,
) -> dict[str, Any]:
if cached.shape != reference.shape:
raise RuntimeError(
f"{kind} shape mismatch at {current_time:g}s: "
f"{tuple(cached.shape)} vs {tuple(reference.shape)}"
)
cached_f = cached.float().reshape(1, -1)
reference_f = reference.float().reshape(1, -1)
difference = (cached_f - reference_f).abs()
max_abs = float(difference.max().item()) if difference.numel() else 0.0
mean_abs = float(difference.mean().item()) if difference.numel() else 0.0
cached_norm = float(cached_f.norm().item())
reference_norm = float(reference_f.norm().item())
if torch.equal(cached_f, reference_f):
cosine = 1.0
elif cached_norm == 0.0 and reference_norm == 0.0:
cosine = 1.0
elif cached_norm == 0.0 or reference_norm == 0.0:
cosine = 0.0
else:
cosine = float(
torch.nn.functional.cosine_similarity(
cached_f, reference_f, dim=-1
).item()
)
passed = max_abs <= max_abs_tolerance and cosine >= min_cosine
return {
"kind": kind,
"time": float(current_time),
"max_abs": max_abs,
"mean_abs": mean_abs,
"cosine": cosine,
"max_abs_tolerance": float(max_abs_tolerance),
"min_cosine": float(min_cosine),
"passed": bool(passed),
}
class _FinalHiddenCapture(AbstractContextManager["_FinalHiddenCapture"]):
"""Capture the final normalized Qwen3 state used by MiniCPM-o's LM head."""
def __init__(self, model: Any) -> None:
try:
self.module = model.llm.model.norm
except AttributeError as exc:
raise RuntimeError(
"MiniCPM-o readout contract changed: expected model.llm.model.norm"
) from exc
self.value: torch.Tensor | None = None
self._handle: Any | None = None
def __enter__(self) -> "_FinalHiddenCapture":
def hook(_module: Any, _inputs: Any, output: Any) -> None:
tensor = output[0] if isinstance(output, (tuple, list)) else output
if not isinstance(tensor, torch.Tensor) or tensor.ndim != 3:
raise RuntimeError(
"Unexpected MiniCPM-o final-norm output: "
f"{type(tensor)!r} shape={getattr(tensor, 'shape', None)}"
)
self.value = tensor[:, -1, :].detach().float().cpu()
self._handle = self.module.register_forward_hook(hook)
return self
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
if self._handle is not None:
self._handle.remove()
self._handle = None
def validate_transformers_version(
version: str,
*,
allow_untested: bool = False,
) -> str:
if version != TESTED_TRANSFORMERS_VERSION and not allow_untested:
raise RuntimeError(
"MiniCPM-o 4.5 officially pins transformers==4.51.0; "
f"found {version}. Pass --allow-untested-transformers only for a "
"separate compatibility audit."
)
return version
def _cache_length(cache: Any | None) -> int:
if cache is None:
return 0
if hasattr(cache, "get_seq_length"):
return int(cache.get_seq_length())
if hasattr(cache, "key_cache") and cache.key_cache:
return int(cache.key_cache[0].shape[-2])
if isinstance(cache, (tuple, list)) and cache:
return int(cache[0][0].shape[-2])
raise TypeError(f"Unsupported cache type: {type(cache)!r}")
def _cache_checksum(cache: Any | None) -> float | None:
"""Match the first-key checksum stored by MiniCPM's snapshot object."""
if cache is None:
return None
if hasattr(cache, "self_attention_cache"):
cache = cache.self_attention_cache
if hasattr(cache, "key_cache") and cache.key_cache:
return float(cache.key_cache[0].sum().item())
if isinstance(cache, (tuple, list)) and cache:
return float(cache[0][0].sum().item())
raise TypeError(f"Unsupported cache type: {type(cache)!r}")
class MiniCPMInputOnlyEngine:
"""Version-gated adapter around MiniCPM-o's simplex streaming prefill."""
def __init__(
self,
model: Any,
processor: Any,
config: MiniCPMStreamConfig,
*,
session_id: str,
) -> None:
self.model = model
self.processor = processor
self.config = config
self.session_id = session_id
self.last_restore_audit: dict[str, Any] | None = None
try:
self.hidden_size = int(model.llm.config.hidden_size)
except (AttributeError, TypeError, ValueError) as exc:
raise RuntimeError("Cannot resolve MiniCPM-o LLM hidden size") from exc
for name in (
"streaming_prefill",
"save_speculative_snapshot",
"restore_speculative_snapshot",
"reset_session",
):
if not hasattr(model, name):
raise RuntimeError(
f"MiniCPM-o revision lacks required input-only API: {name}"
)
def reset(self) -> None:
self.model.reset_session(reset_token2wav_cache=False)
self.model.streaming_prefill(
session_id=self.session_id,
msgs=[
{
"role": "system",
"content": [self.config.system_prompt],
}
],
omni_mode=True,
max_slice_nums=self.config.max_slice_nums,
use_tts_template=False,
enable_thinking=False,
is_last_chunk=False,
processor=self.processor,
)
def _prefill(
self,
*,
frame: Any,
audio: np.ndarray,
is_last_chunk: bool,
) -> torch.Tensor:
if not isinstance(audio, np.ndarray) or audio.ndim != 1 or not len(audio):
raise ValueError("MiniCPM-o audio must be a non-empty mono ndarray")
audio = np.asarray(audio, dtype=np.float32)
with _FinalHiddenCapture(self.model) as capture:
self.model.streaming_prefill(
session_id=self.session_id,
msgs=[{"role": "user", "content": [frame, audio]}],
omni_mode=True,
max_slice_nums=self.config.max_slice_nums,
use_tts_template=False,
enable_thinking=False,
is_last_chunk=is_last_chunk,
processor=self.processor,
)
if capture.value is None:
raise RuntimeError("MiniCPM-o prefill produced no captured LLM state")
if capture.value.shape != (1, self.hidden_size):
raise RuntimeError(
"Unexpected MiniCPM-o state shape: "
f"{tuple(capture.value.shape)}, expected (1,{self.hidden_size})"
)
if not torch.isfinite(capture.value).all():
raise RuntimeError("MiniCPM-o state contains NaN/Inf")
return capture.value
def commit_state(self, *, frame: Any, audio: np.ndarray) -> torch.Tensor:
return self._prefill(frame=frame, audio=audio, is_last_chunk=False)
def temporary_state(self, *, frame: Any, audio: np.ndarray) -> torch.Tensor:
self.last_restore_audit = None
before_llm = _cache_length(getattr(self.model, "llm_past_key_values", None))
before_audio = _cache_length(
getattr(self.model, "audio_past_key_values", None)
)
before_chunk = int(getattr(self.model, "audio_chunk_idx", 0))
before_history = tuple(
id(item)
for item in getattr(self.model, "_omni_chunk_history", ())
)
before_position = int(
getattr(self.model, "streaming_position_offset", 0)
)
before_window_enabled = bool(
getattr(self.model, "streaming_window_enabled", False)
)
snapshot = self.model.save_speculative_snapshot()
try:
# A speculative prefill must never invoke MiniCPM's destructive
# round-pruning path: the upstream snapshot truncates appended KV
# tokens but cannot recreate a round that pruning removed. Native
# committed chunks still retain the model's normal window policy.
if hasattr(self.model, "streaming_window_enabled"):
self.model.streaming_window_enabled = False
try:
state = self._prefill(
frame=frame,
audio=audio,
is_last_chunk=True,
)
finally:
if hasattr(self.model, "streaming_window_enabled"):
self.model.streaming_window_enabled = before_window_enabled
finally:
restored = self.model.restore_speculative_snapshot(snapshot)
if not restored:
raise RuntimeError("MiniCPM-o failed to restore a partial-tail snapshot")
after_llm = _cache_length(getattr(self.model, "llm_past_key_values", None))
after_audio = _cache_length(
getattr(self.model, "audio_past_key_values", None)
)
after_chunk = int(getattr(self.model, "audio_chunk_idx", 0))
after_history = tuple(
id(item)
for item in getattr(self.model, "_omni_chunk_history", ())
)
after_position = int(
getattr(self.model, "streaming_position_offset", 0)
)
after_window_enabled = bool(
getattr(self.model, "streaming_window_enabled", False)
)
after_llm_checksum = _cache_checksum(
getattr(self.model, "llm_past_key_values", None)
)
after_audio_checksum = _cache_checksum(
getattr(self.model, "audio_past_key_values", None)
)
expected_llm_checksum = getattr(snapshot, "llm_cache_checksum", None)
expected_audio_checksum = getattr(snapshot, "audio_cache_checksum", None)
structure_matches = (
after_llm,
after_audio,
after_chunk,
after_history,
after_position,
after_window_enabled,
) == (
before_llm,
before_audio,
before_chunk,
before_history,
before_position,
before_window_enabled,
)
checksum_matches = (
after_llm_checksum == expected_llm_checksum
and after_audio_checksum == expected_audio_checksum
)
self.last_restore_audit = {
"kind": "snapshot_restore",
"llm_cache_length": after_llm,
"audio_cache_length": after_audio,
"audio_chunk_index": after_chunk,
"streaming_position_offset": after_position,
"window_pruning_suspended": bool(before_window_enabled),
"window_policy_restored": bool(
after_window_enabled == before_window_enabled
),
"llm_checksum_matches": bool(
after_llm_checksum == expected_llm_checksum
),
"audio_checksum_matches": bool(
after_audio_checksum == expected_audio_checksum
),
"history_matches": bool(after_history == before_history),
"passed": bool(structure_matches and checksum_matches),
}
if not self.last_restore_audit["passed"]:
raise RuntimeError(
"Partial-tail restore changed committed MiniCPM-o state: "
f"{self.last_restore_audit}"
)
return state
class MiniCPMFeatureExtractor:
"""Schedule native commits and temporary tails on a causal AV reader."""
def __init__(
self,
reader: Any,
engine: Any,
config: MiniCPMStreamConfig,
) -> None:
config.validate()
self.reader = reader
self.engine = engine
self.config = config
def _frame_audio(self, start: float, end: float) -> tuple[Any, np.ndarray]:
# Audio uses [start,end). Excluding a source frame exactly at ``end``
# gives the same half-open cutoff contract as the Qwen extractor.
# CausalSourceAV admits an EPSILON tolerance, so nextafter alone is not
# enough to exclude a frame whose PTS is exactly the right edge.
frame_time = max(start, end - max(4 * direct_media.EPSILON, 1e-6))
frame = self.reader.frame_at_or_before(frame_time)
audio = self.reader.audio_slice(start, end)
return frame, audio
def _commit(self, start: float, end: float) -> torch.Tensor:
frame, audio = self._frame_audio(start, end)
return self.engine.commit_state(frame=frame, audio=audio)
def _temporary(self, start: float, end: float) -> torch.Tensor:
frame, audio = self._frame_audio(start, end)
return self.engine.temporary_state(frame=frame, audio=audio)
def _replay_to(
self,
*,
origin: float,
current_time: float,
) -> tuple[torch.Tensor, float]:
"""Cleanly replay completed chunks and the optional current tail."""
self.engine.reset()
cursor = origin
state: torch.Tensor | None = None
while cursor + self.config.native_chunk_seconds <= current_time + EPSILON:
next_cursor = cursor + self.config.native_chunk_seconds
state = self._commit(cursor, next_cursor)
cursor = next_cursor
if cursor < current_time - EPSILON:
state = self._temporary(cursor, current_time)
if state is None:
state = torch.zeros(1, self.engine.hidden_size, dtype=torch.float32)
return state, cursor
def extract_native_hold(
self,
*,
start: float,
end: float,
show_progress: bool,
) -> ExtractionResult:
times = _time_grid(start, end, self.config.step_seconds)
self.engine.reset()
latest = torch.zeros(1, self.engine.hidden_size, dtype=torch.float32)
source_time = start
chunk_start = start
states: list[torch.Tensor] = []
source_times: list[float] = []
updates: list[bool] = []
initialization: list[bool] = []
with tqdm(
total=len(times),
desc="MiniCPM native 1Hz",
unit="state",
disable=not show_progress,
dynamic_ncols=True,
) as progress:
for current_time in times:
update = _is_native_boundary(
current_time,
origin=start,
chunk_seconds=self.config.native_chunk_seconds,
)
if update:
latest = self._commit(chunk_start, current_time)
chunk_start = current_time
source_time = current_time
states.append(latest.cpu())
source_times.append(source_time)
updates.append(update)
initialization.append(source_time <= start + EPSILON)
progress.update(1)
return ExtractionResult(
times=torch.tensor(times, dtype=torch.float64),
states=torch.cat(states, dim=0),
state_source_times=torch.tensor(source_times, dtype=torch.float64),
native_update_mask=torch.tensor(updates, dtype=torch.bool),
initialization_mask=torch.tensor(initialization, dtype=torch.bool),
verification=[],
partial_tail_admissible=True,
)
def extract_cached_partial(
self,
*,
start: float,
end: float,
verify_every: int,
future_intervention_every: int,
allow_unverified: bool,
show_progress: bool,
) -> ExtractionResult:
if verify_every < 0 or future_intervention_every < 0:
raise ValueError("verification intervals must be non-negative")
times = _time_grid(start, end, self.config.step_seconds)
self.engine.reset()
chunk_start = start
states: list[torch.Tensor] = []
updates: list[bool] = []
checks: list[dict[str, Any]] = []
with tqdm(
total=len(times),
desc="MiniCPM cached partial 5Hz",
unit="state",
disable=not show_progress,
dynamic_ncols=True,
) as progress:
for step_index, current_time in enumerate(times, start=1):
boundary = _is_native_boundary(
current_time,
origin=start,
chunk_seconds=self.config.native_chunk_seconds,
)
if boundary:
# Compare a temporary closed view of exactly the same full
# chunk with the native non-final commit before admitting
# this 5 Hz representation.
temporary = self._temporary(chunk_start, current_time)
restore_audit = getattr(
self.engine, "last_restore_audit", None
)
if restore_audit is not None:
checks.append(
{**restore_audit, "time": float(current_time)}
)
state = self._commit(chunk_start, current_time)
comparison = _comparison(
state,
temporary,
kind="native_boundary_equivalence",
current_time=current_time,
max_abs_tolerance=self.config.boundary_max_abs,
min_cosine=self.config.boundary_min_cosine,
)
checks.append(comparison)
if not comparison["passed"] and not allow_unverified:
raise RuntimeError(
"MiniCPM partial-tail/native boundary check failed "
f"at {current_time:g}s: {comparison}"
)
chunk_start = current_time
else:
state = self._temporary(chunk_start, current_time)
restore_audit = getattr(
self.engine, "last_restore_audit", None
)
if restore_audit is not None:
checks.append(
{**restore_audit, "time": float(current_time)}
)
# Always audit the first row. Positive intervals add sampled
# checks without turning a full session into O(T^2) replay.
replay_due = step_index == 1 or (
verify_every > 0 and step_index % verify_every == 0
)
if replay_due:
reference, rebuilt_until = self._replay_to(
origin=start,
current_time=current_time,
)
comparison = _comparison(
state,
reference,
kind="clean_stream_replay",
current_time=current_time,
max_abs_tolerance=self.config.replay_max_abs,
min_cosine=self.config.replay_min_cosine,
)
comparison["rebuilt_committed_until"] = float(rebuilt_until)
checks.append(comparison)
if not comparison["passed"] and not allow_unverified:
raise RuntimeError(
"MiniCPM clean replay check failed at "
f"{current_time:g}s: {comparison}"
)
future_due = (
current_time < end - EPSILON
and (
step_index == 1
or (
future_intervention_every > 0
and step_index % future_intervention_every == 0
)
)
)
if future_due:
first = int(
math.floor(
current_time * direct_media.SAMPLE_RATE + EPSILON
)
)
last = min(
len(self.reader.audio),
first + direct_media.SAMPLE_RATE,
)
original = self.reader.audio[first:last].copy()
if last > first:
self.reader.audio[first:last] = np.linspace(
-0.25,
0.25,
last - first,
dtype=np.float32,
)
try:
intervened, _ = self._replay_to(
origin=start,
current_time=current_time,
)
finally:
self.reader.audio[first:last] = original
comparison = _comparison(
state,
intervened,
kind="future_audio_intervention",
current_time=current_time,
max_abs_tolerance=0.0,
min_cosine=1.0,
)
checks.append(comparison)
if not comparison["passed"] and not allow_unverified:
raise RuntimeError(
"Future audio changed a causal MiniCPM state at "
f"{current_time:g}s: {comparison}"
)
states.append(state.cpu())
updates.append(boundary)
progress.update(1)
kinds = {check["kind"] for check in checks if check.get("passed")}
mandatory = {
"native_boundary_equivalence",
"clean_stream_replay",
"future_audio_intervention",
"snapshot_restore",
}
admissible = mandatory.issubset(kinds) and all(
bool(check.get("passed")) for check in checks
)
if not admissible and not allow_unverified:
missing = sorted(mandatory - kinds)
raise RuntimeError(
"MiniCPM partial-tail branch lacks mandatory verification; "
f"missing={missing}. Run a >=1.2s smoke test or pass "
"--allow-unverified-partial-tail to save a deliberately "
"non-loadable audit artifact."
)
return ExtractionResult(
times=torch.tensor(times, dtype=torch.float64),
states=torch.cat(states, dim=0),
state_source_times=torch.tensor(times, dtype=torch.float64),
native_update_mask=torch.tensor(updates, dtype=torch.bool),
initialization_mask=torch.zeros(len(times), dtype=torch.bool),
verification=checks,
partial_tail_admissible=admissible,
)
def load_minicpmo(
model_id: str,
*,
revision: str | None,
dtype: str,
device: str,
attention: str,
allow_untested_transformers: bool,
) -> tuple[Any, Any, dict[str, Any]]:
"""Load the frozen AV backbone without the TTS module."""
installed = importlib.metadata.version("transformers")
validate_transformers_version(
installed,
allow_untested=allow_untested_transformers,
)
from transformers import AutoModel, AutoProcessor
dtype_value: Any = {
"auto": "auto",
"bfloat16": torch.bfloat16,
"float16": torch.float16,
"float32": torch.float32,
}[dtype]
common: dict[str, Any] = {
"trust_remote_code": True,
}
if revision is not None:
common["revision"] = revision
processor = AutoProcessor.from_pretrained(model_id, **common)
model = AutoModel.from_pretrained(
model_id,
torch_dtype=dtype_value,
attn_implementation=attention,
init_vision=True,
init_audio=True,
init_tts=False,
device_map={"": device},
**common,
)
model.eval()
model.requires_grad_(False)
resolved_revision = getattr(model.config, "_commit_hash", None)
metadata = {
"transformers_version": installed,
"requested_revision": revision,
"resolved_revision": resolved_revision,
"attention_implementation": attention,
"dtype": dtype,
"device": device,
"init_tts": False,
}
return model, processor, metadata
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--video", type=Path)
source.add_argument("--easycom-session-dir", type=Path)
source.add_argument("--easycom-manifest", type=Path)
parser.add_argument("--session")
parser.add_argument("--audio", type=Path)
parser.add_argument("--crop")
parser.add_argument("--target", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument(
"--mode",
choices=("native-1hz-hold", "cached-partial-5hz"),
required=True,
)
parser.add_argument("--model-id", default=MODEL_ID)
parser.add_argument(
"--revision",
help="Pinned Hugging Face revision; strongly recommended for paper runs",
)
parser.add_argument("--start", type=float, default=0.0)
parser.add_argument("--end", type=float)
parser.add_argument("--step-seconds", type=float, default=0.2)
parser.add_argument("--native-chunk-seconds", type=float, default=1.0)
parser.add_argument(
"--system-prompt",
default="Streaming Omni Conversation.",
)
parser.add_argument("--max-slice-nums", type=int, default=1)
parser.add_argument("--boundary-max-abs", type=float, default=1e-3)
parser.add_argument("--boundary-min-cosine", type=float, default=0.9999)
parser.add_argument("--replay-max-abs", type=float, default=1e-3)
parser.add_argument("--replay-min-cosine", type=float, default=0.9999)
parser.add_argument(
"--verify-every",
type=int,
default=0,
help="Additional clean replays every N rows; the first row is always checked",
)
parser.add_argument(
"--future-intervention-every",
type=int,
default=0,
help=(
"Additional future-audio checks every N rows; the first eligible "
"row is always checked"
),
)
parser.add_argument("--allow-unverified-partial-tail", action="store_true")
parser.add_argument("--frame-cache-size", type=int, default=8)
parser.add_argument(
"--decoder",
choices=("decord", "torchcodec", "pynvcodec"),
default="decord",
)
parser.add_argument("--decoder-gpu-id", type=int, default=0)
parser.add_argument("--decoder-threads", type=int, default=1)
parser.add_argument("--max-open-readers", type=int, default=2)
parser.add_argument(
"--dtype",
choices=("auto", "bfloat16", "float16", "float32"),
default="bfloat16",
)
parser.add_argument(
"--attention",
choices=("sdpa", "flash_attention_2"),
default="sdpa",
)
parser.add_argument("--device", default="cuda:0")
parser.add_argument("--allow-untested-transformers", action="store_true")
parser.add_argument("--media-only", action="store_true")
parser.add_argument("--no-progress", action="store_true")
return parser.parse_args(argv)
def _validate_args(args: argparse.Namespace) -> None:
if args.output is None and not args.media_only:
raise SystemExit("--output is required unless --media-only is used")
if args.audio is not None and args.video is None:
raise SystemExit("--audio is supported only with --video")
if args.session is not None and args.easycom_manifest is None:
raise SystemExit("--session is used only with --easycom-manifest")
if args.easycom_manifest is not None and args.session is None:
raise SystemExit("--easycom-manifest requires --session Session_N")
if args.frame_cache_size < 0:
raise SystemExit("--frame-cache-size must be non-negative")
if args.verify_every < 0 or args.future_intervention_every < 0:
raise SystemExit("verification intervals must be non-negative")
if not args.media_only and args.revision is None:
print(
"WARNING: --revision is not pinned; this run is not paper-reproducible.",
flush=True,
)
def _build_reader(
args: argparse.Namespace,
) -> tuple[Any, Path | None, int | None, float, str, Any]:
manifest_record = None
manifest_target = None
if args.video is not None:
specs = [
direct_media.SourceClipSpec(
path=args.video,
crop=direct_media._parse_crop(args.crop),
audio_path=args.audio,
)
]
source_kind = "single_original_video"
elif args.easycom_session_dir is not None:
if args.crop is not None:
raise SystemExit(
"EasyCom direct extraction uses the fixed rightmost-1920 crop"
)
specs = direct_media._easycom_directory_specs(args.easycom_session_dir)
source_kind = "easycom_dense_original_clips"
else:
if args.crop is not None:
raise SystemExit("EasyCom manifest supplies crop metadata")
specs, manifest_record, manifest_target = direct_media._manifest_session_specs(
args.easycom_manifest.expanduser().resolve(), args.session
)
source_kind = "easycom_manifest_original_clips"
target_path, target_steps, end = direct_media._resolve_duration_and_target(
args, manifest_target
)
reader = direct_media.CausalSourceAV(
specs,
output_fps=1.0 / args.step_seconds,
required_duration=end,
frame_cache_size=args.frame_cache_size,
decoder_backend=args.decoder,
decoder_gpu_id=args.decoder_gpu_id,
decoder_threads=args.decoder_threads,
max_open_readers=args.max_open_readers,
)
is_easycom = (
args.easycom_session_dir is not None
or args.easycom_manifest is not None
)
if is_easycom:
for clip in reader.clips:
if clip.height != direct_media.EASYCOM_HEIGHT:
raise RuntimeError(
f"Expected EasyCom height {direct_media.EASYCOM_HEIGHT}, "
f"got {clip.height}: {clip.path}"
)
if not math.isclose(clip.source_fps, 20.0, abs_tol=1e-6):
raise RuntimeError(
f"Expected EasyCom source FPS 20, got {clip.source_fps:g}: "
f"{clip.path}"
)
if target_steps is not None:
source_frames = sum(clip.num_frames for clip in reader.clips)
media_steps = source_frames // 4
if media_steps != target_steps:
raise RuntimeError(
f"EasyCom source gives {media_steps} 5-Hz rows, target has "
f"{target_steps}"
)
direct_media._print_media_summary(reader, args.start, end)
return reader, target_path, target_steps, end, source_kind, manifest_record
def main(
argv: Sequence[str] | None = None,
*,
model: Any | None = None,
processor: Any | None = None,
) -> None:
args = parse_args(argv)
_validate_args(args)
if (model is None) != (processor is None):
raise ValueError("model and processor must be supplied together")
config = MiniCPMStreamConfig(
step_seconds=args.step_seconds,
native_chunk_seconds=args.native_chunk_seconds,
system_prompt=args.system_prompt,
max_slice_nums=args.max_slice_nums,
boundary_max_abs=args.boundary_max_abs,
boundary_min_cosine=args.boundary_min_cosine,
replay_max_abs=args.replay_max_abs,
replay_min_cosine=args.replay_min_cosine,
)
config.validate()
if not math.isclose(args.native_chunk_seconds, 1.0, abs_tol=1e-9):
raise SystemExit(
"MiniCPM-o 4.5 native streaming is fixed at 1.0s; "
"--native-chunk-seconds is exposed only to reject protocol drift"
)
(
reader,
target_path,
target_steps,
end,
source_kind,
manifest_record,
) = _build_reader(args)
if args.media_only:
probe_end = min(end, args.start + args.step_seconds)
reader.frame_at_or_before(float(np.nextafter(probe_end, -np.inf)))
reader.audio_slice(args.start, probe_end)
print(f"Decoded MiniCPM media probe successfully with {args.decoder}")
return
model_metadata: dict[str, Any]
if model is None:
model, processor, model_metadata = load_minicpmo(
args.model_id,
revision=args.revision,
dtype=args.dtype,
device=args.device,
attention=args.attention,
allow_untested_transformers=args.allow_untested_transformers,
)
else:
model.eval()
model.requires_grad_(False)
model_metadata = {
"transformers_version": importlib.metadata.version("transformers"),
"requested_revision": args.revision,
"resolved_revision": getattr(model.config, "_commit_hash", None),
"preloaded": True,
}
session_name = args.session or (
args.easycom_session_dir.name
if args.easycom_session_dir is not None
else args.video.stem
)
engine = MiniCPMInputOnlyEngine(
model,
processor,
config,
session_id=f"feature-extract-{session_name}",
)
extractor = MiniCPMFeatureExtractor(reader, engine, config)
if args.mode == "native-1hz-hold":
result = extractor.extract_native_hold(
start=args.start,
end=end,
show_progress=not args.no_progress,
)
else:
result = extractor.extract_cached_partial(
start=args.start,
end=end,
verify_every=args.verify_every,
future_intervention_every=args.future_intervention_every,
allow_unverified=args.allow_unverified_partial_tail,
show_progress=not args.no_progress,
)
if target_steps is not None and len(result.times) != target_steps:
raise RuntimeError(
f"Extractor returned {len(result.times)} states, expected {target_steps}"
)
loadable_mode = (
"causal-prefix"
if result.partial_tail_admissible
else "minicpmo-unverified-partial-tail"
)
payload = {
"format": FEATURE_FORMAT,
"model_id": args.model_id,
"mode": loadable_mode,
"extractor_mode": args.mode,
"stream_representation": (
"native_1s_input_only_state_forward_held_to_5hz"
if args.mode == "native-1hz-hold"
else "committed_1s_cache_plus_temporary_causal_partial_tail_at_5hz"
),
"config": asdict(config),
"model_metadata": model_metadata,
"strict_causal": True,
"generation_called": False,
"tts_loaded": False,
"partial_tail_admissible": result.partial_tail_admissible,
"initialization_policy": (
"zeros_until_first_completed_native_chunk"
if args.mode == "native-1hz-hold"
else None
),
"start": args.start,
"end": end,
"num_steps": len(result.times),
"label_indices": torch.arange(len(result.times), dtype=torch.long),
"valid_10_horizon_steps": max(0, len(result.times) - 10),
"times": result.times,
"state_source_times": result.state_source_times,
"native_update_mask": result.native_update_mask,
"initialization_mask": result.initialization_mask,
"states": result.states,
"verification": result.verification,
"target_path": str(target_path) if target_path is not None else None,
"source_kind": source_kind,
"source_manifest_record": manifest_record,
"direct_media": reader.metadata(),
"video_sampling_grid": "latest_source_pts_strictly_before_each_cutoff",
"audio_interval": "half_open_[start,end)",
}
args.output.parent.mkdir(parents=True, exist_ok=True)
torch.save(payload, args.output)
print(f"Saved {len(result.times)} MiniCPM states to {args.output}")
print(f"states: {tuple(result.states.shape)}")
print(f"mode: {loadable_mode}; extractor_mode: {args.mode}")
if result.verification:
kinds = sorted({item["kind"] for item in result.verification})
for kind in kinds:
items = [item for item in result.verification if item["kind"] == kind]
worst_abs = max(float(item["max_abs"]) for item in items)
worst_cosine = min(float(item["cosine"]) for item in items)
print(
f" {kind}: n={len(items)}, max_abs={worst_abs:.6g}, "
f"min_cosine={worst_cosine:.9f}"
)
if __name__ == "__main__":
main()