TOBATSU · SHARED SKILL · DRAFT

pip-compose-rendi

两路视频, 合成一路.

浮窗画中画 / 上下分屏 / 左右分屏. 走 Rendi, 本机 ffmpeg 兜底, 直推 R2. 跟 subtitle-burn-rendi 同套路, 不解析自然语言 — agent 把意图翻成 payload, skill 只接结构化字段.

主路
Rendi
兜底
ffmpeg local
输出
R2 / CDN
额外依赖

定位

解什么 · 不解什么

划清边界, 让 skill 单一职责, 别成"啥都管"的怪物

  • 两路视频按几何参数合成一路
  • 三种布局: 浮窗 / 上下分屏 / 左右分屏
  • 每路独立取景 (offset + zoom)
  • 每路独立音量, 自动 mix
  • 异形 mask (圆 / 方 / 竖矩 / 横矩)
  • 缺音轨自动补静音, 不挂
不解
  • 不解析自然语言 — agent 层做
  • 不做主体检测 / 自动避让 — agent 看图决定
  • 不做 PIP 循环 / 模糊背景填充
  • 不允许选输出比例 — 跟主视频
  • 不做跨画幅 sample 校验 — PIP 任意比例

默认布局

啥都不传, 长这样

本地 ffmpeg 跑了一遍默认 filter graph 的实际帧, 跟 prototype 02 的视觉一致

默认布局渲染帧

配置 · 输出

  • 主视频 1080×1920, 时长 59.07s
  • 圆形 PIP 在右上, 直径占短边 34% (367×367)
  • 距上 / 距右各 22px (= 2% × 1080)
  • 抗锯齿圆边 (1px 渐变, 无锯齿)
  • PIP 内容 cover 中心裁切
  • 音轨: 主 100%, PIP 0% (本次测试关掉)
FYI
前端 prototype 的初始状态也是这套. agent / 前端有别的意见就改 payload 字段, 没意见直接用.

布局

三种合法形态

layout 字段决定走哪条 — 其他字段只对当前 layout 生效

overlay
浮窗 PIP
主视频满帧, PIP 浮窗叠在上面. 位置 4 角预设 + 自定义, 形状 4 选 1, 大小 12-80%.
split_v
上下分屏
主 / PIP 上下拼. PIP 在哪一侧 + 占比 (20-50%) 可调.
split_h
左右分屏
主 / PIP 左右拼. 同理 PIP 哪一侧 + 占比.
EDGE
position=custom 必须同时给 overlay_x + overlay_y, 互斥校验 fail 直接 invalid_layout_param.

入参

Payload 字段表

必填两个 URL, 其他全部可省, 缺啥补啥

视频源

字段类型说明
main_video_urlREQurl主视频, 公网可下
pip_video_urlREQurl副视频 (通常是数字人 / 解说画面)

布局

字段类型取值 · 默认
layoutenumoverlay / split_v / split_h overlay
overlay_positionenumtl / tr / bl / br / custom tr
overlay_sizefloat0.12 - 0.80 (短边占比) 0.34
overlay_shapeenumcircle / square / vrect / hrect circle
overlay_marginfloat0 - 0.10 0.02
overlay_x / overlay_yfloat0 - 1.0, custom 时必填
split_pip_sideenumfirst / second second
split_pip_ratiofloat0.20 - 0.50 0.40

取景 · 音量

字段类型取值 · 默认
main_framingobject \| null{offset_x:-1..1, offset_y:-1..1, zoom:0.30..3.20} null = cover
pip_framingobject \| null同上
main_volumefloat0 - 1.0 1.0
pip_volumefloat0 - 1.0 1.0

处理

跑一遍 compose.py 的全过程

从 payload 到 R2 URL, 10 步

01
读 payload.json + merge 默认
缺字段补 skill 内置默认值, 形成完整 config
02
Validate
字段类型 + 范围 + 互斥检查 (e.g. position=custom 必须有 x/y)
03
下载两路视频
公网 URL 拉到 inputs/, 3 次重试, 全程加 User-Agent
04
ffprobe 两路
拿 width / height / duration / has_audio
05
算 canvas + boxes
canvas = main 尺寸. 按 layout 解出 main_box / pip_box 的 (x, y, w, h)
06
Build filter graph
crop + scale (含 framing) → mask (异形) → overlay / hstack / vstack → amix audio
07
Rendi 提交 (扁平 zip)
10s poll, 600s 超时. 失败立即切兜底
08
本机 ffmpeg 兜底
用同一份 filter graph 在本机跑. Rendi + 本机都挂 → provider_failed
09
上传产物到 R2
tobatsu-agent output upload, 推两份: 视频 + filter-graph.txt
10
写 result.json + done
provider / rendi_command_id / 尺寸 / 时长 / layout_resolved / config_used 全部落盘

渲染

Filter graph 范式

三种 layout 对应三套 filter chain, debug 时会落盘成 filter-graph.txt

浮窗 圆形 (默认)

ffmpeg# 主 1080x1920, PIP 占短边 34% = 367x367 圆, 右上角距边 22px
[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1[main];
[1:v]scale=367:367:force_original_aspect_ratio=increase,crop=367:367,
     format=yuva420p,
     geq=lum='p(X,Y)':a='clip((183-hypot(X-183,Y-183))*255,0,255)'[pip];
[main][pip]overlay=x=691:y=22[v];
[0:a]volume=1.0[a0];[1:a]volume=1.0[a1];[a0][a1]amix=inputs=2:duration=first[a]

geq 那行用 (R - distance) × 255 clip 到 0-255 = 抗锯齿圆边 (距离边缘 1px 内做渐变). 不用 if(lt(...),255,0) 那种硬边.

上下分屏 (PIP 在下, 占 40%)

ffmpeg[0:v]scale=1080:1152:force_original_aspect_ratio=increase,crop=1080:1152[top];
[1:v]scale=1080:768:force_original_aspect_ratio=increase,crop=1080:768[bot];
[top][bot]vstack=inputs=2[v];
[0:a]volume=1.0[a0];[1:a]volume=1.0[a1];[a0][a1]amix=inputs=2:duration=first[a]

左右分屏 (PIP 在右, 占 40%)

ffmpeg[0:v]scale=648:1920:force_original_aspect_ratio=increase,crop=648:1920[left];
[1:v]scale=432:1920:force_original_aspect_ratio=increase,crop=432:1920[right];
[left][right]hstack=inputs=2[v]; ...
PASS
第一种已经在本机跑通验视觉 (见上一节). 后两种 filter chain 设计同理, compose.py 写完会逐一跑.

出参

result.json + 上传产物

两份产物 + 一份元数据, 上游拿 URL 直接渲染

result.json

json{
  "status": "ok",
  "provider": "rendi",
  "fallback_used": false,
  "rendi_command_id": "f004b6a5-...",
  "output_video_url": "https://cdn-tobatsu/.../composed-video.mp4",
  "filter_graph_url":  "https://cdn-tobatsu/.../filter-graph.txt",
  "output_width": 1080,
  "output_height": 1920,
  "output_duration_seconds": 59.20,
  "layout_resolved": {
    "layout": "overlay",
    "canvas": { "w": 1080, "h": 1920 },
    "main_box": { "x": 0, "y": 0, "w": 1080, "h": 1920 },
    "pip_box":  { "x": 691, "y": 22, "w": 367, "h": 367 },
    "pip_shape": "circle"
  },
  "config_used": { /* merge 后完整 config */ }
}

上传产物 (label)

composed-video.mp4 filter-graph.txt

filter-graph.txt 是 debug 用, 把实际下发到 ffmpeg 的 filter 字符串落盘, 出问题能复现.

失败

全部 hard-fail · 不返 error code

跟 subtitle skill 一致: fail 前写 evidence 到 outputs/.logs/compose-error.json, 再 tobatsu-agent fail

reason触发
invalid_payload字段缺 / 类型错 / JSON 解析失败
invalid_layout_param数值超范围 / 互斥字段冲突
download_failed任一视频拉不到 (3 次重试后)
unreadable_videoffprobe 失败 / 无视频流
provider_failedRendi + 本机 ffmpeg 都跑挂
upload_failedR2 上传 3 次重试都挂

对比

跟 subtitle-burn-rendi 的关系

同套路 ≠ 同输入. 两个 skill 共用基础能力, 但解决的事不一样

subtitle-burn-rendipip-compose-rendi
解决一路视频 + SRT/ASS → 烧字幕两路视频 → 合成一路
模式Mode A / B / C单一: 默认 + payload 覆盖
字体装 8 个 (Montserrat + Noto 系)
Python venv需要 (pysubs2)不需要 (标准库)
共用Rendi 主 + 本机兜底 / 扁平 zip / 10s poll / 600s cap / User-Agent / fail evidence / tobatsu-agent output upload / Python 3.10 兼容
SAVING
PIP 这个 skill 的 INSTALL.md 几乎为空 — 只要确认 ffmpeg + RENDI_API_KEY 就行. 之前 magi3 已经装过这两样, 这个 skill 上线不需要再动 worker.

清单

文件结构 · 当前状态

skill 包的目录长这样

skills/shared/pip-compose-rendi/ ├── SKILL.md // done · 契约 + 范式 + 默认值 ├── INSTALL.md // todo · ~20 行 ├── scripts/ │ └── compose.py // todo · ~600 行 └── references/examples/ ├── overlay-circle.json // todo · 默认布局示例 ├── overlay-custom-rect.json // todo · 自定义位置 + 矩形 ├── split-v.json // todo · 上下分屏 └── split-h.json // todo · 左右分屏
— 默认 filter graph 已用 ffmpeg 本地跑通, 视觉对得上 prototype 02.