Hotdry.

Article

逆向工程 Mixbook 私有 API 并用 FFmpeg 重建视频渲染管道

从 minified JS 中提取 Redux thunk 定位私有 API,解析 Lottie 动画定义,用 FFmpeg xfade/zoompan 重建完整视频渲染管道的技术实践。

2026-05-27systems

Mixbook Movies 是一项将用户照片与音乐组合成动画短片的服务。当项目完成后,用户收到邮件通知,可以在线观看或订购实体影集,却找不到下载按钮。官方文档明确说明:"暂不支持下载功能。"

这个 "缺失的功能" 背后藏着一个有趣的架构决策 —— 视频并非预渲染的 MP4 文件,而是由浏览器在每次播放时实时合成的。本文将完整记录如何通过逆向工程提取私有 API 数据,并用 FFmpeg 重建一条可落地的视频渲染管道。

浏览器端实时渲染的架构洞察

最初的自然反应是检查页面源码寻找视频 URL。但公开分享链接返回的 HTML 中不存在任何 .mp4.m3u8videoUrl—— 它只是一个 Next.js 应用外壳,真正的渲染逻辑在客户端执行。

深入分析发现,Mixbook 将 "Memory Explorer" 拆分为独立的子应用,部署在 memories.mixbook.com。该域名下的预览页面包含 React Server Components 载荷,其中引用了名为 AnimatedProject 的组件。这个组件会在 hydrate 后发起数据请求,而视频定义就藏在这个请求的响应里。

从混淆代码中提取 API 端点

面对 minified 的 Next.js bundle,直接阅读几乎不可能。更有效的方法是通过特征字符串定位关键代码。在包含 animatedProject 的代码块中,发现了 Redux Toolkit 的 async thunk 定义:

let h = (0, n.hg)("animatedProject/fetchAnimatedProject", async (t, e) => {
  let { projectId: i, viewKey: n } = t,
      a = o().auth.token,
      u = "".concat(s.l.apiBaseUrl, "/api/v2/my/animated_projects/").concat(i);
  return n && (u += "?".concat(new URLSearchParams({ vk: n }))),
    (await r.L.get(u, { token: a })).data.data;
});

结合同一 bundle 中的环境配置 production: { apiBaseUrl: "https://www.mixbook.com" },完整的 API 端点浮出水面:

GET https://www.mixbook.com/api/v2/my/animated_projects/{projectId}?vk={viewKey}

viewKey 正是公开分享链接中的访问令牌。使用自己的分享链接调用该端点,返回约 174KB 的 JSON—— 这就是构建视频所需的全部素材定义。

解析视频定义的 JSON 结构

API 响应揭示了一个关键事实:Mixbook 存储的不是视频文件,而是视频 "配方"。数据结构包含:

  • 元数据:项目名称、总帧数(2598.96 帧 ≈ 108.3 秒 @ 24fps)
  • 音乐轨道:音频文件的 S3 URL
  • 片段数组:43 个片段,每个包含完整的 Lottie 动画定义
  • 转场数组:42 个转场效果定义

每个 Lottie 片段都是 1920×1080、24fps 的矢量动画,用户的照片作为 assets 数组中的资源嵌入。这种架构的优势显而易见:存储成本低、支持实时编辑、分辨率无关。但对想要本地副本的用户而言,意味着必须自行重建渲染管道。

FFmpeg 重建管道的四个迭代版本

V1:基础交叉淡入淡出幻灯片

首要目标是生成 1080p 视频,照片按正确顺序播放,总时长与原始音乐匹配(108.3 秒)。核心计算在于确定每张照片的显示时长。

假设 N 张照片,转场重叠时间为 T,目标总时长为 TOTAL,则每张照片的净显示时间 D 满足:

TOTAL = N·D - (N-1)·T

代入 N=42、T=0.8s、TOTAL=108.3s,得 D≈3.36s,步进间隔 step≈2.56s。

FFmpeg 的 xfade 滤镜链实现交叉淡入淡出,关键细节是第 n 个转场的起始偏移量为 n·step,因为每个转场消耗 T 秒的重叠时间:

# 标准化为 1920x1080、30fps、YUV420P
scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p

# xfade 链(第 i 个转场在 i·step 秒处开始)
xfade=transition=fade:duration=0.8:offset={i*2.56}

音频处理使用 atrim 裁剪到目标时长,配合 afade 实现最后 2 秒淡出:

atrim=0:108.3,asetpts=PTS-STARTPTS,afade=t=out:st=106.3:d=2

V2:Ken Burns 动态效果

静态照片显得平淡,需要添加缓慢推近 / 平移的 Ken Burns 效果。FFmpeg 的 zoompan 滤镜可实现,但存在一个隐蔽的陷阱:默认行为会在输入帧循环时重置缩放参数,导致画面抖动。

解决方案是强制 zoompan 只读取一帧输入,然后生成整个片段的所有输出帧:

select='eq(n\,0)',zoompan=z='...':x='...':y='...':d=101:s=1920x1080:fps=30

其中 d 设置为片段的总帧数(3.36s × 30fps ≈ 101 帧)。缩放表达式使用输出帧计数器 on 确保平滑过渡:

# 推近效果:从 1.0 缩放到 Z(如 1.15)
zin = f"1.0+{(Z-1)/DF:.6f}*on"

# 四种预设循环:居中推近、居中拉远、推近右移、推近下移
presets = [
    (f"min({Z},{zin})",  cx, cy),
    (f"max(1.0,{zout})", cx, cy),
    (f"min({Z},{zin})",  f"(iw-iw/zoom)*on/{DF}", cy),
    (f"min({Z},{zin})",  cx, f"(ih-ih/zoom)*on/{DF}"),
]

V3:文字叠加的迂回方案

原始视频包含标题卡片(项目名称)和结尾署名("Experience Your Memories")。这些文字存储在 Lottie 的 ty: 5 类型图层中。

直接使用 FFmpeg 的 drawtext 滤镜遭遇障碍:Homebrew 预编译版本未启用 libfreetype。验证方法:

ffmpeg -hide_banner -version | grep -o enable-libfreetype  # 无输出表示未启用

替代方案:使用 Python Pillow 将文字渲染为透明 PNG,然后通过 overlay 滤镜合成。这种方法的优势是完全控制字体、阴影和布局:

def render_card(lines, path, fonts, gap=104):
    img = Image.new("RGBA", (1920, 1080), (0, 0, 0, 0))
    d = ImageDraw.Draw(img)
    y0 = 1080 // 2 - gap * (len(lines) - 1) // 2
    for i, (txt, f) in enumerate(zip(lines, fonts)):
        cy = y0 + i * gap
        # 阴影层
        d.text((964, cy + 4), txt, font=f, fill=(0, 0, 0, 150), anchor="mm")
        # 文字层
        d.text((960, cy), txt, font=f, fill=(255, 255, 255), anchor="mm")
    img.save(path)

PNG 作为独立视频输入,通过 fade 滤镜控制 alpha 通道的淡入淡出,再用 overlay 在指定时间窗口内合成:

# 标题淡入淡出(alpha 通道)
fade=t=in:st=0.3:d=0.5:alpha=1,fade=t=out:st=2.0:d=0.5:alpha=1

# 在 0.2-2.6 秒区间叠加
overlay=0:0:enable='between(t,0.2,2.6)'

V4:时间同步优化

初始版本的标题显示 5 秒,横跨前两张照片。优化策略是将文字淡出时间对齐照片切换节奏。由于照片每 2.56 秒切换一次,标题必须在 2.56 秒前淡出:

# 标题:0.3 秒淡入,2.0 秒开始淡出,确保 2.56 秒前完全消失
fade=t=in:st=0.3:d=0.5:alpha=1,fade=t=out:st=2.0:d=0.5:alpha=1

可复用的工程参数清单

基于上述实践,以下是可直接落地的参数配置:

参数项 推荐值 说明
输出分辨率 1920×1080 与原始 Lottie 画布一致
帧率 30fps 略高于原始的 24fps,兼容性更好
转场时长 0.8s 交叉淡入淡出的重叠时间
单张照片净时长 ~2.56s 基于 42 张照片、108.3 秒总时长计算
Ken Burns 缩放范围 1.0→1.15 15% 的缓慢推近
音频淡出时长 2s 在总时长结束前 2 秒开始淡出
文字淡入 / 淡出 0.5s 与照片切换节奏对齐

局限性与边界

重建结果在以下维度与原始存在差异:

  • Lottie 动画:无法精确复现 Mixbook 的专有缓动曲线和转场效果,只能使用 FFmpeg 的标准滤镜近似
  • 字体:使用 Arial Bold 替代 Mixbook 的 Proxima Nova,标题换行逻辑也略有调整
  • 动态元素:Lottie 中的复杂矢量动画和遮罩效果无法直接转换

伦理边界同样重要:上述方法仅适用于提取自己的项目数据。viewKey 是用户分享链接中的访问令牌,与访问他人内容的权限边界一致。

结论

这次逆向工程揭示了几个可复用的技术模式:

  1. 当页面没有媒体文件时,读取应用代码。通过 grep 混淆 JS 中的特征字符串(如 animatedProject),可以快速定位数据获取逻辑和 API 端点。

  2. "无下载" 往往意味着 "按需渲染"。现代 Web 应用越来越多地采用客户端合成架构,理解这一点有助于设计对应的提取策略。

  3. 将数据 API 视为一等接口。一旦获得 /api/v2/my/animated_projects/{id} 端点,照片、音乐、文字和时序信息全部暴露。

  4. FFmpeg 的 zoompan 需要 select 配合select='eq(n\,0)' 是避免缩放抖动的关键技巧,值得在工具链中固化。

  5. 绕过缺失的滤镜。当 drawtext 不可用时,预渲染透明 PNG 并通过 overlay 合成是更便携的替代方案。

最终输出是一个 1080p、带 Ken Burns 动态效果、音乐同步淡出、包含标题和署名的完整视频文件 —— 完全基于开放工具链和逆向工程获得的数据定义重建而成。


参考来源

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com