问题描述

在实现视频”镜头推进”效果时,发现当设置中心点为 (0.5, 0.5) 时,预期画面应该聚焦到正中央,但实际效果却偏向左上角。

问题根源

核心问题:CGAffineTransform 的操作顺序错误

错误的实现(问题代码)

1
2
3
4
// ❌ 错误:先平移,再缩放
var transform = baseTransform
transform = transform.translatedBy(x: -cropX, y: -cropY) // 先平移
transform = transform.scaledBy(x: zoom, y: zoom) // 再缩放

问题表现:

  • 日志显示:平移 tx=-576.0, ty=-324.0
  • 缩放后:txty 的值没有变化,仍然是 -576.0, -324.0
  • 结果:画面偏移量不足,无法正确聚焦到目标中心点

正确的实现(修复代码)

1
2
3
4
// ✅ 正确:先缩放,再平移
var transform = baseTransform
transform = transform.scaledBy(x: zoom, y: zoom) // 先缩放
transform = transform.translatedBy(x: -cropX, y: -cropY) // 再平移

修复效果:

  • 缩放后在”放大的坐标系”中执行平移
  • 平移量会被自动放大,达到正确的偏移效果
  • 画面准确聚焦到目标中心点

深入理解:为什么顺序很重要?

1. CGAffineTransform 的矩阵本质

CGAffineTransform 本质上是一个 3x3 的仿射变换矩阵

1
2
3
4
5
┌          ┐
│ a b 0 │
│ c d 0 │
│ tx ty 1 │
└ ┘
  • a, d:缩放和旋转
  • b, c:旋转和倾斜
  • tx, ty:平移

2. 变换的组合顺序

在 Swift 中,transform.translatedBy().scaledBy() 看起来是”先平移后缩放”,但实际的矩阵乘法顺序是相反的

1
transform.translatedBy(x, y).scaledBy(sx, sy)

实际执行:

1
最终矩阵 = [缩放矩阵] × [平移矩阵] × [原始矩阵]

点的变换:

1
新坐标 = [缩放矩阵] × [平移矩阵] × [原始点]

这意味着:对点来说,先执行平移,再执行缩放

3. 两种顺序的差异

错误顺序:translatedBy 在前

1
transform.translatedBy(x: -576, y: -324).scaledBy(x: 2.5, y: 2.5)

变换过程:

1
2
3
1. 原始点:(960, 540)
2. 平移:(960 - 576, 540 - 324) = (384, 216)
3. 缩放:(384 × 2.5, 216 × 2.5) = (960, 540) ❌

问题: 点被平移后又被缩放拉回去了,相当于在缩放后的画面中,中心点并没有移动到正确位置!

正确顺序:scaledBy 在前

1
transform.scaledBy(x: 2.5, y: 2.5).translatedBy(x: -576, y: -324)

变换过程:

1
2
3
1. 原始点:(960, 540)
2. 缩放:(960 × 2.5, 540 × 2.5) = (2400, 1350)
3. 平移:(2400 - 576, 1350 - 324) = (1824, 1026) ✓

等效于: 在原始坐标系中,先将裁剪区域的左上角 (576, 324) 移到原点,然后放大 2.5 倍,让中心点 (960, 540) 正好在画面中央。

可视化示例

场景设定

  • 视频尺寸:1920 × 1080
  • 目标中心点:(0.5, 0.5) → (960, 540) 像素
  • 放大倍数:2.5×
  • 裁剪区域:768 × 432(居中于 960, 540)
  • 裁剪起点:(576, 324)

错误顺序的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
原始视频 (1920×1080)
┌─────────────────────────────┐
│ [裁剪区域] │
│ 768×432 │
│ 🏀 │ ← 中心点 (960, 540)
│ │
└─────────────────────────────┘

步骤1: 平移 (-576, -324)
┌─────────────────────────────┐
│ 🏀 │ ← 移到了 (384, 216)
│ │
└─────────────────────────────┘

步骤2: 缩放 2.5×
┌─────────────────────────────┐
│ │
│ 🏀 │ ← 又被拉回 (960, 540),偏左上
│ │
└─────────────────────────────┘
❌ 结果:画面没有正确聚焦

正确顺序的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
原始视频 (1920×1080)
┌─────────────────────────────┐
│ [裁剪区域] │
│ 768×432 │
│ 🏀 │ ← 中心点 (960, 540)
│ │
└─────────────────────────────┘

步骤1: 缩放 2.5×
┌───────────────────────────────────────────────┐
│ │
│ 🏀 │ ← (2400, 1350)
│ │
│ │
└───────────────────────────────────────────────┘

步骤2: 平移 (-576, -324)
┌─────────────────────┐
│ │
│ 🏀 │ ← (1824, 1026) - 正中央!
│ │
└─────────────────────┘
✓ 结果:画面完美聚焦到中心

关键要点

1. Transform 链式调用的反直觉特性

1
2
3
4
// 代码顺序:A → B
transform.A().B()

// 实际执行:B → A(对点的变换来说)

记忆技巧: 最右边的操作先作用于点,就像数学中的函数组合 f(g(x))

2. 坐标系的概念

  • 先缩放再平移:平移发生在”放大后的坐标系”中

    • 平移 -576 在 2.5× 坐标系中 ≈ 平移 -1440 在原始坐标系中
  • 先平移再缩放:平移发生在”原始坐标系”中

    • 平移 -576 就是 -576,缩放不会影响它

3. 为什么会犯这个错误?

直觉上会认为:

“我要先把画面移到正确位置,然后再放大”

但正确的思路应该是:

“我要放大画面,然后在放大的坐标系中调整位置”

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411

import AVFoundation
import CoreGraphics

// MARK: - 动态效果参数
struct DynamicEffectParams {
let startCenterX: CGFloat
let startCenterY: CGFloat
let endCenterX: CGFloat
let endCenterY: CGFloat
let fixedCenterX: CGFloat // 固定中心点 X
let fixedCenterY: CGFloat // 固定中心点 Y
let scaleFactor: CGFloat
let startTimeMs: Int
let endTimeMs: Int
let scoreTimeMs: Int
}

// MARK: - 关键帧数据
struct ZoomKeyframe {
let time: CMTime
let zoomFactor: CGFloat
let transform: CGAffineTransform
}

// MARK: - 动态缩放配置
struct DynamicZoomConfig {
// MVP 模式配置
static let fixedZoomStartOffset: Double = 2.8 // 进球前2.8秒开始
static let fixedZoomEndOffset: Double = 0.8 // 进球后0.8秒结束

// 完整版配置
static let minZoomFactor: CGFloat = 1.5 // 最小放大倍数
static let maxZoomFactor: CGFloat = 3.0 // 最大放大倍数
static let defaultZoomFactor: CGFloat = 2.5 // 默认放大倍数

// 时间计算配置
static let minZoomDuration: Double = 1.0 // 最小缩放时长(秒)
static let bufferTimeRatio: Double = 0.3 // 缓冲时间比例

// 缓动和关键帧配置
static let easingPower: Double = 0.7 // 缓动函数指数
static let keyframeInterval: Double = 0.05 // 关键帧间隔(秒)

// 中心点插值配置
static let centerPathEasingPower: Double = 0.5 // 中心点路径缓动指数
}

// MARK: - 动态缩放处理器
class DynamicZoomProcessor {
static let shared = DynamicZoomProcessor()
private let logger = Logger.shared

private init() {}

// MARK: - 公开接口

/// 应用动态缩放效果(完整版)
func applyDynamicZoom(
to layerInstruction: AVMutableVideoCompositionLayerInstruction,
params: DynamicEffectParams,
videoSize: CGSize,
baseTransform: CGAffineTransform,
videoDuration: Double
) -> Bool {
logger.info("DynamicZoomProcessor", "==== 开始应用动态缩放效果(完整版)====")
logger.info("DynamicZoomProcessor", "视频尺寸: \(videoSize.width) x \(videoSize.height)")
logger.info("DynamicZoomProcessor", "视频时长: \(videoDuration)秒")

// 1. 计算缩放参数
guard let zoomParams = calculateZoomParams(
params: params,
videoDuration: videoDuration
) else {
logger.warning("DynamicZoomProcessor", "⚠️ 缩放参数计算失败")
return false
}

logger.info("DynamicZoomProcessor", "缩放参数计算成功:")
logger.info("DynamicZoomProcessor", " - 起始中心点: (\(params.startCenterX), \(params.startCenterY))")
logger.info("DynamicZoomProcessor", " - 结束中心点: (\(params.endCenterX), \(params.endCenterY))")
logger.info("DynamicZoomProcessor", " - 固定中心点: (\(params.fixedCenterX), \(params.fixedCenterY)) [使用此点作为推进目标]")
logger.info("DynamicZoomProcessor", " - 最大缩放: \(zoomParams.maxZoom)x")
logger.info("DynamicZoomProcessor", " - 开始时间: \(zoomParams.startTime)s")
logger.info("DynamicZoomProcessor", " - 结束时间: \(zoomParams.endTime)s")

// 2. 生成关键帧(使用固定中心点作为推进目标)
let keyframes = generateKeyframesWithFixedCenter(
fixedCenter: CGPoint(x: params.fixedCenterX, y: params.fixedCenterY),
maxZoom: zoomParams.maxZoom,
startTime: zoomParams.startTime,
endTime: zoomParams.endTime,
videoSize: videoSize,
baseTransform: baseTransform
)

logger.info("DynamicZoomProcessor", "生成了 \(keyframes.count) 个关键帧")

// 3. 应用transform到每个关键帧
for (index, keyframe) in keyframes.enumerated() {
layerInstruction.setTransform(keyframe.transform, at: keyframe.time)

if index < 3 || index >= keyframes.count - 3 {
logger.info("DynamicZoomProcessor", " 关键帧[\(index)]: 时间=\(keyframe.time.seconds)s, 缩放=\(keyframe.zoomFactor)x")
}
}

logger.info("DynamicZoomProcessor", "✅ 动态缩放效果应用成功")
return true
}

// MARK: - 私有方法

/// 计算缩放参数(完整版:动态计算)
private func calculateZoomParams(
params: DynamicEffectParams,
videoDuration: Double
) -> (centerX: CGFloat, centerY: CGFloat, maxZoom: CGFloat, startTime: Double, endTime: Double)? {
// 1. 计算时间范围(使用轨迹数据的实际时间)
let startTimeS = Double(params.startTimeMs) / 1000.0
let endTimeS = Double(params.endTimeMs) / 1000.0
let scoreTimeS = Double(params.scoreTimeMs) / 1000.0

logger.info("DynamicZoomProcessor", "轨迹时间范围:")
logger.info("DynamicZoomProcessor", " - 起始时间: \(startTimeS)s (\(params.startTimeMs)ms)")
logger.info("DynamicZoomProcessor", " - 结束时间: \(endTimeS)s (\(params.endTimeMs)ms)")
logger.info("DynamicZoomProcessor", " - 进球时间: \(scoreTimeS)s (\(params.scoreTimeMs)ms)")

// 2. 计算实际的缩放时间范围
let trajectoryDuration = endTimeS - startTimeS
let bufferTime = trajectoryDuration * DynamicZoomConfig.bufferTimeRatio

// 缩放开始时间:轨迹起始时间之前一点
var zoomStartTime = startTimeS

// 缩放结束时间:轨迹结束时间之后一点
var zoomEndTime = endTimeS

// 确保最小时长
let zoomDuration = zoomEndTime - zoomStartTime
if zoomDuration < DynamicZoomConfig.minZoomDuration {
logger.warning("DynamicZoomProcessor", "⚠️ 计算的缩放时长(\(zoomDuration)s)小于最小值,调整为最小时长")

let center = (zoomStartTime + zoomEndTime) / 2
let halfMinDuration = DynamicZoomConfig.minZoomDuration / 2

zoomStartTime = max(0, center - halfMinDuration)
zoomEndTime = min(videoDuration, center + halfMinDuration)
}

logger.info("DynamicZoomProcessor", "计算的缩放时间范围:")
logger.info("DynamicZoomProcessor", " - 开始: \(zoomStartTime)s")
logger.info("DynamicZoomProcessor", " - 结束: \(zoomEndTime)s")
logger.info("DynamicZoomProcessor", " - 时长: \(zoomEndTime - zoomStartTime)s")

// 3. 计算运动距离,用于动态确定缩放倍数
let distance = calculateDistance(
from: CGPoint(x: params.startCenterX, y: params.startCenterY),
to: CGPoint(x: params.endCenterX, y: params.endCenterY)
)

logger.info("DynamicZoomProcessor", "运动距离: \(distance) (归一化)")

// 4. 动态计算缩放倍数
let calculatedZoom = calculateOptimalZoomFactor(
distance: distance,
requestedZoom: params.scaleFactor
)

logger.info("DynamicZoomProcessor", "缩放倍数:")
logger.info("DynamicZoomProcessor", " - 请求的: \(params.scaleFactor)x")
logger.info("DynamicZoomProcessor", " - 计算的: \(calculatedZoom)x")

// 5. 使用固定中心点作为推进目标
let centerX = params.fixedCenterX
let centerY = params.fixedCenterY

return (
centerX: centerX,
centerY: centerY,
maxZoom: calculatedZoom,
startTime: zoomStartTime,
endTime: zoomEndTime
)
}

/// 计算两点之间的距离(归一化坐标)
private func calculateDistance(from start: CGPoint, to end: CGPoint) -> CGFloat {
let dx = end.x - start.x
let dy = end.y - start.y
return sqrt(dx * dx + dy * dy)
}

/// 计算最优缩放倍数
/// 基于运动距离和请求的缩放倍数,智能调整
private func calculateOptimalZoomFactor(distance: CGFloat, requestedZoom: CGFloat) -> CGFloat {
// 如果运动距离很小,可以使用更大的缩放
// 如果运动距离很大,使用较小的缩放以保持画面稳定

var optimalZoom = requestedZoom

// 运动距离阈值(归一化坐标)
let smallDistance: CGFloat = 0.1 // 小距离阈值
let largeDistance: CGFloat = 0.5 // 大距离阈值

if distance < smallDistance {
// 小距离运动:可以放大更多
optimalZoom = min(requestedZoom * 1.2, DynamicZoomConfig.maxZoomFactor)
logger.info("DynamicZoomProcessor", "小距离运动,增加缩放倍数")
} else if distance > largeDistance {
// 大距离运动:减少缩放以保持稳定
optimalZoom = max(requestedZoom * 0.8, DynamicZoomConfig.minZoomFactor)
logger.info("DynamicZoomProcessor", "大距离运动,减少缩放倍数")
}

// 确保在合理范围内
optimalZoom = max(DynamicZoomConfig.minZoomFactor, min(optimalZoom, DynamicZoomConfig.maxZoomFactor))

return optimalZoom
}

/// 生成关键帧数组(使用固定中心点)
private func generateKeyframesWithFixedCenter(
fixedCenter: CGPoint,
maxZoom: CGFloat,
startTime: Double,
endTime: Double,
videoSize: CGSize,
baseTransform: CGAffineTransform
) -> [ZoomKeyframe] {
var keyframes: [ZoomKeyframe] = []

let interval = DynamicZoomConfig.keyframeInterval // 0.05秒
let duration = endTime - startTime
let frameCount = Int(ceil(duration / interval)) + 1

for i in 0..<frameCount {
let time = startTime + Double(i) * interval
let clampedTime = min(time, endTime)

// 计算当前时间点的进度(0-1)
let progress = (clampedTime - startTime) / duration

// 🎯 计算缩放倍数(带缓动)
let zoomFactor = calculateZoomWithEasing(progress: progress, maxZoom: maxZoom)

// 🎯 使用固定中心点(不进行插值)
let center = fixedCenter

// 创建 transform
let transform = createZoomTransform(
zoom: zoomFactor,
center: center,
videoSize: videoSize,
baseTransform: baseTransform
)

let cmTime = CMTime(seconds: clampedTime, preferredTimescale: 1000)
keyframes.append(ZoomKeyframe(time: cmTime, zoomFactor: zoomFactor, transform: transform))
}

return keyframes
}

/// 生成关键帧数组(完整版:支持中心点插值)
private func generateKeyframesWithInterpolation(
startCenter: CGPoint,
endCenter: CGPoint,
maxZoom: CGFloat,
startTime: Double,
endTime: Double,
videoSize: CGSize,
baseTransform: CGAffineTransform
) -> [ZoomKeyframe] {
var keyframes: [ZoomKeyframe] = []

let interval = DynamicZoomConfig.keyframeInterval // 0.1秒
let duration = endTime - startTime
let frameCount = Int(ceil(duration / interval)) + 1

for i in 0..<frameCount {
let time = startTime + Double(i) * interval
let clampedTime = min(time, endTime)

// 计算当前时间点的进度(0-1)
let progress = (clampedTime - startTime) / duration

// 🎯 计算缩放倍数(带缓动)
let zoomFactor = calculateZoomWithEasing(progress: progress, maxZoom: maxZoom)

// 🎯 计算中心点(带插值和缓动)
let center = interpolateCenter(
from: startCenter,
to: endCenter,
progress: progress
)

// 创建 transform
let transform = createZoomTransform(
zoom: zoomFactor,
center: center,
videoSize: videoSize,
baseTransform: baseTransform
)

let cmTime = CMTime(seconds: clampedTime, preferredTimescale: 1000)
keyframes.append(ZoomKeyframe(time: cmTime, zoomFactor: zoomFactor, transform: transform))
}

return keyframes
}

/// 计算带缓动的缩放倍数
private func calculateZoomWithEasing(progress: Double, maxZoom: CGFloat) -> CGFloat {
// 应用缓动函数
let easedProgress = easeInOut(progress: progress, power: DynamicZoomConfig.easingPower)

// 从 1.0 逐渐放大到 maxZoom
return 1.0 + (maxZoom - 1.0) * CGFloat(easedProgress)
}

/// 中心点插值(带缓动)
private func interpolateCenter(from start: CGPoint, to end: CGPoint, progress: Double) -> CGPoint {
// 应用中心点路径缓动
let easedProgress = easeInOut(progress: progress, power: DynamicZoomConfig.centerPathEasingPower)

// 线性插值
let x = start.x + (end.x - start.x) * CGFloat(easedProgress)
let y = start.y + (end.y - start.y) * CGFloat(easedProgress)

return CGPoint(x: x, y: y)
}

/// 计算指定时间点的缩放倍数
private func calculateZoom(
at time: Double,
params: (centerX: CGFloat, centerY: CGFloat, maxZoom: CGFloat, startTime: Double, endTime: Double)
) -> CGFloat {
// 放大开始前
if time < params.startTime {
return 1.0
}

// 放大结束后
if time > params.endTime {
return params.maxZoom
}

// 放大过程中
let duration = params.endTime - params.startTime
let elapsed = time - params.startTime
let progress = elapsed / duration // 0-1

// 应用缓动函数
let easedProgress = easeInOut(progress: progress, power: DynamicZoomConfig.easingPower)

// 计算当前缩放倍数
return 1.0 + (params.maxZoom - 1.0) * CGFloat(easedProgress)
}

/// 缓动函数(幂函数)
private func easeInOut(progress: Double, power: Double) -> Double {
return pow(progress, power)
}

/// 创建镜头推进的Transform
private func createZoomTransform(
zoom: CGFloat,
center: CGPoint, // 归一化坐标 (0-1)
videoSize: CGSize,
baseTransform: CGAffineTransform
) -> CGAffineTransform {

// 1. 计算裁剪区域尺寸
let visibleWidth = videoSize.width / zoom
let visibleHeight = videoSize.height / zoom
logger.info("DynamicZoomProcessor", " 步骤1 - 裁剪区域尺寸:")
logger.info("DynamicZoomProcessor", " - 可见宽度: \(visibleWidth)")
logger.info("DynamicZoomProcessor", " - 可见高度: \(visibleHeight)")

// 2. 计算中心点的绝对坐标
let absoluteCenterX = center.x * videoSize.width
let absoluteCenterY = center.y * videoSize.height

// 3. 计算裁剪区域的起始坐标
var cropX = absoluteCenterX - visibleWidth / 2
var cropY = absoluteCenterY - visibleHeight / 2


// 4. 边界检测和修正
let originalCropX = cropX
let originalCropY = cropY
cropX = max(0, min(cropX, videoSize.width - visibleWidth))
cropY = max(0, min(cropY, videoSize.height - visibleHeight))


// 5. 构建Transform(注意顺序)
var transform = baseTransform // 保留原始旋转/镜像


// 先缩放
transform = transform.scaledBy(x: zoom, y: zoom)
// 再平移(在缩放后的坐标系中平移)
transform = transform.translatedBy(x: -cropX, y: -cropY)
*/
logger.info("DynamicZoomProcessor", "✅ [createZoomTransform] Transform创建完成")
return transform
}
}