Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 157ebd4ef2 | |||
| 3717787cc8 | |||
| 74e2bdf914 | |||
| f03c1ccc17 | |||
| b7b51d306c | |||
| 80c2000bb7 | |||
| 8abb0e88cb |
@@ -200,15 +200,7 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
// Step 4: title(新顺序:标题在预览之前)
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
@@ -222,6 +214,14 @@ test.describe("Core generation flow", () => {
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
@@ -13,15 +13,17 @@ export interface CreatePreviewRequest {
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
/* 标题烧录参数(可选,传入后 ASS 渲染标题到预览视频中) */
|
||||
title_text?: string
|
||||
title_font?: string
|
||||
title_size?: number
|
||||
title_color?: string
|
||||
title_position?: string
|
||||
title_bold?: boolean
|
||||
title_stroke?: boolean
|
||||
title_shadow?: boolean
|
||||
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { getAssetsByKind } from "@/api/assets/assets"
|
||||
import type { AssetItem } from "@/api/assets/types"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
@@ -71,6 +74,20 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewModalOpen,
|
||||
} = formState
|
||||
|
||||
/* ── 查询视频素材,用于 Step4 标题预览背景 ── */
|
||||
const { data: videoAssets = [] } = useQuery({
|
||||
queryKey: ["generate-video-assets"],
|
||||
queryFn: () => getAssetsByKind("video", { limit: 50 }),
|
||||
})
|
||||
|
||||
// 获取第一个选中素材的 URL
|
||||
const sourceVideoUrl = useMemo(() => {
|
||||
const firstId = selectedMaterials[0]
|
||||
if (!firstId) return undefined
|
||||
const asset = videoAssets.find((a: AssetItem) => a.id === firstId)
|
||||
return asset?.file_url
|
||||
}, [selectedMaterials, videoAssets])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
@@ -237,8 +254,8 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step5+ 常驻,展示选中的预览) */}
|
||||
{currentStep >= 5 && (
|
||||
{/* 预览视频面板(Step4+ 常驻,Step4 显示标题预览,Step5+ 显示预览视频) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step5Preview.previewStatus}
|
||||
previewResult={step5Preview.previewResult}
|
||||
@@ -247,7 +264,9 @@ const GeneratePage: React.FC = () => {
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step5Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={currentStep >= 4 ? titleSettings : undefined}
|
||||
titleSettings={titleSettings}
|
||||
showTitlePreview={currentStep === 4}
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { drawTitleOnCanvas } from "../utils/drawTitleOnCanvas"
|
||||
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
previewStatus: PreviewStatus
|
||||
@@ -24,10 +25,14 @@ interface PreviewVideoPanelProps {
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 标题文字(Step5 起传入) */
|
||||
/** 标题文字 */
|
||||
titleText?: string
|
||||
/** 标题样式设置(Step5 起传入) */
|
||||
/** 标题样式设置 */
|
||||
titleSettings?: TitleSettings
|
||||
/** Step4 标题预览模式 */
|
||||
showTitlePreview?: boolean
|
||||
/** 素材视频 URL(用于 Step4 标题预览背景) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/* ── 组件 ── */
|
||||
@@ -41,11 +46,12 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
onRegenerate,
|
||||
titleText,
|
||||
titleSettings,
|
||||
showTitlePreview,
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
@@ -170,12 +176,35 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{hasPreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
<h3>{showTitlePreview ? "标题预览" : "预览视频"}</h3>
|
||||
{hasPreview && !showTitlePreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
</div>
|
||||
|
||||
{/* 空状态:还没生成预览 */}
|
||||
{previewStatus === "idle" && (
|
||||
{/* Step4 标题预览模式 */}
|
||||
{showTitlePreview && titleSettings && titleText && (
|
||||
<div style={{ padding: "0 16px 16px" }}>
|
||||
<TitlePreviewCanvas
|
||||
titleText={titleText}
|
||||
titleSettings={titleSettings}
|
||||
videoRatio="9:16"
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step4 但无标题时的空状态 */}
|
||||
{showTitlePreview && (!titleText || !titleSettings) && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">请输入标题</p>
|
||||
<p className="xx-preview-empty-desc">在左侧设置标题后,这里会实时预览效果</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态:还没生成预览(非 Step4 模式) */}
|
||||
{!showTitlePreview && previewStatus === "idle" && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
@@ -185,8 +214,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
{/* 生成中(非 Step4 模式) */}
|
||||
{!showTitlePreview && isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
@@ -202,8 +231,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
{/* 生成失败(非 Step4 模式) */}
|
||||
{!showTitlePreview && isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
@@ -246,8 +275,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览信息 */}
|
||||
{hasPreview && previewResult && (
|
||||
{/* 预览信息(非 Step4 模式) */}
|
||||
{!showTitlePreview && hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>时长</span>
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
@@ -112,11 +111,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 标题实时预览 - 仅当有标题文字时显示 */}
|
||||
{t.titleSettings.title && (
|
||||
<TitlePreviewCanvas titleText={t.titleSettings.title} titleSettings={t.titleSettings} />
|
||||
)}
|
||||
|
||||
{/* 样式面板始终可见,两种模式下都可调整标题展示样式 */}
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
|
||||
@@ -15,21 +15,24 @@ interface TitlePreviewCanvasProps {
|
||||
titleText: string
|
||||
/** 标题样式设置 */
|
||||
titleSettings: TitleSettings
|
||||
/** 视频比例,默认 "16:9" */
|
||||
/** 视频比例,默认 "9:16"(竖屏) */
|
||||
videoRatio?: string
|
||||
/** 素材视频 URL(作为背景显示) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 videoRatio 字符串为 aspect-ratio CSS 值
|
||||
*/
|
||||
function parseAspect(ratio: string): string {
|
||||
return (ratio || "16:9").replace(":", "/")
|
||||
return (ratio || "9:16").replace(":", "/")
|
||||
}
|
||||
|
||||
const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
|
||||
titleText,
|
||||
titleSettings,
|
||||
videoRatio = "16:9",
|
||||
videoRatio = "9:16",
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
@@ -142,12 +145,31 @@ const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: parseAspect(videoRatio),
|
||||
background: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
|
||||
background: sourceVideoUrl
|
||||
? "#000"
|
||||
: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{sourceVideoUrl && (
|
||||
<video
|
||||
src={sourceVideoUrl}
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
|
||||
@@ -358,15 +358,19 @@ export function useStep5Preview({
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
voice_library_id: voiceLibraryId || undefined,
|
||||
// 标题信息
|
||||
title_text: titleSettings?.title || undefined,
|
||||
title_font: titleSettings?.font || undefined,
|
||||
title_size: titleSettings?.size || undefined,
|
||||
title_color: titleSettings?.color || undefined,
|
||||
title_position: titleSettings?.position || undefined,
|
||||
title_bold: titleSettings?.bold || undefined,
|
||||
title_stroke: titleSettings?.stroke || undefined,
|
||||
title_shadow: titleSettings?.shadow || undefined,
|
||||
// 标题烧录配置
|
||||
title_config: titleSettings?.title
|
||||
? {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
|
||||
@@ -558,7 +558,11 @@ class UnifiedRenderService:
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
title_cfg = config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
subtitle_enabled = subtitle_cfg.get("enabled", True)
|
||||
@@ -789,6 +793,8 @@ class UnifiedRenderService:
|
||||
config = self.plan.config or {}
|
||||
tts_cfg = config.get("tts", {}) or {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
use_subtitle_align = False # 是否使用字幕对齐模式
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
|
||||
@@ -984,9 +984,9 @@ def _load_template_plan_config(template_id: str) -> dict:
|
||||
|
||||
# 从独立字段组装成 plan.config 格式
|
||||
plan_config: dict[str, Any] = {}
|
||||
title_cfg = template.title_config or {}
|
||||
subtitle_cfg = template.subtitle_config or {}
|
||||
bgm_cfg = template.bgm_config or {}
|
||||
title_cfg = template.title_config if isinstance(template.title_config, dict) else {}
|
||||
subtitle_cfg = template.subtitle_config if isinstance(template.subtitle_config, dict) else {}
|
||||
bgm_cfg = template.bgm_config if isinstance(template.bgm_config, dict) else {}
|
||||
|
||||
if title_cfg:
|
||||
plan_config["title"] = title_cfg
|
||||
@@ -1177,7 +1177,8 @@ def _render_video(
|
||||
# 2. 纯文本格式(旧):直接作为标题文本使用
|
||||
if custom_title and custom_title.strip():
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
title_cfg = dict(plan_cfg.get("title", {}) or {})
|
||||
_raw_title = plan_cfg.get("title", {}) or {}
|
||||
title_cfg = dict(_raw_title) if isinstance(_raw_title, dict) else {}
|
||||
ct_stripped = custom_title.strip()
|
||||
parsed_config = None
|
||||
if ct_stripped.startswith("{"):
|
||||
@@ -1245,6 +1246,8 @@ def _render_video(
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["voice_id"] = voice_ids[0]
|
||||
subtitle_cfg = plan_cfg.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
subtitle_cfg["auto_generated"] = True
|
||||
subtitle_cfg["enabled"] = True # 确保 ASR 字幕路径被触发,标题叠加也依赖此路径
|
||||
plan_cfg["subtitle"] = subtitle_cfg
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
测试:模板 config 字段存储了非 dict 值(如 True / False / str)时,
|
||||
渲染链路不会崩溃('bool' object has no attribute 'get')。
|
||||
|
||||
覆盖两个关键文件:
|
||||
1. generation.py — _load_template_plan_config 旧系统路径
|
||||
2. unified_render_service.py — _maybe_generate_ass
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add worker app to path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
|
||||
class TestLoadTemplatePlanConfigBoolDefense:
|
||||
"""_load_template_plan_config 旧系统路径对非 dict 值的防护。"""
|
||||
|
||||
def _call_old_path(self, title_cfg, subtitle_cfg, bgm_cfg):
|
||||
"""通过 mock 新模板系统返回 None,强制走旧模板系统 fallback 路径。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
mock_old_template = MagicMock()
|
||||
mock_old_template.title_config = title_cfg
|
||||
mock_old_template.subtitle_config = subtitle_cfg
|
||||
mock_old_template.bgm_config = bgm_cfg
|
||||
|
||||
mock_session = MagicMock()
|
||||
# 旧系统 query 返回 mock template
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = mock_old_template
|
||||
|
||||
# Mock 新模板系统 repo.get() 返回 None(强制走 fallback)
|
||||
mock_repo_cls = MagicMock()
|
||||
mock_repo_cls.return_value.get.return_value = None
|
||||
|
||||
with (
|
||||
patch("worker_app.tasks.generation.SessionLocal", return_value=mock_session),
|
||||
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyEditTemplateRepository", mock_repo_cls),
|
||||
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyTemplateClipConfigRepository", MagicMock()),
|
||||
):
|
||||
return _load_template_plan_config("fake-id")
|
||||
|
||||
def test_bool_values_return_empty(self):
|
||||
"""title_config=True / subtitle_config=False / bgm_config='str' → 全部过滤掉"""
|
||||
result = self._call_old_path(True, False, "not_a_dict")
|
||||
assert isinstance(result, dict)
|
||||
assert "title" not in result
|
||||
assert "subtitle" not in result
|
||||
assert "bgm" not in result
|
||||
|
||||
def test_valid_dict_passes_through(self):
|
||||
"""正常 dict 正常传递"""
|
||||
result = self._call_old_path(
|
||||
{"text": "标题", "enabled": True},
|
||||
{"text": "副标题"},
|
||||
{"enabled": True, "source": "test.mp3"},
|
||||
)
|
||||
assert result["title"] == {"text": "标题", "enabled": True}
|
||||
assert result["subtitle"] == {"text": "副标题"}
|
||||
assert result["bgm"] == {"enabled": True, "source": "test.mp3"}
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
"""None → 空 dict"""
|
||||
result = self._call_old_path(None, None, None)
|
||||
assert result == {}
|
||||
|
||||
def test_mixed_valid_and_invalid(self):
|
||||
"""部分有效、部分无效时只保留有效的"""
|
||||
result = self._call_old_path({"text": "OK"}, True, None)
|
||||
assert "title" in result
|
||||
assert "subtitle" not in result
|
||||
assert "bgm" not in result
|
||||
|
||||
def test_int_and_list_also_filtered(self):
|
||||
"""int / list 类型也被过滤"""
|
||||
result = self._call_old_path(42, [1, 2, 3], 0)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestUnifiedRenderBoolConfigDefense:
|
||||
"""_maybe_generate_ass 对 plan.config 中非 dict title/subtitle 的防护。"""
|
||||
|
||||
def _make_service(self, config):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = config
|
||||
service.plan = mock_plan
|
||||
service.task_id = "test-task"
|
||||
return service
|
||||
|
||||
def test_bool_title_does_not_crash(self):
|
||||
"""config['title']=True → 不崩溃,返回 None"""
|
||||
service = self._make_service({"title": True, "subtitle": {}})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_bool_subtitle_does_not_crash(self):
|
||||
"""config['subtitle']=False → 不崩溃,返回 None"""
|
||||
service = self._make_service({"title": {}, "subtitle": False})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_str_title_does_not_crash(self):
|
||||
"""config['title']='plain string' → 不崩溃"""
|
||||
service = self._make_service({"title": "plain string", "subtitle": {}})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_none_config_does_not_crash(self):
|
||||
"""config=None → 不崩溃"""
|
||||
service = self._make_service(None)
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_int_title_does_not_crash(self):
|
||||
"""config['title']=42 → 不崩溃"""
|
||||
service = self._make_service({"title": 42, "subtitle": 0})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user