为什么您的视频预览在导出时会出错
您的视频编辑器看起来已完成。预览清晰,字幕位于用户拖动到的确切位置,时间轴平滑滚动。然后用户按下“导出”,字幕却出现在错误的位置,大小不对,有时甚至完全超出画面边缘。没有崩溃,日志中也没有错误——只是预览和文件不一致。
这是所有视频编辑器中最常见的错误,它是一个数据建模问题,而非渲染问题。下面介绍了一种能杜绝此错误的方法、如何根据宽高比正确调整帧大小,以及如何在低端手机上保持快速拖动。这些理念适用于任何语言或 UI 框架。
宽高比是形状,分辨率是大小
大多数帧错误都源于此。宽高比只描述帧的形状:9:16 是高型,16:9 是宽型。分辨率描述像素大小,例如 1080x1920。一种形状支持多种大小,因此这两个值绝不能互换。
| 宽高比 | 数值 | 典型用途 |
| 16:9 | 1.78 | YouTube,横屏网页 |
| 9:16 | 0.56 | Reels, TikTok, Shorts |
| 1:1 | 1.00 | 方形信息流帖子 |
| 4:5 | 0.80 | 竖屏信息流帖子 |
将宽高比存储为纯文本,仅在进行数学计算时将其转换为数字。存储导出目标高度,然后根据宽高比推导出宽度,并在数据到达编码器之前强制这两个数字为偶数。奇数尺寸是许多导出失败的隐秘原因。
height = width / ratioToNumber(ratio) // 调整预览框大小
function widthFromHeight(ratio, height): raw = height * ratioToNumber(ratio) return makeEven(round(raw)) // 编码器要求边长为偶数
// 9:16 at 1080 tall -> 608 x 1080// 16:9 at 1080 tall -> 1920 x 1080
将位置存储为分数,而非像素
一个编辑器存在于三个不同大小的世界中:保存的文档、屏幕预览和导出的文件。文档是唯一的真实来源——其他两个只是以不同比例渲染的文档。
Project -> Clip (源文件, 裁剪, 滤镜) -> Overlay (文本 / 贴纸)
文档 预览渲染器 导出渲染器 x = 0.5, y = 0.9 --> x * 360 px --> x * 1080 px --> MP4 | ^ ^ +------------------------+------------------------+ 一个存储分数,一个公式,两种尺寸
如果您存储 540 像素,该数字仅在测量它的设备上是正确的。如果您存储 0.5,它将永远表示在任何尺寸下的“水平中心”。模型中的每个位置、偏移和比例都应该是一个介于 0 和 1 之间的分数。
type Overlay { x: number // 0.0-1.0 横向 (0.5 = 居中) y: number // 0.0-1.0 纵向 (0.9 = 接近底部) scale: number // 1.0 = 正常大小 rotation: number // 角度 startTimeMs: number // 何时出现 endTimeMs: number // 何时消失}
这样,导出过程变得几乎无趣,而这正是重点。渲染器使用与预览相同的公式,只是乘数更大:px = overlay.x * exportWidth。FFmpeg 处理帧本身,其居中表达式 (ow-iw)/2 与预览用于信箱模式(letterbox)的算法相同。由于存储值从未改变,字幕会精准地落在正确的位置。
ffmpeg -i input.mp4 \ -vf "scale=1080:1920:force_original_aspect_ratio=decrease,\ pad=1080:1920:(ow-iw)/2:(oh-ih)/2:0xD3D3D3" \ -c:v libx264 -crf 23 -pix_fmt yuv420p -c:a aac output.mp4
单次渲染预览
在运行时测量预览框而不是硬编码它,因为手机、平板电脑和调整大小的桌面窗口都会给出不同的尺寸。在一次画布渲染中绘制所有叠加层,而不是将每个叠加层作为单独的 UI 元素进行挂载——单次渲染在手指移动时也能保持流畅。
两条规则保持循环的正确性。跳过任何超出其时间范围的叠加层,并始终将 save() 与 restore() 配对使用,这样可以防止一个项目的变换影响到下一个。
function drawPreview(canvas, overlays, currentTime, box): for each overlay in overlays: if currentTime < overlay.startTimeMs: skip if currentTime > overlay.endTimeMs: skip
px = overlay.x * box.width // 关键公式 py = overlay.y * box.height
canvas.save() canvas.move(px, py) canvas.rotate(overlay.rotation) canvas.resize(overlay.scale) canvas.drawText(overlay.content) canvas.restore() // 绝不能省略
使用两层状态保持拖动流畅
拖动手指每秒报告大约六十个位置。如果每个位置都写入您的项目存储,您将每秒付出六十次验证、持久化和完整状态重建的代价,导致拖动明显卡顿。因此,将工作分为两层:
- 第一层,瞬时:一个小的
liveDrag映射,用于存储手指当前位置。每次移动时更新它并重绘画布。不运行其他任何操作。 - 第二层,持久:在拖动结束时,将最终分数提交到项目模型中的叠加层,清除
liveDrag条目,并记录一个撤销步骤。
这种回报不仅限于帧率。撤销历史保持有用,因为一次拖动只产生一个条目而不是数百个,并且自动保存停止对磁盘的频繁写入。这就是 Figma 和 Canva 保持直接操作响应性的方式。
实际应用背景
在 MicrocosmWorks,我们接手了一个短视频编辑器,其字幕在导出时出现偏移。团队曾将叠加层位置作为从预览画布直接读取的设备像素进行持久化存储,导致在小手机上制作的字幕在 1080p 文件中显示偏高且偏小,而切换宽高比时,一些叠加层甚至完全移出画面。
我们将模型迁移到标准化的 0 到 1 坐标,使两个渲染器共享一个分数乘以尺寸的辅助函数,并强制输出尺寸为偶数,且该尺寸由存储的宽高比推导而来。在所有测试设备上,预览和导出都保持一致。将拖动提交操作移至拖动结束时,消除了用户在低端 Android 硬件上报告的延迟。您可以在我们的项目组合中看到这种短视频和编辑工作的成果。
结论
预览和导出是同一文档的两种渲染,因此应赋予它们一个单一的真实来源。将位置存储为分数,在两个渲染器中使用相同的分数乘以尺寸公式,从宽高比推导宽度,保持尺寸为偶数,并且仅在手指抬起时提交拖动更改。这五种习惯在错误发生之前就消除了整类问题。
在 MicrocosmWorks,构建交互式视图和最终文件一致的媒体引擎是我们视频和流媒体工程工作的核心重点。
正在发布视频或内容编辑器,并希望预览和导出真正一致? 我们已经在短视频编辑工具中解决了这类精确的错误。请联系我们的工程团队 →

