跳转至

video-edit

第5章 · Coding Agent 与代码生成 · 配套项目 chapter5/video-edit

项目说明

实验 5-6:基于 API 的智能视频剪辑

《深入理解 AI Agent》配套实验。用户给一段含多个场景的视频 + 一句自然语言需求 (如"把冲浪部分剪出来"),Agent 自动定位目标场景、生成 Blender Python API 脚本 剪出片段并自我审查。

目的

验证三个核心机制在多媒体处理中的作用:

  1. 两步 Vision 定位:Proposer 无法直接"看懂"视频,于是委托一个视频分析子 Agent, 用 ffmpeg 抽帧 + Vision LLM 读图来定位目标场景的时间边界。
  2. 代码生成(Blender Python API):Proposer 把剪辑计划翻译成一段调用 Blender Python API(bpy) 的脚本——导入 / 裁剪 / 字幕 / 变速 / 渲染各对应一个 API 调用, 用 blender --background --python edit.py 无头执行。这正是书中"把视频编辑重构为 API 调用和代码生成问题"的落地。未装 Blender 时脚本照常生成(代码生成产物), 实际渲染回退到 ffmpeg(见下文"剪辑后端")。
  3. 提议者-审核者(Proposer / Reviewer):Proposer 剪辑后无法自证效果, 由 Reviewer 抽取成片关键帧、用 Vision LLM 检查是否剪对,不合格则反馈、迭代。

两步定位原理

Vision LLM 逐帧扫全片既慢又贵,因此采用"先粗后细":

  • 第一步(粗粒度):每 10 秒抽一帧,把全片的稀疏截图连同"要找哪个场景"一起 交给 Vision,得到大致区间(如"冲浪在 20–30s")。
  • 第二步(细粒度):在粗区间上下各外扩一个粗间隔,每 1 秒抽一帧, 再问 Vision 精确边界(如"15–29s")。

把这套抽帧-读图封装成独立子 Agent:几十张截图只进入子 Agent 的一次性上下文, 不会污染主 Agent(Proposer/Reviewer)的对话历史。demo 末尾会打印两者的 token 对比。

提议者-审核者

NL 需求 ──► Proposer 解析意图(目标场景 + 特效)
         视频分析子 Agent 两步定位 ──► [start, end]
         Proposer 生成 Blender bpy 脚本(edit.py)──► 渲染剪辑(可加字幕/慢动作)
              │                                        装了 Blender 用 bpy,否则回退 ffmpeg
         Reviewer 抽首/中/尾关键帧 ──► Vision 检查 pass/fail + 反馈
              │pass?  否 → Proposer 据反馈修正边界,重剪(最多 3 轮)
              ▼是
           输出成片 final.mp4

运行

pip install -r requirements.txt
cp env.example .env        # 填入 OPENAI_API_KEY(未配置时设 OPENROUTER_API_KEY 自动改走 OpenRouter)
python demo.py             # 默认需求"把冲浪的部分剪出来"(完整流程)
python demo.py "把滑雪部分剪出来,并加上字幕 Winter"   # 自定义需求
python demo.py -i my.mp4 -o out.mp4 "把演讲开场剪出来"  # 用自己的视频 + 自定义输出
python demo.py --backend blender   # 强制用 Blender Python API 无头渲染(需装 Blender)
python demo.py --vision-model gpt-5.6-luna  # 覆盖模型(也可用 --text-model)
python demo.py --quick     # 快速模式:粗采样 + 单轮审查,Vision 调用最少(省时省钱)
python demo.py --smoke     # 冒烟自检:仅剪辑链路 + 生成 bpy 脚本,不调用任何 API
python demo.py --help      # 查看全部参数

常用参数(完整见 --help):--input/-i 输入视频、--output/-o 成片路径、 --backend {auto,blender,ffmpeg} 剪辑后端、--text-model/--vision-model 覆盖模型。

一条命令即可跑通:生成/读取视频 → 两步定位 → 生成 bpy 脚本剪辑 → 审查 → 输出成片。 每次运行都会清空 output/,从干净状态开始(幂等可重复)。完整流程会多次调用 Vision 模型(较慢/耗费额度);只想验证链路时先跑 --smoke(零 API),或用 --quick

预期输出示例

--smoke(零 API,可复现)

以下为 python demo.py --smoke真实输出(无需 OpenAI Key,仅需 ffmpeg):

==========================================================================
  冒烟自检 | 剪辑链路 + bpy 脚本生成,不调用任何 API
==========================================================================
[1/3] 生成测试视频 OK:output/source.mp4(场景真值={'hiking': (0, 15), 'surfing': (15, 30), 'skiing': (30, 42), 'cycling': (42, 54)})
[2/3] 抽帧 OK:output/frames/smoke.png
[3/3] 剪辑+字幕 OK(后端=ffmpeg(未装 Blender,回退)):
  文件: smoke_cut.mp4
  时长: 5.03s
  容器: mov,mp4,m4a,3gp,3g2,mj2
  大小: 121.4 KB
  视频流: h264 1280x720 @ 30/1 fps
  音频流: aac 44100Hz 1ch

已生成 Proposer 的 Blender 脚本:output/edit.py
(这正是书中'生成 Blender Python API 代码'的产物;装好 Blender 后可直接
 `blender --background --python output/edit.py` 无头渲染。)

✓ 冒烟自检通过:剪辑链路正常 + bpy 脚本已生成(未调用 OpenAI)。

生成的 output/edit.py 是一段可执行的 Blender bpy 脚本new_movie 导入、 frame_offset_start/frame_final_duration 裁剪、new_effect(type='TEXT') 字幕、 bpy.ops.render.render 渲染),本机对其做过 py_compile 语法校验。

--quick(完整链路,需 API)

以下为 python demo.py --quick(默认需求"把冲浪的部分剪出来")的真实节选(定位/误差/ token 部分与剪辑后端无关,故不受 bpy/ffmpeg 后端切换影响):

步骤 1 | Proposer 解析自然语言需求
解析结果:目标场景='surfing scene'  特效=[]

步骤 2 | 视频分析子 Agent:两步 Vision 定位(--quick 快速采样)
  [粗粒度] 每 15s 采样 5 帧 → Vision 得区间 [15, 30]s(依据:The word 'SURFING' appears at t=15s and changes at t=30s.)
  [细粒度] 窗口 [0.0, 45.0] 内每 2s 采样 23 帧 → 精确边界 [16.0, 28.0]s
  >>> 最终定位:起 16.0s  止 28.0s
  真值 [15, 30]s → 起点误差 1.0s,终点误差 2.0s(验收要求 ≤ 3s)

步骤 3-4 | Proposer 剪辑 + Reviewer 审查(迭代)
  Proposer 剪出片段 [16.0, 28.0]s,成片时长 12.0s
  Reviewer:pass=... score=... 检查帧=['0.5', '6.0', '11.5']

Token 统计(子 Agent 隔离截图,主上下文不被污染)
  主 Agent(Proposer+Reviewer):573 tokens
  子 Agent(两步定位截图)    :2934 tokens

产物(output/ 目录,真实文件):

文件 时长 说明
source.mp4 54.0s 程序化生成的 4 场景测试原片
edit_round1.py Proposer 生成的 Blender bpy 脚本(代码生成产物,可换机执行)
cut_round1.mp4 12.0s 第 1 轮剪出的候选片段
final.mp4 12.0s 采用的成片(H.264 + AAC,1280x720@30fps)

Token 统计印证了核心结论:几十张截图(2934 tokens)只进入子 Agent的一次性 上下文,主 Agent 的对话历史(573 tokens)几乎不受截图污染。 (注:合成测试片仅显示"SURFING"字样而非真实冲浪画面,Reviewer 有时会据此判为 不通过——这正是审核者按画面内容如实反馈的体现;换真实视频即无此现象。)

依赖

  • ffmpeg / ffprobe:本机实际剪辑与抽帧。brew install ffmpeg(macOS)/ apt install ffmpeg(Ubuntu)。本项目在 ffmpeg 8.0 上验证通过。
  • OPENAI_API_KEY:用 gpt-5.6-luna 做视觉定位/审查与文本规划(视觉模型须支持图像输入);未配置时用 OPENROUTER_API_KEY 兜底,自动改走 OpenRouter。

如何适配 / 扩展

换模型 / 供应商

模型与端点全部通过环境变量注入(见 env.example),无需改代码:

  • TEXT_MODEL:规划/边界修正的文本模型(默认 gpt-5.6-luna)。
  • VISION_MODEL:定位/审查的视觉模型,必须支持图像输入(默认 gpt-5.6-luna)。
  • OPENAI_BASE_URL:换成任何兼容 OpenAI 协议的端点(自建代理、Azure OpenAI、 或其他厂商网关),配合对应的 OPENAI_API_KEY 即可。
export OPENAI_BASE_URL=https://your-gateway.example.com/v1
export VISION_MODEL=gpt-5.6-luna       # 例:用当前廉价旗舰视觉模型
export TEXT_MODEL=gpt-5.6-luna

agents.py 里的 OpenAI() 客户端会自动读取上述变量(client() 惰性初始化)。

换输入视频

make_test_video.py 用 ffmpeg 程序化生成一段 54s 的视频,含 4 个明显不同的场景 (HIKING 绿 / SURFING 蓝 / SKIING 白 / CYCLING 橙),每段都叠加大号场景名与时间码水印, 让 Vision 仅凭画面就能准确定位——便于复现验收。

换成你自己的真实视频:直接 python demo.py -i 你的.mp4 -o 输出.mp4 "剪辑需求" 即可(无需改代码)。此时跳过测试片生成,也不再打印定位误差(外部视频无真值)。

Blender vs. ffmpeg(剪辑后端)

书中原方案用 Blender Python API(bpy) 驱动视频序列编辑器(VSE)完成剪辑。 本项目把它实现为一等后端blender_editor.generate_bpy_script() 把剪辑计划翻译成 一段真实可执行的 bpy 脚本(new_movie 导入、frame_offset_start/frame_final_duration 裁剪、new_effect(type='TEXT'/'SPEED') 字幕/变速、bpy.ops.render.render 渲染), render_with_blender() 再用 blender --background --python edit.py 无头执行。

  • --backend blender:强制走 Blender(需 blender --version 可用);
  • --backend ffmpeg:强制走 ffmpeg;
  • --backend auto(默认):装了 Blender 用 bpy,否则回退 ffmpeg。

关键点:无论哪个后端,Proposer 生成的 bpy 脚本都会落盘到 output/edit_round*.py--smoke 下为 output/edit.py)——即"生成 Blender Python API 代码"这一核心产物, 可人工核对、也可拷到装了 Blender 的机器上执行。本机未安装 Blender,故本仓库的实际 渲染由 ffmpeg 完成并验证;bpy 脚本已通过 py_compile 语法校验,但未在真实 Blender 上跑过渲染(装好 Blender 后即可用 --backend blender 端到端执行)。两种后端的取舍:

ffmpeg Blender(bpy)
定位 裁剪/拼接/字幕/变速等 2D 流水线 3D 场景、合成、关键帧动画、粒子/摄像机
上手 单二进制、无 GUI、CI 友好 需装完整 Blender,体积大、渲染慢
适用 绝大多数"剪一段 + 简单特效"需求 需要 3D 合成/复杂转场/图层混合时才值得

核心的"两步 Vision 定位 + 提议者-审核者"与执行层解耦,两个后端共用同一份剪辑计划, agents.py/demo.py 无需为切换后端改动逻辑。

文件

文件 作用
demo.py 一条命令跑通的编排入口(CLI、启动自检、迭代循环、token 统计)
agents.py VideoAnalyzerAgent(两步定位)/ ProposerAgent / ReviewerAgent
blender_editor.py Blender bpy 脚本生成 + 无头渲染(书中原方案,核心实验点)
video_editor.py 剪辑执行层:apply_edit() 统一入口,调度 Blender/ffmpeg 双后端
make_test_video.py 程序化生成含 4 个场景的测试视频
ffmpeg_utils.py ffmpeg/ffprobe 薄封装(统一错误检查、抽帧、探测时长/流)

output/(生成的视频、截图、成片)已被 .gitignore 忽略,避免仓库膨胀。

局限

  • 定位精度取决于场景在画面上的可辨识度;真实视频若场景过渡渐变,边界误差会大于纯色测试片。
  • 细粒度步长固定 1s,边界精度上限即 ±1s 量级(满足书中 ±3s 验收)。
  • 慢动作音频用 atempo 变速,倍率过大时音质下降;转场/多轨混音等复杂特效未覆盖。
  • Reviewer 仅抽首/中/尾三帧,长片段中段的偶发错误可能漏检(可调高抽帧密度)。

源代码

agents.py

"""
实验 5-6 的三个 Agent:

  VideoAnalyzerAgent —— 视频分析子 Agent,用"两步 Vision 定位"找目标场景边界。
  ProposerAgent      —— 把自然语言需求解析成剪辑计划,调用子 Agent 定位并执行剪辑。
  ReviewerAgent      —— 抽取成片关键帧,用 Vision 检查是否剪对,给出结构化反馈。

把视频分析封装为独立子 Agent 的意义:大量截图只进入子 Agent 的一次性上下文,
不会污染主 Agent(Proposer/Reviewer)的对话历史——见 demo.py 打印的 token 统计。
"""
import base64
import json
import os
import re

from openai import OpenAI

from ffmpeg_utils import extract_frame, probe_duration

TEXT_MODEL = os.getenv("TEXT_MODEL", "gpt-5.6-luna")
VISION_MODEL = os.getenv("VISION_MODEL", "gpt-5.6-luna")  # 必须支持图像输入

OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"

_client = None


def map_model_to_openrouter(model: str) -> str:
    """把直连模型名映射为 OpenRouter 上的 id(非可映射 id 统一兜底到当前廉价旗舰)。"""
    if not model or "/" in model:
        return model or "openai/gpt-5.6-luna"
    m = model.lower()
    if m.startswith(("gpt-", "o1", "o3", "o4")):
        return "openai/" + model
    if m.startswith("claude"):
        if "haiku" in m:
            return "anthropic/claude-haiku-4.5"
        if "sonnet" in m:
            return "anthropic/claude-sonnet-4.6"
        return "anthropic/claude-opus-4.8"
    if m.startswith("gemini"):
        return "google/" + model
    return "openai/gpt-5.6-luna"


def _temp_for(model):
    """推理模型(gpt-5 / o 系列等)不接受 temperature=0。"""
    return (1 if any(k in (model or "").lower()
                     for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
            else 0)


def client() -> OpenAI:
    """构造(并缓存)OpenAI 客户端,含通用 OpenRouter 兜底。

    - 有 OPENAI_API_KEY:直连;但默认模型 gpt-5.x(直连需组织实名认证)且设置了
      OPENROUTER_API_KEY 时优先走 OpenRouter。
    - 无 OPENAI_API_KEY 但有 OPENROUTER_API_KEY:改走 OpenRouter(模型名自动映射)。
    """
    global _client, TEXT_MODEL, VISION_MODEL
    if _client is None:
        api_key = os.getenv("OPENAI_API_KEY")
        base_url = os.getenv("OPENAI_BASE_URL")
        orkey = os.getenv("OPENROUTER_API_KEY")
        prefer_or = bool(orkey) and (
            (TEXT_MODEL or "").lower().startswith("gpt-5") or (VISION_MODEL or "").lower().startswith("gpt-5")
        )
        if prefer_or or (not api_key and orkey):
            api_key, base_url = orkey, OPENROUTER_BASE_URL
            TEXT_MODEL = map_model_to_openrouter(TEXT_MODEL)
            VISION_MODEL = map_model_to_openrouter(VISION_MODEL)
        kw = {}
        if api_key:
            kw["api_key"] = api_key
        if base_url:
            kw["base_url"] = base_url
        _client = OpenAI(**kw)
    return _client


def _img_part(path: str) -> dict:
    with open(path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()
    return {"type": "image_url",
            "image_url": {"url": f"data:image/png;base64,{b64}", "detail": "low"}}


def _extract_json(text: str) -> dict:
    """从 LLM 回复里稳健地抠出第一个 JSON 对象。"""
    m = re.search(r"\{.*\}", text, re.DOTALL)
    if not m:
        raise ValueError(f"未能从回复中解析 JSON:{text[:200]}")
    return json.loads(m.group(0))


def _num(value, default: float) -> float:
    """把 LLM 返回的数值字段转成 float;字段缺失、为 null 或非法时回退 default。"""
    try:
        return float(value)
    except (TypeError, ValueError):
        return default


class TokenMeter:
    """累计 token,用于对比'子 Agent 隔离截图'带来的主上下文节省。"""

    def __init__(self):
        self.prompt = 0
        self.completion = 0

    def add(self, resp):
        u = getattr(resp, "usage", None)
        if u:
            self.prompt += u.prompt_tokens
            self.completion += u.completion_tokens

    def total(self):
        return self.prompt + self.completion


# --------------------------------------------------------------------------- #
# 视频分析子 Agent:两步 Vision 定位
# --------------------------------------------------------------------------- #
class VideoAnalyzerAgent:
    def __init__(self, meter: TokenMeter = None):
        self.meter = meter or TokenMeter()

    def _vision_locate(self, video, timestamps, question, frame_dir):
        """抽取给定时间点的帧,连同问题交给 Vision LLM,返回 {start,end}。"""
        content = [{
            "type": "text",
            "text": (
                f"下面是同一段视频在不同时间点的截图(每张图前标注了该帧的时间,单位秒)。\n"
                f"目标问题:{question}\n"
                f"请判断'目标场景'在视频中出现的时间区间。只依据画面内容判断。\n"
                f"严格输出 JSON:{{\"start\": <起点秒>, \"end\": <终点秒>, "
                f"\"reason\": \"<简要依据>\"}}。若所有截图都看不到目标场景,"
                f"令 start=end=-1。"
            ),
        }]
        for t in timestamps:
            png = os.path.join(frame_dir, f"f_{t:.1f}.png")
            extract_frame(video, t, png)
            content.append({"type": "text", "text": f"[时间 t={t:.1f}s]"})
            content.append(_img_part(png))

        resp = client().chat.completions.create(
            model=VISION_MODEL,
            messages=[{"role": "user", "content": content}],
            temperature=_temp_for(VISION_MODEL),
            max_tokens=300,
        )
        self.meter.add(resp)
        data = _extract_json(resp.choices[0].message.content)
        # 模型可能省略 start/end 或返回 null——按约定的 -1 哨兵处理,走兜底逻辑。
        return _num(data.get("start"), -1.0), _num(data.get("end"), -1.0), data.get("reason", "")

    def locate(self, video, question, coarse_interval=10.0, fine_interval=1.0,
               frame_dir="output/frames"):
        """
        两步定位:
          第一步(粗):每 coarse_interval 秒一帧,Vision 给出大致场景区间。
          第二步(细):在粗区间上下各扩一个粗间隔,每 fine_interval 秒一帧,
                        Vision 精确定位边界。
        返回 (start, end, trace)。
        """
        os.makedirs(frame_dir, exist_ok=True)
        duration = probe_duration(video)
        trace = {}

        # ---- 第一步:粗粒度 ----
        coarse_ts = [t for t in _frange(0, duration, coarse_interval)]
        cs, ce, creason = self._vision_locate(video, coarse_ts, question, frame_dir)
        trace["coarse"] = {"timestamps": coarse_ts, "start": cs, "end": ce,
                           "reason": creason}

        if cs < 0 or ce < 0:
            # 兜底:粗定位失败——退化为全视频精扫(步长放大以控制成本)。
            trace["coarse_fallback"] = True
            step = max(fine_interval, duration / 20.0)
            scan_ts = list(_frange(0, duration, step))
            cs, ce, creason = self._vision_locate(video, scan_ts, question, frame_dir)
            trace["coarse"]["fallback_scan"] = {"start": cs, "end": ce}
            if cs < 0:
                raise RuntimeError(
                    "Vision 定位失败:在整段视频里都没找到匹配'{}'的场景。\n"
                    "请检查需求描述是否与视频内容相符,或更换视频。".format(question)
                )

        # ---- 第二步:细粒度(在粗区间外扩一个粗间隔)----
        lo = max(0.0, cs - coarse_interval)
        hi = min(duration, ce + coarse_interval)
        fine_ts = list(_frange(lo, hi, fine_interval))
        fs, fe, freason = self._vision_locate(video, fine_ts, question, frame_dir)
        trace["fine"] = {"window": [lo, hi], "timestamps_count": len(fine_ts),
                         "start": fs, "end": fe, "reason": freason}

        if fs < 0 or fe < 0 or fe <= fs:
            # 兜底:细定位失败——采用粗定位结果,保证流程可继续。
            trace["fine_fallback"] = True
            fs, fe = cs, ce

        # 收敛到视频范围内。
        fs = max(0.0, fs)
        fe = min(duration, fe)
        return fs, fe, trace


def _frange(start, stop, step):
    """浮点 range(含首、含接近末尾的采样点)。"""
    out = []
    t = start
    while t < stop - 1e-6:
        out.append(round(t, 3))
        t += step
    # 补一个接近末尾的采样点,确保末段场景被覆盖。
    last = round(max(start, stop - 0.5), 3)
    if not out or abs(out[-1] - last) > step / 2:
        out.append(last)
    return out


# --------------------------------------------------------------------------- #
# Proposer Agent
# --------------------------------------------------------------------------- #
class ProposerAgent:
    def __init__(self, meter: TokenMeter = None):
        self.meter = meter or TokenMeter()

    def parse_request(self, nl_request: str) -> dict:
        """把自然语言需求解析成结构化意图:目标场景描述 + 特效列表。"""
        resp = client().chat.completions.create(
            model=TEXT_MODEL,
            temperature=_temp_for(TEXT_MODEL),
            max_tokens=400,
            messages=[{
                "role": "user",
                "content": (
                    "你是视频剪辑规划器。把用户的中文剪辑需求解析成 JSON。\n"
                    "字段:\n"
                    "  target_query: 用于视觉定位的一句话描述(英文更利于匹配画面文字),"
                    "说明要剪出哪个场景;\n"
                    "  effects: 特效数组,元素形如 "
                    "{\"type\":\"subtitle\",\"text\":\"...\"} 或 "
                    "{\"type\":\"slowmo\",\"factor\":2.0},无特效则为 []。\n"
                    f"用户需求:{nl_request}\n"
                    "只输出 JSON。"
                ),
            }],
        )
        self.meter.add(resp)
        return _extract_json(resp.choices[0].message.content)

    def revise_bounds(self, start, end, feedback, duration):
        """根据 Reviewer 反馈微调边界(保守外扩/内收)。"""
        resp = client().chat.completions.create(
            model=TEXT_MODEL,
            temperature=_temp_for(TEXT_MODEL),
            max_tokens=200,
            messages=[{
                "role": "user",
                "content": (
                    f"当前剪辑区间 start={start:.1f}s end={end:.1f}s,视频总长 {duration:.1f}s。\n"
                    f"审核反馈:{feedback}\n"
                    "请给出修正后的区间,输出 JSON {\"start\":..,\"end\":..}。"
                    "若反馈指出包含了无关片段则内收,若指出遗漏内容则外扩,幅度 1~5 秒。"
                ),
            }],
        )
        self.meter.add(resp)
        d = _extract_json(resp.choices[0].message.content)
        # 模型可能省略 start/end 或返回 null——缺失时维持当前区间不变。
        return max(0.0, _num(d.get("start"), start)), min(duration, _num(d.get("end"), end))


# --------------------------------------------------------------------------- #
# Reviewer Agent
# --------------------------------------------------------------------------- #
class ReviewerAgent:
    def __init__(self, meter: TokenMeter = None):
        self.meter = meter or TokenMeter()

    def review(self, clip_path, target_query, frame_dir="output/review_frames"):
        """
        抽取成片的首/中/尾关键帧,用 Vision 检查:
          - 是否完整包含目标场景(无遗漏);
          - 是否夹带了无关场景(无多余)。
        返回结构化结果 {pass, score, feedback, frames_checked}。
        """
        os.makedirs(frame_dir, exist_ok=True)
        dur = probe_duration(clip_path)
        # 取首/中/尾,并在首尾稍微内缩避开黑帧。
        keyts = [min(0.5, dur * 0.1), dur / 2.0, max(0.0, dur - 0.5)]

        content = [{
            "type": "text",
            "text": (
                f"这是剪辑成片的几个关键帧(首/中/尾)。剪辑目标是:{target_query}\n"
                "请检查:(1) 成片是否完整呈现了目标场景;(2) 是否夹带了不该出现的其他场景。\n"
                "严格输出 JSON:{\"pass\": true/false, \"score\": 0-10, "
                "\"feedback\": \"<发现的问题或确认无误>\"}。"
            ),
        }]
        for t in keyts:
            png = os.path.join(frame_dir, f"r_{t:.1f}.png")
            extract_frame(clip_path, t, png)
            content.append({"type": "text", "text": f"[成片内 t={t:.1f}s]"})
            content.append(_img_part(png))

        resp = client().chat.completions.create(
            model=VISION_MODEL,
            temperature=_temp_for(VISION_MODEL),
            max_tokens=300,
            messages=[{"role": "user", "content": content}],
        )
        self.meter.add(resp)
        data = _extract_json(resp.choices[0].message.content)
        data["frames_checked"] = keyts
        return data

blender_editor.py

"""
Blender Python API(bpy)剪辑执行层 —— 实验 5-6 的核心。

书中方案强调"代码生成":Proposer Agent 不去点 GUI,而是**生成一段调用
Blender Python API 的脚本**,每个编辑操作(导入 / 裁剪 / 字幕 / 变速 / 渲染)
对应一个清晰的函数调用,再用 `blender --background --python edit.py` 无头执行。

本模块两个出口:
  generate_bpy_script(source, plan, out_video) -> str
      纯字符串生成,**不依赖 bpy**,任何机器都能产出这段脚本(体现代码生成能力,
      可人工核对,或拷到装了 Blender 的机器上执行)。
  render_with_blender(source, plan, out_video, script_path) -> str
      若本机 `blender` 可执行,写出脚本并无头渲染产出成片;否则抛错由调用方回退。

裁剪基于 Blender 视频序列编辑器(VSE):new_movie / new_sound 导入素材,
frame_offset_start + frame_final_duration 完成裁剪,TEXT / SPEED 特效条叠加,
FFMPEG(H.264+AAC) 容器渲染。API 面向 Blender 3.x / 4.x。
"""
import os
import shutil
import subprocess


def blender_available() -> bool:
    """本机是否有 blender 可执行文件(决定 backend=auto 时走 Blender 还是 ffmpeg)。"""
    return shutil.which("blender") is not None


# 生成的 bpy 脚本模板。占位符全部通过 repr() 注入,保证是合法的 Python 字面量。
_BPY_TEMPLATE = '''"""
本文件由 blender_editor.generate_bpy_script() 自动生成(实验 5-6)。
执行:blender --background --python edit.py
它把一条剪辑计划翻译成 Blender 视频序列编辑器(VSE)的 API 调用序列。
"""
import os
import bpy

SRC = {src}
OUT = {out}
FPS = {fps}
START = {start}       # 目标片段起点(秒)
END = {end}           # 目标片段终点(秒)
SUBTITLE = {subtitle} # None 或字幕文本
SLOWMO = {slowmo}     # None 或放慢倍率(factor>1 表示放慢 factor 倍)

scene = bpy.context.scene
scene.render.fps = FPS
scene.render.fps_base = 1.0

# 清掉可能存在的旧序列,保证幂等
if scene.sequence_editor:
    bpy.ops.sequencer.select_all(action='SELECT')
    bpy.ops.sequencer.delete()
se = scene.sequence_editor_create()

start_frame = int(round(START * FPS))
dur_frames = max(1, int(round((END - START) * FPS)))

# 1) 导入影片 + 音轨(new_sound 在无音轨素材上会抛 RuntimeError,忽略即可)
movie = se.sequences.new_movie(name="clip", filepath=SRC, channel=1, frame_start=1)
try:
    sound = se.sequences.new_sound(name="audio", filepath=SRC, channel=2, frame_start=1)
except RuntimeError:
    sound = None

# 2) 裁剪 [START, END]:偏移掉片头,再固定成片时长
for strip in (movie, sound):
    if strip is None:
        continue
    strip.frame_offset_start = start_frame
    strip.frame_final_duration = dur_frames

top_channel = 3

# 3) 慢动作:SPEED 特效条(MULTIPLY 模式,speed_factor = 1/倍率)
if SLOWMO:
    speed = se.sequences.new_effect(
        name="slowmo", type='SPEED', channel=top_channel,
        frame_start=1, frame_end=1 + dur_frames, seq1=movie,
    )
    speed.use_default_fade = False
    speed.speed_control = 'MULTIPLY'
    speed.speed_factor = 1.0 / SLOWMO
    top_channel += 1
    # 放慢后成片总帧数按倍率拉长
    render_dur = int(round(dur_frames * SLOWMO))
    movie.frame_final_duration = render_dur
else:
    render_dur = dur_frames

# 4) 字幕:TEXT 特效条,底部居中带半透明底框
if SUBTITLE:
    txt = se.sequences.new_effect(
        name="subtitle", type='TEXT', channel=top_channel,
        frame_start=1, frame_end=1 + render_dur,
    )
    txt.text = SUBTITLE
    txt.font_size = 100
    txt.location = (0.5, 0.12)
    txt.align_x = 'CENTER'
    txt.align_y = 'BOTTOM'
    txt.use_box = True
    txt.box_color = (0.0, 0.0, 0.0, 0.6)

# 5) 渲染范围 + 输出为 mp4(H.264+AAC)
scene.frame_start = 1
scene.frame_end = render_dur

r = scene.render
r.image_settings.file_format = 'FFMPEG'
r.ffmpeg.format = 'MPEG4'
r.ffmpeg.codec = 'H264'
r.ffmpeg.audio_codec = 'AAC'
r.filepath = OUT
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)

bpy.ops.render.render(animation=True)
print("BLENDER_RENDER_DONE", OUT)
'''


def _plan_fields(plan: dict):
    """从剪辑计划里抽出 bpy 脚本需要的字段。"""
    start, end = float(plan["start"]), float(plan["end"])
    if end <= start:
        raise ValueError(f"剪辑区间非法:start={start} >= end={end}")
    effects = plan.get("effects", []) or []
    subtitle = None
    slowmo = None
    for eff in effects:
        etype = eff.get("type")
        if etype == "subtitle":
            subtitle = eff.get("text", "")
        elif etype == "slowmo":
            slowmo = float(eff.get("factor", 2.0))
    return start, end, subtitle, slowmo


def generate_bpy_script(source: str, plan: dict, out_video: str, fps: int = 30) -> str:
    """把剪辑计划渲染成一段可执行的 Blender Python(bpy) 脚本文本(不依赖 bpy)。"""
    start, end, subtitle, slowmo = _plan_fields(plan)
    return _BPY_TEMPLATE.format(
        src=repr(os.path.abspath(source)),
        out=repr(os.path.abspath(out_video)),
        fps=int(fps),
        start=repr(start),
        end=repr(end),
        subtitle=repr(subtitle),
        slowmo=repr(slowmo),
    )


def write_bpy_script(source: str, plan: dict, out_video: str,
                     script_path: str, fps: int = 30) -> str:
    """生成 bpy 脚本并落盘(无论用哪个后端都会产出,作为代码生成产物)。"""
    script = generate_bpy_script(source, plan, out_video, fps=fps)
    os.makedirs(os.path.dirname(script_path) or ".", exist_ok=True)
    with open(script_path, "w") as f:
        f.write(script)
    return script_path


def render_with_blender(source: str, plan: dict, out_video: str,
                        script_path: str, fps: int = 30) -> str:
    """写出 bpy 脚本并用 `blender --background --python` 无头执行,产出成片。"""
    if not blender_available():
        raise RuntimeError(
            "指定用 Blender 后端,但未找到 blender 可执行文件。\n"
            "  安装后确保 `blender --version` 可用:https://www.blender.org/download/\n"
            "  或改用 --backend ffmpeg。"
        )
    write_bpy_script(source, plan, out_video, script_path, fps=fps)
    os.makedirs(os.path.dirname(out_video) or ".", exist_ok=True)
    proc = subprocess.run(
        ["blender", "--background", "--python", script_path],
        capture_output=True, text=True,
    )
    if proc.returncode != 0 or not os.path.exists(out_video):
        tail = "\n".join(proc.stderr.strip().splitlines()[-12:])
        raise RuntimeError(
            f"Blender 渲染失败(exit={proc.returncode}):\n{tail}\n"
            f"  可人工检查生成的脚本:{script_path}"
        )
    return out_video


if __name__ == "__main__":
    # 零依赖自检:打印一段"含裁剪 + 字幕"的示例 bpy 脚本(可 py_compile 校验其语法)。
    demo_plan = {
        "start": 16.0,
        "end": 28.0,
        "effects": [{"type": "subtitle", "text": "SURFING"}],
    }
    print("# blender available:", blender_available())
    print("# ---- generated edit.py ----")
    print(generate_bpy_script("output/source.mp4", demo_plan, "output/final.mp4"))

demo.py

"""
实验 5-6:基于 API 的智能视频剪辑(两步 Vision 定位 + 提议者-审核者)

一条命令跑通:
  python demo.py                 # 默认需求"把冲浪的部分剪出来"
  python demo.py "把滑雪部分剪出来,并加上字幕 Winter"   # 自定义需求

流程:
  1. 程序化生成含 4 个明显不同场景的测试视频(HIKING/SURFING/SKIING/CYCLING);
  2. Proposer 解析自然语言需求 → 目标场景 + 特效;
  3. 视频分析子 Agent 两步定位(粗粒度每 10s → 细粒度每 1s)找到精确边界;
  4. Proposer 生成 Blender Python API(bpy)脚本剪出片段(可含字幕/慢动作);
     装了 Blender 则无头渲染,否则回退 ffmpeg——但 bpy 脚本始终生成(代码生成产物);
  5. Reviewer 检查成片关键帧,给出反馈;不合格则 Proposer 修正边界重剪,迭代。

依赖:ffmpeg/ffprobe(回退后端 + 抽帧)、OPENAI_API_KEY(gpt-5.6-luna 视觉 + 文本;未配置时可用 OPENROUTER_API_KEY 兜底);
      可选 Blender(书中原方案,`--backend blender`)。

常用命令(完整用法见 `python demo.py --help`):
  python demo.py                 # 默认需求,完整流程
  python demo.py --quick         # 快速模式:粗采样 + 单轮审查,省时省钱
  python demo.py --smoke         # 冒烟自检:仅剪辑链路 + 生成 bpy 脚本,不调用任何 API
"""
import argparse
import os
import shutil
import sys

from dotenv import load_dotenv

load_dotenv()

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.join(HERE, "output")
SOURCE_VIDEO = os.path.join(OUT_DIR, "source.mp4")   # 测试片输出位置
FINAL_VIDEO = os.path.join(OUT_DIR, "final.mp4")
MAX_ROUNDS = 3  # Reviewer 反馈后最多重剪次数(默认,可用 --max-rounds 覆盖)

DEFAULT_REQUEST = "把冲浪的部分剪出来"


def banner(title):
    print("\n" + "=" * 74)
    print(f"  {title}")
    print("=" * 74)


def build_arg_parser() -> argparse.ArgumentParser:
    """命令行参数:位置参数为中文剪辑需求,另有输入/输出/后端/模型/快速等开关。"""
    p = argparse.ArgumentParser(
        prog="demo.py",
        description="实验 5-6:基于 API 的智能视频剪辑(两步 Vision 定位 + 提议者-审核者)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "示例:\n"
            "  python demo.py\n"
            "  python demo.py \"把滑雪部分剪出来,并加上字幕 Winter\"\n"
            "  python demo.py -i my.mp4 -o out.mp4 \"把演讲开场剪出来\"\n"
            "  python demo.py --backend blender    # 强制用 Blender Python API 渲染\n"
            "  python demo.py --quick    # 更少 Vision 调用,快速验证链路\n"
            "  python demo.py --smoke    # 只跑剪辑链路 + 生成 bpy 脚本,不调用任何 API\n"
        ),
    )
    p.add_argument("request", nargs="?", default=DEFAULT_REQUEST,
                   help="中文剪辑需求(默认:%(default)s)")
    p.add_argument("--input", "-i", metavar="VIDEO", default=None,
                   help="输入视频路径(不指定则程序化生成 4 场景测试片)")
    p.add_argument("--output", "-o", metavar="VIDEO", default=FINAL_VIDEO,
                   help="成片输出路径(默认 output/final.mp4)")
    p.add_argument("--backend", choices=["auto", "blender", "ffmpeg"], default="auto",
                   help="剪辑后端:auto=装了 Blender 用 bpy 否则 ffmpeg;"
                        "blender=强制 Blender Python API;ffmpeg=强制 ffmpeg(默认 auto)")
    p.add_argument("--text-model", metavar="NAME", default=None,
                   help="覆盖文本模型(否则用 $TEXT_MODEL,默认 gpt-5.6-luna)")
    p.add_argument("--vision-model", metavar="NAME", default=None,
                   help="覆盖视觉模型,须支持图像输入(否则用 $VISION_MODEL,默认 gpt-5.6-luna)")
    p.add_argument("--quick", action="store_true",
                   help="快速模式:粗采样(15s/2s)+ 单轮审查,减少 Vision API 调用")
    p.add_argument("--max-rounds", type=int, default=MAX_ROUNDS, metavar="N",
                   help="Reviewer 反馈后最多重剪轮数(默认 %(default)s;--quick 时强制为 1)")
    p.add_argument("--smoke", action="store_true",
                   help="冒烟自检:仅剪辑链路 + 生成 bpy 脚本,不调用任何 API")
    return p


def smoke_check():
    """冒烟自检:不触碰 OpenAI,验证剪辑链路可用并生成 Proposer 的 bpy 脚本。"""
    from blender_editor import blender_available
    from ffmpeg_utils import ensure_ffmpeg, extract_frame, format_probe
    from make_test_video import GROUND_TRUTH, make as make_test_video
    from video_editor import apply_edit

    banner("冒烟自检 | 剪辑链路 + bpy 脚本生成,不调用任何 API")
    try:
        ensure_ffmpeg()
    except RuntimeError as e:
        print(f"\n[错误] {e}")
        sys.exit(1)
    if os.path.isdir(OUT_DIR):
        shutil.rmtree(OUT_DIR)
    os.makedirs(OUT_DIR, exist_ok=True)

    make_test_video(SOURCE_VIDEO)
    print(f"[1/3] 生成测试视频 OK:{SOURCE_VIDEO}(场景真值={GROUND_TRUTH})")
    frame_dir = os.path.join(OUT_DIR, "frames")
    os.makedirs(frame_dir, exist_ok=True)   # extract_frame 要求目录已存在
    frame = extract_frame(SOURCE_VIDEO, 20.0, os.path.join(frame_dir, "smoke.png"))
    print(f"[2/3] 抽帧 OK:{frame}")
    clip = os.path.join(OUT_DIR, "smoke_cut.mp4")
    script_path = os.path.join(OUT_DIR, "edit.py")
    # backend="auto":未装 Blender 则用 ffmpeg 实际渲染,但仍生成 bpy 脚本(代码生成产物)。
    apply_edit(SOURCE_VIDEO, {"start": 15.0, "end": 20.0,
                              "effects": [{"type": "subtitle", "text": "SMOKE"}]},
               clip, backend="auto", script_path=script_path)
    used = "Blender bpy" if blender_available() else "ffmpeg(未装 Blender,回退)"
    print(f"[3/3] 剪辑+字幕 OK(后端={used}):\n{format_probe(clip)}")
    print(f"\n已生成 Proposer 的 Blender 脚本:{script_path}")
    print("(这正是书中'生成 Blender Python API 代码'的产物;装好 Blender 后可直接")
    print(f" `blender --background --python {script_path}` 无头渲染。)")
    print("\n✓ 冒烟自检通过:剪辑链路正常 + bpy 脚本已生成(未调用 OpenAI)。")


def preflight():
    """启动自检:给出清晰中文报错,而非 traceback。"""
    from ffmpeg_utils import ensure_ffmpeg
    if not (os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")):
        print("\n[错误] 未检测到 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底)。\n"
              "  请复制 env.example 为 .env 并填入有效的 OpenAI Key,或执行:\n"
              "    export OPENAI_API_KEY=sk-...   # 或 export OPENROUTER_API_KEY=sk-or-...\n"
              "  本实验用 gpt-5.6-luna 做视觉定位与审查,必须提供有效 Key。")
        sys.exit(1)
    try:
        ensure_ffmpeg()
    except RuntimeError as e:
        print(f"\n[错误] {e}")
        sys.exit(1)


def main():
    args = build_arg_parser().parse_args()
    if args.smoke:                       # 仅剪辑链路,不需要 API Key,提前返回。
        smoke_check()
        return

    nl_request = args.request
    # --quick:粗化采样步长并只审查一轮,把 Vision 调用降到最少(用于快速验证链路)。
    coarse_interval = 15.0 if args.quick else 10.0
    fine_interval = 2.0 if args.quick else 1.0
    max_rounds = 1 if args.quick else max(1, args.max_rounds)

    # 模型覆盖:写回环境变量,供 agents 模块(惰性初始化)读取。须在导入 agents 前设置。
    if args.text_model:
        os.environ["TEXT_MODEL"] = args.text_model
    if args.vision_model:
        os.environ["VISION_MODEL"] = args.vision_model
    preflight()

    # 延迟导入:确保 preflight 的报错优先于任何 SDK 初始化。
    from agents import (ProposerAgent, ReviewerAgent, VideoAnalyzerAgent,
                        TokenMeter, TEXT_MODEL, VISION_MODEL)
    from blender_editor import blender_available
    from ffmpeg_utils import format_probe, probe_duration
    from make_test_video import make as make_test_video, GROUND_TRUTH
    from video_editor import apply_edit

    # 幂等:每次从干净的 output/ 开始。
    if os.path.isdir(OUT_DIR):
        shutil.rmtree(OUT_DIR)
    os.makedirs(OUT_DIR, exist_ok=True)

    ground_truth = None
    if args.input:
        banner("步骤 0 | 使用外部输入视频")
        source_video = os.path.abspath(args.input)
        if not os.path.isfile(source_video):
            print(f"\n[错误] 输入视频不存在:{source_video}")
            sys.exit(1)
        print(f"输入视频:{source_video}")
    else:
        banner("步骤 0 | 生成测试视频(4 个明显不同的场景)")
        source_video = SOURCE_VIDEO
        make_test_video(source_video)
        ground_truth = GROUND_TRUTH
        print(f"已生成 {source_video}")
        print(f"场景真值(用于核对定位误差):{ground_truth}")
    total_dur = probe_duration(source_video)
    print(f"时长 {total_dur:.1f}s")
    print(f"文本模型={TEXT_MODEL}  视觉模型={VISION_MODEL}  剪辑后端={args.backend}")

    # 分离的 token 计量:主 Agent(Proposer+Reviewer)vs 子 Agent(截图定位)。
    main_meter = TokenMeter()
    sub_meter = TokenMeter()
    proposer = ProposerAgent(main_meter)
    reviewer = ReviewerAgent(main_meter)
    analyzer = VideoAnalyzerAgent(sub_meter)

    banner("步骤 1 | Proposer 解析自然语言需求")
    print(f"用户需求:{nl_request}")
    intent = proposer.parse_request(nl_request)
    # 模型可能省略 target_query 或返回 null——退化为用原始需求文本做视觉定位。
    target_query = intent.get("target_query") or nl_request
    effects = intent.get("effects", [])
    print(f"解析结果:目标场景='{target_query}'  特效={effects}")

    banner("步骤 2 | 视频分析子 Agent:两步 Vision 定位"
           + ("(--quick 快速采样)" if args.quick else ""))
    start, end, trace = analyzer.locate(
        source_video, target_query,
        coarse_interval=coarse_interval, fine_interval=fine_interval,
        frame_dir=os.path.join(OUT_DIR, "frames"),
    )
    c = trace["coarse"]
    print(f"  [粗粒度] 每 {coarse_interval:.0f}s 采样 {len(c['timestamps'])} 帧 → Vision 得区间 "
          f"[{c['start']:.0f}, {c['end']:.0f}]s(依据:{c['reason']})")
    f = trace["fine"]
    print(f"  [细粒度] 窗口 {f['window']} 内每 {fine_interval:.0f}s 采样 {f['timestamps_count']} 帧 → "
          f"精确边界 [{f['start']:.1f}, {f['end']:.1f}]s(依据:{f['reason']})")
    print(f"  >>> 最终定位:起 {start:.1f}s  止 {end:.1f}s")

    # 与真值对比,打印定位误差(验收:误差 ≤ ±3s)。仅测试片有真值。
    key = _match_ground_truth(target_query, ground_truth) if ground_truth else None
    if key:
        gs, ge = ground_truth[key]
        print(f"  真值 [{gs}, {ge}]s → 起点误差 {abs(start - gs):.1f}s,"
              f"终点误差 {abs(end - ge):.1f}s(验收要求 ≤ 3s)")

    banner("步骤 3-4 | Proposer 生成 bpy 脚本剪辑 + Reviewer 审查(迭代)")
    plan = {"start": start, "end": end, "effects": effects}
    final_path = None
    for rnd in range(1, max_rounds + 1):
        print(f"\n--- 第 {rnd} 轮 ---")
        clip = os.path.join(OUT_DIR, f"cut_round{rnd}.mp4")
        script_path = os.path.join(OUT_DIR, f"edit_round{rnd}.py")
        apply_edit(source_video, plan, clip, backend=args.backend,
                   script_path=script_path)
        cdur = probe_duration(clip)
        used = "Blender bpy" if (args.backend == "blender" or
                                 (args.backend == "auto" and blender_available())) else "ffmpeg"
        print(f"  Proposer 生成 Blender 脚本 → {script_path}")
        print(f"  剪出片段 [{plan['start']:.1f}, {plan['end']:.1f}]s(后端={used}),"
              f"成片时长 {cdur:.1f}s")

        review = reviewer.review(clip, target_query,
                                 frame_dir=os.path.join(OUT_DIR, "review_frames"))
        print(f"  Reviewer:pass={review.get('pass')} score={review.get('score')} "
              f"检查帧={['%.1f' % t for t in review.get('frames_checked', [])]}")
        print(f"  Reviewer 反馈:{review.get('feedback', '(无)')}")

        if review.get("pass"):
            final_path = clip
            print("  ✓ 审核通过。")
            break
        if rnd == max_rounds:
            final_path = clip
            print("  达到最大轮数,采用当前成片。")
            break
        # 未通过:Proposer 据反馈修正边界后重剪。
        ns, ne = proposer.revise_bounds(plan["start"], plan["end"],
                                        review.get("feedback", ""), total_dur)
        print(f"  Proposer 据反馈修正边界:[{ns:.1f}, {ne:.1f}]s")
        plan["start"], plan["end"] = ns, ne

    output_path = os.path.abspath(args.output)
    os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
    shutil.copy(final_path, output_path)

    banner("步骤 5 | 成片信息(ffprobe)")
    print(format_probe(output_path))

    banner("Token 统计(子 Agent 隔离截图,主上下文不被污染)")
    print(f"  主 Agent(Proposer+Reviewer):{main_meter.total()} tokens "
          f"(prompt={main_meter.prompt}, completion={main_meter.completion})")
    print(f"  子 Agent(两步定位截图)    :{sub_meter.total()} tokens "
          f"(prompt={sub_meter.prompt}, completion={sub_meter.completion})")
    print(f"\n完成:{output_path}")


def _match_ground_truth(query, gt):
    q = query.lower()
    for key in gt:
        if key in q:
            return key
    # 中文关键词兜底映射。
    zh = {"冲浪": "surfing", "徒步": "hiking", "滑雪": "skiing", "骑": "cycling",
          "hik": "hiking", "surf": "surfing", "ski": "skiing", "cycl": "cycling"}
    for k, v in zh.items():
        if k in q:
            return v
    return None


if __name__ == "__main__":
    main()

ffmpeg_utils.py

"""
ffmpeg / ffprobe 薄封装:所有对外部进程的调用都集中在这里,统一做错误检查。

设计要点:
  - run() 捕获非零退出码并抛出带 stderr 的清晰异常(而非让 traceback 泄漏);
  - 提供 probe_duration / probe_streams,供 Reviewer 与验证环节读取成片信息;
  - extract_frame 把某一时间点抽成一张 PNG(缩放到 512 宽以节省 Vision token)。
"""
import json
import os
import shutil
import subprocess

# macOS 自带字体;换平台时改这里即可(Linux 常见 DejaVuSans.ttf)。
FONT_CANDIDATES = [
    "/System/Library/Fonts/Supplemental/Arial.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    "/Library/Fonts/Arial.ttf",
]


def find_font() -> str:
    for p in FONT_CANDIDATES:
        if os.path.exists(p):
            return p
    return ""  # drawtext 会退化为默认字体


def ensure_ffmpeg():
    """启动前自检:ffmpeg / ffprobe 是否可用,给出清晰中文报错。"""
    for tool in ("ffmpeg", "ffprobe"):
        if shutil.which(tool) is None:
            raise RuntimeError(
                f"未找到 {tool},本项目用 ffmpeg 完成实际剪辑。\n"
                f"  macOS: brew install ffmpeg\n"
                f"  Ubuntu: sudo apt install ffmpeg"
            )


def run(cmd, desc="ffmpeg 命令"):
    """执行命令,失败时抛出带 stderr 尾部的异常。"""
    proc = subprocess.run(cmd, capture_output=True, text=True)
    if proc.returncode != 0:
        tail = "\n".join(proc.stderr.strip().splitlines()[-8:])
        raise RuntimeError(f"{desc} 执行失败(exit={proc.returncode}):\n{tail}")
    return proc


def probe_duration(path: str) -> float:
    """返回视频时长(秒)。文件缺少时长元数据时 ffprobe 输出 N/A,给出清晰报错。"""
    proc = run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "default=noprint_wrappers=1:nokey=1", path],
        desc="ffprobe 读取时长",
    )
    out = proc.stdout.strip()
    if not out or out == "N/A":
        raise RuntimeError(f"ffprobe 无法读取时长(文件缺少时长元数据或不是音视频文件):{path}")
    return float(out)


def probe_streams(path: str) -> dict:
    """返回 ffprobe 的 JSON(format + streams),用于打印成片信息。"""
    proc = run(
        ["ffprobe", "-v", "error", "-show_format", "-show_streams",
         "-of", "json", path],
        desc="ffprobe 读取流信息",
    )
    return json.loads(proc.stdout)


def format_probe(path: str) -> str:
    """把成片信息格式化成一行行的人类可读文本(用于验证输出)。"""
    info = probe_streams(path)
    fmt = info.get("format", {})
    lines = [
        f"  文件: {os.path.basename(path)}",
        f"  时长: {float(fmt.get('duration', 0)):.2f}s",
        f"  容器: {fmt.get('format_name', '?')}",
        f"  大小: {int(fmt.get('size', 0)) / 1024:.1f} KB",
    ]
    for s in info.get("streams", []):
        if s.get("codec_type") == "video":
            lines.append(
                f"  视频流: {s.get('codec_name')} {s.get('width')}x{s.get('height')} "
                f"@ {s.get('r_frame_rate')} fps"
            )
        elif s.get("codec_type") == "audio":
            lines.append(
                f"  音频流: {s.get('codec_name')} {s.get('sample_rate')}Hz "
                f"{s.get('channels')}ch"
            )
    return "\n".join(lines)


def extract_frame(video: str, t: float, out_png: str, width: int = 512):
    """抽取 t 秒处的一帧,缩放到 width 宽存为 PNG。"""
    run(
        ["ffmpeg", "-y", "-ss", f"{t:.3f}", "-i", video,
         "-frames:v", "1", "-vf", f"scale={width}:-1", out_png],
        desc=f"抽帧 t={t:.1f}s",
    )
    return out_png

make_test_video.py

"""
程序化生成一段"含多个明显不同场景"的测试视频(无需任何素材文件)。

每个场景 = 一种纯色背景 + 一个大号运动标题(场景英文名)+ 时间码水印。
标题让 Vision LLM 能仅凭画面就准确判断"这是哪个场景",从而验证两步定位。
换成真实视频时:把 demo.py 里的 SOURCE_VIDEO 指向你自己的 mp4 即可(见 README)。
"""
import os

from ffmpeg_utils import find_font, run

# 每个场景:(名称, 背景色, 起始秒, 时长秒)。刻意让每段 > 10s,
# 使"每 10s 一张"的粗粒度采样必然命中每个场景。
SCENES = [
    ("HIKING",  "0x1E6B3A", 0,  15),   # 森林绿
    ("SURFING", "0x1565C0", 15, 15),   # 海洋蓝
    ("SKIING",  "0xE0E0E0", 30, 12),   # 雪地白
    ("CYCLING", "0xE65100", 42, 12),   # 落日橙
]
TOTAL = SCENES[-1][2] + SCENES[-1][3]  # 54s
W, H, FPS = 1280, 720, 30


def _drawtext(text, size, y_expr, color="white", box=False):
    font = find_font()
    parts = [f"text='{text}'", f"fontsize={size}", f"fontcolor={color}",
             "x=(w-text_w)/2", f"y={y_expr}"]
    if font:
        parts.insert(0, f"fontfile={font}")
    if box:
        parts += ["box=1", "boxcolor=black@0.4", "boxborderw=20"]
    return "drawtext=" + ":".join(parts)


def make(out_path: str) -> str:
    """生成测试视频,返回路径。幂等:每次覆盖,保证从干净状态开始。"""
    out_dir = os.path.dirname(out_path)
    if out_dir:
        os.makedirs(out_dir, exist_ok=True)
    clip_paths = []
    tmp_dir = out_dir or "."

    for i, (name, color, start, dur) in enumerate(SCENES):
        clip = os.path.join(tmp_dir, f"_scene_{i}.mp4")
        # 让标题上下缓慢漂移,制造真实"运动画面",避免纯静止帧。
        title = _drawtext(name, 140, "(h-text_h)/2 + 60*sin(t)", box=True)
        # 左上角时间码:t 为片段内相对时间,加 start 得到全局时间。
        # drawtext 里表达式含冒号,必须转义为 \: 否则被当成选项分隔符。
        clock = _drawtext(rf"t=%{{eif\:t+{start}\:d}}s", 48,
                          "40", color="yellow")
        vf = f"{title},{clock}"
        run(
            ["ffmpeg", "-y",
             "-f", "lavfi", "-i", f"color=c={color}:s={W}x{H}:d={dur}:r={FPS}",
             "-f", "lavfi", "-i", f"sine=frequency={220 + i * 110}:duration={dur}",
             "-vf", vf, "-pix_fmt", "yuv420p",
             "-c:v", "libx264", "-c:a", "aac", "-shortest", clip],
            desc=f"生成场景 {name}",
        )
        clip_paths.append(clip)

    # 用 concat demuxer 无缝拼接成完整原始素材。
    list_file = os.path.join(tmp_dir, "_concat_list.txt")
    with open(list_file, "w") as f:
        for c in clip_paths:
            f.write(f"file '{os.path.abspath(c)}'\n")
    run(
        ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file,
         "-c", "copy", out_path],
        desc="拼接测试视频",
    )

    # 清理中间片段。
    for c in clip_paths:
        os.remove(c)
    os.remove(list_file)
    return out_path


# 供 demo / README 引用:场景真值表,用于验证定位误差。
GROUND_TRUTH = {name.lower(): (start, start + dur) for name, _, start, dur in SCENES}

test_agents_llm_json.py

"""LLM 返回的 JSON 缺字段/为 null 时,Agent 解析应按约定哨兵处理,不应崩溃。"""
import types

import pytest

import agents
import ffmpeg_utils


def _fake_client(content):
    resp = types.SimpleNamespace(
        choices=[types.SimpleNamespace(
            message=types.SimpleNamespace(content=content))],
        usage=None)
    completions = types.SimpleNamespace(create=lambda **kw: resp)
    return types.SimpleNamespace(chat=types.SimpleNamespace(completions=completions))


def _stub_io(monkeypatch, content):
    """替换掉网络与帧抽取 IO,让 Agent 直接吃到给定的 LLM 回复文本。"""
    monkeypatch.setattr(agents, "client", lambda: _fake_client(content))
    monkeypatch.setattr(agents, "extract_frame", lambda *a, **k: None)
    monkeypatch.setattr(agents, "_img_part",
                        lambda p: {"type": "image_url", "image_url": {"url": "data:,"}})


def test_vision_locate_missing_keys(monkeypatch):
    """模型省略 start/end → 按 -1 哨兵返回(走兜底逻辑),不抛 KeyError。"""
    _stub_io(monkeypatch, '{"reason": "画面里看不到目标场景"}')
    start, end, reason = agents.VideoAnalyzerAgent()._vision_locate(
        "fake.mp4", [0.0], "目标", "frames")
    assert (start, end) == (-1.0, -1.0)
    assert reason == "画面里看不到目标场景"


def test_vision_locate_null_fields(monkeypatch):
    """模型返回显式 null → 同样按 -1 哨兵返回,不抛 TypeError。"""
    _stub_io(monkeypatch, '{"start": null, "end": null, "reason": "not visible"}')
    start, end, _ = agents.VideoAnalyzerAgent()._vision_locate(
        "fake.mp4", [0.0], "目标", "frames")
    assert (start, end) == (-1.0, -1.0)


def test_revise_bounds_null_start_keeps_current(monkeypatch):
    """修正区间为 null/缺失时维持当前值,正常数值仍生效。"""
    _stub_io(monkeypatch, '{"start": null, "end": 5}')
    ns, ne = agents.ProposerAgent().revise_bounds(1.0, 3.0, "反馈", 10.0)
    assert ns == 1.0
    assert ne == 5.0


def test_probe_duration_na(monkeypatch):
    """ffprobe 输出 N/A(无时长元数据)→ 清晰的 RuntimeError,而非 ValueError。"""
    fake_proc = types.SimpleNamespace(stdout="N/A\n")
    monkeypatch.setattr(ffmpeg_utils, "run", lambda *a, **k: fake_proc)
    with pytest.raises(RuntimeError, match="时长"):
        ffmpeg_utils.probe_duration("no_duration.bin")


def test_probe_duration_normal(monkeypatch):
    fake_proc = types.SimpleNamespace(stdout="12.5\n")
    monkeypatch.setattr(ffmpeg_utils, "run", lambda *a, **k: fake_proc)
    assert ffmpeg_utils.probe_duration("a.mp4") == 12.5

test_bare_output_filename.py

import os
import make_test_video
from video_editor import apply_edit


def test_make_test_video_bare_filename():
    out_name = "test_temp_bare_video.mp4"
    if os.path.exists(out_name):
        os.remove(out_name)
    try:
        path = make_test_video.make(out_name)
        assert os.path.exists(path)
        assert path == out_name
    finally:
        if os.path.exists(out_name):
            os.remove(out_name)
        for i in range(len(make_test_video.SCENES)):
            scene_file = f"_scene_{i}.mp4"
            if os.path.exists(scene_file):
                os.remove(scene_file)


def test_apply_edit_bare_filename():
    source = "test_source.mp4"
    make_test_video.make(source)
    out_name = "test_output_bare_video.mp4"
    if os.path.exists(out_name):
        os.remove(out_name)
    plan = {
        "start": 0.0,
        "end": 2.0,
        "effects": [{"type": "subtitle", "text": "hello"}],
    }
    try:
        path = apply_edit(source, plan, out_name, backend="ffmpeg")
        assert os.path.exists(path)
        assert path == out_name
    finally:
        for name in (out_name, source, "edit.py"):
            if os.path.exists(name):
                os.remove(name)
        for i in range(len(make_test_video.SCENES)):
            scene_file = f"_scene_{i}.mp4"
            if os.path.exists(scene_file):
                os.remove(scene_file)

video_editor.py

"""
视频剪辑执行层(双后端)。

书中实验 5-6 的核心是"代码生成":Proposer Agent 生成一段调用 Blender Python API
(bpy)的脚本来完成剪辑。因此本层有两个后端:

  - blender:把剪辑计划翻译成 bpy 脚本,用 `blender --background --python` 无头渲染
             (见 blender_editor.py)——书中原方案,需安装 Blender;
  - ffmpeg :用 ffmpeg 完成等价的裁剪/字幕/慢动作,单二进制、CI 友好,本机已验证。

apply_edit(..., backend=) 统一入口:backend="auto" 时装了 Blender 走 bpy,否则回退
ffmpeg。**无论走哪个后端,都会把 Proposer 生成的 bpy 脚本落盘(output/edit.py)**,
体现"生成 Blender Python API 代码"这一核心,便于人工核对或换机执行。

支持的操作:
  - trim      裁剪 [start, end] 片段
  - subtitle  在片段上叠加字幕
  - slowmo    慢动作(放慢到 factor 倍时长)
所有操作最终产出一个标准 mp4(H.264 + AAC)。
"""
import os

from blender_editor import blender_available, render_with_blender, write_bpy_script
from ffmpeg_utils import find_font, run


def _esc(text: str) -> str:
    """转义 drawtext/subtitles 文本中的特殊字符。"""
    return text.replace("\\", "\\\\").replace(":", r"\:").replace("'", r"\'")


def apply_edit(source: str, plan: dict, out_path: str,
               backend: str = "auto", script_path: str = None) -> str:
    """
    按剪辑计划 plan 生成成片。

    plan 结构(由 Proposer Agent 产出):
      {
        "start": float,          # 目标片段起点(秒)
        "end": float,            # 目标片段终点(秒)
        "effects": [             # 可选特效列表
          {"type": "subtitle", "text": "..."},
          {"type": "slowmo", "factor": 2.0}
        ]
      }

    backend:
      "auto"    装了 blender 用 bpy,否则回退 ffmpeg(默认)。
      "blender" 强制用 Blender bpy 无头渲染(未装 blender 则报错)。
      "ffmpeg"  强制用 ffmpeg 剪辑。

    无论哪个后端,都会把 Proposer 生成的 bpy 脚本写到 script_path
    (默认 out 同目录 edit.py),作为"生成 Blender API 代码"的产物。
    返回成片路径。
    """
    os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
    if script_path is None:
        script_path = os.path.join(os.path.dirname(out_path) or ".", "edit.py")

    # 始终产出 bpy 脚本:这正是书中"Proposer 生成 Blender 脚本"的落地产物。
    write_bpy_script(source, plan, out_path, script_path)

    use_blender = backend == "blender" or (backend == "auto" and blender_available())
    if use_blender:
        return render_with_blender(source, plan, out_path, script_path)
    if backend == "blender":  # 显式要求 Blender 却没装
        return render_with_blender(source, plan, out_path, script_path)  # 抛清晰错误
    return _apply_edit_ffmpeg(source, plan, out_path)


def _apply_edit_ffmpeg(source: str, plan: dict, out_path: str) -> str:
    """ffmpeg 后端:与 bpy 脚本等价的裁剪/字幕/慢动作,产出 H.264+AAC 的 mp4。"""
    start, end = float(plan["start"]), float(plan["end"])
    if end <= start:
        raise ValueError(f"剪辑区间非法:start={start} >= end={end}")

    effects = plan.get("effects", []) or []
    vf_chain = []          # 视频滤镜链
    af_chain = []          # 音频滤镜链
    font = find_font()

    for eff in effects:
        etype = eff.get("type")
        if etype == "subtitle":
            txt = _esc(eff.get("text", ""))
            opts = [f"text='{txt}'", "fontsize=52", "fontcolor=white",
                    "x=(w-text_w)/2", "y=h-text_h-50",
                    "box=1", "boxcolor=black@0.6", "boxborderw=16"]
            if font:
                opts.insert(0, f"fontfile={font}")
            vf_chain.append("drawtext=" + ":".join(opts))
        elif etype == "slowmo":
            factor = float(eff.get("factor", 2.0))
            vf_chain.append(f"setpts={factor}*PTS")
            # atempo 只支持 0.5~2.0,用 1/factor 放慢音频。
            af_chain.append(f"atempo={max(0.5, min(2.0, 1.0 / factor))}")

    cmd = ["ffmpeg", "-y", "-ss", f"{start:.3f}", "-to", f"{end:.3f}", "-i", source]
    if vf_chain:
        cmd += ["-vf", ",".join(vf_chain)]
    if af_chain:
        cmd += ["-af", ",".join(af_chain)]
    cmd += ["-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", out_path]

    out_dir = os.path.dirname(out_path)
    if out_dir:
        os.makedirs(out_dir, exist_ok=True)
    run(cmd, desc="ffmpeg 剪辑")
    return out_path


# --------------------------------------------------------------------------- #
# Blender 后端说明
# --------------------------------------------------------------------------- #
# 真实的 Blender bpy 脚本生成与无头渲染已实现在 blender_editor.py:
#   - generate_bpy_script():把剪辑计划翻译成一段 bpy 脚本(new_movie / 裁剪 /
#     TEXT / SPEED / render)——即书中"Proposer 生成 Blender 脚本";
#   - render_with_blender():用 `blender --background --python edit.py` 无头执行。
# apply_edit(backend="blender") 即走这条路径;backend="auto"(默认)在未装 Blender 时
# 回退到本文件的 ffmpeg 后端。核心的"两步 Vision 定位 + 提议者-审核者"与执行层解耦,
# 两个后端共用同一份剪辑计划(plan),agents.py / demo.py 无需为切换后端改动逻辑。