dwehr's picture
Migrate action viewer to local Cosmos generation
9f818c5
Raw
History Blame Contribute Delete
13 kB
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1
"""Generate structured-JSON and dense narrative captions from video files using a VLM.
Each video is passed directly to a VLM server via a ``video_url`` content part
using a ``file://`` path. A structured prompt template guides the VLM through
a two-phase captioning process (Phase 1: structured-JSON scene analysis →
Phase 2: dense narrative rewrite). Both outputs are persisted: ``caption.json``
(the canonical structured caption, with the dense narrative embedded as
``temporal_caption`` and the clip's real media fields) and ``caption.txt`` (the
dense narrative on its own).
The VLM server must support the OpenAI chat-completions API with vision and
must be started with ``--allowed-local-media-path`` pointing to the root of
your video storage so that it can read video files by path. Compatible servers
include vLLM serving Qwen2-VL / Qwen3-VL, LLaVA-Next-Video, etc.
Example usage::
# Caption videos listed in a JSONL file (each line has {"name": ..., "vision_path": ...})
python -m cosmos_framework.scripts.caption_from_video \
-i samples.jsonl -o /output/captions \
--server http://localhost:8000/v1
# Caption a single video directly
python -m cosmos_framework.scripts.caption_from_video \
--video /path/to/video.mp4 -o /output/captions \
--server http://localhost:8000/v1
# Caption a directory of videos
python -m cosmos_framework.scripts.caption_from_video \
--video /path/to/videos/ -o /output/captions \
--server http://localhost:8000/v1
"""
import asyncio
import json
from pathlib import Path
from typing import Annotated
import openai
import pydantic
import tyro
from tqdm import tqdm
from cosmos_framework.inference.args import OmniSampleOverrides
from cosmos_framework.inference.common.args import VIDEO_EXTENSIONS
from cosmos_framework.inference.structured_caption import (
assemble_caption_json,
extract_xml_tag,
media_fields_from_metadata,
parse_structured_caption,
)
from cosmos_framework.scripts.video_metadata import probe_video_metadata
from cosmos_framework.utils import log
_PACKAGE_DIR = Path(__file__).parents[1].absolute()
class Args(pydantic.BaseModel):
input_files: Annotated[list[Path] | None, tyro.conf.arg(aliases=("-i",))] = None
"""Path to input manifest files (JSON/JSONL).
Each entry needs a 'vision_path' (a local path or an http(s)/data URL) and may
include 'name' and a 'media' dict (resolution/aspect_ratio/duration/fps) — the
latter is used as the caption's media fields when the video is a remote URL that
ffprobe cannot read locally. Mutually exclusive with --video."""
video: Annotated[Path | None, tyro.conf.arg(aliases=("-v",))] = None
"""Path to a single video file or a directory of videos.
Mutually exclusive with --input_files."""
output_dir: Annotated[Path, tyro.conf.arg(aliases=("-o",))]
"""Output directory for generated captions."""
server: str = "http://localhost:8000/v1"
"""The URL of the OpenAI-compatible VLM API server."""
model: str | None = None
"""The model to use. If not provided, the first model served will be used."""
max_workers: int = 16
"""Maximum number of concurrent requests to the API."""
max_retries: int = 5
"""Maximum number of retries for each request."""
timeout: float = 600.0
"""Per-request client timeout in seconds; a hung request fails after this and is retried."""
prompt_template_path: Path | None = None
"""Path to a custom prompt template. Defaults to the built-in video_captioner.txt."""
debug: bool = False
"""If True, save raw API responses for debugging."""
def _is_remote_ref(ref: str) -> bool:
"""True if ``ref`` is something the server fetches itself (URL / data URI)."""
return "://" in ref or ref.startswith("data:")
def _video_url(video_ref: str) -> str:
"""Map a local path or remote ref to the ``video_url`` string the server receives.
Remote refs (``http(s)://`` or ``data:``) are passed through untouched, so the
server fetches them itself — this is what makes captioning work against a remote
VLM endpoint. Local paths become ``file://`` URLs, which require a local server
started with ``--allowed-local-media-path``.
"""
if _is_remote_ref(video_ref):
return video_ref
return f"file://{Path(video_ref).absolute()}"
def _build_vlm_messages(video_ref: str, prompt_template: str) -> list[dict]:
"""Build an OpenAI-compatible multimodal message with a video + text prompt."""
return [
{
"role": "user",
"content": [
{"type": "video_url", "video_url": {"url": _video_url(video_ref)}},
{"type": "text", "text": prompt_template},
],
}
]
async def _process_single(
args: Args,
client: openai.AsyncOpenAI,
name: str,
video_ref: str,
media_override: dict | None,
prompt_template: str,
) -> bool:
assert args.model
output_dir = args.output_dir / name
messages = _build_vlm_messages(video_ref, prompt_template)
for i_retry in range(args.max_retries):
try:
response = await client.chat.completions.create(
model=args.model,
messages=messages,
max_tokens=2048,
temperature=0.7,
top_p=0.8,
extra_body={"top_k": 20, "min_p": 0.0},
)
except Exception as e:
log.warning(f"[{i_retry + 1}/{args.max_retries}] API Error for {name}: {e}")
await asyncio.sleep(1)
continue
if args.debug:
retry_dir = output_dir / f"{i_retry}"
retry_dir.mkdir(parents=True, exist_ok=True)
(retry_dir / "response.json").write_text(response.model_dump_json())
assert len(response.choices) == 1
choice = response.choices[0]
if choice.finish_reason != "stop" or not choice.message.content:
log.warning(f"[{i_retry + 1}/{args.max_retries}] Invalid response for {name}")
continue
text = choice.message.content.strip()
final_prompt = extract_xml_tag(text, "final_prompt")
if final_prompt is None:
log.warning(f"[{i_retry + 1}/{args.max_retries}] Failed to extract final prompt for {name}")
continue
scene_draft = parse_structured_caption(text)
if scene_draft is None:
log.warning(f"[{i_retry + 1}/{args.max_retries}] Failed to parse scene_draft JSON for {name}")
continue
# Media fields: prefer a manifest-provided override; else ffprobe a local
# file; else leave empty (e.g. a remote URL ffprobe cannot read).
if media_override is not None:
media = media_override
elif not _is_remote_ref(video_ref):
try:
media = media_fields_from_metadata(probe_video_metadata(video_ref))
except Exception as e: # noqa: BLE001 - degrade gracefully, keep the caption
log.warning(f"ffprobe failed for {name}: {e}; writing caption_json without media fields")
media = {}
else:
media = {}
try:
caption_json = assemble_caption_json(scene_draft, final_prompt, media)
except pydantic.ValidationError as e:
log.warning(f"[{i_retry + 1}/{args.max_retries}] caption_json failed validation for {name}: {e}")
continue
output_dir.mkdir(parents=True, exist_ok=True)
sample_overrides = OmniSampleOverrides(
name=name,
prompt=final_prompt,
vision_path=video_ref,
output_dir=output_dir,
)
(output_dir / "sample_args.json").write_text(sample_overrides.model_dump_json())
(output_dir / "caption.txt").write_text(final_prompt)
(output_dir / "caption.json").write_text(json.dumps(caption_json, indent=2, ensure_ascii=False))
# Advisory: the SFT loader truncates very long prompts (see _MAX_CAPTION_TOKENS
# in sft_dataset.py). ~4 chars/token is a rough guide; warn if the serialized
# JSON looks large so it can be checked against the recipe's max_caption_tokens.
approx_tokens = len(json.dumps(caption_json, ensure_ascii=False)) // 4
if approx_tokens > 1024:
log.warning(
f"{name}: structured caption is ~{approx_tokens} tokens (rough estimate); "
"ensure the SFT recipe's max_caption_tokens covers it to avoid truncation."
)
return True
log.warning(f"Failed to get caption for {name}")
return False
async def _process_with_semaphore(
args: Args,
client: openai.AsyncOpenAI,
semaphore: asyncio.Semaphore,
name: str,
video_ref: str,
media_override: dict | None,
prompt_template: str,
) -> bool:
async with semaphore:
return await _process_single(args, client, name, video_ref, media_override, prompt_template)
def _read_manifest_entries(input_files: list[Path]) -> list[tuple[str, str, dict | None]]:
"""Parse ``-i`` JSON/JSONL manifests into ``(name, video_ref, media)`` tuples.
Each entry must have a ``vision_path`` (a local path or an ``http(s)``/``data``
URL) and may carry an optional ``name`` and an optional ``media`` dict (the
structured caption's media fields: resolution/aspect_ratio/duration/fps). The
``media`` override lets remote-URL videos — which ffprobe cannot read — still
get accurate media fields.
"""
items: list[tuple[str, str, dict | None]] = []
for path in input_files:
text = path.read_text()
if path.suffix == ".jsonl":
entries = [json.loads(line) for line in text.splitlines() if line.strip()]
else:
data = json.loads(text)
entries = data if isinstance(data, list) else [data]
for e in entries:
vp = e.get("vision_path")
name = e.get("name")
if not vp:
log.warning(f"Skipping entry with no vision_path: {name or '?'}")
continue
if Path(vp).suffix.lower() not in VIDEO_EXTENSIONS:
log.warning(f"Skipping '{name or vp}': vision_path is not a video ({Path(vp).suffix})")
continue
items.append((name or Path(vp).stem, vp, e.get("media")))
return items
def _collect_video_items(args: Args) -> list[tuple[str, str, dict | None]]:
"""Return ``(name, video_ref, media_override)`` items from the CLI arguments.
``video_ref`` is a local filesystem path or a remote URL (``http(s)``/``data``).
"""
items: list[tuple[str, str, dict | None]] = []
if args.input_files:
items = _read_manifest_entries(args.input_files)
elif args.video:
if args.video.is_dir():
for vp in sorted(args.video.iterdir()):
if vp.suffix.lower() in VIDEO_EXTENSIONS:
items.append((vp.stem, str(vp), None))
elif args.video.is_file():
items.append((args.video.stem, str(args.video), None))
else:
raise FileNotFoundError(f"Video path does not exist: {args.video}")
if not items:
raise ValueError("No video inputs found. Provide --input_files (-i) or --video (-v).")
return items
async def caption_from_video(args: Args):
if args.input_files and args.video:
raise ValueError("Provide either --input_files or --video, not both.")
if args.prompt_template_path:
prompt_template = args.prompt_template_path.read_text()
else:
prompt_template = (_PACKAGE_DIR / "inference/defaults/video_captioner.txt").read_text()
items = _collect_video_items(args)
client = openai.AsyncOpenAI(
api_key="EMPTY",
base_url=args.server,
timeout=args.timeout,
)
if not args.model:
models = await client.models.list()
args.model = models.data[0].id
log.info(f"Using model: {args.model}")
semaphore = asyncio.Semaphore(args.max_workers)
tasks = [
_process_with_semaphore(
args=args,
client=client,
semaphore=semaphore,
name=name,
video_ref=video_ref,
media_override=media,
prompt_template=prompt_template,
)
for name, video_ref, media in items
]
n_success = 0
for result in tqdm(asyncio.as_completed(tasks), desc="Captioning", total=len(tasks)):
if await result:
n_success += 1
log.info(f"{n_success}/{len(tasks)} videos were successfully captioned")
def main():
args = tyro.cli(Args, description=__doc__)
asyncio.run(caption_from_video(args))
if __name__ == "__main__":
main()