Compare commits

..

3 Commits

Author SHA1 Message Date
CI Bot 7aa69b5fc6 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m57s
AI Code Review / AI Code Review (pull_request) Successful in 3m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m5s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m35s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m41s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m39s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 44s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m6s
2026-08-16 11:15:00 +00:00
CI Bot f6d0a009e7 test: add API-level tests for preview edit_plan auto-association
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 41s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
AI Code Review / AI Code Review (pull_request) Successful in 2m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Cover generation_preview.py edit_plan auto-association logic:
- Auto-associate when source_edit_plan_id empty + template_id matches
- Skip when source_edit_plan_id already provided
- Graceful degradation when plan lookup fails
- Skip when no plan matches current user

Also rebase onto latest develop (PR #1393 merged) to eliminate
ass_subtitle_builder.py from diff coverage calculation.
2026-08-16 19:10:56 +08:00
CI Bot b2f01b9951 fix: Worker渲染后提取封面帧+预览任务自动关联edit_plan
修复 Step6 封面生成 400 错误(根因:预览视频渲染后未抽帧写入 cover_url)

修复点 1 - Worker 封面帧提取:
- generation.py 在 _upload_and_record 后调用 MediaKitClient.extract_frames
- 使用 SpecifiedFrames 策略提取 1 帧
- 通过独立 session 将 cover_frame_url 持久化到 gen_task.cover_url
- 异常不影响主流程(try/except 包裹)

修复点 2 - API 自动关联 source_edit_plan_id:
- generation_preview.py 创建预览任务后,通过 template_id + user_id 查找 edit_plan
- 找到后自动设置 task.source_edit_plan_id 并更新 DB
- 使封面路由步骤 B(list_by_source_edit_plan)能生效

新增 11 个测试(test_cover_extract_frames.py)
已有 24 个相关测试无回归
2026-08-16 19:07:29 +08:00
8 changed files with 20 additions and 371 deletions
+3 -1
View File
@@ -235,6 +235,8 @@ const GeneratePage: React.FC = () => {
previewOverallError={step5Preview.previewError}
previewOverallProgress={step5Preview.progress}
previewAnyGenerating={step5Preview.anyGenerating}
previewTemplateName={step5Preview.templateName}
previewMaterialCount={step5Preview.materialCount}
onGeneratePreview={step5Preview.generatePreview}
onRegeneratePreview={step5Preview.regeneratePreview}
/>
@@ -252,7 +254,7 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 显示Step4 显示标题预览叠加Step5+ 视频) */}
{/* 预览视频面板(Step4+ 常驻Step4 显示标题预览,Step5+ 显示预览视频) */}
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={step5Preview.previewStatus}
@@ -74,6 +74,8 @@ export interface GenerateStepContentProps {
previewOverallError: string
previewOverallProgress: number
previewAnyGenerating: boolean
previewTemplateName: string
previewMaterialCount: string
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
@@ -120,6 +122,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewOverallError,
previewOverallProgress,
previewAnyGenerating,
previewTemplateName,
previewMaterialCount,
onGeneratePreview,
onRegeneratePreview,
} = props
@@ -161,6 +165,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
case 5:
return (
<Step5GeneratePreview
templateName={previewTemplateName}
materialCount={previewMaterialCount}
duration={duration}
videoRatio={videoRatio}
previewCount={previewCount}
onPreviewCountChange={onPreviewCountChange}
@@ -15,6 +15,9 @@ import { InputNumber } from "antd"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
interface Step5GeneratePreviewProps {
templateName: string
materialCount: string
duration: number
videoRatio: string
previewCount: number
onPreviewCountChange: (count: number) => void
@@ -37,6 +40,8 @@ const PREVIEW_COUNT_OPTIONS = [
]
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
templateName: _templateName,
materialCount: _materialCount,
videoRatio,
previewCount,
onPreviewCountChange,
@@ -13,16 +13,6 @@ import logging
from pathlib import Path
from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_wrap_title_text,
build_ass_style,
escape_ass_text,
format_ass_time,
hex_to_ass_color,
position_to_ass_alignment,
)
from packages.domain.subtitle import SubtitleTimeline
logger = logging.getLogger(__name__)
@@ -110,10 +100,7 @@ def generate_ass_from_timeline(
*,
video_width: int,
video_height: int,
video_duration: float = 0.0,
subtitle_config: dict[str, Any] | None = None,
title_text: str = "",
title_config: dict[str, Any] | None = None,
) -> Path:
"""从字幕时间轴生成 ASS 字幕文件。
@@ -173,76 +160,7 @@ def generate_ass_from_timeline(
events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}")
# ── 标题样式与事件(叠加在 ASR 字幕之上)───────────────────────────
title_cfg = title_config or {}
if not isinstance(title_cfg, dict):
title_cfg = {}
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
title_style_line = ""
title_event_line = ""
if title_enabled:
# 兼容 boolean stroke/shadow → dict
_stroke_val = title_cfg.get("stroke")
if isinstance(_stroke_val, bool):
title_cfg["stroke"] = (
{"enabled": _stroke_val, "color": "#000000", "width": 2} if _stroke_val else {"enabled": False}
)
_shadow_val = title_cfg.get("shadow")
if isinstance(_shadow_val, bool):
title_cfg["shadow"] = (
{"enabled": _shadow_val, "color": "#000000", "blur": 4, "offset_x": 2, "offset_y": 2}
if _shadow_val
else {"enabled": False}
)
# 字段名归一化: font_size→size, font_color→color
if "font_size" in title_cfg and "size" not in title_cfg:
title_cfg["size"] = title_cfg["font_size"]
if "font_color" in title_cfg and "color" not in title_cfg:
title_cfg["color"] = title_cfg["font_color"]
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
t_stroke = title_cfg.get("stroke", {}) or {}
t_shadow = title_cfg.get("shadow", {}) or {}
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0
sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0
sh_offset = (
t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0,
t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0,
)
t_alignment = position_to_ass_alignment(title_cfg.get("position", "top"))
title_style_line = build_ass_style(
"TitleStyle",
font_name=title_cfg.get("font", "思源黑体"),
font_size=min(int(title_cfg.get("size", 36)), 36),
primary_color=t_color,
outline_color=s_color,
outline_width=s_width,
shadow_blur=sh_blur,
shadow_offset=sh_offset,
bold=bool(title_cfg.get("bold", True)),
italic=bool(title_cfg.get("italic", False)),
alignment=t_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
t_font_size = min(int(title_cfg.get("size", 36)), 36)
safe_raw = escape_ass_text(title_text.strip())
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size)
if video_duration > 0:
t_end_time = format_ass_time(video_duration)
else:
t_end_time = format_ass_time((timeline.segments[-1].end + 5.0) if timeline.segments else 60.0)
title_event_line = f"Dialogue: 0,0:00:00.00,{t_end_time},TitleStyle,,0,0,0,,{safe_wrapped}"
# 组装 ASS 文件
# 组装 ASS 文件
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
@@ -252,12 +170,12 @@ 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 # noqa: E501
{chr(10).join(filter(None, [title_style_line, style_line]))}
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
{style_line}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(filter(None, [title_event_line] + events))}
{chr(10).join(events)}
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -513,10 +513,7 @@ class UnifiedRenderService:
timeline,
video_width=self.output_width,
video_height=self.output_height,
video_duration=video_duration,
subtitle_config=subtitle_cfg,
title_text=title_text,
title_config=title_cfg,
)
logger.info(
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
@@ -13,7 +13,6 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
@@ -1124,7 +1123,6 @@ def _render_video(
resolution: str = "",
bgm_config: dict | None = None,
voice_ids: list[str] | None = None,
custom_title: str = "",
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
@@ -1157,27 +1155,6 @@ def _render_video(
list(template_config.keys()),
)
# ── 用户自定义标题覆盖模板标题配置 ──────────────────────────────────
if custom_title:
try:
user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip():
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
if "font_size" in user_title_cfg and "size" not in user_title_cfg:
user_title_cfg["size"] = user_title_cfg["font_size"]
if "font_color" in user_title_cfg and "color" not in user_title_cfg:
user_title_cfg["color"] = user_title_cfg["font_color"]
plan_cfg = dict(virtual_plan.config or {})
plan_cfg["title"] = user_title_cfg
virtual_plan.config = plan_cfg
logger.info(
"[task_id=%s] [渲染] 用户自定义标题已注入: text=%s",
task_id,
user_title_cfg.get("text", "")[:30],
)
except (json.JSONDecodeError, TypeError):
logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100])
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
if bgm_config:
plan_cfg = virtual_plan.config or {}
@@ -1556,7 +1533,6 @@ def generate_video(self, task_id: str) -> dict:
resolution=_resolved_resolution,
bgm_config=task_info.get("bgm_config", {}),
voice_ids=task_info.get("voice_ids", []),
custom_title=task_info.get("custom_title", ""),
)
if gen_task:
@@ -412,7 +412,7 @@ class TestBuildAssContent:
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "36"
assert parts[2] == "72"
break
def test_title_bold(self):
-256
View File
@@ -1,256 +0,0 @@
"""预览视频标题渲染修复测试 — 覆盖3个断点。
断点1: generate_video() → _render_video() 传递 custom_title
断点2: _render_video() 解析 custom_title 并注入 virtual_plan.config["title"]
断点3: generate_ass_from_timeline() ASR路径也渲染标题
"""
import json
import time
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
# ── 断点2: _render_video 标题注入 ─────────────────────────────────────────────
class TestRenderVideoCustomTitleInjection:
"""验证 _render_video 正确接收并注入 custom_title 到 virtual_plan.config['title']。"""
@pytest.fixture
def sample_custom_title(self):
"""模拟前端发送的 custom_title JSON(含 font_size/font_color)。"""
return json.dumps(
{
"text": "测试标题",
"font": "思源黑体",
"font_size": 30,
"font_color": "#FF0000",
"position": "top",
"bold": True,
"stroke": True,
"shadow": False,
},
ensure_ascii=False,
)
def _call_render_video_with_capture(self, custom_title, template_config=None, tmp_path=None):
"""调用 _render_video,在 RenderAdapter 处中断并捕获 virtual_plan.config。"""
from worker_app.tasks.generation import _render_video
captured_config = {}
class FakePlan:
def __init__(self):
self.config = {}
self.id = "test-plan"
fake_plan = FakePlan()
def capture_and_raise(*args, **kwargs):
# 此时 title 已注入到 fake_plan.config
captured_config.update(fake_plan.config or {})
raise RuntimeError("STOP_HERE")
with (
patch("worker_app.tasks.generation._build_plan_and_clips_from_task") as mock_build,
patch("worker_app.tasks.generation._load_template_plan_config", return_value=template_config),
patch("worker_app.tasks.generation.time.monotonic", side_effect=[0.0, 1.0]),
patch("video_processing.render_adapter.RenderAdapter") as mock_adapter_cls,
):
mock_build.return_value = (fake_plan, [], {})
mock_adapter_cls.side_effect = capture_and_raise
with pytest.raises(RuntimeError, match="STOP_HERE"):
_render_video(
task_id="test-task",
downloaded_videos=[tmp_path / "v1.mp4"] if tmp_path else [Path("/tmp/v1.mp4")],
voice_path=None,
editing_mode=MagicMock(value="one_take"),
project_id="proj-1",
template_id="tpl-1",
user_id="user-1",
temp_path=tmp_path or Path("/tmp"),
output_name="test_output",
resolution="1280x720",
bgm_config={},
voice_ids=[],
custom_title=custom_title,
)
return captured_config
def test_custom_title_injected_into_plan_config(self, sample_custom_title, tmp_path):
"""custom_title JSON 应被解析并注入 virtual_plan.config['title']。"""
config = self._call_render_video_with_capture(sample_custom_title, tmp_path=tmp_path)
assert "title" in config
title_cfg = config["title"]
assert title_cfg["text"] == "测试标题"
# 字段归一化: font_size → size
assert title_cfg["size"] == 30
# 字段归一化: font_color → color
assert title_cfg["color"] == "#FF0000"
def test_custom_title_overrides_template_title(self, sample_custom_title, tmp_path):
"""用户自定义标题应覆盖模板默认标题。"""
template_config = {"title": {"text": "模板默认标题", "size": 24}}
config = self._call_render_video_with_capture(
sample_custom_title, template_config=template_config, tmp_path=tmp_path
)
# 用户标题应覆盖模板标题
assert config["title"]["text"] == "测试标题"
assert config["title"]["size"] == 30
def test_empty_custom_title_no_injection(self, tmp_path):
"""空 custom_title 不应注入 title 字段。"""
config = self._call_render_video_with_capture("", tmp_path=tmp_path)
assert "title" not in config
def test_malformed_custom_title_gracefully_ignored(self, tmp_path):
"""非法 JSON 不应崩溃,应跳过注入。"""
config = self._call_render_video_with_capture("{invalid json!!!", tmp_path=tmp_path)
assert "title" not in config
# ── 断点3: generate_ass_from_timeline ASR路径支持标题 ──────────────────────────
class TestGenerateAssFromTimelineWithTitle:
"""验证 generate_ass_from_timeline 在有标题时生成包含 TitleStyle 的 ASS。"""
def test_title_included_in_ass_output(self, tmp_path):
"""有 title_text 时,ASS 输出应包含 TitleStyle 和标题事件。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="你好世界"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={"font": "思源黑体", "size": 24},
title_text="我的标题",
title_config={"font": "思源黑体", "size": 36, "color": "#FFFFFF", "position": "top"},
)
content = result.read_text(encoding="utf-8")
# 应包含 TitleStyle
assert "TitleStyle" in content
# 应包含标题文本
assert "我的标题" in content
# 也应包含 ASR 字幕
assert "你好世界" in content
def test_no_title_no_title_style(self, tmp_path):
"""无标题时,ASS 输出不应包含 TitleStyle。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="只有字幕"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="",
title_config={},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" not in content
assert "只有字幕" in content
def test_title_field_normalization_in_ass(self, tmp_path):
"""前端字段名 font_size/font_color 应被正确归一化。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="归一化测试",
title_config={
"font_size": 30, # 前端字段名
"font_color": "#FF0000", # 前端字段名
"position": "top",
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "归一化测试" in content
def test_title_boolean_stroke_shadow_compat(self, tmp_path):
"""boolean stroke/shadow 应被兼容处理。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="描边测试",
title_config={
"size": 36,
"stroke": True, # boolean
"shadow": False, # boolean
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "描边测试" in content
# ── 断点1: _render_video 签名包含 custom_title ────────────────────────────────
class TestRenderVideoSignature:
"""验证 _render_video 函数签名正确。"""
def test_custom_title_parameter_exists(self):
"""_render_video 应有 custom_title 参数,默认空字符串。"""
import inspect
from worker_app.tasks.generation import _render_video
sig = inspect.signature(_render_video)
assert "custom_title" in sig.parameters
assert sig.parameters["custom_title"].default == ""