Compare commits

...

8 Commits

Author SHA1 Message Date
xiaoxia 74c3acdf7d fix(ci): preview deploy npm ci remove --include=dev
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m11s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 52s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m32s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 17s
AI Code Review / AI Code Review (pull_request) Successful in 2m59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m4s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 48m25s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1292h13m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1292h13m32s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1292h13m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1292h13m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1292h14m14s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1292h14m23s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1292h14m25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1292h14m27s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1292h17m31s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1292h17m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1292h17m35s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1292h46m50s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1292h45m59s
2026-07-24 19:04:45 +08:00
xiaoxia 58fe71c483 PR #832: Worker基础镜像三级缓存策略优化 + 测试环境变量污染修复
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 55s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 48s
CI/CD Pipeline / Unit Tests (push) Successful in 6m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 23s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m50s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1292h5m15s
CI/CD Pipeline / Build Production API Image (push) Failing after 1292h11m7s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1292h11m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 1292h11m22s
CI/CD Pipeline / Deploy Production (push) Failing after 1292h5m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h13m26s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1292h11m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1292h43m34s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1292h43m49s
perf(ci): Worker基础镜像三级缓存策略优化 + 单元测试环境变量污染修复

- 三级缓存策略:L1本地daemon / L2 Gitea Registry / L3本地构建+推送
- 修复pre-build镜像tag与Dockerfile FROM地址不一致的bug
- worker.Dockerfile层顺序优化,变化少的文件放前面
- 修复test_api_settings.py autouse fixture环境变量污染
  (JWT_SECRET_KEY/DATABASE_URL被pop后影响后续测试模块)
2026-07-24 19:04:19 +08:00
xiaoxia 3ae15eb4fc refactor(#778): 统一Storage接口,定义StoragePort+SharedStorageService作为唯一入口 (#794)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 19:00:13 +08:00
CI Bot 68e55df83b style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 10:59:46 +00:00
xiaoxia 78afa40458 test: P3-1 第50波单元测试(generation_task/edit_plan/edit_template/edit_plan_clip,+86) (#838)
CI/CD Pipeline / Production Browser E2E (push) Failing after 1292h14m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1292h21m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1292h28m31s
CI/CD Pipeline / Build Production API Image (push) Failing after 1292h28m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1292h29m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 1292h29m20s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h30m21s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 55s
CI/CD Pipeline / Unit Tests (push) Failing after 6m24s
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1293h1m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1293h1m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
2026-07-24 18:58:21 +08:00
xiaoxia e38f13eb96 test: P3-1 第49波单元测试(generated_video/duplication/tts_config/classification,+88) (#837)
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h30m27s
2026-07-24 18:58:17 +08:00
xiaoxia 42335eff42 test: P3-1 第48波单元测试(tag/verification_code/subtitle/voice_presets,+59) (#836)
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h30m30s
2026-07-24 18:58:14 +08:00
xiaoxia 9a51cad137 refactor: GeneratePage Phase 2 — 7个Step组件化 + 自定义Hook(2682→485行) (#803)
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m24s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m20s
CI/CD Pipeline / Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Unit Tests (push) Failing after 7m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1294h4m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1294h5m22s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1294h5m22s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1294h7m45s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1294h7m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 1294h7m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1294h7m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1294h37m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 1294h37m51s
2026-07-24 17:20:59 +08:00
36 changed files with 5691 additions and 2495 deletions
+79 -59
View File
@@ -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
+2 -2
View File
@@ -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", () => {
+23 -22
View File
@@ -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
+118
View File
@@ -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
View File
@@ -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):
"""统一存储服务 — 实现 StoragePortAPI 和 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
View File
@@ -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 开始 ==="
+13 -1
View File
@@ -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:
+115
View File
@@ -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
+305
View File
@@ -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
+78
View File
@@ -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
+177
View File
@@ -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("")
+68
View File
@@ -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
+139
View File
@@ -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
+300
View File
@@ -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
+227
View File
@@ -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
+27
View File
@@ -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
+153
View File
@@ -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
+70
View File
@@ -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
+137
View File
@@ -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]