首页 / 文章 / [开发日志][Python] 使用Gemini 3.7 Flash从照片和片段制作短视频:ReelCraft
← 返回
AI技术

[开发日志][Python] 使用Gemini 3.7 Flash从照片和片段制作短视频:ReelCraft

✍️ zhirenhun 📅 2026/8/15 👁 241 阅读 ⏱ 29 分钟
[开发日志][Python] 使用Gemini 3.7 Flash从照片和片段制作短视频:ReelCraft

ReelCraft 标志

前言:

一切都始于一个误解。

我在 Gemini API 文档中发现了一个名为 Omni 的新页面,介绍了一个名为 Gemini Omni Flash 的模型,描述为“原生多模态,同时处理文本、图像、音频和视频。”我第一反应很直接:如果我把自己手机里的整个视频和照片文件夹扔进去,让它理解每个素材的内容,然后用一句话告诉它剪辑成短视频——这不就是一个视频剪辑应用吗?

读完文档后,我意识到我误解了,而且误解恰好发生在最关键的地方。然而,在绕过这个限制之后,其余部分实际上是可行的。结果就是 ReelCraft:一个 Python CLI 工具,你向其中输入一堆视频和照片,Gemini 3.7 Flash 逐一理解素材并提供剪辑建议。一旦我确认了剪辑列表,ffmpeg 将其剪成 9:16 竖屏短视频,背景音乐使用 Lyria 3 生成,字幕自动烧录。

在开发过程中,有三个问题中 ffmpeg 和 Gemini 都报告成功,但输出却是错误的——这种错误只有真正播放视频时才能发现。

摘要

本文将涵盖:

Omni Flash 并非我设想的那样

Gemini Omni Flash(gemini-omni-flash-preview)是一个视频生成和编辑模型,使用 Interactions API。它允许你用自然语言对单个视频应用效果,例如“当人物触摸镜子时,让镜子像液体一样产生漂亮的波纹。”它不是用于“理解一堆视频”的工具。

限制部分写得很清楚:

不支持跨多个视频的引用或推理。尝试多视频提示可能导致模型性能下降或意外输出。

另外:

API 模式接受最长 3 秒的视频引用,但模型目前无法正确处理。

因此,“把一堆视频扔进去,让它理解并剪辑”这条路对 Omni Flash 来说是行不通的。实际上能够执行多视频理解的模型是标准的 Gemini 模型:从 2.5 版本开始,单个请求可以包含多达 10 个视频。凭借 1M 上下文窗口,它可以处理默认分辨率下约一小时的素材,逐秒进行 tokenize,并输出带有时间戳的场景描述。

花在这个误解上的时间没有白费。验证过程帮助理清了“哪个任务应该由哪个模型处理”,架构也随之自然成型。

绕过限制:逐文件理解,再文本聚合

整个流水线分为五个阶段,状态存储在文件中:

[Asset Folder]
     │ poc ingest: Scan videos/photos → catalog.json
     ▼
     │ poc analyze: Call Gemini for each file individually → analysis/*.json
     ▼
     │ poc plan: Aggregate all analysis results, call once for editing suggestions
     ▼ → summary.md (for humans) + edl.yaml (for machine execution)
     ⏸ Human inspection and editing of edl.yaml
     ▼
     │ poc render: ffmpeg editing, 9:16 cropping, xfade transitions
     ▼
output/final.mp4
进入全屏模式 退出全屏模式

关键的设计决策在第二步和第三步:对每个视频调用一次 Gemini 以获取精确的内部时间戳和描述;然后将这些文本结果(而非原始视频)输入第二次调用,以进行跨资产聚合、排序和剪辑建议。

这种方法有两个好处。首先,它完全避免了“不支持多视频推理”的问题,因为第二次调用只处理文本,而不是十个视频。其次,它不受每次请求 10 个视频的限制;无论有多少资产,只是在 analyze 阶段增加更多独立的调用。这些调用可以单独重试或失败,而不会相互影响。

测试还证明,分开处理时时间戳更为可靠。当在单个提示中询问关于十个视频的问题(例如“哪些秒是精彩片段?”)时,模型很容易混淆不同视频的时间线。

analyze 阶段的失败处理被单独记录:如果某个文件在重试三次后仍然失败,它会被记录在 analysis/_errors.json 中,而其他文件则继续处理。这后来在审查过程中揭示了一个漏洞,我稍后会讨论。

使用 edl.yaml 作为人工确认点

我从一开始就决定不把它做成“一键全自动”。在输入资产和输出最终产品之间,必须有一个我可以手动干预的地方,因为 LLM 提供的剪辑点必然会有一些不合理之处,而重新运行整个流程会再次产生 API 成本。

那个界面是一个 YAML 文件:

target_duration_sec: 23
aspect_ratio: '9:16'
clips:
- source: /abs/path/808327978.mp4
  note: Opening shot: Showing the COSCUP x UbuCon Asia main visual backdrop.
  in: '00:00.000'
  out: '00:02.500'
- source: /abs/path/S__1908753.jpg
  note: Fun venue easter egg: Creative semiconductor chip snacks distributed on-site.
  duration_sec: 4.0
transitions: crossfade 0.3s
mood_tags: [Professional, Joyful, Community Cohesion]
进入全屏模式 退出全屏模式

视频使用 in/out 标记范围,照片使用 duration_sec 表示时长,note 是 Gemini 撰写的入选理由(这个字段后来被用于字幕,详见下文)。要修改剪辑点,只需更改数字;要调整顺序,移动片段即可;保存后运行 poc render

每个阶段的输出都保留在项目目录中,因此任何步骤都可以单独重新运行。analyze 会跳过已有分析结果的文件,因此重新运行不会产生重复扣费——这在迭代提示词时非常有用。

poc plan --theme 是后来添加的:你可以提供一个句子作为剪辑主题,例如 --theme "Participating in the COSCUP open source community"。这会影响摘要的叙事角度、片段选择的优先级,以及每个片段 note 的措辞。由于它只影响 plan 阶段,更改主题不需要重新分析素材,因此在同一组素材上尝试不同的叙事方式成本非常低。

切换到 Gemini 3.7 Flash 后的差异

理解和聚合阶段最初使用 gemini-2.5-flash,后来切换到 gemini-3.7-flash。这是 GA 稳定版,不是预览版:

项目 规格
模型 ID gemini-3.7-flash
输入 1,048,576 tokens
输出 65,536 tokens
输入类型 文本、图像、视频、音频、PDF
能力 结构化输出、函数调用、缓存、思考(低/中/高)
不支持 视频/图像/音频生成、Live API

对于这个项目来说,最重要的功能是结构化输出和视频输入,因为 analyze 阶段需要输入视频并请求符合固定结构的 JSON。

切换之后,我不仅仅是改了字符串就完事了;我还通过实际的 API 调用进行了验证,对真实素材运行了 analyze_file。对于同一个讲座视频,两个模型在描述上的差异相当明显。

gemini-2.5-flash 版本:

视频开头,一位女士在舞台上用麦克风向观众介绍自己。她身后的大屏幕显示着她的名字“Zona Wang”和她的工作描述。

gemini-3.7-flash 版本:

视频中,一位女性演讲者(Zona Wang,LINE 技术布道师)正在演讲厅的舞台上进行自我介绍和演示,随后镜头扫过认真聆听的观众。

区别在于“工作描述”和“LINE 技术布道师”。后者实际上读出了幻灯片上的小字,而前者只知道那里有一些工作信息。

聚合阶段的差距更大。对于同一组 COSCUP 素材和相同的 --theme,2.5 的摘要是:“这个短视频旨在展示 COSCUP 开源社区的活力和多样性。从专业的知识分享和深入的技术交流,到社区成员之间温暖的互动与包容”——整体停留在抽象层面。3.7 识别出了完整的活动名称“COSCUP x UbuCon Asia”、展位名称如“FOSS for All”和“Kubernetes”,甚至将一张照片描述为“现场分发的创意半导体芯片零食”。这些细节不在我的提示词中,全部来自照片中的文字和物体。

对于“素材理解质量直接决定剪辑质量”的应用来说,切换模型的收益超出了我的预期。剪辑建议之所以变得更好,是因为它确实理解了更多内容,而不是因为提示词写得更好了。

顺便提一下,3.7 的 note 风格也变成了“简短标签:详细描述”的格式。这个变化后来导致我所有的字幕都出了问题,下文会详细讨论。

背景音乐:Lyria 3 使用不同的 API

背景音乐使用 Lyria 3 生成。有两个模型:lyria-3-clip-preview 用于 30 秒片段,lyria-3-pro-preview 用于完整歌曲。我的输出大约 20 秒,所以片段版本正好合适。

它不需要单独的 Vertex AI 应用或白名单申请,使用同一个 Gemini API 密钥即可。不过,调用方式与 generate_content 完全不同,使用的是 client.interactions.create()

interaction = client.interactions.create(
    model="lyria-3-clip-preview",
    input="An instrumental background music track for a short social-media video, "
          "about 20 seconds long. Mood: Professional, Joyful, Community Cohesion, Happy. "
          "No vocals, no lyrics, loopable.",
)
audio_bytes = base64.b64decode(interaction.output_audio.data)
进入全屏模式 退出全屏模式

有几件事与我原先想象的不同。

它没有结构化的参数。长度、BPM、流派和情绪都必须写在自然语言提示中,而不是传递诸如bpm=120这样的字段。所以generate_score(mood_tags, duration_sec)函数的实际作用是将情绪标签和秒数拼接成一个英文句子。情绪标签是在plan阶段从资产分析结果中聚合而来的,而poc render --mood "Happy, Joyful, Celebration"可以进一步叠加所需的方向。

它是单轮生成的,无法进行迭代修改。与Omni Flash的视频编辑不同,音乐一旦生成便已定型;如果不满意,就必须提交新的提示。所有生成的音频都包含SynthID水印。

当音乐比视频短时,你必须自己处理。片段版本最长30秒,但视频可能更长。因此在混音时,我使用-stream_loop -1来无限循环音频,并用-shortest将其修剪到视频长度:

cmd.extend(["-stream_loop", "-1", "-i", str(audio_path)])
# ... filter_complex, map video ...
cmd.extend(["-map", f"{audio_index}:a", "-c:a", "aac", "-b:a", "128k", "-shortest"])
进入全屏模式 退出全屏模式

音乐生成失败(配额、网络、安全过滤器)不会导致整个渲染崩溃;它会打印一条警告并回退为静默输出。这一原则后来被加入项目的CLAUDE.md中:任何调用外部生成式 API 的增值功能都必须优雅降级,不能因为次要功能而让主进程死亡。

ffmpeg 会静默地让编辑失败

渲染阶段使用 ffmpeg 的xfade滤镜来连接片段。每个xfade都需要一个offset参数,它表示“在输出时间轴的哪一秒开始这个转场”。累加逻辑是:之前所有片段长度之和减去每个转场所重叠的秒数。

写完第一个版本后,单元测试全部通过,真实素材也产出了正常的视频。随后审查发现了两种 ffmpeg 返回退出码 0 但输出文件错误的情况。

场景一:转场时间比片段本身还长,导致片段被静默吞掉。对于两个 1 秒的片段,使用transitions: "crossfade 2s",计算出的 offset 是-1.000。ffmpeg 接受这个负数,不报错,正常完成。输出是一个只包含第一个片段的 1 秒视频;第二个片段完全消失。由于EDL.transitions是一个自由文本字段,我在手动编辑 YAML 时完全有可能输入3s而不是0.3s,而它不会以任何方式告诉我。

场景二:out超过实际素材长度,导致其后所有内容被截断。对于一个 10 秒的视频,如果 EDL 声明in: 8.0 / out: 15.0,实际只能取出 2 秒。如果接下来是一个 1.5 秒的照片,计算出的 offset 为 6.700,而这个时间点落在第一段流的结束之后。结果是一个 2 秒的输出,照片完全缺失,而退出码仍然是 0。这个场景更值得防范,因为 EDL 是由 LLM 生成的,幻觉出一个越界的结束时间是非常自然的。

我为这两种情况都添加了显式检查:如果计算出的 offset 为负数,就抛出ValueError,指明是哪个片段和转场长度;在渲染前,使用ffprobe读取每个视频素材的实际长度,如果out超过该长度,就会报错,并清楚地说明请求的时长与实际时长。

我如此在意,是因为一次“成功”但错误的输出比崩溃糟糕得多。如果崩溃了,我知道马上修复。但退出码为 0 且 mp4 看起来正常时,我可能直到看完整个视频才意识到“等等,有一段缺失了”,然后完全不知道从何查起。

字幕:两个问题只有在烧录后才可见

图片-20260814153330478

字幕的源是 EDL 中每个片段的note——由 Gemini 编写的剪辑理由。既然它已经为每个片段写了描述,把它用作屏幕上的标题就非常合适。

实现没有使用drawtext,而是生成一个 SRT 文件,并用 libass 的subtitles滤镜将其烧录进去。原因是drawtext需要手动处理中文字体路径和字符转义;冒号、逗号和单引号都会与 filtergraph 语法冲突。使用带force_style的 SRT 要干净得多,而且指定FontName=Noto Sans TC能让 fontconfig 找到中文字体。

第一个问题是屏幕上同时出现两条字幕。在第一个版本中,每条字幕的显示区间就是片段自身的起止时间。但相邻片段之间有 0.3 秒的交叉淡化重叠,这 0.3 秒内会有两行白字黑底的字幕叠在一起,看起来很难看。解决办法是把每条字幕的结束时间改成“下一个片段开始的时间”,而不是它自身的结束时间,确保任何时刻最多只有一条字幕可见。单元测试

files.upload() 返回并不意味着文件已就绪。这是在审查期间深入研究 SDK 源代码时发现的:它在真实 API 上会崩溃,但在测试中从未暴露。client.files.upload() 在字节传输完成后立即返回,不会等待服务器端处理。视频上传后,会处于 PROCESSING 状态数秒;在此期间尝试将其用于 generate_content 会导致 400 FAILED_PRECONDITION 错误。

更糟糕的是,我最初的重试循环让事情变得更糟:analyze_file 被包裹在重试逻辑中,因此每次重试都会重新上传整个视频并立即再次失败,三次尝试之间只有大约 3 秒的退避时间。三次尝试后,该素材被记录到 _errors.json,而 plan 阶段当时没有读取该文件,因此该素材从最终产品中悄无声息地消失了。修复方法是添加 wait_for_active(),在上传后轮询 client.files.get(),直到状态变为 ACTIVE 再继续执行,并将上传移出重试循环。

_errors.json 被写入但从未被读取。如前所述,analyze 尽职地记录了失败的素材,但 plan 不会读取它们,summary.md 也不会提及它们。用户唯一能察觉到的方式是数最终产品中的片段数量。现在 plan 会将失败列表附加到摘要末尾,明确说明哪些素材未被包含。

重新运行 ingest 可能会被旧的分析结果坑到。这是在真实使用中遇到的,而非审查时发现的。我更改了素材文件夹的内容,添加了新照片并删除了旧照片,然后重新运行了 poc ingestcatalog.json 已更新,但已删除文件的分析结果仍留在 analysis/ 中。当 plan 读取分析结果时,它没有与当前目录进行交叉比对,因此将过时的素材喂给了 Gemini。模型合理地从中挑选了一个片段,但由于该文件已不存在,整个计划失败了。现在 load_analyses() 会按目录进行过滤,并打印哪些过时记录被忽略。

时间戳精度。format_timestamp 最初使用 :04.1f,只保留一位小数。每次 EDL 写入 YAML 再读回时,都会损失最多 0.05 秒,在 30fps 下大约相当于 1.5 帧,导致编辑点漂移。我将其改为 :06.3f 以保持毫秒级精度。

回顾来看,这些问题可以分为两类。files.upload 和 ffmpeg 的静默错误是在审查期间逐行阅读代码时发现的。字幕重叠、省略号问题和过时的分析结果只有通过实际运行代码、播放视频和尝试不同的素材集才会暴露出来。当所有测试都通过时,这三个问题仍然潜伏在代码中。

结论

ReelCraft 现在做的事情很简单:输入一个包含视频和照片的文件夹,输出一个带音乐和字幕的 9:16 竖屏短视频,中间有一个我可以手动编辑的 YAML 文件。

从架构上讲,真正让这一切发挥作用的是“逐文件理解、文本聚合”的拆分。它最初是为了绕过 Omni Flash 缺乏多视频推理能力而设计的,但最终也解决了时间戳精度和素材数量限制的问题。切换到 Gemini 3.7 Flash 后,素材理解的粒度显著提高,编辑建议也随之改进——这方面的收益比我调整提示词获得的收益更大。

有两个领域尚未涉及:Omni Flash 的单片段生成式润色有一个空的 touch_up_clip 接口,字幕目前是根据 note 自动生成的,text_overlays 字段仍然为空。音乐和字幕都没有缓存;每次运行 render 时都会重新生成。

参考链接:

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

ai gemini python software
← 上一篇
如何使用Python构建一个基础的Discord机器人,支持讲故事、聊天和心理健康
下一篇 →
基于PHP、cPanel和Gemini Flash每月零成本构建生产级AI智能体

📌 相关推荐

停止相信仅文本代理排行榜:来自 Cua-Bench 和 Factorio 的教训
2026/8/26
Agent Memory 有两种不同含义,回答引擎给出的却是错误的那一种
2026/8/26
LLM的止境:AI辅助VAPT流水线的确定性评分
2026/8/22
← 返回文章列表