Files
xiaoxia-saas/packages/domain/video_concat.py

178 lines
5.9 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频拼接领域模型 — 纯逻辑,无外部依赖.
抽离自 concat_engine.py 的数据类和配置解析逻辑,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
# concat demuxer 要求一致的参数列表
CONCAT_DEMUXER_REQUIRED_PARAMS = [
"codec_name",
"width",
"height",
"r_frame_rate",
"pix_fmt",
"sample_rate",
"channels",
"audio_codec",
]
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
@dataclass
class ConcatSegment:
"""单个拼接片段."""
video_path: str # 视频文件路径
start_time: float = 0.0 # 开始时间(秒)
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
has_audio: bool = True # 是否包含音频
@classmethod
def from_dict(cls, seg: dict[str, Any] | None) -> "ConcatSegment":
"""从字典创建拼接片段,带安全类型转换."""
if not seg or not isinstance(seg, dict):
return cls(video_path="")
try:
start_time = max(0.0, float(seg.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(seg.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
video_path=str(seg.get("video_path", "")),
start_time=start_time,
duration=duration,
has_audio=bool(seg.get("has_audio", True)),
)
@property
def is_valid(self) -> bool:
"""是否为有效片段(有视频路径)."""
return bool(self.video_path)
@property
def effective_duration(self) -> float:
"""有效时长(duration > 0 时取 duration,否则 0."""
return max(0.0, self.duration)
@dataclass
class ConcatConfig:
"""视频拼接配置."""
segments: list[ConcatSegment] = field(default_factory=list)
output_width: int = 0 # 输出宽度(0=自动取第一段)
output_height: int = 0 # 输出高度(0=自动取第一段)
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
force_reencode: bool = False # 强制重新编码
transition: str = "none" # 转场效果(none/crossfade
transition_duration: float = 0.3 # 转场时长
@classmethod
def from_config_dict(cls, config: dict[str, Any] | None) -> "ConcatConfig":
"""从配置字典创建 ConcatConfig."""
if not config or not isinstance(config, dict):
return cls()
segments_raw = config.get("segments", [])
segments: list[ConcatSegment] = []
if isinstance(segments_raw, list):
for s in segments_raw:
if isinstance(s, dict) and s.get("video_path"):
try:
seg = ConcatSegment.from_dict(s)
if seg.is_valid:
segments.append(seg)
except Exception:
logger.warning("[concat] skip invalid segment: %s", s)
continue
try:
output_width = max(0, int(config.get("output_width", 0)))
except (TypeError, ValueError):
output_width = 0
try:
output_height = max(0, int(config.get("output_height", 0)))
except (TypeError, ValueError):
output_height = 0
try:
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
except (TypeError, ValueError):
output_fps = 0.0
try:
transition_duration = max(0.1, float(config.get("transition_duration", 0.3)))
except (TypeError, ValueError):
transition_duration = 0.3
return cls(
segments=segments,
output_width=output_width,
output_height=output_height,
output_fps=output_fps,
force_reencode=bool(config.get("force_reencode", False)),
transition=str(config.get("transition", "none")),
transition_duration=transition_duration,
)
@property
def has_effect(self) -> bool:
"""是否有有效片段需要拼接(至少2段)."""
return self.valid_segment_count >= 2
@property
def valid_segment_count(self) -> int:
"""有效片段数量."""
return sum(1 for s in self.segments if s.is_valid)
@property
def total_segments(self) -> int:
"""有效片段数量(向后兼容别名)."""
return self.valid_segment_count
@property
def first_valid_segment(self) -> ConcatSegment | None:
"""第一个有效片段."""
for s in self.segments:
if s.is_valid:
return s
return None
@property
def estimated_total_duration(self) -> float:
"""估算总时长(只统计有明确duration的片段)."""
total = 0.0
for s in self.segments:
if s.is_valid and s.duration > 0:
total += s.duration
return total
def clamp_segments(self, max_segments: int = MAX_CONCAT_SEGMENTS) -> None:
"""截断片段数量,防止OOM."""
if len(self.segments) > max_segments:
self.segments = self.segments[:max_segments]