Files
ai-agent-book/chapter5/video-edit/demo.py
T
liqiang b119135836
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
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

294 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
实验 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()