
Video Understanding
- 28 installs
- 438 repo stars
- Updated July 26, 2026
- worldwonderer/video-recap-skills
Helps with ai & agent building tasks during AI-assisted development.
About
video-understanding is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- video-understanding
- AI & Agent Building
- AI-coding skill
Video Understanding by the numbers
- 28 all-time installs (skills.sh)
- +3 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #9,505 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/worldwonderer/video-recap-skills --skill video-understandingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| repo stars | ★ 438 |
| Last updated | July 26, 2026 |
| Repository | worldwonderer/video-recap-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
What this does
Turns a source video into an understanding index an agent (or a downstream stage) can read: 1. Scene detection — scenes.json (cut points, durations) + junk-scene filtering. 2. Frame extraction — sampled frames for the visual analysis. 3. ASR — asr_result.json (timestamped dialogue) via MiMo mimo-v2.5-asr. 4. Silence detection — silence_periods.json (quiet windows, has_speech flag). 5. VLM analysis — vlm_analysis.json (per-scene description, depth analysis, frame_facts). 6. Timeline fusion + brief — timeline_fusion.json, asr_writing_chunks.json, agent_narration_brief.md.
Stateless: reusable stages are skipped only when their output and provenance sidecar match the current source video plus output-affecting settings. --force recomputes.
Requirements
# ffmpeg: brew install ffmpeg | apt install ffmpeg | choco install ffmpeg
export MIMO_API_KEY=*** # one key drives ASR (mimo-v2.5-asr) + VLM (mimo-v2.5)ASR uses MiMo mimo-v2.5-asr; pass --skip-asr to skip dialogue transcription. The full understanding run still requires MIMO_API_KEY for VLM scene analysis. Optional MiMo scene-chunk video understanding: --mimo-video-overview.
If work_dir/background_research.json exists (story research the agent did first, see references/research-guide.md), its synopsis and named characters are folded into the VLM context, so scene descriptions can name people and read scenes with plot knowledge. Combine with --context for a quick inline hint.
Run
python3 scripts/understand.py <video> --work-dir <work_dir> \
[--context "节目名/角色名"] [--scene-threshold 0.1] [--skip-asr] [--mimo-video-overview] [--force]Output contract
| File | Content |
|---|---|
scenes.json | scene cut list (start/end/duration) |
asr_result.json | [{start, end, text}] timestamped transcript |
vlm_analysis.json | per-scene description / depth / frame_facts |
silence_periods.json | [{start, end, duration, has_speech}] quiet windows |
timeline_fusion.json | VLM + ASR + silence overlap, unified timeline |
asr_writing_chunks.json | ASR split at sentence boundaries, scene-aligned |
agent_narration_brief.md | the human/agent-facing writing brief (read this first) |
Downstream, video-script reads the brief + index to write narration.json.
References
- Background research before writing:
references/research-guide.md(writesbackground_research.json). - Output JSON shapes:
references/data-schema.md.
What this skill does NOT do
- Does NOT write narration / 解说词 or score it — that is video-script.
- Does NOT cut, edit, voice, or render video.
- Does NOT invent plot the signal doesn't support — it emits a substrate warning when ASR/VLM are thin, rather than fabricating.
- Does NOT publish or schedule anything; it writes artifacts to work_dir and stops.
数据格式(中间 JSON)
所有中间文件均在 pipeline 工作目录(work_dir/)下。
vlm_analysis.json
每场景的 VLM 分析结果,数组格式:
[
{
"scene_id": 1,
"start": 5.0,
"end": 15.0,
"description": "男子闯入房间",
"depth_analysis": "角色情绪分析...",
"frame_facts": {
"5.0": ["男子闯入房间, 头发蓬乱表情紧张"],
"10.0": ["男子俯身盯着床上男孩, 男孩睁眼惊醒"]
}
}
]| 字段 | 类型 | 说明 |
|---|---|---|
scene_id | int | 场景编号 |
start | float | 开始时间(秒) |
end | float | 结束时间(秒) |
description | string | 画面简述(≤80字) |
depth_analysis | string | 深层分析(情绪/关系/潜台词) |
frame_facts | object | 帧级事实,key 为时间戳字符串 |
asr_result.json
语音转文字结果:
[
{"start": 0.0, "end": 3.5, "text": "What are you doing here?"}
]asr_writing_chunks.json
由 CLI 在生成 agent_narration_brief.md 时自动写出。它把长 ASR 按句子边界拆成适合 Agent 消化的语义块;中文按字符计数,非 CJK 文本按词数计数,并尽量保留 scene 对齐。
[
{
"chunk_id": 0,
"start": 0.0,
"end": 28.5,
"scene_ids": [0, 1],
"char_count": 642,
"text": "第一段对白……",
"segments": [
{"start": 0.0, "end": 3.5, "text": "第一句。", "char_count": 4}
]
}
]silence_periods.json
静音窗口列表(适合放解说):
[
{"start": 2.0, "end": 8.5, "duration": 6.5, "has_speech": false}
]has_speech 标记该窗口是否与检测到的 ASR 语音重叠;下游(pipeline / narration)只把 has_speech=false 的窗口当作可放解说的安静窗口。
timeline_fusion.json
由 CLI 在生成 brief 时自动写出。它把 VLM 场景、ASR 对白和静音窗口按时间轴 overlap 合并,减少写稿时手工推断“这一幕有没有对白/能不能插解说”的成本。
[
{
"scene_id": 0,
"time_range": [0.0, 10.0],
"visual_description": "两人在门口对峙",
"depth_analysis": "关系紧张",
"frame_facts": {"1.0": ["女子回头"]},
"dialogue_segments": [
{"start": 2.0, "end": 4.0, "overlap_seconds": 2.0, "text": "你到底是谁"}
],
"dialogue_overlap_seconds": 2.0,
"narration_slots": [
{"start": 5.0, "end": 7.0, "duration": 2.0, "char_budget": 5}
],
"recommended_mode": "ducked-bed"
}
]narration.json
Agent 撰写的解说词。full 模式下使用原视频时间;**orchestrated cut 模式(video-recap --edit-mode cut)下,第二次暂停时已经先剪出 edited_source.mp4,因此 narration.json 必须直接使用剪后成片的 OUTPUT 时间轴(0..成片总时长),不会再生成或消费 narration_mapped.json。只有 legacy direct video-cut 单 pass 路径才会把原视频时间的 narration remap 成 narration_mapped.json:
[
{"start": 2.5, "end": 7.0, "narration": "解说文本", "pause_after_ms": 250, "overlaps_speech": true}
]narration_lint.json
--step script 或续跑验证 narration.json 时生成的预检结果。它检查写稿、时间安全和解说密度。metrics 为 full 模式下的密度指标(cut 模式为空对象)。
{
"ok": false,
"error_count": 1,
"warning_count": 1,
"metrics": {
"segment_count": 12,
"narration_coverage": 0.68,
"narration_seconds": 61.2,
"timeline_seconds": 90.0,
"avg_block_chars": 48,
"original_block_count": 4
},
"errors": [
{"level": "error", "index": 2, "code": "time_overlap", "message": "Segment overlaps the previous narration segment"}
],
"warnings": [
{"level": "warning", "index": 0, "code": "over_budget", "budget_chars": 28, "actual_chars": 42}
]
}常见 code:invalid_time、empty_narration、time_overlap、outside_clip_plan、over_budget、incomplete_sentence、slot_too_short、under_narrated、over_narrated、fragmented_beats、no_original_blocks。
clip_plan.json
cut 模式下 Agent 选择要保留的原片片段,数组或 { "clips": [...] } 都可接受。默认片段不能重叠,避免同一原片时间映射到多个输出位置:
{
"target_duration": "10m",
"clips": [
{"start": 12.0, "end": 38.0, "reason": "冲突开端"}
]
}| 字段 | 类型 | 说明 |
|---|---|---|
start | float | 原视频片段开始秒数 |
end | float | 原视频片段结束秒数 |
reason | string | 选择该片段的剧情/信息原因 |
clip_plan_validated.json
CLI 校验 clip_plan.json 后写出,额外包含输出时间轴:
{
"clips": [
{
"clip_id": 0,
"source_start": 12.0,
"source_end": 38.0,
"output_start": 0.0,
"output_end": 26.0,
"duration": 26.0,
"reason": "冲突开端"
}
],
"total_duration": 26.0,
"target_duration": 600.0
}narration_mapped.json
仅 legacy direct video-cut 单 pass 路径会生成。orchestrated cut 模式不使用它:Agent 在 pass2 直接按剪后成片 OUTPUT 时间轴写 narration.json。当 legacy 路径启用时,start/end 已变成短视频输出时间,source_start/source_end 保留原视频时间:
[
{
"start": 2.0,
"end": 7.0,
"source_start": 14.0,
"source_end": 19.0,
"source_clip_id": 0,
"narration": "解说文本"
}
]background_research.json
可选的背景调研结果(由 Agent 使用任意可用搜索/浏览方式整理):
{
"synopsis": "剧情概要",
"characters": {"角色名": "角色简介"},
"worldbuilding": "世界观设定",
"episode_context": "集数上下文",
"character_details": {
"角色名": {
"aliases": ["别名/昵称"],
"role": "主角|配角|反派|次要角色",
"relationships": ["与XX是夫妻", "与YY是师徒"]
}
},
"plot_arcs": [
{"name": "线索名称", "description": "简要描述", "status": "进行中|已解决|伏笔"}
],
"cultural_notes": [
{"item": "文化梗/典故/时代背景", "explanation": "解释"}
]
}character_details、plot_arcs、cultural_notes为(可选,新增)字段。仅含synopsis、characters、worldbuilding、episode_context四个原始字段的旧 JSON 仍然有效。
Prompt 模板
本文件被load_prompt()按### NAME切片读取。当前 CLI 只读取 VLM 模板;解说词由 Agent 根据agent_narration_brief.md撰写。
目录
| 模板名 | 用途 | 使用阶段 |
|---|---|---|
| VLM_DEPTH_PROMPT | 场景画面深度分析 | VLM 分析 |
VLM_DEPTH_PROMPT
仔细观察这些视频帧,分析这个场景。分三部分输出:
【描述】 不超过80字,描述画面中正在发生什么(场景、人物、动作、表情、文字、事件)。
【帧标签】 逐帧列出每帧可见的人物动作和物品关系。每帧一行,格式: 时间点 | 该帧可见的关键动作(1-2个完整短句,包含谁在做什么、物品如何被使用,如有字幕/文字也一并列出) 示例: 5.0s | 男子闯入房间, 头发蓬乱表情紧张 10.0s | 男子俯身盯着床上男孩, 男孩睁眼惊醒 15.0s | 男孩从床上坐起双手抱住头上的瓷枕准备防御
【深层分析】 不超过120字,分析: 1. 角色的真实情绪或心理状态(通过表情、肢体语言、眼神判断) 2. 人物之间的关系动态(亲密、紧张、疏离、试探等) 3. 对话背后的潜台词(他们没说出口但显而易见的东西)
要求:
- 必须基于画面可见的证据推断,不做无根据的猜测
- 不要用"象征""隐喻"等文学词汇
- 用口语化表达:"他明显是在试探她的反应"
- 如果画面信息不足,直接说"信息不足"
背景调研指南(可选)
Agent 在跑视频理解(video-understanding)之前先做背景调研,确认作品名、角色关系、剧情背景或科普概念,写入 work_dir/background_research.json。理解阶段会把它并入 VLM 的画面分析上下文,让场景描述能直接叫出人物名字、带着剧情读画面,而不是把人都标成「黑衣男子」。调研不是必需步骤;没有浏览器工具、网络不可用或用户未提供明确片名时,直接跳过,不阻塞视频生成。
工具选择
使用当前环境里可用的任意检索方式即可,例如:
- Codex / Claude 自带的联网搜索或网页浏览工具
- 用户已经配置好的浏览器自动化或页面读取工具
- 普通浏览器手动查找后把结果整理进 JSON
- 已知资料、用户给的剧情背景或项目内已有文档
通用流程
1. 从 --context、文件名或用户描述里提取可搜索关键词。 2. 搜索 1-3 个高价值问题,不要过度调研。 3. 只记录对解说有帮助的事实:人物关系、剧情前提、世界观、当前集上下文、术语解释。 4. 写入 work_dir/background_research.json。 5. 如果查不到可靠信息,跳过并继续写 narration.json。
按视频类型搜索策略
短剧 / 电视剧
| 搜索关键词 | 目标字段 |
|---|---|
{作品名} 剧情 介绍 人物 | synopsis, characters |
{作品名} 人物 关系 | characters |
{作品名} 第{N}集 剧情 | episode_context |
电影
| 搜索关键词 | 目标字段 |
|---|---|
{电影名} 剧情 简介 | synopsis |
{电影名} 影评 解读 | cultural_notes |
纪录片 / 科普视频
| 搜索关键词 | 目标字段 |
|---|---|
{主题} 背景 知识 | worldbuilding |
{核心概念} 解释 | synopsis |
{主题} 最新 进展 | cultural_notes |
写入格式
写入 work_dir/background_research.json,只填搜到的字段,未搜到的字段省略,不要写 null 或空字符串。
{
"synopsis": "...",
"characters": {
"角色名": "简介或关系"
},
"worldbuilding": "...",
"episode_context": "...",
"cultural_notes": [
{"item": "...", "explanation": "..."}
]
}错误处理
| 场景 | 处理 |
|---|---|
| 没有浏览器 / 搜索工具 | 跳过调研,直接写解说词 |
| 搜索无结果 | 换一组关键词重试一次,仍无结果则跳过 |
| 结果互相矛盾 | 只使用用户提供的上下文或画面/ASR 可验证的信息 |
| 信息可能剧透后续 | 只用于理解人物关系,不在解说里剧透用户没要求的后续剧情 |
原则:背景资料只能补上下文,不能替代 vlm_analysis.json / asr_result.json 里的画面和对白证据。
import base64
import json
import os
import time
from pathlib import Path
from lib import CONFIG
from lib import log, run_cmd, get_video_duration, mimo_asr_api_call, file_fingerprint
# ── Step 3: ASR 转录(MiMo mimo-v2.5-asr,云端 API)────────────────────────
_ASR_AUDIO_MIME = "audio/wav"
def _load_name_glossary(work_dir):
"""从 background_research.json 收集已知人名(characters 键 + character_details 键及别名)。
返回去重后、长度 >=2 的人名列表(按长度降序,长名优先匹配)。文件缺失或无名字时返回 []。
"""
research_path = Path(work_dir) / "background_research.json"
if not research_path.exists():
return []
try:
data = json.loads(research_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
if not isinstance(data, dict):
return []
names = set()
characters = data.get("characters")
if isinstance(characters, dict):
names.update(characters.keys())
details = data.get("character_details")
if isinstance(details, dict):
for key, val in details.items():
names.add(key)
if isinstance(val, dict):
aliases = val.get("aliases")
if isinstance(aliases, list):
names.update(a for a in aliases if isinstance(a, str))
cleaned = {n for n in names if isinstance(n, str) and len(n) >= 2}
return sorted(cleaned, key=len, reverse=True)
def _correct_text_with_glossary(text, names):
"""用人名表修正 ASR 同音字错误(如 叶青眉 → 叶轻眉)。
对每个已知人名,扫描文本中所有等长窗口;若某窗口与人名恰好相差一个字符(仅一处不同),
则替换为该人名。严格约束为「恰好一字之差」,避免过度纠正(叶轻风 与 叶轻眉 也是一字之差,
但这正是限制为单字替换的边界——只有当窗口本身不是任何已知人名时才会被改写)。
"""
if not text or not names:
return text
name_set = set(names)
for name in names:
n = len(name)
if n < 2 or len(text) < n:
continue
i = 0
while i <= len(text) - n:
window = text[i:i + n]
# Only rewrite a one-char-off window when it is NOT itself a known name —
# otherwise a distinct real name one char away (叶轻风 vs 叶轻眉) would be corrupted.
if window != name and window not in name_set and _one_char_diff(window, name):
text = text[:i] + name + text[i + n:]
i += n
else:
i += 1
return text
def _one_char_diff(a, b):
"""两个等长字符串是否恰好相差一个字符(仅一处不同)。"""
if len(a) != len(b):
return False
diff = 0
for ca, cb in zip(a, b):
if ca != cb:
diff += 1
if diff > 1:
return False
return diff == 1
def _apply_glossary_corrections(segments, work_dir):
"""对已转录的 segments 就地应用人名表修正;无人名表时为 no-op。"""
names = _load_name_glossary(work_dir)
if not names:
return segments
for seg in segments:
original = seg.get("text") or ""
corrected = _correct_text_with_glossary(original, names)
if corrected != original:
seg["text"] = corrected
return segments
def _audio_meta_path(work_dir):
return Path(work_dir) / "audio.wav.meta.json"
def _write_audio_meta(work_dir, video_path):
_audio_meta_path(work_dir).write_text(
json.dumps({
"schema_version": 1,
"source_video_fingerprint": file_fingerprint(video_path),
"audio": "audio.wav",
}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def transcribe_audio(video_path, work_dir):
"""提取音频并用 MiMo ASR 分段转录,通过分段合成时间戳。"""
asr_file = work_dir / "asr_result.json"
if not CONFIG.get("mimo_asr_api_key"):
key_name = CONFIG.get("mimo_asr_api_key_source", "MIMO_API_KEY")
log(f"ASR 跳过:未设置 {key_name}(MiMo ASR 需要;VLM/TTS 也需要同一个 key)。"
f"如不需要对白可加 --skip-asr")
asr_file.write_text(json.dumps([], ensure_ascii=False, indent=2), encoding="utf-8")
return []
# 提取音频
audio_wav = work_dir / "audio.wav"
cmd = ["ffmpeg", "-y", "-i", str(video_path), "-vn",
"-ar", "16000", "-ac", "1", str(audio_wav)]
result = run_cmd(cmd)
if result.returncode != 0:
raise RuntimeError(f"音频提取失败: {result.stderr}")
_write_audio_meta(work_dir, video_path)
# 获取音频时长
duration = get_video_duration(video_path)
if duration <= 0:
# ffprobe 失败时不再伪造 180s 时长,否则会向 asr_result.json 写入虚构时间戳
log("ASR 警告: 无法获取音频时长(ffprobe 失败),跳过 ASR 转录")
asr_file.write_text(json.dumps([], ensure_ascii=False, indent=2), encoding="utf-8")
return []
segments_dir = work_dir / "audio_segments"
segments_dir.mkdir(exist_ok=True)
segment_length = max(5, int(CONFIG.get("asr_segment_seconds", 30) or 30))
if duration <= segment_length:
# 短音频,整段转录
text = _run_asr(audio_wav)
asr_result = [{"start": 0.0, "end": round(duration, 2), "text": text}]
else:
# 长音频,分段转录(更细的窗口 → 更精细的对白时间戳)
asr_result = _segment_and_transcribe(audio_wav, segments_dir, duration, segment_length)
# 用 background_research.json 的人名表修正 ASR 同音字错误(如 叶青眉 → 叶轻眉);无人名表时为 no-op
_apply_glossary_corrections(asr_result, work_dir)
# 保存
asr_file.write_text(json.dumps(asr_result, ensure_ascii=False, indent=2), encoding="utf-8")
total_text = " ".join(s["text"] for s in asr_result if s["text"])
empty = sum(1 for s in asr_result if not s["text"])
suffix = f"({empty} 段无文本:静音/切分失败/超限被跳过)" if empty else ""
log(f"ASR 转录完成: {len(asr_result)} 段, 共 {len(total_text)} 字{suffix}")
return asr_result
def _run_asr(wav_path):
"""用 MiMo ASR (mimo-v2.5-asr) 转录单个 wav 文件,返回纯文本。
音频以 base64 data-URI 放进 OpenAI 风格的 chat/completions 消息里,转写文本回到
choices[0].message.content。API/响应结构失败会抛错,避免把瞬时失败缓存成空转写;
只有无音频、超体积等确定不可发送的片段返回空串。
"""
try:
raw = Path(wav_path).read_bytes()
except OSError as e:
log(f"ASR 警告: 无法读取音频 {wav_path}: {e}")
return ""
if not raw:
return ""
b64 = base64.b64encode(raw).decode("ascii")
max_b64_bytes = int(float(CONFIG.get("mimo_asr_base64_max_mb", 10.0)) * 1024 * 1024)
if len(b64) > max_b64_bytes:
log(f"ASR 警告: 分片 base64 体积 {len(b64) / 1024 / 1024:.1f}MB 超过 MiMo 上限 "
f"{CONFIG.get('mimo_asr_base64_max_mb')}MB,跳过该段;可调小 ASR_SEGMENT_SECONDS")
return ""
payload = {
"model": CONFIG.get("mimo_asr_model", "mimo-v2.5-asr"),
"messages": [{
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": {"data": f"data:{_ASR_AUDIO_MIME};base64,{b64}"},
}],
}],
"asr_options": {"language": CONFIG.get("mimo_asr_language", "auto")},
}
try:
resp = mimo_asr_api_call(payload)
except Exception as e:
raise RuntimeError(f"MiMo ASR 调用失败: {e}") from e
try:
return str(resp["choices"][0]["message"]["content"] or "").strip()
except (KeyError, IndexError, TypeError):
raise RuntimeError(f"MiMo ASR 返回结构异常: {json.dumps(resp, ensure_ascii=False)[:200]}")
def _segment_and_transcribe(audio_wav, segments_dir, total_duration, segment_length=None):
"""分段转录长音频"""
if segment_length is None:
segment_length = max(5, int(CONFIG.get("asr_segment_seconds", 30) or 30))
# 长视频 ASR 是顺序调用;可选节流让调用间隔开,降低踩到集群限流的频率(默认 0=不节流)
try:
throttle = max(0.0, float(os.environ.get("ASR_THROTTLE_SECONDS", "0") or 0))
except ValueError:
throttle = 0.0
results = []
for i, start in enumerate(range(0, int(total_duration), segment_length)):
if throttle and i:
time.sleep(throttle)
end = min(start + segment_length, total_duration)
seg_wav = segments_dir / f"seg_{i:03d}.wav"
cmd = ["ffmpeg", "-y", "-i", str(audio_wav),
"-ss", str(start), "-to", str(end),
"-ar", "16000", "-ac", "1", str(seg_wav)]
cut = run_cmd(cmd)
if cut.returncode != 0:
# 切分失败时不要对磁盘上的陈旧/残缺音频转录,否则会得到错位文本
log(f" 段 {i+1}: 切分失败,跳过转录 ({cut.stderr.strip()[:200]})")
text = ""
else:
text = _run_asr(seg_wav)
results.append({
"start": round(start, 2),
"end": round(end, 2),
"text": text,
})
log(f" 段 {i+1}: {start:.0f}s-{end:.0f}s => {len(text)} 字")
return results
#!/usr/bin/env python3
"""video-understanding consolidation / 整理 (index build-up).
Optional, synchronous, in-pipeline. Two independent LLM passes over the video's own signal:
- Pass B (index, default): roll up per-scene vlm_analysis into a global
character / relationship / plot index -> understanding_index.json (+ .md).
- Pass A (asr cleanup, opt-in): clean/punctuate/lightly speaker-attribute the raw run-on
ASR text -> asr_clean.json. Timing is preserved BY CONSTRUCTION: the model returns cleaned
TEXT per segment index only; each segment's original start/end is re-attached here, so a
cleanup pass can never shift the timing-bearing spans downstream chunking depends on.
Both passes degrade gracefully (a chat-API failure logs and is skipped) and are idempotent
(a fresh artifact is reused). Mirrors review.py's pure-seam + thin-driver shape so it is
unit-testable with a mocked api_call. NON-required: the pipeline runs unchanged without it.
"""
import argparse
import hashlib
import json
import re
from pathlib import Path
from lib import CONFIG, log, api_call
from understand import _fresh # canonical freshness helper (same skill, safe import)
# Shared tolerance for the per-segment span check. The brief-side gate inlines the SAME
# literal (it cannot import this module without breaking the brief/narration byte-parity).
# Keep both in sync. Consolidate preserves spans exactly, so this only guards hand-edited files.
_ASR_SPAN_TOL = 0.05
CLEAN_PROMPT = """你在清洗中文视频的 ASR 逐段转写。对【每一段】做:补标点、修明显同音/错别字、(能判断时)在句首轻标说话人,让长段连读文本变成清晰可读的句子。
铁律:
- 不要合并或拆分段落,输出段数必须与输入完全一致,顺序一致。
- 不要改时间,不要输出 start/end(时间由程序保留)。
- 只清洗 text,不要增删事实、不要脑补画面。
只返回 JSON:{"segments":[{"i":0,"text":"清洗后的文本","speaker":"可选说话人"}, ...]},i 为输入段的下标。"""
INDEX_PROMPT = """你在根据逐场景画面分析,为一个视频建立【全局理解索引】,供后续写解说词时保持人物/关系/主线一致。
只依据给到的画面证据(场景描述 + 帧实动作),不要脑补画面之外的剧情。
只返回 JSON:
{"characters":[{"name":"角色名或外观指代","description":"身份/特征"}],
"relationships":[{"a":"角色","b":"角色","relation":"关系"}],
"plot_points":["按时间顺序的关键剧情节点"],
"entities":["重要物件/地点/线索"]}"""
# ── pure seams (no I/O; unit-testable) ────────────────────────────────────────
def build_clean_messages(asr_result):
lines = []
for i, seg in enumerate(asr_result or []):
if not isinstance(seg, dict):
continue
lines.append(f'{i}. {str(seg.get("text", "")).strip()}')
user = f"{CLEAN_PROMPT}\n\n## 逐段转写(共 {len(lines)} 段)\n" + "\n".join(lines)
return [{"role": "user", "content": user}]
def parse_clean_response(text, asr_result):
"""Zip cleaned TEXT onto the original segments (start/end preserved by construction).
Returns the original asr_result UNCHANGED on any parse / shape / count problem."""
base = [s for s in (asr_result or []) if isinstance(s, dict)]
if not base:
return asr_result
data = _extract_json(text)
if not isinstance(data, dict):
return asr_result
segs = data.get("segments")
if not isinstance(segs, list) or len(segs) != len(base):
return asr_result # count mismatch -> reject wholesale (idempotent no-op)
by_index = {}
for item in segs:
if isinstance(item, dict) and isinstance(item.get("i"), int):
by_index[item["i"]] = item
if len(by_index) != len(base):
return asr_result
out = []
for i, seg in enumerate(base):
cleaned = by_index.get(i, {})
new_text = str(cleaned.get("text", "")).strip() or str(seg.get("text", ""))
merged = {"start": seg.get("start"), "end": seg.get("end"), "text": new_text}
speaker = str(cleaned.get("speaker", "")).strip()
if speaker:
merged["speaker"] = speaker
out.append(merged)
return out
def build_index_messages(vlm_analysis):
lines = []
for scene in (vlm_analysis or []):
if not isinstance(scene, dict):
continue
sid = scene.get("scene_id", "?")
start = float(scene.get("start", 0) or 0)
end = float(scene.get("end", 0) or 0)
desc = str(scene.get("description", "")).strip().replace("\n", " ")
facts = scene.get("frame_facts")
fact_txt = ""
if isinstance(facts, dict) and facts: # frame_facts is a DICT {ts: [actions]}
actions = []
for ts in sorted(facts.keys(), key=lambda x: float(x)):
vals = facts[ts]
actions.extend(vals if isinstance(vals, list) else [str(vals)])
if actions:
fact_txt = " | 帧实: " + ";".join(a for a in actions[:6] if a)
lines.append(f"[场景{sid} {start:.0f}-{end:.0f}s] {desc}{fact_txt}")
user = f"{INDEX_PROMPT}\n\n## 逐场景画面分析(共 {len(lines)} 段)\n" + "\n".join(lines)
return [{"role": "user", "content": user}]
def parse_index_response(text):
data = _extract_json(text)
if not isinstance(data, dict):
return {"characters": [], "relationships": [], "plot_points": [], "entities": []}
out = {}
for key in ("characters", "relationships", "plot_points", "entities"):
val = data.get(key)
out[key] = val if isinstance(val, list) else []
return out
def format_index_md(index):
out = ["# Understanding index (from consolidate.py)", ""]
chars = index.get("characters") or []
if chars:
out.append("## Characters")
for c in chars:
if isinstance(c, dict):
out.append(f"- **{c.get('name', '?')}** — {c.get('description', '')}".rstrip())
else:
out.append(f"- {c}")
out.append("")
rels = index.get("relationships") or []
if rels:
out.append("## Relationships")
for r in rels:
if isinstance(r, dict):
out.append(f"- {r.get('a', '?')} — {r.get('relation', '?')} — {r.get('b', '?')}")
else:
out.append(f"- {r}")
out.append("")
plot = index.get("plot_points") or []
if plot:
out.append("## Plot spine")
out.extend(f"{i+1}. {p}" for i, p in enumerate(plot))
out.append("")
ents = index.get("entities") or []
if ents:
out.append("## Entities")
out.extend(f"- {e}" for e in ents)
out.append("")
return "\n".join(out).rstrip() + "\n"
def _extract_json(text):
raw = str(text or "")
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
candidate = fence.group(1) if fence else raw
if not fence:
first, last = candidate.find("{"), candidate.rfind("}")
if first != -1 and last > first:
candidate = candidate[first:last + 1]
try:
return json.loads(candidate)
except ValueError:
return None
def _asr_source_md5(work_dir):
"""Provenance: md5 of the on-disk asr_result.json BYTES (writer + reader hash the same thing)."""
path = Path(work_dir) / "asr_result.json"
return hashlib.md5(path.read_bytes()).hexdigest() if path.exists() else ""
def _vlm_source_md5(work_dir):
"""Provenance: md5 of the on-disk vlm_analysis.json bytes."""
path = Path(work_dir) / "vlm_analysis.json"
return hashlib.md5(path.read_bytes()).hexdigest() if path.exists() else ""
def _index_meta_path(work_dir):
return Path(work_dir) / "understanding_index.json.meta.json"
def _prompt_fingerprint(prompt):
return hashlib.md5(str(prompt or "").encode("utf-8")).hexdigest()
def _write_index_meta(work_dir, vlm_analysis):
_index_meta_path(work_dir).write_text(json.dumps({
"schema_version": 1,
"source_md5": _vlm_source_md5(work_dir),
"scene_count": len([s for s in (vlm_analysis or []) if isinstance(s, dict)]),
"model": CONFIG.get("vlm_model", ""),
"prompt_md5": _prompt_fingerprint(INDEX_PROMPT),
}, ensure_ascii=False, indent=2), encoding="utf-8")
def _index_cache_matches(work_dir, vlm_analysis):
meta_path = _index_meta_path(work_dir)
if not meta_path.exists():
return False
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return False
return (
isinstance(meta, dict)
and meta.get("source_md5") == _vlm_source_md5(work_dir)
and meta.get("scene_count") == len([s for s in (vlm_analysis or []) if isinstance(s, dict)])
and meta.get("model") == CONFIG.get("vlm_model", "")
and meta.get("prompt_md5") == _prompt_fingerprint(INDEX_PROMPT)
)
# ── thin drivers (I/O + api_call) ─────────────────────────────────────────────
def _load(work_dir, name):
path = Path(work_dir) / name
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return None
def consolidate_transcript(work_dir):
work_dir = Path(work_dir)
asr_result = _load(work_dir, "asr_result.json")
if not asr_result:
log("consolidate(asr): 无 asr_result.json,跳过")
return None
out_path = work_dir / "asr_clean.json"
if _fresh(out_path, work_dir / "asr_result.json"):
existing = _load(work_dir, "asr_clean.json") or {}
if (
existing.get("source_md5") == _asr_source_md5(work_dir)
and existing.get("model") == CONFIG.get("vlm_model", "")
and existing.get("prompt_md5") == _prompt_fingerprint(CLEAN_PROMPT)
):
log("consolidate(asr): asr_clean.json 已最新,跳过")
return existing
resp = api_call({"model": CONFIG.get("vlm_model", ""),
"messages": build_clean_messages(asr_result),
"max_tokens": 4000, "temperature": 0.2})
content = _response_text(resp)
segments = parse_clean_response(content, asr_result)
payload = {
"source_md5": _asr_source_md5(work_dir),
"model": CONFIG.get("vlm_model", ""),
"prompt_md5": _prompt_fingerprint(CLEAN_PROMPT),
"segments": segments,
}
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"consolidate(asr): 写出 asr_clean.json({len(segments)} 段)")
return payload
def consolidate_index(work_dir):
work_dir = Path(work_dir)
vlm_analysis = _load(work_dir, "vlm_analysis.json")
if not vlm_analysis:
log("consolidate(index): 无 vlm_analysis.json,跳过")
return None
out_path = work_dir / "understanding_index.json"
if _fresh(out_path, work_dir / "vlm_analysis.json") and _index_cache_matches(work_dir, vlm_analysis):
log("consolidate(index): understanding_index.json 已最新,跳过")
return _load(work_dir, "understanding_index.json")
resp = api_call({"model": CONFIG.get("vlm_model", ""),
"messages": build_index_messages(vlm_analysis),
"max_tokens": 2500, "temperature": 0.2})
index = parse_index_response(_response_text(resp))
out_path.write_text(json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index_meta(work_dir, vlm_analysis)
(work_dir / "understanding_index.md").write_text(format_index_md(index), encoding="utf-8")
log(f"consolidate(index): 写出 understanding_index.json(角色 {len(index['characters'])})")
return index
def consolidate(work_dir, do_asr=False, do_index=True):
"""Default = index-only (Pass B, zero timing risk). Pass A (asr) is opt-in."""
result = {}
if do_index:
try:
result["index"] = consolidate_index(work_dir)
except Exception as e:
log(f"consolidate(index) 跳过(忽略): {e}")
if do_asr:
try:
result["asr_clean"] = consolidate_transcript(work_dir)
except Exception as e:
log(f"consolidate(asr) 跳过(忽略): {e}")
return result
def _response_text(resp):
try:
return resp["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError):
log("consolidate: API 返回结构异常")
return ""
def main():
ap = argparse.ArgumentParser(description="Consolidate the understanding index (and optionally clean ASR).")
ap.add_argument("--work-dir", required=True)
ap.add_argument("--asr", action="store_true", help="also run Pass A (ASR cleanup)")
ap.add_argument("--no-index", action="store_true", help="skip Pass B (index)")
args = ap.parse_args()
res = consolidate(args.work_dir, do_asr=args.asr, do_index=not args.no_index)
print(json.dumps({"status": "consolidated",
"index": bool(res.get("index")),
"asr_clean": bool(res.get("asr_clean"))}, ensure_ascii=False))
if __name__ == "__main__":
main()
import json
import os
import re
import subprocess
from pathlib import Path
from lib import CONFIG
from lib import log, run_cmd, get_video_duration, file_fingerprint
# ── Step 2: 场景检测 ──────────────────────────────────────────────────
def detect_scenes(video_path, work_dir, threshold=None):
"""使用 ffmpeg scdet 滤镜检测场景切换"""
threshold = CONFIG["scene_threshold"] if threshold is None else threshold
scdet_threshold = int(threshold * 100)
cmd = ["ffmpeg", "-i", str(video_path),
"-vf", f"scdet=threshold={scdet_threshold}",
"-f", "null", "-"]
result = run_cmd(cmd)
if result.returncode != 0:
raise RuntimeError(f"场景检测失败: {result.stderr}")
# 解析 lavfi.scd.time 和 lavfi.scd.score
times = []
for line in result.stderr.split("\n"):
match = re.search(r"lavfi\.scd\.time[:=]\s*(\S+)", line)
if match:
times.append(float(match.group(1)))
if not times:
# 没有检测到场景切换,整个视频作为一个场景
duration = get_video_duration(video_path)
scenes = [{"start": 0.0, "end": duration}]
log(f"未检测到场景切换,整个视频作为一个场景 ({duration:.1f}s)")
else:
scenes = []
prev = 0.0
for t in times:
scenes.append({"start": round(prev, 2), "end": round(t, 2)})
prev = t
# 最后一个场景到视频结束
duration = get_video_duration(video_path)
scenes.append({"start": round(prev, 2), "end": round(duration, 2)})
log(f"检测到 {len(scenes)} 个场景")
# 先过滤黑/白帧过渡场景,再合并短场景
# (合并会保留短场景的 start,若黑场被并入长场景,单点采样会误删整段,故顺序在前)
if CONFIG.get("scene_junk_filter", True):
scenes = _filter_junk_scenes(scenes, video_path)
# 合并短场景(< 3s 合并到相邻场景)
scenes = _merge_short_scenes(scenes, min_duration=CONFIG.get("scene_merge_min", 4.0))
# 保存
scenes_file = work_dir / "scenes.json"
scenes_file.write_text(json.dumps(scenes, ensure_ascii=False, indent=2), encoding="utf-8")
for i, s in enumerate(scenes):
log(f" 场景 {i+1}: {s['start']:.1f}s - {s['end']:.1f}s ({s['end']-s['start']:.1f}s)")
return scenes
def _merge_short_scenes(scenes, min_duration=4.0):
"""合并过短的场景到相邻场景"""
if len(scenes) <= 1:
return scenes
merged = [scenes[0]]
for s in scenes[1:]:
prev = merged[-1]
# 如果前一个场景太短,合并到当前
if prev["end"] - prev["start"] < min_duration:
merged[-1] = {"start": prev["start"], "end": s["end"]}
# 如果当前场景太短,合并到前一个
elif s["end"] - s["start"] < min_duration:
merged[-1] = {"start": prev["start"], "end": s["end"]}
else:
merged.append(s)
# 如果最后一个太短,已在前一步合并
log(f"合并短场景后: {len(scenes)} → {len(merged)} 个场景")
return merged
def _sample_frame_luma(video_path, timestamp, sample_size=64):
"""Extract one frame via ffmpeg and return luma values without extra deps."""
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-ss",
f"{max(0.0, float(timestamp)):.3f}",
"-i",
str(video_path),
"-frames:v",
"1",
"-vf",
f"scale={sample_size}:{sample_size},format=rgb24",
"-f",
"rawvideo",
"-",
]
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0 or not result.stdout:
raise RuntimeError(result.stderr.decode("utf-8", errors="replace")[:300])
data = result.stdout
return [
0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]
for i in range(0, len(data) - 2, 3)
]
def _is_junk_scene(video_path, timestamp, threshold_dark=None, threshold_bright=None):
"""Return True for near-black or near-white scene-start frames."""
threshold_dark = CONFIG.get("scene_junk_dark_luma", 8.0) if threshold_dark is None else threshold_dark
threshold_bright = CONFIG.get("scene_junk_bright_luma", 245.0) if threshold_bright is None else threshold_bright
pixel_ratio = min(1.0, max(0.0, float(CONFIG.get("scene_junk_pixel_ratio", 0.995))))
try:
lumas = _sample_frame_luma(video_path, timestamp)
except Exception as exc:
log(f"场景亮度采样失败,保留场景 {timestamp:.1f}s: {exc}")
return False
if not lumas:
return False
avg_luma = sum(lumas) / len(lumas)
dark_ratio = sum(1 for value in lumas if value <= threshold_dark) / len(lumas)
bright_ratio = sum(1 for value in lumas if value >= threshold_bright) / len(lumas)
return (
avg_luma <= threshold_dark and dark_ratio >= pixel_ratio
) or (
avg_luma >= threshold_bright and bright_ratio >= pixel_ratio
)
def _filter_junk_scenes(scenes, video_path):
"""Filter black/white transition scenes while never deleting the whole video."""
if len(scenes) <= 1:
return scenes
filtered = []
removed = []
for scene in scenes:
start = float(scene["start"])
end = float(scene["end"])
# 多点采样:起始、中点、结尾各探一帧;只有全部为垃圾帧才删除,
# 含任意非垃圾帧的场景必须保留(避免短黑场并入长真实场景后被整段误删)
probe_times = [
min(end, start + 0.1),
(start + end) / 2.0,
max(start, end - 0.1),
]
if all(_is_junk_scene(video_path, t) for t in probe_times):
removed.append(scene)
else:
filtered.append(scene)
if not filtered:
log("场景黑/白帧过滤会删除全部场景,已放弃过滤")
return scenes
if removed:
log(f"过滤黑/白帧过渡场景: {len(scenes)} → {len(filtered)}")
return filtered
# ── Step 3.5: 静音检测 ─────────────────────────────────────────────
def detect_silence_periods(video_path, work_dir, asr_result=None):
"""用 ffmpeg silencedetect 检测安静时段,作为解说插入的候选窗口"""
audio_path = work_dir / "audio.wav"
if not _audio_cache_matches(audio_path, video_path):
if audio_path.exists():
audio_path.unlink()
# 提取到临时文件,成功后原子移动到位,避免被中断的 -y 运行留下半截 audio.wav
tmp_path = work_dir / "audio.wav.tmp"
extract = run_cmd([
"ffmpeg", "-y", "-i", str(video_path),
"-vn", "-ar", "16000", "-ac", "1",
"-f", "wav", str(tmp_path) # .tmp extension hides the format from ffmpeg; state it
])
if extract.returncode != 0 or not tmp_path.exists():
log(f"音频提取失败,无法检测静音窗口(视频可能无音轨): {extract.stderr}")
if tmp_path.exists():
tmp_path.unlink()
return []
os.replace(str(tmp_path), str(audio_path))
_write_audio_meta(work_dir, video_path)
noise = CONFIG["silence_noise_threshold"]
min_dur = CONFIG["silence_min_duration"]
cmd = ["ffmpeg", "-i", str(audio_path),
"-af", f"silencedetect=noise={noise}:d={min_dur}",
"-f", "null", "-"]
result = run_cmd(cmd, timeout=120)
if result.returncode != 0:
log(f"静音检测失败: {result.stderr}")
return []
output = result.stderr
# 解析 silence_start / silence_end
starts = [float(m) for m in re.findall(r'silence_start:\s*([\d.]+)', output)]
ends = [float(m) for m in re.findall(r'silence_end:\s*([\d.]+)', output)]
# 配对所有静音段(不过滤时长,后面合并后再过滤)
raw_periods = []
for s, e in zip(starts, ends):
raw_periods.append({"start": round(s, 2), "end": round(e, 2),
"duration": round(e - s, 2)})
# 末尾静音
if len(starts) > len(ends):
dur_start = starts[len(ends)]
total_dur = get_video_duration(str(audio_path))
raw_periods.append({"start": round(dur_start, 2), "end": round(total_dur, 2),
"duration": round(total_dur - dur_start, 2)})
# 合并相邻静音段(间隔 < merge_gap 的合并为一个大窗口)
merge_gap = CONFIG.get("silence_merge_gap", 0.5)
merged = []
for rp in sorted(raw_periods, key=lambda x: x["start"]):
if merged and rp["start"] - merged[-1]["end"] < merge_gap:
merged[-1]["end"] = rp["end"]
merged[-1]["duration"] = round(merged[-1]["end"] - merged[-1]["start"], 2)
else:
merged.append({"start": rp["start"], "end": rp["end"],
"duration": rp["duration"], "has_speech": False})
# 过滤最短时长
quiet_min = CONFIG["quiet_window_min"]
periods = [p for p in merged if p["duration"] >= quiet_min]
# 与 ASR 交叉验证:标记有语音的窗口
# 跳过条件:ASR 段太粗(无法精确判断语音位置)
# 1. 段数少(<=5)且覆盖>80%视频时长 → 时间戳不可靠
# 2. 覆盖率>150% → 时间戳明显异常
# 3. 平均段长过大 → 粒度太粗,无法判断哪些窗口有语音
# 阈值 45s 给默认 ASR 窗口(asr_segment_seconds=30s)留出余量,使交叉验证重新生效;
# 粗粒度(如 180s 旧窗口)仍会被正确跳过。
if asr_result:
video_dur = get_video_duration(str(audio_path))
asr_coverage = sum(seg.get("end", 0) - seg.get("start", 0) for seg in asr_result)
avg_seg_dur = asr_coverage / len(asr_result) if asr_result else 0
skip_cross_check = (
(len(asr_result) <= 5 and asr_coverage > video_dur * 0.8) or
asr_coverage > video_dur * 1.5 or
avg_seg_dur > 45
)
if not skip_cross_check:
for qp in periods:
for seg in asr_result:
seg_s = seg.get("start", 0)
seg_e = seg.get("end", 0)
overlap = min(qp["end"], seg_e) - max(qp["start"], seg_s)
if overlap > qp["duration"] * 0.3:
qp["has_speech"] = True
break
# 保存
(work_dir / "silence_periods.json").write_text(
json.dumps(periods, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"检测到 {len(periods)} 个安静窗口 (≥{quiet_min}s)")
for qp in periods:
flag = " [有语音]" if qp["has_speech"] else ""
log(f" {qp['start']:.1f}s-{qp['end']:.1f}s ({qp['duration']:.1f}s){flag}")
return periods
def _audio_meta_path(work_dir):
return Path(work_dir) / "audio.wav.meta.json"
def _audio_cache_matches(audio_path, video_path):
audio_path = Path(audio_path)
if not audio_path.exists():
return False
meta_path = _audio_meta_path(audio_path.parent)
if not meta_path.exists():
return False
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return False
try:
expected = file_fingerprint(video_path)
except OSError:
return False
return meta.get("source_video_fingerprint") == expected
def _write_audio_meta(work_dir, video_path):
_audio_meta_path(work_dir).write_text(
json.dumps({
"schema_version": 1,
"source_video_fingerprint": file_fingerprint(video_path),
"audio": "audio.wav",
}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
from lib import CONFIG
from lib import log, run_cmd
# ── Step 1: 帧提取 ───────────────────────────────────────────────────
def extract_frames(video_path, work_dir, fps=None):
"""提取视频帧"""
fps = CONFIG["fps"] if fps is None else fps
if fps <= 0:
raise ValueError("fps 必须大于 0;完整 pipeline 会自动计算 fps")
frames_dir = work_dir / "frames"
frames_dir.mkdir(exist_ok=True)
# 清理上一次(可能更高 fps)残留的帧,避免陈旧帧泄漏进本次结果
for stale in frames_dir.glob("frame_*.jpg"):
stale.unlink()
output_pattern = str(frames_dir / "frame_%05d.jpg")
cmd = ["ffmpeg", "-y", "-i", str(video_path),
"-vf", f"fps={fps}", "-q:v", "2", output_pattern]
result = run_cmd(cmd)
if result.returncode != 0:
raise RuntimeError(f"帧提取失败: {result.stderr}")
frames = sorted(frames_dir.glob("frame_*.jpg"))
log(f"提取了 {len(frames)} 帧 ({fps}fps)")
return frames
"""Self-contained config + utilities for this skill (no cross-skill imports).
Merged from the shared core; reads the same env vars as the rest of the bundle."""
import json
import hashlib
import os
import re
import subprocess
import time
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
# ── 配置 ──────────────────────────────────────────────────────────────
DEFAULT_MIMO_API_URL = "https://api.xiaomimimo.com/v1"
DEFAULT_MIMO_TOKEN_PLAN_CLUSTER = "cn"
MIMO_TOKEN_PLAN_API_URLS = {
"cn": "https://token-plan-cn.xiaomimimo.com/v1",
"sgp": "https://token-plan-sgp.xiaomimimo.com/v1",
"ams": "https://token-plan-ams.xiaomimimo.com/v1",
}
DEFAULT_MIMO_MODEL = "mimo-v2.5" # VLM / chat (vision understanding)
DEFAULT_MIMO_ASR_MODEL = "mimo-v2.5-asr" # speech-to-text
DEFAULT_MIMO_TTS_MODEL = "mimo-v2.5-tts" # text-to-speech
def normalize_api_url(raw_url):
"""Normalize a MiMo (OpenAI-compatible) base URL or chat/completions endpoint."""
url = (raw_url or DEFAULT_MIMO_API_URL).rstrip("/")
if url.endswith("/chat/completions"):
return url
return f"{url}/chat/completions"
def is_mimo_token_plan_key(api_key):
"""Return True for Xiaomi MiMo Token Plan keys, which use token-plan base URLs."""
return str(api_key or "").strip().startswith("tp-")
def default_mimo_api_url(api_key="", cluster=None):
"""Pick the correct MiMo base URL for pay-as-you-go vs Token Plan keys.
MiMo uses independent credentials for pay-as-you-go (`sk-*`) and Token Plan
(`tp-*`). Token Plan keys must be sent to the Token Plan cluster base URL,
not the pay-as-you-go `api.xiaomimimo.com` endpoint.
"""
if is_mimo_token_plan_key(api_key):
cluster_name = (cluster or os.environ.get("MIMO_TOKEN_PLAN_CLUSTER") or DEFAULT_MIMO_TOKEN_PLAN_CLUSTER)
cluster_name = str(cluster_name).strip().lower()
return MIMO_TOKEN_PLAN_API_URLS.get(cluster_name, MIMO_TOKEN_PLAN_API_URLS[DEFAULT_MIMO_TOKEN_PLAN_CLUSTER])
return DEFAULT_MIMO_API_URL
def env_int(name, default, *, minimum=None):
"""Read an integer env var; ignore malformed values instead of crashing import."""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
value = int(raw)
except (TypeError, ValueError):
return default
if minimum is not None:
value = max(minimum, value)
return value
def env_bool(name, default=False):
"""Read common boolean env var forms."""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
def env_float(name, default, *, minimum=None):
"""Read a float env var; ignore malformed values instead of crashing import."""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
value = float(raw)
except (TypeError, ValueError):
return default
if minimum is not None:
value = max(minimum, value)
return value
# Single MiMo credential powers ASR + VLM + TTS. Per-capability overrides
# (MIMO_VIDEO_API_KEY / MIMO_TTS_API_KEY / MIMO_ASR_API_KEY and their *_API_URL forms)
# are optional and fall back to MIMO_API_KEY / MIMO_API_URL. Token-Plan keys (tp-*) auto-
# route to the Token-Plan cluster base URL; pay-as-you-go keys use api.xiaomimimo.com.
_mimo_api_key = os.environ.get("MIMO_API_KEY", "")
_mimo_video_api_key = os.environ.get("MIMO_VIDEO_API_KEY", "") or _mimo_api_key
_mimo_tts_api_key = os.environ.get("MIMO_TTS_API_KEY", "") or _mimo_api_key
_mimo_asr_api_key = os.environ.get("MIMO_ASR_API_KEY", "") or _mimo_api_key
_raw_api_url = os.environ.get("MIMO_API_URL") or default_mimo_api_url(_mimo_api_key)
_raw_mimo_video_api_url = (
os.environ.get("MIMO_VIDEO_API_URL")
or os.environ.get("MIMO_API_URL")
or default_mimo_api_url(_mimo_video_api_key)
)
_raw_mimo_tts_api_url = (
os.environ.get("MIMO_TTS_API_URL")
or os.environ.get("MIMO_API_URL")
or default_mimo_api_url(_mimo_tts_api_key)
)
_raw_mimo_asr_api_url = (
os.environ.get("MIMO_ASR_API_URL")
or os.environ.get("MIMO_API_URL")
or default_mimo_api_url(_mimo_asr_api_key)
)
CONFIG = {
"api_provider": "mimo",
"api_provider_source": "default",
"api_url": normalize_api_url(_raw_api_url),
"api_url_source": "env" if os.environ.get("MIMO_API_URL") else "default",
"api_key": _mimo_api_key,
"api_key_source": "MIMO_API_KEY",
"mimo_api_url": normalize_api_url(_raw_api_url),
"mimo_api_url_source": "env" if os.environ.get("MIMO_API_URL") else "default",
"mimo_api_key": _mimo_api_key,
"mimo_api_key_source": "MIMO_API_KEY",
"mimo_video_api_url": normalize_api_url(_raw_mimo_video_api_url),
"mimo_video_api_url_source": "env" if (
os.environ.get("MIMO_VIDEO_API_URL") or os.environ.get("MIMO_API_URL")
) else "default",
"mimo_video_api_key": _mimo_video_api_key,
"mimo_video_api_key_source": "MIMO_VIDEO_API_KEY" if os.environ.get("MIMO_VIDEO_API_KEY") else "MIMO_API_KEY",
"mimo_tts_api_url": normalize_api_url(_raw_mimo_tts_api_url),
"mimo_tts_api_url_source": "env" if (
os.environ.get("MIMO_TTS_API_URL") or os.environ.get("MIMO_API_URL")
) else "default",
"mimo_tts_api_key": _mimo_tts_api_key,
"mimo_tts_api_key_source": "MIMO_TTS_API_KEY" if os.environ.get("MIMO_TTS_API_KEY") else "MIMO_API_KEY",
"mimo_asr_api_url": normalize_api_url(_raw_mimo_asr_api_url),
"mimo_asr_api_url_source": "env" if (
os.environ.get("MIMO_ASR_API_URL") or os.environ.get("MIMO_API_URL")
) else "default",
"mimo_asr_api_key": _mimo_asr_api_key,
"mimo_asr_api_key_source": "MIMO_ASR_API_KEY" if os.environ.get("MIMO_ASR_API_KEY") else "MIMO_API_KEY",
"mimo_model": os.environ.get("MIMO_MODEL", DEFAULT_MIMO_MODEL),
"mimo_model_source": "env" if os.environ.get("MIMO_MODEL") else "default",
"mimo_video_model": os.environ.get("MIMO_VIDEO_MODEL") or os.environ.get("MIMO_MODEL", DEFAULT_MIMO_MODEL),
"mimo_video_model_source": "env" if (
os.environ.get("MIMO_VIDEO_MODEL") or os.environ.get("MIMO_MODEL")
) else "default",
"vlm_model": os.environ.get("MIMO_MODEL", DEFAULT_MIMO_MODEL),
"vlm_model_source": "env" if os.environ.get("MIMO_MODEL") else "default",
"mimo_asr_model": os.environ.get("MIMO_ASR_MODEL", DEFAULT_MIMO_ASR_MODEL),
"mimo_asr_model_source": "env" if os.environ.get("MIMO_ASR_MODEL") else "default",
"mimo_asr_language": os.environ.get("MIMO_ASR_LANGUAGE", "auto"), # auto | zh | en
"mimo_asr_base64_max_mb": env_float("MIMO_ASR_BASE64_MAX_MB", 10.0, minimum=1.0),
# ASR 分段窗口秒数。越小 → 长视频的对白时间戳越精细(默认 15s)。旧值 180s 会把 >3min
# 视频的对白塌缩成一个时间戳,既让 brief 无法定位对白,又触发 detect.py 的粗粒度跳过,
# 使 overlaps_speech/安静窗口判断失真。代价是更多 ASR 调用;ASR 慢时可调大。
"asr_segment_seconds": env_float("ASR_SEGMENT_SECONDS", 15.0, minimum=5.0),
"scene_threshold": 0.1,
"scene_threshold_source": "default",
"mimo_tts_model": os.environ.get("MIMO_TTS_MODEL", DEFAULT_MIMO_TTS_MODEL),
"mimo_tts_model_source": "env" if os.environ.get("MIMO_TTS_MODEL") else "default",
"mimo_tts_voice": os.environ.get("MIMO_TTS_VOICE", "冰糖"),
"mimo_tts_voice_source": "env" if os.environ.get("MIMO_TTS_VOICE") else "default",
"mimo_tts_style": os.environ.get(
"MIMO_TTS_STYLE",
"自然、清晰、有感染力,像在给观众讲故事;随剧情起伏,该紧张时紧张、该动情时动情,不平铺直叙。",
),
"mimo_tts_style_source": "env" if os.environ.get("MIMO_TTS_STYLE") else "default",
"mimo_media_resolution": os.environ.get("MIMO_MEDIA_RESOLUTION", "default"),
"mimo_media_resolution_source": "env" if os.environ.get("MIMO_MEDIA_RESOLUTION") else "default",
"mimo_video_overview": env_bool("MIMO_VIDEO_OVERVIEW", False), # opt-in (--mimo-video-overview / =1); when on it becomes the PRIMARY per-scene description, frames stay the anchor/fallback
"mimo_video_overview_source": "env" if os.environ.get("MIMO_VIDEO_OVERVIEW") else "default",
"mimo_video_fps": env_float("MIMO_VIDEO_FPS", 3.0, minimum=0.1),
"mimo_video_fps_source": "env" if os.environ.get("MIMO_VIDEO_FPS") else "default",
"mimo_video_chunk_max_seconds": env_float("MIMO_VIDEO_CHUNK_MAX_SECONDS", 20.0, minimum=1.0),
"mimo_video_chunk_min_seconds": env_float("MIMO_VIDEO_CHUNK_MIN_SECONDS", 1.0, minimum=0.2),
"mimo_video_chunk_timeout": env_int("MIMO_VIDEO_CHUNK_TIMEOUT", 180, minimum=1),
"mimo_video_base64_max_mb": env_float("MIMO_VIDEO_BASE64_MAX_MB", 45.0, minimum=1.0),
# Per-scene frame VLM sampling — scale frames with scene length instead of a hard cap of 6
"vlm_seconds_per_frame": env_float("VLM_SECONDS_PER_FRAME", 4.0, minimum=0.5),
"vlm_max_frames": env_int("VLM_MAX_FRAMES", 16, minimum=3),
"vlm_max_tokens": env_int("VLM_MAX_TOKENS", 1500, minimum=200),
"mimo_video_prompt": os.environ.get(
"MIMO_VIDEO_PROMPT",
"请用中文分析这个视频分片的主要人物、场景变化、关键动作、情绪走向和剧情冲突,"
"重点提取适合写短视频解说的故事线索。不要泛泛复述画面,要标出对后续写稿有用的信息。",
),
"mimo_disable_thinking": env_bool("MIMO_DISABLE_THINKING", True),
"mimo_disable_thinking_source": "env" if os.environ.get("MIMO_DISABLE_THINKING") else "default",
"fps": 0, # 0 = 自动(≤60s→2fps, ≤5min→1.5fps, >5min→1fps)
# Storyboard contact sheets (advisory): generated only in video-understanding (owns frames+fps).
# Kept ONLY here, matching the bundle convention that each lib.py carries the keys ITS skill uses
# (video-cut's lib.py is deliberately minimal); no other skill reads storyboard_*.
"storyboard": env_bool("STORYBOARD", True), # generate source/edited storyboard contact sheets
"storyboard_max_tiles": env_int("STORYBOARD_MAX_TILES", 30, minimum=1), # cap tiles per sheet for legibility
"storyboard_columns": env_int("STORYBOARD_COLUMNS", 6, minimum=1), # tile grid columns
# TTS 语速(字符/秒)。实测 mimo-tts 冰糖音色中位 ~3.9 字/秒,可用 SPEECH_RATE 覆盖
# 生成解说时使用 speech_rate * safety_margin 作为约束
"speech_rate": env_float("SPEECH_RATE", 3.9, minimum=0.5), # 旧值 3.5 系统性偏低 ~10-17%
"speech_safety_margin": env_float("SPEECH_SAFETY_MARGIN", 0.85, minimum=0.1), # 保守系数:TTS 实际语速有 ±20% 波动
# Block-coverage lint thresholds — promoted from inline .get() literals to real CONFIG keys (tunable; defaults unchanged)
"narration_coverage_target": 0.7, # aim ~70% narrated:original (7:3)
"narration_coverage_min": 0.5, # below this coverage → under_narrated
"narration_block_seconds": 9.0, # block cadence used to derive target block count
"original_block_min_seconds": 2.5, # a deliberate original-audio gap must be at least this long
"narration_block_min_chars": 16, # below this avg block size → fragmented_beats
"fade_ms": 300, # TTS fade-in/fade-out 时长(ms)
"breath_ms": 250, # 段间呼吸空间(ms);block recap 块内连贯、块间留原声呼吸
# Legacy single-pass cut mapping density fields; current writing uses block coverage controls below.
"target_segments_per_minute": 9.6, # legacy single-pass cut mapping report only; block recap uses narration_coverage_*
"min_segments_per_minute": 6.24, # legacy single-pass cut mapping report only
"max_narration_gap_seconds": 11.0, # legacy single-pass cut mapping report only
"ducking_mode": "fixed", # fixed | sidechaincompress | none
"ducking_threshold": 0.15,
"ducking_ratio": 3,
"ducking_attack": 10,
"ducking_release": 300,
"ducking_level_sc": 2.0,
"ducking_makeup": 1.2,
"ducking_narr_weight": 1.5,
"ducking_orig_volume": env_float("DUCKING_ORIG_VOLUME", 0.3, minimum=0.0), # 解说时原声基准音量
"zone_ducking_volume": 0.12, # 解说时原声压低到的音量
"zone_fade_seconds": 0.5, # 解说/原声切换的淡入淡出时长(秒)
"idle_orig_volume": env_float("IDLE_ORIG_VOLUME", 1.0, minimum=0.0), # 解说间隙(无旁白)时的原声音量,铺底避免顿挫
"duck_fade_seconds": env_float("DUCK_FADE_SECONDS", 0.3, minimum=0.0), # 原声 ducking 过渡淡入淡出(秒)
"bgm_path": os.environ.get("BGM_PATH", "").strip(), # 背景音乐文件(可选),留空则不加 BGM
"source_video": os.environ.get("SOURCE_VIDEO", "").strip(), # 剪辑模式下的原始视频(可选),用于时间线/剪映导出引用原片片段
"export_jianying": env_bool("EXPORT_JIANYING", False), # 渲染后可选导出剪映草稿(默认关;与核心解耦)
"jianying_draft_dir": os.environ.get("JIANYING_DRAFT_DIR", "").strip(), # 剪映草稿输出父目录(留空=work_dir)
"jianying_bundle_media": env_bool("JIANYING_BUNDLE_MEDIA", True), # 默认开:macOS 剪映沙箱读不到外部路径,须把素材拷进草稿目录
"bgm_volume": env_float("BGM_VOLUME", 0.18, minimum=0.0), # BGM 铺底音量
"bgm_ducking_volume": env_float("BGM_DUCKING_VOLUME", 0.10, minimum=0.0), # 旁白时 BGM 压低到的音量
"narration_speed": env_float("NARRATION_SPEED", 1.3, minimum=0.5), # 解说整体提速(atempo),默认偏快适配短视频;长片可设 1.0
"mask_source_subtitles": env_bool("MASK_SOURCE_SUBTITLES", True), # 遮挡原片烧录字幕(默认开;无烧录字幕素材设 false)
"source_subtitle_mask_ratio": env_float("SOURCE_SUBTITLE_MASK_RATIO", 0.14, minimum=0.0), # 底部遮挡比例
"narration_delay_seconds": 1.5, # 解说延迟放置秒数,让画面先出现再解说
"narration_tail_pad_seconds": 0.1, # 解说尾部最少留白;短 slot 会自动压低 delay 避免截断
"quiet_overlap_min_ratio": 0.8, # 解说段至少多少比例落在安静窗口内才标记为非对白重叠
"visual_beat_max_seconds": 18.0, # 单段解说超过该时长且跨多个帧锚点时给 lint 提醒
"visual_beat_max_facts": 3, # 单段解说最多建议覆盖的 frame_facts 锚点数量
"asr_chunk_min_chars": env_int("ASR_CHUNK_MIN_CHARS", 500, minimum=1), # brief 中 ASR 写作分块最小字数/词数
"asr_chunk_max_chars": env_int("ASR_CHUNK_MAX_CHARS", 800, minimum=1), # brief 中 ASR 写作分块最大字数/词数
"speech_ducking_volume": env_float("SPEECH_DUCKING_VOLUME", 0.2, minimum=0.0), # 解说与对白重叠时原声音量
"silence_noise_threshold": "-25dB", # ffmpeg silencedetect 噪声阈值
"silence_min_duration": 0.3, # 静音最短持续秒数
"quiet_window_min": 1.0, # 可放解说的安静窗口最短秒数
"silence_merge_gap": 0.5, # 相邻静音段间隔<此值时合并
"scene_merge_min": 4.0, # 场景合并最短时长,<此值的场景合并到相邻场景
"scene_junk_filter": env_bool("SCENE_JUNK_FILTER", True), # 过滤连续黑/白帧无效过渡场景
"scene_junk_dark_luma": env_float("SCENE_JUNK_DARK_LUMA", 8.0, minimum=0.0),
"scene_junk_bright_luma": env_float("SCENE_JUNK_BRIGHT_LUMA", 245.0, minimum=0.0),
"scene_junk_pixel_ratio": env_float("SCENE_JUNK_PIXEL_RATIO", 0.995, minimum=0.0),
"context_info": "", # 额外上下文(节目名、角色名等)
"context_info_source": "default",
"fps_source": "default",
"style": "纪录片", # 解说风格(resume 时随 run_settings 持久化/恢复)
"style_source": "default",
"tts_dynamic_params": True, # 启用动态语速调节
"vlm_workers": env_int("VLM_WORKERS", 8, minimum=1), # VLM 并行分析线程数
"tts_workers": env_int("TTS_WORKERS", 4, minimum=1), # TTS 并行合成线程数
"tts_timeout": env_int("TTS_TIMEOUT", 90, minimum=1), # 单段 TTS 命令超时秒数
"tts_retries": env_int("TTS_RETRIES", 3, minimum=1), # 单段 TTS 失败重试次数
"allow_partial_tts": env_bool("ALLOW_PARTIAL_TTS", False),
"edit_mode": os.environ.get("EDIT_MODE", "full"), # full | cut
"edit_mode_source": "env" if os.environ.get("EDIT_MODE") else "default",
"target_duration": os.environ.get("TARGET_DURATION", ""), # cut 模式目标成片时长,如 10m
"target_duration_source": "env" if os.environ.get("TARGET_DURATION") else "default",
"clip_padding": env_float("CLIP_PADDING", 0.0, minimum=0.0), # cut 模式片段两端扩展秒数
"clip_padding_source": "env" if os.environ.get("CLIP_PADDING") else "default",
"allow_clip_overlap": env_bool("ALLOW_CLIP_OVERLAP", False), # cut 模式是否允许重复/重叠使用原片
"burn_subtitles": env_bool("BURN_SUBTITLES", True), # 烧录解说字幕(默认开;遮挡原字幕后需自带字幕,否则字幕区空白)
"force_video_reencode": env_bool("FORCE_VIDEO_REENCODE", False), # 组装时重编码视频,修复部分容器时间戳问题
# 成片末端整体响度归一(默认混音偏轻,归一后更接近常见短视频响度;样片约 -11.9,默认取更安全的 -14)
"final_loudnorm": env_bool("FINAL_LOUDNORM", True), # 组装末端做一次整体响度归一
"target_lufs": env_float("TARGET_LUFS", -14.0), # 目标综合响度 (LUFS)
"target_true_peak": env_float("TARGET_TRUE_PEAK", -1.0), # 目标真峰值 (dBTP)
"target_lra": env_float("TARGET_LRA", 11.0), # 目标响度范围 (LU)
"subtitle_font_name": os.environ.get("SUBTITLE_FONT_NAME", "Arial"),
"subtitle_font_size": env_int("SUBTITLE_FONT_SIZE", 42, minimum=8),
"subtitle_primary_color": os.environ.get("SUBTITLE_PRIMARY_COLOR", "&H00FFFFFF"),
"subtitle_outline_color": os.environ.get("SUBTITLE_OUTLINE_COLOR", "&H00000000"),
"subtitle_outline": env_float("SUBTITLE_OUTLINE", 2.0, minimum=0.0),
"subtitle_shadow": env_float("SUBTITLE_SHADOW", 1.0, minimum=0.0),
"subtitle_margin_v": env_int("SUBTITLE_MARGIN_V", 48, minimum=0),
"subtitle_margin_l": env_int("SUBTITLE_MARGIN_L", 40, minimum=0),
"subtitle_margin_r": env_int("SUBTITLE_MARGIN_R", 40, minimum=0),
"subtitle_alignment": env_int("SUBTITLE_ALIGNMENT", 2, minimum=1),
"subtitle_max_chars": env_int("SUBTITLE_MAX_CHARS", 20, minimum=6),
"subtitle_play_res_x": env_int("SUBTITLE_PLAY_RES_X", 1280, minimum=1),
"subtitle_play_res_y": env_int("SUBTITLE_PLAY_RES_Y", 720, minimum=1),
}
SCRIPT_DIR = Path(__file__).parent
PROMPTS_DIR = SCRIPT_DIR.parent / "references"
def log(msg):
print(f"[video-recap] {msg}", flush=True)
def run_cmd(cmd, **kwargs):
"""运行命令,返回 CompletedProcess"""
if isinstance(cmd, list):
display_parts = []
for part in cmd:
text = str(part)
display_parts.append(text if len(text) <= 240 else text[:237] + "...")
display = " ".join(display_parts)
else:
display = str(cmd)
if len(display) > 2000:
display = display[:1997] + "..."
log(f"运行: {display}")
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
def get_video_duration(video_path):
"""获取视频时长(秒)"""
cmd = ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "csv=p=0", str(video_path)]
result = run_cmd(cmd)
if result.returncode != 0:
return 0.0
try:
return float(result.stdout.strip())
except (TypeError, ValueError):
return 0.0
def stable_json_dumps(value):
"""Serialize values deterministically for non-secret cache fingerprints."""
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
def stable_hash(value):
"""Return an md5 digest for deterministic JSON-serializable values."""
return hashlib.md5(stable_json_dumps(value).encode("utf-8")).hexdigest()
def file_fingerprint(path, chunk_size=1024 * 1024):
"""Return a full-content fingerprint for cache-correct identity checks.
This intentionally avoids mtime/path so copied videos or JSON artifacts can
be reused when their bytes are identical, while any byte change invalidates
the cache even if timestamps, size, head, or tail bytes are misleading.
"""
h = hashlib.sha256()
with open(os.fspath(path), "rb") as f:
for chunk in iter(lambda: f.read(chunk_size), b""):
h.update(chunk)
return h.hexdigest()
def video_fingerprint(video_path):
"""Full video content fingerprint used as the root pipeline asset print."""
return file_fingerprint(video_path)
def step_cache_key(video_path, step_name, params_fingerprint=""):
"""Build a cache key from video content, step name and step parameters."""
params_digest = params_fingerprint
if not isinstance(params_digest, str):
params_digest = stable_hash(params_digest)
payload = f"{video_fingerprint(video_path)}_{step_name}_{params_digest}"
return hashlib.md5(payload.encode("utf-8")).hexdigest()
def _retry_after_seconds(value, fallback):
"""Parse Retry-After seconds or HTTP-date; return fallback on malformed input."""
if not value:
return fallback
try:
return max(fallback, max(0, int(value)))
except (TypeError, ValueError):
pass
try:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(fallback, max(0, int((retry_at - datetime.now(timezone.utc)).total_seconds())))
except (TypeError, ValueError, IndexError, OverflowError):
return fallback
def _api_headers(api_provider=None, api_url=None, api_key=None):
"""Build MiMo auth headers (OpenAI-compatible chat/completions with an api-key header)."""
del api_provider, api_url # MiMo is the only provider; signature kept for call sites
key = CONFIG.get("api_key", "") if api_key is None else api_key
return {
"Content-Type": "application/json",
"User-Agent": "video-recap/1.0",
"api-key": key,
}
def _prepare_api_payload(payload, api_provider=None, api_url=None):
"""Normalize payload fields for MiMo's OpenAI-compatible chat/completions API."""
del api_provider, api_url
normalized = dict(payload)
if "max_tokens" in normalized and "max_completion_tokens" not in normalized:
normalized["max_completion_tokens"] = normalized.pop("max_tokens")
model = str(normalized.get("model") or "")
if (
CONFIG.get("mimo_disable_thinking", True)
and not model.endswith(("-tts", "-asr"))
and "thinking" not in normalized
):
# MiMo V2.5 may spend small max_completion_tokens budgets on reasoning_content.
# The recap pipeline needs visible text, so disable thinking unless set explicitly.
normalized["thinking"] = {"type": "disabled"}
return normalized
def _mimo_endpoint(kind):
"""Return per-capability MiMo endpoint settings (video understanding / TTS / ASR)."""
by_kind = {
"video": ("mimo_video_api_url", "mimo_video_api_key", "mimo_video_api_key_source"),
"tts": ("mimo_tts_api_url", "mimo_tts_api_key", "mimo_tts_api_key_source"),
"asr": ("mimo_asr_api_url", "mimo_asr_api_key", "mimo_asr_api_key_source"),
}
if kind not in by_kind:
raise ValueError(f"Unsupported MiMo endpoint kind: {kind}")
url_key, key_key, src_key = by_kind[kind]
return {
"api_url": CONFIG.get(url_key) or CONFIG.get("mimo_api_url"),
"api_key": CONFIG.get(key_key) or CONFIG.get("mimo_api_key"),
"api_key_source": CONFIG.get(src_key, "MIMO_API_KEY"),
}
def _call_mimo_endpoint(kind, payload, max_retries=10):
settings = _mimo_endpoint(kind)
return api_call(
payload,
max_retries=max_retries,
api_provider="mimo",
api_url=settings["api_url"],
api_key=settings["api_key"],
api_key_source=settings["api_key_source"],
)
def mimo_video_api_call(payload, max_retries=10):
"""Call the MiMo video-understanding endpoint."""
return _call_mimo_endpoint("video", payload, max_retries=max_retries)
def mimo_tts_api_call(payload, max_retries=10):
"""Call the MiMo TTS endpoint."""
return _call_mimo_endpoint("tts", payload, max_retries=max_retries)
def mimo_asr_api_call(payload, max_retries=10):
"""Call the MiMo speech-recognition (ASR) endpoint."""
return _call_mimo_endpoint("asr", payload, max_retries=max_retries)
def api_call(payload, max_retries=8, *, api_provider=None, api_url=None, api_key=None, api_key_source=None):
"""调用 OpenAI-compatible API,带重试。
长视频理解会发出数百次 VLM/ASR 调用,集群的 429 限流是常态而非错误,所以重试更耐心
(更多次数 + 退避封顶 60s + 遵从 Retry-After),避免一次瞬时限流就中止整段理解。
集群的配额窗口常以分钟计,所以 429 在没有 Retry-After 时也至少等 10s,给窗口时间复位。
"""
endpoint = normalize_api_url(api_url if api_url is not None else CONFIG["api_url"])
headers = _api_headers(api_provider=api_provider, api_url=endpoint, api_key=api_key)
data = json.dumps(_prepare_api_payload(payload, api_provider=api_provider, api_url=endpoint)).encode("utf-8")
for attempt in range(max_retries):
try:
req = urllib.request.Request(endpoint, data=data, headers=headers)
with urllib.request.urlopen(req, timeout=300) as resp:
result = json.loads(resp.read().decode("utf-8"))
return result
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:500]
wait = min(2 ** attempt, 60)
if e.code == 429:
retry_after = e.headers.get("Retry-After")
wait = _retry_after_seconds(retry_after, max(wait, 10))
log(f"API 速率限制 (尝试 {attempt+1}/{max_retries}), 等待 {wait}s")
elif e.code == 401:
key_name = api_key_source or CONFIG.get("api_key_source", "MIMO_API_KEY")
raise RuntimeError(f"API 认证失败 (401)。请检查 {key_name} 和 API URL 是否匹配。")
elif e.code == 403:
hint = "API 访问被拒绝 (403)。"
if "1010" in body or "cloudflare" in body.lower():
hint += "IP 被 Cloudflare 限流,请等待几分钟后重试。"
raise RuntimeError(hint)
hint += "请检查 API key 权限和 API URL 设置。"
raise RuntimeError(hint)
elif e.code == 405:
raise RuntimeError("API 端点不可用 (405),可能被 WAF 拦截。请检查 MIMO_API_URL 或稍后重试。")
elif e.code == 503:
log(f"API 服务暂不可用 (503),等待 {wait}s (尝试 {attempt+1}/{max_retries})")
elif e.code == 524:
# Cloudflare 超时:服务端处理超时,需要更长退避
wait = max(wait, 4 * (attempt + 1))
log(f"API 超时 (524),等待 {wait}s (尝试 {attempt+1}/{max_retries})")
else:
log(f"API 调用失败 (尝试 {attempt+1}/{max_retries}): HTTP {e.code} — {body}")
if attempt < max_retries - 1:
time.sleep(wait)
else:
raise RuntimeError(f"API 调用失败 {max_retries} 次: HTTP {e.code} — {body}")
except (urllib.error.URLError, Exception) as e:
wait = min(2 ** attempt, 60)
log(f"API 调用失败 (尝试 {attempt+1}/{max_retries}): {e}")
if attempt < max_retries - 1:
log(f"等待 {wait}s 后重试...")
time.sleep(wait)
else:
raise RuntimeError(f"API 调用失败 {max_retries} 次: {e}")
def load_prompt(name):
"""加载 prompt 模板"""
path = PROMPTS_DIR / "prompt-templates.md"
if not path.exists():
return None
content = path.read_text(encoding="utf-8")
# 用 ### NAME 和 ### 分隔提取对应 prompt
pattern = rf"### {name}\s*\n(.*?)(?=\n### |\Z)"
m = re.search(pattern, content, re.DOTALL)
return m.group(1).strip() if m else None
#!/usr/bin/env python3
"""Storyboard (contact-sheet) generation for video-understanding.
Two ADVISORY artifacts that help the writing agent orient on the timeline by SCANNING
one image instead of opening dozens of frames:
- source_storyboard.{jpg,json} — scene-anchored tiles over the SOURCE timeline.
- edited_storyboard.{jpg,json} — one row per kept clip over the cut OUTPUT timeline,
each tile dual-labelled (output time / source time).
Both reuse the frames already extracted by understand.py (frames/frame_*.jpg at CONFIG["fps"]).
Nothing here re-extracts video. Every function returns dict|None and degrades to
None + log(...) on ANY failure (no frames, ffmpeg missing/non-zero, font probe raises),
so a storyboard quirk can NEVER block the pipeline (Principle 1: advisory, never blocking).
drawtext "mm:ss" labels are attempted when a usable font is found (labels_burned:true);
when no font is available (or drawtext errors) the sheet is still produced UNLABELLED
(labels_burned:false) and the JSON sidecar stays authoritative for all timestamps.
"""
import json
import math
import shutil
import subprocess
from pathlib import Path
from lib import CONFIG, run_cmd, log
try: # file_fingerprint is the project's content-fingerprint helper; reuse when cheap.
from lib import file_fingerprint
except ImportError: # pragma: no cover - lib always ships it; degrade gracefully if not.
file_fingerprint = None
# Candidate font files probed (in order) for burning mm:ss labels. The first that exists
# AND that drawtext can actually load wins. A probe that RAISES must never abort the sheet.
_FONT_CANDIDATES = (
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
)
def _fmt_mmss(seconds):
"""Format a timestamp as mm:ss (clamped to >= 0)."""
total = int(round(max(0.0, float(seconds))))
return f"{total // 60:02d}:{total % 60:02d}"
def _ffmpeg_available():
return shutil.which("ffmpeg") is not None
def _probe_font():
"""Return a usable font file path, or None. Any exception → None (never raises out).
Probe order: explicit candidate files first, then `fc-match` if available. The path
is only returned when the file exists on disk; we do NOT shell out to drawtext here —
a render-time drawtext error is caught separately and also degrades to unlabelled.
"""
try:
for candidate in _FONT_CANDIDATES:
if Path(candidate).is_file():
return candidate
fc_match = shutil.which("fc-match")
if fc_match:
result = subprocess.run(
[fc_match, "-f", "%{file}", "sans"],
capture_output=True, text=True, timeout=10,
)
path = (result.stdout or "").strip()
if result.returncode == 0 and path and Path(path).is_file():
return path
except Exception as exc: # noqa: BLE001 - a font probe must NEVER abort the sheet
log(f"storyboard 字体探测异常(降级为不烧时间戳): {exc}")
return None
return None
def _frame_index(work_dir):
"""Return (sorted_frame_paths, sorted_numbers) for frames/frame_*.jpg, or ([], [])."""
frames_dir = Path(work_dir) / "frames"
if not frames_dir.is_dir():
return [], []
pairs = []
for path in frames_dir.glob("frame_*.jpg"):
stem_parts = path.stem.split("_")
if len(stem_parts) != 2 or not stem_parts[1].isdigit():
continue
pairs.append((int(stem_parts[1]), path))
pairs.sort(key=lambda item: item[0])
numbers = [num for num, _ in pairs]
paths = [path for _, path in pairs]
return paths, numbers
def _nearest_existing_frame(timestamp, fps, paths, numbers):
"""Map a SOURCE timestamp → the nearest EXISTING frame file, clamped to [first,last].
Frames are named frame_{n:05d}.jpg with t = n / fps (vlm.py convention). Rounding a
timestamp blindly can yield a frame number that was never written (fps boundary / last
frame gap), so we resolve to the closest number that actually exists on disk.
"""
if not numbers:
return None
fps = float(fps)
if fps <= 0:
return None
target = float(timestamp) * fps
# Clamp into the real extracted range so out-of-range timestamps pin to first/last frame.
if target <= numbers[0]:
return paths[0]
if target >= numbers[-1]:
return paths[-1]
# numbers is sorted; find the closest by absolute distance (ties → earlier frame).
best_idx = 0
best_dist = None
for idx, num in enumerate(numbers):
dist = abs(num - target)
if best_dist is None or dist < best_dist:
best_dist = dist
best_idx = idx
elif num - target > best_dist:
break # sorted: distance only grows from here
return paths[best_idx]
def _scene_anchor_timestamps(scenes, max_tiles):
"""Scene-anchored sample timestamps: each scene midpoint; long scenes also +1/3 & +2/3.
Returns a list of (scene_id, timestamp). A scene is "long" when adding the thirds gives
materially distinct sample points; we treat scenes longer than `long_scene_seconds` as
long. Total is capped at max_tiles by evenly subsampling the ordered anchor list, so the
sheet stays legible (D2: one bounded contact sheet, not dozens of frame reads).
"""
long_scene_seconds = float(CONFIG.get("storyboard_long_scene_seconds", 6.0) or 6.0)
anchors = []
for scene_id, scene in enumerate(scenes or []):
try:
start = float(scene["start"])
end = float(scene["end"])
except (KeyError, TypeError, ValueError):
continue
if end <= start:
continue
mid = (start + end) / 2.0
if (end - start) >= long_scene_seconds:
third = start + (end - start) / 3.0
two_third = start + 2.0 * (end - start) / 3.0
points = [third, mid, two_third]
else:
points = [mid]
for ts in points:
anchors.append((scene_id, round(ts, 3)))
if max_tiles and len(anchors) > max_tiles:
# Evenly subsample to the cap, preserving timeline order and scene spread.
step = len(anchors) / float(max_tiles)
anchors = [anchors[int(i * step)] for i in range(max_tiles)]
return anchors
def _file_fp(path):
if file_fingerprint is None:
return None
try:
return file_fingerprint(path)
except (OSError, ValueError):
return None
def _labelled_frame(frame_path, label, font_path, scratch_dir, out_name):
"""Burn `label` onto a copy of frame_path via drawtext; return the labelled path or None.
None signals the caller to fall back to the original frame (and flip labels_burned off).
A drawtext failure here is non-fatal: the unlabelled frame still tiles fine.
"""
out_path = scratch_dir / out_name
safe_label = label.replace("\\", "\\\\").replace(":", "\\:").replace("'", "’")
drawtext = (
f"drawtext=fontfile='{font_path}':text='{safe_label}':"
"x=8:y=8:fontsize=28:fontcolor=white:"
"box=1:boxcolor=black@0.55:boxborderw=6"
)
cmd = ["ffmpeg", "-y", "-i", str(frame_path), "-vf", drawtext, "-frames:v", "1", str(out_path)]
try:
result = run_cmd(cmd)
except Exception as exc: # noqa: BLE001 - a label render must never abort the sheet
log(f"storyboard drawtext 异常(降级为不烧时间戳): {exc}")
return None
if result.returncode != 0 or not out_path.exists():
return None
return out_path
def _tile_pages(frame_paths, columns, out_dir, out_stem, scratch_dir):
"""Tile frame_paths into one or more contact-sheet pages; return the list of page paths.
ffmpeg's tile filter lays one grid per page. We page so each sheet holds at most
columns*rows tiles where rows is chosen to keep the grid roughly square but capped, then
spill into _001.jpg, _002.jpg… Returns [] on any ffmpeg failure (caller degrades to None).
"""
columns = max(1, int(columns))
rows_per_page = max(1, int(CONFIG.get("storyboard_rows_per_page", 5) or 5))
per_page = columns * rows_per_page
pages = []
total = len(frame_paths)
page_count = max(1, math.ceil(total / per_page))
for page_idx in range(page_count):
chunk = frame_paths[page_idx * per_page:(page_idx + 1) * per_page]
if not chunk:
continue
cols = min(columns, len(chunk))
rows = max(1, math.ceil(len(chunk) / cols))
if page_count == 1:
page_path = out_dir / f"{out_stem}.jpg"
else:
page_path = out_dir / f"{out_stem}_{page_idx + 1:03d}.jpg"
# Stage the chunk as a contiguous numbered sequence so ffmpeg's image2 demuxer +
# tile filter consume EXACTLY these frames (the source frame numbers are sparse).
seq_dir = scratch_dir / f"seq_{out_stem}_{page_idx:03d}"
seq_dir.mkdir(parents=True, exist_ok=True)
for seq_idx, src in enumerate(chunk):
shutil.copyfile(src, seq_dir / f"f_{seq_idx:05d}.jpg")
cmd = [
"ffmpeg", "-y",
"-framerate", "1",
"-i", str(seq_dir / "f_%05d.jpg"),
"-frames:v", "1",
"-vf", f"tile={cols}x{rows}",
str(page_path),
]
try:
result = run_cmd(cmd)
except Exception as exc: # noqa: BLE001
log(f"storyboard tile 异常: {exc}")
return []
if result.returncode != 0 or not page_path.exists():
log(f"storyboard tile 失败: {getattr(result, 'stderr', '')[-300:]}")
return []
pages.append(page_path)
return pages
def _render_storyboard(work_dir, tiles, out_stem, fps):
"""Shared render path: optionally burn labels, tile to pages, return (page_paths, labels_burned).
`tiles` is a list of dicts that ALREADY carry a resolved `frame_file` (absolute path) and a
`label` string. Returns (None, _) on hard failure so callers degrade to None.
"""
if not _ffmpeg_available():
log("storyboard 跳过:未找到 ffmpeg")
return None, False
storyboard_dir = Path(work_dir) / "storyboard"
storyboard_dir.mkdir(parents=True, exist_ok=True)
scratch_dir = storyboard_dir / f".scratch_{out_stem}"
if scratch_dir.exists():
shutil.rmtree(scratch_dir, ignore_errors=True)
scratch_dir.mkdir(parents=True, exist_ok=True)
font_path = _probe_font()
labels_burned = bool(font_path)
render_frames = []
if labels_burned:
for idx, tile in enumerate(tiles):
labelled = _labelled_frame(
Path(tile["frame_file"]), tile["label"], font_path, scratch_dir,
f"lbl_{out_stem}_{idx:05d}.jpg",
)
if labelled is None:
# First failure → abandon labelling entirely so the WHOLE sheet is consistent
# (no half-labelled pages). The JSON sidecar still carries every timestamp.
labels_burned = False
break
render_frames.append(labelled)
if not labels_burned:
render_frames = [Path(tile["frame_file"]) for tile in tiles]
try:
pages = _tile_pages(
render_frames, CONFIG.get("storyboard_columns", 6),
storyboard_dir, out_stem, scratch_dir,
)
finally:
shutil.rmtree(scratch_dir, ignore_errors=True)
if not pages:
return None, labels_burned
return pages, labels_burned
def build_source_storyboard(work_dir, video_path, scenes, fps):
"""Scene-anchored SOURCE-timeline contact sheet. Returns dict|None.
Samples each scene midpoint (long scenes also +1/3,+2/3), maps every sample to the
nearest EXISTING extracted frame (clamped), tiles them, and writes
storyboard/source_storyboard.json. Returns None + log on any failure.
"""
try:
work_dir = Path(work_dir)
paths, numbers = _frame_index(work_dir)
if not paths:
log("storyboard 跳过 source:frames/ 为空或缺失")
return None
max_tiles = int(CONFIG.get("storyboard_max_tiles", 30) or 30)
columns = int(CONFIG.get("storyboard_columns", 6) or 6)
anchors = _scene_anchor_timestamps(scenes, max_tiles)
if not anchors:
log("storyboard 跳过 source:无可用场景锚点")
return None
tiles = []
for tile_id, (scene_id, ts) in enumerate(anchors):
frame = _nearest_existing_frame(ts, fps, paths, numbers)
if frame is None:
continue
tiles.append({
"tile_id": tile_id,
"timestamp": round(float(ts), 3),
"label": _fmt_mmss(ts),
"scene_id": scene_id,
"frame_file": str(frame),
})
if not tiles:
log("storyboard 跳过 source:未解析到任何帧")
return None
pages, labels_burned = _render_storyboard(work_dir, tiles, "source_storyboard", fps)
if not pages:
log("storyboard 跳过 source:拼贴失败")
return None
for tile in tiles:
tile["frame_file"] = Path(tile["frame_file"]).name
payload = {
"schema_version": 1,
"timeline": "source",
"video_path": str(video_path),
"video_fingerprint": _file_fp(video_path),
"fps": float(fps) if fps else None,
"labels_burned": labels_burned,
"page_images": [str(p) for p in pages],
"sample_policy": {
"max_tiles": max_tiles,
"columns": columns,
"anchors": "scene_midpoint+long_scene_thirds",
},
"tiles": tiles,
}
duration = get_video_duration_safe(video_path)
if duration:
payload["duration"] = round(duration, 3)
json_path = work_dir / "storyboard" / "source_storyboard.json"
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"storyboard source: {len(tiles)} tiles → {len(pages)} page(s), labels_burned={labels_burned}")
return payload
except Exception as exc: # noqa: BLE001 - advisory: never propagate
log(f"storyboard source 失败(忽略): {exc}")
return None
def get_video_duration_safe(video_path):
"""Best-effort source duration via ffprobe; returns None on any failure (advisory)."""
if not shutil.which("ffprobe"):
return None
try:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "csv=p=0", str(video_path)],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return None
return float((result.stdout or "").strip())
except (ValueError, OSError, subprocess.SubprocessError):
return None
def _clip_plan_clips(clip_plan_validated):
"""Return the clips list from a validated plan (list or {"clips":[...]})."""
if isinstance(clip_plan_validated, dict):
clips = clip_plan_validated.get("clips", [])
else:
clips = clip_plan_validated
return clips if isinstance(clips, list) else []
def _source_to_output(source_time, clip):
"""Forward affine source→output map for ONE clip (reimplements cut.py:319 locally).
output = clip.output_start + (src − clip.source_start), clamped to [output_start, output_end].
Read the authoritative numbers from clip_plan_validated.json; do NOT import cut.py.
"""
src = float(source_time)
out_start = float(clip["output_start"])
out_end = float(clip["output_end"])
mapped = out_start + (src - float(clip["source_start"]))
return round(max(out_start, min(mapped, out_end)), 3)
def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fps):
"""OUTPUT-timeline contact sheet: one row per kept clip over the cut. Returns dict|None.
For each kept clip, samples source start / mid / (end − 0.5s), maps each to the nearest
EXISTING SOURCE frame (SOURCE fps — frames are reused, NO re-extraction), and FRAME-IDENTITY
dedupes so a ≤1s clip yields 1-2 tiles (not 3 identical). Each tile is dual-labelled with
both `output_timestamp` and `source_timestamp` (+ `source_clip_id`). Writes
storyboard/edited_storyboard.json. Returns None + log on any failure.
"""
try:
work_dir = Path(work_dir)
paths, numbers = _frame_index(work_dir)
if not paths:
log("storyboard 跳过 edited:frames/ 为空或缺失")
return None
clips = _clip_plan_clips(clip_plan_validated)
if not clips:
log("storyboard 跳过 edited:clip_plan_validated 无 clips")
return None
columns = int(CONFIG.get("storyboard_columns", 6) or 6)
max_tiles = int(CONFIG.get("storyboard_max_tiles", 30) or 30)
tiles = []
seen_frames = set() # frame-identity dedupe (NOT luma de-dupe; that stays deferred)
tile_id = 0
for clip in clips:
try:
source_start = float(clip["source_start"])
source_end = float(clip["source_end"])
clip_id = clip.get("clip_id")
except (KeyError, TypeError, ValueError):
continue
if source_end <= source_start:
continue
mid = (source_start + source_end) / 2.0
end_sample = max(source_start, source_end - 0.5)
for src_ts in (source_start, mid, end_sample):
frame = _nearest_existing_frame(src_ts, fps, paths, numbers)
if frame is None:
continue
key = (clip_id, str(frame))
if key in seen_frames:
continue # same clip resolving to the same frame → drop the duplicate tile
seen_frames.add(key)
out_ts = _source_to_output(src_ts, clip)
tiles.append({
"tile_id": tile_id,
"output_timestamp": out_ts,
"source_timestamp": round(float(src_ts), 3),
"source_clip_id": clip_id,
"label": f"out {_fmt_mmss(out_ts)} / src {_fmt_mmss(src_ts)}",
"frame_file": str(frame),
})
tile_id += 1
if not tiles:
log("storyboard 跳过 edited:未解析到任何帧")
return None
if len(tiles) > max_tiles:
tiles = tiles[:max_tiles]
pages, labels_burned = _render_storyboard(work_dir, tiles, "edited_storyboard", fps)
if not pages:
log("storyboard 跳过 edited:拼贴失败")
return None
for tile in tiles:
tile["frame_file"] = Path(tile["frame_file"]).name
edited_source = Path(work_dir) / "edited_source.mp4"
payload = {
"schema_version": 1,
"timeline": "output",
"source_video_path": str(source_video_path),
"edited_video_path": str(edited_source) if edited_source.exists() else None,
"clip_plan_fingerprint": _clip_plan_fingerprint(clip_plan_validated),
"labels_burned": labels_burned,
"page_images": [str(p) for p in pages],
"sample_policy": {
"max_tiles": max_tiles,
"columns": columns,
"per_clip": "source_start+mid+(end-0.5s), frame-identity deduped",
},
"tiles": tiles,
}
json_path = work_dir / "storyboard" / "edited_storyboard.json"
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"storyboard edited: {len(tiles)} tiles → {len(pages)} page(s), labels_burned={labels_burned}")
return payload
except Exception as exc: # noqa: BLE001 - advisory: never propagate
log(f"storyboard edited 失败(忽略): {exc}")
return None
def _clip_plan_fingerprint(clip_plan_validated):
"""Stable fingerprint of the validated plan that drives the edited tiles."""
try:
return _stable_hash(clip_plan_validated)
except Exception: # noqa: BLE001
return None
def _stable_hash(value):
import hashlib
blob = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.md5(blob.encode("utf-8")).hexdigest()
import base64
import json
import mimetypes
import re
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from lib import CONFIG
from lib import log, api_call, load_prompt, mimo_video_api_call, run_cmd, file_fingerprint, stable_hash
# ── Step 4: VLM 视觉分析 ─────────────────────────────────────────────
def _parse_vlm_depth_response(raw_text):
"""解析 VLM 深度分析响应,提取【描述】、【帧标签】和【深层分析】"""
if not raw_text or not raw_text.strip():
return "(VLM 无法识别此场景画面)", "", {}
# 提取【描述】
desc_match = re.search(r'【描述】\s*\n?(.*?)(?=【帧标签】|【深层分析】|$)', raw_text, re.DOTALL)
if desc_match:
description = desc_match.group(1).strip()
else:
description = raw_text.strip()
# 提取【帧标签】
frame_facts = {}
facts_match = re.search(r'【帧标签】\s*\n?(.*?)(?=【深层分析】|$)', raw_text, re.DOTALL)
if facts_match:
for line in facts_match.group(1).strip().split("\n"):
line = line.strip()
if not line:
continue
# 格式: "12.0s | 男子拿起茶壶对嘴喝, 满脸疲惫"
m = re.match(r'([\d.]+)\s*s?\s*\|\s*(.+)', line)
if m:
ts = m.group(1)
actions = [a.strip() for a in re.split(r"[,,;;、]+", m.group(2)) if a.strip()]
if actions:
frame_facts[ts] = actions
# 提取【深层分析】
depth_match = re.search(r'【深层分析】\s*\n?(.*?)$', raw_text, re.DOTALL)
depth_analysis = depth_match.group(1).strip() if depth_match else ""
if not description:
description = "(VLM 无法识别此场景画面)"
return description, depth_analysis, frame_facts
def _max_frames_for_duration(duration):
"""Frames the VLM sees for one scene: ~1 per `vlm_seconds_per_frame`, floor 3, capped by
`vlm_max_frames`. Replaces the old hard cap of 6 that starved long/merged scenes."""
spf = float(CONFIG.get("vlm_seconds_per_frame", 4.0) or 4.0)
ceiling = int(CONFIG.get("vlm_max_frames", 16) or 16)
return max(3, min(ceiling, round(max(0.0, float(duration)) / spf)))
def _vlm_scene_cache_path(work_dir):
return Path(work_dir) / "vlm_scene_cache.json"
def _load_vlm_scene_cache(work_dir):
"""Per-scene VLM resume cache (scene_key -> analysis). Tolerant: {} if absent/corrupt."""
path = _vlm_scene_cache_path(work_dir)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _flush_vlm_scene_cache(work_dir, cache):
"""Persist the resume cache atomically (temp + rename) so an abort/crash never corrupts it."""
path = _vlm_scene_cache_path(work_dir)
try:
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
except OSError as exc:
log(f"VLM 场景缓存写入失败(忽略): {exc}")
def _looks_rate_limited(message):
"""Heuristic: was a scene failure a transient rate-limit (worth a low-concurrency retry) rather
than a persistent error (empty response / parse failure) that re-running would not fix?"""
m = str(message).lower()
return "429" in m or "too many requests" in m or "rate limit" in m or "限流" in m
def analyze_scenes(scenes, frames, work_dir, *, resume=True):
"""对每个场景的关键帧调用 VLM 进行视觉分析(并行)"""
if not scenes:
analyses = []
vlm_file = work_dir / "vlm_analysis.json"
vlm_file.write_text(json.dumps(analyses, ensure_ascii=False, indent=2), encoding="utf-8")
log("VLM 分析完成: 0 个场景")
return analyses
if not frames:
raise RuntimeError("VLM 分析需要先提取至少一帧;frames 为空")
fps = CONFIG["fps"]
if fps <= 0:
raise ValueError("CONFIG['fps'] 必须大于 0;请先运行完整 pipeline 或指定 --fps")
vlm_prompt = load_prompt("VLM_DEPTH_PROMPT")
if not vlm_prompt:
vlm_prompt = "仔细观察这些视频帧。分两部分输出:\n【描述】不超过80字,描述画面中正在发生什么。\n【深层分析】不超过120字,分析角色情绪、关系动态、潜台词。"
ctx = CONFIG.get("context_info", "")
if ctx:
vlm_prompt = f"已知信息:{ctx}\n\n{vlm_prompt}"
# 构建帧时间映射 (frame_NNNNN.jpg -> time in seconds)
frame_times = {}
for f in frames:
parts = f.stem.split("_")
if len(parts) != 2 or not parts[1].isdigit():
continue
num = int(parts[1])
t = num / fps
frame_times[f] = t
# base64 编码缓存
b64_cache = {}
def _get_b64(frame_path):
if frame_path not in b64_cache:
b64_cache[frame_path] = base64.b64encode(frame_path.read_bytes()).decode()
return b64_cache[frame_path]
def _analyze_single_scene(i, scene):
"""分析单个场景,返回 (scene_id, result_dict)"""
scene_frames = [f for f, t in frame_times.items()
if scene["start"] <= t <= scene["end"]]
if not scene_frames:
mid = (scene["start"] + scene["end"]) / 2
scene_frames = [min(frames, key=lambda f: abs(frame_times.get(f, 999) - mid))]
duration = scene["end"] - scene["start"]
max_frames = _max_frames_for_duration(duration)
if len(scene_frames) > max_frames:
step = len(scene_frames) / max_frames
scene_frames = [scene_frames[int(j * step)] for j in range(max_frames)]
else:
scene_frames = scene_frames[:max_frames]
content_parts = []
for f in scene_frames:
b64 = _get_b64(f)
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}
})
# 帧级事实标签:将帧时间注入prompt
frame_ts_list = [f"{frame_times[f]:.1f}s" for f in scene_frames]
frame_ts_text = "帧时间点: " + ", ".join(frame_ts_list)
content_parts.append({"type": "text", "text": frame_ts_text + "\n\n" + vlm_prompt})
payload = {
"model": CONFIG["vlm_model"],
"messages": [{"role": "user", "content": content_parts}],
"max_tokens": int(CONFIG.get("vlm_max_tokens", 1500) or 1500),
}
log(f"VLM 分析场景 {i+1}/{len(scenes)} ({len(scene_frames)} 帧)...")
raw_response = ""
for attempt in range(3):
resp = api_call(payload)
try:
msg = resp["choices"][0]["message"]
raw_response = (msg.get("content") or msg.get("reasoning_content") or "")
except (KeyError, IndexError):
log(f"VLM 返回异常: {json.dumps(resp, ensure_ascii=False)[:200]}")
if raw_response.strip():
break
if attempt < 2:
log(f" 场景 {i+1} VLM 返回空,重试 ({attempt+2}/3)...")
retry_parts = content_parts[:-1]
retry_parts.append({"type": "text", "text": frame_ts_text + "\n\n" + vlm_prompt + "\n请务必按格式输出,不要留空。"})
payload = {
"model": CONFIG["vlm_model"],
"messages": [{"role": "user", "content": retry_parts}],
"max_tokens": int(CONFIG.get("vlm_max_tokens", 1500) or 1500),
}
if not raw_response.strip():
raise RuntimeError("VLM 连续 3 次返回空内容")
# 解析 【描述】、【帧标签】和【深层分析】
description, depth_analysis, frame_facts = _parse_vlm_depth_response(raw_response)
result = {
"scene_id": i,
"start": scene["start"],
"end": scene["end"],
"description": description,
"depth_analysis": depth_analysis,
}
if frame_facts:
result["frame_facts"] = frame_facts
return i, result
# 断点续传:逐场景持久化分析结果,避免少数场景失败(多为 429 限流)时整批画面理解全部作废。
cache = _load_vlm_scene_cache(work_dir) if resume else {}
prompt_fp = stable_hash(vlm_prompt)
def _scene_cache_key(i, scene):
# Must invalidate on the SAME output-affecting settings the outer stage gate tracks
# (understand.py:_vlm_cache_payload). prompt_fp already covers vlm_prompt + context_info
# (which folds in background_research). The three below change the request/endpoint but
# are NOT in vlm_prompt, so they must be keyed explicitly — otherwise a partial-failure
# cache reused after flipping e.g. mimo_disable_thinking yields a stale/mixed analysis.
return "|".join(str(x) for x in (
i, round(float(scene["start"]), 3), round(float(scene["end"]), 3),
CONFIG.get("vlm_model"), prompt_fp, CONFIG.get("vlm_max_tokens"),
CONFIG.get("vlm_seconds_per_frame"), CONFIG.get("vlm_max_frames"),
round(float(fps), 3),
CONFIG.get("api_url"), CONFIG.get("mimo_disable_thinking", True),
CONFIG.get("mimo_media_resolution"),
))
analyses = [None] * len(scenes)
todo = []
for i, s in enumerate(scenes):
cached = cache.get(_scene_cache_key(i, s))
analyses[i] = cached if isinstance(cached, dict) else None
if analyses[i] is None:
todo.append(i)
if todo and len(todo) < len(scenes):
log(f"VLM 复用 {len(scenes) - len(todo)} 个已缓存场景,待分析 {len(todo)} 个")
def _run_pass(indices, workers):
"""分析给定场景索引;每完成一个就把结果写入续传缓存(在主线程,无需加锁)。返回 (i, err) 失败列表。"""
if not indices:
return []
failures = []
with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
futures = {executor.submit(_analyze_single_scene, i, scenes[i]): i for i in indices}
for future in as_completed(futures):
i = futures[future]
try:
idx, result = future.result()
analyses[idx] = result
cache[_scene_cache_key(idx, scenes[idx])] = result
_flush_vlm_scene_cache(work_dir, cache) # 持久化进度,崩溃/中止后可续传
except Exception as e: # noqa: BLE001 - 单个场景失败不能拖垮其余已完成的
log(f"VLM 场景 {i+1} 分析失败: {e}")
failures.append((i, str(e)))
return failures
base_workers = min(len(todo) or 1, int(CONFIG.get("vlm_workers", 4) or 4))
log(f"VLM 并行分析 {len(todo)} 个场景 (workers={base_workers})...")
failures = _run_pass(todo, base_workers)
if failures:
# 限流(429)多是并发瞬时拥塞,降并发重试一轮(已成功的从缓存跳过);其它错误(空响应/解析失败)
# 重试无益,直接保留为失败,不浪费一轮调用。
rate_limited = [i for i, msg in failures if _looks_rate_limited(msg)]
persistent = [(i, msg) for i, msg in failures if not _looks_rate_limited(msg)]
if rate_limited:
retry_workers = max(1, base_workers // 4)
log(f"VLM {len(rate_limited)} 个场景疑似限流(429),降并发到 {retry_workers} 重试...")
persistent += _run_pass(rate_limited, retry_workers)
failures = persistent
if failures:
sample = "; ".join(f"场景 {i+1}: {msg}" for i, msg in failures[:3])
raise RuntimeError(
f"VLM 分析失败 {len(failures)}/{len(scenes)} 个场景。其余已缓存到 vlm_scene_cache.json,"
f"重跑可断点续传(只重试失败场景)。示例: {sample}"
)
vlm_file = work_dir / "vlm_analysis.json"
vlm_file.write_text(json.dumps(analyses, ensure_ascii=False, indent=2), encoding="utf-8")
_vlm_scene_cache_path(work_dir).unlink(missing_ok=True) # 完整理解已生成,续传缓存可清理
log(f"VLM 分析完成: {len(analyses)} 个场景")
return analyses
def _video_data_url(video_path):
"""Return a MiMo-compatible data URL for a local video chunk, or None when too large."""
max_bytes = int(float(CONFIG.get("mimo_video_base64_max_mb", 45.0)) * 1024 * 1024)
encoded_size = int(video_path.stat().st_size * 4 / 3) + 128
if encoded_size > max_bytes:
log(
"MiMo 视频分片超过 base64 上限: "
f"编码后约 {encoded_size / 1024 / 1024:.1f}MB,超过限制 {max_bytes / 1024 / 1024:.1f}MB;"
"请降低 MIMO_VIDEO_CHUNK_MAX_SECONDS 或 MIMO_VIDEO_FPS"
)
return None
mime_type = mimetypes.guess_type(str(video_path))[0] or "video/mp4"
encoded = base64.b64encode(video_path.read_bytes()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
def _mimo_video_chunks(scenes):
"""Build local MiMo video-understanding chunks from ffmpeg scene boundaries."""
if not scenes:
raise RuntimeError("MiMo 视频分片理解需要 scenes;请先运行 ffmpeg scene/scdet 场景检测")
max_seconds = float(CONFIG.get("mimo_video_chunk_max_seconds", 20.0) or 20.0)
min_seconds = float(CONFIG.get("mimo_video_chunk_min_seconds", 1.0) or 1.0)
chunks = []
for scene_index, scene in enumerate(scenes):
try:
start = float(scene.get("start", 0.0))
end = float(scene.get("end", start))
except (TypeError, ValueError, AttributeError):
continue
if end <= start:
continue
scene_id = scene.get("scene_id", scene_index) if isinstance(scene, dict) else scene_index
cursor = start
while cursor < end:
chunk_end = min(end, cursor + max_seconds)
if end - chunk_end < min_seconds and chunk_end < end:
chunk_end = end
if chunk_end > cursor:
chunks.append({
"chunk_id": len(chunks),
"scene_id": scene_id,
"start": round(cursor, 3),
"end": round(chunk_end, 3),
})
cursor = chunk_end
if not chunks:
raise RuntimeError("MiMo 视频分片理解没有可用分片;请检查 scenes.json")
return chunks
def _extract_video_chunk(video_path, chunk, output_path):
"""Cut one scene-based chunk into a compact local MP4 for MiMo video_url data URL."""
start = float(chunk["start"])
duration = max(0.1, float(chunk["end"]) - start)
fps = float(CONFIG.get("mimo_video_fps", 2.0) or 2.0)
cmd = [
"ffmpeg", "-y",
"-ss", f"{start:.3f}",
"-t", f"{duration:.3f}",
"-i", str(video_path),
"-map", "0:v:0",
"-an",
"-vf", f"fps={fps:g}",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "30",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
str(output_path),
]
result = run_cmd(cmd, timeout=CONFIG.get("mimo_video_chunk_timeout", 180))
if result.returncode != 0:
raise RuntimeError(f"MiMo 视频分片裁剪失败: {result.stderr[-500:]}")
return output_path
def _mimo_chunk_prompt(chunk):
return (
f"这是原视频 {chunk['start']:.1f}s-{chunk['end']:.1f}s 的场景分片,"
f"scene_id={chunk['scene_id']}。"
f"{CONFIG.get('mimo_video_prompt', '请用中文概括这个视频分片。')}"
)
def _mimo_video_model():
"""MiMo 视频理解使用的模型:优先 mimo_video_model,回退 mimo_model,再回退 vlm_model。"""
return CONFIG.get("mimo_video_model") or CONFIG.get("mimo_model") or CONFIG["vlm_model"]
def mimo_video_settings_fingerprint():
"""Return non-secret MiMo video-overview settings that affect generated content."""
return {
"model": _mimo_video_model(),
"mimo_video_api_url": CONFIG.get("mimo_video_api_url"),
"mimo_video_fps": CONFIG.get("mimo_video_fps", 2.0),
"mimo_media_resolution": CONFIG.get("mimo_media_resolution", "default"),
"mimo_video_chunk_max_seconds": CONFIG.get("mimo_video_chunk_max_seconds", 20.0),
"mimo_video_chunk_min_seconds": CONFIG.get("mimo_video_chunk_min_seconds", 1.0),
"mimo_video_base64_max_mb": CONFIG.get("mimo_video_base64_max_mb", 45.0),
"mimo_video_prompt": CONFIG.get("mimo_video_prompt", ""),
"mimo_disable_thinking": CONFIG.get("mimo_disable_thinking", True),
}
def _mimo_chunk_cache_key(chunk):
"""Stable identifier for a MiMo chunk (index + scene span) for partial-cache reuse."""
return (
f"{chunk['chunk_id']}|{chunk['scene_id']}|"
f"{float(chunk['start']):.3f}-{float(chunk['end']):.3f}"
)
def _mimo_cached_chunks_fingerprint(done):
return stable_hash(done)
def _mimo_overview_payload_fingerprint(overview):
payload = dict(overview)
payload.pop("overview_fingerprint", None)
return stable_hash(payload)
def _mimo_partial_provenance(video_path, scenes):
return {
"source_video_fingerprint": file_fingerprint(video_path),
"chunks": [_mimo_chunk_cache_key(chunk) for chunk in _mimo_video_chunks(scenes)],
}
def _load_mimo_partial(partial_path, video_path=None, scenes=None):
"""Load the internal partial chunk cache, keyed by chunk identifier.
Returns {} when missing/unreadable or when settings/source/chunk provenance differs,
so a changed source video or scene plan cannot reuse paid chunks from another run.
"""
if not partial_path.exists():
return {}
try:
partial = json.loads(partial_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
if not isinstance(partial, dict):
return {}
if partial.get("settings") != mimo_video_settings_fingerprint():
return {}
if video_path is not None or scenes is not None:
try:
if partial.get("provenance") != _mimo_partial_provenance(video_path, scenes):
return {}
except (OSError, RuntimeError, TypeError, ValueError):
return {}
done = partial.get("chunks")
if not isinstance(done, dict):
return {}
recorded = partial.get("chunks_fingerprint")
if not recorded or recorded != _mimo_cached_chunks_fingerprint(done):
return {}
return done
def _save_mimo_partial(partial_path, done, video_path=None, scenes=None):
"""Persist completed chunk results incrementally so paid chunks survive a mid-loop failure."""
payload = {
"settings": mimo_video_settings_fingerprint(),
"chunks": done,
"chunks_fingerprint": _mimo_cached_chunks_fingerprint(done),
}
if video_path is not None or scenes is not None:
payload["provenance"] = _mimo_partial_provenance(video_path, scenes)
partial_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _mimo_chunks_match(cached_chunks, expected_chunks):
if not isinstance(cached_chunks, list) or len(cached_chunks) != len(expected_chunks):
return False
try:
cached_keys = [_mimo_chunk_cache_key(chunk) for chunk in cached_chunks]
expected_keys = [_mimo_chunk_cache_key(chunk) for chunk in expected_chunks]
except (KeyError, TypeError, ValueError):
return False
return cached_keys == expected_keys
def mimo_video_overview_cache_fresh(overview_path, video_path, scenes):
"""Return True only when the final MiMo overview matches current inputs/settings."""
overview_path = Path(overview_path) if not hasattr(overview_path, "read_text") else overview_path
if not overview_path.exists():
return False
try:
overview = json.loads(overview_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return False
if not isinstance(overview, dict) or overview.get("input") != "scene_chunks":
return False
if overview.get("settings") != mimo_video_settings_fingerprint():
return False
overview_fingerprint = overview.get("overview_fingerprint")
if not overview_fingerprint or overview_fingerprint != _mimo_overview_payload_fingerprint(overview):
return False
recorded = overview.get("chunks_fingerprint")
if not recorded:
return False
chunks = overview.get("chunks")
if not isinstance(chunks, list) or not all(
isinstance(chunk, dict) and _is_mimo_chunk_usable(chunk.get("content"))
for chunk in chunks
):
return False
if recorded != _mimo_cached_chunks_fingerprint(chunks):
return False
try:
if overview.get("source_video_fingerprint") != file_fingerprint(video_path):
return False
expected_chunks = _mimo_video_chunks(scenes)
except (OSError, RuntimeError, TypeError, ValueError):
return False
return _mimo_chunks_match(chunks, expected_chunks)
def _analyze_mimo_video_chunk(chunk_path, chunk):
video_url = _video_data_url(chunk_path)
if not video_url:
raise RuntimeError(f"MiMo 视频分片 {chunk['chunk_id'] + 1} 超过 data URL 上限")
content_parts = [
{
"type": "video_url",
"video_url": {"url": video_url},
"fps": CONFIG.get("mimo_video_fps", 2.0),
"media_resolution": CONFIG.get("mimo_media_resolution", "default"),
},
{"type": "text", "text": _mimo_chunk_prompt(chunk)},
]
model = _mimo_video_model()
payload = {
"model": model,
"messages": [{"role": "user", "content": content_parts}],
"max_tokens": 1200,
}
resp = mimo_video_api_call(payload)
try:
msg = resp["choices"][0]["message"]
except (KeyError, IndexError, TypeError) as exc:
raise RuntimeError("MiMo 视频分片理解响应缺少 choices[0].message") from exc
return {
"chunk_id": chunk["chunk_id"],
"scene_id": chunk["scene_id"],
"start": chunk["start"],
"end": chunk["end"],
"model": resp.get("model", model),
"content": msg.get("content", ""),
"reasoning_content": msg.get("reasoning_content", ""),
"usage": resp.get("usage", {}),
"clip_path": f"mimo_video_chunks/{chunk_path.name}",
}
_MIMO_REJECTION_MARKERS = (
"request was rejected", "considered high risk", "high risk",
"content policy", "cannot process", "无法处理", "内容审核", "违规",
)
def _is_mimo_chunk_usable(content):
"""A chunk is usable only if MiMo returned real analysis (not empty / a moderation refusal)."""
text = str(content or "").strip()
if not text:
return False
low = text.lower()
return not any(marker in low for marker in _MIMO_REJECTION_MARKERS)
def analyze_video_overview(video_path, work_dir, scenes=None):
"""Use MiMo video understanding over local ffmpeg scene chunks."""
if not CONFIG.get("mimo_video_overview", False):
return None
if not CONFIG.get("mimo_video_api_key"):
log("MiMo 视频概览已启用,但未设置 MIMO_VIDEO_API_KEY/MIMO_API_KEY,跳过")
return None
chunks = _mimo_video_chunks(scenes)
chunks_dir = work_dir / "mimo_video_chunks"
chunks_dir.mkdir(exist_ok=True)
# 增量缓存:已完成的分片先落盘,避免一段失败时丢弃所有已付费分片
partial_path = work_dir / "mimo_video_overview.partial.json"
done = _load_mimo_partial(partial_path, video_path, scenes)
log(f"MiMo 视频理解:按 ffmpeg scene 分片分析 {len(chunks)} 段...")
chunk_results = []
unusable_chunks = []
removed_stale_cache = False
for chunk in chunks:
cache_key = _mimo_chunk_cache_key(chunk)
cached = done.get(cache_key)
if cached is not None:
if _is_mimo_chunk_usable(cached.get("content")):
log(
f" MiMo 分片 {chunk['chunk_id'] + 1}/{len(chunks)}: "
f"{chunk['start']:.1f}-{chunk['end']:.1f}s(命中增量缓存,跳过)"
)
chunk_results.append(cached)
continue
done.pop(cache_key, None)
removed_stale_cache = True
chunk_path = chunks_dir / (
f"chunk_{chunk['chunk_id']:03d}_scene_{chunk['scene_id']}_"
f"{chunk['start']:.2f}-{chunk['end']:.2f}.mp4"
)
_extract_video_chunk(video_path, chunk, chunk_path)
log(
f" MiMo 分片 {chunk['chunk_id'] + 1}/{len(chunks)}: "
f"{chunk['start']:.1f}-{chunk['end']:.1f}s"
)
chunk_result = _analyze_mimo_video_chunk(chunk_path, chunk)
if _is_mimo_chunk_usable(chunk_result.get("content")):
chunk_results.append(chunk_result)
done[cache_key] = chunk_result
_save_mimo_partial(partial_path, done, video_path, scenes)
else:
unusable_chunks.append(chunk)
log(f" MiMo 分片 {chunk['chunk_id'] + 1}: 未返回有效内容,保留为待重试")
if removed_stale_cache:
_save_mimo_partial(partial_path, done, video_path, scenes)
if not chunk_results:
log(f"MiMo 视频概览:{len(chunks)} 段均无有效内容(疑似被内容审核拦截),跳过概览")
try:
partial_path.unlink()
except OSError:
pass
return None
if unusable_chunks:
# Degrade gracefully instead of aborting the whole understanding: some chunks get
# moderation-rejected (e.g. a burned-in watermark / violent frames). Write the overview
# from the usable chunks; the scenes whose chunks were rejected simply fall back to the
# frame-VLM description downstream. (Aborting here would make overview unsafe to enable
# by default on any moderated source.)
sample = ", ".join(str(chunk["chunk_id"] + 1) for chunk in unusable_chunks[:5])
log(
f"MiMo 视频概览:{len(unusable_chunks)}/{len(chunks)} 段无有效内容(疑似内容审核),"
f"以可用分片降级产出,未覆盖场景回退到逐帧描述。样例分片: {sample}"
)
content = "\n\n".join(
f"### 分片 {item['chunk_id'] + 1} "
f"(scene {item['scene_id']}, {item['start']:.1f}-{item['end']:.1f}s)\n"
f"{item['content'].strip()}"
for item in chunk_results
)
overview = {
"model": _mimo_video_model(),
"content": content,
"chunks": chunk_results,
"chunk_count": len(chunk_results),
"fps": CONFIG.get("mimo_video_fps", 2.0),
"media_resolution": CONFIG.get("mimo_media_resolution", "default"),
"input": "scene_chunks",
"partial": bool(unusable_chunks),
"unusable_chunk_count": len(unusable_chunks),
"source_video_fingerprint": file_fingerprint(video_path),
"chunk_max_seconds": CONFIG.get("mimo_video_chunk_max_seconds", 20.0),
"settings": mimo_video_settings_fingerprint(),
"chunks_fingerprint": _mimo_cached_chunks_fingerprint(chunk_results),
}
overview["overview_fingerprint"] = _mimo_overview_payload_fingerprint(overview)
overview_path = work_dir / "mimo_video_overview.json"
overview_path.write_text(json.dumps(overview, ensure_ascii=False, indent=2), encoding="utf-8")
# 所有分片完成后清理增量缓存,保持 work_dir 仅有规范产物
try:
partial_path.unlink()
except OSError:
pass
log(f"MiMo 分片视频概览完成: {overview_path}")
return overview