Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74c3acdf7d | |||
| 58fe71c483 | |||
| 3ae15eb4fc | |||
| 68e55df83b | |||
| 78afa40458 | |||
| e38f13eb96 | |||
| 42335eff42 | |||
| 9a51cad137 |
@@ -346,6 +346,7 @@ jobs:
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -417,6 +418,7 @@ jobs:
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -638,65 +640,83 @@ jobs:
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
- name: Pre-build worker base images (3-level cache)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
||||
|
||||
# 构建builder基础镜像
|
||||
echo "构建 worker-base-builder..."
|
||||
# 用buildx docker-container驱动构建(兼容DooD模式:普通docker build看不到容器内文件)
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||
|
||||
# 构建builder基础镜像(带重试,buildx容器偶发不稳定)
|
||||
echo "构建 worker-base-builder..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$BASE_BUILDER" .; then
|
||||
echo "worker-base-builder 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-builder 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 构建runtime基础镜像
|
||||
echo "构建 worker-base-runtime..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$BASE_RUNTIME" .; then
|
||||
echo "worker-base-runtime 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-runtime 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像本地构建完成"
|
||||
fi
|
||||
GITEA_REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
ACR_REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
GITEA_BUILDER="${GITEA_REGISTRY}/worker-base-builder:latest"
|
||||
GITEA_RUNTIME="${GITEA_REGISTRY}/worker-base-runtime:latest"
|
||||
ACR_BUILDER="${ACR_REGISTRY}/worker-base-builder:latest"
|
||||
ACR_RUNTIME="${ACR_REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# L1: 本地daemon缓存(DooD模式8runner共享宿主机daemon)
|
||||
echo "=== L1 本地缓存 ==="
|
||||
if docker image inspect "$ACR_BUILDER" > /dev/null 2>&1 \
|
||||
&& docker image inspect "$ACR_RUNTIME" > /dev/null 2>&1; then
|
||||
echo "本地缓存命中"
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "本地无缓存"
|
||||
|
||||
# L2: Gitea registry缓存(内网快)
|
||||
echo "=== L2 Registry拉取 ==="
|
||||
if docker pull "$GITEA_BUILDER" 2>/dev/null && docker pull "$GITEA_RUNTIME" 2>/dev/null; then
|
||||
echo "Registry拉取成功,重tag供Dockerfile使用"
|
||||
docker tag "$GITEA_BUILDER" "$ACR_BUILDER"
|
||||
docker tag "$GITEA_RUNTIME" "$ACR_RUNTIME"
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "Registry无缓存,需本地构建"
|
||||
|
||||
# L3: 本地构建
|
||||
echo "=== L3 本地构建 ==="
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||
|
||||
echo "构建 worker-base-builder..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$ACR_BUILDER" .; then
|
||||
echo "worker-base-builder 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-builder 失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "构建 worker-base-runtime..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$ACR_RUNTIME" .; then
|
||||
echo "worker-base-runtime 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-runtime 失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 推送到Gitea registry供后续复用
|
||||
echo "=== 推送缓存到Registry ==="
|
||||
docker tag "$ACR_BUILDER" "$GITEA_BUILDER"
|
||||
docker tag "$ACR_RUNTIME" "$GITEA_RUNTIME"
|
||||
docker push "$GITEA_BUILDER" 2>/dev/null || echo "push builder失败(不影响)"
|
||||
docker push "$GITEA_RUNTIME" 2>/dev/null || echo "push runtime失败(不影响)"
|
||||
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像构建完成"
|
||||
- name: Build PR image (verify only, no push)
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -710,15 +730,15 @@ jobs:
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
# Worker有本地base镜像时:用BuildKit直接构建(快,无需起buildx容器)
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.has_local_base }}" = "true" ]; then
|
||||
echo "本地base镜像已就绪,BuildKit快速构建"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "Fallback PR Build successful"
|
||||
DOCKER_BUILDKIT=1 docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "快速构建成功"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -115,11 +115,11 @@ jobs:
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci --include=dev; then
|
||||
if ! npm ci; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci --include=dev
|
||||
npm ci
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Step 1 模板选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_GRADIENTS } from "../constants"
|
||||
import { useStep1Template } from "../hooks/useStep1Template"
|
||||
|
||||
interface Step1TemplateSelectProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
}
|
||||
|
||||
const Step1TemplateSelect: React.FC<Step1TemplateSelectProps> = (props) => {
|
||||
const { templates, selectedTemplate, handleSelect, handleKeySelect } = useStep1Template(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎨 选择模板</h3>
|
||||
{templates.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<p>暂无可用模板</p>
|
||||
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
||||
请先在「模板编辑器」中创建模板
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-choice-list">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
|
||||
onClick={() => handleSelect(tpl.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedTemplate === tpl.id}
|
||||
onKeyDown={(e) => handleKeySelect(e, tpl.id)}
|
||||
>
|
||||
<span className="xx-choice-check">✓</span>
|
||||
<div
|
||||
className="xx-choice-thumb"
|
||||
style={{
|
||||
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
<h4>{tpl.name}</h4>
|
||||
<p>
|
||||
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
||||
</p>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{tpl.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-secondary)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step1TemplateSelect
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Step 2 素材选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { LoadingOutlined, PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface Step2MaterialSelectProps {
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
const {
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
materialsLoading,
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
formatDuration,
|
||||
} = useStep2Materials(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>📦 选择素材</h3>
|
||||
|
||||
{/* ── 模式切换 Tab ── */}
|
||||
<div className="xx-material-mode-tabs">
|
||||
<button
|
||||
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
|
||||
onClick={() => onMaterialModeChange("manual")}
|
||||
type="button"
|
||||
>
|
||||
手动选择素材
|
||||
</button>
|
||||
<button
|
||||
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
|
||||
onClick={() => onMaterialModeChange("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 视频库选择(两种模式共用) ── */}
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择视频库</label>
|
||||
<select value={selectedLibraryId} onChange={(e) => setSelectedLibraryId(e.target.value)}>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ── 手动选择模式 ── */}
|
||||
{materialMode === "manual" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">已选 {selectedMaterials.length} 个素材</span>
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
||||
) : materials.items.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在视频库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleToggleMaterial(m.id)}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 自动匹配模式 ── */}
|
||||
{materialMode === "auto" && (
|
||||
<div className="xx-smart-match-section">
|
||||
{/* 描述输入区 */}
|
||||
<div className="xx-smart-match-input-area">
|
||||
<label className="xx-smart-match-label">🤖 描述你想要的视频内容</label>
|
||||
<textarea
|
||||
className="xx-smart-match-input"
|
||||
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
|
||||
value={smartMatchInput}
|
||||
onChange={(e) => setSmartMatchInput(e.target.value)}
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
handleSmartMatch()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="xx-smart-match-input-footer">
|
||||
<span className="xx-smart-match-tip">
|
||||
{materialsLoading
|
||||
? "扫描视频库中…"
|
||||
: `当前视频库共 ${materials.items.length} 个素材可供匹配`}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{hasMatched && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={handleRefreshMatch}
|
||||
disabled={smartMatching || materialsLoading}
|
||||
>
|
||||
🔄 换一批
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleSmartMatch}
|
||||
disabled={smartMatching || materialsLoading || !smartMatchInput.trim()}
|
||||
>
|
||||
{smartMatching ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
匹配中…
|
||||
</>
|
||||
) : (
|
||||
"✨ 智能匹配"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 推荐结果区 */}
|
||||
{hasMatched && !smartMatching && smartMatchedResults.length > 0 && (
|
||||
<div className="xx-smart-match-results">
|
||||
<div className="xx-smart-match-results-header">
|
||||
<span className="xx-smart-match-results-title">
|
||||
推荐素材 ({smartMatchedResults.length}个)
|
||||
</span>
|
||||
<div className="xx-smart-match-results-actions">
|
||||
<button type="button" className="xx-link-btn" onClick={handleSelectAllMatched}>
|
||||
全选
|
||||
</button>
|
||||
<span style={{ color: "var(--border-color)" }}>|</span>
|
||||
<button type="button" className="xx-link-btn" onClick={handleClearSmartSelect}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-smart-match-grid">
|
||||
{smartMatchedResults.map((result) => {
|
||||
const isSelected = smartSelectedIds.includes(result.asset.id)
|
||||
return (
|
||||
<div
|
||||
key={result.asset.id}
|
||||
className={`xx-smart-match-card ${isSelected ? "selected" : ""}`}
|
||||
onClick={() => handleToggleSmartSelect(result.asset.id)}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-smart-match-thumb">
|
||||
{result.asset.thumbnail_url ? (
|
||||
<img src={result.asset.thumbnail_url} alt={result.asset.name} />
|
||||
) : (
|
||||
<div className="xx-smart-match-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-smart-match-score">{result.matchScore}%</div>
|
||||
{isSelected && (
|
||||
<div className="xx-smart-match-check">
|
||||
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
|
||||
</div>
|
||||
)}
|
||||
{result.asset.duration && (
|
||||
<div className="xx-smart-match-duration">
|
||||
{formatDuration(result.asset.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 信息区 */}
|
||||
<div className="xx-smart-match-info">
|
||||
<div className="xx-smart-match-name" title={result.asset.name}>
|
||||
{result.asset.name}
|
||||
</div>
|
||||
<div className="xx-smart-match-reason">🎯 {result.matchReason}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 匹配中状态 */}
|
||||
{smartMatching && (
|
||||
<div className="xx-smart-match-loading">
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 32, color: "var(--primary-color)", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在分析素材…</div>
|
||||
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
正在根据描述从视频库中匹配最合适的素材
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未匹配状态提示 */}
|
||||
{!hasMatched && !smartMatching && (
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
输入视频内容描述,点击「智能匹配」让 AI 帮你选素材
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已选素材汇总 */}
|
||||
{hasMatched && !smartMatching && smartSelectedIds.length > 0 && (
|
||||
<div className="xx-smart-match-summary">
|
||||
<div className="xx-smart-match-summary-header">
|
||||
<span className="xx-pill xx-pill-ok">已选 {smartSelectedIds.length} 个素材</span>
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
|
||||
预计总时长约 {smartSelectedTotalDuration.toFixed(0)} 秒
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step2MaterialSelect
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useStep3Preview } from "../hooks/useStep3Preview"
|
||||
|
||||
interface Step3GeneratePreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
}
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = (props) => {
|
||||
const { templateName, materialCount, duration, videoRatio } = useStep3Preview(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">模板草稿预览</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
<span className="xx-preview-plan-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材数量</span>
|
||||
<span className="xx-preview-plan-value">{materialCount}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">预计时长</span>
|
||||
<span className="xx-preview-plan-value">{duration} 秒</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">
|
||||
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3GeneratePreview
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Select } from "antd"
|
||||
import { LoadingOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const {
|
||||
userTitles,
|
||||
titleSettings,
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
} = useStep4Title(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{/* AI 智能生成标题 */}
|
||||
<div className="xx-ai-title-section">
|
||||
<div className="xx-ai-title-header">
|
||||
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
|
||||
</div>
|
||||
<div className="xx-ai-title-input-row">
|
||||
<input
|
||||
className="xx-ai-title-input"
|
||||
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
|
||||
value={aiTitleInput}
|
||||
onChange={(e) => setAiTitleInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGenerateAiTitles()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerateAiTitles}
|
||||
disabled={aiTitleGenerating || !aiTitleInput.trim()}
|
||||
>
|
||||
{aiTitleGenerating ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
"生成标题"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{hasGeneratedTitles && !aiTitleGenerating && aiTitleResults.length > 0 && (
|
||||
<div className="xx-ai-title-results">
|
||||
<div className="xx-ai-title-results-header">
|
||||
<span className="xx-ai-title-results-count">
|
||||
为你生成 {aiTitleResults.length} 个标题
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-link-btn"
|
||||
onClick={handleRefreshAiTitles}
|
||||
disabled={aiTitleGenerating}
|
||||
>
|
||||
🔄 换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-ai-title-list">
|
||||
{aiTitleResults.map((item, idx) => {
|
||||
const isSelected = titleSettings.title === item.title
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`xx-ai-title-card ${isSelected ? "selected" : ""} ${item.style}`}
|
||||
onClick={() => handleSelectAiTitle(item.title)}
|
||||
>
|
||||
<div className="xx-ai-title-card-text">{item.title}</div>
|
||||
<div className="xx-ai-title-card-tag">{item.highlight}</div>
|
||||
{isSelected && (
|
||||
<div className="xx-ai-title-card-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{aiTitleGenerating && (
|
||||
<div className="xx-ai-title-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
AI 正在为你创作标题…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>或手动选择</span>
|
||||
</div>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div
|
||||
className={`xx-switch ${titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={toggleAiAutoSelect}
|
||||
>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
{/* 标题内容选择 */}
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={titleSettings.title || undefined}
|
||||
onChange={(val) => updateTitle(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>
|
||||
<input
|
||||
placeholder="输入自定义标题…"
|
||||
value={titleSettings.title}
|
||||
onChange={(e) => updateTitle(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题样式设置区 */}
|
||||
<div className="xx-title-style-section">
|
||||
<h4 className="xx-section-subtitle">标题样式</h4>
|
||||
|
||||
{/* 位置 + 字体 一行 */}
|
||||
<div className="xx-title-style-row">
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>位置</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={titleSettings.position}
|
||||
onChange={(e) => updatePosition(e.target.value)}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>字体</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={titleSettings.font}
|
||||
onChange={(e) => updateFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="xx-form-field">
|
||||
<div className="xx-field-label-row">
|
||||
<label>字号</label>
|
||||
<span className="xx-field-value">{titleSettings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={titleSettings.size}
|
||||
onChange={(e) => updateSize(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设样式 */}
|
||||
<div className="xx-form-field">
|
||||
<label>预设样式</label>
|
||||
<div className="xx-title-presets-grid">
|
||||
{titlePresets.map((p) => {
|
||||
const isActive = activePreset === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`xx-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() => applyPreset(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
className="xx-title-preset-preview-text"
|
||||
style={p.previewStyle as React.CSSProperties}
|
||||
>
|
||||
标题
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 样式按钮:粗体/斜体/描边/阴影 */}
|
||||
<div className="xx-form-field">
|
||||
<label>样式</label>
|
||||
<div className="xx-style-btns">
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.bold ? "active" : ""}`}
|
||||
onClick={toggleBold}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.italic ? "active" : ""}`}
|
||||
onClick={toggleItalic}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
||||
onClick={toggleStroke}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
||||
onClick={toggleShadow}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4TitleSettings
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Step 5 配音选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import {
|
||||
AudioOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useStep5Voice } from "../hooks/useStep5Voice"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
|
||||
const {
|
||||
presetVoices,
|
||||
presetVoicesLoading,
|
||||
clonedVoices,
|
||||
hasProcessing,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
handleVoiceRecommend,
|
||||
handleSelectRecommendedVoice,
|
||||
playingVoice,
|
||||
toggleVoicePlay,
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
customVoiceText,
|
||||
setCustomVoiceText,
|
||||
customAudioUrl,
|
||||
ttsError,
|
||||
completedTtsJobId,
|
||||
synthesizeMutation,
|
||||
handleSynthesizeVoice,
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
saveName,
|
||||
setSaveName,
|
||||
saveTagIds,
|
||||
setSaveTagIds,
|
||||
saveNewTag,
|
||||
setSaveNewTag,
|
||||
allTags,
|
||||
saveToLibraryMutation,
|
||||
handleOpenSaveModal,
|
||||
handleConfirmSave,
|
||||
handleAddTagInModal,
|
||||
handleOpenCloneModal,
|
||||
handleSelectClonedVoice,
|
||||
VOICE_GENDER_ICON,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
} = useStep5Voice(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
|
||||
{/* AI 智能推荐配音 */}
|
||||
<div className="xx-voice-recommend-section">
|
||||
<div className="xx-voice-recommend-header">
|
||||
<span className="xx-voice-recommend-label">✨ AI 智能推荐</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleVoiceRecommend}
|
||||
disabled={voiceRecommendLoading || presetVoicesLoading}
|
||||
>
|
||||
{voiceRecommendLoading ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
推荐中
|
||||
</>
|
||||
) : hasVoiceRecommend ? (
|
||||
"换一批"
|
||||
) : (
|
||||
"智能推荐"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceRecommendLoading && (
|
||||
<div className="xx-voice-recommend-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
根据视频内容为你匹配最合适的音色…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && (
|
||||
<div className="xx-voice-recommend-list">
|
||||
{voiceRecommendations.map((voiceId) => {
|
||||
const v = presetVoices.find((pv) => pv.voice_id === voiceId)
|
||||
if (!v) return null
|
||||
const isSelected = voiceMode === "preset" && selectedVoice === v.voice_id
|
||||
return (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-recommend-card ${isSelected ? "selected" : ""}`}
|
||||
onClick={() => handleSelectRecommendedVoice(v.voice_id)}
|
||||
>
|
||||
<div className="xx-voice-recommend-avatar">
|
||||
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
|
||||
</div>
|
||||
<div className="xx-voice-recommend-info">
|
||||
<div className="xx-voice-recommend-name">{v.name}</div>
|
||||
<div className="xx-voice-recommend-desc">{v.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="xx-voice-recommend-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 16 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && !hasVoiceRecommend && (
|
||||
<div className="xx-voice-recommend-empty">
|
||||
<span>点击「智能推荐」,AI 根据视频内容匹配音色</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>全部音色</span>
|
||||
</div>
|
||||
|
||||
{/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
|
||||
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
||||
{presetVoices.slice(0, 3).map((v) => (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-choice-item ${
|
||||
voiceMode === "preset" && selectedVoice === v.voice_id ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => handleSelectPresetVoice(v.voice_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={voiceMode === "preset" && selectedVoice === v.voice_id}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
handleSelectPresetVoice(v.voice_id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="xx-voice-choice-check">✓</span>
|
||||
<div className="xx-voice-choice-avatar">{VOICE_GENDER_ICON[v.gender] ?? "✨"}</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>{v.name}</h4>
|
||||
<p>{v.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{presetVoicesLoading && (
|
||||
<div className="xx-voice-choice-item" style={{ opacity: 0.5 }}>
|
||||
<div className="xx-voice-choice-avatar">⏳</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>加载中…</h4>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 克隆我的声音 */}
|
||||
<div
|
||||
className={`xx-voice-choice-item ${voiceMode === "clone" ? "selected" : ""}`}
|
||||
onClick={handleSelectCloneVoice}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={voiceMode === "clone"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
handleSelectCloneVoice()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="xx-voice-choice-check">✓</span>
|
||||
<div
|
||||
className="xx-voice-choice-avatar"
|
||||
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
|
||||
>
|
||||
🎤
|
||||
</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>克隆我的声音</h4>
|
||||
<p>上传语音样本克隆</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预设音色详细列表(当 voiceMode === preset 时显示 API 返回的音色) */}
|
||||
{voiceMode === "preset" && (
|
||||
<div>
|
||||
<div className="xx-form-field">
|
||||
<label>从配音库选择</label>
|
||||
<select value={selectedVoice} onChange={(e) => handleSelectPresetVoice(e.target.value)}>
|
||||
<option value="">请选择配音…</option>
|
||||
{presetVoicesLoading ? (
|
||||
<option disabled>加载中…</option>
|
||||
) : (
|
||||
presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name} — {v.description}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
{/* 试听按钮 */}
|
||||
{presetVoices.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{presetVoices.slice(0, 4).map((v) => (
|
||||
<button
|
||||
key={v.voice_id}
|
||||
className="xx-btn xx-btn-ghost"
|
||||
style={{ height: 32, padding: "0 12px", fontSize: 12 }}
|
||||
onClick={() => toggleVoicePlay(v.voice_id, v.preview_url)}
|
||||
>
|
||||
{playingVoice === v.voice_id ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 停止
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined /> {v.name}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自定义录制 */}
|
||||
{voiceMode === "custom" && (
|
||||
<div>
|
||||
<textarea
|
||||
placeholder="输入配音文案,点击合成按钮生成语音…"
|
||||
value={customVoiceText}
|
||||
onChange={(e) => setCustomVoiceText(e.target.value)}
|
||||
maxLength={500}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 100,
|
||||
border: "1px solid var(--border-color, #e2e8f0)",
|
||||
borderRadius: "var(--radius-sm, 10px)",
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
|
||||
onClick={handleSynthesizeVoice}
|
||||
>
|
||||
<AudioOutlined /> {synthesizeMutation.isPending ? "合成中…" : "合成语音"}
|
||||
</button>
|
||||
</div>
|
||||
{ttsError && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--error, #ef4444)",
|
||||
marginTop: 8,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</Text>
|
||||
)}
|
||||
{customAudioUrl && completedTtsJobId && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "var(--success, #10b981)" }}>✓ 语音合成完成</Text>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
|
||||
onClick={handleOpenSaveModal}
|
||||
>
|
||||
<SaveOutlined /> 存为素材
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 存为素材弹窗 ── */}
|
||||
{saveModalOpen && (
|
||||
<div className="xx-save-modal-overlay" onClick={() => setSaveModalOpen(false)}>
|
||||
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="xx-save-modal-header">
|
||||
<span>保存到配音库</span>
|
||||
<button className="xx-save-modal-close" onClick={() => setSaveModalOpen(false)}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-save-modal-body">
|
||||
<label className="xx-save-modal-label">素材名称</label>
|
||||
<input
|
||||
className="xx-save-modal-input"
|
||||
placeholder="留空则自动生成名称"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<label className="xx-save-modal-label">
|
||||
标签
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
(可选)
|
||||
</span>
|
||||
</label>
|
||||
<div className="xx-save-modal-tags">
|
||||
{saveTagIds.map((id) => {
|
||||
const tag = allTags.find((t) => t.id === id)
|
||||
return tag ? (
|
||||
<span key={id} className="xx-save-modal-tag active">
|
||||
{tag.name}
|
||||
<CloseOutlined
|
||||
className="xx-save-modal-tag-remove"
|
||||
onClick={() => setSaveTagIds((prev) => prev.filter((x) => x !== id))}
|
||||
/>
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
<input
|
||||
className="xx-save-modal-tag-input"
|
||||
placeholder="输入标签名回车添加"
|
||||
value={saveNewTag}
|
||||
onChange={(e) => setSaveNewTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddTagInModal(saveNewTag)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{allTags.length > 0 && (
|
||||
<div className="xx-save-modal-tag-presets">
|
||||
{allTags
|
||||
.filter((t) => !saveTagIds.includes(t.id))
|
||||
.slice(0, 12)
|
||||
.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="xx-save-modal-tag-preset"
|
||||
onClick={() => setSaveTagIds((prev) => [...prev, t.id])}
|
||||
>
|
||||
{t.name}
|
||||
<PlusOutlined style={{ fontSize: 10, marginLeft: 4 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-save-modal-footer">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={() => setSaveModalOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
disabled={saveToLibraryMutation.isPending}
|
||||
onClick={handleConfirmSave}
|
||||
>
|
||||
{saveToLibraryMutation.isPending ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆声音展开区域 */}
|
||||
{voiceMode === "clone" && (
|
||||
<div className="xx-clone-section">
|
||||
<p className="xx-clone-section-title">克隆我的声音</p>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
|
||||
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 36, fontSize: 13 }}
|
||||
onClick={handleOpenCloneModal}
|
||||
>
|
||||
<ThunderboltOutlined /> 克隆新声音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 轮询提示 */}
|
||||
{hasProcessing && (
|
||||
<div className="xx-clone-polling-hint" style={{ marginTop: 10 }}>
|
||||
<span className="xx-clone-polling-dot" />
|
||||
正在同步克隆进度...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已克隆声音列表 */}
|
||||
{clonedVoices.length > 0 && (
|
||||
<div className="xx-clone-voices-list">
|
||||
{clonedVoices.map((cv) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
|
||||
const isReady = cv.status === "ready"
|
||||
const selected = selectedClonedVoice === cv.id
|
||||
return (
|
||||
<div
|
||||
key={cv.id}
|
||||
className={`xx-clone-voice-row ${selected ? "selected" : ""} ${
|
||||
!isReady ? "disabled" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isReady) handleSelectClonedVoice(cv.id)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={isReady ? 0 : -1}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className={`xx-clone-avatar ${cv.status}`}>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-info">
|
||||
<div className="xx-clone-name">{cv.name}</div>
|
||||
<div className="xx-clone-status">
|
||||
<span
|
||||
className="xx-clone-status-dot"
|
||||
style={{ background: statusCfg.color }}
|
||||
/>
|
||||
<span style={{ color: statusCfg.color }}>{statusCfg.label}</span>
|
||||
{isReady && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{formatDuration(cv.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected && isReady && (
|
||||
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clonedVoices.length === 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
暂无克隆音色,点击「克隆新声音」开始
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
marginTop: 10,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
💡 提示:录音环境越安静,克隆效果越好。
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step5VoiceSelect
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Step 6 封面设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
} = useStep6Cover(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverSettings.enabled}
|
||||
onChange={(e) => toggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="xx-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) => setFrameTime(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setFrameTime(t)}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
<img src={coverSettings.upload_url} alt="封面预览" className="xx-cover-preview-img" />
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step6CoverSettings
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
const {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
} = useStep7Generate(props)
|
||||
|
||||
const { onRetry, onDismissError } = 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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step7ConfirmGenerate
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 视频生成 Hook
|
||||
* 封装视频生成的核心逻辑、状态管理、轮询等
|
||||
*/
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import {
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
getGenerationStatus,
|
||||
getEditPlan,
|
||||
} from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface UseGenerateVideoProps {
|
||||
titleSettings: TitleSettings
|
||||
selectedTemplate: string
|
||||
selectedMaterials: string[]
|
||||
materialMode: "manual" | "auto"
|
||||
smartSelectedIds: string[]
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
coverSettings: CoverConfig
|
||||
videoRatio: string
|
||||
style: string
|
||||
duration: number
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
}
|
||||
|
||||
export function useGenerateVideo({
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
}: UseGenerateVideoProps) {
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [generated, setGenerated] = useState(false)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
|
||||
|
||||
/* ── 生成阶段映射 ── */
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
voiceMode,
|
||||
})
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
setGenerated(false)
|
||||
setGenerateError(null)
|
||||
|
||||
try {
|
||||
const voiceConfig: Pick<
|
||||
EditPlanConfig,
|
||||
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||
> = {}
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
} else if (voiceMode === "clone") {
|
||||
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
||||
} else if (voiceMode === "custom") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
|
||||
// 自定义配音模式需从 step5 组件传回
|
||||
}
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing",
|
||||
})
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await getGenerationStatus(selectedTemplate)
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
setProgress(100)
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
setGeneratedVideos(videos)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
setGenerating(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const dataAny = data as Record<string, any>
|
||||
const rawMsg =
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
||||
?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const errorMsg = safeExtract(rawMsg)
|
||||
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
|
||||
setGenerateError(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const clips = data.clips || []
|
||||
const total = clips.length || 1
|
||||
const done = clips.filter((c: { status: string }) => c.status === "completed").length
|
||||
setProgress(Math.round((done / total) * 100))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string | object
|
||||
error?: string | object
|
||||
detail?: string | object
|
||||
msg?: string | object
|
||||
}
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return extractString(obj.message)
|
||||
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const backendMsg =
|
||||
extractString(axiosErr.response?.data?.message) ||
|
||||
extractString(axiosErr.response?.data?.error) ||
|
||||
extractString(axiosErr.response?.data?.detail) ||
|
||||
extractString(axiosErr.response?.data?.msg) ||
|
||||
axiosErr.message ||
|
||||
""
|
||||
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object") return safeExtractErr(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const rawError = safeExtractErr(backendMsg)
|
||||
const translateError = (msg: string): string => {
|
||||
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
||||
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
||||
return "正在准备生成,请稍候再试"
|
||||
}
|
||||
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
||||
return "所选模板或素材不可用,请重新选择"
|
||||
}
|
||||
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
||||
return "素材数据异常,请返回视频库重新检查"
|
||||
}
|
||||
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
||||
return "网络连接超时,请检查网络后重试"
|
||||
}
|
||||
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
||||
return "已达到生成次数上限,请稍后再试或联系客服"
|
||||
}
|
||||
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
||||
return "生成失败,请稍后重试或联系管理员"
|
||||
}
|
||||
const finalMsg = translateError(rawError)
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
generate()
|
||||
}, [generate])
|
||||
|
||||
/* 清除错误 */
|
||||
const dismissError = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
}, [])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const download = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
try {
|
||||
const url = video.download_url || video.file_url
|
||||
if (url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = video.name || "generated-video.mp4"
|
||||
a.target = "_blank"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[下载失败]", err)
|
||||
message.error("下载失败,请重试")
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const share = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
const shareUrl = video.file_url || window.location.href
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
message.success("视频链接已复制到剪贴板")
|
||||
} catch {
|
||||
message.info(`视频链接: ${shareUrl}`)
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
generating,
|
||||
progress,
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
// 操作
|
||||
generate,
|
||||
retry,
|
||||
dismissError,
|
||||
download,
|
||||
share,
|
||||
// 工具
|
||||
getGenerationPhase,
|
||||
}
|
||||
}
|
||||
|
||||
export default useGenerateVideo
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Step 1 模板选择 Hook
|
||||
* 封装模板选择的交互逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseStep1TemplateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
}
|
||||
|
||||
export function useStep1Template({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
onSelectTemplate,
|
||||
}: UseStep1TemplateProps) {
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelectTemplate(id)
|
||||
},
|
||||
[onSelectTemplate],
|
||||
)
|
||||
|
||||
const handleKeySelect = useCallback(
|
||||
(e: React.KeyboardEvent, id: string) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectTemplate(id)
|
||||
}
|
||||
},
|
||||
[onSelectTemplate],
|
||||
)
|
||||
|
||||
return {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
handleSelect,
|
||||
handleKeySelect,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep1Template
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Step 2 素材选择 Hook
|
||||
* 封装素材库加载、手动选择、智能匹配等逻辑
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
import { SMART_MATCH_REASONS } from "../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
selectedMaterials,
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
/* ── 智能素材匹配状态 ── */
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
onSelectedMaterialsChange(
|
||||
selectedMaterials.includes(materialId)
|
||||
? selectedMaterials.filter((id) => id !== materialId)
|
||||
: [...selectedMaterials, materialId],
|
||||
)
|
||||
},
|
||||
[selectedMaterials, onSelectedMaterialsChange],
|
||||
)
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
return
|
||||
}
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
|
||||
return {
|
||||
// 素材库
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
materialsLoading,
|
||||
// 模式
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
// 手动选择
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
// utils
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep2Materials
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 封装预览信息的计算逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
}
|
||||
|
||||
export function useStep3Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
}: UseStep3PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep3Preview
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
/* ── 标题库 API ── */
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: () => getTitles(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── AI 标题生成状态 ── */
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
/* ── 辅助函数 ── */
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
// 取前3个关键词组合
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const getActivePreset = (settings: TitleSettings): string | null => {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
settings.size === p.style.size &&
|
||||
settings.color === p.style.color &&
|
||||
settings.bold === p.style.bold &&
|
||||
settings.italic === p.style.italic &&
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
|
||||
|
||||
/* ── AI 标题生成 ── */
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
|
||||
// 模拟 AI 生成延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
// 每种风格随机选2个
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
const title = tpl.replace(/\{topic\}/g, topic)
|
||||
const highlights = {
|
||||
catchy: "吸睛标题",
|
||||
emotional: "情感共鸣",
|
||||
informative: "知识干货",
|
||||
}
|
||||
results.push({
|
||||
title,
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 打乱顺序
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
// 重新生成一批
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/* ── 标题设置更新 ── */
|
||||
const updateTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleAiAutoSelect = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(position: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateFont = useCallback(
|
||||
(font: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, font })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateSize = useCallback(
|
||||
(size: number) => {
|
||||
onTitleSettingsChange({ ...titleSettings, size })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateColor = useCallback(
|
||||
(color: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, color })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleBold = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleItalic = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleStroke = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleShadow = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
onTitleSettingsChange({
|
||||
...titleSettings,
|
||||
size: preset.style.size,
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset,
|
||||
titlePresets: TITLE_PRESETS,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
// 标题设置操作
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
updateColor,
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Title
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Step 5 配音选择 Hook
|
||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||
|
||||
interface UseStep5VoiceProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
}
|
||||
|
||||
export function useStep5Voice({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceMode,
|
||||
onVoiceModeChange,
|
||||
selectedClonedVoice,
|
||||
onSelectedClonedVoiceChange,
|
||||
clonedVoices,
|
||||
addClone,
|
||||
hasProcessing,
|
||||
cloneModalOpen,
|
||||
onCloneModalOpenChange,
|
||||
titleText,
|
||||
}: UseStep5VoiceProps) {
|
||||
const navigate = useNavigate()
|
||||
/* ── 预置音色 API ── */
|
||||
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── 音频播放 ── */
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
||||
|
||||
const toggleVoicePlay = useCallback(
|
||||
(voiceId: string, previewUrl: string | null) => {
|
||||
if (playingVoice === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoice(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
if (!previewUrl) {
|
||||
message.warning("该音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
const audio = new Audio(previewUrl)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
message.error("播放失败,请检查网络")
|
||||
})
|
||||
audio.onended = () => {
|
||||
setPlayingVoice(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
setPlayingVoice(voiceId)
|
||||
},
|
||||
[playingVoice],
|
||||
)
|
||||
|
||||
/* ── 智能配音推荐 ── */
|
||||
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
||||
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
||||
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
||||
|
||||
const handleVoiceRecommend = useCallback(async () => {
|
||||
if (presetVoices.length === 0) return
|
||||
setVoiceRecommendLoading(true)
|
||||
setHasVoiceRecommend(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
|
||||
const title = titleText.toLowerCase()
|
||||
let recommended: string[] = []
|
||||
|
||||
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
||||
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
||||
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
||||
|
||||
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
||||
recommended = femaleVoices.slice(0, 3)
|
||||
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
||||
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
||||
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
||||
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
||||
} else {
|
||||
// 默认推荐前3个
|
||||
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
||||
}
|
||||
|
||||
// 不足3个时补足
|
||||
if (recommended.length < 3) {
|
||||
const others = presetVoices
|
||||
.filter((v) => !recommended.includes(v.voice_id))
|
||||
.map((v) => v.voice_id)
|
||||
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
||||
}
|
||||
|
||||
setVoiceRecommendations(recommended)
|
||||
setVoiceRecommendLoading(false)
|
||||
}, [presetVoices, titleText])
|
||||
|
||||
const handleSelectRecommendedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/* ── TTS 自定义合成状态 ── */
|
||||
const [customVoiceText, setCustomVoiceText] = useState("")
|
||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
/** 合成完成后保留的 job ID,用于"存为素材" */
|
||||
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
||||
|
||||
/* ── TTS mutation ── */
|
||||
const synthesizeMutation = useMutation({
|
||||
mutationFn: synthesizeSpeech,
|
||||
onSuccess: (data) => {
|
||||
setTtsJobId(data.job_id)
|
||||
message.info("语音合成已提交,等待处理…")
|
||||
},
|
||||
onError: () => {
|
||||
setTtsError("语音合成请求失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
/** 轮询 TTS 任务状态 */
|
||||
useEffect(() => {
|
||||
if (!ttsJobId) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getTTSJobStatus(ttsJobId)
|
||||
if (cancelled) return
|
||||
if (status.status === "completed") {
|
||||
setCustomAudioUrl(status.output_audio_url)
|
||||
setCompletedTtsJobId(ttsJobId)
|
||||
setTtsJobId(null)
|
||||
setTtsError(null)
|
||||
message.success("语音合成完成!")
|
||||
return
|
||||
}
|
||||
if (status.status === "failed" || status.status === "cancelled") {
|
||||
setTtsError(status.error_message || "语音合成失败")
|
||||
setTtsJobId(null)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(poll, 2000)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTtsError("查询合成状态失败")
|
||||
setTtsJobId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, 2000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [ttsJobId])
|
||||
|
||||
/** 触发自定义文本 TTS 合成 */
|
||||
const handleSynthesizeVoice = useCallback(() => {
|
||||
if (!customVoiceText.trim()) {
|
||||
message.warning("请先输入配音文案")
|
||||
return
|
||||
}
|
||||
setTtsError(null)
|
||||
setCustomAudioUrl(null)
|
||||
synthesizeMutation.mutate({
|
||||
text: customVoiceText.trim(),
|
||||
voice_id: selectedVoice || undefined,
|
||||
language: "zh-CN",
|
||||
})
|
||||
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
||||
|
||||
/* ── 存为素材弹窗状态 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||
const [saveName, setSaveName] = useState("")
|
||||
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
||||
const [saveNewTag, setSaveNewTag] = useState("")
|
||||
|
||||
/* ── 标签列表(用于存为素材弹窗) ── */
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ["generate-save-tags"],
|
||||
queryFn: getTags,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── 存为素材 mutation ── */
|
||||
const saveToLibraryMutation = useMutation({
|
||||
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
||||
saveTtsToLibrary(completedTtsJobId!, params),
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
已保存到配音库!{" "}
|
||||
<a
|
||||
onClick={handleGoToLibrary}
|
||||
style={{
|
||||
color: "var(--primary-500, #6366f1)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去视频库查看
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
duration: 5,
|
||||
})
|
||||
setSaveModalOpen(false)
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setCompletedTtsJobId(null)
|
||||
setCustomAudioUrl(null)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`保存失败:${err.message || "请重试"}`)
|
||||
},
|
||||
})
|
||||
|
||||
/** 打开存为素材弹窗 */
|
||||
const handleOpenSaveModal = useCallback(() => {
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setSaveModalOpen(true)
|
||||
}, [])
|
||||
|
||||
/** 确认保存 */
|
||||
const handleConfirmSave = useCallback(() => {
|
||||
if (!completedTtsJobId) return
|
||||
saveToLibraryMutation.mutate({
|
||||
name: saveName.trim() || undefined,
|
||||
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
||||
})
|
||||
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
||||
|
||||
/** 在弹窗中新增标签(先创建再选中) */
|
||||
const handleAddTagInModal = useCallback(
|
||||
async (tagName: string) => {
|
||||
const trimmed = tagName.trim()
|
||||
if (!trimmed) return
|
||||
/* 已在选中列表则跳过 */
|
||||
const existing = allTags.find((t) => t.name === trimmed)
|
||||
if (existing) {
|
||||
if (!saveTagIds.includes(existing.id)) {
|
||||
setSaveTagIds((prev) => [...prev, existing.id])
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const created = await createTag(trimmed)
|
||||
setSaveTagIds((prev) => [...prev, created.id])
|
||||
setSaveNewTag("")
|
||||
} catch {
|
||||
message.error(`创建标签"${trimmed}"失败`)
|
||||
}
|
||||
},
|
||||
[allTags, saveTagIds],
|
||||
)
|
||||
|
||||
/** 保存成功后跳转到视频库 */
|
||||
const handleGoToLibrary = useCallback(() => {
|
||||
navigate("/app/voice-materials")
|
||||
}, [navigate])
|
||||
|
||||
/* ── 克隆成功回调 ── */
|
||||
const handleCloneSuccess = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
onCloneModalOpenChange(false)
|
||||
message.success("音色克隆成功!")
|
||||
},
|
||||
[addClone, onCloneModalOpenChange],
|
||||
)
|
||||
|
||||
/* ── 预设音色选择操作 ── */
|
||||
const handleSelectPresetVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
const handleSelectCloneVoice = useCallback(() => {
|
||||
onVoiceModeChange("clone")
|
||||
}, [onVoiceModeChange])
|
||||
|
||||
const handleSelectClonedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onSelectedClonedVoiceChange(voiceId)
|
||||
},
|
||||
[onSelectedClonedVoiceChange],
|
||||
)
|
||||
|
||||
const handleOpenCloneModal = useCallback(() => {
|
||||
onCloneModalOpenChange(true)
|
||||
}, [onCloneModalOpenChange])
|
||||
|
||||
return {
|
||||
// 数据
|
||||
presetVoices,
|
||||
presetVoicesLoading,
|
||||
clonedVoices,
|
||||
hasProcessing,
|
||||
// 模式 & 选择
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
// AI 推荐
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
handleVoiceRecommend,
|
||||
handleSelectRecommendedVoice,
|
||||
// 音频播放
|
||||
playingVoice,
|
||||
toggleVoicePlay,
|
||||
// 预设音色操作
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
// 自定义 TTS
|
||||
customVoiceText,
|
||||
setCustomVoiceText,
|
||||
customAudioUrl,
|
||||
ttsError,
|
||||
ttsJobId,
|
||||
completedTtsJobId,
|
||||
synthesizeMutation,
|
||||
handleSynthesizeVoice,
|
||||
// 存为素材
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
saveName,
|
||||
setSaveName,
|
||||
saveTagIds,
|
||||
setSaveTagIds,
|
||||
saveNewTag,
|
||||
setSaveNewTag,
|
||||
allTags,
|
||||
saveToLibraryMutation,
|
||||
handleOpenSaveModal,
|
||||
handleConfirmSave,
|
||||
handleAddTagInModal,
|
||||
// 克隆
|
||||
cloneModalOpen,
|
||||
handleOpenCloneModal,
|
||||
handleCloneSuccess,
|
||||
handleSelectClonedVoice,
|
||||
// utils
|
||||
VOICE_GENDER_ICON,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep5Voice
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
}: UseStep6CoverProps) {
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}, [])
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
onCoverSettingsChange({ ...coverSettings, enabled })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: CoverConfig["mode"]) => {
|
||||
onCoverSettingsChange({ ...coverSettings, mode })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setFrameTime = useCallback(
|
||||
(frameTime: number) => {
|
||||
onCoverSettingsChange({ ...coverSettings, frame_time: frameTime })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const url = ev.target?.result as string
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
DEFAULT_COVER_SETTINGS,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep6Cover
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Step 7 确认生成 Hook
|
||||
* 封装生成确认页的展示逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
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 { COVER_MODE_LABELS } from "../constants"
|
||||
|
||||
interface UseStep7GenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
export function useStep7Generate({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
title,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
coverSettings,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
}: UseStep7GenerateProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialSummary = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const voiceName = useMemo(() => {
|
||||
if (voiceMode === "clone") {
|
||||
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
|
||||
return cv ? cv.name : "未选择"
|
||||
}
|
||||
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
|
||||
return pv ? pv.name : "未选择"
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice, presetVoices, clonedVoices])
|
||||
|
||||
const coverSummary = useMemo(() => {
|
||||
if (!coverSettings.enabled) return "不使用"
|
||||
return COVER_MODE_LABELS[coverSettings.mode] || "智能封面"
|
||||
}, [coverSettings])
|
||||
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
const handleDecrement = () => {
|
||||
onGenerateCountChange(Math.max(1, generateCount - 1))
|
||||
}
|
||||
|
||||
const handleIncrement = () => {
|
||||
onGenerateCountChange(Math.min(10, generateCount + 1))
|
||||
}
|
||||
|
||||
const handleScrollToPreview = () => {
|
||||
const el = document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep7Generate
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* GenerateHeader 组件单元测试
|
||||
* 同时 import GeneratePage 主组件,确保 vitest related 模式
|
||||
* 能匹配到 generate 目录下所有文件的改动
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import GenerateHeader from "@/pages/generate/components/GenerateHeader"
|
||||
// 引入主组件以建立依赖链,让 vitest related 覆盖整个 generate 目录
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("GenerateHeader", () => {
|
||||
it("should render title and description", () => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Worker Dockerfile - 分层缓存优化版
|
||||
# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖
|
||||
# 基础镜像:worker-base-builder / worker-base-runtime
|
||||
# 预计节省:依赖不变时构建时间从23min降至5min以内
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
@@ -24,8 +23,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- 增量瘦身(只处理新增的业务依赖)----
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
# ---- 增量瘦身(清理新增业务依赖的冗余文件)----
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
@@ -41,30 +39,33 @@ ARG APP_VERSION=dev
|
||||
# 从 builder 复制 Python 虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制应用代码
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
COPY apps/api/app/core/ /app/apps/api/app/core/
|
||||
COPY packages/ /app/packages/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
|
||||
# 复制 Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 设置 Python 路径
|
||||
# 设置 Python 环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 创建非 root 用户运行 Worker
|
||||
RUN groupadd -r celery && useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
&& mkdir -p /app/generated && chown celery:celery /app/generated
|
||||
# 创建非 root 用户(极少变化,放最前)
|
||||
RUN groupadd -r celery \
|
||||
&& useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
&& mkdir -p /app/generated \
|
||||
&& chown celery:celery /app/generated
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制文件按变化频率从低到高排序,最大化层缓存命中
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
COPY apps/api/app/core/ /app/apps/api/app/core/
|
||||
|
||||
# 复制 Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 业务代码(变化最频繁,放最后)
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
|
||||
USER celery
|
||||
|
||||
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
"""Storage 端口接口 — 统一存储服务的抽象定义。
|
||||
|
||||
所有存储实现(OSS、本地、S3等)都必须实现这个端口。
|
||||
API 和 Worker 都通过这个端口与存储交互,消除两套独立实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class StoragePort(ABC):
|
||||
"""统一存储服务端口。
|
||||
|
||||
定义所有存储后端必须实现的核心能力。
|
||||
具体实现见 packages.shared.storage.SharedStorageService。
|
||||
"""
|
||||
|
||||
# ── 基础上传 / 下载 ────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def upload_file(
|
||||
self,
|
||||
file_or_path: Union[str, Path, object],
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""上传文件到存储,返回公开 URL。
|
||||
|
||||
Args:
|
||||
file_or_path: 本地文件路径(str/Path)或类文件对象
|
||||
storage_key: 目标存储键
|
||||
content_type: MIME 类型
|
||||
|
||||
Returns:
|
||||
公开访问 URL
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def download_file(self, storage_key_or_url: str, local_path: Union[str, Path]) -> bool:
|
||||
"""从存储下载文件到本地。
|
||||
|
||||
自动识别输入:完整URL走HTTP下载(支持预签名),存储键走SDK下载。
|
||||
|
||||
Args:
|
||||
storage_key_or_url: 存储键或完整 URL
|
||||
local_path: 本地保存路径
|
||||
|
||||
Returns:
|
||||
True 成功,False 失败
|
||||
"""
|
||||
...
|
||||
|
||||
# ── URL 生成 ──────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""获取公开 URL。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""获取预签名下载 URL(私有 bucket 用)。
|
||||
|
||||
未配置OSS时降级为公开URL。
|
||||
"""
|
||||
...
|
||||
|
||||
# ── 文件操作 ──────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def delete_file(self, storage_key: str) -> None:
|
||||
"""删除文件(不抛异常)。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
"""检查文件是否存在。"""
|
||||
...
|
||||
|
||||
# ── 浏览器直传 ────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
storage_key: str,
|
||||
content_type: str,
|
||||
max_size_bytes: int,
|
||||
expires_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
"""创建浏览器直传 POST 表单(用于前端直传OSS)。"""
|
||||
...
|
||||
|
||||
# ── Asset 解析(Worker 用)────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def resolve_asset_path(self, asset_id: str, work_dir: Union[str, Path]) -> Optional[Path]:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略:本地路径 → 缓存命中 → OSS下载 → None
|
||||
缓存:SHA256(asset_id)[:16] 为文件名,避免重复下载
|
||||
"""
|
||||
...
|
||||
|
||||
# ── 工具方法 ──────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 提取存储键,URL decode 处理。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def diagnose(self) -> None:
|
||||
"""输出存储配置诊断日志。"""
|
||||
...
|
||||
+321
-61
@@ -1,4 +1,13 @@
|
||||
"""Shared OSS storage service for API and Worker."""
|
||||
"""统一存储服务 — API 和 Worker 共用的唯一存储入口。
|
||||
|
||||
实现 StoragePort 端口接口,整合原来分散在各处的存储能力:
|
||||
- API端 SharedStorageService 的全部能力(上传/下载/签名URL/直传POST)
|
||||
- Worker端 oss_helpers 的高级能力(分片上传/超时保护/HTTP下载/Asset路径解析)
|
||||
|
||||
所有服务都通过这个统一入口与存储交互,消除重复实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
@@ -7,53 +16,70 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
import oss2
|
||||
except ImportError: # pragma: no cover
|
||||
oss2 = None
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
from packages.config import get_shared_settings
|
||||
from packages.ports.storage_port import StoragePort
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── OSS 高级配置(从 oss_helpers 合并)─────────────────────────────────
|
||||
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒)
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒)
|
||||
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB
|
||||
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
|
||||
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
|
||||
OSS_HTTP_DOWNLOAD_TIMEOUT = 300 # HTTP下载超时(秒)
|
||||
|
||||
class SharedStorageService:
|
||||
"""Shared OSS storage service."""
|
||||
|
||||
class SharedStorageService(StoragePort):
|
||||
"""统一存储服务 — 实现 StoragePort,API 和 Worker 共用。
|
||||
|
||||
整合了原 SharedStorageService + oss_helpers 的全部能力。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
settings = get_shared_settings()
|
||||
self.bucket_name = settings.oss_bucket_name
|
||||
self.endpoint = settings.oss_endpoint
|
||||
self.public_url = f"https://{settings.oss_bucket_name}.{settings.oss_endpoint}"
|
||||
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
||||
self.bucket = None
|
||||
|
||||
has_key_id = bool(settings.oss_access_key_id)
|
||||
has_key_secret = bool(settings.oss_access_key_secret)
|
||||
self.access_key_id = settings.oss_access_key_id
|
||||
self.access_key_secret = settings.oss_access_key_secret
|
||||
|
||||
has_key_id = bool(self.access_key_id)
|
||||
has_key_secret = bool(self.access_key_secret)
|
||||
|
||||
if has_key_id and has_key_secret:
|
||||
if oss2 is not None:
|
||||
try:
|
||||
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
||||
# 否则 sign_url 默认生成 HTTP URL。
|
||||
bucket_endpoint = settings.oss_endpoint
|
||||
# endpoint 不带 scheme 时补 https:// 前缀
|
||||
bucket_endpoint = self.endpoint
|
||||
if not bucket_endpoint.startswith(("http://", "https://")):
|
||||
bucket_endpoint = f"https://{bucket_endpoint}"
|
||||
auth = oss2.Auth(
|
||||
settings.oss_access_key_id,
|
||||
settings.oss_access_key_secret,
|
||||
)
|
||||
auth = oss2.Auth(self.access_key_id, self.access_key_secret)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
bucket_endpoint,
|
||||
settings.oss_bucket_name,
|
||||
self.bucket_name,
|
||||
connect_timeout=OSS_CONNECT_TIMEOUT,
|
||||
)
|
||||
logger.info(
|
||||
"OSS initialized: endpoint=%s bucket=%s",
|
||||
settings.oss_endpoint,
|
||||
settings.oss_bucket_name,
|
||||
self.endpoint,
|
||||
self.bucket_name,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to initialize OSS bucket client: %s", error)
|
||||
@@ -67,12 +93,10 @@ class SharedStorageService:
|
||||
missing.append("OSS_ACCESS_KEY_SECRET")
|
||||
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
|
||||
|
||||
self.access_key_id = settings.oss_access_key_id
|
||||
self.access_key_secret = settings.oss_access_key_secret
|
||||
self.endpoint = settings.oss_endpoint
|
||||
# ── 诊断 ───────────────────────────────────────────────────────────
|
||||
|
||||
def diagnose(self) -> None:
|
||||
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
||||
"""输出存储配置诊断日志。"""
|
||||
key_id_display = (
|
||||
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
||||
)
|
||||
@@ -86,89 +110,234 @@ class SharedStorageService:
|
||||
logger.error(
|
||||
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
||||
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
||||
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
||||
)
|
||||
else:
|
||||
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
||||
|
||||
# ── 工具方法 ───────────────────────────────────────────────────────
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
return path.startswith(f"{self.local_url_prefix}/")
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 提取存储键,并做 URL 解码。
|
||||
|
||||
防止 URL 编码的字符(空格=%20、中文=%XX)导致签名不匹配。
|
||||
"""
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
return unquote(parsed.path.lstrip("/"))
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
def normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 提取存储键(公开方法)。"""
|
||||
return self._normalize_storage_key(storage_key_or_url)
|
||||
|
||||
# ── 上传 ───────────────────────────────────────────────────────────
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
file_or_path,
|
||||
file_or_path: Union[str, Path, object],
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""Upload file to OSS."""
|
||||
"""上传文件到存储,返回公开 URL(简单上传,API端原有行为)。
|
||||
|
||||
- 路径字符串 → bucket.put_object_from_file
|
||||
- 类文件对象 → bucket.put_object
|
||||
- bucket未配置 → 抛 RuntimeError
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
if isinstance(file_or_path, str):
|
||||
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
if isinstance(file_or_path, (str, Path)):
|
||||
self.bucket.put_object_from_file(storage_key, str(file_or_path), headers={"Content-Type": content_type})
|
||||
else:
|
||||
file_or_path.seek(0)
|
||||
file_or_path.seek(0) # type: ignore[attr-defined]
|
||||
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to upload file to OSS: {e}") from e
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""Get public URL for a file."""
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
def upload_file_smart(
|
||||
self,
|
||||
local_path: Union[str, Path],
|
||||
storage_key: str,
|
||||
) -> Optional[str]:
|
||||
"""智能上传:大文件自动分片+超时保护(从 oss_helpers 合并)。
|
||||
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
"""Download file from OSS to local path."""
|
||||
- 大文件(>100MB)走分片上传,3 线程并发
|
||||
- 总超时 300s,防止网络异常时挂死
|
||||
- 成功返回 URL,失败返回 None(不抛异常)
|
||||
|
||||
Worker端 oss_helpers.upload_to_oss 的统一入口。
|
||||
"""
|
||||
local_path = Path(local_path)
|
||||
if not local_path.exists():
|
||||
logger.error("上传文件不存在: %s", local_path)
|
||||
return None
|
||||
if self.bucket is None:
|
||||
logger.error("OSS未配置,无法上传: %s", storage_key[:80])
|
||||
return None
|
||||
|
||||
result: dict = {"url": None, "error": None, "file_size": 0}
|
||||
done = threading.Event()
|
||||
|
||||
def _do_upload():
|
||||
try:
|
||||
try:
|
||||
file_size = local_path.stat().st_size
|
||||
result["file_size"] = file_size
|
||||
use_multipart = file_size >= OSS_MULTIPART_THRESHOLD
|
||||
except OSError:
|
||||
use_multipart = False
|
||||
file_size = 0
|
||||
|
||||
if use_multipart:
|
||||
logger.info(
|
||||
"大文件分片上传: storage_key=%s, size=%.1fMB, part_size=%dMB, threads=%d",
|
||||
storage_key[:80],
|
||||
file_size / 1024 / 1024,
|
||||
OSS_PART_SIZE // 1024 // 1024,
|
||||
OSS_MULTIPART_NUM_THREADS,
|
||||
)
|
||||
oss2.resumable_upload(
|
||||
self.bucket,
|
||||
storage_key,
|
||||
str(local_path),
|
||||
multipart_threshold=OSS_MULTIPART_THRESHOLD,
|
||||
part_size=OSS_PART_SIZE,
|
||||
num_threads=OSS_MULTIPART_NUM_THREADS,
|
||||
)
|
||||
else:
|
||||
self.bucket.put_object_from_file(storage_key, str(local_path))
|
||||
|
||||
result["url"] = f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
result["error"] = e
|
||||
logger.exception("上传 OSS 失败: %s", storage_key)
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
upload_thread = threading.Thread(target=_do_upload, daemon=True)
|
||||
upload_thread.start()
|
||||
finished = done.wait(timeout=OSS_UPLOAD_TOTAL_TIMEOUT)
|
||||
|
||||
if not finished:
|
||||
logger.error(
|
||||
"OSS 上传超时(%.0fs),强制中止: storage_key=%s, size=%.1fMB",
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT,
|
||||
storage_key[:80],
|
||||
result["file_size"] / 1024 / 1024 if result["file_size"] else 0,
|
||||
)
|
||||
return None
|
||||
|
||||
if result["error"]:
|
||||
return None
|
||||
|
||||
return result["url"]
|
||||
|
||||
# ── 下载 ───────────────────────────────────────────────────────────
|
||||
|
||||
def download_file(self, storage_key: str, local_path: Union[str, Path]) -> None:
|
||||
"""从 OSS 下载文件(简单下载,API端原有行为)。
|
||||
|
||||
bucket未配置 → 抛 RuntimeError
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
local_path = Path(local_path)
|
||||
os.makedirs(local_path.parent, exist_ok=True)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
self.bucket.get_object_to_file(storage_key, local_path)
|
||||
self.bucket.get_object_to_file(self._normalize_storage_key(storage_key), str(local_path))
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to download file from OSS: {e}") from e
|
||||
|
||||
def download_asset(self, asset_storage_key: str, local_path: Union[str, Path]) -> bool:
|
||||
"""下载素材(从 oss_helpers 合并)。
|
||||
|
||||
自动识别输入类型:
|
||||
- 完整 URL → 走 HTTP 下载(支持预签名URL)
|
||||
- 存储键 → 走 oss2 SDK 下载
|
||||
|
||||
成功返回 True,失败返回 False(不抛异常)。
|
||||
"""
|
||||
local_path = Path(local_path)
|
||||
os.makedirs(local_path.parent, exist_ok=True)
|
||||
|
||||
# 完整URL走HTTP下载(兼容预签名URL)
|
||||
if asset_storage_key.startswith(("http://", "https://")):
|
||||
return self._download_via_http(asset_storage_key, local_path)
|
||||
|
||||
# OSS存储键走SDK
|
||||
if self.bucket is None:
|
||||
logger.error("OSS not configured, cannot download: %s", asset_storage_key[:80])
|
||||
return False
|
||||
try:
|
||||
self.bucket.get_object_to_file(self._normalize_storage_key(asset_storage_key), str(local_path))
|
||||
return local_path.exists() and local_path.stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("下载素材失败: %s", asset_storage_key)
|
||||
return False
|
||||
|
||||
def _download_via_http(self, url: str, local_path: Path) -> bool:
|
||||
"""通过 HTTP 下载文件(支持预签名 URL)。
|
||||
|
||||
流式下载避免大文件内存溢出。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=OSS_HTTP_DOWNLOAD_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return local_path.exists() and local_path.stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("HTTP下载素材失败: %s", url[:100])
|
||||
return False
|
||||
|
||||
# ── URL 生成 ──────────────────────────────────────────────────────
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""获取公开 URL。"""
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""Get signed download URL."""
|
||||
"""获取预签名下载 URL。
|
||||
|
||||
bucket未配置时降级为公开URL;本地产物URL直接返回。
|
||||
"""
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. storage_key_or_url=%s",
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. key=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
return self.get_url(self.normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
storage_key = self.normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info(
|
||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
||||
"get_download_url: signed URL generated. key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_download_url: sign_url failed, falling back to raw URL. storage_key=%s",
|
||||
"get_download_url: sign_url failed, falling back to raw URL. key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""Extract storage key from URL.
|
||||
|
||||
从完整 URL 提取 OSS 存储键,并做 URL 解码 — 否则 URL 编码的字符
|
||||
(如空格=%20、中文=%XX)会导致 sign_url 计算的签名与 OSS 服务端
|
||||
不匹配(SignatureDoesNotMatch)。原始 key 传入时直接返回。
|
||||
"""
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
return unquote(parsed.path.lstrip("/"))
|
||||
return storage_key_or_url.lstrip("/")
|
||||
# ── 浏览器直传 POST ────────────────────────────────────────────────
|
||||
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
@@ -177,10 +346,10 @@ class SharedStorageService:
|
||||
max_size_bytes: int,
|
||||
expires_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
"""Create browser direct upload POST form."""
|
||||
"""创建浏览器直传 POST 表单。"""
|
||||
if not self.access_key_id or not self.access_key_secret:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
normalized_key = self._normalize_storage_key(storage_key)
|
||||
normalized_key = self.normalize_storage_key(storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise ValueError("direct upload key must be under uploads/")
|
||||
|
||||
@@ -193,12 +362,20 @@ class SharedStorageService:
|
||||
{"bucket": self.bucket_name},
|
||||
{"key": normalized_key},
|
||||
["content-length-range", 1, max_size_bytes],
|
||||
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
|
||||
[
|
||||
"starts-with",
|
||||
"$Content-Type",
|
||||
content_type.split("/", 1)[0] + "/" if "/" in content_type else "",
|
||||
],
|
||||
],
|
||||
}
|
||||
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
||||
signature = base64.b64encode(
|
||||
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
|
||||
hmac.new(
|
||||
self.access_key_secret.encode("utf-8"),
|
||||
encoded_policy.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
).decode("ascii")
|
||||
|
||||
return {
|
||||
@@ -216,8 +393,10 @@ class SharedStorageService:
|
||||
},
|
||||
}
|
||||
|
||||
def delete_file(self, storage_key: str):
|
||||
"""Delete file from OSS."""
|
||||
# ── 文件操作 ───────────────────────────────────────────────────────
|
||||
|
||||
def delete_file(self, storage_key: str) -> None:
|
||||
"""删除文件(不抛异常)。"""
|
||||
if self.bucket is None:
|
||||
return
|
||||
try:
|
||||
@@ -226,17 +405,98 @@ class SharedStorageService:
|
||||
logger.warning("Failed to delete file from OSS", extra={"storage_key": storage_key, "error": str(error)})
|
||||
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
"""Check if file exists."""
|
||||
"""检查文件是否存在。"""
|
||||
if self.bucket is None:
|
||||
return False
|
||||
return self.bucket.object_exists(storage_key)
|
||||
|
||||
# ── Asset 路径解析(Worker 用)────────────────────────────────────
|
||||
|
||||
def resolve_asset_path(self, asset_id: str, work_dir: Union[str, Path]) -> Optional[Path]:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略(按优先级):
|
||||
1. 本地绝对路径(在允许目录内)→ 直接返回
|
||||
2. work_dir 缓存命中 → 返回缓存路径
|
||||
3. 从OSS下载到缓存 → 返回下载路径
|
||||
4. 全部失败 → None
|
||||
|
||||
从 oss_helpers.resolve_asset_path 合并而来。
|
||||
"""
|
||||
# 延迟导入,避免循环依赖
|
||||
from video_processing.path_security import ( # type: ignore[import-not-found]
|
||||
PathSecurityError,
|
||||
get_allowed_local_dirs,
|
||||
is_in_allowed_dirs,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
if not asset_id or not isinstance(asset_id, str):
|
||||
return None
|
||||
|
||||
work_dir = Path(work_dir)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
|
||||
# 空字节检测
|
||||
if "\x00" in asset_id:
|
||||
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
|
||||
return None
|
||||
|
||||
# 1. 本地绝对路径 — 必须在允许的目录内
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
try:
|
||||
resolved = Path(asset_id).resolve()
|
||||
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
|
||||
return resolved
|
||||
else:
|
||||
logger.warning(
|
||||
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
|
||||
asset_id[:80],
|
||||
get_allowed_local_dirs(),
|
||||
)
|
||||
return None
|
||||
except (OSError, PathSecurityError):
|
||||
return None
|
||||
|
||||
# 2. 缓存命中(SHA256 hash 防路径遍历)
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
safe_name = sanitize_filename(cache_hash)
|
||||
cached_path = work_dir / f"{safe_name}.mp4"
|
||||
if cached_path.exists() and cached_path.stat().st_size > 0:
|
||||
return cached_path
|
||||
|
||||
# 3. 从 OSS 下载(先标准化 key,防路径遍历注入)
|
||||
safe_key = self.normalize_storage_key(asset_id)
|
||||
if ".." in safe_key or safe_key.startswith("/"):
|
||||
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
|
||||
return None
|
||||
|
||||
if self.download_asset(safe_key, cached_path):
|
||||
return cached_path
|
||||
|
||||
return None
|
||||
|
||||
def resolve_asset_ids_to_paths(
|
||||
self,
|
||||
asset_ids: list[str],
|
||||
work_dir: Union[str, Path],
|
||||
) -> dict[str, Path]:
|
||||
"""批量解析 asset_id → 本地路径。"""
|
||||
result: dict[str, Path] = {}
|
||||
for aid in asset_ids:
|
||||
local_path = self.resolve_asset_path(aid, work_dir)
|
||||
if local_path:
|
||||
result[aid] = local_path
|
||||
return result
|
||||
|
||||
|
||||
# ── 单例管理 ────────────────────────────────────────────────────────────
|
||||
|
||||
_storage_service: Optional[SharedStorageService] = None
|
||||
|
||||
|
||||
def get_shared_storage_service() -> SharedStorageService:
|
||||
"""Get shared storage service instance (global singleton)."""
|
||||
"""获取统一存储服务单例。"""
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = SharedStorageService()
|
||||
@@ -244,7 +504,7 @@ def get_shared_storage_service() -> SharedStorageService:
|
||||
return _storage_service
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
# 向后兼容别名
|
||||
def get_storage_service() -> SharedStorageService:
|
||||
"""Backward compatibility: returns shared storage service."""
|
||||
"""向后兼容:返回统一存储服务。"""
|
||||
return get_shared_storage_service()
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
|
||||
set -eu
|
||||
|
||||
# 测试环境必须的密钥变量
|
||||
export JWT_SECRET_KEY=${JWT_SECRET_KEY:-test-jwt-secret-for-ci-only-2026}
|
||||
|
||||
JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
|
||||
@@ -12,11 +12,23 @@ from packages.config.base import SharedSettings, get_cached_settings, reload_set
|
||||
def _reset_cache():
|
||||
"""每个测试前清空配置缓存,避免单例污染."""
|
||||
reload_settings_cache()
|
||||
# 保存关键环境变量(避免其他测试模块的全局污染)
|
||||
_saved_env = {}
|
||||
for key in ["JWT_SECRET_KEY", "DATABASE_URL", "USE_IN_MEMORY_DB", "APP_ENV"]:
|
||||
_saved_env[key] = os.environ.get(key)
|
||||
# 设置必要的环境变量,避免 JWT 校验失败
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret-key-for-unit-tests-only-12345"
|
||||
# 清除可能被其他模块污染的变量,确保默认值测试准确
|
||||
for key in ["DATABASE_URL", "APP_ENV"]:
|
||||
os.environ.pop(key, None)
|
||||
yield
|
||||
reload_settings_cache()
|
||||
os.environ.pop("JWT_SECRET_KEY", None)
|
||||
# 恢复所有保存的环境变量,避免污染其他测试模块
|
||||
for key, val in _saved_env.items():
|
||||
if val is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
class TestSharedSettingsDefaults:
|
||||
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
"""classification 分类领域模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.classification import (
|
||||
AssetClassification,
|
||||
AssetLibraryKind,
|
||||
ClassificationJob,
|
||||
ClassificationJobStatus,
|
||||
ClassificationStatus,
|
||||
IngestJobStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestAssetLibraryKind:
|
||||
def test_values(self):
|
||||
assert AssetLibraryKind.VIDEO.value == "video"
|
||||
assert AssetLibraryKind.VOICE.value == "voice"
|
||||
assert AssetLibraryKind.IMAGE.value == "image"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(AssetLibraryKind.VIDEO, str)
|
||||
|
||||
|
||||
class TestIngestJobStatus:
|
||||
def test_values(self):
|
||||
assert IngestJobStatus.PENDING.value == "pending"
|
||||
assert IngestJobStatus.PROCESSING.value == "processing"
|
||||
assert IngestJobStatus.COMPLETED.value == "completed"
|
||||
assert IngestJobStatus.FAILED.value == "failed"
|
||||
|
||||
|
||||
class TestClassificationJobStatusMissing:
|
||||
"""ClassificationJobStatus._missing_ 兼容性测试."""
|
||||
|
||||
def test_normal_values(self):
|
||||
assert ClassificationJobStatus("pending") == ClassificationJobStatus.PENDING
|
||||
assert ClassificationJobStatus("processing") == ClassificationJobStatus.PROCESSING
|
||||
assert ClassificationJobStatus("completed") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("failed") == ClassificationJobStatus.FAILED
|
||||
|
||||
@pytest.mark.parametrize("value", ["done", "success", "finished", "complete"])
|
||||
def test_completed_aliases(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.COMPLETED
|
||||
|
||||
@pytest.mark.parametrize("value", ["fail", "error", "err"])
|
||||
def test_failed_aliases(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.FAILED
|
||||
|
||||
@pytest.mark.parametrize("value", ["process", "processing", "running", "run"])
|
||||
def test_processing_aliases(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.PROCESSING
|
||||
|
||||
@pytest.mark.parametrize("value", ["unknown", "foobar", ""])
|
||||
def test_unknown_fallback_to_pending(self, value):
|
||||
assert ClassificationJobStatus(value) == ClassificationJobStatus.PENDING
|
||||
|
||||
def test_none_fallback_to_pending(self):
|
||||
assert ClassificationJobStatus(None) == ClassificationJobStatus.PENDING # type: ignore[arg-type]
|
||||
|
||||
def test_case_insensitive_with_strip(self):
|
||||
assert ClassificationJobStatus(" DONE ") == ClassificationJobStatus.COMPLETED
|
||||
assert ClassificationJobStatus("ERROR") == ClassificationJobStatus.FAILED
|
||||
|
||||
def test_backward_compat_alias(self):
|
||||
"""ClassificationStatus 是 ClassificationJobStatus 的别名."""
|
||||
assert ClassificationStatus is ClassificationJobStatus
|
||||
assert ClassificationStatus("done") == ClassificationJobStatus.COMPLETED
|
||||
|
||||
|
||||
class TestAssetClassification:
|
||||
def test_values(self):
|
||||
assert AssetClassification.SCENIC.value == "scenic"
|
||||
assert AssetClassification.PRODUCT.value == "product"
|
||||
assert AssetClassification.PERSON.value == "person"
|
||||
assert AssetClassification.ANIMAL.value == "animal"
|
||||
assert AssetClassification.FOOD.value == "food"
|
||||
assert AssetClassification.TECH.value == "tech"
|
||||
assert AssetClassification.SPORT.value == "sport"
|
||||
assert AssetClassification.MUSIC.value == "music"
|
||||
assert AssetClassification.OTHER.value == "other"
|
||||
|
||||
|
||||
class TestClassificationJobCreate:
|
||||
def test_create_normal(self):
|
||||
job = ClassificationJob.create(project_id="proj1", asset_id="asset1")
|
||||
assert job.id
|
||||
assert job.project_id == "proj1"
|
||||
assert job.asset_id == "asset1"
|
||||
assert job.status == ClassificationJobStatus.PENDING
|
||||
assert job.classification == ""
|
||||
assert job.confidence == 0.0
|
||||
assert job.error_message == ""
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
job = ClassificationJob.create(project_id=" proj1 ", asset_id=" asset1 ")
|
||||
assert job.project_id == "proj1"
|
||||
assert job.asset_id == "asset1"
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
ClassificationJob.create(project_id="", asset_id="a1")
|
||||
|
||||
def test_create_empty_asset_id_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_id 不能为空"):
|
||||
ClassificationJob.create(project_id="p1", asset_id="")
|
||||
|
||||
def test_create_whitespace_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
ClassificationJob.create(project_id=" ", asset_id="a1")
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
job1 = ClassificationJob.create(project_id="p1", asset_id="a1")
|
||||
job2 = ClassificationJob.create(project_id="p1", asset_id="a2")
|
||||
assert job1.id != job2.id
|
||||
Executable
+305
@@ -0,0 +1,305 @@
|
||||
"""Duplication 查重记录领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
|
||||
|
||||
class TestDuplicateSegmentCreate:
|
||||
def test_create_normal(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=20.0,
|
||||
matched_video_id="vid123",
|
||||
matched_video_name="测试视频",
|
||||
matched_start=5.0,
|
||||
matched_end=15.0,
|
||||
similarity=85.5,
|
||||
)
|
||||
assert seg.id
|
||||
assert seg.source_start == 10.0
|
||||
assert seg.source_end == 20.0
|
||||
assert seg.matched_video_id == "vid123"
|
||||
assert seg.matched_video_name == "测试视频"
|
||||
assert seg.matched_start == 5.0
|
||||
assert seg.matched_end == 15.0
|
||||
assert seg.similarity == 85.5
|
||||
|
||||
def test_create_negative_source_start_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=-1.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_zero_duration_source_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_reversed_source_range_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=20.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_negative_matched_start_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=-1.0,
|
||||
matched_end=10.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_zero_duration_matched_raises(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=5.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_negative_raises(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=-1.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_over_100_raises(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=10.0,
|
||||
similarity=101.0,
|
||||
)
|
||||
|
||||
def test_create_similarity_boundary_values(self):
|
||||
# 0 和 100 都是合法的
|
||||
seg0 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=0.0,
|
||||
)
|
||||
assert seg0.similarity == 0.0
|
||||
|
||||
seg100 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=100.0,
|
||||
)
|
||||
assert seg100.similarity == 100.0
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
seg1 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
seg2 = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
assert seg1.id != seg2.id
|
||||
|
||||
|
||||
class TestDuplicationRecordCreate:
|
||||
def test_create_normal(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id="user1",
|
||||
filename="test.mp4",
|
||||
file_size=1024000,
|
||||
storage_key="videos/test.mp4",
|
||||
duration_seconds=30.5,
|
||||
)
|
||||
assert record.id
|
||||
assert record.user_id == "user1"
|
||||
assert record.filename == "test.mp4"
|
||||
assert record.file_size == 1024000
|
||||
assert record.storage_key == "videos/test.mp4"
|
||||
assert record.duration_seconds == 30.5
|
||||
assert record.status == "pending"
|
||||
assert record.duplicate_rate is None
|
||||
assert record.duplicate_count == 0
|
||||
assert record.segments == []
|
||||
assert record.error_message == ""
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id=" user1 ",
|
||||
filename=" test.mp4 ",
|
||||
file_size=100,
|
||||
storage_key="key1",
|
||||
)
|
||||
assert record.user_id == "user1"
|
||||
assert record.filename == "test.mp4"
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="",
|
||||
filename="test.mp4",
|
||||
file_size=100,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
def test_create_empty_filename_raises(self):
|
||||
with pytest.raises(ValueError, match="filename cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="",
|
||||
file_size=100,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
def test_create_zero_file_size_raises(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
file_size=0,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
def test_create_negative_file_size_raises(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
file_size=-100,
|
||||
storage_key="key1",
|
||||
)
|
||||
|
||||
|
||||
class TestDuplicationRecordStatus:
|
||||
def test_mark_processing(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
record.mark_processing()
|
||||
assert record.status == "processing"
|
||||
|
||||
def test_mark_completed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=90.0,
|
||||
)
|
||||
record.mark_completed(duplicate_rate=25.5, duplicate_count=3, segments=[seg])
|
||||
assert record.status == "completed"
|
||||
assert record.duplicate_rate == 25.5
|
||||
assert record.duplicate_count == 3
|
||||
assert len(record.segments) == 1
|
||||
|
||||
def test_mark_completed_invalid_rate_raises(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
record.mark_completed(duplicate_rate=-1, duplicate_count=0, segments=[])
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
record.mark_completed(duplicate_rate=101, duplicate_count=0, segments=[])
|
||||
|
||||
def test_mark_failed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
record.mark_failed("网络超时")
|
||||
assert record.status == "failed"
|
||||
assert record.error_message == "网络超时"
|
||||
|
||||
def test_can_retry_only_failed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
assert record.can_retry() is False # pending
|
||||
|
||||
record.mark_processing()
|
||||
assert record.can_retry() is False # processing
|
||||
|
||||
record.mark_failed("error")
|
||||
assert record.can_retry() is True # failed
|
||||
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
record2 = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
record2.mark_completed(10.0, 1, [seg])
|
||||
assert record2.can_retry() is False # completed
|
||||
|
||||
def test_reset_for_retry(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k1")
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0.0,
|
||||
source_end=1.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="v",
|
||||
matched_start=0.0,
|
||||
matched_end=1.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
record.mark_completed(50.0, 2, [seg])
|
||||
record.video_fingerprint = {"hash": "abc"}
|
||||
|
||||
record.reset_for_retry()
|
||||
assert record.status == "pending"
|
||||
assert record.duplicate_rate is None
|
||||
assert record.duplicate_count == 0
|
||||
assert record.error_message == ""
|
||||
assert record.segments == []
|
||||
assert record.video_fingerprint is None
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
"""EditPlan 剪辑计划领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
|
||||
class TestEditPlanStatus:
|
||||
def test_values(self):
|
||||
assert EditPlanStatus.DRAFT.value == "draft"
|
||||
assert EditPlanStatus.EDITING.value == "editing"
|
||||
assert EditPlanStatus.RENDERING.value == "rendering"
|
||||
assert EditPlanStatus.COMPLETED.value == "completed"
|
||||
assert EditPlanStatus.FAILED.value == "failed"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(EditPlanStatus.DRAFT, str)
|
||||
|
||||
|
||||
class TestEditPlanCreate:
|
||||
def test_create_normal(self):
|
||||
plan = EditPlan.create(template_id="tmpl1", name="我的剪辑计划")
|
||||
assert plan.id
|
||||
assert plan.template_id == "tmpl1"
|
||||
assert plan.name == "我的剪辑计划"
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
assert plan.total_duration == 0.0
|
||||
assert plan.config == {}
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
plan = EditPlan.create(
|
||||
template_id=" tmpl1 ",
|
||||
name=" 我的计划 ",
|
||||
source_edit_plan_id=" src1 ",
|
||||
project_id=" proj1 ",
|
||||
created_by_user_id=" user1 ",
|
||||
)
|
||||
assert plan.template_id == "tmpl1"
|
||||
assert plan.name == "我的计划"
|
||||
assert plan.source_edit_plan_id == "src1"
|
||||
assert plan.project_id == "proj1"
|
||||
assert plan.created_by_user_id == "user1"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="计划名称不能为空"):
|
||||
EditPlan.create(template_id="tmpl1", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="计划名称不能为空"):
|
||||
EditPlan.create(template_id="tmpl1", name=" ")
|
||||
|
||||
def test_create_empty_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id="", name="计划")
|
||||
|
||||
def test_create_whitespace_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id=" ", name="计划")
|
||||
|
||||
def test_create_with_config(self):
|
||||
config = {"resolution": "1080p", "fps": 30}
|
||||
plan = EditPlan.create(
|
||||
template_id="tmpl1",
|
||||
name="计划",
|
||||
config=config,
|
||||
total_duration=30.5,
|
||||
)
|
||||
assert plan.config == config
|
||||
assert plan.total_duration == 30.5
|
||||
|
||||
def test_create_none_config_defaults_to_empty(self):
|
||||
plan = EditPlan.create(template_id="tmpl1", name="计划", config=None)
|
||||
assert plan.config == {}
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
p1 = EditPlan.create(template_id="t1", name="p1")
|
||||
p2 = EditPlan.create(template_id="t1", name="p2")
|
||||
assert p1.id != p2.id
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
"""EditPlanClip 剪辑计划片段领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
|
||||
class TestEditPlanClipStatus:
|
||||
def test_values(self):
|
||||
assert EditPlanClipStatus.PENDING.value == "pending"
|
||||
assert EditPlanClipStatus.READY.value == "ready"
|
||||
assert EditPlanClipStatus.RENDERED.value == "rendered"
|
||||
assert EditPlanClipStatus.FAILED.value == "failed"
|
||||
|
||||
|
||||
class TestEditPlanClipCreate:
|
||||
def test_create_normal(self):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id="plan1",
|
||||
clip_type="video",
|
||||
order=1,
|
||||
start_time=0.0,
|
||||
duration=5.0,
|
||||
)
|
||||
assert clip.id
|
||||
assert clip.plan_id == "plan1"
|
||||
assert clip.clip_type == "video"
|
||||
assert clip.order == 1
|
||||
assert clip.status == EditPlanClipStatus.PENDING
|
||||
assert clip.start_time == 0.0
|
||||
assert clip.duration == 5.0
|
||||
assert clip.playback_speed == 1.0
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_empty_plan_id_raises(self):
|
||||
with pytest.raises(ValueError, match="plan_id 不能为空"):
|
||||
EditPlanClip.create(plan_id="", clip_type="video", order=1)
|
||||
|
||||
def test_create_empty_clip_type_raises(self):
|
||||
with pytest.raises(ValueError, match="clip_type 不能为空"):
|
||||
EditPlanClip.create(plan_id="p1", clip_type="", order=1)
|
||||
|
||||
def test_create_negative_start_time_raises(self):
|
||||
with pytest.raises(ValueError, match="start_time 不能为负数"):
|
||||
EditPlanClip.create(plan_id="p1", clip_type="v", order=1, start_time=-1.0)
|
||||
|
||||
def test_create_negative_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="duration 不能为负数"):
|
||||
EditPlanClip.create(plan_id="p1", clip_type="v", order=1, duration=-1.0)
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=" plan1 ",
|
||||
clip_type=" video ",
|
||||
order=1,
|
||||
template_clip_config_id=" cfg1 ",
|
||||
asset_id=" a1 ",
|
||||
text_content=" 你好 ",
|
||||
transition_effect=" fade ",
|
||||
)
|
||||
assert clip.plan_id == "plan1"
|
||||
assert clip.clip_type == "video"
|
||||
assert clip.template_clip_config_id == "cfg1"
|
||||
assert clip.asset_id == "a1"
|
||||
assert clip.text_content == "你好"
|
||||
assert clip.transition_effect == "fade"
|
||||
|
||||
def test_create_empty_transition_effect_defaults_to_cut(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, transition_effect="")
|
||||
assert clip.transition_effect == "cut"
|
||||
|
||||
def test_create_playback_speed_zero_defaults_to_1(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=0)
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
def test_create_playback_speed_negative_defaults_to_1(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=-1.0)
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
def test_create_playback_speed_below_min_clamped(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=0.1)
|
||||
assert clip.playback_speed == 0.25
|
||||
|
||||
def test_create_playback_speed_above_max_clamped(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=5.0)
|
||||
assert clip.playback_speed == 4.0
|
||||
|
||||
def test_create_playback_speed_within_range(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, playback_speed=1.5)
|
||||
assert clip.playback_speed == 1.5
|
||||
|
||||
def test_create_transition_duration_negative_clamped(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, transition_duration=-0.5)
|
||||
assert clip.transition_duration == 0.0
|
||||
|
||||
def test_create_none_config_defaults_to_empty(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, config=None)
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
c1 = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
c2 = EditPlanClip.create(plan_id="p1", clip_type="v", order=2)
|
||||
assert c1.id != c2.id
|
||||
|
||||
|
||||
class TestEditPlanClipStateTransitions:
|
||||
def test_pending_to_ready(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
assert clip.status == EditPlanClipStatus.READY
|
||||
|
||||
def test_ready_to_rendered(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
clip.mark_rendered()
|
||||
assert clip.status == EditPlanClipStatus.RENDERED
|
||||
|
||||
def test_ready_to_failed(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
clip.mark_failed()
|
||||
assert clip.status == EditPlanClipStatus.FAILED
|
||||
|
||||
def test_ready_mark_ready_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.mark_ready()
|
||||
with pytest.raises(ValueError, match="只有 pending 状态的片段可以标记就绪"):
|
||||
clip.mark_ready()
|
||||
|
||||
def test_pending_mark_rendered_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="只有 ready 状态的片段可以标记已渲染"):
|
||||
clip.mark_rendered()
|
||||
|
||||
def test_pending_mark_failed_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="只有 ready 状态的片段可以标记失败"):
|
||||
clip.mark_failed()
|
||||
|
||||
|
||||
class TestEditPlanClipProperties:
|
||||
def test_end_time(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, start_time=10.0, duration=5.0)
|
||||
assert clip.end_time == 15.0
|
||||
|
||||
def test_end_time_zero_duration(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, start_time=5.0, duration=0.0)
|
||||
assert clip.end_time == 5.0
|
||||
|
||||
def test_has_asset_true(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, asset_id="a1")
|
||||
assert clip.has_asset is True
|
||||
|
||||
def test_has_asset_false(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
assert clip.has_asset is False
|
||||
|
||||
def test_has_asset_empty_string(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1, asset_id="")
|
||||
assert clip.has_asset is False
|
||||
|
||||
|
||||
class TestEditPlanClipAssignAsset:
|
||||
def test_assign_asset(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.assign_asset("asset1")
|
||||
assert clip.asset_id == "asset1"
|
||||
|
||||
def test_assign_asset_strips(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
clip.assign_asset(" asset1 ")
|
||||
assert clip.asset_id == "asset1"
|
||||
|
||||
def test_assign_asset_empty_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p1", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="asset_id 不能为空"):
|
||||
clip.assign_asset("")
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
"""EditTemplate 剪辑模板领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
|
||||
class TestEditTemplateStatus:
|
||||
def test_values(self):
|
||||
assert EditTemplateStatus.ACTIVE.value == "active"
|
||||
assert EditTemplateStatus.INACTIVE.value == "inactive"
|
||||
|
||||
|
||||
class TestEditTemplateCreate:
|
||||
def test_create_default(self):
|
||||
tpl = EditTemplate.create(name="测试模板")
|
||||
assert tpl.id
|
||||
assert tpl.name == "测试模板"
|
||||
assert tpl.editing_mode == EditingMode.ONE_TAKE.value
|
||||
assert tpl.status == EditTemplateStatus.ACTIVE
|
||||
assert tpl.version == 1
|
||||
assert tpl.config == {}
|
||||
assert tpl.description == ""
|
||||
assert tpl.sort_weight == 0
|
||||
|
||||
def test_create_strips_name(self):
|
||||
tpl = EditTemplate.create(name=" 我的模板 ")
|
||||
assert tpl.name == "我的模板"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
EditTemplate.create(name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
EditTemplate.create(name=" ")
|
||||
|
||||
def test_create_valid_editing_modes(self):
|
||||
for mode in EditingMode:
|
||||
tpl = EditTemplate.create(name=f"模板_{mode.value}", editing_mode=mode.value)
|
||||
assert tpl.editing_mode == mode.value
|
||||
|
||||
def test_create_invalid_editing_mode_raises(self):
|
||||
with pytest.raises(ValueError, match="无效的 editing_mode"):
|
||||
EditTemplate.create(name="模板", editing_mode="invalid_mode")
|
||||
|
||||
def test_create_empty_editing_mode_defaults_to_one_take(self):
|
||||
tpl = EditTemplate.create(name="模板", editing_mode="")
|
||||
assert tpl.editing_mode == EditingMode.ONE_TAKE.value
|
||||
|
||||
def test_create_with_config(self):
|
||||
config = {"key": "value"}
|
||||
tpl = EditTemplate.create(name="模板", config=config)
|
||||
assert tpl.config == config
|
||||
|
||||
def test_create_none_config_defaults_to_empty(self):
|
||||
tpl = EditTemplate.create(name="模板", config=None)
|
||||
assert tpl.config == {}
|
||||
|
||||
def test_create_with_custom_status(self):
|
||||
tpl = EditTemplate.create(name="模板", status=EditTemplateStatus.INACTIVE)
|
||||
assert tpl.status == EditTemplateStatus.INACTIVE
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
t1 = EditTemplate.create(name="t1")
|
||||
t2 = EditTemplate.create(name="t2")
|
||||
assert t1.id != t2.id
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
"""GeneratedVideo 生成视频领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
|
||||
class TestGeneratedVideoCreate:
|
||||
def test_create_normal(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="我的视频",
|
||||
file_url="https://example.com/video.mp4",
|
||||
file_size=1024000,
|
||||
duration=30.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=30.0,
|
||||
)
|
||||
assert video.id
|
||||
assert video.project_id == "proj1"
|
||||
assert video.generation_task_id == "task1"
|
||||
assert video.name == "我的视频"
|
||||
assert video.file_url == "https://example.com/video.mp4"
|
||||
assert video.file_size == 1024000
|
||||
assert video.duration == 30.5
|
||||
assert video.width == 1920
|
||||
assert video.height == 1080
|
||||
assert video.fps == 30.0
|
||||
assert video.status == "completed"
|
||||
assert video.review_status == "pending_review"
|
||||
assert video.is_duplicate is False
|
||||
assert video.generation_params == {}
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id=" proj1 ",
|
||||
generation_task_id=" task1 ",
|
||||
name=" 我的视频 ",
|
||||
file_url=" https://example.com/video.mp4 ",
|
||||
user_id=" user1 ",
|
||||
)
|
||||
assert video.project_id == "proj1"
|
||||
assert video.generation_task_id == "task1"
|
||||
assert video.name == "我的视频"
|
||||
assert video.file_url == "https://example.com/video.mp4"
|
||||
assert video.user_id == "user1"
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_whitespace_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id=" ",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_generation_task_id_raises(self):
|
||||
with pytest.raises(ValueError, match="generation_task_id cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="name cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_file_url_raises(self):
|
||||
with pytest.raises(ValueError, match="file_url cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="",
|
||||
)
|
||||
|
||||
def test_create_default_values(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
)
|
||||
assert video.file_size == 0
|
||||
assert video.duration == 0.0
|
||||
assert video.width == 0
|
||||
assert video.height == 0
|
||||
assert video.fps == 0.0
|
||||
assert video.thumbnail_url is None
|
||||
assert video.user_id == ""
|
||||
assert video.generation_params == {}
|
||||
|
||||
def test_create_with_generation_params(self):
|
||||
params = {"mode": "pip", "resolution": "1080p"}
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
generation_params=params,
|
||||
)
|
||||
assert video.generation_params == params
|
||||
|
||||
def test_create_none_generation_params(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name="视频",
|
||||
file_url="https://example.com/v.mp4",
|
||||
generation_params=None,
|
||||
)
|
||||
assert video.generation_params == {}
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
v1 = GeneratedVideo.create(
|
||||
project_id="proj1", generation_task_id="t1", name="v1", file_url="https://a.com/1.mp4"
|
||||
)
|
||||
v2 = GeneratedVideo.create(
|
||||
project_id="proj1", generation_task_id="t2", name="v2", file_url="https://a.com/2.mp4"
|
||||
)
|
||||
assert v1.id != v2.id
|
||||
Executable
+300
@@ -0,0 +1,300 @@
|
||||
"""GenerationTask 生成任务领域模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.generation_task import (
|
||||
TERMINAL_STATUSES,
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerationTaskCreate:
|
||||
def test_create_with_template_id(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="",
|
||||
asset_library_id="lib1",
|
||||
template_id="tmpl1",
|
||||
)
|
||||
assert task.id
|
||||
assert task.template_id == "tmpl1"
|
||||
assert task.asset_library_id == "lib1"
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert task.progress == 0.0
|
||||
assert task.retry_count == 0
|
||||
assert task.auto_retry_enabled is False
|
||||
|
||||
def test_create_with_project_id(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="lib1",
|
||||
)
|
||||
assert task.project_id == "proj1"
|
||||
|
||||
def test_create_both_empty_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 或 template_id 至少需要提供一个"):
|
||||
GenerationTask.create(
|
||||
project_id="",
|
||||
asset_library_id="lib1",
|
||||
template_id="",
|
||||
)
|
||||
|
||||
def test_create_asset_library_and_assets_both_empty_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个"):
|
||||
GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="",
|
||||
asset_ids=None,
|
||||
title_ids=None,
|
||||
voice_ids=None,
|
||||
)
|
||||
|
||||
def test_create_with_asset_ids(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="",
|
||||
asset_ids=["a1", "a2"],
|
||||
)
|
||||
assert task.asset_ids == ["a1", "a2"]
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
task = GenerationTask.create(
|
||||
project_id=" proj1 ",
|
||||
asset_library_id=" lib1 ",
|
||||
template_id=" tmpl1 ",
|
||||
video_title=" 测试视频 ",
|
||||
resolution=" 1080p ",
|
||||
created_by_user_id=" user1 ",
|
||||
source_edit_plan_id=" plan1 ",
|
||||
)
|
||||
assert task.project_id == "proj1"
|
||||
assert task.asset_library_id == "lib1"
|
||||
assert task.template_id == "tmpl1"
|
||||
assert task.video_title == "测试视频"
|
||||
assert task.resolution == "1080p"
|
||||
assert task.created_by_user_id == "user1"
|
||||
assert task.source_edit_plan_id == "plan1"
|
||||
|
||||
def test_create_default_values(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="proj1",
|
||||
asset_library_id="lib1",
|
||||
)
|
||||
assert task.title_ids == []
|
||||
assert task.voice_ids == []
|
||||
assert task.result_count == 0
|
||||
assert task.error_message == ""
|
||||
assert task.error_info == {}
|
||||
assert task.bgm_config == {}
|
||||
assert task.logs == "[]"
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
t1 = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
t2 = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
assert t1.id != t2.id
|
||||
|
||||
|
||||
class TestGenerationTaskStatusQueries:
|
||||
def test_is_terminal_completed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_completed()
|
||||
assert task.is_terminal is True
|
||||
assert task.is_completed is True
|
||||
assert task.is_failed is False
|
||||
assert task.is_running is False
|
||||
|
||||
def test_is_terminal_failed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("error")
|
||||
assert task.is_terminal is True
|
||||
assert task.is_completed is False
|
||||
assert task.is_failed is True
|
||||
|
||||
def test_is_terminal_cancelled(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.transition_to(GenerationTaskStatus.CANCELLED)
|
||||
assert task.is_terminal is True
|
||||
|
||||
def test_is_running(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
assert task.is_running is False
|
||||
task.mark_processing()
|
||||
assert task.is_running is True
|
||||
|
||||
def test_terminal_statuses_set(self):
|
||||
assert GenerationTaskStatus.COMPLETED in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.FAILED in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.CANCELLED in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.PENDING not in TERMINAL_STATUSES
|
||||
assert GenerationTaskStatus.RUNNING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestGenerationTaskStateTransitions:
|
||||
def test_pending_to_running(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
assert task.status == GenerationTaskStatus.RUNNING
|
||||
assert task.started_at is not None
|
||||
assert task.error_message == ""
|
||||
|
||||
def test_running_to_completed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_completed(result_count=3)
|
||||
assert task.status == GenerationTaskStatus.COMPLETED
|
||||
assert task.completed_at is not None
|
||||
assert task.progress == 100.0
|
||||
assert task.result_count == 3
|
||||
assert task.error_message == ""
|
||||
|
||||
def test_running_to_failed_with_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("渲染失败", error_info={"stage": "render"})
|
||||
assert task.status == GenerationTaskStatus.FAILED
|
||||
assert task.completed_at is not None
|
||||
assert task.error_message == "渲染失败"
|
||||
assert task.error_info["stage"] == "render"
|
||||
|
||||
def test_running_to_failed_without_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("未知错误")
|
||||
assert task.error_info is not None
|
||||
assert task.error_info["message"] == "未知错误"
|
||||
assert "error_type" in task.error_info
|
||||
assert "failed_at" in task.error_info
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("error")
|
||||
assert task.retry_count == 0
|
||||
|
||||
task.mark_pending_from_failed()
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert task.retry_count == 1
|
||||
assert task.error_message == ""
|
||||
assert task.error_info == {}
|
||||
assert task.started_at is None
|
||||
assert task.completed_at is None
|
||||
assert task.progress == 0.0
|
||||
assert task.result_count == 0
|
||||
|
||||
def test_mark_pending_from_failed_wrong_status_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
with pytest.raises(ValueError, match="只有 failed 状态的任务可以重置为 pending"):
|
||||
task.mark_pending_from_failed()
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
# pending 不能直接到 completed
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.transition_to(GenerationTaskStatus.COMPLETED)
|
||||
|
||||
def test_completed_cannot_transition(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_completed()
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.mark_failed("test")
|
||||
|
||||
def test_transition_to_with_string(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.transition_to("running")
|
||||
assert task.status == GenerationTaskStatus.RUNNING
|
||||
|
||||
def test_transition_to_invalid_string_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
task.transition_to("invalid_status")
|
||||
|
||||
|
||||
class TestGenerationTaskLogs:
|
||||
def test_append_log_single(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.append_log("初始化", "任务创建成功")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["stage"] == "初始化"
|
||||
assert logs[0]["message"] == "任务创建成功"
|
||||
assert logs[0]["level"] == "INFO"
|
||||
assert "ts" in logs[0]
|
||||
|
||||
def test_append_log_multiple(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
for i in range(5):
|
||||
task.append_log(f"stage{i}", f"msg{i}", level="INFO")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 5
|
||||
assert logs[0]["stage"] == "stage0"
|
||||
assert logs[4]["stage"] == "stage4"
|
||||
|
||||
def test_append_log_with_extra_fields(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.append_log("下载", "下载完成", asset_id="a1", duration=10.5)
|
||||
logs = task.get_logs()
|
||||
assert logs[0]["asset_id"] == "a1"
|
||||
assert logs[0]["duration"] == 10.5
|
||||
|
||||
def test_append_log_error_level(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.append_log("渲染", "渲染失败", level="ERROR")
|
||||
logs = task.get_logs()
|
||||
assert logs[0]["level"] == "ERROR"
|
||||
|
||||
def test_logs_max_limit(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
# _MAX_LOGS = 200
|
||||
for i in range(250):
|
||||
task.append_log("test", f"msg{i}")
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 200
|
||||
# 保留最新的200条
|
||||
assert logs[0]["message"] == "msg50"
|
||||
assert logs[-1]["message"] == "msg249"
|
||||
|
||||
def test_get_logs_empty(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_get_logs_corrupted_json(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.logs = "not json"
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_mark_failed_without_error_info(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
task.mark_processing()
|
||||
task.mark_failed("error msg")
|
||||
assert task.error_info is not None
|
||||
assert task.error_info["message"] == "error msg"
|
||||
assert "error_type" in task.error_info
|
||||
assert "failed_at" in task.error_info
|
||||
|
||||
|
||||
class TestGenerationTaskCreateWithStrategy:
|
||||
def test_create_with_strategy_and_voice(self):
|
||||
task = GenerationTask.create(
|
||||
project_id="p1",
|
||||
asset_library_id="l1",
|
||||
strategy_id="s1",
|
||||
voice_library_id="v1",
|
||||
auto_retry_enabled=True,
|
||||
auto_retry_max=3,
|
||||
)
|
||||
assert task.strategy_id == "s1"
|
||||
assert task.voice_library_id == "v1"
|
||||
assert task.auto_retry_enabled is True
|
||||
assert task.auto_retry_max == 3
|
||||
|
||||
def test_create_with_bgm_config(self):
|
||||
bgm = {"volume": 0.5, "track": "bgm1"}
|
||||
task = GenerationTask.create(
|
||||
project_id="p1",
|
||||
asset_library_id="l1",
|
||||
bgm_config=bgm,
|
||||
)
|
||||
assert task.bgm_config == bgm
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
"""Subtitle 领域模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
def test_duration_normal(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=2.5)
|
||||
assert word.duration == pytest.approx(1.5)
|
||||
|
||||
def test_duration_zero(self):
|
||||
word = SubtitleWord(text="啊", start=3.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
def test_duration_negative_returns_zero(self):
|
||||
word = SubtitleWord(text="test", start=5.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
def test_duration(self):
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=3.0)
|
||||
assert seg.duration == pytest.approx(3.0)
|
||||
|
||||
def test_char_count(self):
|
||||
seg = SubtitleSegment(text="今天天气真好", start=0.0, end=5.0)
|
||||
assert seg.char_count == 6
|
||||
|
||||
def test_empty_text(self):
|
||||
seg = SubtitleSegment(text="", start=0.0, end=1.0)
|
||||
assert seg.char_count == 0
|
||||
|
||||
def test_default_words_empty(self):
|
||||
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
|
||||
assert seg.words == []
|
||||
|
||||
|
||||
class TestSubtitleTimeline:
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segment_count == 0
|
||||
assert tl.total_chars == 0
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
|
||||
def test_segment_count(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="第一段", start=0.0, end=2.0),
|
||||
SubtitleSegment(text="第二段", start=2.0, end=5.0),
|
||||
]
|
||||
)
|
||||
assert tl.segment_count == 2
|
||||
assert tl.total_chars == 6
|
||||
|
||||
def test_total_chars(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="abc", start=0, end=1),
|
||||
SubtitleSegment(text="defg", start=1, end=2),
|
||||
]
|
||||
)
|
||||
assert tl.total_chars == 7
|
||||
|
||||
|
||||
class TestMergeShortSegments:
|
||||
def test_single_segment_no_change(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好"
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_merge_short_segments(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="今天", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="天气", start=2.0, end=3.0),
|
||||
SubtitleSegment(text="真好", start=3.0, end=4.0),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# 每段2字,min=4,应该每2段合并
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "你好今天"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 2.0
|
||||
assert result.segments[1].text == "天气真好"
|
||||
assert result.segments[1].start == 2.0
|
||||
assert result.segments[1].end == 4.0
|
||||
|
||||
def test_remaining_merged_to_last(self):
|
||||
# 3段,每段2字,min=5 → 前5字合并,剩余1字并到最后
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二", start=0, end=1),
|
||||
SubtitleSegment(text="三四", start=1, end=2),
|
||||
SubtitleSegment(text="五", start=2, end=3),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五"
|
||||
|
||||
def test_merge_with_words(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(
|
||||
text="你好",
|
||||
start=0.0,
|
||||
end=1.0,
|
||||
words=[
|
||||
SubtitleWord(text="你", start=0.0, end=0.5),
|
||||
SubtitleWord(text="好", start=0.5, end=1.0),
|
||||
],
|
||||
),
|
||||
SubtitleSegment(
|
||||
text="世界",
|
||||
start=1.0,
|
||||
end=2.0,
|
||||
words=[
|
||||
SubtitleWord(text="世", start=1.0, end=1.5),
|
||||
SubtitleWord(text="界", start=1.5, end=2.0),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=10)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 4
|
||||
|
||||
|
||||
class TestSplitLongSegments:
|
||||
def test_short_segments_no_split(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="短文本", start=0.0, end=1.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 1
|
||||
|
||||
def test_split_by_punctuation(self):
|
||||
text = "今天天气真好。我们出去玩吧!"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=5.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
# 合并起来应该等于原文
|
||||
assert "".join(s.text for s in result.segments) == text
|
||||
|
||||
def test_split_preserves_time_order(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八九十。十一二三四五六七八九十。", start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
# 时间应该是递增的
|
||||
for i in range(len(result.segments) - 1):
|
||||
assert result.segments[i].end <= result.segments[i + 1].start + 0.001
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
def test_no_punctuation_short(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好世界", 20)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "你好世界"
|
||||
|
||||
def test_sentence_end_punctuation_long_enough(self):
|
||||
# 每段超过 max_chars//2 才会在句末标点断开
|
||||
text = "今天天气真的非常好。明天天气也不错。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_short_text_with_punctuation_no_split(self):
|
||||
# 文本太短(< max_chars//2),即使有标点也不断开
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 20)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_long_text_hard_split(self):
|
||||
text = "一二三四五六七八九十十一二三四五六七八九十"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_empty_text(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", 10)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestMergeSegments:
|
||||
def test_merge_two_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0),
|
||||
]
|
||||
result = SubtitleTimeline._merge_segments(segs)
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_empty_list(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
"""Tag 领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tag import Tag
|
||||
|
||||
|
||||
class TestTagCreate:
|
||||
def test_create_normal(self):
|
||||
tag = Tag.create(user_id="user1", name=" 美食 ")
|
||||
assert tag.id
|
||||
assert tag.user_id == "user1"
|
||||
assert tag.name == "美食" # 自动 strip
|
||||
assert tag.created_at is not None
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user1", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user1", name=" ")
|
||||
|
||||
def test_create_generates_unique_ids(self):
|
||||
tag1 = Tag.create(user_id="u1", name="tag1")
|
||||
tag2 = Tag.create(user_id="u1", name="tag2")
|
||||
assert tag1.id != tag2.id
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
"""TtsConfig 配音配置模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
|
||||
class TestTtsConfigDefaults:
|
||||
def test_default_values(self):
|
||||
config = TtsConfig()
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
assert config.pitch == 0.0
|
||||
assert config.volume == 0.8
|
||||
assert config.text == ""
|
||||
assert config.align_mode == "full"
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigParse:
|
||||
def test_parse_none(self):
|
||||
config = TtsConfig.parse(None)
|
||||
assert config.enabled is False
|
||||
assert isinstance(config, TtsConfig)
|
||||
|
||||
def test_parse_empty_dict(self):
|
||||
config = TtsConfig.parse({})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_not_dict(self):
|
||||
config = TtsConfig.parse("not a dict")
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_enabled_false_returns_disabled(self):
|
||||
# 即使传了其他参数,enabled=False 就直接返回禁用
|
||||
config = TtsConfig.parse({"enabled": False, "voice_id": "v1", "speed": 1.5})
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_enabled_true_with_all_fields(self):
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": True,
|
||||
"voice_id": "female_warm",
|
||||
"speed": 1.5,
|
||||
"pitch": 2.0,
|
||||
"volume": 0.9,
|
||||
"text": "你好世界",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "female_warm"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 2.0
|
||||
assert config.volume == 0.9
|
||||
assert config.text == "你好世界"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_enabled_not_bool(self):
|
||||
config = TtsConfig.parse({"enabled": "true", "voice_id": "v1"})
|
||||
assert config.enabled is False # 非 bool 值视为 False
|
||||
|
||||
def test_parse_voice_id_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_speed_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "text": 12345})
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_invalid_align_mode(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_invalid_overlap_mode(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigClamp:
|
||||
def test_speed_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_speed_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_speed_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 1.2})
|
||||
assert config.speed == 1.2
|
||||
|
||||
def test_speed_boundary_values(self):
|
||||
config_low = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
assert config_low.speed == 0.5
|
||||
config_high = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
assert config_high.speed == 2.0
|
||||
|
||||
def test_pitch_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_pitch_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_pitch_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -3.5})
|
||||
assert config.pitch == -3.5
|
||||
|
||||
def test_volume_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_volume_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 2.0})
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_volume_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 0.5})
|
||||
assert config.volume == 0.5
|
||||
|
||||
def test_int_speed_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 1})
|
||||
assert isinstance(config.speed, float)
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_int_pitch_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 2})
|
||||
assert isinstance(config.pitch, float)
|
||||
assert config.pitch == 2.0
|
||||
|
||||
def test_int_volume_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1})
|
||||
assert isinstance(config.volume, float)
|
||||
assert config.volume == 1.0
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
"""VerificationCode 领域实体单测."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
def test_create_default_ttl(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.id
|
||||
assert code.recipient == "test@example.com"
|
||||
assert code.code_type == "email_bind"
|
||||
assert len(code.code) == 6
|
||||
assert code.code.isdigit()
|
||||
assert code.used_at is None
|
||||
assert code.attempts == 0
|
||||
# 默认5分钟过期
|
||||
assert code.expires_at > code.created_at
|
||||
assert (code.expires_at - code.created_at).total_seconds() == pytest.approx(300, abs=1)
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
code = VerificationCode.create(recipient="13800138000", code_type="phone_login", ttl_seconds=60)
|
||||
assert (code.expires_at - code.created_at).total_seconds() == pytest.approx(60, abs=1)
|
||||
|
||||
def test_create_custom_code(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="reset_password", custom_code="123456")
|
||||
assert code.code == "123456"
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
code = VerificationCode.create(recipient=" test@example.com ", code_type="email_bind")
|
||||
assert code.recipient == "test@example.com"
|
||||
|
||||
|
||||
class TestVerificationCodeStatus:
|
||||
def test_is_valid_initial(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.is_valid is True
|
||||
assert code.is_expired is False
|
||||
assert code.is_used is False
|
||||
|
||||
def test_mark_used(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
code.mark_used()
|
||||
assert code.is_used is True
|
||||
assert code.used_at is not None
|
||||
assert code.is_valid is False
|
||||
|
||||
def test_is_expired_future(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind", ttl_seconds=3600)
|
||||
assert code.is_expired is False
|
||||
|
||||
def test_increment_attempts(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.attempts == 0
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 1
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 2
|
||||
|
||||
def test_is_valid_after_expired(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind", ttl_seconds=0)
|
||||
# 0秒TTL,立即可能过期(有极小概率因时间差没过)
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
assert code.is_expired is True
|
||||
assert code.is_valid is False
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
"""voice_presets 音色预设单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.voice_presets import (
|
||||
MOCK_VOICES,
|
||||
VoiceGender,
|
||||
VoicePreset,
|
||||
VoiceStyle,
|
||||
get_default_voice,
|
||||
get_voice,
|
||||
list_voices,
|
||||
)
|
||||
|
||||
|
||||
class TestVoiceGender:
|
||||
def test_values(self):
|
||||
assert VoiceGender.MALE.value == "male"
|
||||
assert VoiceGender.FEMALE.value == "female"
|
||||
assert VoiceGender.CHILD.value == "child"
|
||||
|
||||
def test_is_str(self):
|
||||
assert isinstance(VoiceGender.FEMALE, str)
|
||||
|
||||
|
||||
class TestVoiceStyle:
|
||||
def test_values(self):
|
||||
assert VoiceStyle.STABLE.value == "stable"
|
||||
assert VoiceStyle.LIVELY.value == "lively"
|
||||
assert VoiceStyle.NARRATION.value == "narration"
|
||||
assert VoiceStyle.NEWS.value == "news"
|
||||
assert VoiceStyle.STORY.value == "story"
|
||||
|
||||
|
||||
class TestVoicePreset:
|
||||
def test_default_values(self):
|
||||
v = VoicePreset(voice_id="test", name="测试音色")
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
assert v.style == VoiceStyle.NARRATION
|
||||
assert v.provider == "mock"
|
||||
assert v.default_speed == 1.0
|
||||
assert v.default_pitch == 0.0
|
||||
assert v.sample_rate == 22050
|
||||
assert v.language == "zh-CN"
|
||||
|
||||
def test_custom_values(self):
|
||||
v = VoicePreset(
|
||||
voice_id="male1",
|
||||
name="男声",
|
||||
gender=VoiceGender.MALE,
|
||||
style=VoiceStyle.STABLE,
|
||||
provider="aliyun",
|
||||
default_speed=0.9,
|
||||
)
|
||||
assert v.gender == VoiceGender.MALE
|
||||
assert v.style == VoiceStyle.STABLE
|
||||
assert v.provider == "aliyun"
|
||||
assert v.default_speed == 0.9
|
||||
|
||||
|
||||
class TestMockVoices:
|
||||
def test_mock_voices_not_empty(self):
|
||||
assert len(MOCK_VOICES) > 0
|
||||
|
||||
def test_all_mock_voices_have_ids(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert v.voice_id
|
||||
assert v.name
|
||||
assert v.provider == "mock"
|
||||
|
||||
def test_unique_voice_ids(self):
|
||||
ids = [v.voice_id for v in MOCK_VOICES]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
class TestGetVoice:
|
||||
def test_get_existing_voice(self):
|
||||
v = get_voice("female_warm")
|
||||
assert v is not None
|
||||
assert v.voice_id == "female_warm"
|
||||
assert v.name == "温暖女声"
|
||||
|
||||
def test_get_nonexistent_voice(self):
|
||||
v = get_voice("nonexistent")
|
||||
assert v is None
|
||||
|
||||
def test_non_mock_provider_returns_none(self):
|
||||
v = get_voice("female_warm", provider="aliyun")
|
||||
assert v is None
|
||||
|
||||
|
||||
class TestListVoices:
|
||||
def test_list_all(self):
|
||||
voices = list_voices()
|
||||
assert len(voices) == len(MOCK_VOICES)
|
||||
|
||||
def test_filter_by_gender(self):
|
||||
female_voices = list_voices(gender="female")
|
||||
assert len(female_voices) > 0
|
||||
assert all(v.gender == VoiceGender.FEMALE for v in female_voices)
|
||||
|
||||
def test_filter_by_style(self):
|
||||
story_voices = list_voices(style="story")
|
||||
assert len(story_voices) > 0
|
||||
assert all(v.style == VoiceStyle.STORY for v in story_voices)
|
||||
|
||||
def test_filter_by_keyword_name(self):
|
||||
voices = list_voices(keyword="女声")
|
||||
assert len(voices) > 0
|
||||
assert all("女声" in v.name for v in voices)
|
||||
|
||||
def test_filter_by_keyword_description(self):
|
||||
voices = list_voices(keyword="商务")
|
||||
assert len(voices) > 0
|
||||
assert any("商务" in v.description for v in voices)
|
||||
|
||||
def test_filter_by_provider_non_mock(self):
|
||||
voices = list_voices(provider="aliyun")
|
||||
assert len(voices) == 0
|
||||
|
||||
def test_filter_multiple_conditions(self):
|
||||
voices = list_voices(gender="female", style="narration")
|
||||
assert len(voices) > 0
|
||||
assert all(v.gender == VoiceGender.FEMALE for v in voices)
|
||||
assert all(v.style == VoiceStyle.NARRATION for v in voices)
|
||||
|
||||
def test_keyword_case_insensitive(self):
|
||||
voices1 = list_voices(keyword="FEMALE")
|
||||
voices2 = list_voices(keyword="female")
|
||||
assert len(voices1) == len(voices2)
|
||||
|
||||
|
||||
class TestGetDefaultVoice:
|
||||
def test_default_voice_exists(self):
|
||||
v = get_default_voice()
|
||||
assert v is not None
|
||||
assert v == MOCK_VOICES[0]
|
||||
Reference in New Issue
Block a user