【音视频】剪辑 SDK(裁剪/旋转/变速)

采集、处理、编码、推流、播放全打通了,但用户真正的高频需求其实是一个字——「剪」。拍完的 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 卡 500msI 帧变稀疏,seek 落在 P 帧SEEK_TO_CLOSEST_SYNC + 加速超过 2x 建议重编码
4帧级预览内存暴涨连续 seek 内存飙到 1GBgenerateCGImageAsynchronously 未取消旧任务维护当前 task 引用,新 seek 到来先 cancel 旧的
5裁剪边界出现花屏起点非关键帧passthrough 起点落在 P 帧起点向前对齐到最近 I 帧,终点向后对齐
6双端时长不一致iOS/Android 导出时长差 1 帧时间精度:iOS 用 Double 秒,Android 用 Long ms统一时间基准为微秒 (us),两端换算

8、性能对比

操作方案100MB 视频耗时是否重编码
旋转 90°transform passthrough2.1s
裁剪 50%timeRange passthrough1.8s
变速 2xMediaExtractor + Muxer3.5s
旋转 + 裁剪 + 变速全链路重编码18s

结论: 能不重编码就不重编码。旋转和裁剪都能走 passthrough 快路径,只有变速(和滤镜)才需要付出重编码的代价。把「快路径」和「慢路径」在架构上分开,是剪辑 SDK 性能的关键。

学习和提升音视频开发技术,欢迎你加入我们的知识星球

【音视频】剪辑 SDK(裁剪/旋转/变速)

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

(0)

相关推荐