Merge develop into main - v0.1.125
Auto Merge PRs / auto-merge (push) Failing after 1m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h58m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h32m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h32m48s
Auto Merge PRs / auto-merge (push) Failing after 1m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h58m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h32m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h32m48s
This commit is contained in:
@@ -43,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types (consistent with existing upload.py)
|
||||
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
|
||||
ALLOWED_MIME_TYPES = {
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/svg+xml",
|
||||
# Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/mpeg",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
# Audio
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mp3",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/x-m4a",
|
||||
"audio/webm",
|
||||
}
|
||||
|
||||
# Chunk storage root directory
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SearchOutlined,
|
||||
InboxOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
@@ -37,7 +36,7 @@ import "./assets.css";
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "voice" | "image";
|
||||
type AssetKind = "video" | "image";
|
||||
type StatusType = "ok" | "warn" | "bad" | "info";
|
||||
|
||||
interface LibraryItem {
|
||||
@@ -67,7 +66,6 @@ interface AssetItem {
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "voice";
|
||||
return "image";
|
||||
};
|
||||
|
||||
@@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => {
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
});
|
||||
|
||||
@@ -149,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "voice":
|
||||
return <SoundOutlined />;
|
||||
case "image":
|
||||
return <PictureOutlined />;
|
||||
}
|
||||
@@ -160,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频";
|
||||
case "voice":
|
||||
return "配音";
|
||||
case "image":
|
||||
return "图片";
|
||||
}
|
||||
@@ -172,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
|
||||
}
|
||||
@@ -246,7 +238,7 @@ const AssetCard: React.FC<{
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮 */}
|
||||
{(asset.kind === "video" || asset.kind === "voice") && (
|
||||
{asset.kind === "video" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
@@ -450,8 +442,9 @@ const AssetLibrary: React.FC = () => {
|
||||
message.success(`"${file.name}" 上传成功`);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
} catch {
|
||||
message.error(`"${file.name}" 上传失败`);
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "";
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
@@ -645,7 +638,7 @@ const AssetLibrary: React.FC = () => {
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,audio/*,image/*"
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
@@ -655,7 +648,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">
|
||||
支持视频、音频、图片,单文件不超过 2GB
|
||||
支持视频、图片,单文件不超过 2GB
|
||||
</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
@@ -678,7 +671,6 @@ const AssetLibrary: React.FC = () => {
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
@@ -809,7 +801,6 @@ const AssetLibrary: React.FC = () => {
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Typography, message } from "antd";
|
||||
import { Typography, message, Select } from "antd";
|
||||
import {
|
||||
AudioOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
@@ -160,6 +163,7 @@ const GeneratePage: React.FC = () => {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -477,6 +481,7 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerating(true);
|
||||
setProgress(0);
|
||||
setGenerated(false);
|
||||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
const voiceConfig: Record<string, unknown> = {};
|
||||
@@ -537,6 +542,7 @@ const GeneratePage: React.FC = () => {
|
||||
)?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试";
|
||||
console.error("[生成失败] planId:", plan.id, "响应:", data);
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
@@ -593,7 +599,9 @@ const GeneratePage: React.FC = () => {
|
||||
"完整错误:",
|
||||
axiosErr,
|
||||
);
|
||||
message.error(backendMsg || "生成失败,请检查网络后重试或联系管理员");
|
||||
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}, [
|
||||
title,
|
||||
@@ -917,14 +925,30 @@ const GeneratePage: React.FC = () => {
|
||||
<h3>📝 选择标题</h3>
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<select value={title} onChange={(e) => setTitle(e.target.value)}>
|
||||
<option value="">请选择标题…</option>
|
||||
{userTitles.map((t) => (
|
||||
<option key={t.id} value={t.content}>
|
||||
{t.content}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={title || undefined}
|
||||
onChange={(val) => setTitle(val || "")}
|
||||
options={userTitles.map((t) => ({
|
||||
label: t.content,
|
||||
value: t.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
@@ -1407,18 +1431,82 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 */}
|
||||
{generating && (
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="xx-progress-bar">
|
||||
{generating && (
|
||||
<>
|
||||
<div className="xx-progress-bar">
|
||||
<div
|
||||
className="xx-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
正在生成视频,请稍候… {Math.round(progress)}%
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div
|
||||
className="xx-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
正在生成视频,请稍候… {Math.round(progress)}%
|
||||
</Text>
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(82, 196, 26, 0.08)",
|
||||
border: "1px solid rgba(82, 196, 26, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ color: "#52c41a", display: "block", fontSize: 14 }}
|
||||
>
|
||||
视频生成完成!
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
可在右侧预览或前往成片库查看
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255, 77, 79, 0.08)",
|
||||
border: "1px solid rgba(255, 77, 79, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CloseCircleOutlined
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 18,
|
||||
marginTop: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}
|
||||
>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{generateError}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1529,10 +1617,16 @@ const GeneratePage: React.FC = () => {
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || generated}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating ? "生成中…" : generated ? "已生成" : "✨ 确认生成"}
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -739,17 +739,37 @@ const ProductLibrary: React.FC = () => {
|
||||
if (isError) {
|
||||
console.error("[ProductLibrary] 加载失败:", error);
|
||||
const errorMsg = error?.message || "加载失败";
|
||||
// 区分 404 和其他错误
|
||||
// 404 视为空数据(API 尚未就绪或无数据)
|
||||
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
|
||||
if (is404) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-header">
|
||||
<h2>
|
||||
<VideoCameraOutlined /> 成片库
|
||||
</h2>
|
||||
</div>
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">🎬</div>
|
||||
<p>暂无成片数据</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
完成视频生成后,成片将自动保存到这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">{is404 ? "🔍" : "❌"}</div>
|
||||
<p>
|
||||
{is404
|
||||
? "成片库功能正在建设中,敬请期待"
|
||||
: errorMsg || "加载失败,请稍后重试"}
|
||||
</p>
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{errorMsg || "加载失败,请稍后重试"}</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
|
||||
@@ -91,7 +91,7 @@ const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: inferTemplateType(item.category),
|
||||
description: item.description,
|
||||
description: item.description ?? "",
|
||||
usageCount: 0,
|
||||
isFavorite: item.is_favorite ?? false,
|
||||
thumbnailGradient: gradientForCategory(item.category),
|
||||
@@ -370,9 +370,10 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{template.description.slice(0, 80)}...
|
||||
{(template.description ?? "").slice(0, 80)}...
|
||||
</div>
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-preview-hint">点击预览</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
@@ -386,7 +387,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
{/* 信息区 */}
|
||||
<div className="xx-template-info">
|
||||
<div className="xx-template-info-top">
|
||||
<h4 className="xx-template-name">{template.name}</h4>
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
@@ -397,7 +397,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
{template.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description}</p>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">
|
||||
已使用 {template.usageCount} 次
|
||||
@@ -483,7 +483,9 @@ const TemplateLibrary: React.FC = () => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(t.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()) ||
|
||||
t.tags.some((tag) =>
|
||||
tag.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
@@ -233,6 +233,24 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 缩略图底部名称 */
|
||||
.xx-template-thumb-name {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24px 14px 10px;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.55) 0%, transparent 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 预览提示(hover 显示) */
|
||||
.xx-template-preview-hint {
|
||||
position: absolute;
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
CheckOutlined,
|
||||
TagsOutlined,
|
||||
MutedOutlined,
|
||||
RobotOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select, Modal, Tag } from "@/components/ui";
|
||||
import { message, Popover, Popconfirm, Tooltip } from "antd";
|
||||
@@ -56,6 +58,8 @@ import {
|
||||
tagAsset,
|
||||
untagAsset,
|
||||
} from "@/api/tags";
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
|
||||
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices";
|
||||
import "./voice-materials.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -1086,6 +1090,14 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
[assets],
|
||||
);
|
||||
|
||||
// ── 获取预设音色列表(AI 配音用) ─────────────────────────
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [];
|
||||
|
||||
// ── 上传 mutation ─────────────────────────────────────────
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
@@ -1218,6 +1230,19 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
);
|
||||
const [batchCustomTag, setBatchCustomTag] = useState("");
|
||||
|
||||
// ── AI 配音(TTS 合成)状态 ────────────────────────────────
|
||||
const [ttsOpen, setTtsOpen] = useState(false);
|
||||
const [ttsText, setTtsText] = useState("");
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0);
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
|
||||
const [ttsStatus, setTtsStatus] = useState<
|
||||
"idle" | "synthesizing" | "done" | "error"
|
||||
>("idle");
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
|
||||
const [ttsError, setTtsError] = useState<string | null>(null);
|
||||
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ── 播放控制 ──────────────────────────────────────────────
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
@@ -1505,6 +1530,77 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
[tags, createTagMutation, handleBatchTag],
|
||||
);
|
||||
|
||||
// ── TTS 合成处理 ─────────────────────────────────────────
|
||||
/** 开始 AI 配音合成 */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本");
|
||||
return;
|
||||
}
|
||||
setTtsError(null);
|
||||
setTtsStatus("synthesizing");
|
||||
setTtsAudioUrl(null);
|
||||
setTtsJobId(null);
|
||||
|
||||
try {
|
||||
const resp = await synthesizeSpeech({
|
||||
text: ttsText.trim(),
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
});
|
||||
setTtsJobId(resp.job_id);
|
||||
|
||||
// 轮询任务状态
|
||||
ttsTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getTTSJobStatus(resp.job_id);
|
||||
if (job.status === "completed") {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("done");
|
||||
setTtsAudioUrl(job.output_audio_url);
|
||||
} else if (job.status === "failed") {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("error");
|
||||
setTtsError(job.error_message || "合成失败");
|
||||
}
|
||||
} catch {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("error");
|
||||
setTtsError("查询合成状态失败");
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "合成请求失败";
|
||||
setTtsStatus("error");
|
||||
setTtsError(msg);
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed]);
|
||||
|
||||
/** 保存 TTS 结果到素材库 */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
if (!ttsJobId) return;
|
||||
try {
|
||||
await saveTtsToLibrary(ttsJobId, {
|
||||
name: ttsText.slice(0, 20) || "AI配音",
|
||||
});
|
||||
message.success("已保存到配音素材库");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
|
||||
setTtsOpen(false);
|
||||
} catch {
|
||||
message.error("保存失败");
|
||||
}
|
||||
}, [ttsJobId, ttsText, queryClient]);
|
||||
|
||||
// TTS 定时器清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 渲染 ─────────────────────────────────────────────── */
|
||||
|
||||
const isUploading = uploadMutation.isPending;
|
||||
@@ -1512,6 +1608,13 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
|
||||
const pageActions = (
|
||||
<div className="vmat-page-actions">
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => setTtsOpen(true)}
|
||||
>
|
||||
AI配音
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
@@ -1818,6 +1921,182 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* AI 配音(TTS 合成)弹窗 */}
|
||||
<Modal
|
||||
title="AI 配音"
|
||||
open={ttsOpen}
|
||||
onCancel={() => {
|
||||
setTtsOpen(false);
|
||||
if (ttsTimerRef.current) {
|
||||
clearInterval(ttsTimerRef.current);
|
||||
ttsTimerRef.current = null;
|
||||
}
|
||||
setTtsStatus("idle");
|
||||
setTtsAudioUrl(null);
|
||||
setTtsError(null);
|
||||
setTtsJobId(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="请输入需要转换为语音的文本内容…"
|
||||
value={ttsText}
|
||||
onChange={(e) => setTtsText(e.target.value)}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginTop: 4,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</label>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => setTtsVoiceId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 36,
|
||||
padding: "0 10px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary, #fff)",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速调节 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={
|
||||
ttsStatus === "synthesizing" ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<RobotOutlined />
|
||||
)
|
||||
}
|
||||
onClick={handleTtsSynthesize}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
>
|
||||
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
|
||||
</Button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsStatus === "error" && ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "#fff2f0",
|
||||
borderRadius: 6,
|
||||
color: "#ff4d4f",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-surface, #f5f5f5)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
controls
|
||||
src={ttsAudioUrl}
|
||||
style={{ width: "100%", marginBottom: 12 }}
|
||||
/>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleTtsSave}
|
||||
>
|
||||
保存到素材库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,7 +78,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
|
||||
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
|
||||
return [self.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
models = (
|
||||
@@ -86,7 +86,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
.filter(GeneratedVideoModel.generation_task_id == generation_task_id)
|
||||
.all()
|
||||
)
|
||||
return [self.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
|
||||
"""通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。"""
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
测试视频上传失败排查修复:
|
||||
1. chunked_upload ALLOWED_MIME_TYPES 与 upload.py 保持一致
|
||||
2. generated_video_repository list 方法无 N+1 查询
|
||||
3. 成片库 API 端点可正常返回数据
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ─── 1. chunked_upload ALLOWED_MIME_TYPES 一致性 ───
|
||||
|
||||
|
||||
class TestChunkedUploadMIMEConsistency:
|
||||
"""chunked_upload.py 的 ALLOWED_MIME_TYPES 必须包含 upload.py 的所有类型。"""
|
||||
|
||||
def _get_upload_mime_types(self) -> set:
|
||||
from apps.api.app.api.routes.upload import ALLOWED_MIME_TYPES
|
||||
|
||||
return set(ALLOWED_MIME_TYPES)
|
||||
|
||||
def _get_chunked_mime_types(self) -> set:
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
return set(ALLOWED_MIME_TYPES)
|
||||
|
||||
def test_chunked_upload_has_all_upload_mime_types(self):
|
||||
"""chunked_upload 白名单必须覆盖 upload.py 的全部类型。"""
|
||||
upload_types = self._get_upload_mime_types()
|
||||
chunked_types = self._get_chunked_mime_types()
|
||||
missing = upload_types - chunked_types
|
||||
assert not missing, f"chunked_upload 缺少以下 MIME 类型: {missing}"
|
||||
|
||||
def test_chunked_upload_supports_video_mpeg(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/mpeg" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_video_matroska(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/x-matroska" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_video_3gpp(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/3gpp" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_flac(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/flac" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_aac(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/aac" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_m4a(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/x-m4a" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_webm(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/webm" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_extra_image_types(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "image/bmp" in ALLOWED_MIME_TYPES
|
||||
assert "image/tiff" in ALLOWED_MIME_TYPES
|
||||
assert "image/svg+xml" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_both_have_same_core_video_types(self):
|
||||
"""两条路径的核心视频类型必须一致。"""
|
||||
upload_types = self._get_upload_mime_types()
|
||||
chunked_types = self._get_chunked_mime_types()
|
||||
core_video = {"video/mp4", "video/quicktime", "video/webm"}
|
||||
for vt in core_video:
|
||||
assert vt in upload_types, f"upload.py 缺少 {vt}"
|
||||
assert vt in chunked_types, f"chunked_upload.py 缺少 {vt}"
|
||||
|
||||
|
||||
# ─── 2. GeneratedVideo Repository N+1 修复验证 ───
|
||||
|
||||
|
||||
class TestGeneratedVideoRepositoryNoNPlus1:
|
||||
"""list_by_project 和 list_by_generation_task 应使用 _to_domain 而非 self.get。"""
|
||||
|
||||
def _make_model(self, video_id: str, project_id: str = "proj-1", task_id: str = "task-1"):
|
||||
model = MagicMock()
|
||||
model.id = video_id
|
||||
model.project_id = project_id
|
||||
model.generation_task_id = task_id
|
||||
model.name = f"video-{video_id}.mp4"
|
||||
model.file_url = f"https://oss.example.com/{video_id}.mp4"
|
||||
model.file_size = 1024
|
||||
model.duration = 5.0
|
||||
model.thumbnail_url = None
|
||||
model.width = 1280
|
||||
model.height = 720
|
||||
model.fps = 25.0
|
||||
model.status = "completed"
|
||||
model.review_status = "pending_review"
|
||||
model.generation_params = json.dumps({"mode": "one_take"})
|
||||
model.video_fingerprint = None
|
||||
model.is_duplicate = False
|
||||
model.duplicate_of = None
|
||||
model.generated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
model.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
return model
|
||||
|
||||
def test_list_by_project_uses_to_domain(self):
|
||||
"""list_by_project 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
models = [self._make_model(f"v{i}") for i in range(5)]
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = models
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||||
result = repo.list_by_project("proj-1")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
assert len(result) == 5
|
||||
assert all(v.id.startswith("v") for v in result)
|
||||
|
||||
def test_list_by_generation_task_uses_to_domain(self):
|
||||
"""list_by_generation_task 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
models = [self._make_model(f"v{i}", task_id="task-42") for i in range(3)]
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = models
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||||
result = repo.list_by_generation_task("task-42")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
def test_list_by_project_returns_empty_when_no_videos(self):
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = []
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
result = repo.list_by_project("empty-project")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ─── 3. 成片库 API 端点可用性确认 ───
|
||||
|
||||
|
||||
class TestGeneratedVideosAPIAvailability:
|
||||
"""确认成片库 API 路由注册正确,端点可正常返回数据。"""
|
||||
|
||||
def test_generated_videos_routes_registered(self):
|
||||
"""成片库路由已注册到 router。"""
|
||||
from apps.api.app.api.router import api_router
|
||||
|
||||
# 检查 router 包含 generated-videos 路径
|
||||
routes = [r for r in api_router.routes if hasattr(r, "path")]
|
||||
gv_routes = [r for r in routes if "generated-videos" in r.path]
|
||||
assert len(gv_routes) > 0, "generated-videos 路由未注册"
|
||||
|
||||
def test_generated_videos_list_endpoint_exists(self):
|
||||
"""GET /generated-videos 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "" in paths, "GET /generated-videos 列表端点不存在"
|
||||
|
||||
def test_generated_videos_detail_endpoint_exists(self):
|
||||
"""GET /generated-videos/{video_id} 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}" in paths, "GET /generated-videos/{{video_id}} 详情端点不存在"
|
||||
|
||||
def test_generated_videos_review_endpoint_exists(self):
|
||||
"""PATCH /generated-videos/{video_id}/review 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}/review" in paths, "PATCH review 端点不存在"
|
||||
|
||||
def test_generated_videos_download_url_endpoint_exists(self):
|
||||
"""GET /generated-videos/{video_id}/download-url 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}/download-url" in paths, "download-url 端点不存在"
|
||||
|
||||
def test_generated_video_response_schema_complete(self):
|
||||
"""GeneratedVideoResponse 包含所有必要字段。"""
|
||||
from apps.api.app.schemas.generated_video import GeneratedVideoResponse
|
||||
|
||||
fields = GeneratedVideoResponse.model_fields
|
||||
required_fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"name",
|
||||
"file_url",
|
||||
"status",
|
||||
"review_status",
|
||||
"download_url",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in fields, f"GeneratedVideoResponse 缺少字段: {field}"
|
||||
|
||||
def test_list_generated_videos_response_schema(self):
|
||||
"""ListGeneratedVideosResponse 包含 items 列表。"""
|
||||
from apps.api.app.schemas.generated_video import ListGeneratedVideosResponse
|
||||
|
||||
fields = ListGeneratedVideosResponse.model_fields
|
||||
assert "items" in fields, "ListGeneratedVideosResponse 缺少 items 字段"
|
||||
|
||||
def test_generation_task_results_endpoint_exists(self):
|
||||
"""GET /generation/tasks/{task_id}/results 端点存在。"""
|
||||
from apps.api.app.api.routes.generation_tasks import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/tasks/{task_id}/results" in paths, "generation results 端点不存在"
|
||||
|
||||
|
||||
# ─── 4. GeneratedVideo Use Cases 可用性 ───
|
||||
|
||||
|
||||
class TestGeneratedVideoUseCases:
|
||||
"""确认成片库 Use Case 层可正常工作。"""
|
||||
|
||||
def _make_video(self, video_id: str, project_id: str = "proj-1"):
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
return GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
generation_task_id="task-1",
|
||||
name=f"video-{video_id}.mp4",
|
||||
file_url=f"https://oss.example.com/{video_id}.mp4",
|
||||
file_size=1024,
|
||||
duration=5.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
def test_list_generated_videos_use_case(self):
|
||||
"""ListGeneratedVideosUseCase 可正常列出视频。"""
|
||||
from packages.application import ListGeneratedVideosUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
videos = [self._make_video(f"v{i}") for i in range(3)]
|
||||
repo.list_by_project.return_value = videos
|
||||
|
||||
use_case = ListGeneratedVideosUseCase(repo)
|
||||
result = use_case.execute("proj-1")
|
||||
|
||||
repo.list_by_project.assert_called_once_with("proj-1")
|
||||
assert len(result) == 3
|
||||
|
||||
def test_get_generated_video_use_case(self):
|
||||
"""GetGeneratedVideoUseCase 可正常获取单个视频。"""
|
||||
from packages.application import GetGeneratedVideoUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
video = self._make_video("v1")
|
||||
repo.get.return_value = video
|
||||
|
||||
use_case = GetGeneratedVideoUseCase(repo)
|
||||
result = use_case.execute("v1")
|
||||
|
||||
repo.get.assert_called_once_with("v1")
|
||||
assert result.id == "v1"
|
||||
|
||||
def test_list_by_task_use_case(self):
|
||||
"""ListGeneratedVideosByTaskUseCase 可按任务列出视频。"""
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
videos = [self._make_video(f"v{i}") for i in range(2)]
|
||||
repo.list_by_generation_task.return_value = videos
|
||||
|
||||
use_case = ListGeneratedVideosByTaskUseCase(repo)
|
||||
result = use_case.execute("task-1")
|
||||
|
||||
repo.list_by_generation_task.assert_called_once_with("task-1")
|
||||
assert len(result) == 2
|
||||
Reference in New Issue
Block a user