455 lines
21 KiB
Python
Executable file
455 lines
21 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
"""
|
||
08-video-analysis.py — Sherlock Holmes-style deep video analysis with Gemini 3.1 Pro
|
||
|
||
For each .mp4 in /Users/guto/ufo/raw/videos/:
|
||
1. Upload to Gemini Files API
|
||
2. Wait for ACTIVE state
|
||
3. Call gemini-3.1-pro-preview with structured Sherlock prompt
|
||
4. Receive JSON containing:
|
||
- audio_transcript_verbatim (original language, with timestamps)
|
||
- vision_description (rich English description, frame-by-frame)
|
||
- vision_description_pt_br
|
||
- entities_extracted (people/voices, organizations, locations, equipment, UAP objects)
|
||
- uap_observations (shape, color, motion descriptors, sensor info, kinematics)
|
||
- timeline (events with timestamps in mm:ss)
|
||
- anomalies (sensor artifacts vs candidate phenomena, with Locard-style reasoning)
|
||
- sherlock_observations (what Holmes/Poirot/Dupin would notice — non-obvious details)
|
||
- classification_markings, redactions (visible on screen)
|
||
- confidence_band per major claim
|
||
5. Save raw JSON to processing/video-analysis/<video-id>.json
|
||
6. Write markdown to wiki/videos/<video-id>.md with bilingual frontmatter + body
|
||
|
||
Idempotent: skips videos whose .md + .json already exist (use --force to redo).
|
||
|
||
Usage:
|
||
./08-video-analysis.py # process all videos in raw/videos/
|
||
./08-video-analysis.py --video DOD_111688970.mp4 # single file
|
||
./08-video-analysis.py --max-files 3 # cap for testing
|
||
./08-video-analysis.py --model gemini-3.1-flash-lite # cheaper fallback
|
||
./08-video-analysis.py --force # re-process even if output exists
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import time
|
||
import unicodedata
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
try:
|
||
from google import genai
|
||
from google.genai import types as genai_types
|
||
except ImportError:
|
||
sys.stderr.write("Missing google-genai. Run: pip3 install google-genai\n")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError:
|
||
sys.stderr.write("Missing pyyaml. Run: pip3 install pyyaml\n")
|
||
sys.exit(1)
|
||
|
||
|
||
UFO_ROOT = Path("/Users/guto/ufo")
|
||
VIDEOS_DIR = UFO_ROOT / "raw" / "videos"
|
||
VIDEO_ANALYSIS_DIR = UFO_ROOT / "processing" / "video-analysis"
|
||
WIKI_VIDEOS_DIR = UFO_ROOT / "wiki" / "videos"
|
||
LOG_PATH = UFO_ROOT / "wiki" / "log.md"
|
||
|
||
DEFAULT_MODEL = "gemini-3.1-pro-preview"
|
||
FALLBACK_MODELS = ["gemini-3-pro-preview", "gemini-3.1-flash-lite"]
|
||
SCHEMA_VERSION = "0.1.0"
|
||
WIKI_VERSION = "0.1.0"
|
||
|
||
|
||
SHERLOCK_VIDEO_PROMPT = """You are an evidence officer in the Investigation Bureau, applying the combined methodologies of Sherlock Holmes (observation + elimination), Hercule Poirot (psychology of behavior), Auguste Dupin (ratiocination, hidden-in-plain-sight), and Edmond Locard (trace evidence, chain of custody) to a UAP/UFO video released by the U.S. Department of War at war.gov/ufo.
|
||
|
||
Your task: extract EVERYTHING from this video — visual content, audio, transcription, contextual signals, equipment, persons, and any anomalies. Be exhaustive. A great detective notices what others miss.
|
||
|
||
Output a SINGLE JSON object (no markdown fence, no preamble, no commentary) matching this exact schema:
|
||
|
||
{
|
||
"video_overview": {
|
||
"duration_seconds": <float>,
|
||
"primary_subject": "what the video is fundamentally about, one sentence",
|
||
"camera_perspective": "cockpit | ground | aerial | satellite | unknown",
|
||
"sensor_type": "visual_eo | infrared_FLIR | radar_screen | mixed | unknown",
|
||
"platform_inferred": "F/A-18 | helicopter | naval ship | satellite | unknown — based on cockpit layout, HUD elements, screen overlays etc.",
|
||
"primary_language_spoken": "en | pt | es | other | none"
|
||
},
|
||
|
||
"audio_transcript_verbatim": [
|
||
{
|
||
"t_start": "mm:ss",
|
||
"t_end": "mm:ss",
|
||
"speaker": "pilot_1 | pilot_2 | unknown_male | unknown_female | radio | narrator | n/a",
|
||
"text": "EXACT VERBATIM TRANSCRIPTION in the original language, preserve all 'uh', stutters, military jargon, callsigns. Do NOT translate.",
|
||
"confidence": "high | medium | low"
|
||
}
|
||
],
|
||
|
||
"vision_description": "Comprehensive English narrative of what is visible on screen, with timestamps. Frame-by-frame for key moments. Describe HUD overlays, sensor readouts, on-screen text, classification markings, redactions, target lock indicators, altitude/heading/airspeed values visible, any UAP morphology and motion (track its path with timestamps). 8-15 sentences for a typical 1-3 minute clip.",
|
||
"vision_description_pt_br": "Same content as vision_description, translated to Brazilian Portuguese (pt-br). Brazilian vocabulary and spelling. Preserve UTF-8 accents. Keep verbatim quotes from audio/screen in original language.",
|
||
|
||
"classification_markings_visible": [
|
||
{"t_start": "mm:ss", "t_end": "mm:ss", "level": "UNCLASSIFIED | CUI | CONFIDENTIAL | SECRET | TOP SECRET", "caveats": ["NOFORN", "ORCON", ...], "location_on_screen": "header_banner | corner | watermark | stamp"}
|
||
],
|
||
|
||
"redactions_visible": [
|
||
{"t_start": "mm:ss", "t_end": "mm:ss", "code": "(b)(1) 1.4(a) | (b)(3) | (b)(6) | other_or_unknown", "description": "what is being blacked/blurred (face, callsign, location, sensor frequency, etc.)"}
|
||
],
|
||
|
||
"entities_extracted": {
|
||
"people": [{"label": "Pilot 1 | Air Crew | Officer X", "role": "primary observer | radio operator | passenger | narrator | unknown", "voice_only": true, "first_appearance": "mm:ss"}],
|
||
"organizations": [{"name": "USS Nimitz | VFA-41 | AARO | ...", "evidence_for": "patch visible | radio callsign | hull number"}],
|
||
"locations": [{"name": "Pacific Ocean off San Diego | Strait of Hormuz | ...", "evidence_for": "coordinates on HUD | named in audio | identifiable landmark", "coordinates": {"lat": null, "lon": null, "raw_text": "..."}}],
|
||
"events": [{"label": "UAP intercept during routine patrol", "date": "YYYY-MM-DD | YYYY | NA"}],
|
||
"uap_objects": [{"shape": "sphere | disc | triangle | cylinder | cube | elongated-ellipsoid | cigar | irregular | unknown", "color": "...", "size_estimate": "...", "motion_descriptors": ["hover", "instantaneous-direction-change", "descent-from-X-to-Y", "no-visible-exhaust"]}],
|
||
"vehicles": [{"name": "F/A-18 Super Hornet | AH-64 | ...", "class": "aircraft | ship | submarine | spacecraft | satellite | ground | other"}],
|
||
"equipment_visible": [{"name": "ATFLIR pod | AESA radar | binoculars | EO/IR turret", "purpose": "sensor used to observe UAP"}],
|
||
"operations": [{"name": "Range Fouler | Operation X", "type": "military-operation | reporting-protocol | research-program | task-force | other"}],
|
||
"concepts": [{"name": "FLIR | Mark I eyeball | SECRET//NOFORN | ...", "class": "jargon | legal-instrument | sensor-term | scientific-term | other"}]
|
||
},
|
||
|
||
"uap_observation_fields": {
|
||
"first_visible_at": "mm:ss",
|
||
"last_visible_at": "mm:ss",
|
||
"duration_visible_seconds": <int>,
|
||
"shape": "sphere | disc | triangle | cylinder | cube | elongated-ellipsoid | cigar | irregular | unknown",
|
||
"color": "metallic | white | dark | luminous | ...",
|
||
"size_estimate": "1-3 m | 10 m | car-sized | etc.",
|
||
"altitude_ft": <int or null>,
|
||
"speed_kts": <int or null or "supersonic">,
|
||
"bearing_deg": <int or null>,
|
||
"distance_nm": <float or null>,
|
||
"coordinates": {"lat": null, "lon": null, "raw_text": "..."},
|
||
"maneuver_descriptors": ["hover", "instantaneous-direction-change", ...],
|
||
"sensor_observations": [{"sensor": "ATFLIR", "type": "infrared", "duration_min": "..."}],
|
||
"kinematic_anomalies": "describe any motion that defies known physics: no inertia, no sonic boom at high speed, instantaneous turns, etc."
|
||
},
|
||
|
||
"timeline": [
|
||
{"t": "mm:ss", "event": "Operator says 'There's a whole fleet of them, look on the SA'", "kind": "audio_quote | visual_event | sensor_event | redaction | classification_change"}
|
||
],
|
||
|
||
"anomalies_detected": [
|
||
{
|
||
"kind": "kinematic | sensor_artifact | atmospheric | optical_illusion | hoax_indicator | unredacted_slip | inconsistency",
|
||
"description": "what is anomalous",
|
||
"evidence": "at timestamp mm:ss the object does X while expected Y",
|
||
"candidate_explanations": ["prosaic-advanced-tech", "sensor-glare", "atmospheric-refraction", "extraterrestrial", "hoax", ...],
|
||
"confidence_band": "high | medium | low | speculation"
|
||
}
|
||
],
|
||
|
||
"sherlock_observations": [
|
||
{
|
||
"detective_lens": "holmes | poirot | dupin | locard",
|
||
"observation": "Non-obvious detail a regular viewer would miss. E.g., 'the operator's voice quaver at 1:42 increases at the moment the SA reading changes — emotional reaction precedes the sensor change by 0.5s, suggesting the operator saw the target visually before the radar updated.'",
|
||
"implication": "why it matters investigatively",
|
||
"confidence_band": "high | medium | low | speculation"
|
||
}
|
||
],
|
||
|
||
"executive_summary_en": "3-5 sentence English summary of WHAT happened in this video, suitable for a chat citation in the future Sherlock UI.",
|
||
"executive_summary_pt_br": "Same summary in Brazilian Portuguese (pt-br).",
|
||
|
||
"quality_signals": {
|
||
"video_quality_overall": "high | medium | low",
|
||
"audio_quality_overall": "high | medium | low | none",
|
||
"redaction_density": "none | light | heavy | full-blackout",
|
||
"completeness": "complete | truncated | partial",
|
||
"extraction_confidence": "high | medium | low"
|
||
},
|
||
|
||
"flags": ["sensitive-content", "audio-only", "redaction-heavy", "low-resolution", "interlaced-artifacts", "thermal-only", "no-audio"]
|
||
}
|
||
|
||
Rules:
|
||
- Output ONLY the JSON. No fence, no preamble.
|
||
- Empty arrays for not-applicable fields; null for unknown scalars. Never omit keys.
|
||
- ALL extracted text (transcript, on-screen text, callsigns) stays in ORIGINAL source language. Do NOT translate.
|
||
- ONLY `vision_description_pt_br` and `executive_summary_pt_br` are translations — Brazilian Portuguese (pt-br), NOT European Portuguese. Preserve UTF-8 accents.
|
||
- Verbatim quotes from audio INSIDE narrative fields stay in original language; only the surrounding narration is translated.
|
||
- Be EXHAUSTIVE in sherlock_observations — aim for 5-15 observations, including subtle audio cues, sensor metadata, behavioral signals.
|
||
- For anomalies, list ≥3 candidate explanations including a mundane one (sensor artifact, parallax, atmospheric).
|
||
- If duration_seconds = 0 or no content, still return the JSON with empty arrays and flags=["empty-or-corrupt"].
|
||
"""
|
||
|
||
|
||
def utc_now_iso() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
|
||
def filename_to_video_id(filename: str) -> str:
|
||
base = filename.rsplit(".", 1)[0]
|
||
nfkd = unicodedata.normalize("NFKD", base)
|
||
ascii_str = "".join(c for c in nfkd if not unicodedata.combining(c))
|
||
lower = ascii_str.lower()
|
||
replaced = re.sub(r"[^a-z0-9-]", "-", lower)
|
||
collapsed = re.sub(r"-+", "-", replaced).strip("-")
|
||
if collapsed and collapsed[0].isdigit():
|
||
collapsed = "vid-" + collapsed
|
||
return collapsed
|
||
|
||
|
||
def sha256_file(p: Path) -> str:
|
||
h = hashlib.sha256()
|
||
with open(p, "rb") as fh:
|
||
for chunk in iter(lambda: fh.read(65536), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
def upload_and_wait(client, video_path: Path, poll_interval: float = 2.0, timeout: int = 600):
|
||
"""Upload video to Files API and wait until ACTIVE."""
|
||
print(f" uploading {video_path.name} ({video_path.stat().st_size/1024/1024:.1f} MB)…", flush=True)
|
||
t0 = time.time()
|
||
f = client.files.upload(file=str(video_path))
|
||
while f.state.name == "PROCESSING":
|
||
if time.time() - t0 > timeout:
|
||
raise TimeoutError(f"upload still PROCESSING after {timeout}s")
|
||
time.sleep(poll_interval)
|
||
f = client.files.get(name=f.name)
|
||
if f.state.name != "ACTIVE":
|
||
raise RuntimeError(f"file state is {f.state.name} (not ACTIVE) — cannot use")
|
||
print(f" ✓ file ready ({time.time() - t0:.1f}s upload+process)", flush=True)
|
||
return f
|
||
|
||
|
||
def call_gemini_for_video(client, video_file, model: str, attempt: int = 1, timeout: int = 240):
|
||
"""Generate Sherlock analysis for a video file. Retries with fallback models on failure.
|
||
Uses a thread-based timeout to avoid SDK hangs (genai sometimes hangs forever on rate limit)."""
|
||
import concurrent.futures
|
||
|
||
def _call():
|
||
return client.models.generate_content(
|
||
model=model,
|
||
contents=[video_file, SHERLOCK_VIDEO_PROMPT],
|
||
config=genai_types.GenerateContentConfig(
|
||
response_mime_type="application/json",
|
||
temperature=0.2,
|
||
max_output_tokens=32768,
|
||
),
|
||
)
|
||
|
||
try:
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
||
future = ex.submit(_call)
|
||
try:
|
||
resp = future.result(timeout=timeout)
|
||
except concurrent.futures.TimeoutError:
|
||
raise RuntimeError(f"Gemini hung >{timeout}s — treating as failure")
|
||
return resp.text, model
|
||
except Exception as e:
|
||
if attempt < len(FALLBACK_MODELS):
|
||
next_model = FALLBACK_MODELS[attempt - 1]
|
||
print(f" ⚠ {model} failed ({e}); falling back to {next_model}", flush=True)
|
||
return call_gemini_for_video(client, video_file, next_model, attempt + 1, timeout)
|
||
raise
|
||
|
||
|
||
def render_video_md(
|
||
*,
|
||
video_id: str,
|
||
video_path: Path,
|
||
analysis: dict,
|
||
meta: dict,
|
||
now_iso: str,
|
||
) -> str:
|
||
"""Render wiki/videos/<video-id>.md (bilingual)."""
|
||
frontmatter = {
|
||
"schema_version": SCHEMA_VERSION,
|
||
"type": "video",
|
||
"video_id": video_id,
|
||
"original_filename": video_path.name,
|
||
"raw_path": f"../../raw/videos/{video_path.name}",
|
||
"sha256": sha256_file(video_path),
|
||
"size_bytes": video_path.stat().st_size,
|
||
"collection": "DOW-UAP-Video",
|
||
"vision_model": meta.get("model"),
|
||
"analyzed_at": now_iso,
|
||
# Promote video_overview
|
||
**{f"overview_{k}": v for k, v in (analysis.get("video_overview") or {}).items()},
|
||
"uap_observation_fields": analysis.get("uap_observation_fields"),
|
||
"classification_markings_visible": analysis.get("classification_markings_visible") or [],
|
||
"redactions_visible": analysis.get("redactions_visible") or [],
|
||
"entities_extracted": analysis.get("entities_extracted") or {},
|
||
"timeline": analysis.get("timeline") or [],
|
||
"anomalies_detected": analysis.get("anomalies_detected") or [],
|
||
"sherlock_observations": analysis.get("sherlock_observations") or [],
|
||
"audio_transcript_verbatim": analysis.get("audio_transcript_verbatim") or [],
|
||
"vision_description": analysis.get("vision_description", ""),
|
||
"vision_description_pt_br": analysis.get("vision_description_pt_br", ""),
|
||
"executive_summary_en": analysis.get("executive_summary_en", ""),
|
||
"executive_summary_pt_br": analysis.get("executive_summary_pt_br", ""),
|
||
"quality_signals": analysis.get("quality_signals") or {},
|
||
"flags": analysis.get("flags") or [],
|
||
"last_ingest": now_iso,
|
||
"wiki_version": WIKI_VERSION,
|
||
}
|
||
yaml_str = yaml.dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
||
|
||
body = f"""# Video Analysis — {video_id}
|
||
|
||
> Source: `{video_path.name}` · Gemini model: `{meta.get("model")}` · Analyzed: {now_iso}
|
||
|
||
## Executive Summary (EN)
|
||
|
||
{analysis.get("executive_summary_en", "").strip()}
|
||
|
||
## Sumário Executivo (PT-BR)
|
||
|
||
{analysis.get("executive_summary_pt_br", "").strip()}
|
||
|
||
## Vision Description (EN)
|
||
|
||
{analysis.get("vision_description", "").strip()}
|
||
|
||
## Descrição Vision (PT-BR)
|
||
|
||
{analysis.get("vision_description_pt_br", "").strip()}
|
||
|
||
## Audio Transcript (verbatim, original language)
|
||
|
||
"""
|
||
for seg in (analysis.get("audio_transcript_verbatim") or []):
|
||
body += f"- **[{seg.get('t_start','?')}–{seg.get('t_end','?')}] {seg.get('speaker','?')}**: {seg.get('text','')} _(confidence: {seg.get('confidence','?')})_\n"
|
||
|
||
body += "\n## Sherlock Observations\n\n"
|
||
for obs in (analysis.get("sherlock_observations") or []):
|
||
body += f"- **[{obs.get('detective_lens','?')}]** {obs.get('observation','')}\n - _Implication:_ {obs.get('implication','')}\n - _Confidence:_ `{obs.get('confidence_band','?')}`\n\n"
|
||
|
||
body += "## Anomalies Detected\n\n"
|
||
for a in (analysis.get("anomalies_detected") or []):
|
||
body += f"- **{a.get('kind','?')}**: {a.get('description','')}\n - _Evidence:_ {a.get('evidence','')}\n - _Candidates:_ {', '.join(a.get('candidate_explanations', []))}\n - _Confidence:_ `{a.get('confidence_band','?')}`\n\n"
|
||
|
||
return f"---\n{yaml_str}---\n\n{body}"
|
||
|
||
|
||
def process_video(client, video_path: Path, model: str, force: bool = False) -> bool:
|
||
video_id = filename_to_video_id(video_path.name)
|
||
json_out = VIDEO_ANALYSIS_DIR / f"{video_id}.json"
|
||
md_out = WIKI_VIDEOS_DIR / f"{video_id}.md"
|
||
|
||
if not force and json_out.exists() and md_out.exists():
|
||
print(f" skip {video_id} (already processed)", flush=True)
|
||
return True
|
||
|
||
json_out.parent.mkdir(parents=True, exist_ok=True)
|
||
md_out.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
print(f"\n=== {video_path.name} → {video_id} ===", flush=True)
|
||
t0 = time.time()
|
||
try:
|
||
video_file = upload_and_wait(client, video_path)
|
||
except Exception as e:
|
||
print(f" ✗ upload failed: {e}", flush=True)
|
||
return False
|
||
|
||
print(f" calling {model} for Sherlock analysis…", flush=True)
|
||
try:
|
||
text, model_used = call_gemini_for_video(client, video_file, model)
|
||
except Exception as e:
|
||
print(f" ✗ generation failed: {e}", flush=True)
|
||
return False
|
||
|
||
# Strip optional fence
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||
text = re.sub(r"\s*```$", "", text)
|
||
try:
|
||
analysis = json.loads(text)
|
||
except json.JSONDecodeError as e:
|
||
print(f" ✗ JSON parse failed: {e}", flush=True)
|
||
# Save raw output anyway for inspection
|
||
json_out.with_suffix(".raw.txt").write_text(text, encoding="utf-8")
|
||
return False
|
||
|
||
meta = {"model": model_used, "duration_seconds": round(time.time() - t0, 1)}
|
||
json_out.write_text(
|
||
json.dumps({"analysis": analysis, "meta": meta}, indent=2, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
md = render_video_md(
|
||
video_id=video_id,
|
||
video_path=video_path,
|
||
analysis=analysis,
|
||
meta=meta,
|
||
now_iso=utc_now_iso(),
|
||
)
|
||
md_out.write_text(md, encoding="utf-8")
|
||
|
||
# Clean up uploaded file to free quota
|
||
try:
|
||
client.files.delete(name=video_file.name)
|
||
except Exception:
|
||
pass
|
||
|
||
elapsed = time.time() - t0
|
||
print(f" ✓ {video_id} done ({elapsed:.1f}s)", flush=True)
|
||
return True
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Sherlock-style video analysis via Gemini 3.1 Pro.")
|
||
g = ap.add_mutually_exclusive_group()
|
||
g.add_argument("--video", help="single video filename in raw/videos/")
|
||
g.add_argument("--all", action="store_true", help="process all videos (default)")
|
||
ap.add_argument("--max-files", type=int, default=None, help="cap number of videos (for testing)")
|
||
ap.add_argument("--model", default=DEFAULT_MODEL, help=f"Gemini model (default {DEFAULT_MODEL})")
|
||
ap.add_argument("--force", action="store_true", help="reprocess existing outputs")
|
||
ap.add_argument("--sort-by-size", action="store_true", help="process smallest videos first (for cheap testing)")
|
||
args = ap.parse_args()
|
||
|
||
api_key = os.environ.get("GEMINI_API_KEY")
|
||
if not api_key:
|
||
sys.stderr.write("GEMINI_API_KEY not set\n")
|
||
sys.exit(2)
|
||
|
||
client = genai.Client(api_key=api_key)
|
||
|
||
if args.video:
|
||
v = VIDEOS_DIR / args.video
|
||
if not v.exists():
|
||
sys.stderr.write(f"Video not found: {v}\n"); sys.exit(1)
|
||
videos = [v]
|
||
else:
|
||
videos = sorted(VIDEOS_DIR.glob("*.mp4"))
|
||
if args.sort_by_size:
|
||
videos.sort(key=lambda p: p.stat().st_size)
|
||
|
||
if args.max_files:
|
||
videos = videos[: args.max_files]
|
||
|
||
print(f"Processing {len(videos)} video(s) with model {args.model}")
|
||
ok = 0
|
||
fail = []
|
||
for v in videos:
|
||
if process_video(client, v, args.model, force=args.force):
|
||
ok += 1
|
||
else:
|
||
fail.append(v.name)
|
||
|
||
print(f"\nDone. ok={ok}, failed={len(fail)}")
|
||
if fail:
|
||
print("Failed:", fail)
|
||
|
||
if ok > 0:
|
||
with open(LOG_PATH, "a", encoding="utf-8") as fh:
|
||
fh.write(
|
||
f"\n## {utc_now_iso()} — VIDEO ANALYSIS (Gemini Phase 4)\n"
|
||
f"- operator: archivist + evidence-officer\n- script: scripts/08-video-analysis.py\n"
|
||
f"- model: {args.model}\n- videos_ok: {ok}\n- videos_failed: {len(fail)}\n"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|