5ca3bbab31
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 137h30m1s
CI/CD Pipeline / Frontend Lint (push) Failing after 137h30m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 137h30m16s
625 lines
21 KiB
Python
625 lines
21 KiB
Python
"""VideoComposeService — Phase 8 任务 2.09.
|
||
|
||
FFmpeg 视频合成编排服务:
|
||
1. 根据 EditPlan + EditPlanClips 生成 FFmpeg filter_complex 命令
|
||
2. 支持逐片段 scale / crop / trim / setpts 滤镜
|
||
3. 支持转场效果(fade / slide / dissolve / wipe)
|
||
4. 支持音频流合并
|
||
5. 提供合成前校验逻辑
|
||
|
||
设计原则:
|
||
- 本服务只负责 **命令生成 + 校验**,不执行 FFmpeg
|
||
- Worker 层(Celery task)调用本服务生成命令后执行
|
||
- API 层可调用 build_compose_command 做预览 / 调试
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import shutil
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
||
SQLAlchemyEditPlanClipRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||
SQLAlchemyEditPlanRepository,
|
||
)
|
||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||
from packages.domain.template_clip_config import TransitionEffect
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||
|
||
DEFAULT_OUTPUT_WIDTH = 1280
|
||
DEFAULT_OUTPUT_HEIGHT = 720
|
||
DEFAULT_FPS = 25
|
||
DEFAULT_CODEC = "libx264"
|
||
DEFAULT_CRF = 23
|
||
DEFAULT_PRESET = "medium"
|
||
|
||
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
|
||
_XFADE_TRANSITION_MAP: dict[str, str] = {
|
||
TransitionEffect.FADE: "fade",
|
||
TransitionEffect.SLIDE_LEFT: "slideleft",
|
||
TransitionEffect.SLIDE_RIGHT: "slideright",
|
||
TransitionEffect.DISSOLVE: "dissolve",
|
||
TransitionEffect.WIPE: "wipeleft",
|
||
}
|
||
|
||
# 转场默认时长(秒)
|
||
DEFAULT_TRANSITION_DURATION = 0.5
|
||
|
||
|
||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClipFilterChain:
|
||
"""单个片段的滤镜链描述。"""
|
||
|
||
clip_id: str
|
||
input_index: int
|
||
video_label: str
|
||
audio_label: str | None
|
||
filters: list[str]
|
||
duration: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ComposeCommand:
|
||
"""完整的 FFmpeg 合成命令描述。"""
|
||
|
||
command: list[str]
|
||
"""可直接传给 subprocess.run 的命令列表。"""
|
||
|
||
filter_complex: str
|
||
"""-filter_complex 参数值(方便调试 / 日志)。"""
|
||
|
||
input_paths: list[str]
|
||
"""输入文件路径列表。"""
|
||
|
||
output_path: str
|
||
"""输出文件路径。"""
|
||
|
||
estimated_duration: float
|
||
"""预估输出时长(秒)。"""
|
||
|
||
clip_chains: list[ClipFilterChain]
|
||
"""每个片段的滤镜链描述。"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ComposeValidation:
|
||
"""合成前校验结果。"""
|
||
|
||
valid: bool
|
||
errors: list[str] = field(default_factory=list)
|
||
warnings: list[str] = field(default_factory=list)
|
||
ready_clip_count: int = 0
|
||
total_clip_count: int = 0
|
||
|
||
|
||
# ── 服务主体 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class VideoComposeService:
|
||
"""FFmpeg 视频合成编排服务。
|
||
|
||
职责:
|
||
- 根据 EditPlan 及其 Clips 生成 FFmpeg filter_complex 命令
|
||
- 校验合成前置条件
|
||
- 提供合成状态查询
|
||
|
||
用法::
|
||
|
||
svc = VideoComposeService(db)
|
||
validation = svc.validate_compose(plan_id)
|
||
if validation.valid:
|
||
cmd = svc.build_compose_command(plan_id, output_path="/tmp/out.mp4")
|
||
subprocess.run(cmd.command, check=True)
|
||
"""
|
||
|
||
def __init__(self, db: Session) -> None:
|
||
self._db = db
|
||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||
|
||
# ── 公开方法 ──────────────────────────────────────────────────────────
|
||
|
||
def validate_compose(self, plan_id: str) -> ComposeValidation:
|
||
"""校验剪辑计划是否可以合成。
|
||
|
||
检查项:
|
||
1. 计划存在
|
||
2. 计划状态为 editing 或 rendering
|
||
3. 至少有一个 ready 状态的片段
|
||
4. 每个 ready 片段都有 asset_id
|
||
5. 每个 ready 片段都有 duration > 0
|
||
"""
|
||
errors: list[str] = []
|
||
warnings: list[str] = []
|
||
|
||
plan = self._plan_repo.get(plan_id)
|
||
if plan is None:
|
||
return ComposeValidation(
|
||
valid=False,
|
||
errors=[f"剪辑计划不存在: {plan_id}"],
|
||
)
|
||
|
||
# 状态检查
|
||
if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING):
|
||
errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}")
|
||
|
||
# 加载片段
|
||
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||
if not clips:
|
||
errors.append("计划没有任何片段")
|
||
return ComposeValidation(
|
||
valid=False,
|
||
errors=errors,
|
||
total_clip_count=0,
|
||
)
|
||
|
||
# 按 order 排序
|
||
clips.sort(key=lambda c: c.order)
|
||
|
||
ready_count = 0
|
||
pending_count = 0
|
||
no_asset_count = 0
|
||
no_duration_count = 0
|
||
|
||
for clip in clips:
|
||
if clip.status == EditPlanClipStatus.READY:
|
||
ready_count += 1
|
||
if not clip.asset_id:
|
||
errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材")
|
||
no_asset_count += 1
|
||
if clip.duration <= 0:
|
||
warnings.append(f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长")
|
||
no_duration_count += 1
|
||
elif clip.status == EditPlanClipStatus.PENDING:
|
||
pending_count += 1
|
||
elif clip.status == EditPlanClipStatus.FAILED:
|
||
warnings.append(f"片段 {clip.id} (order={clip.order}) 状态为 failed,已跳过")
|
||
|
||
if ready_count == 0:
|
||
errors.append("没有就绪(ready)的片段可以合成")
|
||
|
||
if pending_count > 0:
|
||
warnings.append(f"有 {pending_count} 个片段仍处于 pending 状态")
|
||
|
||
return ComposeValidation(
|
||
valid=len(errors) == 0,
|
||
errors=errors,
|
||
warnings=warnings,
|
||
ready_clip_count=ready_count,
|
||
total_clip_count=len(clips),
|
||
)
|
||
|
||
def build_compose_command(
|
||
self,
|
||
plan_id: str,
|
||
output_path: str,
|
||
*,
|
||
output_width: int = DEFAULT_OUTPUT_WIDTH,
|
||
output_height: int = DEFAULT_OUTPUT_HEIGHT,
|
||
fps: int = DEFAULT_FPS,
|
||
codec: str = DEFAULT_CODEC,
|
||
crf: int = DEFAULT_CRF,
|
||
preset: str = DEFAULT_PRESET,
|
||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||
) -> ComposeCommand:
|
||
"""构建 FFmpeg 合成命令。
|
||
|
||
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
|
||
|
||
滤镜链逻辑:
|
||
- 每个片段:scale → crop → setpts → trim → atrim
|
||
- 多片段之间:concat 滤镜 或 xfade 转场
|
||
- 最终输出:-map '[outv]' -map '[outa]'(如有音频)
|
||
"""
|
||
plan = self._plan_repo.get(plan_id)
|
||
if plan is None:
|
||
raise ValueError(f"剪辑计划不存在: {plan_id}")
|
||
|
||
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||
if not clips:
|
||
raise ValueError(f"剪辑计划没有片段: {plan_id}")
|
||
|
||
# 只处理 ready 且有 asset_id 的片段
|
||
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id]
|
||
ready_clips.sort(key=lambda c: c.order)
|
||
|
||
if not ready_clips:
|
||
raise ValueError(f"剪辑计划没有可合成的片段: {plan_id}")
|
||
|
||
# 构建每个片段的滤镜链
|
||
clip_chains: list[ClipFilterChain] = []
|
||
input_paths: list[str] = []
|
||
|
||
for idx, clip in enumerate(ready_clips):
|
||
chain = self._build_clip_filter(
|
||
clip=clip,
|
||
input_index=idx,
|
||
output_width=output_width,
|
||
output_height=output_height,
|
||
fps=fps,
|
||
)
|
||
clip_chains.append(chain)
|
||
input_paths.append(clip.asset_id) # asset_id 存储的是 storage_key / URL
|
||
|
||
# 构建 filter_complex
|
||
filter_complex, estimated_duration = self._build_filter_complex(
|
||
clip_chains=clip_chains,
|
||
output_width=output_width,
|
||
output_height=output_height,
|
||
transition_duration=transition_duration,
|
||
transitions=[c.transition_effect for c in ready_clips],
|
||
)
|
||
|
||
# 构建完整命令
|
||
command: list[str] = ["ffmpeg", "-y"]
|
||
|
||
# 输入文件
|
||
for path in input_paths:
|
||
command.extend(["-i", path])
|
||
|
||
# filter_complex
|
||
command.extend(["-filter_complex", filter_complex])
|
||
|
||
# 映射输出流
|
||
command.extend(["-map", "[outv]"])
|
||
if self._has_audio(clip_chains):
|
||
command.extend(["-map", "[outa]"])
|
||
|
||
# 编码参数
|
||
command.extend(
|
||
[
|
||
"-c:v",
|
||
codec,
|
||
"-crf",
|
||
str(crf),
|
||
"-preset",
|
||
preset,
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"192k",
|
||
]
|
||
)
|
||
|
||
# 输出
|
||
command.append(output_path)
|
||
|
||
return ComposeCommand(
|
||
command=command,
|
||
filter_complex=filter_complex,
|
||
input_paths=input_paths,
|
||
output_path=output_path,
|
||
estimated_duration=estimated_duration,
|
||
clip_chains=clip_chains,
|
||
)
|
||
|
||
def build_single_clip_command(
|
||
self,
|
||
clip_id: str,
|
||
output_path: str,
|
||
*,
|
||
output_width: int = DEFAULT_OUTPUT_WIDTH,
|
||
output_height: int = DEFAULT_OUTPUT_HEIGHT,
|
||
fps: int = DEFAULT_FPS,
|
||
) -> ComposeCommand:
|
||
"""为单个片段构建 FFmpeg 命令(预览 / 调试用)。"""
|
||
clip = self._clip_repo.get(clip_id)
|
||
if clip is None:
|
||
raise ValueError(f"片段不存在: {clip_id}")
|
||
if not clip.asset_id:
|
||
raise ValueError(f"片段没有分配素材: {clip_id}")
|
||
|
||
chain = self._build_clip_filter(
|
||
clip=clip,
|
||
input_index=0,
|
||
output_width=output_width,
|
||
output_height=output_height,
|
||
fps=fps,
|
||
)
|
||
|
||
# 简单命令:input → filter → output
|
||
filter_str = ",".join(chain.filters)
|
||
command = [
|
||
"ffmpeg",
|
||
"-y",
|
||
"-i",
|
||
clip.asset_id,
|
||
"-filter_complex",
|
||
f"{filter_str}[outv]",
|
||
"-map",
|
||
"[outv]",
|
||
"-c:v",
|
||
DEFAULT_CODEC,
|
||
"-crf",
|
||
str(DEFAULT_CRF),
|
||
"-preset",
|
||
DEFAULT_PRESET,
|
||
output_path,
|
||
]
|
||
|
||
return ComposeCommand(
|
||
command=command,
|
||
filter_complex=filter_str,
|
||
input_paths=[clip.asset_id],
|
||
output_path=output_path,
|
||
estimated_duration=clip.duration,
|
||
clip_chains=[chain],
|
||
)
|
||
|
||
def get_compose_status(self, plan_id: str) -> dict[str, Any]:
|
||
"""获取合成状态摘要。"""
|
||
plan = self._plan_repo.get(plan_id)
|
||
if plan is None:
|
||
raise ValueError(f"剪辑计划不存在: {plan_id}")
|
||
|
||
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||
clips.sort(key=lambda c: c.order)
|
||
|
||
total_duration = sum(c.duration for c in clips if c.duration > 0)
|
||
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY]
|
||
pending_clips = [c for c in clips if c.status == EditPlanClipStatus.PENDING]
|
||
rendered_clips = [c for c in clips if c.status == EditPlanClipStatus.RENDERED]
|
||
failed_clips = [c for c in clips if c.status == EditPlanClipStatus.FAILED]
|
||
|
||
return {
|
||
"plan_id": plan_id,
|
||
"plan_status": plan.status.value,
|
||
"total_clips": len(clips),
|
||
"ready_clips": len(ready_clips),
|
||
"pending_clips": len(pending_clips),
|
||
"rendered_clips": len(rendered_clips),
|
||
"failed_clips": len(failed_clips),
|
||
"total_duration": total_duration,
|
||
"can_compose": len(ready_clips) > 0
|
||
and plan.status
|
||
in (
|
||
EditPlanStatus.EDITING,
|
||
EditPlanStatus.RENDERING,
|
||
),
|
||
"rendered_url": plan.config.get("rendered_url", ""),
|
||
}
|
||
|
||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _build_clip_filter(
|
||
clip: EditPlanClip,
|
||
input_index: int,
|
||
output_width: int,
|
||
output_height: int,
|
||
fps: int,
|
||
) -> ClipFilterChain:
|
||
"""为单个片段构建滤镜链。
|
||
|
||
滤镜顺序:
|
||
1. scale — 等比缩放到目标分辨率(保证覆盖)
|
||
2. crop — 居中裁剪到目标分辨率
|
||
3. setpts — 重置时间戳 + 偏移
|
||
4. trim — 视频时长裁剪
|
||
5. atrim — 音频时长裁剪(如有音频流)
|
||
"""
|
||
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
|
||
start = clip.start_time
|
||
|
||
filters: list[str] = []
|
||
|
||
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop)
|
||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase")
|
||
|
||
# 2. crop: 居中裁剪
|
||
filters.append(f"crop={output_width}:{output_height}")
|
||
|
||
# 3. setpts: 重置时间戳
|
||
if start > 0:
|
||
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
|
||
else:
|
||
filters.append("setpts=PTS-STARTPTS")
|
||
|
||
# 4. trim: 视频时长
|
||
filters.append(f"trim=0:{duration}")
|
||
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
|
||
|
||
video_label = f"v{input_index}"
|
||
|
||
# 5. 音频标签:仅当片段类型可能有音频时才设置
|
||
# title/subtitle 是纯文字/图片卡片,没有音频流
|
||
clip_type = clip.clip_type.lower() if clip.clip_type else ""
|
||
has_audio_stream = clip_type not in ("title", "subtitle")
|
||
audio_label = f"a{input_index}" if has_audio_stream else None
|
||
|
||
return ClipFilterChain(
|
||
clip_id=clip.id,
|
||
input_index=input_index,
|
||
video_label=video_label,
|
||
audio_label=audio_label,
|
||
filters=filters,
|
||
duration=duration,
|
||
)
|
||
|
||
@staticmethod
|
||
def _build_filter_complex(
|
||
clip_chains: list[ClipFilterChain],
|
||
output_width: int,
|
||
output_height: int,
|
||
transition_duration: float,
|
||
transitions: list[str],
|
||
) -> tuple[str, float]:
|
||
"""构建完整的 filter_complex 字符串。
|
||
|
||
策略:
|
||
- 单片段:直接输出
|
||
- 多片段 + 全 cut:使用 concat 滤镜(高效)
|
||
- 多片段 + 有转场:使用 xfade 滤镜链
|
||
|
||
返回 (filter_complex_string, estimated_total_duration)。
|
||
"""
|
||
n = len(clip_chains)
|
||
|
||
if n == 0:
|
||
return "", 0.0
|
||
|
||
# ── 单片段 ─────────────────────────────────────────────────────
|
||
if n == 1:
|
||
chain = clip_chains[0]
|
||
filter_str = _chain_filters(chain.filters, chain.video_label)
|
||
# 音频
|
||
if chain.audio_label:
|
||
filter_str += f";[0:a]{chain.audio_label}"
|
||
total_duration = chain.duration
|
||
return filter_str, total_duration
|
||
|
||
# ── 检查是否有转场 ─────────────────────────────────────────────
|
||
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
||
|
||
if not has_transitions:
|
||
return _build_concat_filter(clip_chains)
|
||
|
||
# ── 有转场:使用 xfade ─────────────────────────────────────────
|
||
return _build_xfade_filter(
|
||
clip_chains=clip_chains,
|
||
transition_duration=transition_duration,
|
||
transitions=transitions,
|
||
)
|
||
|
||
@staticmethod
|
||
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||
"""是否有任何片段包含音频流。"""
|
||
return any(c.audio_label is not None for c in clip_chains)
|
||
|
||
|
||
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _chain_filters(filters: list[str], output_label: str) -> str:
|
||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
|
||
filter_body = ",".join(filters)
|
||
return f"[0:v]{filter_body}[{output_label}]"
|
||
|
||
|
||
def _build_concat_filter(
|
||
clip_chains: list[ClipFilterChain],
|
||
) -> tuple[str, float]:
|
||
"""构建 concat 滤镜(无转场,高效拼接)。
|
||
|
||
格式:
|
||
[0:v]filters[v0]; [1:v]filters[v1]; ...
|
||
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
|
||
"""
|
||
n = len(clip_chains)
|
||
parts: list[str] = []
|
||
total_duration = 0.0
|
||
|
||
# 每个片段的滤镜链
|
||
for idx, chain in enumerate(clip_chains):
|
||
filter_body = ",".join(chain.filters)
|
||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||
total_duration += chain.duration
|
||
|
||
# concat 滤镜
|
||
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
|
||
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
|
||
parts.append(concat_filter)
|
||
|
||
# 音频 concat(如果有)
|
||
audio_parts: list[str] = []
|
||
for idx, chain in enumerate(clip_chains):
|
||
if chain.audio_label:
|
||
audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]")
|
||
|
||
if audio_parts:
|
||
parts.extend(audio_parts)
|
||
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
|
||
audio_count = sum(1 for c in clip_chains if c.audio_label)
|
||
if audio_count > 0:
|
||
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
|
||
|
||
return ";".join(parts), total_duration
|
||
|
||
|
||
def _build_xfade_filter(
|
||
clip_chains: list[ClipFilterChain],
|
||
transition_duration: float,
|
||
transitions: list[str],
|
||
) -> tuple[str, float]:
|
||
"""构建 xfade 转场滤镜链。
|
||
|
||
每两个相邻片段之间插入 xfade 转场。
|
||
offset = 前一个片段的累积时长 - 转场时长。
|
||
|
||
格式(2 片段):
|
||
[0:v]filters[v0]; [1:v]filters[v1];
|
||
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
|
||
|
||
格式(3+ 片段):
|
||
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
|
||
"""
|
||
n = len(clip_chains)
|
||
parts: list[str] = []
|
||
total_duration = 0.0
|
||
|
||
# 每个片段的滤镜链
|
||
for idx, chain in enumerate(clip_chains):
|
||
filter_body = ",".join(chain.filters)
|
||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||
total_duration += chain.duration
|
||
|
||
# xfade 链
|
||
if n == 1:
|
||
# 单片段不需要 xfade
|
||
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
|
||
return ";".join(parts), total_duration
|
||
|
||
# 计算每个转场的 offset
|
||
cumulative = 0.0
|
||
prev_label = clip_chains[0].video_label
|
||
|
||
for i in range(1, n):
|
||
cumulative += clip_chains[i - 1].duration
|
||
offset = max(0.0, cumulative - transition_duration * i)
|
||
|
||
# 获取转场类型
|
||
transition = transitions[i] if i < len(transitions) else "cut"
|
||
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
|
||
|
||
if i == n - 1:
|
||
# 最后一个转场,输出到 [outv]
|
||
out_label = "outv"
|
||
else:
|
||
out_label = f"xf{i}"
|
||
|
||
parts.append(
|
||
f"[{prev_label}][{clip_chains[i].video_label}]"
|
||
f"xfade=transition={xfade_transition}"
|
||
f":duration={transition_duration}"
|
||
f":offset={offset:.3f}"
|
||
f"[{out_label}]"
|
||
)
|
||
prev_label = out_label
|
||
|
||
# 总时长需要减去转场重叠部分
|
||
total_duration -= transition_duration * (n - 1)
|
||
|
||
# 音频 crossfade(简化处理:使用 adelay + amix)
|
||
audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
|
||
if len(audio_labels) >= 2:
|
||
# 简单拼接音频(不做 crossfade)
|
||
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
|
||
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
|
||
elif len(audio_labels) == 1:
|
||
parts.append(f"[{audio_labels[0]}]acopy[outa]")
|
||
|
||
return ";".join(parts), max(0.0, total_duration)
|