| |
| """RunPod scene processor — autonomous batch VLM pipeline via vllm endpoint. |
| |
| Designed to run ON a RunPod pod (or any machine running vllm). Replaces the |
| local transformers model with HTTP calls to a vllm OpenAI-compatible endpoint. |
| Processes videos in configurable batch sizes, checkpoints progress so it can |
| resume after a restart, and POSTs a webhook when each batch completes. |
| |
| Prerequisites on the RunPod pod: |
| pip install vllm # start with: vllm serve Qwen/Qwen2.5-VL-72B-Instruct |
| pip install openai # for the API client |
| pip install requests # for webhook |
| |
| Typical RunPod workflow: |
| # 1. On local machine — sync data to pod: |
| rsync -avz backend/scene-local-work/ runpod:/workspace/scene-local-work/ |
| rsync -avz backend/ runpod:/workspace/backend/ --exclude=videos --exclude=subtitles |
| |
| # 2. On pod — start vllm server (separate tmux): |
| vllm serve Qwen/Qwen2.5-VL-72B-Instruct --tensor-parallel-size 1 |
| |
| # 3. On pod — run this script: |
| cd /workspace |
| source backend/venv/bin/activate |
| export SEARCH_UI_DATA_ROOT=/workspace/backend |
| python scripts/runpod_scene_processor.py process \\ |
| --next 50 \\ |
| --vllm-url http://localhost:8000/v1 \\ |
| --vllm-model Qwen/Qwen2.5-VL-72B-Instruct \\ |
| --stop-on-error \\ |
| --webhook-url https://hooks.example.com/batch-done |
| |
| # 4. On local machine — sync results back: |
| rsync -avz runpod:/workspace/backend/scene_index.db backend/scene_index.db |
| rsync -avz runpod:/workspace/backend/scene-local-work/ backend/scene-local-work/ |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import base64 |
| import json |
| import logging |
| import os |
| import re |
| import subprocess |
| import sys |
| import time |
| from typing import Any |
|
|
| SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) |
| REPO_ROOT = os.path.dirname(SCRIPT_DIR) |
| BACKEND_DIR = os.path.join(REPO_ROOT, "backend") |
| if BACKEND_DIR not in sys.path: |
| sys.path.insert(0, BACKEND_DIR) |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s [%(levelname)s] %(message)s", |
| datefmt="%H:%M:%S", |
| ) |
| log = logging.getLogger(__name__) |
|
|
| |
| |
| |
|
|
| WINDOW_SYSTEM = ( |
| "You are a precise video description assistant. " |
| "You write concise, factual descriptions of short video clips for a searchable index. " |
| "You follow instructions exactly and return only valid JSON." |
| ) |
|
|
| WINDOW_USER_TEMPLATE = """\ |
| You are analyzing a 30-second window from a JW.org educational or documentary video. |
| |
| Subtitle text spoken in this window: |
| {subtitle_block} |
| |
| Task: Describe what is happening in these frames. |
| |
| Strict rules: |
| 1. Write EXACTLY 60-100 words. Count carefully before finalising. |
| 2. Lead with the ACTION or RELATIONSHIP visible — not the setting. Do NOT start with \ |
| "In this scene", "The video shows", "This window", or similar filler. |
| 3. Combine what is CLEARLY VISIBLE in the frames with what is LITERALLY STATED in \ |
| the subtitle text. Do NOT invent details. |
| 4. Conservative: if you cannot clearly see something, do not describe it. |
| 5. End-card detection: if this window is a standard JW.org end card — the jw.org logo \ |
| or Watchtower logo on a plain black or dark background, with copyright text and NO human \ |
| action — set skip=true and provide a skip_reason. Do NOT skip windows with meaningful content. |
| |
| Also list any place names (cities, countries, regions) visible as ON-SCREEN TEXT only \ |
| (chyrons, lower-thirds, signs, title cards, text overlays). Do NOT include places \ |
| mentioned only in spoken dialogue. |
| |
| Return ONLY this JSON — no markdown fences, no extra text: |
| {{ |
| "description": "<60-100 word description, or empty string if skip=true>", |
| "onscreen_text_places": ["<place name>", ...], |
| "skip": false, |
| "skip_reason": "" |
| }}""" |
|
|
| SUMMARY_SYSTEM = ( |
| "You write precise video-level summaries for a searchable index. " |
| "You follow word-count and formatting instructions exactly and return only valid JSON." |
| ) |
|
|
| SUMMARY_USER_TEMPLATE = """\ |
| You have described all windows of a JW.org video titled: "{title}" |
| |
| Window descriptions (chronological): |
| {window_block} |
| |
| Subtitle context (all spoken text): |
| {subtitle_block} |
| |
| Write a video-level summary. Strict rules: |
| 1. tldr: 80-140 words, specific and factual — name who, what, where, when if present. \ |
| No vague generalities. |
| 2. themes: 5-8 SPECIFIC themes (e.g. "delegates arriving by plane at Yankee Stadium", \ |
| not "travel"). Each theme is a concrete observable activity or subject in the video. |
| 3. acts: 3-5 acts covering the video chronologically. The first act MUST have \ |
| start_seconds=0. Each act description should be 1-2 sentences. |
| 4. locations: countries, cities, or regions the video is SET IN or SUBSTANTIALLY ABOUT \ |
| (drawn from narration/dialogue, not from onscreen text). Only include if the video is \ |
| genuinely located there. Generic references like "many countries" do NOT count. |
| |
| Return ONLY this JSON — no markdown fences, no extra text: |
| {{ |
| "tldr": "<80-140 words>", |
| "themes": ["<specific theme>", ...], |
| "acts": [ |
| {{"start_seconds": 0, "description": "<act 1>"}}, |
| ... |
| ], |
| "locations": ["<city or country>", ...] |
| }}""" |
|
|
|
|
| |
| |
| |
|
|
| def _repair_truncated_json(fragment: str) -> str: |
| """Best-effort repair of JSON truncated mid-output (hit max_tokens). |
| |
| Closes an unterminated string, then appends the closing brackets/braces |
| needed to balance the structure. Recovers all fields that completed plus |
| the (possibly slightly clipped) field that was being written. Returns the |
| repaired string; the caller still json.loads() it and may still fail. |
| """ |
| s = fragment.rstrip().rstrip(",") |
|
|
| |
| in_string = False |
| escaped = False |
| stack: list[str] = [] |
| for ch in s: |
| if escaped: |
| escaped = False |
| continue |
| if ch == "\\": |
| escaped = True |
| continue |
| if ch == '"': |
| in_string = not in_string |
| continue |
| if in_string: |
| continue |
| if ch in "{[": |
| stack.append("}" if ch == "{" else "]") |
| elif ch in "}]" and stack: |
| stack.pop() |
|
|
| if in_string: |
| s += '"' |
| while stack: |
| s += stack.pop() |
| return s |
|
|
|
|
| def extract_json(raw: str, context: str = "") -> dict: |
| text = re.sub(r"```(?:json)?\s*", "", raw).strip() |
| start = text.find("{") |
| if start == -1: |
| raise ValueError( |
| f"No JSON object found in model output{(' (' + context + ')') if context else ''}.\n" |
| f"Raw: {raw[:500]!r}" |
| ) |
| depth = 0 |
| end = -1 |
| for i, ch in enumerate(text[start:], start): |
| if ch == "{": |
| depth += 1 |
| elif ch == "}": |
| depth -= 1 |
| if depth == 0: |
| end = i + 1 |
| break |
| if end == -1: |
| |
| |
| try: |
| repaired = _repair_truncated_json(text[start:]) |
| result = json.loads(repaired) |
| log.warning( |
| "Recovered truncated JSON via repair (%s) — consider raising max_tokens.", |
| context, |
| ) |
| return result |
| except json.JSONDecodeError: |
| raise ValueError( |
| f"Unmatched braces in output ({context}); repair failed. " |
| f"Raw: {raw[:500]!r}" |
| ) |
| try: |
| return json.loads(text[start:end]) |
| except json.JSONDecodeError as exc: |
| raise ValueError( |
| f"JSON parse error ({context}): {exc}\n" |
| f"Extracted: {text[start:end][:500]!r}" |
| ) from exc |
|
|
|
|
| |
| |
| |
|
|
| def _make_client(vllm_url: str, api_key: str) -> Any: |
| try: |
| from openai import OpenAI |
| except ImportError as exc: |
| raise RuntimeError( |
| "openai package is required for RunPod mode. " |
| "Run: pip install openai" |
| ) from exc |
| |
| |
| |
| |
| return OpenAI(base_url=vllm_url, api_key=api_key or "placeholder", max_retries=0) |
|
|
|
|
| def _image_content(path: str) -> dict: |
| """Encode a local JPEG as a base64 data URI for the vllm API.""" |
| with open(path, "rb") as fh: |
| b64 = base64.b64encode(fh.read()).decode() |
| return {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}} |
|
|
|
|
| def pick_frames(frame_paths: list[str], n: int = 3) -> list[str]: |
| if not frame_paths: |
| return [] |
| if len(frame_paths) <= n: |
| return frame_paths |
| indices = [round(i * (len(frame_paths) - 1) / (n - 1)) for i in range(n)] |
| return [frame_paths[i] for i in indices] |
|
|
|
|
| def _chat_with_retry(client: Any, *, max_retries: int = 8, **kwargs: Any) -> Any: |
| """Call chat.completions.create with exponential backoff on transient errors. |
| |
| OpenRouter's shared upstreams (e.g. Alibaba for qwen3-vl) intermittently |
| return 429 "temporarily rate-limited". Without backoff, every window of a |
| video fails and the whole video is skipped, churning the queue uselessly. |
| Here we wait and retry on 429 / 5xx / timeout / connection errors so a |
| rate-limit window just pauses the run instead of burning through videos. |
| Non-transient errors (e.g. 400 bad request) raise immediately. |
| """ |
| delay = 5.0 |
| cap = 90.0 |
| last_exc: Exception | None = None |
| for attempt in range(max_retries): |
| try: |
| return client.chat.completions.create(**kwargs) |
| except Exception as exc: |
| last_exc = exc |
| status = getattr(exc, "status_code", None) |
| msg = str(exc).lower() |
| is_rate = status == 429 or "429" in msg or "rate-limit" in msg or "rate limit" in msg |
| is_5xx = isinstance(status, int) and 500 <= status < 600 |
| is_conn = "timeout" in msg or "connection" in msg or "temporarily" in msg |
| if not (is_rate or is_5xx or is_conn) or attempt == max_retries - 1: |
| raise |
| wait = min(delay * (2 ** attempt), cap) |
| log.warning( |
| "Transient API error (status=%s); retry %d/%d in %.0fs", |
| status if status is not None else "?", attempt + 1, max_retries, wait, |
| ) |
| time.sleep(wait) |
| assert last_exc is not None |
| raise last_exc |
|
|
|
|
| def run_window_inference( |
| client: Any, |
| model_name: str, |
| window: dict, |
| n_frames: int = 3, |
| max_tokens: int = 350, |
| timeout: float = 120.0, |
| ) -> dict: |
| chosen_paths = pick_frames(window["frame_paths"], n_frames) |
| if not chosen_paths: |
| raise ValueError(f"Window {window['window_index']} has no frame_paths.") |
| for p in chosen_paths: |
| if not os.path.exists(p): |
| raise FileNotFoundError(f"Frame file not found: {p}. Run prepare first.") |
|
|
| sub_text = (window.get("subtitle_text") or "").strip() |
| subtitle_block = f'"{sub_text}"' if sub_text else "(no spoken dialogue in this window)" |
| user_text = WINDOW_USER_TEMPLATE.format(subtitle_block=subtitle_block) |
|
|
| content: list[dict] = [_image_content(p) for p in chosen_paths] |
| content.append({"type": "text", "text": user_text}) |
|
|
| response = _chat_with_retry( |
| client, |
| model=model_name, |
| messages=[ |
| {"role": "system", "content": WINDOW_SYSTEM}, |
| {"role": "user", "content": content}, |
| ], |
| max_tokens=max_tokens, |
| temperature=0.0, |
| timeout=timeout, |
| ) |
| raw = response.choices[0].message.content or "" |
| context = f"window {window['window_index']}" |
| result = extract_json(raw, context) |
|
|
| skip = bool(result.get("skip", False)) |
| description = str(result.get("description", "")).strip() |
|
|
| if skip: |
| if not result.get("skip_reason", "").strip(): |
| result["skip_reason"] = "boilerplate end card (auto-detected)" |
| result["description"] = "" |
| log.info(" window %d: SKIP — %s", window["window_index"], result["skip_reason"]) |
| else: |
| if not description: |
| raise ValueError( |
| f"Window {window['window_index']}: empty description without skip=true. " |
| f"Raw: {raw[:300]!r}" |
| ) |
| word_count = len(description.split()) |
| |
| |
| if word_count < 8: |
| raise ValueError( |
| f"Window {window['window_index']}: description suspiciously short " |
| f"({word_count} words). Raw: {description!r}" |
| ) |
| if word_count < 60 or word_count > 130: |
| log.warning(" window %d: %d words (target 60-100)", window["window_index"], word_count) |
| else: |
| log.info(" window %d: %d words OK", window["window_index"], word_count) |
| result["description"] = description |
|
|
| result.setdefault("onscreen_text_places", []) |
| result.setdefault("skip", skip) |
| return result |
|
|
|
|
| def run_summary_inference( |
| client: Any, |
| model_name: str, |
| request: dict, |
| window_results: list[dict], |
| max_tokens: int = 1500, |
| timeout: float = 120.0, |
| ) -> dict: |
| title = request.get("title", "") |
| window_lines = [] |
| sub_lines = [] |
| for req_w, res_w in zip(request["windows"], window_results): |
| if res_w.get("skip"): |
| continue |
| start, end = req_w["start_seconds"], req_w["end_seconds"] |
| desc = res_w.get("description", "").strip() |
| window_lines.append(f"[{start:.0f}s–{end:.0f}s] {desc}") |
| sub = (req_w.get("subtitle_text") or "").strip() |
| if sub: |
| sub_lines.append(f"[{start:.0f}s] {sub}") |
|
|
| if not window_lines: |
| raise ValueError(f"[{request['natural_key']}] All windows skipped; cannot summarise.") |
|
|
| user_text = SUMMARY_USER_TEMPLATE.format( |
| title=title, |
| window_block="\n".join(window_lines), |
| subtitle_block="\n".join(sub_lines) if sub_lines else "(no spoken dialogue)", |
| ) |
|
|
| response = _chat_with_retry( |
| client, |
| model=model_name, |
| messages=[ |
| {"role": "system", "content": SUMMARY_SYSTEM}, |
| {"role": "user", "content": user_text}, |
| ], |
| max_tokens=max_tokens, |
| temperature=0.0, |
| timeout=timeout, |
| ) |
| raw = response.choices[0].message.content or "" |
| result = extract_json(raw, "video summary") |
|
|
| tldr = str(result.get("tldr", "")).strip() |
| if not tldr: |
| raise ValueError(f"[{request['natural_key']}] Summary returned empty tldr. Raw: {raw[:500]!r}") |
| word_count = len(tldr.split()) |
| if word_count < 8: |
| raise ValueError(f"[{request['natural_key']}] tldr suspiciously short ({word_count} words).") |
| if word_count < 60 or word_count > 160: |
| log.warning(" video summary: tldr %d words (target 80-140)", word_count) |
| else: |
| log.info(" video summary: tldr %d words OK", word_count) |
|
|
| result.setdefault("themes", []) |
| result.setdefault("acts", []) |
| result.setdefault("locations", []) |
| return result |
|
|
|
|
| |
| |
| |
|
|
| def process_one_video( |
| *, |
| natural_key: str, |
| language: str, |
| label: str, |
| client: Any, |
| model_name: str, |
| work_dir: str, |
| n_frames: int, |
| no_prepare: bool, |
| no_persist: bool, |
| ) -> None: |
| video_work = os.path.join(work_dir, natural_key) |
| request_path = os.path.join(video_work, "request.json") |
| output_path = os.path.join(video_work, "response.json") |
|
|
| if not no_prepare and not os.path.exists(request_path): |
| log.info("[%s] Running prepare ...", natural_key) |
| _run_prepare(natural_key, language, label, work_dir) |
| if not os.path.exists(request_path): |
| raise FileNotFoundError( |
| f"[{natural_key}] request.json not found at {request_path}. " |
| f"Run: python scripts/scene-index-local.py prepare --keys {natural_key}" |
| ) |
|
|
| with open(request_path, "r", encoding="utf-8") as fh: |
| request = json.load(fh) |
|
|
| log.info( |
| "[%s] %d windows (%s subs) → response.json", |
| natural_key, len(request["windows"]), request.get("subtitle_source", "?"), |
| ) |
|
|
| window_results: list[dict] = [] |
| t_vlm = time.time() |
| for i, window in enumerate(request["windows"]): |
| log.info( |
| " window %d/%d [%ds–%ds] ...", |
| i + 1, len(request["windows"]), |
| window["start_seconds"], window["end_seconds"], |
| ) |
| result = run_window_inference(client, model_name, window, n_frames=n_frames) |
| result["window_index"] = window["window_index"] |
| window_results.append(result) |
|
|
| log.info(" windows done in %.1fs", time.time() - t_vlm) |
| log.info(" running video summary ...") |
| summary = run_summary_inference(client, model_name, request, window_results) |
|
|
| response = { |
| "natural_key": natural_key, |
| "windows": [ |
| { |
| "window_index": r["window_index"], |
| "description": r.get("description", ""), |
| "onscreen_text_places": r.get("onscreen_text_places", []), |
| **({"skip": True, "skip_reason": r.get("skip_reason", "")} |
| if r.get("skip") else {}), |
| } |
| for r in window_results |
| ], |
| "locations": summary.get("locations", []), |
| "video_summary": { |
| "tldr": summary["tldr"], |
| "themes": summary.get("themes", []), |
| "acts": summary.get("acts", []), |
| }, |
| "_vlm_model": model_name, |
| "_generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), |
| } |
| with open(output_path, "w", encoding="utf-8") as fh: |
| json.dump(response, fh, indent=2, ensure_ascii=False) |
| log.info(" wrote %s", output_path) |
|
|
| if not no_persist: |
| log.info("[%s] Running persist ...", natural_key) |
| _run_persist(natural_key, language, work_dir) |
|
|
|
|
| def _run_prepare(natural_key: str, language: str, label: str, work_dir: str) -> None: |
| cmd = [ |
| sys.executable, |
| os.path.join(SCRIPT_DIR, "scene-index-local.py"), |
| "prepare", |
| "--keys", natural_key, |
| "--language", language, |
| "--label", label, |
| "--work-dir", work_dir, |
| ] |
| r = subprocess.run(cmd, capture_output=False) |
| if r.returncode != 0: |
| raise RuntimeError(f"prepare failed for {natural_key} (exit {r.returncode}).") |
|
|
|
|
| def _run_persist(natural_key: str, language: str, work_dir: str) -> None: |
| cmd = [ |
| sys.executable, |
| os.path.join(SCRIPT_DIR, "scene-index-local.py"), |
| "persist", |
| "--keys", natural_key, |
| "--language", language, |
| "--work-dir", work_dir, |
| ] |
| r = subprocess.run(cmd, capture_output=False) |
| if r.returncode != 0: |
| raise RuntimeError( |
| f"persist failed for {natural_key} (exit {r.returncode}). " |
| f"DB is unchanged. Fix the issue before continuing." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def select_next_keys(language: str, n: int, db_path: str) -> list[str]: |
| from catalog_priority import load_cached_catalog |
| from scene_processing import scene_db |
| from scene_processing.exclusions import load_exclusions, should_exclude |
| from scene_processing.index_filter import priority_tier, should_index |
|
|
| catalog = load_cached_catalog(language) |
| exclusions = load_exclusions() |
| with scene_db.open_db(db_path) as conn: |
| done = scene_db.successful_run_keys(conn, language) |
| kept: list[tuple[str, dict]] = [] |
| for key, item in catalog.items(): |
| if not should_index(item): |
| continue |
| excluded, _ = should_exclude(key, item, exclusions) |
| if excluded or key in done: |
| continue |
| kept.append((key, item)) |
| kept.sort(key=lambda kv: (priority_tier(kv[1]), kv[1].get("duration") or 0, kv[0])) |
| return [k for k, _ in kept[:n]] |
|
|
|
|
| |
| |
| |
|
|
| def load_checkpoint(checkpoint_path: str) -> set[str]: |
| """Load set of already-completed keys from checkpoint file.""" |
| if not os.path.exists(checkpoint_path): |
| return set() |
| with open(checkpoint_path, "r", encoding="utf-8") as fh: |
| data = json.load(fh) |
| return set(data.get("completed", [])) |
|
|
|
|
| def save_checkpoint(checkpoint_path: str, completed: set[str]) -> None: |
| with open(checkpoint_path, "w", encoding="utf-8") as fh: |
| json.dump({"completed": sorted(completed), "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S")}, |
| fh, indent=2) |
|
|
|
|
| |
| |
| |
|
|
| def send_webhook(url: str, payload: dict) -> None: |
| if not url: |
| return |
| try: |
| import requests |
| r = requests.post(url, json=payload, timeout=10) |
| log.info("Webhook sent: HTTP %d", r.status_code) |
| except Exception as exc: |
| log.warning("Webhook failed (non-fatal): %s", exc) |
|
|
|
|
| |
| |
| |
|
|
| def _data_root() -> str: |
| from runtime_paths import get_data_root |
| return get_data_root() |
|
|
|
|
| def cmd_process(args: argparse.Namespace) -> int: |
| from runtime_paths import ensure_runtime_dirs |
| ensure_runtime_dirs() |
|
|
| work_dir = args.work_dir or os.path.join(_data_root(), "scene-local-work") |
| db_path = args.db or os.path.join(_data_root(), "scene_index.db") |
| os.makedirs(work_dir, exist_ok=True) |
|
|
| checkpoint_path = args.checkpoint or os.path.join(work_dir, "runpod_checkpoint.json") |
| completed = load_checkpoint(checkpoint_path) |
| log.info("Checkpoint: %d already completed", len(completed)) |
|
|
| |
| if args.keys: |
| all_keys = [k.strip() for k in args.keys.split(",") if k.strip()] |
| else: |
| log.info("Selecting next %d video(s) from priority queue ...", args.next) |
| all_keys = select_next_keys(args.language, args.next, db_path) |
| if not all_keys: |
| print("Nothing to process — all priority videos are already indexed.") |
| return 0 |
|
|
| |
| keys = [k for k in all_keys if k not in completed] |
| if not keys: |
| print(f"All {len(all_keys)} selected video(s) are already in the checkpoint. Done.") |
| return 0 |
|
|
| log.info( |
| "Processing %d video(s) (%d already done, %d remaining)", |
| len(all_keys), len(all_keys) - len(keys), len(keys), |
| ) |
|
|
| |
| client = _make_client(args.vllm_url, args.api_key) |
|
|
| |
| |
| |
| try: |
| import urllib.request |
| base = args.vllm_url.rstrip("/") |
| req = urllib.request.Request( |
| f"{base}/models", |
| headers={"Authorization": f"Bearer {args.api_key}"}, |
| ) |
| with urllib.request.urlopen(req, timeout=10) as resp: |
| data = json.loads(resp.read()) |
| |
| items = data if isinstance(data, list) else data.get("data", []) |
| available = [m.get("id", "") for m in items if isinstance(m, dict)] |
| log.info("API connected. Models found: %d", len(available)) |
| if args.vllm_model not in available: |
| log.warning( |
| "Model '%s' not in server list — proceeding anyway " |
| "(provider may use a different alias).", |
| args.vllm_model, |
| ) |
| else: |
| log.info("Model '%s' confirmed available.", args.vllm_model) |
| except Exception as exc: |
| |
| |
| |
| log.warning( |
| "Could not verify API connection (%s). " |
| "Proceeding — first inference call will confirm if the key works.", |
| exc, |
| ) |
|
|
| batch_start = time.time() |
| failed: list[str] = [] |
|
|
| for i, key in enumerate(keys, 1): |
| print(f"\n[{i}/{len(keys)}] {key}") |
| try: |
| process_one_video( |
| natural_key=key, |
| language=args.language, |
| label=args.label, |
| client=client, |
| model_name=args.vllm_model, |
| work_dir=work_dir, |
| n_frames=args.frames, |
| no_prepare=args.no_prepare, |
| no_persist=not args.persist, |
| ) |
| completed.add(key) |
| save_checkpoint(checkpoint_path, completed) |
| print(f" [{key}] DONE — checkpoint saved") |
|
|
| |
| if args.webhook_url and i % args.batch_size == 0: |
| elapsed = time.time() - batch_start |
| send_webhook(args.webhook_url, { |
| "event": "batch_complete", |
| "completed": i, |
| "total": len(keys), |
| "failed": len(failed), |
| "elapsed_seconds": round(elapsed), |
| "latest_key": key, |
| }) |
|
|
| except Exception as exc: |
| log.error("[%s] FAILED: %s", key, exc) |
| failed.append(key) |
| if args.stop_on_error: |
| print(f"\nStopping on first error (--stop-on-error). Failed: {key}") |
| if args.webhook_url: |
| send_webhook(args.webhook_url, { |
| "event": "stopped_on_error", |
| "failed_key": key, |
| "error": str(exc), |
| "completed_before_stop": i - 1, |
| }) |
| return 1 |
|
|
| elapsed = time.time() - batch_start |
| print( |
| f"\n{'All' if not failed else str(len(keys) - len(failed)) + '/' + str(len(keys))} " |
| f"video(s) processed in {elapsed:.0f}s. " |
| f"{'Failed: ' + ', '.join(failed) if failed else 'No failures.'}" |
| ) |
|
|
| if args.webhook_url: |
| send_webhook(args.webhook_url, { |
| "event": "run_complete", |
| "processed": len(keys) - len(failed), |
| "failed": len(failed), |
| "failed_keys": failed, |
| "elapsed_seconds": round(elapsed), |
| }) |
|
|
| return 1 if failed else 0 |
|
|
|
|
| |
| |
| |
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| ) |
| sub = parser.add_subparsers(dest="command", required=True) |
|
|
| p_proc = sub.add_parser( |
| "process", |
| help="Run vllm-backed VLM on videos and persist to scene_index.db", |
| ) |
| p_proc.add_argument("--keys", default=None, |
| help="Comma-separated natural_keys. If omitted, uses --next.") |
| p_proc.add_argument("--next", type=int, default=50, |
| help="Pick next N priority videos (default 50)") |
| p_proc.add_argument("--language", default="E") |
| p_proc.add_argument("--label", default="720p") |
| p_proc.add_argument("--vllm-url", default="http://localhost:8000/v1", |
| help="vllm OpenAI-compatible base URL (default: http://localhost:8000/v1)") |
| p_proc.add_argument("--vllm-model", default="Qwen/Qwen2.5-VL-72B-Instruct", |
| help="Model name as registered in vllm (default: Qwen/Qwen2.5-VL-72B-Instruct)") |
| p_proc.add_argument("--api-key", default="", |
| help="API key for vllm (usually empty for local deployments)") |
| p_proc.add_argument("--frames", type=int, default=3, |
| help="Frames to send per window (default 3)") |
| p_proc.add_argument("--batch-size", type=int, default=50, |
| help="Send webhook every N videos (default 50)") |
| p_proc.add_argument("--persist", action="store_true", default=True, |
| help="Run persist after each video (default: True)") |
| p_proc.add_argument("--no-persist", dest="persist", action="store_false", |
| help="Skip persist step (write response.json only)") |
| p_proc.add_argument("--no-prepare", action="store_true", |
| help="Skip prepare; fail if request.json is missing") |
| p_proc.add_argument("--stop-on-error", action="store_true", |
| help="Halt on first video failure (default: continue)") |
| p_proc.add_argument("--checkpoint", default=None, |
| help="Path to checkpoint JSON (default: <work-dir>/runpod_checkpoint.json)") |
| p_proc.add_argument("--webhook-url", default=None, |
| help="POST progress updates here after each batch and at completion") |
| p_proc.add_argument("--work-dir", default=None) |
| p_proc.add_argument("--db", default=None) |
| p_proc.set_defaults(func=cmd_process) |
|
|
| args = parser.parse_args(argv) |
| return args.func(args) |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|