Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb26ccb147 | |||
| b094346bf6 | |||
| acca081149 | |||
| ffed853f6e | |||
| f0cf4f50dc |
@@ -163,10 +163,14 @@ def probe_duration(local_path: str | Path) -> float:
|
||||
|
||||
|
||||
def probe_video_info(video_path: str) -> dict[str, Any]:
|
||||
"""获取视频信息(宽、高、时长、fps)。
|
||||
"""获取视频信息(宽、高、时长、fps、编码、像素格式)。
|
||||
|
||||
Returns:
|
||||
{"width": int, "height": int, "duration": float, "fps": float}
|
||||
{
|
||||
"width": int, "height": int, "duration": float, "fps": float,
|
||||
"video_codec": str, "audio_codec": str, "pix_fmt": str,
|
||||
"has_audio": bool,
|
||||
}
|
||||
失败时返回默认值。
|
||||
"""
|
||||
try:
|
||||
@@ -175,10 +179,8 @@ def probe_video_info(video_path: str) -> dict[str, Any]:
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height,r_frame_rate,duration",
|
||||
"stream=width,height,r_frame_rate,duration,codec_name,codec_type,pix_fmt",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
@@ -195,14 +197,19 @@ def probe_video_info(video_path: str) -> dict[str, Any]:
|
||||
import json
|
||||
|
||||
info = json.loads(result.stdout)
|
||||
stream = info.get("streams", [{}])[0]
|
||||
streams = info.get("streams", [])
|
||||
fmt = info.get("format", {})
|
||||
|
||||
width = int(stream.get("width", DEFAULT_OUTPUT_WIDTH))
|
||||
height = int(stream.get("height", DEFAULT_OUTPUT_HEIGHT))
|
||||
video_stream = next((s for s in streams if s.get("codec_type") == "video"), {})
|
||||
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {})
|
||||
|
||||
width = int(video_stream.get("width", DEFAULT_OUTPUT_WIDTH))
|
||||
height = int(video_stream.get("height", DEFAULT_OUTPUT_HEIGHT))
|
||||
video_codec = video_stream.get("codec_name", "") or ""
|
||||
pix_fmt = video_stream.get("pix_fmt", "") or ""
|
||||
|
||||
# 解析帧率
|
||||
fps_str = stream.get("r_frame_rate", "25/1")
|
||||
fps_str = video_stream.get("r_frame_rate", "25/1")
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
fps = float(num) / float(den) if float(den) > 0 else DEFAULT_FPS
|
||||
@@ -210,13 +217,20 @@ def probe_video_info(video_path: str) -> dict[str, Any]:
|
||||
fps = float(fps_str) if fps_str else DEFAULT_FPS
|
||||
|
||||
# 时长
|
||||
duration = float(fmt.get("duration", 0)) or float(stream.get("duration", 0))
|
||||
duration = float(fmt.get("duration", 0)) or float(video_stream.get("duration", 0))
|
||||
|
||||
has_audio = bool(audio_stream)
|
||||
audio_codec = audio_stream.get("codec_name", "") or ""
|
||||
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": duration,
|
||||
"fps": round(fps, 2),
|
||||
"video_codec": video_codec,
|
||||
"audio_codec": audio_codec,
|
||||
"pix_fmt": pix_fmt,
|
||||
"has_audio": has_audio,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("获取视频信息失败: %s, error: %s", video_path, e)
|
||||
@@ -225,6 +239,10 @@ def probe_video_info(video_path: str) -> dict[str, Any]:
|
||||
"height": DEFAULT_OUTPUT_HEIGHT,
|
||||
"duration": 0.0,
|
||||
"fps": DEFAULT_FPS,
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"pix_fmt": "",
|
||||
"has_audio": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -296,7 +295,7 @@ WrapStyle: 2
|
||||
Encoding: UTF-8
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
|
||||
{chr(10).join(styles)}
|
||||
|
||||
[Events]
|
||||
@@ -460,12 +459,25 @@ class UnifiedRenderService:
|
||||
|
||||
is_pass_through = self._can_use_pass_through(layers)
|
||||
pass_through_has_audio = False
|
||||
used_stream_copy = False
|
||||
|
||||
if is_pass_through:
|
||||
# 直通优化:单clip场景一次FFmpeg同时处理视频+音频,省去提取+合并两次调用
|
||||
pass_through_has_audio = self._render_pass_through(
|
||||
# 先尝试 stream copy 优化(无重编码,性能提升 10 倍+)
|
||||
# 条件不满足或失败时回退到带滤镜的直通渲染
|
||||
stream_copy_ok = self._try_render_stream_copy(
|
||||
layers, output_path, ass_path=ass_path, video_duration=video_duration
|
||||
)
|
||||
if stream_copy_ok:
|
||||
used_stream_copy = True
|
||||
# stream copy 模式下,直接探测输出是否有音频
|
||||
clip = layers[0].clips[0]
|
||||
info = probe_video_info(str(clip.local_path))
|
||||
pass_through_has_audio = info.get("has_audio", True)
|
||||
else:
|
||||
# 回退到带滤镜的直通渲染
|
||||
pass_through_has_audio = self._render_pass_through(
|
||||
layers, output_path, ass_path=ass_path, video_duration=video_duration
|
||||
)
|
||||
else:
|
||||
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
|
||||
self._execute_ffmpeg(filter_complex, input_args, video_only_path)
|
||||
@@ -473,10 +485,11 @@ class UnifiedRenderService:
|
||||
t_video_end = time.time()
|
||||
video_render_ms = int((t_video_end - t_video_start) * 1000)
|
||||
logger.info(
|
||||
"[unified-render] video render done: plan_id=%s duration_ms=%d pass_through=%s",
|
||||
"[unified-render] video render done: plan_id=%s duration_ms=%d pass_through=%s stream_copy=%s",
|
||||
self.plan.id,
|
||||
video_render_ms,
|
||||
is_pass_through,
|
||||
used_stream_copy,
|
||||
)
|
||||
|
||||
# 6. 音频后处理混音(直通场景已合并处理,跳过)
|
||||
@@ -619,6 +632,176 @@ class UnifiedRenderService:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _can_use_stream_copy(
|
||||
self,
|
||||
clip: ResolvedClip,
|
||||
*,
|
||||
ass_path: Path | None = None,
|
||||
video_duration: float = 0.0,
|
||||
) -> tuple[bool, str]:
|
||||
"""判断是否可以走 stream copy(流拷贝,不重编码)。
|
||||
|
||||
性能提升:10 倍以上(典型场景从 20s → 1-2s)。
|
||||
|
||||
条件:
|
||||
1. 视频编码为 h264(输出目标也是 h264)
|
||||
2. 像素格式为 yuv420p
|
||||
3. 分辨率与输出一致(不需要 scale/crop)
|
||||
4. 帧率与输出一致(误差 < 0.1fps)
|
||||
5. 无字幕叠加(字幕需要滤镜)
|
||||
6. 无 trim 需求(或 trim 后恰好等于原时长)
|
||||
7. 无转场、无特效(单 clip 直通已保证)
|
||||
|
||||
Returns:
|
||||
(是否可以 copy, 原因说明)
|
||||
"""
|
||||
# 有字幕 → 需要滤镜 → 不能 copy
|
||||
if ass_path is not None:
|
||||
return False, "有字幕叠加"
|
||||
|
||||
# 探测输入视频参数
|
||||
info = probe_video_info(str(clip.local_path))
|
||||
|
||||
# 编码必须是 h264
|
||||
if info.get("video_codec", "") != "h264":
|
||||
return False, f"视频编码不是h264: {info.get('video_codec', 'unknown')}"
|
||||
|
||||
# 像素格式必须是 yuv420p
|
||||
if info.get("pix_fmt", "") != "yuv420p":
|
||||
return False, f"像素格式不是yuv420p: {info.get('pix_fmt', 'unknown')}"
|
||||
|
||||
# 分辨率必须一致
|
||||
if info.get("width", 0) != self.output_width or info.get("height", 0) != self.output_height:
|
||||
return False, (
|
||||
f"分辨率不匹配: "
|
||||
f"{info.get('width', 0)}x{info.get('height', 0)} "
|
||||
f"vs {self.output_width}x{self.output_height}"
|
||||
)
|
||||
|
||||
# 帧率必须一致(误差 < 0.1fps)
|
||||
fps_diff = abs(info.get("fps", 0) - self.output_fps)
|
||||
if fps_diff > 0.1:
|
||||
return False, f"帧率不匹配: {info.get('fps', 0)} vs {self.output_fps}"
|
||||
|
||||
# 检查是否需要 trim
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
if effective_duration > 0:
|
||||
# 有 trim 需求但视频时长足够,可用 -ss/-t 实现 copy trim
|
||||
input_duration = info.get("duration", 0)
|
||||
if input_duration <= 0:
|
||||
return False, "无法探测输入时长"
|
||||
# trim 起始点 + 目标时长 <= 输入时长
|
||||
start_time = getattr(clip, "start_time", 0) or 0
|
||||
if start_time + effective_duration > input_duration + 0.1:
|
||||
return False, "trim 超出输入时长"
|
||||
|
||||
# video_duration 截断
|
||||
if video_duration > 0 and effective_duration > 0:
|
||||
final_duration = min(effective_duration, video_duration)
|
||||
if final_duration != effective_duration:
|
||||
# 也需要截断,但 -t 可以 copy 模式下用
|
||||
pass
|
||||
|
||||
return True, "所有条件满足"
|
||||
|
||||
def _try_render_stream_copy(
|
||||
self,
|
||||
layers: list[RenderLayer],
|
||||
output_path: Path,
|
||||
*,
|
||||
ass_path: Path | None = None,
|
||||
video_duration: float = 0.0,
|
||||
) -> bool:
|
||||
"""尝试 stream copy 渲染,成功返回 True,失败返回 False(调用方回退到重编码)。
|
||||
|
||||
stream copy 模式:不重编码,直接拷贝视频/音频流,性能提升 10 倍+。
|
||||
仅用于单 clip 直通场景且满足 copy 条件。
|
||||
"""
|
||||
clip = layers[0].clips[0]
|
||||
role = layers[0].role
|
||||
|
||||
# 判断是否满足 copy 条件
|
||||
can_copy, reason = self._can_use_stream_copy(clip, ass_path=ass_path, video_duration=video_duration)
|
||||
if not can_copy:
|
||||
logger.info(
|
||||
"[unified-render] stream_copy 跳过: plan_id=%s reason=%s",
|
||||
self.plan.id,
|
||||
reason,
|
||||
)
|
||||
return False
|
||||
|
||||
# 构建 copy 命令
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
]
|
||||
|
||||
# trim 支持(-ss 放在 -i 前 = input seeking,速度更快但精度稍差;
|
||||
# 放在 -i 后 = output seeking,精度高但慢)
|
||||
# 这里用 output seeking 保证精度,反正 copy 模式已经很快了
|
||||
start_time = getattr(clip, "start_time", 0) or 0
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
|
||||
command.extend(["-i", str(clip.local_path)])
|
||||
|
||||
if start_time > 0:
|
||||
command.extend(["-ss", f"{start_time:.3f}"])
|
||||
|
||||
# 计算最终时长
|
||||
final_duration = effective_duration
|
||||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||||
final_duration = video_duration
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
|
||||
# 流拷贝
|
||||
command.extend(
|
||||
[
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[unified-render] stream_copy 渲染: plan_id=%s clip=%s role=%s duration=%.2fs",
|
||||
self.plan.id,
|
||||
clip.clip_id,
|
||||
role,
|
||||
final_duration,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
# 验证输出文件存在且有大小
|
||||
if output_path.exists() and output_path.stat().st_size > 0:
|
||||
logger.info(
|
||||
"[unified-render] stream_copy 成功: plan_id=%s size=%d",
|
||||
self.plan.id,
|
||||
output_path.stat().st_size,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning("[unified-render] stream_copy 输出为空: plan_id=%s", self.plan.id)
|
||||
return False
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
logger.warning(
|
||||
"[unified-render] stream_copy 失败,回退到重编码: plan_id=%s error=%s",
|
||||
self.plan.id,
|
||||
str(e)[:200],
|
||||
)
|
||||
# 清理可能的损坏输出文件
|
||||
if output_path.exists():
|
||||
try:
|
||||
output_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _render_pass_through(
|
||||
self,
|
||||
layers: list[RenderLayer],
|
||||
@@ -797,7 +980,6 @@ class UnifiedRenderService:
|
||||
|
||||
# 计算 PiP 位置
|
||||
pip_width = int(self.output_width * _PIP_SCALE)
|
||||
pip_height = int(self.output_height * _PIP_SCALE)
|
||||
margin = 20 # 边距
|
||||
|
||||
if "overlay" in layer_map:
|
||||
|
||||
@@ -1399,3 +1399,239 @@ class TestAudioMixing:
|
||||
assert r1 is True and r2 is True and r3 is True
|
||||
# 实际只探测了 1 次
|
||||
assert mock_probe.call_count == 1
|
||||
|
||||
|
||||
# ── 测试 stream copy 流拷贝优化 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStreamCopy:
|
||||
"""stream copy 流拷贝优化测试。"""
|
||||
|
||||
def _make_single_clip_service(self):
|
||||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||||
svc = _make_service(clips)
|
||||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
return svc, resolved[0], layers
|
||||
|
||||
def test_can_use_stream_copy_all_conditions_met(self):
|
||||
"""所有条件满足 → 可以 stream copy。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
probe_result = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
with patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
):
|
||||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||||
assert can_copy is True
|
||||
assert "所有条件满足" in reason
|
||||
|
||||
def test_cannot_copy_with_subtitles(self):
|
||||
"""有字幕 → 不能 stream copy。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=Path("/tmp/sub.ass"), video_duration=0)
|
||||
assert can_copy is False
|
||||
assert "字幕" in reason
|
||||
|
||||
def test_cannot_copy_wrong_codec(self):
|
||||
"""编码不是 h264 → 不能 stream copy。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
probe_result = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"video_codec": "hevc",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
with patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
):
|
||||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||||
assert can_copy is False
|
||||
assert "编码" in reason
|
||||
|
||||
def test_cannot_copy_wrong_resolution(self):
|
||||
"""分辨率不匹配 → 不能 stream copy。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
probe_result = {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 25.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
with patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
):
|
||||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||||
assert can_copy is False
|
||||
assert "分辨率" in reason
|
||||
|
||||
def test_cannot_copy_wrong_fps(self):
|
||||
"""帧率不匹配 → 不能 stream copy。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
probe_result = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
with patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
):
|
||||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||||
assert can_copy is False
|
||||
assert "帧率" in reason
|
||||
|
||||
def test_cannot_copy_wrong_pix_fmt(self):
|
||||
"""像素格式不匹配 → 不能 stream copy。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
probe_result = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv422p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
with patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
):
|
||||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||||
assert can_copy is False
|
||||
assert "像素格式" in reason
|
||||
|
||||
def test_try_render_stream_copy_success(self):
|
||||
"""stream copy 渲染成功 → 返回 True。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
probe_result = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
output_path = Path("/tmp/test_output.mp4")
|
||||
|
||||
def fake_stat():
|
||||
m = MagicMock()
|
||||
m.st_size = 1024000
|
||||
return m
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch("pathlib.Path.stat", side_effect=fake_stat),
|
||||
):
|
||||
result = svc._try_render_stream_copy(layers, output_path, ass_path=None, video_duration=0)
|
||||
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-c:v" in cmd
|
||||
assert "copy" in cmd
|
||||
assert "-c:a" in cmd
|
||||
|
||||
def test_try_render_stream_copy_fallback_on_ffmpeg_error(self):
|
||||
"""stream copy FFmpeg 失败 → 返回 False(调用方回退到重编码)。"""
|
||||
svc, clip, layers = self._make_single_clip_service()
|
||||
probe_result = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
output_path = Path("/tmp/test_output.mp4")
|
||||
|
||||
import subprocess as sp
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
),
|
||||
patch(
|
||||
"video_processing.unified_render_service.run_ffmpeg",
|
||||
side_effect=sp.CalledProcessError(1, ["ffmpeg"], stderr="copy failed"),
|
||||
),
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
):
|
||||
result = svc._try_render_stream_copy(layers, output_path, ass_path=None, video_duration=0)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_render_uses_stream_copy_when_eligible(self):
|
||||
"""完整渲染流程:满足条件时走 stream copy。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||||
svc = _make_service(clips)
|
||||
|
||||
probe_result = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
|
||||
def fake_stat():
|
||||
m = MagicMock()
|
||||
m.st_size = 1024000
|
||||
return m
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch(
|
||||
"video_processing.unified_render_service.probe_video_info",
|
||||
return_value=probe_result,
|
||||
),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
patch("pathlib.Path.stat", side_effect=fake_stat),
|
||||
patch("shutil.copy2"),
|
||||
):
|
||||
result = svc.render()
|
||||
|
||||
assert mock_run.call_count == 1
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "copy" in cmd
|
||||
assert isinstance(result.output_path, Path)
|
||||
|
||||
Reference in New Issue
Block a user