Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6aab85825 | |||
| e255f27cd7 | |||
| ee39086a53 |
Regular → Executable
+24
-130
@@ -2,19 +2,14 @@
|
||||
* Step 7 确认生成组件
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
import SummaryCard from "./step7-confirm/SummaryCard"
|
||||
import GenerationStatus from "./step7-confirm/GenerationStatus"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
@@ -64,129 +59,28 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={handleDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SummaryCard
|
||||
templateName={templateName}
|
||||
materialSummary={materialSummary}
|
||||
title={title}
|
||||
voiceName={voiceName}
|
||||
coverSummary={coverSummary}
|
||||
generateCount={generateCount}
|
||||
generating={generating}
|
||||
onDecrement={handleDecrement}
|
||||
onIncrement={handleIncrement}
|
||||
/>
|
||||
<GenerationStatus
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
getGenerationPhase={getGenerationPhase}
|
||||
onScrollToPreview={handleScrollToPreview}
|
||||
onRetry={onRetry}
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
interface GenerationStatusProps {
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
getGenerationPhase: (progress: number) => { icon: string; label: string }
|
||||
onScrollToPreview: () => void
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
onScrollToPreview,
|
||||
onRetry,
|
||||
onDismissError,
|
||||
}) => {
|
||||
if (!generating && !generated && !generateError) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationStatus
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react"
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
|
||||
interface SummaryCardProps {
|
||||
templateName: string
|
||||
materialSummary: string
|
||||
title: string
|
||||
voiceName: string
|
||||
coverSummary: string
|
||||
generateCount: number
|
||||
generating: boolean
|
||||
onDecrement: () => void
|
||||
onIncrement: () => void
|
||||
}
|
||||
|
||||
const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
generating,
|
||||
onDecrement,
|
||||
onIncrement,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={onDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={onIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SummaryCard
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from packages.domain.speed_config import (
|
||||
DEFAULT_SPEED,
|
||||
SpeedConfig,
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
"""plan_generator_utils 单元测试 - wave166
|
||||
|
||||
覆盖:
|
||||
- distribute_assets 素材分配(4种模式 + 边界)
|
||||
- map_clip_types_for_mode clip类型映射(4种模式)
|
||||
- generate_default_clips 默认片段生成(4种模式 + 边界)
|
||||
- create_clips_from_configs 从模板配置创建
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.plan_generator_utils import (
|
||||
DEFAULT_CLIP_DURATION,
|
||||
create_clips_from_configs,
|
||||
distribute_assets,
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# ============================================================
|
||||
# 辅助函数
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _make_main_clip(plan_id: str, order: int = 0) -> EditPlanClip:
|
||||
return EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=5.0,
|
||||
)
|
||||
|
||||
|
||||
def _make_clips(plan_id: str, count: int, clip_type: str = "main") -> list[EditPlanClip]:
|
||||
return [
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=i,
|
||||
duration=5.0,
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - ONE_TAKE
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeOneTake:
|
||||
def test_equal_count(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == "a3"
|
||||
|
||||
def test_more_clips_than_assets(self):
|
||||
clips = _make_clips("p1", 5)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == "" # 没分配到
|
||||
|
||||
def test_more_assets_than_clips(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
|
||||
def test_empty_assets(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
|
||||
for c in clips:
|
||||
assert c.asset_id == ""
|
||||
|
||||
def test_empty_clips(self):
|
||||
# 不报错即可
|
||||
distribute_assets([], ["a1", "a2"], EditingMode.ONE_TAKE.value)
|
||||
|
||||
def test_only_main_clips_get_assigned(self):
|
||||
# intro/outro 不应该被分配
|
||||
clips = []
|
||||
clips.append(EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0))
|
||||
clips.append(_make_main_clip("p1", order=1))
|
||||
clips.append(EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0))
|
||||
assets = ["a1"]
|
||||
distribute_assets(clips, assets, EditingMode.ONE_TAKE.value)
|
||||
assert clips[0].asset_id == "" # intro 无
|
||||
assert clips[1].asset_id == "a1" # main 有
|
||||
assert clips[2].asset_id == "" # outro 无
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - PIP
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributePip:
|
||||
def test_first_asset_to_main(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
# 第一个main是背景,其余改为overlay
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.PIP.value)
|
||||
assert clips[0].asset_id == "a1" # main → 背景
|
||||
assert clips[1].asset_id == "a2" # overlay
|
||||
assert clips[2].asset_id == "a3" # overlay
|
||||
|
||||
def test_single_asset(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assets = ["a1"]
|
||||
distribute_assets(clips, assets, EditingMode.PIP.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
|
||||
def test_only_main_clip_with_no_overlays(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assets = ["a1", "a2", "a3"] # 多余素材
|
||||
distribute_assets(clips, assets, EditingMode.PIP.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - VOICE_OVER
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeVoiceOver:
|
||||
def test_assets_to_main_clips(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_OVER.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == "a3"
|
||||
|
||||
def test_more_clips_than_assets(self):
|
||||
clips = _make_clips("p1", 5)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_OVER.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].asset_id == ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - VOICE_PIP
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeVoicePip:
|
||||
def test_three_assets_three_roles(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assets = ["a1", "a2", "a3"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[1].asset_id == "a2"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[2].asset_id == "a3"
|
||||
|
||||
def test_single_asset(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assets = ["a1"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[0].asset_id == "a1"
|
||||
|
||||
def test_two_assets(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# distribute_assets - 边界情况
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDistributeEdgeCases:
|
||||
def test_unknown_mode_falls_back_to_one_take(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
assets = ["a1", "a2"]
|
||||
distribute_assets(clips, assets, "unknown_mode")
|
||||
assert clips[0].asset_id == "a1"
|
||||
assert clips[1].asset_id == "a2"
|
||||
|
||||
def test_none_clips_no_crash(self):
|
||||
# 空列表
|
||||
distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value)
|
||||
|
||||
def test_none_assets_no_crash(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
|
||||
for c in clips:
|
||||
assert c.asset_id == ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# map_clip_types_for_mode
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestMapClipTypesForMode:
|
||||
def test_one_take_unchanged(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
original_types = [c.clip_type for c in clips]
|
||||
map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value)
|
||||
assert [c.clip_type for c in clips] == original_types
|
||||
|
||||
def test_voice_over_unchanged(self):
|
||||
clips = _make_clips("p1", 3)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value)
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_pip_first_stays_main_rest_overlay(self):
|
||||
clips = _make_clips("p1", 4)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
assert clips[1].clip_type == "overlay"
|
||||
assert clips[2].clip_type == "overlay"
|
||||
assert clips[3].clip_type == "overlay"
|
||||
|
||||
def test_pip_single_clip_stays_main(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_voice_pip_mapping(self):
|
||||
clips = _make_clips("p1", 5)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[3].clip_type == "b_roll"
|
||||
assert clips[4].clip_type == "b_roll"
|
||||
|
||||
def test_voice_pip_two_clips(self):
|
||||
clips = _make_clips("p1", 2)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
|
||||
def test_voice_pip_single_clip(self):
|
||||
clips = _make_clips("p1", 1)
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "background"
|
||||
|
||||
def test_non_main_clips_unchanged(self):
|
||||
clips = [
|
||||
EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0),
|
||||
_make_main_clip("p1", order=1),
|
||||
EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0),
|
||||
]
|
||||
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
|
||||
assert clips[0].clip_type == "intro"
|
||||
assert clips[1].clip_type == "background" # main 被改了
|
||||
assert clips[2].clip_type == "outro"
|
||||
|
||||
def test_empty_clips_no_error(self):
|
||||
map_clip_types_for_mode([], EditingMode.PIP.value)
|
||||
|
||||
def test_no_main_clips_no_error(self):
|
||||
clips = [
|
||||
EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0),
|
||||
]
|
||||
map_clip_types_for_mode(clips, EditingMode.PIP.value)
|
||||
assert clips[0].clip_type == "intro"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate_default_clips
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsOneTake:
|
||||
def test_basic(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 3)
|
||||
assert len(clips) == 3
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
assert c.plan_id == "p1"
|
||||
|
||||
def test_order_sequential(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 5)
|
||||
for i, c in enumerate(clips):
|
||||
assert c.order == i
|
||||
|
||||
def test_zero_assets_at_least_one(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 0)
|
||||
assert len(clips) == 1
|
||||
|
||||
def test_negative_assets_at_least_one(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, -5)
|
||||
assert len(clips) == 1
|
||||
|
||||
def test_default_duration(self):
|
||||
clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 1)
|
||||
assert clips[0].duration == DEFAULT_CLIP_DURATION
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsPip:
|
||||
def test_one_asset(self):
|
||||
clips = generate_default_clips("p1", EditingMode.PIP.value, 1)
|
||||
assert len(clips) == 1
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_three_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.PIP.value, 3)
|
||||
assert len(clips) == 3
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
assert clips[1].clip_type == "overlay"
|
||||
assert clips[2].clip_type == "overlay"
|
||||
|
||||
def test_zero_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.PIP.value, 0)
|
||||
assert len(clips) >= 1
|
||||
assert clips[0].clip_type == ClipType.MAIN.value
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsVoiceOver:
|
||||
def test_basic(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 3)
|
||||
assert len(clips) == 3
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_has_b_roll_config(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 2)
|
||||
# VOICE_OVER 标记 role=b_roll
|
||||
assert clips[0].config.get("role") == "b_roll"
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsVoicePip:
|
||||
def test_one_asset(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 1)
|
||||
assert len(clips) == 1
|
||||
assert clips[0].clip_type == "background"
|
||||
|
||||
def test_two_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 2)
|
||||
assert len(clips) == 2
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
|
||||
def test_five_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 5)
|
||||
assert len(clips) == 5
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[3].clip_type == "b_roll"
|
||||
assert clips[4].clip_type == "b_roll"
|
||||
|
||||
def test_zero_assets(self):
|
||||
clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 0)
|
||||
assert len(clips) >= 1
|
||||
assert clips[0].clip_type == "background"
|
||||
|
||||
|
||||
class TestGenerateDefaultClipsUnknownMode:
|
||||
def test_falls_back_to_one_take(self):
|
||||
clips = generate_default_clips("p1", "unknown_mode", 3)
|
||||
assert len(clips) == 3
|
||||
for c in clips:
|
||||
assert c.clip_type == ClipType.MAIN.value
|
||||
|
||||
|
||||
# ============================================================
|
||||
# create_clips_from_configs
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _make_template_config(
|
||||
cfg_id: str,
|
||||
order: int,
|
||||
clip_type: ClipType = ClipType.MAIN,
|
||||
min_dur: float = 0,
|
||||
max_dur: float = 0,
|
||||
) -> TemplateClipConfig:
|
||||
return TemplateClipConfig(
|
||||
id=cfg_id,
|
||||
template_id="t1",
|
||||
clip_type=clip_type,
|
||||
order=order,
|
||||
min_duration=min_dur,
|
||||
max_duration=max_dur,
|
||||
transition_effect="cut",
|
||||
config={},
|
||||
)
|
||||
|
||||
|
||||
class TestCreateClipsFromConfigs:
|
||||
def test_empty_configs(self):
|
||||
result = create_clips_from_configs("p1", [])
|
||||
assert result == []
|
||||
|
||||
def test_single_config(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=7.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert len(result) == 1
|
||||
assert result[0].plan_id == "p1"
|
||||
assert result[0].template_clip_config_id == "c1"
|
||||
# 平均时长 = (3+7)/2 = 5.0
|
||||
assert result[0].duration == pytest.approx(5.0)
|
||||
|
||||
def test_duration_min_only(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=4.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].duration == 4.0
|
||||
|
||||
def test_duration_max_only(self):
|
||||
configs = [_make_template_config("c1", 0, max_dur=6.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].duration == 6.0
|
||||
|
||||
def test_duration_default_when_no_bounds(self):
|
||||
configs = [_make_template_config("c1", 0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].duration == DEFAULT_CLIP_DURATION
|
||||
|
||||
def test_sorted_by_order(self):
|
||||
configs = [
|
||||
_make_template_config("c_third", 2),
|
||||
_make_template_config("c_first", 0),
|
||||
_make_template_config("c_second", 1),
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert len(result) == 3
|
||||
assert result[0].template_clip_config_id == "c_first"
|
||||
assert result[1].template_clip_config_id == "c_second"
|
||||
assert result[2].template_clip_config_id == "c_third"
|
||||
assert result[0].order == 0
|
||||
assert result[1].order == 1
|
||||
assert result[2].order == 2
|
||||
|
||||
def test_clip_type_preserved(self):
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c_intro",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.INTRO,
|
||||
order=0,
|
||||
min_duration=3.0,
|
||||
max_duration=3.0,
|
||||
transition_effect="cut",
|
||||
config={},
|
||||
),
|
||||
TemplateClipConfig(
|
||||
id="c_main",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="cut",
|
||||
config={},
|
||||
),
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].clip_type == ClipType.INTRO.value
|
||||
assert result[1].clip_type == ClipType.MAIN.value
|
||||
|
||||
def test_playback_speed_from_config(self):
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=0,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="cut",
|
||||
config={"playback_speed": 1.5},
|
||||
)
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].playback_speed == pytest.approx(1.5)
|
||||
|
||||
def test_speed_ratio_fallback(self):
|
||||
# 兼容 speed_ratio 字段名
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=0,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="cut",
|
||||
config={"speed_ratio": 0.8},
|
||||
)
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].playback_speed == pytest.approx(0.8)
|
||||
|
||||
def test_default_playback_speed(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=5.0, max_dur=5.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].playback_speed == pytest.approx(1.0)
|
||||
|
||||
def test_transition_effect_preserved(self):
|
||||
configs = [
|
||||
TemplateClipConfig(
|
||||
id="c1",
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=0,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
transition_effect="fade",
|
||||
config={},
|
||||
)
|
||||
]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert result[0].transition_effect == "fade"
|
||||
|
||||
def test_returns_edit_plan_clip_objects(self):
|
||||
configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=5.0)]
|
||||
result = create_clips_from_configs("p1", configs)
|
||||
assert isinstance(result[0], EditPlanClip)
|
||||
@@ -6,7 +6,6 @@ domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.transition_presets import (
|
||||
|
||||
Reference in New Issue
Block a user