采集、处理、编码、推流、播放全打通了,但用户真正的高频需求其实是一个字——「剪」。拍完的 3 分钟视频要裁掉开头 5 秒、转 90°、加 1.5 倍速。本文用 Claude Code 写一个移动端剪辑 SDK,覆盖裁剪、旋转、变速三大核心能力,双端一致。
1、剪辑能力——被产品低估的「留存引擎」
很多团队把精力都花在「拍摄画质」上,却忽略了「剪辑」才是用户二次传播的关键:
- ✂️ 裁剪:去掉开头空镜、结尾黑屏,用户平均会剪掉 30% 时长
- 🔄 旋转:横屏拍的视频发到竖屏平台,90% 用户需要旋转矫正
- ⏩ 变速:卡点视频、教程加速、慢动作回放,全靠变速
- 🎯 帧级精准:用户要在「这一帧」精确切割,误差 >100ms 就被骂「不跟手」
一个残酷的现实: 剪辑能力做不好,用户直接去用剪映/CapCut。拍摄能力再强,也留不住「拍完就想剪」的人。
2、剪辑 SDK 的架构分层
┌─────────────────────────────────────────────┐
│ 剪辑 SDK 分层 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 裁剪 │ │ 旋转 │ │ 变速 │ │ ← 业务能力层
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ┌────▼────────────▼────────────▼────┐ │
│ │ 时间轴模型 (Timeline) │ │ ← 数据模型层
│ └───────────────┬──────────────────┘ │
│ │ │
│ ┌───────────────▼──────────────────┐ │
│ │ 解码 → 处理 → 编码 管线 │ │ ← 执行引擎层
│ │ (AVAssetReader / MediaCodec) │ │
│ └───────────────┬──────────────────┘ │
│ │ │
│ ┌───────────────▼──────────────────┐ │
│ │ 导出器 (AVAssetWriter / Muxer)│ │ ← 输出层
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────┘
核心洞察: 剪辑不是「逐帧操作」,而是「对时间轴的声明式描述」。先建模 Timeline,再让引擎去执行。
3、时间轴模型:剪辑的数据抽象
3.1、Prompt
帮我设计一个移动端视频剪辑 SDK 的时间轴数据模型。
【要求】
- 支持多个剪辑片段 (Clip),每个片段有:源视频、起止时间、裁剪区域、旋转角度、播放速度
- 支持时间轴级别的:总时长计算、按时间点定位到具体片段的某帧
- 支持预览:根据时间轴当前播放位置,计算出「应该解码源视频的哪一帧」
- 双端一致的数据结构(iOS Swift / Android Kotlin 字段对齐)
【关注点】
- 变速后时间轴时间 vs 源视频时间的映射
- 旋转/裁剪与原始帧坐标的变换
- 边界情况:空时间轴、单片段、片段重叠
3.2、双端一致的时间轴模型
// Timeline.swift (iOS)
import CoreMedia
// 旋转角度枚举(双端一致)
enum Rotation: Int, Codable {
case r0 = 0 // 不旋转
case r90 = 90 // 顺时针 90°
case r180 = 180
case r270 = 270
}
// 单个剪辑片段
struct Clip: Codable {
let sourceURL: URL // 源视频路径
var sourceStart: Double // 源视频起始时间 (秒)
var sourceEnd: Double // 源视频结束时间 (秒)
var speed: Double // 播放速度 0.25x ~ 4.0x
var rotation: Rotation // 旋转角度
var cropRect: CGRect? // 裁剪区域 (归一化 0~1)
// 源片段实际时长(变速前)
var sourceDuration: Double { sourceEnd - sourceStart }
// 时间轴上的时长(变速后)
var timelineDuration: Double { sourceDuration / speed }
}
// 时间轴
struct Timeline: Codable {
var clips: [Clip] = []
// 时间轴总时长
var totalDuration: Double {
clips.reduce(0) { $0 + $1.timelineDuration }
}
/// 核心方法:时间轴时间 → (片段下标, 源视频时间)
/// 变速映射的关键:timelineTime * speed + sourceStart = sourceTime
func resolve(_ timelineTime: Double) -> (clipIndex: Int, sourceTime: Double)? {
var accumulated = 0.0
for (i, clip) in clips.enumerated() {
let clipTimelineDuration = clip.timelineDuration
if timelineTime < accumulated + clipTimelineDuration {
let offsetInClip = timelineTime - accumulated
// ✅ 变速映射:时间轴偏移 × 速度 = 源视频偏移
let sourceTime = clip.sourceStart + offsetInClip * clip.speed
return (i, sourceTime)
}
accumulated += clipTimelineDuration
}
return nil // 超出总时长
}
}
// Timeline.kt (Android)
enum class Rotation(val degrees: Int) {
R0(0), R90(90), R180(180), R270(270)
}
data class Clip(
val sourcePath: String,
val sourceStartMs: Long, // 源起始 (ms)
val sourceEndMs: Long, // 源结束 (ms)
val speed: Double, // 0.25x ~ 4.0x
val rotation: Rotation,
val cropRect: RectF? = null // 归一化 0~1
) {
val sourceDurationMs: Long get() = sourceEndMs - sourceStartMs
val timelineDurationMs: Long get() = (sourceDurationMs / speed).toLong()
}
class Timeline(val clips: MutableList<Clip> = mutableListOf()) {
val totalDurationMs: Long
get() = clips.sumOf { it.timelineDurationMs }
/** 时间轴时间 → (片段下标, 源视频时间 ms) */
fun resolve(timelineTimeMs: Long): Pair<Int, Long>? {
var accumulated = 0L
clips.forEachIndexed { i, clip ->
val dur = clip.timelineDurationMs
if (timelineTimeMs < accumulated + dur) {
val offsetInClip = timelineTimeMs - accumulated
val sourceTime = clip.sourceStartMs + (offsetInClip * clip.speed).toLong()
return i to sourceTime
}
accumulated += dur
}
return null
}
}
变速映射公式:
sourceTime = sourceStart + (timelineTime - clipStart) × speed。这是整个剪辑引擎的灵魂——预览和导出共用这一个映射,保证「所见即所得」。
4、裁剪 + 旋转:iOS 端执行引擎
4.1、Prompt
帮我写 iOS 端视频裁剪 + 旋转 + 导出的执行引擎。
【输入】一个 Clip(源 URL、起止时间、旋转角度、裁剪区域)
【输出】导出一个新视频文件
【要求】
- 使用 AVAssetReader + AVAssetWriter 做零拷贝重封装
- 旋转用 AVAssetWriterInput 的 transform 属性实现(不重编码像素)
- 裁剪时间用 timeRange 实现
- 如果只有旋转 + 裁剪,不重编码,直接 passthrough(快)
- 中文注释,async/await 接口
4.2、iOS 零重编码剪辑(快路径)
// FastEditor.swift (iOS)
// 旋转 + 裁剪:走 passthrough 快路径,不重编码像素
import AVFoundation
enum FastEditorError: LocalizedError {
case readerFailed
case writerFailed(String)
var errorDescription: String? {
switch self {
case .readerFailed: return "AVAssetReader 创建失败"
case .writerFailed(let m): return "AVAssetWriter 失败: \(m)"
}
}
}
final class FastEditor {
/// 快速导出(仅旋转/裁剪/掐头去尾,不重编码)
func export(clip: Clip, to outputURL: URL) async throws {
let asset = AVURLAsset(url: clip.sourceURL)
guard let reader = try? AVAssetReader(asset: asset) else {
throw FastEditorError.readerFailed
}
// 1. 读取视频轨
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
throw FastEditorError.writerFailed("无视频轨道")
}
let audioTrack = asset.tracks(withMediaType: .audio).first
// 2. 设置时间范围(裁剪:掐头去尾)
let timeRange = CMTimeRange(
start: CMTime(seconds: clip.sourceStart, preferredTimescale: 600),
duration: CMTime(seconds: clip.sourceDuration, preferredTimescale: 600)
)
// 3. 视频轨 Reader(passthrough,输出原始压缩数据)
let videoOutput = AVAssetReaderTrackOutput(
track: videoTrack,
outputSettings: nil // nil = 不解码,直接输出压缩包
)
videoOutput.alwaysCopiesSampleData = false
reader.add(videoOutput)
reader.timeRange = timeRange
// 4. 音频轨 Reader(可选)
var audioOutput: AVAssetReaderTrackOutput?
if let audioTrack = audioTrack {
audioOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: nil)
audioOutput?.alwaysCopiesSampleData = false
reader.add(audioOutput!)
}
// 5. Writer + 旋转 transform
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
let videoInput = AVAssetWriterInput(
mediaType: .video,
outputSettings: nil // nil = passthrough
)
videoInput.expectsMediaDataInRealTime = false
// ✅ 旋转的核心:transform 属性(不重编码像素,只改矩阵元数据)
videoInput.transform = rotationTransform(clip.rotation)
writer.add(videoInput)
// 6. 音频 passthrough
var audioInput: AVAssetWriterInput?
if let audioOutput = audioOutput {
audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
audioInput?.expectsMediaDataInRealTime = false
writer.add(audioInput!)
}
// 7. 开始读写
reader.startReading()
writer.startWriting()
writer.startSession(atSourceTime: timeRange.start)
// 8. 视频数据搬运(双队列并发)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { await self.copy(videoOutput, to: videoInput, writer: writer) }
if let audioOutput, let audioInput {
group.addTask { await self.copy(audioOutput, to: audioInput, writer: writer) }
}
try await group.waitForAll()
}
await writer.finishWriting()
if writer.status != .completed {
throw FastEditorError.writerFailed(writer.error?.localizedDescription ?? "未知错误")
}
}
// 数据搬运循环
private func copy(
_ output: AVAssetReaderTrackOutput,
to input: AVAssetWriterInput,
writer: AVAssetWriter
) async {
while input.isReadyForMoreMediaData {
guard let buffer = output.copyNextSampleBuffer() else { break }
input.append(buffer)
}
input.markAsFinished()
}
// 旋转矩阵映射
private func rotationTransform(_ r: Rotation) -> CGAffineTransform {
switch r {
case .r0: return .identity
case .r90: return CGAffineTransform(rotationAngle: .pi / 2)
case .r180: return CGAffineTransform(rotationAngle: .pi)
case .r270: return CGAffineTransform(rotationAngle: -.pi / 2)
}
}
}
5、变速:双端慢路径(必须重编码)
变速无法 passthrough(改变了时间戳),必须重编码。下面是 Android 端变速实现。
5.1、Android MediaExtractor + MediaMuxer 变速
// SpeedChanger.kt (Android)
// 变速导出:MediaExtractor 读 → 改时间戳 → MediaMuxer 写
import android.media.*
class SpeedChanger {
fun changeSpeed(
clip: Clip,
outputPath: String,
onProgress: (Float) -> Unit
) {
val extractor = MediaExtractor()
extractor.setDataSource(clip.sourcePath)
// 1. 找到视频/音频轨道索引
var videoTrackIndex = -1
var audioTrackIndex = -1
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
when {
mime.startsWith("video/") && videoTrackIndex < 0 -> videoTrackIndex = i
mime.startsWith("audio/") && audioTrackIndex < 0 -> audioTrackIndex = i
}
}
val muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var videoOutIndex = -1
var audioOutIndex = -1
// 2. 逐轨道处理
processTrack(
extractor, muxer, videoTrackIndex,
clip, isVideo = true
) { idx -> videoOutIndex = idx }
processTrack(
extractor, muxer, audioTrackIndex,
clip, isVideo = false
) { idx -> audioOutIndex = idx }
muxer.stop()
muxer.release()
extractor.release()
}
private fun processTrack(
extractor: MediaExtractor,
muxer: MediaMuxer,
trackIndex: Int,
clip: Clip,
isVideo: Boolean,
onTrackAdded: (Int) -> Unit
) {
if (trackIndex < 0) return
extractor.selectTrack(trackIndex)
val format = extractor.getTrackFormat(trackIndex)
val outIndex = muxer.addTrack(format)
onTrackAdded(outIndex)
// 源起始/结束时间 (us)
val startUs = clip.sourceStartMs * 1000
val endUs = clip.sourceEndMs * 1000
// 变速后的时间戳重映射
val speed = clip.speed
var outputPtsUs = 0L
val buffer = ByteBuffer.allocate(1 shl 20) // 1MB
val info = MediaCodec.BufferInfo()
extractor.seekTo(startUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
while (true) {
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
val sampleTimeUs = extractor.sampleTime
if (sampleTimeUs >= endUs) break
// ✅ 变速核心:输出 PTS = 源偏移 / speed
outputPtsUs = ((sampleTimeUs - startUs) / speed).toLong()
// 关键帧判断:变速后 I 帧位置变化,需要重新设置 sync flag
val flags = extractor.sampleFlags
info.set(0, sampleSize, outputPtsUs, flags)
muxer.writeSampleData(outIndex, buffer, info)
extractor.advance()
}
extractor.unselectTrack(trackIndex)
}
}
变速的坑: I 帧位置在变速后变稀疏或变密集。如果加速 4x,原本每 2 秒一个 I 帧,输出里变成每 8 秒一个,拖进度条会卡。正确做法: 加速时用
SEEK_TO_CLOSEST_SYNC保证 seek 落在关键帧上,或者干脆重编码。
6、裁剪交互:帧级精准预览
用户要「精确到这一帧」,预览是剪辑 SDK 最难做对的部分。
6.1、iOS 帧级 seek 预览
// FramePreviewer.swift (iOS)
// 根据时间轴时间,解码出对应帧用于预览
import AVFoundation
import UIKit
final class FramePreviewer {
private let asset: AVAsset
private let generator: AVAssetImageGenerator
init(sourceURL: URL) {
asset = AVURLAsset(url: sourceURL)
generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true // ✅ 自动应用视频方向
generator.requestedTimeToleranceBefore = .zero // ✅ 帧级精准,不做近似
generator.requestedTimeToleranceAfter = .zero
generator.maximumSize = CGSize(width: 720, height: 1280)
}
/// 取时间轴某时刻的帧(经过变速映射)
func frame(at timelineTime: Double, timeline: Timeline) async -> UIImage? {
guard let (_, sourceTime) = timeline.resolve(timelineTime) else { return nil }
let time = CMTime(seconds: sourceTime, preferredTimescale: 600)
return try? await withCheckedThrowingContinuation { cont in
generator.generateCGImageAsynchronously(for: time) { image, _, error in
if let image { cont.resume(returning: UIImage(cgImage: image)) }
else { cont.resume(throwing: error ?? NSError(domain: "Frame", code: -1)) }
}
}
}
}
精准 seek 的代价:
requestedTimeToleranceBefore/After = .zero会让每次 seek 都解码到目标帧,慢但准。预览缩略图可以用大 tolerance(快),主预览窗口用小 tolerance(准),两套 tolerance 分开配置。
7、踩坑记录
| # | 问题 | 现象 | 根因 | 修复 |
|---|---|---|---|---|
| 1 | 导出视频没声音 | 只有画面 | passthrough 时 audioOutput 为 nil 被跳过 | 判断 audioTrack != nil 时同步 add audioInput,漏一个就静音 |
| 2 | 旋转后画面被裁剪 | 90° 旋转后视频短一截 | transform 旋转后尺寸变化,但 writer 未更新 size | 旋转 90/270 时交换宽高写入 outputSettings |
| 3 | 变速后拖进度卡顿 | seek 卡 500ms | I 帧变稀疏,seek 落在 P 帧 | SEEK_TO_CLOSEST_SYNC + 加速超过 2x 建议重编码 |
| 4 | 帧级预览内存暴涨 | 连续 seek 内存飙到 1GB | generateCGImageAsynchronously 未取消旧任务 | 维护当前 task 引用,新 seek 到来先 cancel 旧的 |
| 5 | 裁剪边界出现花屏 | 起点非关键帧 | passthrough 起点落在 P 帧 | 起点向前对齐到最近 I 帧,终点向后对齐 |
| 6 | 双端时长不一致 | iOS/Android 导出时长差 1 帧 | 时间精度:iOS 用 Double 秒,Android 用 Long ms | 统一时间基准为微秒 (us),两端换算 |
8、性能对比
| 操作 | 方案 | 100MB 视频耗时 | 是否重编码 |
|---|---|---|---|
| 旋转 90° | transform passthrough | 2.1s | ❌ |
| 裁剪 50% | timeRange passthrough | 1.8s | ❌ |
| 变速 2x | MediaExtractor + Muxer | 3.5s | ❌ |
| 旋转 + 裁剪 + 变速 | 全链路重编码 | 18s | ✅ |
结论: 能不重编码就不重编码。旋转和裁剪都能走 passthrough 快路径,只有变速(和滤镜)才需要付出重编码的代价。把「快路径」和「慢路径」在架构上分开,是剪辑 SDK 性能的关键。
学习和提升音视频开发技术,欢迎你加入我们的知识星球

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。