feat: 视频调速引擎(快进/慢放)

- SpeedEngine 抽象层:SpeedConfig + SpeedEngine,支持 0.25x~4x 变速
- 视频调速:基于 FFmpeg setpts,在 clip 预处理环节插入
- 音频调速:基于 atempo,超范围自动多级串联(如 4x=2.0*2.0)
- 音画同步:视频音频同时调速,音调自动修正
- 分段调速:每个 clip 独立 playback_speed 字段
- 整段调速:所有 clip 设相同 speed 即可实现
- 降级策略:速度超出范围自动钳制,不阻断渲染
- 领域模型:EditPlanClip 新增 playback_speed 字段
- 数据库:EditPlanClipModel 新增 playback_speed 列
- API:create_clip / update_clip 支持 playback_speed 参数
- 45 个新增单测全绿 + 100 个现有测试全绿,无回归
This commit is contained in:
CI Bot
2026-07-14 00:36:16 +08:00
committed by xiaoxia-bot
parent c840f37a44
commit e5f2d26c0b
9 changed files with 585 additions and 24 deletions
+87 -19
View File
@@ -16,6 +16,8 @@ import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from video_processing.speed_engine import SpeedEngine
# 延迟导入避免循环依赖:unified_render_service 定义 ResolvedClip / RenderLayer,
# 本模块提供音频函数供 unified_render_service 调用。
# 使用 from __future__ import annotations + TYPE_CHECKING 解决类型引用。
@@ -145,38 +147,104 @@ def concat_main_audio(
# 单 clip,直接提取音频,截断到 min(clip有效时长, 视频总时长)
clip = clips[0]
effective_duration = clip_effective_duration(clip)
# 最终时长:取 clip 有效时长和视频总时长的较小值
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
final_duration = effective_duration
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
# 调速后时长
adjusted_duration = effective_duration / speed if abs(speed - 1.0) >= 1e-6 else effective_duration
# 最终时长:取调速后时长和视频总时长的较小值
final_duration = adjusted_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
]
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
if abs(speed - 1.0) < 1e-6:
# 原速:简单命令行
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
]
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
else:
# 有调速:用 filter_complex + atempo
from video_processing.speed_engine import SpeedConfig
speed_engine = SpeedEngine()
config = SpeedConfig(speed=float(speed))
config.clamp()
atempo_filter = speed_engine.build_audio_filter(config)
filter_parts: list[str] = []
audio_filters = []
if effective_duration > 0:
audio_filters.append(f"atrim=0:{effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
if atempo_filter:
audio_filters.append(atempo_filter)
filter_parts.append(f"[0:a]{','.join(audio_filters)}[outa]")
if video_duration > 0 and final_duration < adjusted_duration:
filter_parts.append(f"[outa]atrim=0:{final_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "outa"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
run_ffmpeg(command)
return
# 多 clip,用 filter_complex concat
input_args: list[str] = []
filter_parts: list[str] = []
speed_engine = SpeedEngine()
for i, clip in enumerate(clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
audio_filters: list[str] = []
if effective_duration > 0:
filter_parts.append(f"[{i}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[a{i}]")
audio_filters.append(f"atrim=0:{effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频调速 — atempo 多级串联
if abs(speed - 1.0) >= 1e-6:
from video_processing.speed_engine import SpeedConfig
config = SpeedConfig(speed=float(speed))
config.clamp()
atempo_filter = speed_engine.build_audio_filter(config)
if atempo_filter:
audio_filters.append(atempo_filter)
if audio_filters:
filter_parts.append(f"[{i}:a]{','.join(audio_filters)}[a{i}]")
else:
filter_parts.append(f"[{i}:a]asetpts=PTS-STARTPTS[a{i}]")