GEOZ

本文作者从误解 Omni Flash 出发,实际用 Gemi

2026/8/16
本文作者从误解 Omni Flash 出发,实际用 Gemi

AIAI Summary (BLUF)

本文作者从误解 Omni Flash 出发,实际用 Gemini 3.7 Flash 和 ffmpeg 构建了 ReelCraft 工具,实现批量素材分析、剪辑规划和自动渲染。文章详细对比了 2.5 与 3.7 的视觉理解差异,分享了 Lyria 3 的 API 用法,并总结了 ffmpeg 和字幕处理中的隐蔽坑。

核心洞察

这篇文章最有意思的地方是,作者一开始被“原生多模态”这几个字带偏了,以为 Omni Flash 能一口气理解一堆视频。他绕开限制的办法特别朴素:每个素材单独让 Gemini 看懂,再用文本汇总做剪辑决策。这个拆分思路比任何提示词技巧都值得抄。

核心结论

  1. Gemini Omni Flashgemini-omni-flash-preview)不是多视频理解模型,而是单段视频的生成与编辑模型,官方明确不支持跨视频引用和推理;多视频理解应改用标准 Gemini 系列,从 2.5 起单次请求最多可放 10 段视频,配合 100 万 token 上下文窗口。

  2. 整个项目最关键的架构是“单个文件理解 + 文本汇总”:先对每个视频单独调用 Gemini 生成带内部时间戳的描述,再用文本结果做跨素材剪辑规划。这个拆分绕开了 Omni Flash 的多视频限制,也避免了单次请求 10 个视频的上限,同时让时间戳不因跨视频推理而混淆。

  3. gemini-2.5-flash 换成 gemini-3.7-flash 后,素材理解质量提升明显:3.7 能读到幻灯片上的小字“LINE 技术布道师”,识别出 COSCUP x UbuCon Asia、FOSS for All、Kubernetes 等具体名称,而 2.5 只能给出“工作描述”这类抽象概括;剪辑建议也随之变得更具体、可用。

  4. ffmpegxfade 滤镜会静默剪坏视频:当 crossfade 时长超过片段长度(例如两个 1 秒片段配 2 秒过渡),或 out 超出素材真实长度时,命令仍返回退出码 0,但输出视频会丢失片段或被截断;必须用 ffprobe 校验真实时长,并显式检查 offset 是否为负值。

  5. 字幕烧录有两个隐蔽问题:相邻片段之间的 0.3 秒 crossfade 会让两条字幕同时出现在同一帧;Gemini 3.7 的 note 改为“短标签:详细描述”格式后,冒号不在原有截断规则中,导致所有字幕以省略号结尾。修复方式是把字幕结束时间改为下一片段开始时间,并让硬截断按英文单词边界回退,避免劈开单词。

前言

这个项目从一个误会开始。

Gemini API 文档里多了一个 Omni 页面,里面有个叫 Gemini Omni Flash 的模型,号称原生多模态,可以同时处理文本、图像、音频和视频。我的第一反应很直接:把手机里一个文件夹的视频和照片都丢进去,让它搞清楚每个素材是什么,再用一句话让它剪成短视频,这不就是视频剪辑应用?

把文档看完才发现,我理解错了,而且错在最关键的地方。不过绕过那个限制之后,剩下的路是通的。于是就有了 ReelCraft:一个 Python 命令行工具,喂进去一堆视频和照片,Gemini 3.7 Flash 逐个理解素材并给出剪辑建议,我确认完剪辑清单,ffmpeg 把它切成 9:16 竖屏短视频,背景音乐用 Lyria 3 生成,字幕自动烧进去。

一路上有三个坑,ffmpeg 和 Gemini 都报告成功,输出却是错的。这类错误只有把视频真的放一遍才会发现。

这篇会讲什么

  • Omni Flash 的实际定位
  • 绕过限制:单个文件理解,再做文本汇总
  • 用 edl.yaml 做人工确认点
  • 换成 Gemini 3.7 Flash 之后的差别
  • 背景音乐:Lyria 3 走的是另一套 API
  • ffmpeg 会静默剪坏视频
  • 字幕里两个烧进去才看得见的问题
  • 其他坑
  • 总结
  • 参考链接

Omni Flash 跟我想的不一样

Gemini Omni Flash(gemini-omni-flash-preview)是一个视频生成和编辑模型,走的是 Interactions API。你可以用自然语言对单段视频加效果,比如“人碰到镜子的时候,让镜子像液体一样漂亮地波动”。它压根不是用来理解一堆视频的工具。

限制部分写得很明白:

不支持跨多个视频进行引用或推理。尝试用多段视频提问,模型性能可能下降,输出也可能变得不可预期。

另外还有一条:

API schema 接受最长 3 秒的视频引用,但模型目前处理不好这些引用。

所以“丢一堆视频进去,让它理解并剪辑”这条路,对 Omni Flash 是堵死的。真正能处理多视频理解的是标准 Gemini 系列模型:从 2.5 开始,单次请求最多放 10 段视频。配合 100 万 token 的上下文窗口,默认分辨率下大概能装下一小时的素材,按秒生成 token,输出带时间戳的场景描述。

这段误会没白费。验证过程帮我搞清楚了哪类活应该交给哪个模型,后面的架构也就顺着出来了。

绕过限制:单个文件理解,再做文本汇总

整条流程拆成五个阶段,状态都存在文件里:

[素材文件夹]
     │ poc ingest: 扫描视频/照片 → catalog.json
     ▼
     │ poc analyze: 每个文件单独调 Gemini → analysis/*.json
     ▼
     │ poc plan: 汇总所有分析结果,调一次拿剪辑建议
     ▼ → summary.md(给人看)+ edl.yaml(给机器执行)
     ⏸ 人工检查和修改 edl.yaml
     ▼
     │ poc render: ffmpeg 剪辑、9:16 裁切、xfade 转场
     ▼
output/final.mp4

关键设计在第二步和第三步。analyze 阶段每个视频单独调一次 Gemini,拿到带内部时间戳的描述。第二次调用只拿到这些文本结果,不拿原始视频,然后做跨素材汇总、排序和剪辑建议。

这样有两个好处。第一,多视频推理不支持的问题直接被绕开,因为第二次调用只看见文本,看不见十段视频。第二,不受单次请求 10 个视频的限制,素材再多也只是 analyze 阶段多一些独立调用。每个调用可以单独重试,单个失败不会影响其他文件。

测试也证明,分开处理时间戳更可靠。要是一次问十段视频“高光在第几秒”,模型很容易把不同视频的时间线搞混。

analyze 阶段的失败处理是单独记录的:一个文件重试三次还失败,就写进 analysis/_errors.json,其他文件继续处理。这个设计后来在 review 时露出一个漏洞,后面再说。

把 edl.yaml 作为人工确认点

我一开始就决定不做一键全自动。素材进去、成片出来,中间必须有一个能手动改的地方。LLM 给的剪辑点总会有不合理的时候,整条流程重跑又要再花 API 费。

这个确认点就是一份 YAML 文件:

target_duration_sec: 23
aspect_ratio: '9:16'
clips:
- source: /abs/path/808327978.mp4
  note: 开场镜头:COSCUP x UbuCon Asia 主视觉背景
  in: '00:00.000'
  out: '00:02.500'
- source: /abs/path/S__1908753.jpg
  note: 会场彩蛋:现场发的创意半导体芯片零食
  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 "参加 COSCUP 开源社区"。它会影响 summary 的叙事角度、选片优先级,以及每个片段的 note 措辞。因为只影响 plan 阶段,换主题不用重新分析素材。同一批素材试不同叙事,成本很低。

换成 Gemini 3.7 Flash 之后的差别

理解和汇总这两个阶段一开始用的 gemini-2.5-flash,后来换成 gemini-3.7-flash。这是正式稳定版,不是预览版。

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

这个项目里最要紧的是结构化输出和视频输入,因为 analyze 阶段就是喂一段视频,要它返回固定 schema 的 JSON。

换了模型之后不是改个字符串就完事,我拿真实素材跑了一遍 analyze_file 验证。对同一段讲座视频,两个模型给出的描述差别挺明显。

gemini-2.5-flash 版本:

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

gemini-3.7-flash 版本:

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

差别在于“工作描述”和“LINE 技术布道师”。3.7 真的读到了幻灯片上的小字,2.5 只晓得那里有工作信息。

汇总阶段的差距更大。同一批 COSCUP 素材,同一个 --theme,2.5 的 summary 是:“这个短视频希望展现 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 秒,clip 版本正好。

它不需要单独的 Vertex AI 项目,也不用申请白名单,同一个 Gemini API Key 就能用。但调用方式和 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) 这个函数实际干的事,就是把情绪标签和秒数拼成一句英文。mood_tags 来自 plan 阶段对素材分析的汇总,poc render --mood "Happy, Joyful, Celebration" 还可以再叠加想要的方向。

它是单轮生成,不能来回改。跟 Omni Flash 的视频编辑不一样,音乐生成完就定了。不满意只能重新提交提示词。所有生成的音频都带 SynthID 水印。

音乐比视频短的情况要自己处理。clip 版本最长 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"])

音乐生成失败(配额、网络、安全过滤)不会让整个 render 崩掉,只打印警告,然后回退成静音。这个原则后来写进了项目里的 CLAUDE.md:任何调用外部生成 API 的增值功能,都必须优雅降级,不能让主流程因为次要功能死掉。

ffmpeg 会静默剪坏视频

render 阶段用 ffmpeg 的 xfade 滤镜连接片段。每个 xfade 都要一个 offset 参数,表示输出时间轴上从这个秒数开始过渡。累计逻辑是:前面所有片段长度之和,减去每次过渡重叠的秒数。

第一版写完,单元测试全绿,真实素材也能出正常视频。然后 review 发现两种场景:ffmpeg 返回退出码 0,输出文件却是错的。

场景一:过渡比片段还长,片段被悄悄吞掉。两个 1 秒的片段,配上 transitions: "crossfade 2s",算出来的 offset 是 -1.000。ffmpeg 接受这个负数,不报错,正常结束。输出是一个 1 秒的视频,只有第一个片段,第二个整个消失。EDL.transitions 是自由文本字段,我手动改 YAML 时完全可能把 0.3s 打成 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,你可能直到完整看一遍才发现“等等,有一段没了”,然后毫无头绪从哪查起。

字幕:烧进去才能看见的两个问题

字幕来源是 EDL 里每个片段的 note,也就是 Gemini 写的入选理由。它本来就会给每段写一句描述,拿来当画面标题正合适。

实现不用 drawtext,而是生成 SRT 文件,再用 libass 的 subtitles 滤镜烧进去。原因是 drawtext 要手动处理中文字体路径和转义,冒号、逗号、单引号都会跟 filtergraph 语法打架。SRT 配 force_style 干净得多,指定 FontName=Noto Sans TC,fontconfig 就能找到中文字体。

第一个问题是同一屏出现两条字幕。第一版里每条字幕的显示区间就是它所属片段的起止时间。但相邻片段之间有 0.3 秒 crossfade 重叠,那 0.3 秒里会出现两行白字叠在一起,很丑。修法是把每条字幕的结束时间改成“下一个片段开始的时间”,保证任意时刻最多只有一条字幕可见。单元测试抓不到这个,因为 SRT 完全合法,ffmpeg 也烧录成功,我是看帧才发现的。

第二个问题是字幕全带省略号。note 是一整句描述,直接烧上去会占满屏幕,所以要做成短标题:在第一个逗号或句号处截断,没有标点就用字符数兜底,截断了就补一个省略号。

换成 Gemini 3.7 Flash 之后,这个规则失效了。3.7 喜欢把 note 写成“短标签:详细描述”的格式,比如开场镜头:展示 2024 COSCUP x UbuCon Asia 主视觉背景。冒号不在我的断句字符列表里,整句话就落到字符数兜底的路径,结果八条字幕全部以省略号结尾。

硬截断还有个毛病:不看英文单词边界。比如 Presenting the female speaker sharing presentation content about ChatGPT and Antigravity,截到第 20 个字符,会得到 ...and An...,一个英文单词被劈成两半。

两个问题都修了。冒号现在按标签分隔符处理,标签本身直接当完整标题,不加省略号。必须硬截断的时候,如果切断的位置落在一串连续英文字母或数字中间,就往回退到这段字符开头,整段丢掉,坚决不劈一半。同时字符数上限从 20 放宽到 24。

重新烧完之后,八条字幕变成了“开场镜头、会场实况、技术分享特写、会场彩蛋、社区摊位互动”这种干净的短标题,一条省略号都没有。

其他坑

files.upload() 返回不代表文件已经能用。这是 review 时翻 SDK 源码发现的,真要跑线上 API 会炸,测试却永远不炸。文件传完之后会停在 PROCESSING 状态好几秒,这时候拿去 generate_content,会得到 400 FAILED_PRECONDITION

更糟的是,我原来的重试逻辑让情况更坏。analyze_file 被包在重试里,每次重试都会把整个视频重新传一遍,然后立刻失败,三次之间只退了大概 3 秒。三遍之后素材进了 _errors.json,而 plan 阶段当时不读这个文件,素材就悄悄从成片里消失了。修法是加 wait_for_active(),上传之后轮询 client.files.get(),等状态变成 ACTIVE 再往下走,同时把上传挪到重试循环外面。

_errors.json 写了但没人读。analyze 勤勤恳恳记了失败素材,plan 不读,summary 里也不提。用户想发现只能去数成片里有没有少一段。现在 plan 会把失败列表附在 summary 末尾,明确说哪些素材没被收进来。

重复跑 ingest 会被旧分析结果坑。这是实际使用中遇到的,不是 review。我改了素材文件夹,加了些照片,删了些旧的,然后重跑 poc ingestcatalog.json 更新了,但 analysis/ 里还躺着已删除文件的分析结果。plan 读分析结果时没有跟当前 catalog 交叉比对,于是把过期的素材也喂给了 Gemini。模型理所当然地从里面挑了一段,但文件已经不存在,整个 plan 直接失败。现在 load_analyses() 会按 catalog 过滤,并打印哪些过期记录被忽略。

时间戳精度也有问题。format_timestamp 最初用 :04.1f,只保留一位小数。EDL 每次进出 YAML,都会丢掉最多 0.05 秒,按 30fps 算大概 1.5 帧,剪辑点会慢慢漂。我改成 :06.3f,保留毫秒精度。

回头看,这些问题分两类。files.upload 和 ffmpeg 静默错误是 review 时逐行读代码抓到的。字幕重叠、省略号、过期分析结果,这些只有真正跑代码、播放视频、换不同素材才会冒出来。测试全绿的时候,这三个问题还都藏在代码里。

总结

ReelCraft 现在做的事很简单:一个装满视频和照片的文件夹进去,一段带音乐和字幕的 9:16 竖屏短视频出来,中间有一份我可以手改的 YAML 文件。

这套架构里最值钱的是“单个文件理解,文本汇总”这个拆分。它本来是为了绕开 Omni Flash 不支持多视频推理,结果把时间戳精度、素材数量上限的问题也一起解决了。换成 Gemini 3.7 Flash 之后,素材理解的颗粒度明显变细,剪辑建议也跟着变好,这一块的收益比我调提示词拿到的还大。

剩下两件事还没做。Omni Flash 的单片段生成式精修留了一个空的 touch_up_clip 接口。字幕目前是从 note 自动来的,text_overlays 字段还是空的。音乐和字幕也都没有缓存,每次跑 render 都会重新生成。

参考链接

常见问题(FAQ)

Omni Flash 为什么不能理解多个视频?

Omni Flash是视频编辑模型,不是通用理解模型,其API schema只接受最长3秒的视频引用,且不支持跨多个视频推理。因此不能一次性分析多段视频,只能用于对单段视频进行编辑操作。

如何处理多视频素材并生成剪辑建议?

可以采用拆分策略:每个素材单独调用Gemini生成带时间戳描述,然后汇总文本结果再让Gemini规划剪辑,生成edl.yaml作为确认点,最后用ffmpeg渲染。这样既绕过多视频限制,又保证每步可重试和修改。

Gemini 2.5 和 3.7 在视频理解能力上有什么差别?

Gemini 3.7 Flash能读懂幻灯片上的小字,如讲者职位、活动名称,甚至识别照片中的物体细节;2.5只给出模糊描述。3.7的剪辑建议更具体,且note风格变为'短标签:描述',会影响字幕生成格式。

阿凯广州
本文由 阿凯 审核,最后更新于 2026年8月16日
联系编辑 →
← 返回文章列表
分享到:微博

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。