Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 499c66c295 | |||
| d5ddb47c8f |
@@ -21,8 +21,9 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ci-pipeline-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
|
||||
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
@@ -1086,7 +1087,26 @@ jobs:
|
||||
- name: Run Playwright E2E on staging
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/ci/run_staging_tests.sh e2e
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1133,7 +1153,22 @@ jobs:
|
||||
- name: Run API integration tests on staging
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/ci/run_staging_tests.sh api
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-api-tests-$$"
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1460,8 +1495,9 @@ jobs:
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Run production browser E2E
|
||||
shell: bash
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm --ipc=host \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
||||
@@ -1470,7 +1506,7 @@ jobs:
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying..."; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1826,4 +1862,4 @@ jobs:
|
||||
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -95,9 +95,12 @@ jobs:
|
||||
set -eu
|
||||
cd apps/web
|
||||
|
||||
# Config npm mirror for speed
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break
|
||||
npm ci --no-audit --no-fund && break
|
||||
echo "npm install failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
@@ -106,12 +109,12 @@ jobs:
|
||||
|
||||
# TypeScript check
|
||||
echo "=== TypeScript check ==="
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
npx --no-install tsc --noEmit
|
||||
|
||||
# Vite build
|
||||
echo "=== Vite build ==="
|
||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
||||
./node_modules/.bin/vite build
|
||||
npx --no-install vite build
|
||||
|
||||
echo "=== Build completed ==="
|
||||
ls -la dist/
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""确认生成 API 改造:为 generation_tasks 表添加 source_task_id、output_width、output_height、cover_url、custom_title 字段
|
||||
|
||||
Revision ID: 054_confirm_gen_fields
|
||||
Revises: 053_generation_task_is_preview
|
||||
Create Date: 2026-08-16
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 source_task_id(来源预览任务 ID,带索引)
|
||||
2. generation_tasks 表新增 output_width / output_height(动态输出分辨率)
|
||||
3. generation_tasks 表新增 cover_url / custom_title(自定义封面和标题)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "054_confirm_gen_fields"
|
||||
down_revision = "053_generation_task_is_preview"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
is_pg = conn.dialect.name == "postgresql"
|
||||
|
||||
if is_pg:
|
||||
# 幂等检查:source_task_id 列是否已存在
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'source_task_id'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
# source_task_id
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# output_width
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_width", sa.Integer, nullable=False, server_default=sa.text("1280")),
|
||||
)
|
||||
|
||||
# output_height
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_height", sa.Integer, nullable=False, server_default=sa.text("720")),
|
||||
)
|
||||
|
||||
# cover_url
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# custom_title
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# 索引
|
||||
op.create_index(
|
||||
"ix_generation_tasks_source_task_id",
|
||||
"generation_tasks",
|
||||
["source_task_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_source_task_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "custom_title")
|
||||
op.drop_column("generation_tasks", "cover_url")
|
||||
op.drop_column("generation_tasks", "output_height")
|
||||
op.drop_column("generation_tasks", "output_width")
|
||||
op.drop_column("generation_tasks", "source_task_id")
|
||||
@@ -1,84 +0,0 @@
|
||||
"""封面模板表 cover_templates
|
||||
|
||||
Revision ID: 055_cover_templates
|
||||
Revises: 054_confirm_gen_fields
|
||||
Create Date: 2026-08-09
|
||||
|
||||
Changes:
|
||||
1. 新建 cover_templates 表,支持系统预置和用户自定义封面模板
|
||||
2. user_id 为 NULL 表示系统模板,is_system 标记区分
|
||||
3. config 为 JSON 字段,存储封面配置信息
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "055_cover_templates"
|
||||
down_revision = "054_confirm_gen_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
SYSTEM_TEMPLATES = [
|
||||
("a8b0120fd98e44788f5a6590f983d327", "默认模板", {}),
|
||||
("6d8c501b11424432b3df3a45ae89b1a9", "大胆红", {"background_color": "#ef4444"}),
|
||||
("04937fb57fea4bad95e7883e71a6b246", "优雅黑", {"background_color": "#111827"}),
|
||||
("3ff9cc821174437ca53931073e7f536e", "渐变蓝", {"background_color": "#3b82f6"}),
|
||||
("db51b3ea8f1a4f4caa94bf2d51f27d11", "渐变紫", {"background_color": "#8b5cf6"}),
|
||||
("5027d113432a4f798a3b4ee1644d66af", "暖橙", {"background_color": "#f97316"}),
|
||||
("0e10def2b5a148d686416494474726c2", "清新绿", {"background_color": "#22c55e"}),
|
||||
("38ea98ac00c04bada064006d880546f0", "科技蓝", {"background_color": "#06b6d4"}),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
result = conn.execute(sa.text("SELECT to_regclass('public.cover_templates')"))
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"cover_templates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=True, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""),
|
||||
sa.Column("is_system", sa.Boolean, nullable=False, server_default=sa.false(), index=True),
|
||||
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# 预置系统模板 seed 数据
|
||||
cover_templates = sa.table(
|
||||
"cover_templates",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("user_id", sa.String),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("thumbnail_url", sa.String),
|
||||
sa.column("is_system", sa.Boolean),
|
||||
sa.column("config", sa.JSON),
|
||||
sa.column("created_at", sa.DateTime),
|
||||
sa.column("updated_at", sa.DateTime),
|
||||
)
|
||||
|
||||
for tid, name, config in SYSTEM_TEMPLATES:
|
||||
conn.execute(
|
||||
cover_templates.insert().values(
|
||||
id=tid,
|
||||
user_id=None,
|
||||
name=name,
|
||||
thumbnail_url="",
|
||||
is_system=True,
|
||||
config=json.dumps(config),
|
||||
created_at=sa.func.now(),
|
||||
updated_at=sa.func.now(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cover_templates")
|
||||
@@ -5,10 +5,8 @@ from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_cover import router as generation_cover_router
|
||||
from app.api.routes.generation_preview import router as generation_preview_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
@@ -47,10 +45,6 @@ api_router.include_router(
|
||||
prefix="/tags",
|
||||
tags=["Tag"],
|
||||
)
|
||||
api_router.include_router(
|
||||
cover_templates_router,
|
||||
tags=["CoverTemplate"],
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
@@ -99,11 +93,6 @@ api_router.include_router(
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_cover_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
|
||||
@@ -671,28 +671,20 @@ def create_asset(
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
# 先获取素材库,用于推导 project_id(前端可能不传)
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
|
||||
# project_id 自动推导:优先用请求值,否则从 library 关联的项目获取
|
||||
project_id = request.project_id or library.project_id
|
||||
|
||||
project = project_repository.find_by_id(project_id)
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
# 确保 library 和 project 归属一致
|
||||
if library.project_id != project_id:
|
||||
raise HTTPException(status_code=400, detail="AssetLibrary does not belong to the specified project")
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None or library.project_id != request.project_id:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
|
||||
use_case = CreateAssetUseCase(asset_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetCommand(
|
||||
project_id=project_id,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
name=request.name,
|
||||
storage_key=request.storage_key,
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""封面模板 CRUD 路由。
|
||||
|
||||
API:
|
||||
GET /api/v1/cover-templates - 列出当前用户可见的模板
|
||||
POST /api/v1/cover-templates - 创建自定义模板
|
||||
PUT /api/v1/cover-templates/{id} - 更新模板
|
||||
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_cover_template_repository
|
||||
from app.schemas.cover_template import (
|
||||
CoverTemplateResponse,
|
||||
CreateCoverTemplateRequest,
|
||||
ListCoverTemplatesResponse,
|
||||
UpdateCoverTemplateRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
|
||||
from packages.domain.cover_template import CoverTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/cover-templates", tags=["CoverTemplate"])
|
||||
|
||||
|
||||
@router.get("", response_model=ListCoverTemplatesResponse)
|
||||
def list_cover_templates(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> ListCoverTemplatesResponse:
|
||||
"""列出当前用户可见的封面模板(系统模板 + 用户自定义模板)。
|
||||
|
||||
当数据库表不存在时(迁移未执行),降级返回空列表而非 500。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
items = repo.list_for_user(user_id, skip=skip, limit=limit)
|
||||
total = repo.count_for_user(user_id)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表查询失败(可能未迁移),返回空列表: %s", exc)
|
||||
return ListCoverTemplatesResponse(items=[], total=0)
|
||||
return ListCoverTemplatesResponse(
|
||||
items=[
|
||||
CoverTemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
thumbnail_url=t.thumbnail_url,
|
||||
is_system=t.is_system,
|
||||
created_at=t.created_at,
|
||||
config=t.config or {},
|
||||
)
|
||||
for t in items
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CoverTemplateResponse, status_code=201)
|
||||
def create_cover_template(
|
||||
request: CreateCoverTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> CoverTemplateResponse:
|
||||
"""创建用户自定义封面模板。"""
|
||||
user_id = authenticated_user.user.id
|
||||
config_dict = request.config.model_dump() if request.config else {}
|
||||
template = CoverTemplate.create_user(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
config=config_dict,
|
||||
thumbnail_url=request.thumbnail_url,
|
||||
)
|
||||
try:
|
||||
created = repo.create(template)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表不可用(可能未迁移): %s", exc)
|
||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
||||
return CoverTemplateResponse(
|
||||
id=created.id,
|
||||
name=created.name,
|
||||
thumbnail_url=created.thumbnail_url,
|
||||
is_system=created.is_system,
|
||||
created_at=created.created_at,
|
||||
config=created.config,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{template_id}", response_model=CoverTemplateResponse)
|
||||
def update_cover_template(
|
||||
template_id: str,
|
||||
request: UpdateCoverTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> CoverTemplateResponse:
|
||||
"""更新封面模板(仅允许更新自己的模板)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
template = repo.get(template_id)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表不可用: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
if template.is_system:
|
||||
raise HTTPException(status_code=403, detail="系统模板不可修改")
|
||||
if template.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="无权修改该模板")
|
||||
|
||||
if request.name is not None:
|
||||
template.update(name=request.name)
|
||||
if request.config is not None:
|
||||
template.update(config=request.config.model_dump())
|
||||
if request.thumbnail_url is not None:
|
||||
template.update(thumbnail_url=request.thumbnail_url)
|
||||
|
||||
updated = repo.update(template)
|
||||
return CoverTemplateResponse(
|
||||
id=updated.id,
|
||||
name=updated.name,
|
||||
thumbnail_url=updated.thumbnail_url,
|
||||
is_system=updated.is_system,
|
||||
created_at=updated.created_at,
|
||||
config=updated.config,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=204, response_class=Response)
|
||||
def delete_cover_template(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> None:
|
||||
"""删除用户自定义封面模板(系统模板不可删除)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
template = repo.get(template_id)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表不可用: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
if template.is_system:
|
||||
raise HTTPException(status_code=403, detail="系统模板不可删除")
|
||||
if template.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="无权删除该模板")
|
||||
repo.delete(template_id)
|
||||
@@ -1,237 +0,0 @@
|
||||
"""封面生成路由 — Generation 模块.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
|
||||
挂载路径: /api/v1/generation/generate-cover
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Generation"])
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
template_id: str = Query(..., description="模板 ID"),
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
logger.info(
|
||||
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
|
||||
)
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
logger.info(
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
if preview_tasks:
|
||||
completed_preview = preview_tasks[0]
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(completed_preview.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
completed_preview.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 仍然找不到才报 400
|
||||
if not rendered_storage_key:
|
||||
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请先生成预览视频,再生成封面",
|
||||
)
|
||||
|
||||
# 回写到 plan.config
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读)
|
||||
primary_video_url = None
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
|
||||
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
|
||||
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取预览视频URL失败: {e}",
|
||||
) from e
|
||||
|
||||
# 优先使用渲染时预抽的封面候选帧(跳过 MediaKit,秒级返回)
|
||||
cover_candidates = (plan.config or {}).get("cover_candidates", [])
|
||||
if cover_candidates and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
logger.info(
|
||||
"[封面生成] 使用预存封面候选帧: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(cover_candidates),
|
||||
)
|
||||
first_frame = cover_candidates[0]
|
||||
cover_data = {
|
||||
"type": "ai_frame",
|
||||
"image_url": first_frame.get("image_url", ""),
|
||||
"frame_time": first_frame.get("frame_time", 0.0),
|
||||
"confidence": 0.9,
|
||||
}
|
||||
if cover_data["image_url"]:
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
try:
|
||||
logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id)
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -17,7 +17,6 @@ from app.core.task_enqueue import (
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
@@ -46,6 +45,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PREVIEW_RESOLUTION = "854x480"
|
||||
|
||||
# 模板 mode → 视频比例映射
|
||||
_TEMPLATE_MODE_TO_RATIO = {
|
||||
@@ -55,7 +55,24 @@ _TEMPLATE_MODE_TO_RATIO = {
|
||||
}
|
||||
|
||||
|
||||
def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||||
def _calc_preview_resolution(video_ratio: str = "") -> str:
|
||||
"""根据视频比例计算预览分辨率(短边 480,长边按比例)。
|
||||
|
||||
支持的比例:16:9, 9:16, 1:1, 4:3, 3:4, 其他默认 16:9。
|
||||
"""
|
||||
ratio_map = {
|
||||
"16:9": "854x480",
|
||||
"9:16": "480x854",
|
||||
"1:1": "480x480",
|
||||
"4:3": "640x480",
|
||||
"3:4": "480x640",
|
||||
}
|
||||
return ratio_map.get(video_ratio.strip(), PREVIEW_RESOLUTION)
|
||||
|
||||
|
||||
def _infer_video_ratio_from_template(
|
||||
template_id: str, db: Session, user_id: str = ""
|
||||
) -> str:
|
||||
"""从模板 mode 推断视频比例,前端未传 video_ratio 时使用。
|
||||
|
||||
Returns:
|
||||
@@ -85,7 +102,9 @@ def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_strategy_id_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||||
def _resolve_strategy_id_from_template(
|
||||
template_id: str, db: Session, user_id: str = ""
|
||||
) -> str:
|
||||
"""从模板读取 editing_mode / mode 作为 strategy_id。
|
||||
|
||||
优先查新模板系统(EditTemplate.editing_mode),fallback 旧模板(Template.mode)。
|
||||
@@ -155,6 +174,29 @@ def _mark_task_failed(repo, task, reason: str) -> None:
|
||||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
||||
|
||||
|
||||
def _sign_video_url(raw_url: str) -> str:
|
||||
"""为私有 OSS bucket 的视频 URL 生成预签名下载链接。
|
||||
|
||||
有效期 2 小时,签名失败时降级返回原始 URL。
|
||||
"""
|
||||
if not raw_url:
|
||||
return ""
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
signed = storage.get_download_url(raw_url, expires_seconds=7200)
|
||||
# 如果返回的 URL 与原始 URL 完全不同且不是签名 URL(说明 bucket 未配置),
|
||||
# 降级返回原始 URL
|
||||
if signed and signed != raw_url:
|
||||
return signed
|
||||
if signed == raw_url:
|
||||
return raw_url
|
||||
# signed 为空或与 raw_url 无关,返回原始
|
||||
return raw_url
|
||||
except Exception:
|
||||
logger.warning("[预览] URL签名失败,降级返回原始URL: %s", raw_url[:100], exc_info=True)
|
||||
return raw_url
|
||||
|
||||
|
||||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
||||
"""将领域任务对象转换为预览响应 DTO。
|
||||
|
||||
@@ -171,12 +213,8 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
|
||||
if generated_videos:
|
||||
first_video = generated_videos[0]
|
||||
raw_url = getattr(first_video, "file_url", "") or ""
|
||||
# rendered/* 已配置公开读,直接用裸 URL
|
||||
if raw_url.startswith("http"):
|
||||
video_url = raw_url
|
||||
else:
|
||||
storage = get_storage_service()
|
||||
video_url = storage.get_url(raw_url)
|
||||
# P0 修复:私有 bucket 需要预签名 URL,否则前端 403 → 黑屏
|
||||
video_url = _sign_video_url(raw_url)
|
||||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
||||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
||||
|
||||
@@ -198,7 +236,7 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
|
||||
status=task.status.value if hasattr(task.status, "value") else str(task.status),
|
||||
progress=float(task.progress or 0.0),
|
||||
is_preview=bool(getattr(task, "is_preview", True)),
|
||||
resolution=getattr(task, "resolution", "") or "",
|
||||
resolution=getattr(task, "resolution", PREVIEW_RESOLUTION) or PREVIEW_RESOLUTION,
|
||||
video_url=video_url,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
@@ -219,11 +257,10 @@ def create_preview_generation_task(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
asset_repo=Depends(get_asset_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""创建预览生成任务。
|
||||
|
||||
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
|
||||
预览为完整时长的低清版(480p + 低码率),效果与正式生成一致,仅清晰度降低。
|
||||
|
||||
Args:
|
||||
request: 预览任务创建请求(template_id + asset_ids 等)
|
||||
@@ -233,11 +270,10 @@ def create_preview_generation_task(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
logger.info(
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d",
|
||||
user_id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.preview_count,
|
||||
)
|
||||
|
||||
# 预检查队列限流
|
||||
@@ -275,17 +311,17 @@ def create_preview_generation_task(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id=strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
voice_library_id="",
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_ids),
|
||||
voice_ids=list(request.voice_ids),
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
source_edit_plan_id="",
|
||||
asset_select_mode="",
|
||||
batch_id="",
|
||||
video_title=request.video_title,
|
||||
resolution="",
|
||||
resolution=_calc_preview_resolution(video_ratio),
|
||||
bgm_config=request.bgm_config or {},
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
|
||||
@@ -26,7 +26,6 @@ from app.schemas.generated_video import (
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
@@ -63,12 +62,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -298,12 +291,6 @@ def create_generation_task(
|
||||
bgm_config=request.bgm_config,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
is_preview=request.is_preview,
|
||||
source_task_id=request.source_task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -344,120 +331,6 @@ def create_generation_task(
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
|
||||
def confirm_generation(
|
||||
task_id: str,
|
||||
request: ConfirmGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
|
||||
|
||||
预览已使用 1080p / CRF 23 / medium 渲染,品质与正式生成一致。
|
||||
确认时直接将预览任务标记为正式产出,无需重新渲染,实现秒出。
|
||||
仅当预览任务未完成时,才创建新的正式任务走渲染流程。
|
||||
"""
|
||||
# 1. 查找源预览任务
|
||||
source_task = generation_task_repository.get(task_id)
|
||||
if source_task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Preview task {task_id} not found")
|
||||
|
||||
# 2. 权限检查
|
||||
if source_task.created_by_user_id and source_task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
if source_task.project_id:
|
||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
|
||||
if source_task.is_completed and getattr(source_task, "is_preview", False):
|
||||
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
|
||||
req_w = request.output_width or 0
|
||||
req_h = request.output_height or 0
|
||||
src_w = getattr(source_task, "output_width", 0) or 0
|
||||
src_h = getattr(source_task, "output_height", 0) or 0
|
||||
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
|
||||
|
||||
if resolution_match:
|
||||
source_task.mark_confirmed(
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
)
|
||||
generation_task_repository.update(source_task)
|
||||
logger.info(
|
||||
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(source_task)],
|
||||
total=1,
|
||||
)
|
||||
# 分辨率不一致,跳过复用,走新建任务流程
|
||||
logger.info(
|
||||
"[确认生成] 分辨率不一致,跳过复用: task_id=%s, src=%sx%s, req=%sx%s",
|
||||
task_id,
|
||||
src_w,
|
||||
src_h,
|
||||
req_w,
|
||||
req_h,
|
||||
)
|
||||
|
||||
# 4. 预览任务未完成,创建新的正式任务走渲染流程
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
new_task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=source_task.project_id,
|
||||
asset_library_id=source_task.asset_library_id,
|
||||
strategy_id=source_task.strategy_id,
|
||||
voice_library_id=source_task.voice_library_id,
|
||||
template_id=source_task.template_id,
|
||||
asset_ids=source_task.asset_ids,
|
||||
title_ids=source_task.title_ids,
|
||||
voice_ids=source_task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=source_task.source_edit_plan_id or "",
|
||||
asset_select_mode=source_task.asset_select_mode,
|
||||
video_title=getattr(source_task, "video_title", ""),
|
||||
resolution=getattr(source_task, "resolution", ""),
|
||||
is_preview=False,
|
||||
source_task_id=task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 调度 worker
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
new_task,
|
||||
generation_task_repository,
|
||||
user_id=authenticated_user.user.id,
|
||||
log_prefix="[确认生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(new_task)],
|
||||
total=1,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -555,12 +428,6 @@ def retry_generation_task(
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- bgm.py: BGM 管理
|
||||
- effects.py: 转场 + 滤镜
|
||||
- export.py: 导出配置
|
||||
- cover.py: 封面管理 + AI 生成封面
|
||||
- subtitles.py: 字幕管理
|
||||
- ai_features.py: AI 推荐
|
||||
- generation.py: 生成(触发/进度/记录)
|
||||
@@ -30,6 +31,7 @@ from .adjustments import router as adjustments_router
|
||||
from .ai_features import router as ai_features_router
|
||||
from .bgm import router as bgm_router
|
||||
from .clips import router as clips_router
|
||||
from .cover import router as cover_router
|
||||
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
@@ -49,6 +51,7 @@ _sub_routers = [
|
||||
bgm_router,
|
||||
effects_router,
|
||||
export_router,
|
||||
cover_router,
|
||||
subtitles_router,
|
||||
ai_features_router,
|
||||
generation_router,
|
||||
|
||||
@@ -27,14 +27,18 @@ from packages.domain.edit_plan import EditPlanStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
def _auto_fallback_draft_to_editing(
|
||||
svc: EditPlanService, plan_id: str, plan_check
|
||||
) -> None:
|
||||
"""自动兜底 1: draft → editing"""
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
|
||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||
def _auto_fallback_copy_template_clips(
|
||||
svc: EditPlanService, plan_id: str, plan_check, db: Session
|
||||
) -> None:
|
||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
@@ -49,15 +53,15 @@ def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
clip_type=cfg.clip_type.value
|
||||
if hasattr(cfg.clip_type, "value")
|
||||
else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
transition_effect=cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
||||
@@ -86,20 +90,14 @@ def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_
|
||||
)
|
||||
|
||||
|
||||
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
|
||||
def _auto_fallback_assign_assets(
|
||||
svc: EditPlanService, plan_id: str, plan_check
|
||||
) -> list:
|
||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
|
||||
plan_id,
|
||||
len(all_clips),
|
||||
len(clips_without_asset),
|
||||
config_asset_ids[:5] if config_asset_ids else [],
|
||||
)
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
@@ -107,42 +105,11 @@ def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check)
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
assigned = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
|
||||
plan_id,
|
||||
assigned,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新检查剩余无素材片段
|
||||
all_clips_after = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
|
||||
if clips_without_asset:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
elif not clips_without_asset:
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
|
||||
elif not config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
|
||||
plan_id,
|
||||
)
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||
clips_without_asset = []
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
@@ -154,74 +121,43 @@ def _auto_fallback_auto_material_mode(
|
||||
clips_without_asset: list,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
user_id: str = "",
|
||||
) -> None:
|
||||
"""自动兜底 4: 自动选素材分配给无素材片段
|
||||
|
||||
查找策略(按优先级):
|
||||
1. plan 有 project_id → 从项目素材库查找
|
||||
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
|
||||
"""
|
||||
"""自动兜底 4: 项目有视频素材库时自动选素材"""
|
||||
if not clips_without_asset:
|
||||
return
|
||||
|
||||
ready_videos: list = []
|
||||
source_desc = ""
|
||||
|
||||
# 策略 1: 通过 project_id 查找项目素材库
|
||||
if plan_check.project_id:
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
source_desc = f"素材库 {video_lib.name}"
|
||||
|
||||
# 策略 2: 通过 user_id 查找用户上传的素材
|
||||
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
|
||||
plan_id,
|
||||
user_id,
|
||||
)
|
||||
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
|
||||
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
|
||||
|
||||
if not ready_videos:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
|
||||
plan_id,
|
||||
plan_check.project_id or "(empty)",
|
||||
user_id[:8] + "..." if user_id else "(empty)",
|
||||
)
|
||||
if not plan_check.project_id:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
|
||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
)
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 从 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
if ready_videos:
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材",
|
||||
plan_id,
|
||||
video_lib.name,
|
||||
len(ready_videos),
|
||||
)
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
"""封面管理路由.
|
||||
|
||||
端点:
|
||||
- GET /cover 封面配置
|
||||
- PUT /cover 更新封面
|
||||
- POST /cover/extract 抽帧生成封面
|
||||
- POST /cover/smart 智能选帧
|
||||
- POST /generate-cover AI 生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
CoverConfigResponse,
|
||||
CoverExtractRequest,
|
||||
CoverGenerateResponse,
|
||||
CoverSmartRequest,
|
||||
CoverUpdateRequest,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
@router.get("/cover", response_model=CoverConfigResponse)
|
||||
def get_editor_cover(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config = plan.config or {}
|
||||
cover_config = config.get("cover", {})
|
||||
|
||||
return CoverConfigResponse(
|
||||
type=cover_config.get("cover_type", "auto"),
|
||||
image_url=cover_config.get("cover_image_url", ""),
|
||||
frame_time=cover_config.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/cover", response_model=CoverConfigResponse)
|
||||
def update_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
current_cover = dict(config.get("cover", {}))
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
current_cover.update(update_data)
|
||||
|
||||
config["cover"] = current_cover
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
return CoverConfigResponse(
|
||||
type=current_cover.get("cover_type", "auto"),
|
||||
image_url=current_cover.get("cover_image_url", ""),
|
||||
frame_time=current_cover.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/extract", response_model=CoverGenerateResponse)
|
||||
def extract_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverExtractRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段抽帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
clip = plan_svc.get_clip(body.clip_id)
|
||||
if not clip or clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿")
|
||||
|
||||
cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg"
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "extract",
|
||||
"cover_image_url": cover_url,
|
||||
"clip_id": body.clip_id,
|
||||
"frame_time": body.frame_time,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
type="extract",
|
||||
image_url=cover_url,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/smart", response_model=CoverGenerateResponse)
|
||||
def smart_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverSmartRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
cover_url = f"cover/smart/{plan_id}_smart.jpg"
|
||||
strategy = getattr(body, "strategy", "auto")
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "smart",
|
||||
"cover_image_url": cover_url,
|
||||
"strategy": strategy,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
strategy,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
type="smart",
|
||||
image_url=cover_url,
|
||||
frame_time=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def editor_generate_cover(
|
||||
template_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 获取第一个视频的下载 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url = None
|
||||
if body.asset_ids and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
try:
|
||||
from app.database import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
with get_db_session() as session:
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
first_asset = asset_repo.get(body.asset_ids[0])
|
||||
if first_asset and first_asset.storage_key:
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_download_url(first_asset.storage_key)
|
||||
logger.info(
|
||||
"获取视频URL用于封面生成: asset_id=%s url=%s",
|
||||
body.asset_ids[0],
|
||||
primary_video_url[:80] if primary_video_url else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取视频URL失败,将使用stub封面: %s", str(e))
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -18,7 +18,6 @@ from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
)
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
@@ -29,7 +28,6 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
@@ -73,60 +71,25 @@ def generate_editor_draft(
|
||||
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
|
||||
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_auto_material_mode(
|
||||
plan_svc,
|
||||
plan_id,
|
||||
plan_check,
|
||||
clips_without_asset,
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id=str(current_user.user.id),
|
||||
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
||||
)
|
||||
|
||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
reusable_task.mark_confirmed()
|
||||
gen_task_repo.update(reusable_task)
|
||||
|
||||
# 将产物 URL 写入 plan config
|
||||
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
|
||||
plan_svc.update_plan_config(
|
||||
plan_id,
|
||||
{
|
||||
"generation_task_id": reusable_task.id,
|
||||
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
|
||||
},
|
||||
)
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
logger.info(
|
||||
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
reusable_task.id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=reusable_task.id,
|
||||
clip_count=len((plan_check.config or {}).get("clips", [])),
|
||||
)
|
||||
|
||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
||||
# 检查是否可生成
|
||||
try:
|
||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=reason
|
||||
)
|
||||
|
||||
try:
|
||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
@@ -160,7 +123,9 @@ def generate_editor_draft(
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
plan_status=updated_plan.status.value
|
||||
if hasattr(updated_plan.status, "value")
|
||||
else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
@@ -182,58 +147,6 @@ def generate_editor_draft(
|
||||
) from _e
|
||||
|
||||
|
||||
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
|
||||
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
|
||||
|
||||
复用条件:
|
||||
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
|
||||
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
|
||||
|
||||
Returns:
|
||||
可复用的 GenerationTask,或 None
|
||||
"""
|
||||
try:
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for task in tasks:
|
||||
if not getattr(task, "is_preview", False):
|
||||
continue
|
||||
if not task.is_completed:
|
||||
continue
|
||||
# 检查 plan 是否在预览完成后被修改
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if completed_at and hasattr(plan, "updated_at"):
|
||||
plan_updated = plan.updated_at
|
||||
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
|
||||
if plan_updated is None:
|
||||
continue
|
||||
# 如果 plan 在预览完成后又被修改了,不能复用
|
||||
if plan_updated > completed_at:
|
||||
continue
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
"""获取任务的输出视频 URL。"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
url = getattr(videos[0], "file_url", "") or ""
|
||||
# 规范化:合并路径中的双斜杠(保留协议头 ://)
|
||||
if url:
|
||||
import re as _re
|
||||
url = _re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||
def get_editor_generation_status(
|
||||
template_id: str,
|
||||
@@ -247,7 +160,9 @@ def get_editor_generation_status(
|
||||
try:
|
||||
gen_status = plan_svc.get_generation_status(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
clips = gen_status["clips"]
|
||||
@@ -265,22 +180,25 @@ def get_editor_generation_status(
|
||||
for c in clips
|
||||
]
|
||||
|
||||
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
|
||||
raw_video_url = (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
if raw_video_url.startswith("http"):
|
||||
video_url = raw_video_url # 已经是完整 URL
|
||||
else:
|
||||
try:
|
||||
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
|
||||
except Exception as e:
|
||||
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
|
||||
video_url = raw_video_url
|
||||
try:
|
||||
video_url = storage_service.get_download_url(
|
||||
raw_video_url, expires_seconds=86400
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"生成视频签名URL失败: template_id=%s error=%s", template_id, e
|
||||
)
|
||||
video_url = raw_video_url
|
||||
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
gen_task_status = gen_status.get("generation_task_status")
|
||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
plan_status_val = (
|
||||
plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
)
|
||||
if plan_status_val == "completed" and progress < 100:
|
||||
progress = 100.0
|
||||
|
||||
|
||||
@@ -99,6 +99,29 @@ class AIRecommendResponse(BaseModel):
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
# ── 封面生成 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── BGM ────────────────────────────────────────────────────────────────────
|
||||
@@ -234,6 +257,43 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── 导出配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRe
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.cover_template_repository import (
|
||||
SQLAlchemyCoverTemplateRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.duplication_repository import (
|
||||
SQLAlchemyDuplicationRecordRepository,
|
||||
)
|
||||
@@ -131,13 +128,6 @@ def get_project_repository(
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
def get_cover_template_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyCoverTemplateRepository:
|
||||
"""Provide the SQLAlchemy cover template repository implementation."""
|
||||
return SQLAlchemyCoverTemplateRepository(session)
|
||||
|
||||
|
||||
def get_tag_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
|
||||
@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateAssetRequest(BaseModel):
|
||||
project_id: str | None = Field(default=None, description="可选,不传时从 library.project_id 自动推导")
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""封面模板 Schema。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoverTemplateConfig(BaseModel):
|
||||
"""封面模板配置。"""
|
||||
|
||||
background_enabled: bool = Field(default=True, description="是否启用背景")
|
||||
background_color: str = Field(default="#000000", description="背景颜色")
|
||||
portrait_enabled: bool = Field(default=True, description="是否显示人像")
|
||||
title_text: str = Field(default="", description="主标题文字")
|
||||
subtitle_text: str = Field(default="", description="副标题文字")
|
||||
mask_enabled: bool = Field(default=False, description="是否启用蒙版")
|
||||
|
||||
|
||||
class CreateCoverTemplateRequest(BaseModel):
|
||||
"""创建封面模板请求。"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
||||
thumbnail_url: str = Field(default="", description="缩略图 URL")
|
||||
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
|
||||
|
||||
|
||||
class UpdateCoverTemplateRequest(BaseModel):
|
||||
"""更新封面模板请求。"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200, description="模板名称")
|
||||
thumbnail_url: str | None = Field(default=None, description="缩略图 URL")
|
||||
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
|
||||
|
||||
|
||||
class CoverTemplateResponse(BaseModel):
|
||||
"""封面模板响应。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
thumbnail_url: str
|
||||
is_system: bool
|
||||
created_at: datetime
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ListCoverTemplatesResponse(BaseModel):
|
||||
"""封面模板列表响应。"""
|
||||
|
||||
items: list[CoverTemplateResponse]
|
||||
total: int = Field(default=0, ge=0)
|
||||
@@ -4,15 +4,6 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class ConfirmGenerationRequest(BaseModel):
|
||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
||||
|
||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
"""创建生成任务请求。
|
||||
|
||||
@@ -66,13 +57,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
# ── 预览 / 确认生成 ──
|
||||
is_preview: bool = Field(default=False, description="是否为预览任务")
|
||||
source_task_id: str = Field(default="", description="来源预览任务 ID(确认生成时传入)")
|
||||
output_width: int = Field(default=1280, description="输出视频宽度")
|
||||
output_height: int = Field(default=720, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -103,12 +87,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = Field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
@@ -154,16 +132,13 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
"""创建预览生成任务请求。
|
||||
|
||||
仅支持模板模式:template_id + asset_ids 等素材 ID 列表。
|
||||
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset)。
|
||||
预览为完整时长低清版(480p + 低码率)。
|
||||
"""
|
||||
|
||||
template_id: str
|
||||
asset_ids: list[str] = Field(default_factory=list)
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
voice_library_id: str = Field(
|
||||
default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材"
|
||||
)
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
duration: float = Field(default=0.0, ge=0, description="期望视频时长(秒),0 表示由模板决定")
|
||||
video_ratio: str = Field(default="", description="视频比例,如 16:9 / 9:16,为空使用模板默认")
|
||||
@@ -171,16 +146,6 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
preview_count: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="预览视频生成数量,范围 1-10,默认 1",
|
||||
)
|
||||
source_edit_plan_id: str = Field(
|
||||
default="",
|
||||
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""封面管理服务.
|
||||
|
||||
提供封面配置管理和从视频抽帧生成封面的能力。
|
||||
抽帧使用 FFmpeg,上传使用共享存储服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_COVER_WIDTH = 1080
|
||||
DEFAULT_COVER_HEIGHT = 1920
|
||||
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
|
||||
COVER_STORAGE_PREFIX = "covers"
|
||||
|
||||
|
||||
class CoverService:
|
||||
"""封面管理服务."""
|
||||
|
||||
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
|
||||
self._storage = storage_service
|
||||
self._asset_repo = asset_repository
|
||||
|
||||
# ── 配置读写 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""从 plan.config 中提取封面配置.
|
||||
|
||||
Args:
|
||||
plan_config: 剪辑计划的 config 字段
|
||||
|
||||
Returns:
|
||||
封面配置 dict
|
||||
"""
|
||||
cover = plan_config.get("cover", {})
|
||||
if not isinstance(cover, dict):
|
||||
cover = {}
|
||||
# 确保默认字段存在
|
||||
return {
|
||||
"type": cover.get("type", "ai_frame"),
|
||||
"image_url": cover.get("image_url", ""),
|
||||
"frame_time": cover.get("frame_time"),
|
||||
}
|
||||
|
||||
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
|
||||
|
||||
def extract_cover_from_clip(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
frame_time: float = 1.0,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""从指定素材的指定时间点抽取一帧作为封面.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID(用于生成存储路径)
|
||||
asset_id: 素材 ID
|
||||
frame_time: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
|
||||
Raises:
|
||||
ValueError: 素材不存在或不是视频
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
# 1. 获取素材
|
||||
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
|
||||
if not asset:
|
||||
raise ValueError(f"素材不存在: {asset_id}")
|
||||
|
||||
storage_key = getattr(asset, "storage_key", "")
|
||||
if not storage_key:
|
||||
raise ValueError(f"素材没有文件: {asset_id}")
|
||||
|
||||
mime_type = getattr(asset, "mime_type", "")
|
||||
if mime_type and not mime_type.startswith("video"):
|
||||
raise ValueError(f"素材不是视频类型: {mime_type}")
|
||||
|
||||
# 2. 下载视频到临时目录
|
||||
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
video_path = tmp_path / f"source_{asset_id[:8]}"
|
||||
|
||||
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
|
||||
try:
|
||||
self._storage.download_file(storage_key, str(video_path))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"下载素材失败: {e}") from e
|
||||
|
||||
if not video_path.exists() or video_path.stat().st_size == 0:
|
||||
raise RuntimeError("下载的素材文件为空")
|
||||
|
||||
# 3. FFmpeg 抽帧
|
||||
output_path = tmp_path / "cover.jpg"
|
||||
self._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError("封面抽帧失败")
|
||||
|
||||
# 4. 上传到 OSS
|
||||
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
|
||||
logger.info("上传封面到存储: key=%s", cover_key)
|
||||
|
||||
try:
|
||||
self._storage.upload_file(
|
||||
file_or_path=str(output_path),
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"上传封面失败: {e}") from e
|
||||
|
||||
# 5. 获取访问 URL
|
||||
try:
|
||||
image_url = self._storage.get_url(cover_key)
|
||||
except Exception:
|
||||
image_url = cover_key # 降级为 storage_key
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
|
||||
plan_id,
|
||||
asset_id,
|
||||
frame_time,
|
||||
output_path.stat().st_size if output_path.exists() else 0,
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "manual",
|
||||
"image_url": image_url,
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
def generate_smart_cover(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_id: 素材 ID
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict
|
||||
"""
|
||||
# 简单实现:取视频 1/3 处的帧作为智能封面
|
||||
# 更复杂的多帧选清晰帧可以后续优化
|
||||
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
|
||||
|
||||
result = self.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
frame_time=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
result["type"] = "ai_frame"
|
||||
return result
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_frame(
|
||||
video_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
time_sec: float,
|
||||
width: int,
|
||||
height: int,
|
||||
quality: int,
|
||||
) -> None:
|
||||
"""使用 FFmpeg 从视频中抽取一帧.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径
|
||||
time_sec: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
|
||||
# 尝试不使用 scale+crop 的简化命令
|
||||
simple_command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
result2 = subprocess.run(
|
||||
simple_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result2.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError("FFmpeg 抽帧超时") from e
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError("FFmpeg 不可用") from e
|
||||
@@ -571,10 +571,6 @@ class EditPlanService:
|
||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||
"""检查是否可以触发渲染
|
||||
|
||||
包含最后一道防线的自动修复:
|
||||
- 如果 clips 存在但都没有 asset_id,且 config.asset_ids 非空,
|
||||
直接在内部执行素材分配,不再依赖前置 fallback 链路。
|
||||
|
||||
Returns:
|
||||
tuple: (can_generate, reason)
|
||||
"""
|
||||
@@ -589,69 +585,10 @@ class EditPlanService:
|
||||
if not clips:
|
||||
return False, "请先添加片段后再生成视频"
|
||||
|
||||
# 检查是否至少有一个片段分配了素材
|
||||
has_asset = any(c.asset_id for c in clips)
|
||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
||||
logger.info(
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
|
||||
plan_id,
|
||||
plan.status,
|
||||
len(clips),
|
||||
clips_with_asset_count,
|
||||
config_asset_ids_count,
|
||||
)
|
||||
if not has_asset:
|
||||
# ── 最后防线:自动从 config.asset_ids 分配素材 ──
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
if config_asset_ids:
|
||||
logger.warning(
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
|
||||
plan_id,
|
||||
len(clips),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
clips_without_asset = [c for c in clips if not c.asset_id]
|
||||
assigned_count = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
self.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned_count += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"can_generate 最后防线: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"can_generate 最后防线: plan=%s 已为 %d/%d 个片段分配素材",
|
||||
plan_id,
|
||||
assigned_count,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新加载 clips 验证分配结果
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
if not any(c.asset_id for c in clips):
|
||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
||||
else:
|
||||
logger.warning(
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
|
||||
plan_id,
|
||||
len(clips),
|
||||
)
|
||||
return False, "没有可渲染的就绪片段,请确保已选择素材"
|
||||
|
||||
return True, ""
|
||||
|
||||
def mark_clips_ready(self, plan_id: str) -> int:
|
||||
"""将已分配素材的 pending 片段标记为 ready
|
||||
|
||||
只标记同时满足以下条件的片段:
|
||||
- status == PENDING
|
||||
- asset_id 非空(已分配素材)
|
||||
"""将所有 pending 状态的片段标记为 ready
|
||||
|
||||
Returns:
|
||||
int: 标记的片段数量
|
||||
@@ -662,16 +599,10 @@ class EditPlanService:
|
||||
)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
if clip.asset_id:
|
||||
clip.mark_ready()
|
||||
self._clip_repo.update(clip)
|
||||
count += 1
|
||||
logger.info(
|
||||
"标记片段就绪: plan_id=%s marked=%d total_pending=%d",
|
||||
plan_id,
|
||||
count,
|
||||
len(clips),
|
||||
)
|
||||
clip.mark_ready()
|
||||
self._clip_repo.update(clip)
|
||||
count += 1
|
||||
logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
|
||||
return count
|
||||
|
||||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||||
|
||||
@@ -49,10 +49,9 @@ class PlanGeneratorService:
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session, asset_repo=None) -> None:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
self._asset_repo = asset_repo
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,7 +64,6 @@ class PlanGeneratorService:
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
name: str = "",
|
||||
random_preview: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""基于模板+素材生成剪辑计划
|
||||
|
||||
@@ -76,7 +74,6 @@ class PlanGeneratorService:
|
||||
project_id: 所属项目 ID
|
||||
created_by_user_id: 创建者用户 ID
|
||||
name: 计划名称(为空则自动取模板名)
|
||||
random_preview: 是否启用随机预览模式(随机选素材+随机截取片段)
|
||||
|
||||
Returns:
|
||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||
@@ -118,17 +115,7 @@ class PlanGeneratorService:
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
# 如果是随机预览模式,获取素材时长信息
|
||||
asset_durations = None
|
||||
if random_preview and self._asset_repo:
|
||||
asset_durations = self._fetch_asset_durations(asset_ids)
|
||||
self._distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
random_selection=random_preview,
|
||||
asset_durations=asset_durations,
|
||||
)
|
||||
self._distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: List[EditPlanClip] = []
|
||||
@@ -212,34 +199,9 @@ class PlanGeneratorService:
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
*,
|
||||
random_selection: bool = False,
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||
|
||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||
"""
|
||||
distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
random_selection=random_selection,
|
||||
asset_durations=asset_durations,
|
||||
)
|
||||
|
||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
||||
"""从数据库获取素材时长信息.
|
||||
|
||||
Args:
|
||||
asset_ids: 素材 ID 列表
|
||||
|
||||
Returns:
|
||||
dict: 素材 ID -> 时长(秒)映射
|
||||
"""
|
||||
durations: dict[str, float] = {}
|
||||
for asset_id in asset_ids:
|
||||
asset = self._asset_repo.get(asset_id)
|
||||
if asset and hasattr(asset, "duration"):
|
||||
durations[asset_id] = float(asset.duration or 0.0)
|
||||
return durations
|
||||
distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
@@ -374,7 +374,7 @@ class VideoComposeService:
|
||||
EditPlanStatus.EDITING,
|
||||
EditPlanStatus.RENDERING,
|
||||
),
|
||||
"rendered_url": plan.config.get("rendered_storage_key", "") or plan.config.get("rendered_url", ""),
|
||||
"rendered_url": plan.config.get("rendered_url", ""),
|
||||
}
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,10 +50,10 @@ type AssetListResponse = {
|
||||
}
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
test.setTimeout(180_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
@@ -196,50 +196,41 @@ test.describe("Core generation flow", () => {
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// Step 3: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 120_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
// Step 4: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Antd AutoComplete 的 placeholder 渲染在 span 上,input 无 placeholder 属性
|
||||
// 使用 Antd AutoComplete 特有的 class 定位输入框
|
||||
const titleInput = page.locator(".ant-select-auto-complete input")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
||||
await firstVoiceCard.click()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 7: confirm and generate
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 确认生成走新流程:POST /tasks/{taskId}/confirm(复用预览产物)
|
||||
// 或旧流程:POST /editor/generate(向后兼容)
|
||||
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
|
||||
// 等 generate 接口返回,确认生成流程启动
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
(path.endsWith("/confirm") || path.endsWith("/editor/generate"))
|
||||
)
|
||||
return response.request().method() === "POST" && path.endsWith("/editor/generate")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
@@ -255,15 +246,10 @@ test.describe("Core generation flow", () => {
|
||||
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
||||
)
|
||||
}
|
||||
// Generate API may return 400 in test env if template has no ready segments
|
||||
// That is OK for a wizard flow smoke test
|
||||
if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
expect(genResp.ok()).toBeTruthy()
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
|
||||
Generated
+26
-14
@@ -1847,7 +1847,7 @@
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -1937,7 +1937,7 @@
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -3112,7 +3112,7 @@
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -4028,6 +4028,18 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4456,7 +4468,7 @@
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -4998,7 +5010,7 @@
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -5014,7 +5026,7 @@
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -5026,6 +5038,14 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -5723,14 +5743,6 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 封面模板 CRUD API
|
||||
* 后端路由: /api/v1/cover-templates
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
|
||||
export interface CoverTemplateListResponse {
|
||||
items: CoverTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CoverTemplateCreateRequest {
|
||||
name: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
|
||||
|
||||
/** 获取封面模板列表 */
|
||||
export async function fetchCoverTemplates(): Promise<CoverTemplateListResponse> {
|
||||
const response = await apiClient.get<CoverTemplateListResponse>("/cover-templates")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建封面模板 */
|
||||
export async function createCoverTemplate(
|
||||
data: CoverTemplateCreateRequest,
|
||||
): Promise<CoverTemplate> {
|
||||
const response = await apiClient.post<CoverTemplate>("/cover-templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新封面模板 */
|
||||
export async function updateCoverTemplate(
|
||||
id: string,
|
||||
data: CoverTemplateUpdateRequest,
|
||||
): Promise<CoverTemplate> {
|
||||
const response = await apiClient.put<CoverTemplate>(`/cover-templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除封面模板(系统模板不可删) */
|
||||
export async function deleteCoverTemplate(id: string): Promise<void> {
|
||||
await apiClient.delete(`/cover-templates/${id}`)
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import type { CoverConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { ConfirmGenerationRequest, ConfirmGenerationResponse } from "./types"
|
||||
|
||||
/** 确认生成 — 基于预览任务创建正式生成任务 */
|
||||
export const confirmGeneration = async (
|
||||
taskId: string,
|
||||
params: ConfirmGenerationRequest,
|
||||
): Promise<ConfirmGenerationResponse> => {
|
||||
const response = await apiClient.post<ConfirmGenerationResponse>(
|
||||
`/generation/tasks/${taskId}/confirm`,
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string
|
||||
cover: {
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
image_url?: string
|
||||
thumbnail_url?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 生成封面 — 从预览视频中抽帧 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post<GenerateCoverResponse>(
|
||||
"/generation/generate-cover",
|
||||
{ ...data, template_id: templateId },
|
||||
{
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
Executable → Regular
-7
@@ -3,13 +3,6 @@ export type {
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
ConfirmGenerationResponse,
|
||||
ConfirmGenerationTaskItem,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
export { generateCover } from "./cover"
|
||||
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
|
||||
|
||||
Executable → Regular
-47
@@ -5,11 +5,8 @@ export type PreviewStatus = "pending" | "generating" | "completed" | "failed" |
|
||||
export interface CreatePreviewRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
source_edit_plan_id?: string
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材 */
|
||||
voice_library_id?: string
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
@@ -48,47 +45,3 @@ export interface PreviewTaskResponse {
|
||||
finished_at?: string
|
||||
generate_duration?: number
|
||||
}
|
||||
|
||||
/** 确认生成请求体 — 基于预览任务创建正式生成任务 */
|
||||
export interface ConfirmGenerationRequest {
|
||||
/** 输出视频宽度,默认 1080 */
|
||||
output_width?: number
|
||||
/** 输出视频高度,默认 1920 */
|
||||
output_height?: number
|
||||
/** 自定义封面图片 URL */
|
||||
cover_url?: string
|
||||
/** 自定义视频标题 */
|
||||
custom_title?: string
|
||||
}
|
||||
|
||||
/** 确认生成响应 */
|
||||
export interface ConfirmGenerationResponse {
|
||||
items: ConfirmGenerationTaskItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 确认生成返回的任务项 */
|
||||
export interface ConfirmGenerationTaskItem {
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
strategy_id: string
|
||||
voice_library_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
source_edit_plan_id: string
|
||||
asset_select_mode: string
|
||||
batch_id: string
|
||||
is_preview: boolean
|
||||
source_task_id: string
|
||||
output_width: number
|
||||
output_height: number
|
||||
cover_url: string
|
||||
custom_title: string
|
||||
status: string
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* AI 推荐 API
|
||||
* AI 推荐 + 封面生成 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { AIRecommendRequest, AIRecommendResponse } from "./types"
|
||||
import type {
|
||||
AIRecommendRequest,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
} from "./types"
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
export async function aiRecommendClips(
|
||||
@@ -12,3 +17,12 @@ export async function aiRecommendClips(
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ export type {
|
||||
AIRecommendRequest,
|
||||
AIRecommendClipItem,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
CoverResult,
|
||||
EditPlanClipStatus,
|
||||
EditPlanClip,
|
||||
CreateEditPlanClipRequest,
|
||||
@@ -78,8 +81,8 @@ export {
|
||||
createClipsFromAssets,
|
||||
} from "./clips"
|
||||
|
||||
// AI 推荐
|
||||
export { aiRecommendClips } from "./aiFeatures"
|
||||
// AI 推荐 + 封面生成
|
||||
export { aiRecommendClips, generateCover } from "./aiFeatures"
|
||||
|
||||
// 素材库
|
||||
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
|
||||
|
||||
@@ -9,8 +9,8 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import type { CoverConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
/* ── 模板草稿状态 ── */
|
||||
|
||||
@@ -118,10 +118,6 @@ export interface EditPlanConfig {
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 预览视频 URL(封面生成用) */
|
||||
rendered_storage_key?: string
|
||||
/** 生成任务 ID */
|
||||
generation_task_id?: string
|
||||
}
|
||||
|
||||
/* ── 模板草稿主体 ── */
|
||||
@@ -244,7 +240,7 @@ export interface GeneratedVideo {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ── AI 推荐 ── */
|
||||
/* ── AI 推荐 & 封面生成 ── */
|
||||
|
||||
/** AI 推荐请求 */
|
||||
export interface AIRecommendRequest {
|
||||
@@ -274,6 +270,27 @@ export interface AIRecommendResponse {
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** AI 封面生成请求 */
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string
|
||||
cover: CoverResult
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
/* ── 片段 CRUD 相关 ── */
|
||||
|
||||
/** 片段状态 */
|
||||
|
||||
@@ -22,10 +22,10 @@ interface ModalComponent extends React.FC<ModalProps> {
|
||||
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>
|
||||
}
|
||||
|
||||
const Modal: ModalComponent = ({ className, v21 = true, centered = true, children, ...rest }) => {
|
||||
const Modal: ModalComponent = ({ className, v21 = true, children, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-modal", className)
|
||||
return (
|
||||
<AntModal className={v21Class} centered={centered} {...rest}>
|
||||
<AntModal className={v21Class} {...rest}>
|
||||
{children}
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -33,7 +32,6 @@
|
||||
gap: var(--space-sm);
|
||||
position: sticky;
|
||||
top: var(--space-md);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-asset-library-item {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react"
|
||||
import { Select as AntSelect } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal, Select as AntSelect } from "antd"
|
||||
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
@@ -25,7 +24,7 @@ const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
onCategoryChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<Modal
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -44,7 +43,7 @@ const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchClassifyModal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
@@ -12,7 +12,7 @@ export interface PlayModalProps {
|
||||
}
|
||||
|
||||
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<Modal
|
||||
<AntModal
|
||||
title={asset?.name ?? "播放"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
@@ -28,7 +28,7 @@ const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<p className="xx-asset-empty-fallback-id">素材 ID: {asset?.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default PlayModal
|
||||
|
||||
@@ -14,13 +14,7 @@ export interface ResultDrawerProps {
|
||||
}
|
||||
|
||||
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
|
||||
<Drawer
|
||||
title={`${title} — 操作结果`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
>
|
||||
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
|
||||
{result && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.ep-v8-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 68px);
|
||||
height: 100vh;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
color: var(--text-primary, #1e293b);
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
@@ -6408,35 +6408,3 @@
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ═══ 封面 AI 生成按钮 ═══ */
|
||||
.cover-generate-section {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cover-generate-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--color-primary, #1677ff);
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary, #1677ff);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cover-generate-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover, #4096ff);
|
||||
border-color: var(--color-primary-hover, #4096ff);
|
||||
}
|
||||
|
||||
.cover-generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ const EditingPlanner: React.FC = () => {
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
} = useGlobalSettings()
|
||||
|
||||
/* ── 右侧栏 Tab ── */
|
||||
@@ -117,6 +119,7 @@ const EditingPlanner: React.FC = () => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
@@ -128,6 +131,7 @@ const EditingPlanner: React.FC = () => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
})
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
@@ -177,6 +181,7 @@ const EditingPlanner: React.FC = () => {
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
isPlaying={playback.isPlaying}
|
||||
titleConfig={titleConfig}
|
||||
coverConfig={coverConfig}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 封面选择器入口(向后兼容)
|
||||
* 实际实现位于 ./cover-selector/ 目录
|
||||
*/
|
||||
export { default } from "./cover-selector"
|
||||
@@ -56,7 +56,6 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
|
||||
title="滤镜调色"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="filter-panel-drawer"
|
||||
|
||||
@@ -48,7 +48,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
title="绿幕抠像"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
|
||||
@@ -63,7 +63,6 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config
|
||||
title="🎬 片头片尾设置"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="intro-outro-panel-drawer"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览
|
||||
* 手机模型预览 + 封面预览 并排
|
||||
* 封面为只读展示(从模板/计划继承)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types"
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
@@ -19,6 +21,7 @@ interface PreviewPlayerProps {
|
||||
selectedClipId: string | null
|
||||
isPlaying: boolean
|
||||
titleConfig?: TitleConfig
|
||||
coverConfig?: CoverConfig
|
||||
subtitleSettings?: SubtitleSettings
|
||||
onClipSelect: (clipId: string) => void
|
||||
onPlayPause: () => void
|
||||
@@ -34,11 +37,18 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
isPlaying,
|
||||
titleConfig,
|
||||
coverConfig,
|
||||
subtitleSettings,
|
||||
onPlayPause,
|
||||
}) => {
|
||||
@@ -122,6 +132,26 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面预览(只读) */}
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
|
||||
<img
|
||||
src={coverConfig.thumbnail_url || coverConfig.upload_url}
|
||||
alt="封面预览"
|
||||
className="ep-cover-img"
|
||||
/>
|
||||
) : displayClip ? (
|
||||
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ const SpeedPanel: React.FC<SpeedPanelProps> = ({ open, onClose, config, onChange
|
||||
title="⚡ 片段调速"
|
||||
placement="right"
|
||||
width={380}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="speed-panel-drawer"
|
||||
|
||||
@@ -34,7 +34,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
title="💬 字幕样式配置"
|
||||
placement="right"
|
||||
width={380}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
|
||||
@@ -53,7 +53,6 @@ const TransitionSelector: React.FC<TransitionSelectorProps> = ({
|
||||
title={`🎬 ${title}`}
|
||||
placement="right"
|
||||
width={480}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="transition-selector-drawer"
|
||||
|
||||
@@ -60,6 +60,7 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* ── 组件卸载时清理音频资源 ── */
|
||||
|
||||
@@ -42,7 +42,6 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
|
||||
title="🔖 水印设置"
|
||||
placement="right"
|
||||
width={400}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="watermark-panel-drawer"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface CoverAutoModeProps {
|
||||
config: CoverConfig
|
||||
formatTime: (s: number) => string
|
||||
onUseAiSuggestion: () => void
|
||||
}
|
||||
|
||||
/** 智能封面模式面板 */
|
||||
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
|
||||
config,
|
||||
formatTime,
|
||||
onUseAiSuggestion,
|
||||
}) => (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">AI 将分析视频内容,自动选择最具吸引力的画面作为封面。</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">推荐时间点:{formatTime(config.ai_suggested_time)}</div>
|
||||
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverFrameModeProps {
|
||||
config: CoverConfig
|
||||
totalDuration: number
|
||||
formatTime: (s: number) => string
|
||||
onFrameTimeChange: (time: number) => void
|
||||
}
|
||||
|
||||
/** 抽帧选封面模式面板 */
|
||||
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
|
||||
config,
|
||||
totalDuration,
|
||||
formatTime,
|
||||
onFrameTimeChange,
|
||||
}) => (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverUploadModeProps {
|
||||
config: CoverConfig
|
||||
isDragging: boolean
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onAreaClick: () => void
|
||||
onFileChange: (file: File) => void
|
||||
}
|
||||
|
||||
/** 上传封面模式面板 */
|
||||
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
|
||||
config,
|
||||
isDragging,
|
||||
fileInputRef,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onAreaClick,
|
||||
onFileChange,
|
||||
}) => (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onAreaClick}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileChange(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
|
||||
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
} = useCoverSelector({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{config.mode === "auto" && (
|
||||
<CoverAutoMode
|
||||
config={config}
|
||||
formatTime={formatTime}
|
||||
onUseAiSuggestion={handleUseAiSuggestion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "frame" && (
|
||||
<CoverFrameMode
|
||||
config={config}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={(t) => update({ frame_time: t })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "upload" && (
|
||||
<CoverUploadMode
|
||||
config={config}
|
||||
isDragging={isDragging}
|
||||
fileInputRef={fileInputRef}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onAreaClick={() => fileInputRef.current?.click()}
|
||||
onFileChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../../types"
|
||||
|
||||
/** 封面模式标签 */
|
||||
export const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
export const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
interface UseCoverSelectorOptions {
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 封面选择器 Hook
|
||||
* 封装状态管理、文件上传、模式切换等逻辑
|
||||
*/
|
||||
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
const formatTime = (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}`
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import type { TransitionEffect } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface UsePlanLoadingOptions {
|
||||
loadedPlanId: string | null
|
||||
@@ -15,6 +16,7 @@ interface UsePlanLoadingOptions {
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,7 @@ export function usePlanLoading({
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
}: UsePlanLoadingOptions) {
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return
|
||||
@@ -74,6 +77,17 @@ export function usePlanLoading({
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}))
|
||||
}
|
||||
if (cfg.cover_config) {
|
||||
setCoverConfig((prev: CoverConfig) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}))
|
||||
}
|
||||
|
||||
/* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */
|
||||
const backendClips = clipsRes?.items || []
|
||||
@@ -138,5 +152,6 @@ export function usePlanLoading({
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "../../types"
|
||||
import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
interface UseTemplateSaveOptions {
|
||||
@@ -32,6 +33,7 @@ interface UseTemplateSaveOptions {
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
loadedTemplateId: string | null
|
||||
loadTemplates: () => Promise<void>
|
||||
}
|
||||
@@ -53,6 +55,7 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
} = options
|
||||
@@ -129,6 +132,7 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
@@ -159,6 +163,7 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
])
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import {
|
||||
DEFAULT_WATERMARK,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
|
||||
@@ -45,6 +47,8 @@ export interface GlobalSettings {
|
||||
setChromaKeySettings: (config: ChromaKeyConfig) => void
|
||||
stickerSettings: StickerConfig
|
||||
setStickerSettings: (config: StickerConfig) => void
|
||||
coverConfig: CoverConfig
|
||||
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
}
|
||||
|
||||
export const useGlobalSettings = (): GlobalSettings => {
|
||||
@@ -88,6 +92,10 @@ export const useGlobalSettings = (): GlobalSettings => {
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
})
|
||||
|
||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
})
|
||||
|
||||
return {
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
@@ -107,5 +115,7 @@ export const useGlobalSettings = (): GlobalSettings => {
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
@@ -28,6 +29,7 @@ interface UseTemplateManagementParams {
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
titleConfig: TitleConfig
|
||||
@@ -39,6 +41,7 @@ interface UseTemplateManagementParams {
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
@@ -67,6 +71,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
} = params
|
||||
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
|
||||
@@ -114,6 +119,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
})
|
||||
@@ -140,6 +146,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
})
|
||||
|
||||
/* ── 事件 ── */
|
||||
|
||||
@@ -67,4 +67,5 @@ export interface ClipPropertiesPanelProps {
|
||||
onOpenGreenScreenDrawer?: () => void
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
}
|
||||
|
||||
+1
-19
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* 智能剪辑封面类型定义
|
||||
* 独立于 editing-planner,仅供 generate 模块使用
|
||||
* 封面配置类型
|
||||
*/
|
||||
|
||||
/** 封面来源模式 */
|
||||
@@ -31,20 +30,3 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
/** 封面模板 */
|
||||
export interface CoverTemplate {
|
||||
id: string
|
||||
name: string
|
||||
thumbnail_url: string
|
||||
is_system: boolean
|
||||
created_at: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,9 @@ export {
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "./sticker"
|
||||
|
||||
/* 封面 */
|
||||
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG } from "./cover"
|
||||
|
||||
/* 片段数据 */
|
||||
export { type ClipType, type ClipData } from "./clip"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
|
||||
* 智能剪辑页面 — V21 原型 1:1 还原
|
||||
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
@@ -9,7 +9,7 @@
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*/
|
||||
import React, { useState, useMemo } from "react"
|
||||
import React from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
@@ -24,7 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useStep4Preview } from "./hooks/useStep4Preview"
|
||||
import { useStep3Preview } from "./hooks/useStep3Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -80,24 +80,8 @@ const GeneratePage: React.FC = () => {
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 预览数量(多预览) ── */
|
||||
const [previewCount, setPreviewCount] = useState(1)
|
||||
|
||||
/* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */
|
||||
/* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID),
|
||||
见 useGenerateFormState 返回值类型定义 */
|
||||
const previewVoiceIds = useMemo((): string[] => {
|
||||
if (voiceMode === "clone") {
|
||||
const id: string = selectedClonedVoice
|
||||
return id ? [id] : []
|
||||
}
|
||||
// preset / custom 模式
|
||||
const id: string = selectedVoice
|
||||
return id ? [id] : []
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice])
|
||||
|
||||
/* ── Step4 预览生成(多预览 + voice_ids) ── */
|
||||
const step4Preview = useStep4Preview({
|
||||
/* ── Step3 预览生成 ── */
|
||||
const step3Preview = useStep3Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
@@ -105,9 +89,6 @@ const GeneratePage: React.FC = () => {
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
})
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -119,7 +100,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step4Preview.canProceed,
|
||||
previewReady: step3Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -150,7 +131,6 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: step4Preview.selectedTaskId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
@@ -207,20 +187,14 @@ const GeneratePage: React.FC = () => {
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/* Step4 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step4Preview.items}
|
||||
previewSelectedIndex={step4Preview.selectedIndex}
|
||||
onSelectPreview={step4Preview.setSelectedIndex}
|
||||
previewOverallStatus={step4Preview.previewStatus}
|
||||
previewOverallError={step4Preview.previewError}
|
||||
previewOverallProgress={step4Preview.progress}
|
||||
previewAnyGenerating={step4Preview.anyGenerating}
|
||||
previewTemplateName={step4Preview.templateName}
|
||||
previewMaterialCount={step4Preview.materialCount}
|
||||
onGeneratePreview={step4Preview.generatePreview}
|
||||
onRegeneratePreview={step4Preview.regeneratePreview}
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
previewTemplateName={step3Preview.templateName}
|
||||
previewMaterialCount={step3Preview.materialCount}
|
||||
previewProgress={step3Preview.progress}
|
||||
onGeneratePreview={step3Preview.generatePreview}
|
||||
onRegeneratePreview={step3Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -236,37 +210,35 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step4+ 常驻,展示选中的预览) */}
|
||||
{currentStep >= 4 && (
|
||||
{/* 预览视频面板(Step3+ 常驻) */}
|
||||
{currentStep >= 3 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step4Preview.previewStatus}
|
||||
previewResult={step4Preview.previewResult}
|
||||
previewError={step4Preview.previewError}
|
||||
progress={step4Preview.progress}
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
progress={step3Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step4Preview.regeneratePreview}
|
||||
onRegenerate={step3Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={currentStep >= 5 ? titleSettings : undefined}
|
||||
titleSettings={titleSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果(Step5+ 才显示) */}
|
||||
{currentStep >= 5 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
)}
|
||||
{/* 正式生成结果 */}
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step4GeneratePreview from "../components/Step4GeneratePreview"
|
||||
import Step5TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step3GeneratePreview from "../components/Step3GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
@@ -64,18 +63,13 @@ export interface GenerateStepContentProps {
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
videoRatio: string
|
||||
/* Step4 预览(多预览) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
previewItems: PreviewItem[]
|
||||
previewSelectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
previewOverallStatus: PreviewStatus
|
||||
previewOverallError: string
|
||||
previewOverallProgress: number
|
||||
previewAnyGenerating: boolean
|
||||
/* Step3 预览 */
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
previewTemplateName: string
|
||||
previewMaterialCount: string
|
||||
previewProgress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
@@ -113,17 +107,12 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
previewItems,
|
||||
previewSelectedIndex,
|
||||
onSelectPreview,
|
||||
previewOverallStatus,
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
previewAnyGenerating,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
previewTemplateName,
|
||||
previewMaterialCount,
|
||||
previewProgress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
@@ -150,46 +139,39 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4GeneratePreview
|
||||
<Step3GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
items={previewItems}
|
||||
selectedIndex={previewSelectedIndex}
|
||||
onSelectPreview={onSelectPreview}
|
||||
overallStatus={previewOverallStatus}
|
||||
overallError={previewOverallError}
|
||||
overallProgress={previewOverallProgress}
|
||||
anyGenerating={previewAnyGenerating}
|
||||
previewStatus={previewStatus}
|
||||
previewResult={previewResult}
|
||||
previewError={previewError}
|
||||
progress={previewProgress}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
case 4:
|
||||
return (
|
||||
<Step5TitleSettings
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={onCoverSettingsChange}
|
||||
duration={duration}
|
||||
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step4 生成预览后常驻显示预览视频
|
||||
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
* Step3 生成预览后常驻显示预览视频
|
||||
* Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
*
|
||||
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
|
||||
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
@@ -23,9 +23,9 @@ interface PreviewVideoPanelProps {
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 标题文字(Step5 起传入) */
|
||||
/** 标题文字(Step4 起传入) */
|
||||
titleText?: string
|
||||
/** 标题样式设置(Step5 起传入) */
|
||||
/** 标题样式设置(Step4 起传入) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
@@ -169,7 +169,6 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
@@ -311,7 +310,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-video">
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
@@ -328,7 +327,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<div className="xx-preview-video xx-preview-video--error">
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">
|
||||
@@ -343,7 +342,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 预览成功 + Canvas 标题叠加 */}
|
||||
{hasPreview && (
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
* 调用后端预览生成接口,展示真实视频预览
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
|
||||
interface Step3GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
templateName,
|
||||
materialCount,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 预览生成按钮 */}
|
||||
{previewStatus === "idle" && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板和素材,智能生成完整视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排队中 */}
|
||||
{previewStatus === "pending" && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{previewStatus === "generating" && (
|
||||
<div className="xx-preview-loading">
|
||||
<LoadingOutlined style={{ fontSize: 32, color: "#3b82f6" }} spin />
|
||||
<p className="xx-preview-loading-text">正在生成预览视频... {progress}%</p>
|
||||
<p className="xx-preview-loading-desc">AI 正在剪辑素材并合成预览视频</p>
|
||||
<div className="xx-preview-progress-bar">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{previewStatus === "error" && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof previewError === "string" && previewError ? previewError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成成功 - 视频预览 */}
|
||||
{previewStatus === "ready" && previewResult && (
|
||||
<>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>预览生成成功,确认效果后进入下一步</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<div className="xx-preview-video-wrapper">
|
||||
<video
|
||||
className="xx-preview-video"
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</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">{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">转场次数</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.transitionCount} 次</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材使用率</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.materialUsage}%</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">实际时长</span>
|
||||
<span className="xx-preview-plan-value">
|
||||
{(typeof previewResult.duration === "number"
|
||||
? previewResult.duration
|
||||
: 0
|
||||
).toFixed(1)}{" "}
|
||||
秒
|
||||
</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">💡 这是 480p 预览版,正式生成将输出高清视频</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3GeneratePreview
|
||||
@@ -1,294 +0,0 @@
|
||||
/**
|
||||
* Step 4 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
|
||||
interface Step4GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
items: PreviewItem[]
|
||||
selectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
overallStatus: PreviewStatus
|
||||
overallError: string
|
||||
overallProgress: number
|
||||
anyGenerating: boolean
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
/** 预览数量选项 */
|
||||
const PREVIEW_COUNT_OPTIONS = [
|
||||
{ value: 1, label: "1个" },
|
||||
{ value: 2, label: "2个" },
|
||||
{ value: 3, label: "3个" },
|
||||
]
|
||||
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelectPreview,
|
||||
overallStatus,
|
||||
overallError,
|
||||
overallProgress,
|
||||
anyGenerating,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 预览数量选择器(仅在 idle 状态显示) */}
|
||||
{isIdle && (
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: 14, color: "#666" }}>预览数量:</span>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{PREVIEW_COUNT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(opt.value)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
|
||||
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
|
||||
color: previewCount === opt.value ? "#1677ff" : "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: previewCount === opt.value ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={previewCount}
|
||||
onChange={(val) => val && onPreviewCountChange(val)}
|
||||
style={{ width: 70 }}
|
||||
placeholder="自定义"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#999", marginLeft: 4 }}>(1~10)</span>
|
||||
</div>
|
||||
{previewCount > 1 && (
|
||||
<span style={{ fontSize: 12, color: "#999" }}>生成多个预览可对比不同剪辑效果</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览生成按钮(idle 状态) */}
|
||||
{isIdle && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板、素材和配音,智能生成
|
||||
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览{previewCount > 1 ? `(${previewCount}个)` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体排队中(所有都在 pending) */}
|
||||
{anyGenerating && items.every((it) => it.status === "pending") && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
className="xx-preview-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||||
margin: "0 auto 16px",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.index === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
onClick={() => {
|
||||
if (item.status === "ready") onSelectPreview(item.index)
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
overflow: "hidden",
|
||||
cursor: item.status === "ready" ? "pointer" : "default",
|
||||
opacity: item.status === "error" ? 0.6 : 1,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 缩略图/状态区域 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
background: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{item.status === "ready" && item.result && (
|
||||
<video
|
||||
src={item.result.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(item.status === "pending" || item.status === "generating") && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
|
||||
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
生成失败
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
fontSize: 10,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息 */}
|
||||
{item.status === "ready" && item.result && (
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
background: "#fafafa",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{item.result.duration.toFixed(1)}秒</span>
|
||||
<span>{item.result.clipCount}段</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体进度条(多预览生成中) */}
|
||||
{anyGenerating && (
|
||||
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部完成提示 */}
|
||||
{overallStatus === "ready" && (
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>
|
||||
{items.filter((it) => it.status === "ready").length} 个预览生成成功
|
||||
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
|
||||
</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4GeneratePreview
|
||||
@@ -3,7 +3,6 @@
|
||||
* 展示用户已上传的配音素材,支持选中、预览播放
|
||||
*/
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
@@ -35,7 +34,6 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
@@ -88,8 +86,8 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
|
||||
/** 跳转到配音库上传 */
|
||||
const handleGoToUpload = useCallback(() => {
|
||||
navigate("/app/voices")
|
||||
}, [navigate])
|
||||
window.location.href = "/voices"
|
||||
}, [])
|
||||
|
||||
// 加载中状态
|
||||
if (isLoading) {
|
||||
|
||||
@@ -1,110 +1,100 @@
|
||||
import React from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
|
||||
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
|
||||
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
duration: props.duration,
|
||||
assetIds: props.assetIds,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
})
|
||||
|
||||
const handleAutoGenerate = () => {
|
||||
generateAutoCover()
|
||||
}
|
||||
|
||||
// 预览图:优先 thumbnail_url,其次 upload_url
|
||||
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
|
||||
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-actions">
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate}>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
⚙️ 封面设置
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="封面预览" className="xx-cover-preview-img" />
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>点击"自动生成封面"或选择模板</span>
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<CoverModeSelector
|
||||
mode={coverSettings.mode}
|
||||
onModeChange={setMode}
|
||||
modeLabels={COVER_MODE_LABELS}
|
||||
modeIcons={COVER_MODE_ICONS}
|
||||
/>
|
||||
|
||||
{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" && (
|
||||
<FrameCoverPicker
|
||||
frameTime={coverSettings.frame_time}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={setFrameTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{coverSettings.mode === "upload" && (
|
||||
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} />
|
||||
)}
|
||||
|
||||
<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 className="xx-cover-preview-ratio">9:16</div>
|
||||
</div>
|
||||
|
||||
<CoverSettingsModal
|
||||
open={showCoverSettings}
|
||||
onClose={() => setShowCoverSettings(false)}
|
||||
templates={coverTemplates}
|
||||
loading={templatesLoading}
|
||||
error={templatesError}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onEditTemplate={handleEditTemplate}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onCreateNew={() => {
|
||||
setShowCoverSettings(false)
|
||||
setShowCoverEditor(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<CoverEditorModal
|
||||
open={showCoverEditor}
|
||||
onClose={() => setShowCoverEditor(false)}
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* AI 生成封面进度弹窗 */}
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI 正在生成封面,请稍候...</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
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"
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState } from "react"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
interface CoverEditorModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
template: CoverTemplate | null
|
||||
onSave: (template: CoverTemplate) => void
|
||||
}
|
||||
|
||||
interface SectionState {
|
||||
basic: boolean
|
||||
portrait: boolean
|
||||
background: boolean
|
||||
title: boolean
|
||||
subtitle: boolean
|
||||
mask: boolean
|
||||
}
|
||||
|
||||
const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, template, onSave }) => {
|
||||
const [name, setName] = useState(template?.name || "")
|
||||
const [sections, setSections] = useState<SectionState>({
|
||||
basic: true,
|
||||
portrait: false,
|
||||
background: false,
|
||||
title: true,
|
||||
subtitle: true,
|
||||
mask: false,
|
||||
})
|
||||
|
||||
const toggleSection = (key: keyof SectionState) => {
|
||||
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!template) return
|
||||
onSave({ ...template, name })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
title="自定义封面编辑器"
|
||||
centered
|
||||
footer={null}
|
||||
>
|
||||
<div className="xx-cover-editor-header">
|
||||
<input
|
||||
className="xx-cover-editor-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入模板名称"
|
||||
/>
|
||||
<div className="xx-cover-editor-header-actions">
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={handleSave}>
|
||||
保存模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-cover-editor-layout">
|
||||
{/* 左侧折叠面板 */}
|
||||
<div className="xx-cover-editor-left">
|
||||
{[
|
||||
{ key: "basic" as const, label: "基础设置" },
|
||||
{ key: "portrait" as const, label: "人像设置" },
|
||||
{ key: "background" as const, label: "背景设置", toggle: true },
|
||||
{ key: "title" as const, label: "主标题" },
|
||||
{ key: "subtitle" as const, label: "副标题" },
|
||||
{ key: "mask" as const, label: "蒙版", toggle: true },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="xx-cover-editor-section">
|
||||
<div
|
||||
className="xx-cover-editor-section-header"
|
||||
onClick={() => toggleSection(item.key)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{sections[item.key] ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections[item.key] && (
|
||||
<div className="xx-cover-editor-section-body">
|
||||
{item.toggle ? (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<input type="checkbox" defaultChecked={false} />
|
||||
已开启
|
||||
</label>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-tertiary)" }}>暂无配置项</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右侧画布预览 */}
|
||||
<div className="xx-cover-editor-right">
|
||||
<div className="xx-cover-editor-canvas">
|
||||
{/* 人像占位 */}
|
||||
<div className="xx-cover-editor-portrait">
|
||||
{/* 四角拖拽手柄 */}
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, right: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, right: -4 }} />
|
||||
</div>
|
||||
{/* 文字占位 */}
|
||||
<div className="xx-cover-editor-title-placeholder">主标题文字</div>
|
||||
<div className="xx-cover-editor-subtitle-placeholder">副标题文字</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverEditorModal
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import type { CoverMode } from "../../types/cover"
|
||||
import type { CoverMode } from "../../../editing-planner/types"
|
||||
|
||||
interface CoverModeSelectorProps {
|
||||
mode: CoverMode
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import React from "react"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
interface CoverSettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
templates: CoverTemplate[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
selectedTemplateId: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
onEditTemplate: (template: CoverTemplate) => void
|
||||
onDeleteTemplate: (id: string) => void
|
||||
onCreateNew: () => void
|
||||
}
|
||||
|
||||
const GRADIENT_MAP: Record<string, string> = {
|
||||
default: "linear-gradient(135deg, #e0e0e0, #c0c0c0)",
|
||||
"bold-red": "linear-gradient(135deg, #ef4444, #b91c1c)",
|
||||
"elegant-black": "linear-gradient(135deg, #374151, #111827)",
|
||||
"gradient-blue": "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
||||
"gradient-purple": "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
"warm-orange": "linear-gradient(135deg, #f97316, #ea580c)",
|
||||
"fresh-green": "linear-gradient(135deg, #22c55e, #15803d)",
|
||||
"tech-blue": "linear-gradient(135deg, #06b6d4, #0e7490)",
|
||||
}
|
||||
|
||||
const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
templates,
|
||||
loading = false,
|
||||
error = null,
|
||||
selectedTemplateId,
|
||||
onSelectTemplate,
|
||||
onEditTemplate,
|
||||
onDeleteTemplate,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
|
||||
<div className="xx-cover-modal-toolbar">
|
||||
<Button buttonType="primary">选择素材文件</Button>
|
||||
<Button buttonType="ghost">导出全部</Button>
|
||||
<Button buttonType="primary" onClick={onCreateNew}>
|
||||
创建新模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "var(--text-secondary)" }}>
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="xx-cover-template-grid">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
>
|
||||
<div
|
||||
className="xx-cover-template-thumb"
|
||||
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
|
||||
>
|
||||
🖼️
|
||||
</div>
|
||||
<div className="xx-cover-template-info">
|
||||
<div className="xx-cover-template-name">
|
||||
{tpl.name}
|
||||
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
|
||||
</div>
|
||||
<div className="xx-cover-template-date">{tpl.created_at}</div>
|
||||
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
|
||||
编辑
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm">
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSettingsModal
|
||||
@@ -2,7 +2,7 @@
|
||||
* 智能剪辑页面 — 常量定义
|
||||
*/
|
||||
|
||||
import type { CoverConfig } from "./types/cover"
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
|
||||
/* ── 克隆声音状态配置 ── */
|
||||
export const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
@@ -31,9 +31,9 @@ export const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
export const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "生成预览" },
|
||||
{ key: 5, label: "选择标题" },
|
||||
{ key: 3, label: "生成预览" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "选择配音" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
@@ -891,7 +891,7 @@
|
||||
|
||||
/* ── 视频预览 ── */
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
@@ -2356,8 +2356,6 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-generate-hint {
|
||||
@@ -2390,8 +2388,6 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-loading-text {
|
||||
@@ -2407,15 +2403,13 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 错误状态(手机屏尺寸)── */
|
||||
/* ── 错误状态 ── */
|
||||
.xx-preview-error {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-error-text {
|
||||
@@ -2462,9 +2456,6 @@
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
max-width: 320px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.xx-preview-plan-card {
|
||||
@@ -2579,16 +2570,6 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── 整体进度条(手机屏尺寸)── */
|
||||
.xx-preview-progress-bar {
|
||||
max-width: 320px;
|
||||
margin: 12px auto;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-progress-bar-wrap {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
@@ -2666,7 +2647,7 @@
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2888,196 +2869,3 @@
|
||||
.ant-modal-close {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* ── 封面设置区域改造样式 ── */
|
||||
|
||||
/* 封面操作按钮区 */
|
||||
.xx-cover-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 已选模板文字 */
|
||||
.xx-cover-selected-template {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 封面设置弹窗 - 工具栏 */
|
||||
.xx-cover-modal-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 封面模板网格 */
|
||||
.xx-cover-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 封面模板卡片 */
|
||||
.xx-cover-template-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.xx-cover-template-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.xx-cover-template-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
/* 卡片缩略图 */
|
||||
.xx-cover-template-thumb {
|
||||
aspect-ratio: 9/16;
|
||||
background: linear-gradient(135deg, #f0f0f0, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* 卡片信息区 */
|
||||
.xx-cover-template-info {
|
||||
padding: 8px;
|
||||
}
|
||||
.xx-cover-template-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.xx-cover-template-badge {
|
||||
font-size: 11px;
|
||||
color: #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xx-cover-template-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.xx-cover-template-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 封面编辑器 */
|
||||
.xx-cover-editor-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 500px;
|
||||
}
|
||||
.xx-cover-editor-left {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.xx-cover-editor-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.xx-cover-editor-canvas {
|
||||
width: 225px;
|
||||
height: 400px;
|
||||
background: #ddd;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.xx-cover-editor-portrait {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 45%;
|
||||
background: #a8d4f0;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
.xx-cover-editor-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #333;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
/* 编辑器折叠面板 */
|
||||
.xx-cover-editor-section {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xx-cover-editor-section-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.xx-cover-editor-section-body {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 编辑器顶部 */
|
||||
.xx-cover-editor-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.xx-cover-editor-name-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.xx-cover-editor-header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 编辑器画布内文字占位 */
|
||||
.xx-cover-editor-title-placeholder {
|
||||
position: absolute;
|
||||
top: 72%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xx-cover-editor-subtitle-placeholder {
|
||||
position: absolute;
|
||||
top: 82%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,74 @@
|
||||
import type { UseGenerateVideoProps } from "./types"
|
||||
import { buildVoiceConfig } from "./voiceConfig"
|
||||
|
||||
/**
|
||||
* 构建 updateEditPlan 的 payload
|
||||
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
|
||||
*/
|
||||
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
|
||||
return {
|
||||
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" as const,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前置校验
|
||||
* 返回错误信息,通过则返回 null
|
||||
*/
|
||||
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
|
||||
const {
|
||||
titleSettings,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
} = props
|
||||
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
|
||||
|
||||
// AI 自动选择模式下,标题可以为空(后端会自行生成)
|
||||
if (!titleSettings.aiAutoSelect && !titleSettings.title?.trim()) {
|
||||
return "请先选择或输入标题"
|
||||
}
|
||||
// 无论手动还是自动模式,都必须有素材
|
||||
const materialIds = materialMode === "auto" ? smartSelectedIds || [] : selectedMaterials || []
|
||||
if (materialIds.length === 0) {
|
||||
return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材"
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
return "请至少选择一个素材"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
|
||||
Executable → Regular
+1
-3
@@ -1,5 +1,5 @@
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
/** useGenerateVideo 入参 */
|
||||
@@ -19,8 +19,6 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId: string
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseTitleCoverSyncOptions {
|
||||
|
||||
Executable → Regular
+10
-14
@@ -5,11 +5,11 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { confirmGeneration } from "@/api/generation"
|
||||
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
@@ -55,19 +55,15 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
})
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, payload)
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
startPolling()
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
@@ -78,7 +74,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, clearTimer, startPolling])
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Step 2 素材选择 Hook
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
@@ -34,25 +34,6 @@ export function useStep2Materials({
|
||||
onSmartSelectedIdsChange,
|
||||
})
|
||||
|
||||
/* ── 自动触发智能匹配:选择视频库后自动调用 ── */
|
||||
const autoTriggeredRef = useRef<string>("")
|
||||
const { handleSmartMatch } = smartMatch
|
||||
useEffect(() => {
|
||||
// 离开 auto 模式时重置,确保下次进入 auto 模式能重新触发
|
||||
if (materialMode !== "auto") {
|
||||
autoTriggeredRef.current = ""
|
||||
return
|
||||
}
|
||||
if (!selectedLibraryId) return
|
||||
if (materialsLoading) return
|
||||
if (materials.items.length === 0) return
|
||||
// 防止同一视频库重复触发
|
||||
if (autoTriggeredRef.current === selectedLibraryId) return
|
||||
|
||||
autoTriggeredRef.current = selectedLibraryId
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 调用 /generation/preview 接口创建预览任务,轮询状态直到完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
titleText?: string
|
||||
voiceId?: string
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
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])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 预览生成状态 ── */
|
||||
const [previewStatus, setPreviewStatus] = useState<PreviewStatus>("idle")
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [previewError, setPreviewError] = useState<string>("")
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
// 任务 ID + 轮询定时器,用于防止竞态条件
|
||||
const currentTaskIdRef = useRef<string | null>(null)
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 参数变化时重置预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && previewStatus !== "idle") {
|
||||
// 参数变化,作废当前任务
|
||||
currentTaskIdRef.current = null
|
||||
clearPollTimer()
|
||||
setPreviewStatus("idle")
|
||||
setPreviewResult(null)
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
clearPollTimer,
|
||||
])
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 检查是否已被取消(参数变化或重新生成)
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setPreviewError("预览生成超时,请重试")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
setProgress(100)
|
||||
setPreviewResult({
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
})
|
||||
setPreviewStatus("ready")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setPreviewError(safeString(data.error_message, "预览生成失败,请重试"))
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setPreviewError("预览任务已取消")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
setProgress(safeNumber(data.progress))
|
||||
if (status === "pending") {
|
||||
setPreviewStatus("pending")
|
||||
pollTimerRef.current = setTimeout(poll, 5000)
|
||||
} else {
|
||||
setPreviewStatus("generating")
|
||||
pollTimerRef.current = setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (pollErr) {
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
// 轮询出错,延迟后重试
|
||||
pollTimerRef.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 首次延迟 1 秒开始轮询
|
||||
pollTimerRef.current = setTimeout(poll, 1000)
|
||||
}, [])
|
||||
|
||||
/** 生成预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setPreviewError("请先选择模板")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setPreviewError("请先选择素材")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的轮询
|
||||
clearPollTimer()
|
||||
|
||||
setPreviewStatus("pending")
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
setPreviewResult(null)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
try {
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
})
|
||||
|
||||
// 竞态检查
|
||||
if (startTimeRef.current === 0) return // 已被重置
|
||||
|
||||
currentTaskIdRef.current = response.task_id
|
||||
pollPreviewStatus(response.task_id)
|
||||
} catch (e) {
|
||||
setPreviewError(safeString(e instanceof Error ? e.message : e, "预览生成失败"))
|
||||
setPreviewStatus("error")
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否可以下一步(预览已生成) */
|
||||
const canProceed = previewStatus === "ready"
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 预览生成状态
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep3Preview
|
||||
@@ -1,453 +0,0 @@
|
||||
/**
|
||||
* Step 4 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import { updateEditPlan } from "@/api/template-editor/editPlans"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep4PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 单个预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
/** 单个预览项的完整状态(用于多预览) */
|
||||
export interface PreviewItem {
|
||||
index: number
|
||||
status: PreviewStatus
|
||||
result: PreviewResult | null
|
||||
error: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/** 初始单项状态 */
|
||||
const createInitialItem = (index: number): PreviewItem => ({
|
||||
index,
|
||||
status: "idle",
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
export function useStep4Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
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])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 多预览状态 ── */
|
||||
const [items, setItems] = useState<PreviewItem[]>(() =>
|
||||
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
|
||||
const taskIdsRef = useRef<Map<number, string>>(new Map())
|
||||
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback((index?: number) => {
|
||||
if (index !== undefined) {
|
||||
const timer = pollTimersRef.current.get(index)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pollTimersRef.current.delete(index)
|
||||
}
|
||||
} else {
|
||||
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
|
||||
pollTimersRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 同步 previewCount 变化(增减项)
|
||||
useEffect(() => {
|
||||
setItems((prev) => {
|
||||
if (prev.length === previewCount) return prev
|
||||
if (prev.length > previewCount) return prev.slice(0, previewCount)
|
||||
return [
|
||||
...prev,
|
||||
...Array.from({ length: previewCount - prev.length }, (_, i) =>
|
||||
createInitialItem(prev.length + i),
|
||||
),
|
||||
]
|
||||
})
|
||||
// 如果 selectedIndex 超出范围,重置
|
||||
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
|
||||
}, [previewCount])
|
||||
|
||||
/* ── 参数变化时重置所有预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||||
taskIdsRef.current.clear()
|
||||
clearPollTimer()
|
||||
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询单个预览任务状态 */
|
||||
const pollPreviewStatus = useCallback(
|
||||
(index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
}
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
),
|
||||
)
|
||||
|
||||
// 保存预览视频 URL 到 plan config,供封面生成使用
|
||||
if (result.videoUrl && selectedTemplate) {
|
||||
updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: result.videoUrl },
|
||||
}).catch((err) => {
|
||||
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
},
|
||||
[selectedTemplate],
|
||||
)
|
||||
|
||||
/** 生成所有预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的所有轮询
|
||||
clearPollTimer()
|
||||
taskIdsRef.current.clear()
|
||||
|
||||
// 初始化所有项为 pending
|
||||
setItems(
|
||||
Array.from({ length: previewCount }, (_, i) => ({
|
||||
index: i,
|
||||
status: "pending" as PreviewStatus,
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})),
|
||||
)
|
||||
setSelectedIndex(0)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
|
||||
// 并发创建所有预览任务(Promise.all 并行请求,减少串行等待)
|
||||
const createTasks = Array.from({ length: previewCount }, async (_, i) => {
|
||||
try {
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
voice_library_id: voiceLibraryId || undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
|
||||
taskIdsRef.current.set(i, response.task_id)
|
||||
pollPreviewStatus(i, response.task_id)
|
||||
} catch (e) {
|
||||
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(createTasks)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成所有预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否所有预览都已完成 */
|
||||
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
|
||||
/** 是否至少有一个预览已完成 */
|
||||
const anyReady = items.some((it) => it.status === "ready")
|
||||
/** 是否有任一正在生成中 */
|
||||
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
|
||||
|
||||
/** 当前选中的预览结果 */
|
||||
const selectedResult = items[selectedIndex]?.result ?? null
|
||||
|
||||
/** 综合状态(兼容旧逻辑) */
|
||||
const previewStatus: PreviewStatus = useMemo(() => {
|
||||
if (items.every((it) => it.status === "idle")) return "idle"
|
||||
if (items.some((it) => it.status === "pending" || it.status === "generating"))
|
||||
return "generating"
|
||||
if (allReady) return "ready"
|
||||
if (items.every((it) => it.status === "error")) return "error"
|
||||
// 部分完成部分出错
|
||||
if (anyReady) return "ready"
|
||||
return "error"
|
||||
}, [items, allReady, anyReady])
|
||||
|
||||
/** 综合进度(取平均) */
|
||||
const progress = useMemo(() => {
|
||||
if (items.length === 0) return 0
|
||||
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
|
||||
}, [items])
|
||||
|
||||
/** 综合错误信息 */
|
||||
const previewError = useMemo(() => {
|
||||
const errorItems = items.filter((it) => it.status === "error" && it.error)
|
||||
if (errorItems.length === 0) return ""
|
||||
if (errorItems.length === 1) return errorItems[0].error
|
||||
return `${errorItems.length} 个预览生成失败`
|
||||
}, [items])
|
||||
|
||||
const canProceed = anyReady
|
||||
|
||||
/** 当前选中预览的 taskId(用于确认生成时复用预览产物) */
|
||||
const selectedTaskId = selectedResult?.taskId ?? ""
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 多预览状态
|
||||
items,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
previewCount,
|
||||
// 综合状态
|
||||
previewStatus,
|
||||
previewResult: selectedResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
allReady,
|
||||
anyReady,
|
||||
anyGenerating,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
// 确认生成复用预览产物
|
||||
selectedTaskId,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Preview
|
||||
@@ -1,217 +1,80 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
* 封装封面设置的交互逻辑
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
updateCoverTemplate,
|
||||
deleteCoverTemplate,
|
||||
} from "@/api/cover-templates"
|
||||
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
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
}: UseStep6CoverProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
// ── 封面设置弹窗状态 ──
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
const [showCoverEditor, setShowCoverEditor] = useState(false)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState("default")
|
||||
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
|
||||
const [coverTemplates, setCoverTemplates] = useState<CoverTemplate[]>([])
|
||||
|
||||
// ── API 加载状态 ──
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setTemplatesLoading(true)
|
||||
setTemplatesError(null)
|
||||
try {
|
||||
const res = await fetchCoverTemplates()
|
||||
setCoverTemplates(res.items || [])
|
||||
} catch (err) {
|
||||
console.error("[Step6] 加载封面模板失败:", err)
|
||||
setTemplatesError("加载模板失败,请稍后重试")
|
||||
} finally {
|
||||
setTemplatesLoading(false)
|
||||
}
|
||||
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}`
|
||||
}, [])
|
||||
|
||||
/** 弹窗打开时加载模板列表 */
|
||||
useEffect(() => {
|
||||
if (showCoverSettings) {
|
||||
loadTemplates()
|
||||
}
|
||||
}, [showCoverSettings, loadTemplates])
|
||||
const toggleEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
onCoverSettingsChange({ ...coverSettings, enabled })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generating) {
|
||||
message.warning("封面正在生成中,请稍候...")
|
||||
return
|
||||
}
|
||||
const setMode = useCallback(
|
||||
(mode: CoverConfig["mode"]) => {
|
||||
onCoverSettingsChange({ ...coverSettings, mode })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
if (!selectedTemplate) {
|
||||
message.error("请先选择模板")
|
||||
return
|
||||
}
|
||||
const setFrameTime = useCallback(
|
||||
(frameTime: number) => {
|
||||
onCoverSettingsChange({ ...coverSettings, frame_time: frameTime })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
if (assetIds.length === 0) {
|
||||
message.error("请先选择素材")
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
|
||||
const timeoutId = setTimeout(() => {
|
||||
setGenerating(false)
|
||||
}, 300000)
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
const thumbnailUrl = response.cover?.image_url || ""
|
||||
if (thumbnailUrl) {
|
||||
const handleUpload = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const url = ev.target?.result as string
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
ai_suggested_time: response.cover?.frame_time ?? null,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
})
|
||||
message.success("封面生成成功")
|
||||
} else {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
console.error("[Step6] 智能封面生成失败:", err)
|
||||
|
||||
// 提取详细错误信息
|
||||
let errorMsg = "封面生成失败"
|
||||
const e = err as {
|
||||
response?: { data?: { detail?: string; message?: string }; status?: number }
|
||||
request?: unknown
|
||||
message?: string
|
||||
}
|
||||
if (e.response) {
|
||||
// 后端返回错误
|
||||
const detail = e.response.data?.detail || e.response.data?.message || ""
|
||||
errorMsg = detail || `后端错误 (${e.response.status})`
|
||||
console.error("[Step6] 后端返回:", e.response.data)
|
||||
} else if (e.request) {
|
||||
// 请求已发送但无响应
|
||||
errorMsg = "服务器无响应,请检查网络连接"
|
||||
console.error("[Step6] 请求无响应:", e.request)
|
||||
} else if (e.message) {
|
||||
errorMsg = e.message
|
||||
}
|
||||
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
setSelectedTemplateId(id)
|
||||
}, [])
|
||||
|
||||
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
|
||||
setEditingTemplate(tpl)
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
/** 保存模板(创建或更新) */
|
||||
const handleSaveTemplate = useCallback(
|
||||
async (tpl: CoverTemplate) => {
|
||||
try {
|
||||
if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) {
|
||||
const updated = await updateCoverTemplate(tpl.id, {
|
||||
name: tpl.name,
|
||||
config: tpl.config,
|
||||
})
|
||||
setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
|
||||
} else {
|
||||
const created = await createCoverTemplate({
|
||||
name: tpl.name,
|
||||
config: tpl.config,
|
||||
})
|
||||
setCoverTemplates((prev) => [...prev, created])
|
||||
}
|
||||
setShowCoverEditor(false)
|
||||
} catch (err) {
|
||||
console.error("[Step6] 保存模板失败:", err)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[coverTemplates],
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
/** 删除模板 */
|
||||
const handleDeleteTemplate = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteCoverTemplate(id)
|
||||
setCoverTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
if (selectedTemplateId === id) {
|
||||
setSelectedTemplateId("default")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Step6] 删除模板失败:", err)
|
||||
}
|
||||
},
|
||||
[selectedTemplateId],
|
||||
)
|
||||
|
||||
const selectedTemplateName =
|
||||
coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认"
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
setSelectedTemplateId,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
selectedTemplateName,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
loadTemplates,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
DEFAULT_COVER_SETTINGS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -14,7 +13,7 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** Step4 是否已生成预览 */
|
||||
/** Step3 是否已生成预览(Step3校验用) */
|
||||
previewReady: boolean
|
||||
}
|
||||
|
||||
@@ -48,12 +47,11 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
// Step3 配音:配音为可选项,不强制校验,用户可跳过
|
||||
if (currentStep === 4 && !previewReady) {
|
||||
if (currentStep === 3 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep === 5 && !titleSettings.title.trim()) {
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -991,17 +991,19 @@
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1400px) {
|
||||
.xx-products-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 992px) {
|
||||
.xx-products-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-products-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-status-tabs {
|
||||
@@ -56,10 +56,10 @@
|
||||
|
||||
/* 表格 */
|
||||
.task-table {
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-table .ant-table {
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
.task-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 任务 ID */
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
/* 操作按钮 */
|
||||
.task-retry-btn {
|
||||
color: var(--color-primary-500);
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.task-retry-btn:hover {
|
||||
@@ -139,7 +139,7 @@
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-error-header {
|
||||
@@ -148,7 +148,7 @@
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--error);
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error-icon {
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
|
||||
.task-error-message {
|
||||
color: var(--error);
|
||||
color: var(--error-500, #ef4444);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
.task-error-stack pre {
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
padding: var(--space-sm);
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
@@ -224,7 +224,7 @@
|
||||
.task-error {
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
color: var(--error);
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error .anticon {
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
@@ -717,13 +717,13 @@
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1400px) {
|
||||
@media (max-width: 1200px) {
|
||||
.xx-templates-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 768px) {
|
||||
.xx-templates-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { CopyOutlined, CheckOutlined } from "@ant-design/icons"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { AI_KEYWORD_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||
@@ -28,7 +28,7 @@ export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
|
||||
onAdopt,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
<AntModal
|
||||
title="AI 生成标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -116,6 +116,6 @@ export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { TitleType } from "../../types/titleLibrary"
|
||||
import { TITLE_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||
@@ -30,7 +30,7 @@ export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
<AntModal
|
||||
title="新建标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -84,6 +84,6 @@ export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -33,7 +32,6 @@
|
||||
gap: var(--space-sm);
|
||||
position: sticky;
|
||||
top: var(--space-md);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-title-category-item {
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: var(--z-toast);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -310,7 +310,7 @@
|
||||
.vc-edit-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg);
|
||||
|
||||
@@ -351,19 +351,18 @@
|
||||
.vmat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vmat-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr 80px 160px 120px 60px 70px 80px;
|
||||
min-width: 700px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@@ -374,7 +373,6 @@
|
||||
.vmat-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr 80px 160px 120px 60px 70px 80px;
|
||||
min-width: 700px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
@@ -1064,7 +1062,6 @@
|
||||
.vmat-list.batch-mode .vmat-list-header,
|
||||
.vmat-list.batch-mode .vmat-row {
|
||||
grid-template-columns: 30px 48px 1fr 80px 160px 120px 60px 70px 80px;
|
||||
min-width: 730px;
|
||||
}
|
||||
|
||||
/* ─── 标签筛选药丸条 ─────────────────────────────────────── */
|
||||
|
||||
@@ -117,9 +117,11 @@ const VoiceLibrary: React.FC = () => {
|
||||
uploadOpen,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
setUploadName,
|
||||
setUploadGender,
|
||||
setUploadDesc,
|
||||
setUploadOpen,
|
||||
handleFileSelect,
|
||||
@@ -250,12 +252,14 @@ const VoiceLibrary: React.FC = () => {
|
||||
uploadOpen={uploadOpen}
|
||||
uploadFile={uploadFile}
|
||||
uploadName={uploadName}
|
||||
uploadGender={uploadGender}
|
||||
uploadDesc={uploadDesc}
|
||||
uploadProgress={uploadProgress}
|
||||
onUploadClose={handleUploadClose}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleFileRemove}
|
||||
onNameChange={setUploadName}
|
||||
onGenderChange={setUploadGender}
|
||||
onDescChange={setUploadDesc}
|
||||
onUpload={handleUpload}
|
||||
ttsOpen={ttsOpen}
|
||||
|
||||
@@ -14,12 +14,14 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
open,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
onClose,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
onUpload,
|
||||
}) => {
|
||||
@@ -28,7 +30,7 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}>上传配音素材</span>}
|
||||
title="上传音频"
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (uploading) return // 上传中不可关闭
|
||||
@@ -43,7 +45,7 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "12px 0 4px",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* 拖拽上传区 */}
|
||||
@@ -62,9 +64,11 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
{/* 表单字段 */}
|
||||
<FormFields
|
||||
name={uploadName}
|
||||
gender={uploadGender}
|
||||
desc={uploadDesc}
|
||||
disabled={uploading}
|
||||
onNameChange={onNameChange}
|
||||
onGenderChange={onGenderChange}
|
||||
onDescChange={onDescChange}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* VoiceLibrary 弹窗集合
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
|
||||
import type { ClonedVoiceDisplay, PresetVoiceDisplay, VoiceGender } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { TtsStatus } from "./TtsModal"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
@@ -27,12 +27,14 @@ export interface VoiceModalsProps {
|
||||
uploadOpen: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onUploadClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
|
||||
@@ -65,12 +67,14 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
uploadOpen,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
onUploadClose,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
onUpload,
|
||||
ttsOpen,
|
||||
@@ -109,12 +113,14 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
open={uploadOpen}
|
||||
uploadFile={uploadFile}
|
||||
uploadName={uploadName}
|
||||
uploadGender={uploadGender}
|
||||
uploadDesc={uploadDesc}
|
||||
uploadProgress={uploadProgress}
|
||||
onClose={onUploadClose}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
onNameChange={onNameChange}
|
||||
onGenderChange={onGenderChange}
|
||||
onDescChange={onDescChange}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react"
|
||||
import { Button } from "antd"
|
||||
|
||||
interface ActionButtonsProps {
|
||||
uploading: boolean
|
||||
@@ -14,6 +13,12 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
onCancel,
|
||||
onUpload,
|
||||
}) => {
|
||||
const disabled = uploading || !canUpload
|
||||
|
||||
const handleUpload = () => {
|
||||
onUpload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -23,32 +28,39 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
height: 36,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploading ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onUpload}
|
||||
disabled={!canUpload}
|
||||
loading={uploading}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 36,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
minWidth: 100,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{uploading ? "上传中" : "开始上传"}
|
||||
</Button>
|
||||
{uploading ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,55 +10,28 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ file }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 10,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
border: "1px solid var(--border-color)",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{/* 文件图标圆形底托 */}
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-soft, rgba(22, 119, 255, 0.1))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 16, color: "var(--primary-color)" }} />
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: 2,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,6 @@ const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Upload.Dragger
|
||||
accept={UPLOAD_CONFIG.accept}
|
||||
@@ -30,73 +28,25 @@ const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
||||
showUploadList={false}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div
|
||||
<p
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "20px 16px",
|
||||
borderRadius: 12,
|
||||
background: isHovered
|
||||
? "var(--primary-soft, rgba(22, 119, 255, 0.08))"
|
||||
: "var(--primary-soft, rgba(22, 119, 255, 0.03))",
|
||||
border: `2px dashed ${isHovered ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
transition: "all 0.25s ease",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 图标圆形底托 */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-soft, rgba(22, 119, 255, 0.1))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 10,
|
||||
transition: "transform 0.2s ease",
|
||||
transform: isHovered ? "scale(1.08)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
<UploadOutlined
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主文案 */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
margin: "0 0 6px",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
点击或拖拽音频文件到此处
|
||||
</p>
|
||||
|
||||
{/* 副文案 */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>点击或拖拽音频文件到此处</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user