ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# 生成的视频、截图、成片——避免仓库膨胀
output/
__pycache__/
.env
+457
View File
@@ -0,0 +1,457 @@
# Experiment 5-6: API-Driven Smart Video Editing / 实验 5-6:基于 API 的智能视频剪辑
> Companion lab for *AI Agents in Depth*, Chapter 5 — NL request + multi-scene video → two-step Vision locate → Blender bpy script / ffmpeg cut → ProposerReviewer.
> 《深入理解 AI Agent》配套:自然语言需求 + 多场景视频 → 两步 Vision 定位 → 生成 Blender bpy / ffmpeg 剪辑 → 提议者-审核者。
← [Chapter 5 index / 返回第 5 章目录](../README.md)
---
## English
### Purpose
Given a multi-scene video and one NL request (e.g. “cut out the surfing part”), the Agent locates the target scene, **generates a Blender Python API script**, cuts the clip, and self-reviews.
Three mechanisms in multimedia processing:
1. **Two-step Vision locate**: Proposer cannot “watch” video directly; a **video-analysis sub-agent** uses ffmpeg frame extraction + Vision LLM to find time bounds.
2. **Code generation (Blender Python API)**: Proposer turns the edit plan into a **bpy** script—import / trim / subtitle / speed / render as API calls, run with `blender --background --python edit.py`. Without Blender, scripts still generate; render falls back to ffmpeg (see “edit backends”).
3. **Proposer / Reviewer**: after cut, Reviewer samples key frames with Vision; fail → feedback → iterate.
### Two-step locate
Scanning every frame is slow/expensive → coarse then fine:
- **Coarse**: one frame every **10s**; Vision gets a rough interval (e.g. surfing 2030s).
- **Fine**: expand the coarse window by one coarse step; one frame every **1s**; Vision returns precise bounds (e.g. 1529s).
Encapsulated as a **sub-agent**: tens of screenshots live only in the sub-agents one-shot context and do not pollute Proposer/Reviewer history. Demo prints token comparison at the end.
### ProposerReviewer flow
```
NL request ──► Proposer parses intent (scene + effects)
Video-analysis sub-agent two-step locate ──► [start, end]
Proposer emits Blender bpy script (edit.py) ──► render cut (+ subtitle/slow-mo)
│ Blender if installed else ffmpeg
Reviewer samples start/mid/end frames ──► Vision pass/fail + feedback
│pass? no → Proposer adjusts bounds, re-cut (max 3 rounds)
▼yes
final.mp4
```
### Run
```bash
# From the repository root: use the shared Chapter 5 environment
uv sync --locked --python 3.12 --extra ch5
# Activate it before changing directories:
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
# pip fallback when uv is not installed:
# python -m pip install -e ".[ch5]"
cd chapter5/video-edit
# Single-project compatibility path, still supported during migration:
# python -m pip install -r requirements.txt
cp env.example .env # OPENAI_API_KEY (or OPENROUTER_API_KEY fallback)
python demo.py # default: "把冲浪的部分剪出来" (full pipeline)
python demo.py "把滑雪部分剪出来,并加上字幕 Winter" # custom request
python demo.py -i my.mp4 -o out.mp4 "把演讲开场剪出来" # own video + output
python demo.py --backend blender # force Blender headless (Blender required)
python demo.py --vision-model gpt-5.6-luna # also --text-model
python demo.py --quick # coarse sampling + single review round (cheapest Vision)
python demo.py --smoke # smoke: edit path + bpy script only; no API
python demo.py --help
```
Common flags (see `--help`): `--input/-i`, `--output/-o`, `--backend {auto,blender,ffmpeg}`, `--text-model` / `--vision-model`.
One command runs: generate/read video → two-step locate → bpy cut → review → final. Each run clears `output/` (idempotent). Full path calls Vision many times; use `--smoke` (zero API) or `--quick` first.
### Sample outputs
#### `--smoke` (zero API, reproducible)
Real output of `python demo.py --smoke` (no OpenAI key; needs ffmpeg):
```text
==========================================================================
冒烟自检 | 剪辑链路 + bpy 脚本生成,不调用任何 API
==========================================================================
[1/3] 生成测试视频 OKoutput/source.mp4(场景真值={'hiking': (0, 15), 'surfing': (15, 30), 'skiing': (30, 42), 'cycling': (42, 54)}
[2/3] 抽帧 OKoutput/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` is an executable Blender bpy script (`new_movie`, `frame_offset_start` / `frame_final_duration`, `new_effect(type='TEXT')`, `bpy.ops.render.render`); syntax-checked with `py_compile`.
#### `--quick` (full path, needs API)
Excerpt from `python demo.py --quick` (default surfing request); locate/error/token parts independent of bpy/ffmpeg:
```text
步骤 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
Reviewerpass=... score=... 检查帧=['0.5', '6.0', '11.5']
Token 统计(子 Agent 隔离截图,主上下文不被污染)
主 AgentProposer+Reviewer):573 tokens
子 Agent(两步定位截图) 2934 tokens
```
Artifacts under `output/`:
| File | Duration | Note |
| --- | --- | --- |
| `source.mp4` | 54.0s | Procedural 4-scene test source |
| `edit_round1.py` | — | Proposer bpy script (portable) |
| `cut_round1.mp4` | 12.0s | Round-1 candidate |
| `final.mp4` | 12.0s | Chosen final (H.264 + AAC, 1280x720@30fps) |
Token stats: tens of screenshots (2934 tok) only in **sub-agent**; main history (~573 tok) almost unpolluted. (Synthetic test shows “SURFING” text, not real surfing—Reviewer may fail on content; real video avoids that.)
### Dependencies
- **ffmpeg / ffprobe** for cuts and frames. `brew install ffmpeg` (macOS) / `apt install ffmpeg` (Ubuntu). Validated on ffmpeg 8.0.
- **OPENAI_API_KEY**: `gpt-5.6-luna` for vision locate/review and text planning (vision model must accept images); or `OPENROUTER_API_KEY` fallback via OpenRouter.
### Adapt / extend
#### Model / provider
All via env (`env.example`); no code change:
- `TEXT_MODEL` (default `gpt-5.6-luna`).
- `VISION_MODEL` must support images (default `gpt-5.6-luna`).
- `OPENAI_BASE_URL` + matching `OPENAI_API_KEY` for compatible endpoints.
```bash
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 reads these lazily via `client()`.
#### Own input video
`make_test_video.py` builds a 54s video with 4 distinct scenes (HIKING green / SURFING blue / SKIING white / CYCLING orange) plus large scene-name and timecode watermarks for Vision reproducibility.
Own video: `python demo.py -i your.mp4 -o out.mp4 "edit request"` (skips test generation; no ground-truth error print).
#### Blender vs ffmpeg (edit backends)
Book uses **Blender Python API (bpy)** on the VSE. First-class here: `blender_editor.generate_bpy_script()` emits real bpy (`new_movie`, frame offsets, `TEXT`/`SPEED` effects, render); `render_with_blender()` runs `blender --background --python edit.py`.
- `--backend blender`: force Blender (`blender --version` required);
- `--backend ffmpeg`: force ffmpeg;
- `--backend auto` (default): bpy if Blender installed, else ffmpeg.
**Always**: Proposers bpy script is written to `output/edit_round*.py` (`output/edit.py` under `--smoke`)—core “generate Blender Python API code” artifact. This environment may not have Blender, so render is validated with ffmpeg; bpy is `py_compile`-checked but **not rendered in real Blender** unless you install it and use `--backend blender`.
| | ffmpeg | Blender (bpy) |
| --- | --- | --- |
| Fit | 2D cut/join/subtitle/speed pipelines | 3D scenes, compositing, keyframe anim, particles/camera |
| Ops | single binary, no GUI, CI-friendly | full Blender install; larger/slower |
| When | most “cut a clip + simple effects” | only when 3D/compositing/complex transitions needed |
Two-step Vision + ProposerReviewer is decoupled from the execution layer; same edit plan for both backends.
### Files
| File | Role |
| --- | --- |
| `demo.py` | CLI orchestration, self-check, iteration, token stats |
| `agents.py` | `VideoAnalyzerAgent` / `ProposerAgent` / `ReviewerAgent` |
| `blender_editor.py` | **bpy script gen + headless render** (book path) |
| `video_editor.py` | `apply_edit()`; Blender/ffmpeg backends |
| `make_test_video.py` | Procedural 4-scene test video |
| `ffmpeg_utils.py` | Thin ffmpeg/ffprobe wrappers |
`output/` is gitignored.
### Limitations
- Locate accuracy depends on visual distinctness; gradual scene transitions increase boundary error vs solid-color test film.
- Fine step fixed at 1s → boundary precision about ±1s (book acceptance ±3s).
- Slow-mo audio uses `atempo`; extreme ratios hurt quality; complex transitions/multi-track not covered.
- Reviewer samples only start/mid/end; mid-clip glitches can be missed (raise sample density if needed).
---
## 中文
### 目的
用户给一段含多个场景的视频 + 一句自然语言需求(如"把冲浪部分剪出来"),Agent 自动定位目标场景、**生成 Blender Python API 脚本**剪出片段并自我审查。
验证三个核心机制在多媒体处理中的作用:
1. **两步 Vision 定位**Proposer 无法直接"看懂"视频,于是委托一个**视频分析子 Agent**,
用 ffmpeg 抽帧 + Vision LLM 读图来定位目标场景的时间边界。
2. **代码生成(Blender Python API**:Proposer 把剪辑计划翻译成一段调用 **Blender
Python APIbpy** 的脚本——导入 / 裁剪 / 字幕 / 变速 / 渲染各对应一个 API 调用,
`blender --background --python edit.py` 无头执行。这正是书中"把视频编辑重构为
API 调用和代码生成问题"的落地。未装 Blender 时脚本照常生成(代码生成产物),
实际渲染回退到 ffmpeg(见下文"剪辑后端")。
3. **提议者-审核者(Proposer / Reviewer**:Proposer 剪辑后无法自证效果,
由 Reviewer 抽取成片关键帧、用 Vision LLM 检查是否剪对,不合格则反馈、迭代。
### 两步定位原理
Vision LLM 逐帧扫全片既慢又贵,因此采用"先粗后细":
- **第一步(粗粒度)**:每 **10 秒**抽一帧,把全片的稀疏截图连同"要找哪个场景"一起
交给 Vision,得到大致区间(如"冲浪在 2030s")。
- **第二步(细粒度)**:在粗区间上下各外扩一个粗间隔,每 **1 秒**抽一帧,
再问 Vision 精确边界(如"1529s")。
把这套抽帧-读图封装成**独立子 Agent**:几十张截图只进入子 Agent 的一次性上下文,
不会污染主 AgentProposer/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
```
### 运行
```bash
# 在仓库根目录使用统一的第 5 章环境
uv sync --locked --python 3.12 --extra ch5
# 切换目录前先激活环境:
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell.\.venv\Scripts\Activate.ps1
# Windows cmd.venv\Scripts\activate.bat
# 未安装 uv 时可用 pip 兜底:
# python -m pip install -e ".[ch5]"
cd chapter5/video-edit
# 迁移期间仍支持单项目兼容路径:
# python -m 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):
```text
==========================================================================
冒烟自检 | 剪辑链路 + bpy 脚本生成,不调用任何 API
==========================================================================
[1/3] 生成测试视频 OKoutput/source.mp4(场景真值={'hiking': (0, 15), 'surfing': (15, 30), 'skiing': (30, 42), 'cycling': (42, 54)}
[2/3] 抽帧 OKoutput/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 后端切换影响):
```text
步骤 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
Reviewerpass=... score=... 检查帧=['0.5', '6.0', '11.5']
Token 统计(子 Agent 隔离截图,主上下文不被污染)
主 AgentProposer+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 + AAC1280x720@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` 即可。
```bash
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 APIbpy** 驱动视频序列编辑器(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 | Blenderbpy |
| --- | --- | --- |
| 定位 | 裁剪/拼接/字幕/变速等 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 仅抽首/中/尾三帧,长片段中段的偶发错误可能漏检(可调高抽帧密度)。
---
## Notes / 说明
- Prefer `--smoke` then `--quick` before a full Vision run. / 完整 Vision 前先 `--smoke`,再 `--quick`
- Commands/code/paths/env vars are identical in both language sections. / 命令、代码、路径与环境变量在中英文两侧保持一致。
+325
View File
@@ -0,0 +1,325 @@
"""
实验 5-6 的三个 Agent
VideoAnalyzerAgent —— 视频分析子 Agent,用"两步 Vision 定位"找目标场景边界。
ProposerAgent —— 把自然语言需求解析成剪辑计划,调用子 Agent 定位并执行剪辑。
ReviewerAgent —— 抽取成片关键帧,用 Vision 检查是否剪对,给出结构化反馈。
把视频分析封装为独立子 Agent 的意义:大量截图只进入子 Agent 的一次性上下文,
不会污染主 AgentProposer/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 对象。"""
start = text.find("{")
if start < 0:
raise ValueError(f"未能从回复中解析 JSON{text[:200]}")
try:
obj, _ = json.JSONDecoder().raw_decode(text, start)
except json.JSONDecodeError as e:
raise ValueError(f"未能从回复中解析 JSON{text[:200]}") from e
if not isinstance(obj, dict):
raise ValueError(f"未能从回复中解析 JSON{text[:200]}")
return obj
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
+210
View File
@@ -0,0 +1,210 @@
"""
Blender Python APIbpy)剪辑执行层 —— 实验 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,忽略即可)
# frame_offset_start trims the strip's visible left edge as well as advancing
# into the source. Start the raw strip earlier by the same amount so the
# trimmed clip's final visible start remains at output frame 1.
movie = se.sequences.new_movie(
name="clip", filepath=SRC, channel=1, frame_start=1 - start_frame
)
try:
sound = se.sequences.new_sound(
name="audio", filepath=SRC, channel=2, frame_start=1 - start_frame
)
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":
# Skip null factor like non-positive.
raw = eff.get("factor", 2.0)
if raw is None:
continue
factor = float(raw)
if factor <= 0:
continue
slowmo = factor
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"))
+792
View File
@@ -0,0 +1,792 @@
"""Real-media, real-Vision, real-Blender acceptance campaign for Experiment 5-6."""
from __future__ import annotations
import argparse
import ast
import base64
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from openai import OpenAI
ROOT = Path(__file__).resolve().parent
VALIDATION = ROOT / "validation"
SOURCE_CACHE = VALIDATION / "source_cache" / "big-buck-bunny-trailer-480p.mov"
SOURCE_URL = "https://download.blender.org/peach/trailer/trailer_480p.mov"
SOURCE_SHA256 = "36801b74638c12be9aa587e93cd18edfc9bc51a1c089ab2a19ee42beed9f497d"
BLENDER = Path("/Applications/Blender.app/Contents/MacOS/Blender")
GROUND_TRUTH = {"start": 9.08, "end": 11.20}
TARGET = (
"the single continuous shot showing the large white rabbit walking alone in a sunny green "
"meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title"
)
REQUEST = (
"Cut out the shot of the large white rabbit walking alone in the sunny meadow, slow it to "
"1.5x duration, and add the subtitle BIG BUNNY along the bottom."
)
def _utc() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(value, ensure_ascii=False, indent=2) + "\n"
if re.search(r"\b(?:sk|gh[opusr])-[A-Za-z0-9_-]{12,}\b", text):
raise ValueError(f"credential-shaped value in {path}")
path.write_text(text, encoding="utf-8")
def _probe(path: Path) -> dict[str, Any]:
process = subprocess.run(
[
"ffprobe", "-v", "error", "-show_entries",
"format=duration,size,format_name:stream=index,codec_type,codec_name,width,height,r_frame_rate,sample_rate,channels",
"-of", "json", str(path),
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return json.loads(process.stdout)
def _extract(source: Path, timestamp: float, output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", f"{timestamp:.3f}",
"-i", str(source), "-frames:v", "1", "-vf", "scale=640:-2", "-y", str(output),
],
check=True,
)
if not output.is_file():
raise RuntimeError(f"ffmpeg produced no frame at {timestamp:.3f}s: {source}")
def _frame_set(source: Path, timestamps: list[float], directory: Path) -> list[dict[str, Any]]:
result = []
for timestamp in timestamps:
# PNG avoids the local ffmpeg build's strict MJPEG full-range rejection
# on the trailer's final credits frame while preserving the exact pixels.
path = directory / f"frame-{timestamp:06.2f}.png"
_extract(source, timestamp, path)
result.append(
{
"timestamp_s": timestamp,
"path": str(path),
"sha256": _sha(path),
"bytes": path.stat().st_size,
}
)
return result
class Backend:
def __init__(self, provider: str):
if provider == "ark":
key = os.environ.get("ARK_API_KEY")
self.endpoint = os.environ.get("ARK_BASE_URL") or "https://ark.cn-beijing.volces.com/api/v3"
self.model = os.environ.get("ARK_MODEL") or "doubao-seed-1-6-250615"
elif provider == "moonshot":
key = os.environ.get("MOONSHOT_API_KEY") or os.environ.get("KIMI_API_KEY")
self.endpoint = os.environ.get("MOONSHOT_BASE_URL") or "https://api.moonshot.cn/v1"
self.model = os.environ.get("KIMI_MODEL") or "kimi-k3"
else:
key = os.environ.get("OPENAI_API_KEY")
self.endpoint = os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1"
self.model = os.environ.get("OPENAI_MODEL") or "gpt-5.6-luna"
if not key:
raise RuntimeError(f"missing credential for {provider}")
self.provider = provider
self.receipt_checkpoint: Path | None = None
self.client = OpenAI(api_key=key, base_url=self.endpoint, timeout=240, max_retries=0)
def _json_object(text: str) -> dict[str, Any]:
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1].rsplit("```", 1)[0]
value = json.loads(text)
if not isinstance(value, dict):
raise ValueError("model response must be an object")
return value
def _call(
backend: Backend,
*,
purpose: str,
messages: list[dict[str, Any]],
receipt_messages: list[dict[str, Any]],
receipts: list[dict[str, Any]],
max_tokens: int,
) -> dict[str, Any]:
request: dict[str, Any] = {
"model": backend.model,
"messages": messages,
"temperature": 0,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
}
started = time.perf_counter()
response = backend.client.chat.completions.create(**request)
latency = round(time.perf_counter() - started, 3)
content = response.choices[0].message.content or ""
usage = response.usage
receipts.append(
{
"purpose": purpose,
"provider": backend.provider,
"endpoint": backend.endpoint,
"request": {
"model": backend.model,
"messages": receipt_messages,
"temperature": 0,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
},
"response": {
"id": response.id,
"model": response.model,
"finish_reason": response.choices[0].finish_reason,
"content": content,
},
"usage": {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
},
"latency_s": latency,
}
)
if backend.receipt_checkpoint is not None:
_write_json(backend.receipt_checkpoint, {"calls": receipts})
return _json_object(content)
def _vision(
backend: Backend,
*,
purpose: str,
prompt: str,
frames: list[dict[str, Any]],
receipts: list[dict[str, Any]],
) -> dict[str, Any]:
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
summarized: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for frame in frames:
path = Path(frame["path"])
content.append({"type": "text", "text": f"timestamp={frame['timestamp_s']:.2f}s"})
content.append(
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode("ascii"),
"detail": "low",
},
}
)
summarized.append({"type": "text", "text": f"timestamp={frame['timestamp_s']:.2f}s"})
summarized.append(
{
"type": "image_artifact",
"path": frame["path"],
"sha256": frame["sha256"],
"bytes": frame["bytes"],
}
)
return _call(
backend,
purpose=purpose,
messages=[{"role": "user", "content": content}],
receipt_messages=[{"role": "user", "content": summarized}],
receipts=receipts,
max_tokens=700,
)
def _bounds(value: dict[str, Any], duration: float) -> tuple[float, float]:
start = float(value["start"])
end = float(value["end"])
if not (0 <= start < end <= duration):
raise ValueError(f"invalid model interval [{start}, {end}] for duration {duration}")
return start, end
def _script_is_safe(code: str, source: Path, output: Path) -> None:
tree = ast.parse(code)
imports = {
alias.name
for node in ast.walk(tree)
if isinstance(node, (ast.Import, ast.ImportFrom))
for alias in node.names
}
if "bpy" not in imports:
raise ValueError("generated script does not import bpy")
forbidden_imports = {"subprocess", "socket", "requests", "urllib", "http", "ftplib"}
if imports & forbidden_imports:
raise ValueError(f"generated script imports forbidden modules: {sorted(imports & forbidden_imports)}")
forbidden_calls = {"eval", "exec", "compile", "__import__", "system", "popen", "remove", "unlink", "rmtree"}
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = ""
if isinstance(node.func, ast.Name):
name = node.func.id
elif isinstance(node.func, ast.Attribute):
name = node.func.attr
if name.casefold() in forbidden_calls:
raise ValueError(f"generated script calls forbidden function: {name}")
if str(source.resolve()) not in code or str(output.resolve()) not in code:
raise ValueError("generated script does not pin the supplied input and output paths")
required_markers = ("new_movie", "frame_offset_start", "new_effect", "TEXT", "SPEED", "render")
missing = [marker for marker in required_markers if marker not in code]
if missing:
raise ValueError(f"generated script omits requested Blender API operations: {missing}")
def _render_model_script(
backend: Backend,
*,
label: str,
source: Path,
output: Path,
start: float,
end: float,
scripts: Path,
logs: Path,
receipts: list[dict[str, Any]],
) -> dict[str, Any]:
prompt = f"""Generate a complete Blender 4.3 Python script for this video edit.
Input movie: {source.resolve()}
Output MP4: {output.resolve()}
Source FPS: 25
Source size: 853x480; render at 854x480 (one-pixel even-width pad required by H.264)
Trim interval: [{start:.3f}, {end:.3f}] seconds
Effects: slow playback so output duration is 1.5 times the trimmed interval; add bottom-centered subtitle BIG BUNNY with a visible semi-transparent dark box.
Requirements:
- Use bpy and Blender's Video Sequence Editor, including new_movie, new_sound when available, frame_offset_start/frame_final_duration, a SPEED effect, and a TEXT effect.
- Render exactly 854x480 at 25 fps through Blender to MPEG-4 H.264 with AAC audio.
- Set the scene frame range to the slowed duration and call bpy.ops.render.render(animation=True).
- Create only the requested output. Do not invoke ffmpeg, subprocesses, a shell, the network, or read credentials.
- Use APIs available in Blender 4.3 (scene.sequence_editor_create().sequences).
- In Blender 4.3, SpeedControlSequence has no use_audio property. Never read or assign use_audio, and do not apply a SPEED effect to the sound strip. A valid movie slow-motion pattern is speed_control='MULTIPLY', speed_factor=1/1.5, and extending the movie/render duration; the sound strip may remain normally trimmed.
- For the subtitle background, prefer the TEXT strip's use_box=True and box_color=(0,0,0,0.6); do not assume a COLOR strip has text-layout properties.
Use this real-Blender-4.3-validated timing pattern, substituting the supplied paths and interval but preserving the timing relationships exactly:
```
FPS = 25
start_frame = int(round(START_SECONDS * FPS))
dur_frames = max(1, int(round((END_SECONDS - START_SECONDS) * FPS)))
render_dur = int(round(dur_frames * 1.5))
se = scene.sequence_editor_create()
movie = se.sequences.new_movie(name='clip', filepath=INPUT_PATH, channel=1,
frame_start=1 - start_frame)
sound = se.sequences.new_sound(name='audio', filepath=INPUT_PATH, channel=2,
frame_start=1 - start_frame)
for strip in (movie, sound):
strip.frame_offset_start = start_frame
strip.frame_final_duration = dur_frames
speed = se.sequences.new_effect(name='slowmo', type='SPEED', channel=3,
frame_start=1, frame_end=1 + dur_frames, seq1=movie)
speed.use_default_fade = False
speed.speed_control = 'MULTIPLY'
speed.speed_factor = 1.0 / 1.5
movie.frame_final_duration = render_dur
text = se.sequences.new_effect(name='subtitle', type='TEXT', channel=4,
frame_start=1, frame_end=1 + render_dur)
text.text = 'BIG BUNNY'; text.location = (0.5, 0.12)
text.align_x = 'CENTER'; text.align_y = 'BOTTOM'
text.use_box = True; text.box_color = (0.0, 0.0, 0.0, 0.6)
scene.frame_start = 1; scene.frame_end = render_dur
```
This pattern was executed against Blender 4.3.2 and visibly retained the requested source frames through the whole slowed output. Do not replace its negative source-strip frame_start with frame_start=1: frame_offset_start would then move the visible strip later and render black frames.
Return one JSON object only: {{"code":"complete executable Python source"}}.
"""
feedback = ""
attempts: list[dict[str, Any]] = []
for attempt in range(1, 7):
payload = _call(
backend,
purpose=f"blender_script_{label}_attempt_{attempt}",
messages=[
{"role": "system", "content": "You are a Blender VSE engineer. Return one JSON object only."},
{"role": "user", "content": prompt + (f"\nPrior executable feedback:\n{feedback}" if feedback else "")},
],
receipt_messages=[
{"role": "system", "content": "You are a Blender VSE engineer. Return one JSON object only."},
{"role": "user", "content": prompt + (f"\nPrior executable feedback:\n{feedback}" if feedback else "")},
],
receipts=receipts,
max_tokens=7000,
)
code = payload.get("code")
script_path = scripts / f"{label}-attempt-{attempt}.py"
if not isinstance(code, str):
feedback = "Response lacked a string code field."
attempts.append({"attempt": attempt, "accepted": False, "error": feedback})
continue
script_path.write_text(code, encoding="utf-8")
try:
_script_is_safe(code, source, output)
except (SyntaxError, ValueError) as exc:
feedback = f"Static safety/API validation failed: {type(exc).__name__}: {exc}"
attempts.append({"attempt": attempt, "script": str(script_path), "accepted": False, "error": feedback})
continue
if output.exists():
output.unlink()
started = time.perf_counter()
process = subprocess.run(
[str(BLENDER), "--background", "--python", str(script_path)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=300,
)
latency = round(time.perf_counter() - started, 3)
log_path = logs / f"{label}-attempt-{attempt}.log"
log_path.write_text(process.stdout, encoding="utf-8")
accepted = (
process.returncode == 0
and "Traceback (most recent call last)" not in process.stdout
and output.is_file()
and output.stat().st_size > 1000
)
attempt_record = {
"attempt": attempt,
"script": str(script_path),
"script_sha256": _sha(script_path),
"blender_log": str(log_path),
"blender_log_sha256": _sha(log_path),
"blender_exit_code": process.returncode,
"blender_latency_s": latency,
"accepted": accepted,
}
if not accepted and output.is_file():
partial_output = logs / f"{label}-attempt-{attempt}.partial.mp4"
output.replace(partial_output)
attempt_record["partial_output"] = str(partial_output)
attempt_record["partial_output_sha256"] = _sha(partial_output)
attempts.append(attempt_record)
if accepted:
return {"attempts": attempts, "accepted_script": str(script_path), "output": str(output)}
feedback = (
f"Blender exit={process.returncode}; output_exists={output.exists()}. "
"Tail of real Blender log:\n" + "\n".join(process.stdout.splitlines()[-30:])
)
raise RuntimeError(f"model never generated an executable Blender script for {label}: {attempts}")
def _review(
backend: Backend,
*,
purpose: str,
clip: Path,
directory: Path,
receipts: list[dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
duration = float(_probe(clip)["format"]["duration"])
timestamps = sorted({round(min(0.35, duration / 5), 3), round(duration / 2, 3), round(max(0.0, duration - 0.35), 3)})
frames = _frame_set(clip, timestamps, directory)
prompt = f"""Independently review these actual rendered keyframes against this edit request:
{REQUEST}
The target source shot is: {TARGET}.
Judge the pixels, not filenames or claims. Return JSON exactly with:
{{"pass":boolean,"target_present":boolean,"irrelevant_content_present":boolean,
"subtitle_visible":boolean,"feedback":"specific evidence from the frames"}}.
Pass only if all frames show the intended large white rabbit meadow shot without title cards or unrelated scenes and the BIG BUNNY subtitle is visibly overlaid near the bottom.
"""
result = _vision(backend, purpose=purpose, prompt=prompt, frames=frames, receipts=receipts)
for key in ("pass", "target_present", "irrelevant_content_present", "subtitle_visible"):
if not isinstance(result.get(key), bool):
raise ValueError(f"review response lacks boolean {key}")
return result, frames
def _refine_boundaries(
backend: Backend,
*,
source: Path,
duration: float,
start: float,
end: float,
reviewer_feedback: str,
round_number: int,
directory: Path,
receipts: list[dict[str, Any]],
) -> tuple[float, float, dict[str, Any], list[dict[str, Any]]]:
"""Ask Vision to tighten a rejected edit using dense source-frame evidence.
Ground-truth annotations are deliberately not supplied here: the next edit
must be driven by the independent review and actual source pixels.
"""
lo = max(0.0, start - 1.0)
hi = min(duration, end + 1.0)
first_tick = math.ceil(lo * 4)
last_tick = math.floor(hi * 4)
timestamps = [tick / 4 for tick in range(first_tick, last_tick + 1)]
frames = _frame_set(source, timestamps, directory)
prompt = f"""A rendered video edit was rejected by an independent pixel reviewer.
Target shot: {TARGET}
Prior attempted source interval: [{start:.3f}, {end:.3f}] seconds
Reviewer feedback: {reviewer_feedback}
These are dense 0.25-second frames from the source around both attempted boundaries.
Using only the timestamped pixels and the reviewer feedback, return the maximal safe
continuous interval containing the intended rabbit-meadow shot while excluding every
neighboring title card or unrelated shot. It is better to trim a fraction of a second
of the target than include even one title-card frame.
Return JSON exactly as {{"start":seconds,"end":seconds,"reason":"visual boundary evidence"}}.
"""
result = _vision(
backend,
purpose=f"reviewer_driven_quarter_second_boundary_refinement_{round_number}",
prompt=prompt,
frames=frames,
receipts=receipts,
)
refined_start, refined_end = _bounds(result, duration)
return refined_start, refined_end, result, frames
def run(provider: str, run_id: str) -> dict[str, Any]:
run_dir = VALIDATION / "runs" / run_id
if run_dir.exists():
raise FileExistsError(run_dir)
run_dir.mkdir(parents=True)
if not SOURCE_CACHE.is_file() or _sha(SOURCE_CACHE) != SOURCE_SHA256:
raise RuntimeError("pinned source cache missing or hash mismatch")
if not BLENDER.is_file():
raise RuntimeError(f"real Blender binary not found: {BLENDER}")
source = run_dir / "source.mov"
shutil.copy2(SOURCE_CACHE, source)
source_probe = _probe(source)
duration = float(source_probe["format"]["duration"])
backend = Backend(provider)
receipts: list[dict[str, Any]] = []
backend.receipt_checkpoint = run_dir / "provider_receipts.checkpoint.json"
frames_dir = run_dir / "frames"
scripts_dir = run_dir / "scripts"
logs_dir = run_dir / "blender_logs"
scripts_dir.mkdir()
logs_dir.mkdir()
source_evidence = {
"url": SOURCE_URL,
"title": "Big Buck Bunny trailer (Blender Foundation Peach Open Movie Project)",
"license": "Creative Commons Attribution 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"sha256": _sha(source),
"probe": source_probe,
"naturally_occurring_scenes": True,
"ground_truth": {
**GROUND_TRUTH,
"target": TARGET,
"method": "Human-labeled target shot bounded by ffmpeg scene-change frames; source inspection found transitions at 9.08 and 11.20 seconds.",
"ffmpeg_scene_threshold": 0.35,
},
}
_write_json(run_dir / "source_evidence.json", source_evidence)
# Container audio extends slightly beyond the last decodable video frame;
# stay 1.5 s inside format duration for the final sparse sample.
coarse_times = [0.0, 10.0, 20.0, 30.0, round(duration - 1.5, 3)]
coarse_frames = _frame_set(source, coarse_times, frames_dir / "coarse")
coarse_prompt = f"""These are frames from one contiguous real trailer sampled about every 10 seconds.
Locate this target: {TARGET}.
Return JSON {{"start":seconds,"end":seconds,"reason":"visual evidence"}} with a rough continuous interval. Use timestamps and visual content only."""
coarse = _vision(
backend,
purpose="coarse_10_second_visual_localization",
prompt=coarse_prompt,
frames=coarse_frames,
receipts=receipts,
)
coarse_start, coarse_end = _bounds(coarse, duration)
midpoint = (coarse_start + coarse_end) / 2
fine_lo = max(0, math.floor(midpoint - 10))
fine_hi = min(duration, math.ceil(midpoint + 10))
fine_times = [float(value) for value in range(int(fine_lo), int(math.floor(fine_hi)) + 1)]
fine_frames = _frame_set(source, fine_times, frames_dir / "fine")
fine_prompt = f"""These are one-second samples from the narrowed window [{fine_lo:.1f}, {fine_hi:.1f}] seconds of the same real trailer.
Precisely locate the boundaries of this one continuous target shot: {TARGET}.
Return JSON {{"start":seconds,"end":seconds,"reason":"visual boundary evidence"}}. The answer may interpolate between adjacent one-second samples; do not include either neighboring title card."""
fine = _vision(
backend,
purpose="fine_1_second_visual_localization",
prompt=fine_prompt,
frames=fine_frames,
receipts=receipts,
)
start, end = _bounds(fine, duration)
localization = {
"request": REQUEST,
"target": TARGET,
"coarse_interval_s": 10,
"coarse_frames": coarse_frames,
"coarse_result": coarse,
"fine_interval_s": 1,
"fine_window": [fine_lo, fine_hi],
"fine_frames": fine_frames,
"fine_result": fine,
"ground_truth": GROUND_TRUTH,
"start_error_s": round(abs(start - GROUND_TRUTH["start"]), 3),
"end_error_s": round(abs(end - GROUND_TRUTH["end"]), 3),
}
_write_json(run_dir / "localization.json", localization)
negative_output = run_dir / "negative_control.mp4"
negative_render = _render_model_script(
backend,
label="negative-control",
source=source,
output=negative_output,
start=0.0,
end=3.0,
scripts=scripts_dir,
logs=logs_dir,
receipts=receipts,
)
negative_review, negative_frames = _review(
backend,
purpose="review_negative_control_rendered_pixels",
clip=negative_output,
directory=frames_dir / "negative-review",
receipts=receipts,
)
correction_triggered = negative_review["pass"] is False
if not correction_triggered:
raise RuntimeError("independent reviewer failed to reject the obvious wrong-shot negative control")
final_output = run_dir / "final.mp4"
final_render = _render_model_script(
backend,
label="corrected-final",
source=source,
output=final_output,
start=start,
end=end,
scripts=scripts_dir,
logs=logs_dir,
receipts=receipts,
)
final_review, final_frames = _review(
backend,
purpose="review_corrected_final_rendered_pixels",
clip=final_output,
directory=frames_dir / "final-review",
receipts=receipts,
)
correction_rounds: list[dict[str, Any]] = [
{
"round": 0,
"kind": "initial_fine_localization",
"plan": {"start": start, "end": end, "effects": ["slowmo:1.5", "subtitle:BIG BUNNY"]},
"render": final_render,
"review": final_review,
"frames": final_frames,
}
]
# A reviewer rejection is actionable evidence, not merely a failed gate.
# Densely inspect the real source pixels, ask Vision to tighten the interval,
# then generate and execute a fresh Blender script before reviewing again.
for refinement_round in range(1, 4):
if final_review["pass"]:
break
prior_start, prior_end = start, end
start, end, refinement, refinement_frames = _refine_boundaries(
backend,
source=source,
duration=duration,
start=prior_start,
end=prior_end,
reviewer_feedback=str(final_review.get("feedback") or ""),
round_number=refinement_round,
directory=frames_dir / f"boundary-refinement-{refinement_round}",
receipts=receipts,
)
final_render = _render_model_script(
backend,
label=f"corrected-final-refinement-{refinement_round}",
source=source,
output=final_output,
start=start,
end=end,
scripts=scripts_dir,
logs=logs_dir,
receipts=receipts,
)
final_review, final_frames = _review(
backend,
purpose=f"review_corrected_final_refinement_{refinement_round}_rendered_pixels",
clip=final_output,
directory=frames_dir / f"final-review-refinement-{refinement_round}",
receipts=receipts,
)
correction_rounds.append(
{
"round": refinement_round,
"kind": "reviewer_driven_quarter_second_refinement",
"prior_interval": [prior_start, prior_end],
"source_frames": refinement_frames,
"refinement": refinement,
"plan": {"start": start, "end": end, "effects": ["slowmo:1.5", "subtitle:BIG BUNNY"]},
"render": final_render,
"review": final_review,
"frames": final_frames,
}
)
localization["reviewer_driven_refinements"] = [
{
"round": item["round"],
"prior_interval": item["prior_interval"],
"source_frames": item["source_frames"],
"result": item["refinement"],
}
for item in correction_rounds[1:]
]
localization["final_interval"] = [start, end]
localization["final_start_error_s"] = round(abs(start - GROUND_TRUTH["start"]), 3)
localization["final_end_error_s"] = round(abs(end - GROUND_TRUTH["end"]), 3)
_write_json(run_dir / "localization.json", localization)
final_probe = _probe(final_output)
output_duration = float(final_probe["format"]["duration"])
expected_duration = (end - start) * 1.5
blender_version = subprocess.run(
[str(BLENDER), "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
).stdout
(run_dir / "blender_version.txt").write_text(blender_version, encoding="utf-8")
_write_json(
run_dir / "review.json",
{
"negative_control": {
"plan": {"start": 0.0, "end": 3.0, "effects": ["slowmo:1.5", "subtitle:BIG BUNNY"]},
"render": negative_render,
"review": negative_review,
"frames": negative_frames,
},
"correction_triggered": correction_triggered,
"correction_rounds": correction_rounds,
"corrected_final": {
"plan": {"start": start, "end": end, "effects": ["slowmo:1.5", "subtitle:BIG BUNNY"]},
"render": final_render,
"review": final_review,
"frames": final_frames,
"probe": final_probe,
"expected_slowed_duration_s": expected_duration,
"observed_duration_s": output_duration,
},
},
)
_write_json(run_dir / "provider_receipts.json", {"calls": receipts})
video_streams = [stream for stream in final_probe["streams"] if stream.get("codec_type") == "video"]
gates = {
"pinned_real_public_multiscene_source": source_evidence["sha256"] == SOURCE_SHA256,
"coarse_real_vision_about_10_seconds": len(coarse_frames) >= 4 and bool(coarse.get("reason")),
"fine_real_vision_every_1_second": len(fine_frames) >= 10 and all(
abs(b["timestamp_s"] - a["timestamp_s"] - 1.0) < 1e-6 for a, b in zip(fine_frames, fine_frames[1:])
),
"start_boundary_error_le_3s": localization["final_start_error_s"] <= 3.0,
"end_boundary_error_le_3s": localization["final_end_error_s"] <= 3.0,
"model_generated_blender_python": all(
Path(item["accepted_script"]).is_file()
for item in [negative_render, *[round_["render"] for round_ in correction_rounds]]
),
"actual_blender_execution": all(
any(attempt.get("accepted") and attempt.get("blender_exit_code") == 0 for attempt in item["attempts"])
for item in [negative_render, *[round_["render"] for round_ in correction_rounds]]
),
"reviewer_rejected_bad_edit": negative_review["pass"] is False and negative_review["target_present"] is False,
"reviewer_triggered_correction": correction_triggered,
"reviewer_accepted_corrected_pixels": final_review["pass"] is True and final_review["target_present"] is True,
"subtitle_visually_verified": final_review["subtitle_visible"] is True,
"slow_motion_duration_verified": abs(output_duration - expected_duration) <= 0.8,
"final_format_and_quality": bool(video_streams)
and video_streams[0].get("codec_name") == "h264"
and int(video_streams[0].get("width") or 0) == 854
and int(video_streams[0].get("height") or 0) == 480,
"raw_receipts_usage_latency_complete": all(
call.get("response")
and call.get("latency_s") is not None
and call.get("usage", {}).get("prompt_tokens") is not None
and call.get("usage", {}).get("completion_tokens") is not None
for call in receipts
),
}
official_complete = all(gates.values())
artifacts = {
str(path.relative_to(run_dir)): {"sha256": _sha(path), "bytes": path.stat().st_size}
for path in sorted(run_dir.rglob("*"))
if path.is_file()
}
manifest = {
"schema_version": "1.0",
"experiment": "5-6",
"run_id": run_id,
"generated_at_utc": _utc(),
"provider": backend.provider,
"model": backend.model,
"source_url": SOURCE_URL,
"source_sha256": SOURCE_SHA256,
"blender_binary": str(BLENDER),
"blender_version": blender_version.splitlines()[0],
"localization": {
"predicted": [start, end],
"ground_truth": [GROUND_TRUTH["start"], GROUND_TRUTH["end"]],
"errors_s": [localization["final_start_error_s"], localization["final_end_error_s"]],
"reviewer_driven_refinement_rounds": len(correction_rounds) - 1,
},
"final_video": {"path": "final.mp4", "sha256": _sha(final_output), "probe": final_probe},
"model_call_count": len(receipts),
"gates": gates,
"artifacts": artifacts,
"official_complete": official_complete,
}
_write_json(run_dir / "manifest.json", manifest)
if not official_complete:
raise RuntimeError("Experiment 5-6 acceptance gates failed: " + json.dumps(gates))
_write_json(
VALIDATION / "latest.json",
{
"experiment": "5-6",
"run_id": run_id,
"manifest": str((run_dir / "manifest.json").relative_to(ROOT)),
"manifest_sha256": _sha(run_dir / "manifest.json"),
"official_complete": True,
},
)
return manifest
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--provider", choices=("ark", "moonshot", "openai"), default="ark")
parser.add_argument("--run-id", default=f"exp5-6-real-blender-{datetime.now().strftime('%Y%m%d-%H%M%S')}")
args = parser.parse_args()
print(json.dumps(run(args.provider, args.run_id), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+293
View File
@@ -0,0 +1,293 @@
"""
实验 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 APIbpy)脚本剪出片段(可含字幕/慢动作);
装了 Blender 则无头渲染,否则回退 ffmpeg——但 bpy 脚本始终生成(代码生成产物);
5. Reviewer 检查成片关键帧,给出反馈;不合格则 Proposer 修正边界重剪,迭代。
依赖:ffmpeg/ffprobe(回退后端 + 抽帧)、OPENAI_API_KEYgpt-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 APIffmpeg=强制 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=your-openai-api-key # 或 export OPENROUTER_API_KEY=your-openrouter-api-key\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 计量:主 AgentProposer+Reviewervs 子 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" Reviewerpass={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" 主 AgentProposer+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()
+14
View File
@@ -0,0 +1,14 @@
# 必填其一:OpenAI API Key(本实验用 gpt-5.6-luna 做视觉定位/审查 + 文本规划)
OPENAI_API_KEY=your_openai_api_key_here
# 通用兜底:未配置 OPENAI_API_KEY 时自动改走 OpenRouter
# 默认模型 gpt-5.6-lunagpt-5.x)直连 OpenAI 需组织实名认证,
# 故设置了本 key 时会优先走 OpenRouterroute openai/gpt-5.6-luna)。
# OPENROUTER_API_KEY=your_openrouter_api_key_here
# 可选:兼容 OpenAI 协议的自定义端点
# OPENAI_BASE_URL=https://api.openai.com/v1
# 可选:指定模型(默认均为 gpt-5.6-luna;视觉模型必须支持图像输入)
# TEXT_MODEL=gpt-5.6-luna
# VISION_MODEL=gpt-5.6-luna
+103
View File
@@ -0,0 +1,103 @@
"""
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 的 JSONformat + 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
+81
View File
@@ -0,0 +1,81 @@
"""
程序化生成一段"含多个明显不同场景"的测试视频(无需任何素材文件)。
每个场景 = 一种纯色背景 + 一个大号运动标题(场景英文名)+ 时间码水印。
标题让 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}
+2
View File
@@ -0,0 +1,2 @@
openai>=1.30.0
python-dotenv>=1.0
@@ -0,0 +1,63 @@
"""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
@@ -0,0 +1,45 @@
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)
@@ -0,0 +1,22 @@
"""_extract_json must accept the first object when another object follows."""
import pytest
from agents import _extract_json
def test_adjacent_json_objects_returns_first():
assert _extract_json('{"a":1}{"b":2}') == {"a": 1}
def test_prose_with_two_objects_returns_first():
assert _extract_json('note {"a":1} mid {"b":2}') == {"a": 1}
def test_single_object_unchanged():
assert _extract_json('prefix {"ok": true, "n": 3} suffix') == {"ok": True, "n": 3}
def test_no_object_raises():
with pytest.raises(ValueError, match="未能从回复中解析 JSON"):
_extract_json("no braces here")
@@ -0,0 +1,18 @@
"""Regression: slowmo factor<=0 must not ZeroDivisionError."""
from pathlib import Path
def test_source_skips_nonpositive_factor():
src = Path(__file__).with_name("video_editor.py").read_text()
assert "if factor <= 0:" in src
assert "continue" in src.split("if factor <= 0:")[1][:80]
def test_division_guard_math():
factor = 0.0
if factor <= 0:
skipped = True
else:
_ = 1.0 / factor
skipped = False
assert skipped is True
@@ -0,0 +1,49 @@
"""slowmo factor:null must not TypeError (skip like non-positive)."""
from unittest.mock import patch, MagicMock
from blender_editor import _plan_fields, generate_bpy_script
from video_editor import _apply_edit_ffmpeg
def test_null_slowmo_factor_skipped_in_plan_fields():
start, end, subtitle, slowmo = _plan_fields(
{"start": 0.0, "end": 2.0, "effects": [{"type": "slowmo", "factor": None}]}
)
assert start == 0.0 and end == 2.0
assert subtitle is None
assert slowmo is None
def test_missing_slowmo_factor_still_defaults():
_, _, _, slowmo = _plan_fields(
{"start": 0.0, "end": 2.0, "effects": [{"type": "slowmo"}]}
)
assert slowmo == 2.0
def test_positive_slowmo_factor_kept():
_, _, _, slowmo = _plan_fields(
{"start": 0.0, "end": 2.0, "effects": [{"type": "slowmo", "factor": 3.0}]}
)
assert slowmo == 3.0
def test_generate_bpy_script_with_null_factor():
script = generate_bpy_script(
"/tmp/in.mp4",
{"start": 0.0, "end": 1.0, "effects": [{"type": "slowmo", "factor": None}]},
"/tmp/out.mp4",
)
assert "SLOWMO = None" in script
def test_apply_edit_ffmpeg_factor_speedup_and_bare_filename():
plan = {
"start": 0.0,
"end": 2.0,
"effects": [{"type": "slowmo", "factor": 0.5}]
}
with patch("video_editor.run") as mock_run:
_apply_edit_ffmpeg("input.mp4", plan, "output.mp4")
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "atempo=2.0" in cmd[cmd.index("-af") + 1]
@@ -0,0 +1,7 @@
{
"experiment": "5-6",
"run_id": "exp5-6-real-blender-20260730-055102",
"manifest": "validation/runs/exp5-6-real-blender-20260730-055102/manifest.json",
"manifest_sha256": "fd044738e812faa832863f4c112880a9583c268afb7d08f388c00623498ab7a8",
"official_complete": true
}
@@ -0,0 +1,92 @@
"""
本文件由 blender_editor.generate_bpy_script() 自动生成(实验 5-6)。
执行:blender --background --python edit.py
它把一条剪辑计划翻译成 Blender 视频序列编辑器(VSE)的 API 调用序列。
"""
import os
import bpy
SRC = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/source_cache/big-buck-bunny-trailer-480p.mov'
OUT = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/preflight-nospeed.mp4'
FPS = 25
START = 9.0 # 目标片段起点(秒)
END = 11.0 # 目标片段终点(秒)
SUBTITLE = 'BIG BUNNY' # None 或字幕文本
SLOWMO = None # None 或放慢倍率(factor>1 表示放慢 factor 倍)
scene = bpy.context.scene
scene.render.fps = FPS
scene.render.fps_base = 1.0
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
# 清掉可能存在的旧序列,保证幂等
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 - start_frame)
try:
sound = se.sequences.new_sound(name="audio", filepath=SRC, channel=2, frame_start=1 - start_frame)
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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,92 @@
"""
本文件由 blender_editor.generate_bpy_script() 自动生成(实验 5-6)。
执行:blender --background --python edit.py
它把一条剪辑计划翻译成 Blender 视频序列编辑器(VSE)的 API 调用序列。
"""
import os
import bpy
SRC = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/source_cache/big-buck-bunny-trailer-480p.mov'
OUT = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/preflight-reference.mp4'
FPS = 25
START = 9.0 # 目标片段起点(秒)
END = 11.0 # 目标片段终点(秒)
SUBTITLE = 'BIG BUNNY' # None 或字幕文本
SLOWMO = 1.5 # None 或放慢倍率(factor>1 表示放慢 factor 倍)
scene = bpy.context.scene
scene.render.fps = FPS
scene.render.fps_base = 1.0
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
# 清掉可能存在的旧序列,保证幂等
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 - start_frame)
try:
sound = se.sequences.new_sound(name="audio", filepath=SRC, channel=2, frame_start=1 - start_frame)
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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,42 @@
{
"url": "https://download.blender.org/peach/trailer/trailer_480p.mov",
"title": "Big Buck Bunny trailer (Blender Foundation Peach Open Movie Project)",
"license": "Creative Commons Attribution 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"sha256": "36801b74638c12be9aa587e93cd18edfc9bc51a1c089ab2a19ee42beed9f497d",
"probe": {
"programs": [],
"stream_groups": [],
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_type": "video",
"width": 853,
"height": 480,
"r_frame_rate": "25/1"
},
{
"index": 1,
"codec_name": "aac",
"codec_type": "audio",
"sample_rate": "48000",
"channels": 6,
"r_frame_rate": "0/0"
}
],
"format": {
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"duration": "32.995000",
"size": "11061011"
}
},
"naturally_occurring_scenes": true,
"ground_truth": {
"start": 9.08,
"end": 11.2,
"target": "the single continuous shot showing the large white rabbit walking alone in a sunny green meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title",
"method": "Human-labeled target shot bounded by ffmpeg scene-change frames; source inspection found transitions at 9.08 and 11.20 seconds.",
"ffmpeg_scene_threshold": 0.35
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

@@ -0,0 +1,42 @@
{
"url": "https://download.blender.org/peach/trailer/trailer_480p.mov",
"title": "Big Buck Bunny trailer (Blender Foundation Peach Open Movie Project)",
"license": "Creative Commons Attribution 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"sha256": "36801b74638c12be9aa587e93cd18edfc9bc51a1c089ab2a19ee42beed9f497d",
"probe": {
"programs": [],
"stream_groups": [],
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_type": "video",
"width": 853,
"height": 480,
"r_frame_rate": "25/1"
},
{
"index": 1,
"codec_name": "aac",
"codec_type": "audio",
"sample_rate": "48000",
"channels": 6,
"r_frame_rate": "0/0"
}
],
"format": {
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"duration": "32.995000",
"size": "11061011"
}
},
"naturally_occurring_scenes": true,
"ground_truth": {
"start": 9.08,
"end": 11.2,
"target": "the single continuous shot showing the large white rabbit walking alone in a sunny green meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title",
"method": "Human-labeled target shot bounded by ffmpeg scene-change frames; source inspection found transitions at 9.08 and 11.20 seconds.",
"ffmpeg_scene_threshold": 0.35
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

@@ -0,0 +1,186 @@
{
"request": "Cut out the shot of the large white rabbit walking alone in the sunny meadow, slow it to 1.5x duration, and add the subtitle BIG BUNNY along the bottom.",
"target": "the single continuous shot showing the large white rabbit walking alone in a sunny green meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title",
"coarse_interval_s": 10,
"coarse_frames": [
{
"timestamp_s": 0.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/coarse/frame-000.00.png",
"sha256": "cdd5c7f27f84347a40eb467c356c5a2fda3facdad9057f246034e355b89dcfdd",
"bytes": 1580
},
{
"timestamp_s": 10.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/coarse/frame-010.00.png",
"sha256": "bdf01683fab23e1e98099d0f6e19596eabcd96ddb1c643b3d0c6749b5f2995a9",
"bytes": 288771
},
{
"timestamp_s": 20.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/coarse/frame-020.00.png",
"sha256": "713c2f7a5db41b01640dbae71ef4d5057c382dd1480d9e31bcc3f6d05c9da52b",
"bytes": 183618
},
{
"timestamp_s": 30.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/coarse/frame-030.00.png",
"sha256": "ce5d5733e716607000f9d6aef75dbb9b31279dd50e7db7c6c1e714819624224a",
"bytes": 264372
},
{
"timestamp_s": 31.495,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/coarse/frame-031.50.png",
"sha256": "57e555d305ac18228155454935f9a411a9e21ccc092348997f8e3299d4011b89",
"bytes": 40146
}
],
"coarse_result": {
"start": 5.0,
"end": 15.0,
"reason": "The 10.00s frame shows the large white rabbit alone in a sunny green meadow with green grass, mountains, and clouds. The next frame at 20.00s features three rodents, indicating the rabbit shot ends before 20.00s. It starts after the 'ONE BIG RABBIT' title (before 10.00s), so the rough interval is 5.0-15.0s."
},
"fine_interval_s": 1,
"fine_window": [
0,
20
],
"fine_frames": [
{
"timestamp_s": 0.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-000.00.png",
"sha256": "cdd5c7f27f84347a40eb467c356c5a2fda3facdad9057f246034e355b89dcfdd",
"bytes": 1580
},
{
"timestamp_s": 1.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-001.00.png",
"sha256": "702f6c8f994090d5f989709bea4372d9cfccb64ea4e09e8245639be85436ddc0",
"bytes": 30336
},
{
"timestamp_s": 2.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-002.00.png",
"sha256": "0ad215be811389364b7616996f62082234c5759d2f5e489b50c2c794933dcb11",
"bytes": 30702
},
{
"timestamp_s": 3.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-003.00.png",
"sha256": "ac7283cb0e9b36e87fffa5ef702b4ebc839c2cae9d68c2486ad6b7c6137281de",
"bytes": 31566
},
{
"timestamp_s": 4.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-004.00.png",
"sha256": "83e61400d490cb6d008465e31f72466b14b8efbc25695003e571715a5ecf2609",
"bytes": 391627
},
{
"timestamp_s": 5.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-005.00.png",
"sha256": "7bf0f2f437e8781ac96e6a27b41f975189c3784dbd3368672d43fd4b2d92813a",
"bytes": 451029
},
{
"timestamp_s": 6.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-006.00.png",
"sha256": "618ee855deb607995c0be98d0a64db5614c4f851837af105e0672822947fe361",
"bytes": 451002
},
{
"timestamp_s": 7.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-007.00.png",
"sha256": "40e9724ee1834abee7d12002cd83a4120738b0ffd0a294741c25c2135edb02b8",
"bytes": 196294
},
{
"timestamp_s": 8.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-008.00.png",
"sha256": "1da6191f1a0c6ccc987a8e5d0682d7e664aa4ce8b8ce31d232bec5ae695b0c4e",
"bytes": 25229
},
{
"timestamp_s": 9.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-009.00.png",
"sha256": "c0358569f2defb8cfdcd0c73393fc2e75cab6168920819a7c0d28395389132b7",
"bytes": 26259
},
{
"timestamp_s": 10.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-010.00.png",
"sha256": "bdf01683fab23e1e98099d0f6e19596eabcd96ddb1c643b3d0c6749b5f2995a9",
"bytes": 288771
},
{
"timestamp_s": 11.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-011.00.png",
"sha256": "c1abe47a8bd0e10472b75a472cea78ff24613c64ccc74ffba9b566d71336c4f4",
"bytes": 290263
},
{
"timestamp_s": 12.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-012.00.png",
"sha256": "5833475f37ad6ccc7f98c953193368b11c4f3390d1633e224b0a43514c7aa2ec",
"bytes": 23585
},
{
"timestamp_s": 13.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-013.00.png",
"sha256": "f14dca5370861a1b1f1ee95d1e00172314f27e60ec9f3451a16e08efb46cabf4",
"bytes": 24310
},
{
"timestamp_s": 14.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-014.00.png",
"sha256": "ab5ed49e0a1267d80e53bc524db96a521aedf419d4a2e766463bd58fc2cbd96a",
"bytes": 290328
},
{
"timestamp_s": 15.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-015.00.png",
"sha256": "101c7a220045bdad559ffabcf2345a307c4b2a8ace71c1f289f861cb5ac8dfcf",
"bytes": 337454
},
{
"timestamp_s": 16.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-016.00.png",
"sha256": "6b674c7689ae86485ce112daa9a14cbd4334d3c41cbc12bcc8a06b0c34c3d0bd",
"bytes": 118937
},
{
"timestamp_s": 17.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-017.00.png",
"sha256": "44134f20bffcd9fb57c3ca421a7be03fa2596962423a955140bf44e3d5c2582b",
"bytes": 24201
},
{
"timestamp_s": 18.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-018.00.png",
"sha256": "bf44d8b974343063293c92d2933edd0e9a711802d44753f5c0c8b4f4cf3406aa",
"bytes": 25412
},
{
"timestamp_s": 19.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-019.00.png",
"sha256": "7d2fdecd1cff9d025880e069d111c9e8aa0c488dd1e570b62f5147a136543271",
"bytes": 78776
},
{
"timestamp_s": 20.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/frames/fine/frame-020.00.png",
"sha256": "713c2f7a5db41b01640dbae71ef4d5057c382dd1480d9e31bcc3f6d05c9da52b",
"bytes": 183618
}
],
"fine_result": {
"start": 9.0,
"end": 11.0,
"reason": "Continuous shot of large white rabbit in sunny green meadow between 'ONE BIG RABBIT' title (ends at 8.00s) and 'THREE RODENTS' title (starts at 12.00s), visible in 10.00s and 11.00s samples."
},
"ground_truth": {
"start": 9.08,
"end": 11.2
},
"start_error_s": 0.08,
"end_error_s": 0.2
}
@@ -0,0 +1,112 @@
import bpy
# Clear default objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Set up scene and sequence editor
scene = bpy.context.scene
scene.sequence_editor = scene.sequence_editor_create()
se = scene.sequence_editor
# File paths
input_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/source.mov'
output_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/negative_control.mp4'
# Video parameters
source_fps = 25
trim_start_sec = 0.0
trim_end_sec = 3.0
trim_duration_sec = trim_end_sec - trim_start_sec
trim_duration_frames = int(trim_duration_sec * source_fps) # 75 frames
output_duration_sec = trim_duration_sec * 1.5 # 4.5 seconds
output_duration_frames = int(output_duration_sec * source_fps) # 113 frames (rounded up from 112.5)
speed_factor = trim_duration_sec / output_duration_sec # 0.666...
# Add video strip
video_strip = se.sequences.new_movie(
name='Video',
filepath=input_path,
channel=2,
frame_start=1
)
video_strip.frame_offset_start = 0 # Start at 0s of source
video_strip.frame_final_duration = trim_duration_frames # Trim to 3 seconds
# Add audio strip
audio_strip = se.sequences.new_sound(
name='Audio',
filepath=input_path,
channel=1,
frame_start=1
)
audio_strip.frame_offset_start = 0
audio_strip.frame_final_duration = trim_duration_frames
# Add speed effect to video
speed_video = se.sequences.new_effect(
name='Speed_Video',
type='SPEED',
channel=3,
frame_start=1,
seq1=video_strip
)
speed_video.speed_factor = speed_factor
speed_video.frame_final_duration = output_duration_frames
# Add speed effect to audio
speed_audio = se.sequences.new_effect(
name='Speed_Audio',
type='SPEED',
channel=2,
frame_start=1,
seq1=audio_strip
)
speed_audio.speed_factor = speed_factor
speed_audio.frame_final_duration = output_duration_frames
# Add subtitle text strip
text_strip = se.sequences.new_effect(
name='Subtitle',
type='TEXT',
channel=4,
frame_start=1,
frame_end=1 + output_duration_frames - 1
)
text_strip.text = 'BIG BUNNY'
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
text_strip.font_size = 48
text_strip.color = (1.0, 1.0, 1.0, 1.0) # White text
# Subtitle background box
text_strip.use_box = True
text_strip.box_color = (0.0, 0.0, 0.0, 0.5) # Semi-transparent black
text_strip.box_margin_left = 10
text_strip.box_margin_right = 10
text_strip.box_margin_top = 10
text_strip.box_margin_bottom = 10
# Render settings
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
scene.render.fps = source_fps
scene.render.fps_base = 1.0
# Output format settings
scene.render.image_settings.file_format = 'MPEG4'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H.264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.ffmpeg.audio_bitrate = 192
scene.render.ffmpeg.constant_rate_factor = 'MEDIUM'
scene.render.filepath = output_path
# Set scene frame range
scene.frame_start = 1
scene.frame_end = output_duration_frames
# Render animation
bpy.ops.render.render(animation=True)
@@ -0,0 +1,90 @@
import bpy
import math
# Clear default objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Set up scene
scene = bpy.context.scene
# Render settings
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
scene.render.fps = 25
scene.render.fps_base = 1.0
scene.render.image_settings.file_format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.filepath = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/negative_control.mp4'
# Set up Video Sequence Editor
if not scene.sequence_editor:
scene.sequence_editor_create()
scene.sequence_editor.sequences.clear()
# Import source movie
input_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/source.mov'
movie_strip = scene.sequence_editor.sequences.new_movie(
name='Source',
filepath=input_path,
channel=1,
frame_start=1
)
# Trim to 3 seconds (75 frames at 25fps)
movie_strip.frame_final_duration = 75
# Add speed effect (1.5x duration = 2/3 speed)
speed_strip = scene.sequence_editor.sequences.new_effect(
name='Slow',
type='SPEED',
channel=2,
frame_start=1,
frame_end=movie_strip.frame_final_end,
seq1=movie_strip
)
speed_strip.speed_factor = 2/3 # 3s * 1.5 = 4.5s duration
# Calculate scene frame range
slowed_frames = movie_strip.frame_final_duration / speed_strip.speed_factor
scene.frame_start = 1
scene.frame_end = math.ceil(scene.frame_start + slowed_frames - 1)
# Create sequence editor if not exists
if not scene.sequence_editor:
scene.sequence_editor_create()
# Subtitle background (semi-transparent dark box)
color_strip = scene.sequence_editor.sequences.new_effect(
name='SubtitleBG',
type='COLOR',
channel=3,
frame_start=scene.frame_start,
frame_end=scene.frame_end
)
color_strip.color = (0.0, 0.0, 0.0) # Black
color_strip.alpha = 0.5 # Semi-transparent
color_strip.transform.scale_x = 0.4 # Box width
color_strip.transform.scale_y = 0.15 # Box height
color_strip.transform.translate_y = -0.4 # Bottom position
color_strip.transform.align_x = 'CENTER' # Center horizontally
# Subtitle text
text_strip = scene.sequence_editor.sequences.new_effect(
name='SubtitleText',
type='TEXT',
channel=4,
frame_start=scene.frame_start,
frame_end=scene.frame_end
)
text_strip.text = 'BIG BUNNY'
text_strip.align_x = 'CENTER'
text_strip.align_y = 'CENTER'
text_strip.font_size = 40
text_strip.color = (1.0, 1.0, 1.0) # White text
text_strip.transform.translate_y = -0.4 # Match background Y position
text_strip.transform.align_x = 'CENTER'
# Render animation
bpy.ops.render.render(animation=True)
@@ -0,0 +1,73 @@
import bpy
# Clear existing data to start fresh
bpy.ops.wm.read_factory_settings(use_empty=True)
# Get the current scene
scene = bpy.context.scene
# Set render resolution and FPS
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
scene.render.fps = 25
scene.render.fps_base = 1.0
# Set render output settings
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.ffmpeg.constant_rate_factor = 'MEDIUM'
scene.render.filepath = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/negative_control.mp4'
# Create sequence editor
scene.sequence_editor_create()
seq_ed = scene.sequence_editor
# Input movie path
input_movie = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-050817/source.mov'
# Add video strip with trim (0.000-3.000s = 0-75 frames at 25fps)
video_strip = seq_ed.sequences.new_movie(name='Video', filepath=input_movie, channel=1, frame_start=0)
video_strip.frame_offset_start = 0 # Start at source frame 0
video_strip.frame_final_duration = 75 # 3s * 25fps = 75 frames
# Add speed effect to slow down (1.5x duration: 3s → 4.5s = 112.5 frames → 113 frames)
speed_effect = seq_ed.sequences.new_effect(name='Speed', type='SPEED', channel=2, frame_start=0, frame_end=113, seq1=video_strip)
speed_effect.speed_factor = 2/3 # Slows to 1.5x original duration
# Add audio strip from movie
audio_strip = seq_ed.sequences.new_sound(name='Audio', filepath=input_movie, channel=1, frame_start=0)
audio_strip.frame_offset_start = 0 # Match video trim start
audio_strip.frame_final_duration = 75 # Match video trim duration
# Add speed effect to audio
audio_speed = seq_ed.sequences.new_effect(name='AudioSpeed', type='SPEED', channel=2, frame_start=0, frame_end=113, seq1=audio_strip)
audio_speed.speed_factor = 2/3
audio_speed.use_audio = True
# Create subtitle background (semi-transparent dark box)
bg_strip = seq_ed.sequences.new_effect(name='SubtitleBG', type='COLOR', channel=3, frame_start=0, frame_end=113)
bg_strip.color = (0.1, 0.1, 0.1, 0.7) # Dark gray with 70% transparency
bg_strip.align_x = 'CENTER'
bg_strip.align_y = 'BOTTOM'
bg_strip.transform.scale_x = 0.5 # Width of background box
bg_strip.transform.scale_y = 0.15 # Height of background box
bg_strip.transform.location_y = 0.03 # Position slightly above bottom edge
# Create subtitle text
text_strip = seq_ed.sequences.new_effect(name='SubtitleText', type='TEXT', channel=4, frame_start=0, frame_end=113)
text_strip.text = 'BIG BUNNY'
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
text_strip.font_size = 48
text_strip.color = (1.0, 1.0, 1.0) # White text
text_strip.transform.location_y = 0.05 # Align with background box
# Set scene frame range to cover slowed duration (4.5 seconds = 112.5 frames → 113 frames)
scene.frame_start = 0
scene.frame_end = 113
# Render the animation
bpy.ops.render.render(animation=True)
@@ -0,0 +1,42 @@
{
"url": "https://download.blender.org/peach/trailer/trailer_480p.mov",
"title": "Big Buck Bunny trailer (Blender Foundation Peach Open Movie Project)",
"license": "Creative Commons Attribution 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"sha256": "36801b74638c12be9aa587e93cd18edfc9bc51a1c089ab2a19ee42beed9f497d",
"probe": {
"programs": [],
"stream_groups": [],
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_type": "video",
"width": 853,
"height": 480,
"r_frame_rate": "25/1"
},
{
"index": 1,
"codec_name": "aac",
"codec_type": "audio",
"sample_rate": "48000",
"channels": 6,
"r_frame_rate": "0/0"
}
],
"format": {
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"duration": "32.995000",
"size": "11061011"
}
},
"naturally_occurring_scenes": true,
"ground_truth": {
"start": 9.08,
"end": 11.2,
"target": "the single continuous shot showing the large white rabbit walking alone in a sunny green meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title",
"method": "Human-labeled target shot bounded by ffmpeg scene-change frames; source inspection found transitions at 9.08 and 11.20 seconds.",
"ffmpeg_scene_threshold": 0.35
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

@@ -0,0 +1,186 @@
{
"request": "Cut out the shot of the large white rabbit walking alone in the sunny meadow, slow it to 1.5x duration, and add the subtitle BIG BUNNY along the bottom.",
"target": "the single continuous shot showing the large white rabbit walking alone in a sunny green meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title",
"coarse_interval_s": 10,
"coarse_frames": [
{
"timestamp_s": 0.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-000.00.png",
"sha256": "cdd5c7f27f84347a40eb467c356c5a2fda3facdad9057f246034e355b89dcfdd",
"bytes": 1580
},
{
"timestamp_s": 10.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-010.00.png",
"sha256": "bdf01683fab23e1e98099d0f6e19596eabcd96ddb1c643b3d0c6749b5f2995a9",
"bytes": 288771
},
{
"timestamp_s": 20.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-020.00.png",
"sha256": "713c2f7a5db41b01640dbae71ef4d5057c382dd1480d9e31bcc3f6d05c9da52b",
"bytes": 183618
},
{
"timestamp_s": 30.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-030.00.png",
"sha256": "ce5d5733e716607000f9d6aef75dbb9b31279dd50e7db7c6c1e714819624224a",
"bytes": 264372
},
{
"timestamp_s": 31.495,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-031.50.png",
"sha256": "57e555d305ac18228155454935f9a411a9e21ccc092348997f8e3299d4011b89",
"bytes": 40146
}
],
"coarse_result": {
"start": 10.0,
"end": 20.0,
"reason": "The 10.00s frame shows the large white rabbit standing alone in a sunny green meadow with rolling hills and a clear sky, matching the target description. The next frame at 20.00s transitions to three rodents in a darker forested area (likely the 'THREE RODENTS' title content), indicating the rabbit shot is continuous between these timestamps."
},
"fine_interval_s": 1,
"fine_window": [
5,
25
],
"fine_frames": [
{
"timestamp_s": 5.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-005.00.png",
"sha256": "7bf0f2f437e8781ac96e6a27b41f975189c3784dbd3368672d43fd4b2d92813a",
"bytes": 451029
},
{
"timestamp_s": 6.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-006.00.png",
"sha256": "618ee855deb607995c0be98d0a64db5614c4f851837af105e0672822947fe361",
"bytes": 451002
},
{
"timestamp_s": 7.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-007.00.png",
"sha256": "40e9724ee1834abee7d12002cd83a4120738b0ffd0a294741c25c2135edb02b8",
"bytes": 196294
},
{
"timestamp_s": 8.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-008.00.png",
"sha256": "1da6191f1a0c6ccc987a8e5d0682d7e664aa4ce8b8ce31d232bec5ae695b0c4e",
"bytes": 25229
},
{
"timestamp_s": 9.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-009.00.png",
"sha256": "c0358569f2defb8cfdcd0c73393fc2e75cab6168920819a7c0d28395389132b7",
"bytes": 26259
},
{
"timestamp_s": 10.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-010.00.png",
"sha256": "bdf01683fab23e1e98099d0f6e19596eabcd96ddb1c643b3d0c6749b5f2995a9",
"bytes": 288771
},
{
"timestamp_s": 11.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-011.00.png",
"sha256": "c1abe47a8bd0e10472b75a472cea78ff24613c64ccc74ffba9b566d71336c4f4",
"bytes": 290263
},
{
"timestamp_s": 12.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-012.00.png",
"sha256": "5833475f37ad6ccc7f98c953193368b11c4f3390d1633e224b0a43514c7aa2ec",
"bytes": 23585
},
{
"timestamp_s": 13.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-013.00.png",
"sha256": "f14dca5370861a1b1f1ee95d1e00172314f27e60ec9f3451a16e08efb46cabf4",
"bytes": 24310
},
{
"timestamp_s": 14.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-014.00.png",
"sha256": "ab5ed49e0a1267d80e53bc524db96a521aedf419d4a2e766463bd58fc2cbd96a",
"bytes": 290328
},
{
"timestamp_s": 15.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-015.00.png",
"sha256": "101c7a220045bdad559ffabcf2345a307c4b2a8ace71c1f289f861cb5ac8dfcf",
"bytes": 337454
},
{
"timestamp_s": 16.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-016.00.png",
"sha256": "6b674c7689ae86485ce112daa9a14cbd4334d3c41cbc12bcc8a06b0c34c3d0bd",
"bytes": 118937
},
{
"timestamp_s": 17.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-017.00.png",
"sha256": "44134f20bffcd9fb57c3ca421a7be03fa2596962423a955140bf44e3d5c2582b",
"bytes": 24201
},
{
"timestamp_s": 18.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-018.00.png",
"sha256": "bf44d8b974343063293c92d2933edd0e9a711802d44753f5c0c8b4f4cf3406aa",
"bytes": 25412
},
{
"timestamp_s": 19.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-019.00.png",
"sha256": "7d2fdecd1cff9d025880e069d111c9e8aa0c488dd1e570b62f5147a136543271",
"bytes": 78776
},
{
"timestamp_s": 20.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-020.00.png",
"sha256": "713c2f7a5db41b01640dbae71ef4d5057c382dd1480d9e31bcc3f6d05c9da52b",
"bytes": 183618
},
{
"timestamp_s": 21.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-021.00.png",
"sha256": "c9758159919eb5f8612e3af01258d1c7792d446ca5c08add2ff3dcd3b97e7617",
"bytes": 96371
},
{
"timestamp_s": 22.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-022.00.png",
"sha256": "54b41278b256654341515bee8f7adb3166bdb15c90f3da9f7f47eb72cad77d33",
"bytes": 192864
},
{
"timestamp_s": 23.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-023.00.png",
"sha256": "3e49ae19ac287a5acdcff0db46d93d3ca77f98d039d9c53f07210466a323b5b3",
"bytes": 22428
},
{
"timestamp_s": 24.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-024.00.png",
"sha256": "6eb1ae362db441052be6685922270fcd86572c8b223e99a41a519424ac113c0d",
"bytes": 24410
},
{
"timestamp_s": 25.0,
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-025.00.png",
"sha256": "354595be0073fcaed614da1ff41177f01e54f74b63f5614ed5033a9c00e0a773",
"bytes": 211411
}
],
"fine_result": {
"start": 9.0,
"end": 12.0,
"reason": "Starts immediately after the 'ONE BIG RABBIT' title (ends at 9.00s) and ends just before the 'THREE RODENTS' title (starts at 12.00s), with continuous visuals of the large white rabbit in the sunny green meadow at 10.00s and 11.00s."
},
"ground_truth": {
"start": 9.08,
"end": 11.2
},
"start_error_s": 0.08,
"end_error_s": 0.8
}
@@ -0,0 +1,546 @@
{
"calls": [
{
"purpose": "coarse_10_second_visual_localization",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "These are frames from one contiguous real trailer sampled about every 10 seconds.\nLocate this target: the single continuous shot showing the large white rabbit walking alone in a sunny green meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title.\nReturn JSON {\"start\":seconds,\"end\":seconds,\"reason\":\"visual evidence\"} with a rough continuous interval. Use timestamps and visual content only."
},
{
"type": "text",
"text": "timestamp=0.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-000.00.png",
"sha256": "cdd5c7f27f84347a40eb467c356c5a2fda3facdad9057f246034e355b89dcfdd",
"bytes": 1580
},
{
"type": "text",
"text": "timestamp=10.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-010.00.png",
"sha256": "bdf01683fab23e1e98099d0f6e19596eabcd96ddb1c643b3d0c6749b5f2995a9",
"bytes": 288771
},
{
"type": "text",
"text": "timestamp=20.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-020.00.png",
"sha256": "713c2f7a5db41b01640dbae71ef4d5057c382dd1480d9e31bcc3f6d05c9da52b",
"bytes": 183618
},
{
"type": "text",
"text": "timestamp=30.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-030.00.png",
"sha256": "ce5d5733e716607000f9d6aef75dbb9b31279dd50e7db7c6c1e714819624224a",
"bytes": 264372
},
{
"type": "text",
"text": "timestamp=31.50s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/coarse/frame-031.50.png",
"sha256": "57e555d305ac18228155454935f9a411a9e21ccc092348997f8e3299d4011b89",
"bytes": 40146
}
]
}
],
"temperature": 0,
"max_tokens": 700,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536012758415b00b15f7b15a122bee0bef9a71075862cfbb",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"start\":10.00,\"end\":20.00,\"reason\":\"The 10.00s frame shows the large white rabbit standing alone in a sunny green meadow with rolling hills and a clear sky, matching the target description. The next frame at 20.00s transitions to three rodents in a darker forested area (likely the 'THREE RODENTS' title content), indicating the rabbit shot is continuous between these timestamps.\"}"
},
"usage": {
"prompt_tokens": 1686,
"completion_tokens": 734,
"total_tokens": 2420
},
"latency_s": 20.057
},
{
"purpose": "fine_1_second_visual_localization",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "These are one-second samples from the narrowed window [5.0, 25.0] seconds of the same real trailer.\nPrecisely locate the boundaries of this one continuous target shot: the single continuous shot showing the large white rabbit walking alone in a sunny green meadow, after the ONE BIG RABBIT title and before the THREE RODENTS title.\nReturn JSON {\"start\":seconds,\"end\":seconds,\"reason\":\"visual boundary evidence\"}. The answer may interpolate between adjacent one-second samples; do not include either neighboring title card."
},
{
"type": "text",
"text": "timestamp=5.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-005.00.png",
"sha256": "7bf0f2f437e8781ac96e6a27b41f975189c3784dbd3368672d43fd4b2d92813a",
"bytes": 451029
},
{
"type": "text",
"text": "timestamp=6.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-006.00.png",
"sha256": "618ee855deb607995c0be98d0a64db5614c4f851837af105e0672822947fe361",
"bytes": 451002
},
{
"type": "text",
"text": "timestamp=7.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-007.00.png",
"sha256": "40e9724ee1834abee7d12002cd83a4120738b0ffd0a294741c25c2135edb02b8",
"bytes": 196294
},
{
"type": "text",
"text": "timestamp=8.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-008.00.png",
"sha256": "1da6191f1a0c6ccc987a8e5d0682d7e664aa4ce8b8ce31d232bec5ae695b0c4e",
"bytes": 25229
},
{
"type": "text",
"text": "timestamp=9.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-009.00.png",
"sha256": "c0358569f2defb8cfdcd0c73393fc2e75cab6168920819a7c0d28395389132b7",
"bytes": 26259
},
{
"type": "text",
"text": "timestamp=10.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-010.00.png",
"sha256": "bdf01683fab23e1e98099d0f6e19596eabcd96ddb1c643b3d0c6749b5f2995a9",
"bytes": 288771
},
{
"type": "text",
"text": "timestamp=11.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-011.00.png",
"sha256": "c1abe47a8bd0e10472b75a472cea78ff24613c64ccc74ffba9b566d71336c4f4",
"bytes": 290263
},
{
"type": "text",
"text": "timestamp=12.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-012.00.png",
"sha256": "5833475f37ad6ccc7f98c953193368b11c4f3390d1633e224b0a43514c7aa2ec",
"bytes": 23585
},
{
"type": "text",
"text": "timestamp=13.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-013.00.png",
"sha256": "f14dca5370861a1b1f1ee95d1e00172314f27e60ec9f3451a16e08efb46cabf4",
"bytes": 24310
},
{
"type": "text",
"text": "timestamp=14.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-014.00.png",
"sha256": "ab5ed49e0a1267d80e53bc524db96a521aedf419d4a2e766463bd58fc2cbd96a",
"bytes": 290328
},
{
"type": "text",
"text": "timestamp=15.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-015.00.png",
"sha256": "101c7a220045bdad559ffabcf2345a307c4b2a8ace71c1f289f861cb5ac8dfcf",
"bytes": 337454
},
{
"type": "text",
"text": "timestamp=16.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-016.00.png",
"sha256": "6b674c7689ae86485ce112daa9a14cbd4334d3c41cbc12bcc8a06b0c34c3d0bd",
"bytes": 118937
},
{
"type": "text",
"text": "timestamp=17.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-017.00.png",
"sha256": "44134f20bffcd9fb57c3ca421a7be03fa2596962423a955140bf44e3d5c2582b",
"bytes": 24201
},
{
"type": "text",
"text": "timestamp=18.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-018.00.png",
"sha256": "bf44d8b974343063293c92d2933edd0e9a711802d44753f5c0c8b4f4cf3406aa",
"bytes": 25412
},
{
"type": "text",
"text": "timestamp=19.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-019.00.png",
"sha256": "7d2fdecd1cff9d025880e069d111c9e8aa0c488dd1e570b62f5147a136543271",
"bytes": 78776
},
{
"type": "text",
"text": "timestamp=20.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-020.00.png",
"sha256": "713c2f7a5db41b01640dbae71ef4d5057c382dd1480d9e31bcc3f6d05c9da52b",
"bytes": 183618
},
{
"type": "text",
"text": "timestamp=21.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-021.00.png",
"sha256": "c9758159919eb5f8612e3af01258d1c7792d446ca5c08add2ff3dcd3b97e7617",
"bytes": 96371
},
{
"type": "text",
"text": "timestamp=22.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-022.00.png",
"sha256": "54b41278b256654341515bee8f7adb3166bdb15c90f3da9f7f47eb72cad77d33",
"bytes": 192864
},
{
"type": "text",
"text": "timestamp=23.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-023.00.png",
"sha256": "3e49ae19ac287a5acdcff0db46d93d3ca77f98d039d9c53f07210466a323b5b3",
"bytes": 22428
},
{
"type": "text",
"text": "timestamp=24.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-024.00.png",
"sha256": "6eb1ae362db441052be6685922270fcd86572c8b223e99a41a519424ac113c0d",
"bytes": 24410
},
{
"type": "text",
"text": "timestamp=25.00s"
},
{
"type": "image_artifact",
"path": "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/frames/fine/frame-025.00.png",
"sha256": "354595be0073fcaed614da1ff41177f01e54f74b63f5614ed5033a9c00e0a773",
"bytes": 211411
}
]
}
],
"temperature": 0,
"max_tokens": 700,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536014931215b00b15f7b15a122bee0bef9a7107589f12c6",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"start\":9.0,\"end\":12.0,\"reason\":\"Starts immediately after the 'ONE BIG RABBIT' title (ends at 9.00s) and ends just before the 'THREE RODENTS' title (starts at 12.00s), with continuous visuals of the large white rabbit in the sunny green meadow at 10.00s and 11.00s.\"}"
},
"usage": {
"prompt_tokens": 6555,
"completion_tokens": 1158,
"total_tokens": 7713
},
"latency_s": 31.727
},
{
"purpose": "blender_script_negative-control_attempt_1",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "system",
"content": "You are a Blender VSE engineer. Return one JSON object only."
},
{
"role": "user",
"content": "Generate a complete Blender 4.3 Python script for this video edit.\n\nInput movie: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\nOutput MP4: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\nSource FPS: 25\nSource size: 853x480; render at 854x480 (one-pixel even-width pad required by H.264)\nTrim interval: [0.000, 3.000] seconds\nEffects: slow playback so output duration is 1.5 times the trimmed interval; add bottom-centered subtitle BIG BUNNY with a visible semi-transparent dark box.\n\nRequirements:\n- Use bpy and Blender's Video Sequence Editor, including new_movie, new_sound when available, frame_offset_start/frame_final_duration, a SPEED effect, and a TEXT effect.\n- Render exactly 854x480 at 25 fps through Blender to MPEG-4 H.264 with AAC audio.\n- Set the scene frame range to the slowed duration and call bpy.ops.render.render(animation=True).\n- Create only the requested output. Do not invoke ffmpeg, subprocesses, a shell, the network, or read credentials.\n- Use APIs available in Blender 4.3 (scene.sequence_editor_create().sequences).\n- In Blender 4.3, SpeedControlSequence has no use_audio property. Never read or assign use_audio, and do not apply a SPEED effect to the sound strip. A valid movie slow-motion pattern is speed_control='MULTIPLY', speed_factor=1/1.5, and extending the movie/render duration; the sound strip may remain normally trimmed.\n- For the subtitle background, prefer the TEXT strip's use_box=True and box_color=(0,0,0,0.6); do not assume a COLOR strip has text-layout properties.\n\nReturn one JSON object only: {\"code\":\"complete executable Python source\"}.\n"
}
],
"temperature": 0,
"max_tokens": 7000,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536018101615b00b15f7b15a122bee0bef9a710758b49a93",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"code\":\"import bpy\\n\\n# Clear existing objects to start fresh\\nbpy.ops.object.select_all(action='SELECT')\\nbpy.ops.object.delete()\\n\\n# Configuration paths and parameters\\nINPUT_MOVIE = \\\"/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\\\"\\nOUTPUT_MP4 = \\\"/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\\\"\\nSOURCE_FPS = 25\\nTRIM_DURATION_SECONDS = 3.0\\nSLOW_FACTOR = 1.5\\nRENDER_RESOLUTION = (854, 480)\\n\\n# Calculate frame values\\ntrim_frames = int(TRIM_DURATION_SECONDS * SOURCE_FPS)\\nslowed_frames = int(trim_frames * SLOW_FACTOR)\\nscene = bpy.context.scene\\n\\n# Create sequence editor\\nseq_ed = scene.sequence_editor_create()\\n\\n# Add movie strip with trim\\nmovie_strip = seq_ed.sequences.new_movie(\\n name=\\\"SourceVideo\\\",\\n filepath=INPUT_MOVIE,\\n channel=1,\\n frame_start=1\\n)\\nmovie_strip.frame_offset_start = 0 # Start at beginning of source\\nmovie_strip.frame_final_duration = trim_frames # Trim to 3 seconds\\n\\n# Locate and trim audio strip (auto-created with movie)\\nsound_strip = None\\nfor strip in seq_ed.sequences:\\n if strip.type == 'SOUND' and strip.channel == 2 and strip.frame_start == 1:\\n sound_strip = strip\\nif sound_strip:\\n sound_strip.frame_final_duration = trim_frames # Keep audio at original speed\\n\\n# Add speed effect to video (slow motion)\\nspeed_strip = seq_ed.sequences.new_effect(\\n name=\\\"SlowMotion\\\",\\n type='SPEED',\\n channel=3,\\n frame_start=1,\\n frame_end=1 + slowed_frames - 1,\\n target=movie_strip\\n)\\nspeed_strip.speed_control = 'MULTIPLY'\\nspeed_strip.speed_factor = 1 / SLOW_FACTOR # 1/1.5 speed factor\\n\\n# Add subtitle text strip with background box\\ntext_strip = seq_ed.sequences.new_text(\\n name=\\\"Subtitle\\\",\\n text=\\\"BIG BUNNY\\\",\\n channel=4,\\n frame_start=1,\\n frame_end=1 + slowed_frames - 1\\n)\\ntext_strip.align_x = 'CENTER'\\ntext_strip.align_y = 'BOTTOM'\\ntext_strip.use_box = True\\ntext_strip.box_color = (0.0, 0.0, 0.0, 0.6) # Black semi-transparent box\\ntext_strip.font_size = 48 # Visible size for 854x480 resolution\\n\\n# Configure render settings\\nscene.render.resolution_x = RENDER_RESOLUTION[0]\\nscene.render.resolution_y = RENDER_RESOLUTION[1]\\nscene.render.resolution_percentage = 100\\nscene.render.fps = SOURCE_FPS\\nscene.render.fps_base = 1.0\\nscene.render.image_settings.file_format = 'FFMPEG'\\nscene.render.ffmpeg.format = 'MPEG4'\\nscene.render.ffmpeg.codec = 'H264'\\nscene.render.ffmpeg.audio_codec = 'AAC'\\nscene.render.filepath = OUTPUT_MP4\\n\\n# Set render frame range\\nscene.frame_start = 1\\nscene.frame_end = 1 + slowed_frames - 1\\n\\n# Execute render\\nbpy.ops.render.render(animation=True)\"}"
},
"usage": {
"prompt_tokens": 614,
"completion_tokens": 6404,
"total_tokens": 7018
},
"latency_s": 153.914
},
{
"purpose": "blender_script_negative-control_attempt_2",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "system",
"content": "You are a Blender VSE engineer. Return one JSON object only."
},
{
"role": "user",
"content": "Generate a complete Blender 4.3 Python script for this video edit.\n\nInput movie: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\nOutput MP4: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\nSource FPS: 25\nSource size: 853x480; render at 854x480 (one-pixel even-width pad required by H.264)\nTrim interval: [0.000, 3.000] seconds\nEffects: slow playback so output duration is 1.5 times the trimmed interval; add bottom-centered subtitle BIG BUNNY with a visible semi-transparent dark box.\n\nRequirements:\n- Use bpy and Blender's Video Sequence Editor, including new_movie, new_sound when available, frame_offset_start/frame_final_duration, a SPEED effect, and a TEXT effect.\n- Render exactly 854x480 at 25 fps through Blender to MPEG-4 H.264 with AAC audio.\n- Set the scene frame range to the slowed duration and call bpy.ops.render.render(animation=True).\n- Create only the requested output. Do not invoke ffmpeg, subprocesses, a shell, the network, or read credentials.\n- Use APIs available in Blender 4.3 (scene.sequence_editor_create().sequences).\n- In Blender 4.3, SpeedControlSequence has no use_audio property. Never read or assign use_audio, and do not apply a SPEED effect to the sound strip. A valid movie slow-motion pattern is speed_control='MULTIPLY', speed_factor=1/1.5, and extending the movie/render duration; the sound strip may remain normally trimmed.\n- For the subtitle background, prefer the TEXT strip's use_box=True and box_color=(0,0,0,0.6); do not assume a COLOR strip has text-layout properties.\n\nReturn one JSON object only: {\"code\":\"complete executable Python source\"}.\n\nPrior executable feedback:\nStatic safety/API validation failed: ValueError: generated script omits requested Blender API operations: ['TEXT']"
}
],
"temperature": 0,
"max_tokens": 7000,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536033493315b00b15f7b15a122bee0bef9a710758de0884",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"code\":\"import bpy\\n\\n# Clear existing data\\nbpy.ops.wm.read_factory_settings(use_empty=True)\\nscene = bpy.context.scene\\n\\n# Scene settings\\nscene.render.resolution_x = 854\\nscene.render.resolution_y = 480\\nscene.render.resolution_percentage = 100\\nscene.render.fps = 25\\nscene.render.fps_base = 1.0\\n\\n# Create sequence editor\\nif not scene.sequence_editor:\\n scene.sequence_editor_create()\\nseq_ed = scene.sequence_editor\\nsequences = seq_ed.sequences\\n\\n# File paths\\ninput_movie = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov'\\noutput_mp4 = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4'\\n\\n# Add movie strip (3 seconds = 75 frames at 25fps)\\nmov_strip = sequences.new_movie(\\n name='SourceMovie',\\n filepath=input_movie,\\n frame_start=1,\\n channel=1\\n)\\nmov_strip.frame_offset_start = 0\\nmov_strip.frame_final_duration = 75 # 3s * 25fps\\n\\n# Locate and trim sound strip\\nsound_strip = next((s for s in sequences if s.type == 'SOUND' and s.frame_start == mov_strip.frame_start), None)\\nif sound_strip:\\n sound_strip.frame_final_duration = 75 # Keep original audio duration\\n\\n# Add speed effect (1.5x slowdown)\\nspeed_strip = sequences.new_effect(\\n name='SlowMotion',\\n type='SPEED',\\n frame_start=1,\\n channel=2,\\n input_1=mov_strip\\n)\\nspeed_strip.speed_control = 'MULTIPLY'\\nspeed_strip.speed_factor = 1 / 1.5\\n\\n# Calculate slowed duration (4.5s = 112.5 frames → 113 frames)\\nslowed_frames = int(4.5 * 25) + 1\\nscene.frame_start = 1\\nscene.frame_end = slowed_frames\\n\\n# Add subtitle text strip\\ntext_strip = sequences.new_effect(\\n name='Subtitle',\\n type='TEXT',\\n frame_start=1,\\n frame_end=scene.frame_end,\\n channel=3\\n)\\ntext_strip.text = 'BIG BUNNY'\\ntext_strip.align_x = 'CENTER'\\ntext_strip.align_y = 'BOTTOM'\\ntext_strip.use_box = True\\ntext_strip.box_color = (0, 0, 0, 0.6)\\ntext_strip.font_size = 48\\n\\n# Render settings\\nscene.render.filepath = output_mp4\\nscene.render.image_settings.file_format = 'FFMPEG'\\nscene.render.ffmpeg.format = 'MPEG4'\\nscene.render.ffmpeg.codec = 'H264'\\nscene.render.ffmpeg.audio_codec = 'AAC'\\nscene.render.ffmpeg.constant_rate_factor = 'MEDIUM'\\n\\n# Render animation\\nbpy.ops.render.render(animation=True)\"}"
},
"usage": {
"prompt_tokens": 641,
"completion_tokens": 4682,
"total_tokens": 5323
},
"latency_s": 103.993
},
{
"purpose": "blender_script_negative-control_attempt_3",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "system",
"content": "You are a Blender VSE engineer. Return one JSON object only."
},
{
"role": "user",
"content": "Generate a complete Blender 4.3 Python script for this video edit.\n\nInput movie: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\nOutput MP4: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\nSource FPS: 25\nSource size: 853x480; render at 854x480 (one-pixel even-width pad required by H.264)\nTrim interval: [0.000, 3.000] seconds\nEffects: slow playback so output duration is 1.5 times the trimmed interval; add bottom-centered subtitle BIG BUNNY with a visible semi-transparent dark box.\n\nRequirements:\n- Use bpy and Blender's Video Sequence Editor, including new_movie, new_sound when available, frame_offset_start/frame_final_duration, a SPEED effect, and a TEXT effect.\n- Render exactly 854x480 at 25 fps through Blender to MPEG-4 H.264 with AAC audio.\n- Set the scene frame range to the slowed duration and call bpy.ops.render.render(animation=True).\n- Create only the requested output. Do not invoke ffmpeg, subprocesses, a shell, the network, or read credentials.\n- Use APIs available in Blender 4.3 (scene.sequence_editor_create().sequences).\n- In Blender 4.3, SpeedControlSequence has no use_audio property. Never read or assign use_audio, and do not apply a SPEED effect to the sound strip. A valid movie slow-motion pattern is speed_control='MULTIPLY', speed_factor=1/1.5, and extending the movie/render duration; the sound strip may remain normally trimmed.\n- For the subtitle background, prefer the TEXT strip's use_box=True and box_color=(0,0,0,0.6); do not assume a COLOR strip has text-layout properties.\n\nReturn one JSON object only: {\"code\":\"complete executable Python source\"}.\n\nPrior executable feedback:\nBlender exit=0; output_exists=False. Tail of real Blender log:\nBlender 4.3.2 (hash 32f5fdce0a0a built 2024-12-17 02:14:25)\nTraceback (most recent call last):\n File \"/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/scripts/negative-control-attempt-2.py\", line 40, in <module>\n speed_strip = sequences.new_effect(\n ^^^^^^^^^^^^^^^^^^^^^\nTypeError: SequencesTopLevel.new_effect(): was called with invalid keyword argument(s) (input_1), expected (name, type, channel, frame_start, frame_end, seq1, seq2)\n\nBlender quit"
}
],
"temperature": 0,
"max_tokens": 7000,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536043966315b00b15f7b15a122bee0bef9a710758ef6add",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"code\":\"import bpy\\nimport os\\n\\n# Clear default objects\\nbpy.ops.object.select_all(action='SELECT')\\nbpy.ops.object.delete()\\n\\n# Set up scene\\nscene = bpy.context.scene\\nscene.render.fps = 25\\nscene.render.fps_base = 1.0\\nscene.render.resolution_x = 854\\nscene.render.resolution_y = 480\\nscene.render.resolution_percentage = 100\\n\\n# Configure output settings\\nscene.render.image_settings.file_format = 'FFMPEG'\\nscene.render.ffmpeg.format = 'MPEG4'\\nscene.render.ffmpeg.codec = 'H264'\\nscene.render.ffmpeg.audio_codec = 'AAC'\\nscene.render.ffmpeg.audio_bitrate = 192\\noutput_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4'\\nscene.render.filepath = output_path\\n\\n# Create sequence editor\\nscene.sequence_editor_create()\\nsequences = scene.sequence_editor.sequences\\n\\n# Import source movie and sound\\nsource_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov'\\nmovie_strip = sequences.new_movie(name='SourceMovie', filepath=source_path, channel=1, frame_start=0)\\n\\nsound_strip = None\\nfor strip in sequences:\\n if strip.type == 'SOUND' and strip.name.startswith('SourceMovie'):\\n sound_strip = strip\\n break\\nif not sound_strip:\\n sound_strip = sequences.new_sound(name='SourceAudio', filepath=source_path, channel=2, frame_start=0)\\n\\n# Trim to 3-second interval (75 frames at 25fps)\\ntrim_frames = 75 # 3.0s * 25fps\\nmovie_strip.frame_start = 0\\nmovie_strip.frame_final_duration = trim_frames\\nsound_strip.frame_start = 0\\nsound_strip.frame_final_duration = trim_frames\\n\\n# Apply speed effect (1.5x slowdown = 4.5s output)\\nspeed_strip = sequences.new_effect(\\n name='SpeedControl',\\n type='SPEED',\\n channel=2,\\n frame_start=0,\\n frame_end=113, # 75 frames * 1.5 = 112.5 → 113 frames\\n seq1=movie_strip,\\n seq2=None\\n)\\nspeed_strip.speed_control = 'MULTIPLY'\\nspeed_strip.speed_factor = 1/1.5\\n\\n# Set scene frame range\\nduration_frames = 113 # 4.5s * 25fps = 112.5 → 113 frames\\nscene.frame_start = 0\\nscene.frame_end = duration_frames\\n\\n# Add subtitle with background box\\ntext_strip = sequences.new_effect(\\n name='Subtitle',\\n type='TEXT',\\n channel=3,\\n frame_start=0,\\n frame_end=duration_frames,\\n seq1=None,\\n seq2=None\\n)\\ntext_strip.text = 'BIG BUNNY'\\ntext_strip.align_x = 'CENTER'\\ntext_strip.align_y = 'BOTTOM'\\ntext_strip.use_box = True\\ntext_strip.box_color = (0.0, 0.0, 0.0, 0.6)\\ntext_strip.font_size = 48\\n\\n# Render animation\\nbpy.ops.render.render(animation=True)\"}"
},
"usage": {
"prompt_tokens": 821,
"completion_tokens": 5369,
"total_tokens": 6190
},
"latency_s": 111.15
},
{
"purpose": "blender_script_negative-control_attempt_4",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "system",
"content": "You are a Blender VSE engineer. Return one JSON object only."
},
{
"role": "user",
"content": "Generate a complete Blender 4.3 Python script for this video edit.\n\nInput movie: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\nOutput MP4: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\nSource FPS: 25\nSource size: 853x480; render at 854x480 (one-pixel even-width pad required by H.264)\nTrim interval: [0.000, 3.000] seconds\nEffects: slow playback so output duration is 1.5 times the trimmed interval; add bottom-centered subtitle BIG BUNNY with a visible semi-transparent dark box.\n\nRequirements:\n- Use bpy and Blender's Video Sequence Editor, including new_movie, new_sound when available, frame_offset_start/frame_final_duration, a SPEED effect, and a TEXT effect.\n- Render exactly 854x480 at 25 fps through Blender to MPEG-4 H.264 with AAC audio.\n- Set the scene frame range to the slowed duration and call bpy.ops.render.render(animation=True).\n- Create only the requested output. Do not invoke ffmpeg, subprocesses, a shell, the network, or read credentials.\n- Use APIs available in Blender 4.3 (scene.sequence_editor_create().sequences).\n- In Blender 4.3, SpeedControlSequence has no use_audio property. Never read or assign use_audio, and do not apply a SPEED effect to the sound strip. A valid movie slow-motion pattern is speed_control='MULTIPLY', speed_factor=1/1.5, and extending the movie/render duration; the sound strip may remain normally trimmed.\n- For the subtitle background, prefer the TEXT strip's use_box=True and box_color=(0,0,0,0.6); do not assume a COLOR strip has text-layout properties.\n\nReturn one JSON object only: {\"code\":\"complete executable Python source\"}.\n\nPrior executable feedback:\nStatic safety/API validation failed: ValueError: generated script omits requested Blender API operations: ['frame_offset_start']"
}
],
"temperature": 0,
"max_tokens": 7000,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536055081715b00b15f7b15a122bee0bef9a71075821cc7c",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"code\":\"import bpy\\nimport os\\n\\n# Clear existing data\\nbpy.ops.wm.read_factory_settings(use_empty=True)\\n\\n# Get the current scene\\nscene = bpy.context.scene\\n\\n# Set render resolution (854x480 for H.264 even width)\\nscene.render.resolution_x = 854\\nscene.render.resolution_y = 480\\nscene.render.resolution_percentage = 100\\n\\n# Set frame rate (25 FPS)\\nscene.render.fps = 25\\nscene.render.fps_base = 1.0\\n\\n# Output settings\\noutput_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4'\\nscene.render.filepath = output_path\\nscene.render.image_settings.file_format = 'FFMPEG'\\nscene.render.ffmpeg.format = 'MPEG4'\\nscene.render.ffmpeg.codec = 'H.264'\\nscene.render.ffmpeg.audio_codec = 'AAC'\\nscene.render.ffmpeg.audio_mixrate = 44100\\nscene.render.ffmpeg.audio_bitrate = 192\\nscene.render.use_audio = True\\n\\n# Create sequence editor\\nscene.sequence_editor_create()\\nseq_ed = scene.sequence_editor\\n\\n# Source movie path\\nsource_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov'\\n\\n# Add video strip with trim (0.000-3.000s = 75 frames @25FPS)\\nvideo_strip = seq_ed.sequences.new_movie(\\n name='Video',\\n filepath=source_path,\\n channel=1,\\n frame_start=1\\n)\\nvideo_strip.frame_offset_start = 0 # Start at 0s of source\\nvideo_strip.frame_final_duration = 75 # Trim to 3s (75 frames)\\n\\n# Add SPEED effect for slow motion (1.5x duration)\\n speed_effect = seq_ed.sequences.new_effect(\\n name='SpeedControl',\\n type='SPEED',\\n channel=2,\\n frame_start=1,\\n seq1=video_strip\\n)\\nspeed_effect.speed_control = 'MULTIPLY'\\nspeed_effect.speed_factor = 1/1.5 # Slow playback speed\\n\\n# Extend video duration to 1.5x trimmed interval (4.5s = 112.5 frames → 113 frames)\\nvideo_strip.frame_final_duration = 113\\n\\n# Configure audio strip (trim to original 3s duration)\\nsound_strip = None\\nfor strip in seq_ed.sequences:\\n if strip.type == 'SOUND' and strip.name.startswith('Audio'):\\n sound_strip = strip\\nif sound_strip:\\n sound_strip.frame_offset_start = 0 # Start at 0s of source audio\\n sound_strip.frame_final_duration = 75 # Keep original 3s duration\\n\\n# Add subtitle text strip with background box\\ntext_strip = seq_ed.sequences.new_effect(\\n name='Subtitle',\\n type='TEXT',\\n channel=3,\\n frame_start=1,\\n frame_final_duration=113\\n)\\ntext_strip.text = 'BIG BUNNY'\\ntext_strip.align_x = 'CENTER'\\ntext_strip.align_y = 'BOTTOM'\\ntext_strip.use_box = True\\ntext_strip.box_color = (0, 0, 0, 0.6) # Semi-transparent black box\\ntext_strip.font_size = 48\\n\\n# Set scene frame range to match slowed video duration\\nscene.frame_start = 1\\nscene.frame_end = 113\\n\\n# Render the animation\\nbpy.ops.render.render(animation=True)\"}"
},
"usage": {
"prompt_tokens": 643,
"completion_tokens": 4515,
"total_tokens": 5158
},
"latency_s": 97.508
},
{
"purpose": "blender_script_negative-control_attempt_5",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "system",
"content": "You are a Blender VSE engineer. Return one JSON object only."
},
{
"role": "user",
"content": "Generate a complete Blender 4.3 Python script for this video edit.\n\nInput movie: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\nOutput MP4: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\nSource FPS: 25\nSource size: 853x480; render at 854x480 (one-pixel even-width pad required by H.264)\nTrim interval: [0.000, 3.000] seconds\nEffects: slow playback so output duration is 1.5 times the trimmed interval; add bottom-centered subtitle BIG BUNNY with a visible semi-transparent dark box.\n\nRequirements:\n- Use bpy and Blender's Video Sequence Editor, including new_movie, new_sound when available, frame_offset_start/frame_final_duration, a SPEED effect, and a TEXT effect.\n- Render exactly 854x480 at 25 fps through Blender to MPEG-4 H.264 with AAC audio.\n- Set the scene frame range to the slowed duration and call bpy.ops.render.render(animation=True).\n- Create only the requested output. Do not invoke ffmpeg, subprocesses, a shell, the network, or read credentials.\n- Use APIs available in Blender 4.3 (scene.sequence_editor_create().sequences).\n- In Blender 4.3, SpeedControlSequence has no use_audio property. Never read or assign use_audio, and do not apply a SPEED effect to the sound strip. A valid movie slow-motion pattern is speed_control='MULTIPLY', speed_factor=1/1.5, and extending the movie/render duration; the sound strip may remain normally trimmed.\n- For the subtitle background, prefer the TEXT strip's use_box=True and box_color=(0,0,0,0.6); do not assume a COLOR strip has text-layout properties.\n\nReturn one JSON object only: {\"code\":\"complete executable Python source\"}.\n\nPrior executable feedback:\nStatic safety/API validation failed: IndentationError: unexpected indent (<unknown>, line 48)"
}
],
"temperature": 0,
"max_tokens": 7000,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536064833115b00b15f7b15a122bee0bef9a710758d5fc02",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"code\":\"import bpy\\n\\n# Clear existing data\\nbpy.ops.wm.read_factory_settings(use_empty=True)\\n\\n# Get the scene\\nscene = bpy.context.scene\\n\\n# Set render resolution and FPS\\nscene.render.resolution_x = 854\\nscene.render.resolution_y = 480\\nscene.render.resolution_percentage = 100\\nscene.render.fps = 25\\nscene.render.fps_base = 1.0\\n\\n# Set output settings\\noutput_path = \\\"/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\\\"\\nscene.render.filepath = output_path\\nscene.render.image_settings.file_format = 'FFMPEG'\\nscene.render.ffmpeg.format = 'MPEG4'\\nscene.render.ffmpeg.codec = 'H264'\\nscene.render.ffmpeg.audio_codec = 'AAC'\\nscene.render.ffmpeg.constant_rate_factor = 'MEDIUM'\\n\\n# Create sequence editor\\nseq_ed = scene.sequence_editor_create()\\n\\n# Import movie strip\\nmovie_path = \\\"/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\\\"\\nmovie_strip = seq_ed.sequences.new_movie(\\n name=\\\"SourceMovie\\\",\\n filepath=movie_path,\\n channel=1,\\n frame_start=0\\n)\\n\\n# Trim movie to [0.000, 3.000] seconds (75 frames at 25 FPS)\\nmovie_strip.frame_offset_start = 0\\nmovie_strip.frame_final_duration = 75\\n\\n# Add speed control effect\\nspeed_strip = seq_ed.sequences.new_effect(\\n name=\\\"SpeedControl\\\",\\n type='SPEED',\\n channel=2,\\n frame_start=0,\\n frame_end=movie_strip.frame_start + movie_strip.frame_final_duration,\\n seq1=movie_strip\\n)\\nspeed_strip.speed_control = 'MULTIPLY'\\nspeed_strip.speed_factor = 1 / 1.5 # Slow down by 1.5x\\n\\n# Find sound strip and trim it\\nsound_strip = None\\nfor strip in seq_ed.sequences:\\n if strip.type == 'SOUND' and strip.frame_start == movie_strip.frame_start:\\n sound_strip = strip\\n break\\nif sound_strip:\\n sound_strip.frame_offset_start = 0\\n sound_strip.frame_final_duration = 75 # Keep sound at normal duration\\n\\n# Calculate render duration: 3s * 1.5 = 4.5s = 112.5 frames → 113 frames (0-112)\\nscene.frame_start = 0\\nscene.frame_end = 113 # 113 frames total\\n\\n# Add subtitle text strip\\ntext_strip = seq_ed.sequences.new_effect(\\n name=\\\"Subtitle\\\",\\n type='TEXT',\\n channel=3,\\n frame_start=0,\\n frame_end=scene.frame_end\\n)\\ntext_strip.text = \\\"BIG BUNNY\\\"\\ntext_strip.align_x = 'CENTER'\\ntext_strip.align_y = 'BOTTOM'\\ntext_strip.font_size = 48\\ntext_strip.color = (1, 1, 1, 1) # White text\\ntext_strip.use_box = True\\ntext_strip.box_color = (0, 0, 0, 0.6) # Dark semi-transparent box\\n\\n# Render animation\\nscene.render.engine = 'BLENDER_EEVEE'\\nbpy.ops.render.render(animation=True)\"}"
},
"usage": {
"prompt_tokens": 641,
"completion_tokens": 6960,
"total_tokens": 7601
},
"latency_s": 139.053
},
{
"purpose": "blender_script_negative-control_attempt_6",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"request": {
"model": "doubao-seed-1-6-250615",
"messages": [
{
"role": "system",
"content": "You are a Blender VSE engineer. Return one JSON object only."
},
{
"role": "user",
"content": "Generate a complete Blender 4.3 Python script for this video edit.\n\nInput movie: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov\nOutput MP4: /Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4\nSource FPS: 25\nSource size: 853x480; render at 854x480 (one-pixel even-width pad required by H.264)\nTrim interval: [0.000, 3.000] seconds\nEffects: slow playback so output duration is 1.5 times the trimmed interval; add bottom-centered subtitle BIG BUNNY with a visible semi-transparent dark box.\n\nRequirements:\n- Use bpy and Blender's Video Sequence Editor, including new_movie, new_sound when available, frame_offset_start/frame_final_duration, a SPEED effect, and a TEXT effect.\n- Render exactly 854x480 at 25 fps through Blender to MPEG-4 H.264 with AAC audio.\n- Set the scene frame range to the slowed duration and call bpy.ops.render.render(animation=True).\n- Create only the requested output. Do not invoke ffmpeg, subprocesses, a shell, the network, or read credentials.\n- Use APIs available in Blender 4.3 (scene.sequence_editor_create().sequences).\n- In Blender 4.3, SpeedControlSequence has no use_audio property. Never read or assign use_audio, and do not apply a SPEED effect to the sound strip. A valid movie slow-motion pattern is speed_control='MULTIPLY', speed_factor=1/1.5, and extending the movie/render duration; the sound strip may remain normally trimmed.\n- For the subtitle background, prefer the TEXT strip's use_box=True and box_color=(0,0,0,0.6); do not assume a COLOR strip has text-layout properties.\n\nReturn one JSON object only: {\"code\":\"complete executable Python source\"}.\n\nPrior executable feedback:\nBlender exit=0; output_exists=False. Tail of real Blender log:\nBlender 4.3.2 (hash 32f5fdce0a0a built 2024-12-17 02:14:25)\nTraceback (most recent call last):\n File \"/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/scripts/negative-control-attempt-5.py\", line 42, in <module>\n speed_strip = seq_ed.sequences.new_effect(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nTypeError: SequencesTopLevel.new_effect(): error with keyword argument \"frame_end\" - Function.frame_end expected an int type, not float\n\nBlender quit"
}
],
"temperature": 0,
"max_tokens": 7000,
"response_format": {
"type": "json_object"
}
},
"response": {
"id": "02178536078809715b00b15f7b15a122bee0bef9a710758656f73",
"model": "doubao-seed-1-6-250615",
"finish_reason": "stop",
"content": "{\"code\":\"import bpy\\nimport math\\n\\n# Clear existing objects\\nbpy.ops.object.select_all(action='SELECT')\\nbpy.ops.object.delete()\\n\\n# File paths\\ninput_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov'\\noutput_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4'\\n\\n# Scene setup\\nscene = bpy.context.scene\\n\\n# Render settings\\nscene.render.fps = 25\\nscene.render.fps_base = 1.0\\nscene.render.resolution_x = 854\\nscene.render.resolution_y = 480\\nscene.render.resolution_percentage = 100\\n\\n# Output format settings\\nscene.render.image_settings.file_format = 'MPEG4'\\nscene.render.ffmpeg.format = 'MPEG4'\\nscene.render.ffmpeg.codec = 'H264'\\nscene.render.ffmpeg.audio_codec = 'AAC'\\nscene.render.filepath = output_path\\n\\n# VSE setup\\nscene.sequence_editor_create()\\nseq_ed = scene.sequence_editor\\n\\n# Add video strip\\nvideo_strip = seq_ed.sequences.new_movie(\\n name='Video',\\n filepath=input_path,\\n frame_start=0,\\n channel=1\\n)\\nvideo_strip.frame_final_duration = 75 # 3s * 25fps\\n\\n# Add speed effect (1.5x slowdown)\\noriginal_duration = 75\\nnew_duration = math.ceil(original_duration * 1.5) # 113 frames (4.52s)\\nspeed_strip = seq_ed.sequences.new_effect(\\n name='SpeedControl',\\n type='SPEED',\\n channel=2,\\n frame_start=0,\\n frame_end=new_duration,\\n seq1=video_strip\\n)\\nspeed_strip.speed_control = 'MULTIPLY'\\nspeed_strip.speed_factor = 1/1.5\\n\\n# Add audio strip\\naudio_strip = seq_ed.sequences.new_sound(\\n name='Audio',\\n filepath=input_path,\\n frame_start=0,\\n channel=3\\n)\\naudio_strip.frame_final_duration = 75 # Keep original audio duration\\n\\n# Add subtitle text strip\\ntext_strip = seq_ed.sequences.new_effect(\\n name='Subtitle',\\n type='TEXT',\\n channel=4,\\n frame_start=0,\\n frame_end=new_duration\\n)\\ntext_strip.text = 'BIG BUNNY'\\ntext_strip.align_x = 'CENTER'\\ntext_strip.align_y = 'BOTTOM'\\ntext_strip.font_size = 48\\ntext_strip.use_box = True\\ntext_strip.box_color = (0, 0, 0, 0.6) # Semi-transparent black box\\n\\n# Set render frame range\\nscene.frame_start = 1\\nscene.frame_end = new_duration\\n\\n# Render animation\\nbpy.ops.render.render(animation=True)\"}"
},
"usage": {
"prompt_tokens": 812,
"completion_tokens": 6780,
"total_tokens": 7592
},
"latency_s": 129.381
}
]
}
@@ -0,0 +1,84 @@
import bpy
# Clear existing objects to start fresh
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Configuration paths and parameters
INPUT_MOVIE = "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov"
OUTPUT_MP4 = "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4"
SOURCE_FPS = 25
TRIM_DURATION_SECONDS = 3.0
SLOW_FACTOR = 1.5
RENDER_RESOLUTION = (854, 480)
# Calculate frame values
trim_frames = int(TRIM_DURATION_SECONDS * SOURCE_FPS)
slowed_frames = int(trim_frames * SLOW_FACTOR)
scene = bpy.context.scene
# Create sequence editor
seq_ed = scene.sequence_editor_create()
# Add movie strip with trim
movie_strip = seq_ed.sequences.new_movie(
name="SourceVideo",
filepath=INPUT_MOVIE,
channel=1,
frame_start=1
)
movie_strip.frame_offset_start = 0 # Start at beginning of source
movie_strip.frame_final_duration = trim_frames # Trim to 3 seconds
# Locate and trim audio strip (auto-created with movie)
sound_strip = None
for strip in seq_ed.sequences:
if strip.type == 'SOUND' and strip.channel == 2 and strip.frame_start == 1:
sound_strip = strip
if sound_strip:
sound_strip.frame_final_duration = trim_frames # Keep audio at original speed
# Add speed effect to video (slow motion)
speed_strip = seq_ed.sequences.new_effect(
name="SlowMotion",
type='SPEED',
channel=3,
frame_start=1,
frame_end=1 + slowed_frames - 1,
target=movie_strip
)
speed_strip.speed_control = 'MULTIPLY'
speed_strip.speed_factor = 1 / SLOW_FACTOR # 1/1.5 speed factor
# Add subtitle text strip with background box
text_strip = seq_ed.sequences.new_text(
name="Subtitle",
text="BIG BUNNY",
channel=4,
frame_start=1,
frame_end=1 + slowed_frames - 1
)
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
text_strip.use_box = True
text_strip.box_color = (0.0, 0.0, 0.0, 0.6) # Black semi-transparent box
text_strip.font_size = 48 # Visible size for 854x480 resolution
# Configure render settings
scene.render.resolution_x = RENDER_RESOLUTION[0]
scene.render.resolution_y = RENDER_RESOLUTION[1]
scene.render.resolution_percentage = 100
scene.render.fps = SOURCE_FPS
scene.render.fps_base = 1.0
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.filepath = OUTPUT_MP4
# Set render frame range
scene.frame_start = 1
scene.frame_end = 1 + slowed_frames - 1
# Execute render
bpy.ops.render.render(animation=True)
@@ -0,0 +1,79 @@
import bpy
# Clear existing data
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
# Scene settings
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
scene.render.fps = 25
scene.render.fps_base = 1.0
# Create sequence editor
if not scene.sequence_editor:
scene.sequence_editor_create()
seq_ed = scene.sequence_editor
sequences = seq_ed.sequences
# File paths
input_movie = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov'
output_mp4 = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4'
# Add movie strip (3 seconds = 75 frames at 25fps)
mov_strip = sequences.new_movie(
name='SourceMovie',
filepath=input_movie,
frame_start=1,
channel=1
)
mov_strip.frame_offset_start = 0
mov_strip.frame_final_duration = 75 # 3s * 25fps
# Locate and trim sound strip
sound_strip = next((s for s in sequences if s.type == 'SOUND' and s.frame_start == mov_strip.frame_start), None)
if sound_strip:
sound_strip.frame_final_duration = 75 # Keep original audio duration
# Add speed effect (1.5x slowdown)
speed_strip = sequences.new_effect(
name='SlowMotion',
type='SPEED',
frame_start=1,
channel=2,
input_1=mov_strip
)
speed_strip.speed_control = 'MULTIPLY'
speed_strip.speed_factor = 1 / 1.5
# Calculate slowed duration (4.5s = 112.5 frames → 113 frames)
slowed_frames = int(4.5 * 25) + 1
scene.frame_start = 1
scene.frame_end = slowed_frames
# Add subtitle text strip
text_strip = sequences.new_effect(
name='Subtitle',
type='TEXT',
frame_start=1,
frame_end=scene.frame_end,
channel=3
)
text_strip.text = 'BIG BUNNY'
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
text_strip.use_box = True
text_strip.box_color = (0, 0, 0, 0.6)
text_strip.font_size = 48
# Render settings
scene.render.filepath = output_mp4
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.ffmpeg.constant_rate_factor = 'MEDIUM'
# Render animation
bpy.ops.render.render(animation=True)
@@ -0,0 +1,84 @@
import bpy
import os
# Clear default objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Set up scene
scene = bpy.context.scene
scene.render.fps = 25
scene.render.fps_base = 1.0
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
# Configure output settings
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.ffmpeg.audio_bitrate = 192
output_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4'
scene.render.filepath = output_path
# Create sequence editor
scene.sequence_editor_create()
sequences = scene.sequence_editor.sequences
# Import source movie and sound
source_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov'
movie_strip = sequences.new_movie(name='SourceMovie', filepath=source_path, channel=1, frame_start=0)
sound_strip = None
for strip in sequences:
if strip.type == 'SOUND' and strip.name.startswith('SourceMovie'):
sound_strip = strip
break
if not sound_strip:
sound_strip = sequences.new_sound(name='SourceAudio', filepath=source_path, channel=2, frame_start=0)
# Trim to 3-second interval (75 frames at 25fps)
trim_frames = 75 # 3.0s * 25fps
movie_strip.frame_start = 0
movie_strip.frame_final_duration = trim_frames
sound_strip.frame_start = 0
sound_strip.frame_final_duration = trim_frames
# Apply speed effect (1.5x slowdown = 4.5s output)
speed_strip = sequences.new_effect(
name='SpeedControl',
type='SPEED',
channel=2,
frame_start=0,
frame_end=113, # 75 frames * 1.5 = 112.5 → 113 frames
seq1=movie_strip,
seq2=None
)
speed_strip.speed_control = 'MULTIPLY'
speed_strip.speed_factor = 1/1.5
# Set scene frame range
duration_frames = 113 # 4.5s * 25fps = 112.5 → 113 frames
scene.frame_start = 0
scene.frame_end = duration_frames
# Add subtitle with background box
text_strip = sequences.new_effect(
name='Subtitle',
type='TEXT',
channel=3,
frame_start=0,
frame_end=duration_frames,
seq1=None,
seq2=None
)
text_strip.text = 'BIG BUNNY'
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
text_strip.use_box = True
text_strip.box_color = (0.0, 0.0, 0.0, 0.6)
text_strip.font_size = 48
# Render animation
bpy.ops.render.render(animation=True)
@@ -0,0 +1,90 @@
import bpy
import os
# Clear existing data
bpy.ops.wm.read_factory_settings(use_empty=True)
# Get the current scene
scene = bpy.context.scene
# Set render resolution (854x480 for H.264 even width)
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
# Set frame rate (25 FPS)
scene.render.fps = 25
scene.render.fps_base = 1.0
# Output settings
output_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4'
scene.render.filepath = output_path
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H.264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.ffmpeg.audio_mixrate = 44100
scene.render.ffmpeg.audio_bitrate = 192
scene.render.use_audio = True
# Create sequence editor
scene.sequence_editor_create()
seq_ed = scene.sequence_editor
# Source movie path
source_path = '/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov'
# Add video strip with trim (0.000-3.000s = 75 frames @25FPS)
video_strip = seq_ed.sequences.new_movie(
name='Video',
filepath=source_path,
channel=1,
frame_start=1
)
video_strip.frame_offset_start = 0 # Start at 0s of source
video_strip.frame_final_duration = 75 # Trim to 3s (75 frames)
# Add SPEED effect for slow motion (1.5x duration)
speed_effect = seq_ed.sequences.new_effect(
name='SpeedControl',
type='SPEED',
channel=2,
frame_start=1,
seq1=video_strip
)
speed_effect.speed_control = 'MULTIPLY'
speed_effect.speed_factor = 1/1.5 # Slow playback speed
# Extend video duration to 1.5x trimmed interval (4.5s = 112.5 frames → 113 frames)
video_strip.frame_final_duration = 113
# Configure audio strip (trim to original 3s duration)
sound_strip = None
for strip in seq_ed.sequences:
if strip.type == 'SOUND' and strip.name.startswith('Audio'):
sound_strip = strip
if sound_strip:
sound_strip.frame_offset_start = 0 # Start at 0s of source audio
sound_strip.frame_final_duration = 75 # Keep original 3s duration
# Add subtitle text strip with background box
text_strip = seq_ed.sequences.new_effect(
name='Subtitle',
type='TEXT',
channel=3,
frame_start=1,
frame_final_duration=113
)
text_strip.text = 'BIG BUNNY'
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
text_strip.use_box = True
text_strip.box_color = (0, 0, 0, 0.6) # Semi-transparent black box
text_strip.font_size = 48
# Set scene frame range to match slowed video duration
scene.frame_start = 1
scene.frame_end = 113
# Render the animation
bpy.ops.render.render(animation=True)
@@ -0,0 +1,85 @@
import bpy
# Clear existing data
bpy.ops.wm.read_factory_settings(use_empty=True)
# Get the scene
scene = bpy.context.scene
# Set render resolution and FPS
scene.render.resolution_x = 854
scene.render.resolution_y = 480
scene.render.resolution_percentage = 100
scene.render.fps = 25
scene.render.fps_base = 1.0
# Set output settings
output_path = "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/negative_control.mp4"
scene.render.filepath = output_path
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.audio_codec = 'AAC'
scene.render.ffmpeg.constant_rate_factor = 'MEDIUM'
# Create sequence editor
seq_ed = scene.sequence_editor_create()
# Import movie strip
movie_path = "/Users/boj/book/ai-agent-book/chapter5/video-edit/validation/runs/exp5-6-real-blender-20260730-052206/source.mov"
movie_strip = seq_ed.sequences.new_movie(
name="SourceMovie",
filepath=movie_path,
channel=1,
frame_start=0
)
# Trim movie to [0.000, 3.000] seconds (75 frames at 25 FPS)
movie_strip.frame_offset_start = 0
movie_strip.frame_final_duration = 75
# Add speed control effect
speed_strip = seq_ed.sequences.new_effect(
name="SpeedControl",
type='SPEED',
channel=2,
frame_start=0,
frame_end=movie_strip.frame_start + movie_strip.frame_final_duration,
seq1=movie_strip
)
speed_strip.speed_control = 'MULTIPLY'
speed_strip.speed_factor = 1 / 1.5 # Slow down by 1.5x
# Find sound strip and trim it
sound_strip = None
for strip in seq_ed.sequences:
if strip.type == 'SOUND' and strip.frame_start == movie_strip.frame_start:
sound_strip = strip
break
if sound_strip:
sound_strip.frame_offset_start = 0
sound_strip.frame_final_duration = 75 # Keep sound at normal duration
# Calculate render duration: 3s * 1.5 = 4.5s = 112.5 frames → 113 frames (0-112)
scene.frame_start = 0
scene.frame_end = 113 # 113 frames total
# Add subtitle text strip
text_strip = seq_ed.sequences.new_effect(
name="Subtitle",
type='TEXT',
channel=3,
frame_start=0,
frame_end=scene.frame_end
)
text_strip.text = "BIG BUNNY"
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
text_strip.font_size = 48
text_strip.color = (1, 1, 1, 1) # White text
text_strip.use_box = True
text_strip.box_color = (0, 0, 0, 0.6) # Dark semi-transparent box
# Render animation
scene.render.engine = 'BLENDER_EEVEE'
bpy.ops.render.render(animation=True)

Some files were not shown because too many files have changed in this diff Show More