Compare commits

..

1 Commits

Author SHA1 Message Date
deploy-release-bot 05f832a2eb fix(dedup): check_duplicate支持exclude_video_id,修复recompute自匹配duplicate_of指向自己 (Issue #1702连带修复)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
AI Code Review / AI Code Review (pull_request) Successful in 3m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m17s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 13s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m19s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m8s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 11s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 23s
recompute-dedup重算时视频记录已在库中(正常生成流程新视频未落库所以没暴露),
check_duplicate候选列表包含自身,自匹配汉明距离0融合分最高,导致
is_duplicate=true但duplicate_of指向视频自己。
check_batch_duplicate/compute_duplicate_rate本就有排除自身逻辑,
给check_duplicate补exclude_video_id参数,recompute任务与helpers均传入。
补回归单测。
2026-09-05 08:59:57 +08:00
281 changed files with 4107 additions and 23328 deletions
-2
View File
@@ -1187,8 +1187,6 @@ jobs:
COSYVOICE_API_KEY: ${{ secrets.COSYVOICE_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
MEDIAKIT_API_KEY: ${{ secrets.MEDIAKIT_API_KEY }}
WECHAT_APP_ID: ${{ secrets.WECHAT_APP_ID }}
WECHAT_APP_SECRET: ${{ secrets.WECHAT_APP_SECRET }}
run: |
set -eu
echo "Rendering .env from template + secrets..."
@@ -1,34 +0,0 @@
"""add client_upload_id to assets and asset_id to ingest_jobs
Issue #1714:上传 complete 幂等 + worker 转码回写关联。
- assets.client_upload_id:客户端幂等 tokencomplete 去重)
- ingest_jobs.asset_idcomplete 阶段创建的占位 asset idworker 回写关联,
防止 HEVC 转码改写 storage_key 后找不到占位而兜底新建 READY 记录)
Revision ID: 066_upload_idempotency
Revises: 065_dup_record_sim_match
Create Date: 2026-09-05
"""
import sqlalchemy as sa
from alembic import op
revision = "066_upload_idempotency"
down_revision = "065_dup_record_sim_match"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("assets", sa.Column("client_upload_id", sa.String(64), nullable=True))
op.create_index("ix_assets_client_upload_id", "assets", ["client_upload_id"])
op.add_column("ingest_jobs", sa.Column("asset_id", sa.String(36), nullable=False, server_default=""))
op.create_index("ix_ingest_jobs_asset_id", "ingest_jobs", ["asset_id"])
def downgrade() -> None:
op.drop_index("ix_ingest_jobs_asset_id", table_name="ingest_jobs")
op.drop_column("ingest_jobs", "asset_id")
op.drop_index("ix_assets_client_upload_id", table_name="assets")
op.drop_column("assets", "client_upload_id")
@@ -1,35 +0,0 @@
"""add celery_task_id to generation_tasks and ingest_jobs
Issue #1714:孤儿恢复/超时清理撤销队列消息。
- generation_tasks.celery_task_id:入队时记录的 Celery 消息 ID,清理时 revoke
- ingest_jobs.celery_task_id:同上(素材转码任务)
Revision ID: 067_celery_task_id
Revises: 066_upload_idempotency
Create Date: 2026-09-05
"""
import sqlalchemy as sa
from alembic import op
revision = "067_celery_task_id"
down_revision = "066_upload_idempotency"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
)
op.add_column(
"ingest_jobs",
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("ingest_jobs", "celery_task_id")
op.drop_column("generation_tasks", "celery_task_id")
@@ -1,26 +0,0 @@
"""add profile_completed to users
Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。
- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导),
微信新建用户在应用层置 False。
"""
import sqlalchemy as sa
from alembic import op
revision = "068_user_profile_completed"
down_revision = "067_celery_task_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
def downgrade() -> None:
op.drop_column("users", "profile_completed")
-6
View File
@@ -11,7 +11,6 @@ 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.generation_variant_plans import router as generation_variant_plans_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
@@ -100,11 +99,6 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_variant_plans_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_cover_router,
prefix="/generation",
+2 -186
View File
@@ -1,5 +1,4 @@
"""
from __future__ import annotations
Canonical authentication API routes.
The route layer is intentionally thin: repository construction lives in
@@ -14,9 +13,9 @@ import jwt
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr, field_validator
from pydantic import BaseModel, EmailStr
from packages.adapters.redis import NoopSessionStore
from packages.adapters.smtp import NoopEmailService
@@ -85,23 +84,6 @@ class CurrentUserResponse(BaseModel):
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
profile_completed: bool = True
class UserProfileResponse(BaseModel):
"""用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)"""
user_id: str
email: str
username: str
display_name: str
email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
profile_completed: bool = True
class PasswordResetRequestModel(BaseModel):
@@ -290,52 +272,9 @@ async def get_current_user_info(
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
profile_completed=user.profile_completed,
)
class UpdateProfileRequest(BaseModel):
"""更新个人资料请求(当前仅支持昵称)"""
display_name: str
@field_validator("display_name")
@classmethod
def _validate_display_name(cls, v: str) -> str:
name = (v or "").strip()
if not name:
raise ValueError("昵称不能为空白")
if len(name) > 20:
raise ValueError("昵称长度需在 1-20 个字符之间")
return name
class UpdateProfileResponse(BaseModel):
"""更新资料响应:前端 normalizeUser(response.user) 直接消费"""
user: UserProfileResponse
@router.patch("/me", response_model=UpdateProfileResponse)
async def update_current_user_profile(
request: UpdateProfileRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> UpdateProfileResponse:
"""更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。"""
user = current_user.user
user.display_name = request.display_name # 已 stripvalidator
if not user.profile_completed:
user.profile_completed = True
user_repository.save(user)
logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed)
# 重新读取,确保返回的是持久化后的最新状态
fresh = user_repository.find_by_id(user.id) or user
return UpdateProfileResponse(user=_user_profile(fresh))
class _NoopSessionStore(NoopSessionStore):
pass
@@ -487,7 +426,6 @@ async def get_wechat_auth_url() -> WechatAuthUrlResponse:
@router.post("/wechat/callback", response_model=WechatLoginResponse)
async def wechat_callback(
request: WechatCallbackRequest,
http_request: Request,
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatLoginResponse:
"""微信登录回调处理"""
@@ -495,30 +433,11 @@ async def wechat_callback(
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
# 回调可观测性:记录 UA(区分微信内置浏览器 MicroMessenger)与 state
# 便于排查"停留 open.weixin.qq.com / 回调失败"类问题(#1718
user_agent = http_request.headers.get("User-Agent", "")
is_wechat_browser = "MicroMessenger" in user_agent
logger.info(
"[微信回调] 收到回调: state=%s code_len=%d UA=%r 微信内置浏览器=%s",
(request.state or "")[:8],
len(request.code or ""),
user_agent[:200],
is_wechat_browser,
)
# 1. 用 code 换微信用户信息
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
# state 校验失败 / 微信 errcode 等错误原文已在 service 内 log,这里带上 UA 上下文
logger.warning("[微信回调] 处理失败: err=%s 微信内置浏览器=%s", err, is_wechat_browser)
raise HTTPException(status_code=400, detail=err)
logger.info(
"[微信回调] state 校验通过,微信用户信息获取成功: openid=%s unionid=%s",
wechat_user.openid[:8] if wechat_user.openid else "",
bool(wechat_user.unionid),
)
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
use_case = WechatSyncUseCase(user_repository=user_repository)
@@ -553,109 +472,6 @@ async def wechat_callback(
)
# ==================== 微信账号绑定/解绑(已登录用户) ====================
class WechatBindUrlResponse(BaseModel):
auth_url: str
state: str
class WechatBindCompleteRequest(BaseModel):
code: str
state: str = ""
class WechatBindCompleteResponse(BaseModel):
success: bool
user: UserProfileResponse
class WechatUnbindResponse(BaseModel):
success: bool
def _user_profile(user) -> UserProfileResponse:
binding_complete = bool(
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return UserProfileResponse(
user_id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
email_verified=user.email_verified,
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
profile_completed=user.profile_completed,
)
@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse)
async def get_wechat_bind_url(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> WechatBindUrlResponse:
"""获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。"""
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
oauth_service = get_wechat_oauth_service()
auth_url, state = oauth_service.generate_auth_url()
logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id)
return WechatBindUrlResponse(auth_url=auth_url, state=state)
@router.post("/wechat/bind", response_model=WechatBindCompleteResponse)
async def wechat_bind(
request: WechatBindCompleteRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatBindCompleteResponse:
"""微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。"""
from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err)
raise HTTPException(status_code=400, detail=err)
use_case = WechatBindUseCase(user_repository=user_repository)
result, error, http_status = use_case.bind(
WechatBindRequest(
user_id=current_user.user.id,
openid=wechat_user.openid,
unionid=wechat_user.unionid or "",
)
)
if error:
logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error)
raise HTTPException(status_code=http_status, detail=error)
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
return WechatBindCompleteResponse(success=True, user=_user_profile(result.user))
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
async def wechat_unbind(
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatUnbindResponse:
"""解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。"""
from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase
use_case = WechatUnbindUseCase(user_repository=user_repository)
result, error, http_status = use_case.unbind(current_user.user.id)
if error:
logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error)
raise HTTPException(status_code=http_status, detail=error)
logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id)
return WechatUnbindResponse(success=True)
# ==================== 验证码 & 绑定 ====================
+1 -3
View File
@@ -14,7 +14,6 @@ from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.api.routes.upload import _persist_celery_task_id
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
@@ -382,8 +381,7 @@ async def complete_chunked_upload(
file_hash=request.file_hash,
)
)
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
_persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", ""))
celery_app.send_task("worker.ingest_asset", args=[job.id])
# Update metadata status
meta["status"] = "completed"
+4 -84
View File
@@ -75,86 +75,6 @@ class GenerateCoverResponse(BaseModel):
# ── Route ────────────────────────────────────────────────────────────────
def _select_best_frame_from_snapshots(
snapshots: list[dict], plan_id: str
) -> str:
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
降级策略:cv2 不可用或评分失败时,返回第一帧。
Args:
snapshots: MediaKit 返回的帧列表 [{"image_url": str, ...}, ...]
plan_id: 计划 ID(日志用)
Returns:
最佳帧的 image_url,或空字符串
"""
if not snapshots:
return ""
if len(snapshots) == 1:
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
try:
import tempfile
import httpx
from packages.shared.cover_frame_scorer import score_frames
scored_candidates = []
for snap in snapshots:
url = snap.get("image_url") or snap.get("url") or ""
if not url:
continue
# 下载帧到临时文件进行评分
try:
resp = httpx.get(url, timeout=15, follow_redirects=True)
resp.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
scored_candidates.append({"image_path": tmp_path, "url": url})
except Exception:
# 下载失败的帧跳过,给默认低分
scored_candidates.append({"image_path": None, "url": url, "score": 0.0})
if not scored_candidates:
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
scored = score_frames(scored_candidates)
best = scored[0] if scored else None
best_url = best.get("url", "") if best else ""
best_score = best.get("score", 0.0) if best else 0.0
logger.info(
"[封面生成] 帧质量评分完成: plan_id=%s candidates=%d best_score=%.1f",
plan_id,
len(scored_candidates),
best_score,
)
# 清理临时文件
for c in scored_candidates:
path = c.get("image_path")
if path:
try:
from pathlib import Path
Path(path).unlink(missing_ok=True)
except Exception:
pass
return best_url
except Exception:
logger.warning(
"[封面生成] 帧质量评分失败,使用第一帧: plan_id=%s",
plan_id,
exc_info=True,
)
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
def _persist_cover_frame(
frame_url: str,
plan_id: str,
@@ -732,13 +652,13 @@ def generate_cover(
snapshots = mk_client.extract_frames(
video_url=primary_video_url,
strategy="SpecifiedFrames",
max_frames=5, # 抽 5 帧,通过质量评分选最佳
max_frames=1,
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = _select_best_frame_from_snapshots(snapshots, plan_id)
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
if raw:
cover_url_from_task = _persist_cover_frame(raw, plan_id)
logger.info(
@@ -795,13 +715,13 @@ def generate_cover(
snapshots = mk_client.extract_frames(
video_url=src_url,
strategy="SpecifiedFrames",
max_frames=5, # 抽 5 帧,通过质量评分选最佳
max_frames=1,
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = _select_best_frame_from_snapshots(snapshots, plan_id)
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
if raw:
cover_url_from_task = _persist_cover_frame(
raw,
+25 -108
View File
@@ -14,7 +14,6 @@ from app.core.task_enqueue import (
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
build_rate_limit_detail,
safe_enqueue_generation_task,
)
from app.dependencies import (
@@ -313,12 +312,12 @@ def create_preview_generation_task(
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待后再提交",
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
detail="系统繁忙,请稍后再试",
) from e
# 确定视频比例:优先前端传入,否则从模板 mode 推断
@@ -390,6 +389,7 @@ def create_preview_generation_task(
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,
asset_select_mode="",
@@ -419,118 +419,42 @@ def create_preview_generation_task(
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# ── 独立变体 plan#1743)──
# count=1:克隆源 plan(预览不污染源 plan,仅起点重算),行为与旧版一致;
# count>1:变体 0 保留源 plan,变体 1..N-1 用 reselect_plan_for_variant 完整
# 重跑单视频选片(素材洗牌+镜头洗牌+起点随机+跨变体避让+批次 20% 重叠重选),
# 所见即所得——预览变体差异即正式成片差异。
# ── 克隆独立变体 plan:N 个预览全部克隆(预览不污染源 plan)──
# 源 plan 不存在(无编辑历史)时各任务走自身随机选片流程,不克隆。
source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else ""
# #1749:各变体配音解析(严格守卫已在 schema;此处取每变体 voice 查时长)+ 时长分配
def _preview_voice_durations() -> list[float]:
try:
from packages.domain.variant_voice_resolver import resolve_variant_voice_ids
voices = resolve_variant_voice_ids(
count=count,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
except Exception:
logger.warning("[预览生成] 配音解析失败(按无配音处理)", exc_info=True)
return [0.0] * count
try:
from app.api.routes.generation_tasks import _query_voice_durations
return _query_voice_durations(db, voices)
except Exception:
return [0.0] * count
voice_durations = _preview_voice_durations()
if source_plan_id and count == 1:
# 单预览:克隆一份(原逻辑)+ 配音时长分配
if source_plan_id:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
variant_plan = _plan_svc.clone_plan_for_variant(
source_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体",
)
if voice_durations and voice_durations[0] > 0:
try:
_plan_svc.apply_voice_duration_to_plan(variant_plan.id, voice_durations[0])
except Exception:
logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", variant_plan.id)
variant_plan_ids.append(variant_plan.id)
except Exception as e:
logger.error("[预览生成] 克隆预览 plan 异常: %s", e, exc_info=True)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from e
elif source_plan_id and count > 1:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
# #1749:变体 0 也 clone(不污染源 plan+ 配音分配;变体 1..N-1 独立选片
_plan0 = _plan_svc.clone_plan_for_variant(
source_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体1",
)
if voice_durations and voice_durations[0] > 0:
try:
_plan_svc.apply_voice_duration_to_plan(_plan0.id, voice_durations[0])
except Exception:
logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", _plan0.id)
variant_plan_ids.append(_plan0.id)
batch_asset_pool = list(dict.fromkeys(request.asset_ids or []))
for variant_index in range(1, count):
for variant_index in range(count):
last_err: Exception | None = None
variant_plan = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant_plan = _plan_svc.reselect_plan_for_variant(
variant_plan = _plan_svc.clone_plan_for_variant(
source_plan_id,
batch_asset_pool,
created_by_user_id=user_id,
name_suffix=f"预览变体{variant_index + 1}",
voice_duration=(
voice_durations[variant_index] if variant_index < len(voice_durations) else 0.0
),
name_suffix=f"预览变体{variant_index + 1}" if count > 1 else "预览变体",
)
break
except ValueError as ve:
logger.warning("[预览生成] 变体独立选片失败(素材不足): %s", ve)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体选片失败")
raise HTTPException(
status_code=400,
detail=f"批量预览第 {variant_index + 1} 个视频无法独立选片:{ve}"
"请增加素材库中的视频素材后重试。",
) from ve
except Exception as reselection_err: # noqa: PERF203
last_err = reselection_err
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[预览生成] 变体独立选片失败(尝试%d/2): variant=%d error=%s",
"[预览生成] 克隆变体 plan 失败(尝试%d/2): variant=%d error=%s",
_attempt + 1,
variant_index,
reselection_err,
clone_err,
exc_info=True,
)
if variant_plan is None:
logger.error(
"[预览生成] 变体独立选片重试仍失败: variant=%d source=%s",
"[预览生成] 克隆预览变体 plan 重试仍失败: variant=%d source=%s",
variant_index,
source_plan_id,
exc_info=last_err,
)
# 标记已创建任务失败
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException(
@@ -541,7 +465,7 @@ def create_preview_generation_task(
except HTTPException:
raise
except Exception as e:
logger.error("[预览生成] 变体 plan 生成异常: %s", e, exc_info=True)
logger.error("[预览生成] 克隆变体 plan 异常: %s", e, exc_info=True)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException(
@@ -574,7 +498,6 @@ def create_preview_generation_task(
# ── 入队 ──
responses: list[PreviewGenerationTaskResponse] = []
rate_limit_exc: Exception | None = None # 记录首个限流异常,全部失败时返回结构化提示
for variant_index, task in enumerate(created_tasks):
try:
enqueued = safe_enqueue_generation_task(
@@ -587,29 +510,23 @@ def create_preview_generation_task(
if not enqueued:
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
except UserPendingLimitExceeded as e:
except UserPendingLimitExceeded:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
rate_limit_exc = rate_limit_exc or e
except GlobalQueueFull as e:
except GlobalQueueFull:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
rate_limit_exc = rate_limit_exc or e
except Exception:
logger.exception("[预览生成] 入队异常: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队异常")
# enqueue 会原地更新 task 状态/进度,直接用 task 构造响应
responses.append(_to_preview_response(task))
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败"
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
)
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
)
# 队列满/限流时若全部失败,返回明确错误码
if all(r.status == "failed" for r in responses):
first_err = next((r.error_message for r in responses if r.error_message), "")
if "待处理任务" in first_err:
raise HTTPException(status_code=429, detail=first_err or "待处理任务超限")
if "队列" in first_err:
raise HTTPException(status_code=503, detail=first_err or "系统繁忙,请稍后再试")
logger.info(
"[预览生成] 创建完成: %d 个变体任务, task_ids=%s",
+58 -252
View File
@@ -10,7 +10,6 @@ from app.core.task_enqueue import (
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
build_rate_limit_detail,
safe_enqueue_generation_task,
)
from app.dependencies import (
@@ -57,30 +56,6 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
return values[index] if index < len(values) else fallback
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
"""
ids = [v for v in dict.fromkeys(voice_ids or []) if v]
if not ids:
return []
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
return [dur_map.get(v, 0.0) for v in ids]
except Exception:
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in ids]
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -147,7 +122,6 @@ def _select_assets_from_library(
assets: list,
mode: str,
count: int,
rng=None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -155,8 +129,6 @@ def _select_assets_from_library(
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 smart 模式有效)
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
测试可注入固定种子或零噪声随机源获得确定性结果。
Returns:
选中的素材 ID 列表
@@ -169,9 +141,8 @@ def _select_assets_from_library(
if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
limit = count if count > 0 else None
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
return [r.asset.id for r in results]
# 默认 all 模式:返回全部 ready 视频素材
@@ -446,12 +417,12 @@ def create_generation_task(
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
detail="系统繁忙,请稍后再试",
) from e
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
@@ -460,199 +431,55 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
# - 变体 0clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
# - #1749:前端可回传 variant-plans 接口预生成的 plan_idvariant_plan_ids),直接复用;
# 回传 plan 仍按各变体配音幂等重分配段长(防 variant-plans 阶段未带配音/占位时长);
# - 配音时长:独立配音各自时长、统一配音同值,逐变体 apply_voice_duration_to_plan
# 成片总时长=配音时长(素材短→末帧冻结,禁慢放/禁截配音);
# - count>1 但没有源 plan 时,不允许 N 个任务兜底共用同一 plan,直接 4xx 中断。
# 在创建任何任务【之前】预生成/校验全部变体 plan:失败直接中断(此时无脏数据)。
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
variant_plan_ids: list[str] = []
if count > 1:
if count > 1 and request.source_edit_plan_id:
from app.services.edit_plan_service import EditPlanService
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
_plan_svc = EditPlanService(db)
# 解析每变体配音(严格守卫:独立配音长度/缺值 → 400,禁静默 fallback
try:
variant_voices = resolve_variant_voice_ids(
count=count,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
except VariantVoiceError as ve:
raise HTTPException(status_code=400, detail=str(ve)) from ve
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
voice_durations = _query_voice_durations(db, variant_voices)
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源)
batch_source_plan_id = request.source_edit_plan_id
if not batch_source_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.clone_plan_for_variant(
request.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
)
.order_by(EditPlanModel.created_at.desc())
.first()
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
request.source_edit_plan_id,
clone_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
request.source_edit_plan_id,
exc_info=last_err,
)
if _latest:
batch_source_plan_id = _latest.id
except Exception:
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
if not batch_source_plan_id and not request.variant_plan_ids:
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
logger.error("[生成任务] 批量 count=%d 但无可编辑计划(无 source_edit_plan_id/template plan", count)
raise HTTPException(
status_code=400,
detail="批量生成需要先完成预览生成(缺少剪辑计划)。请先生成预览后再批量创建。",
)
# 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids
batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or []))
if request.variant_plan_ids:
# ① 前端回传 variant-plans 预生成结果:直接复用(轻量选片接口已建好 plan)
if len(request.variant_plan_ids) != count:
raise HTTPException(
status_code=400,
detail=f"variant_plan_ids 数量({len(request.variant_plan_ids)})与视频数量({count})不一致",
)
# 校验归属权
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
for _pid in request.variant_plan_ids:
_pm = db.query(EditPlanModel).filter(EditPlanModel.id == _pid).first()
if _pm is None:
raise HTTPException(status_code=400, detail=f"剪辑计划不存在: {_pid}")
if _pm.created_by_user_id and _pm.created_by_user_id != user_id:
raise HTTPException(status_code=403, detail=f"无权使用剪辑计划: {_pid}")
variant_plan_ids = list(request.variant_plan_ids)
else:
# ② 服务端选片:变体 0 clone 源 plan(不污染源 plan
try:
_plan0 = _plan_svc.clone_plan_for_variant(
batch_source_plan_id,
created_by_user_id=user_id,
name_suffix="批量1",
)
except Exception as clone_err:
logger.error("[生成任务] 变体0 clone 失败: %s", clone_err, exc_info=True)
raise HTTPException(
status_code=500, detail="创建批量任务失败:无法生成独立剪辑计划,请重试"
) from clone_err
variant_plan_ids.append(_plan0.id)
# 变体 1..N-1 独立选片
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.reselect_plan_for_variant(
batch_source_plan_id,
batch_asset_pool,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
)
break
except ValueError as ve:
# 素材不足等可预期错误:不重试,直接中断并给出明确提示
logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve)
raise HTTPException(
status_code=400,
detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}"
"请增加素材库中的视频素材后重试。",
) from ve
except Exception as reselection_err: # noqa: PERF203
last_err = reselection_err
logger.warning(
"[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
batch_source_plan_id,
reselection_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s",
batch_source_plan_id,
exc_info=last_err,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
for _vi, _pid in enumerate(variant_plan_ids):
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
if _vd > 0:
try:
_plan_svc.apply_voice_duration_to_plan(_pid, _vd)
except Exception:
logger.exception("[生成任务] 变体%d 配音时长分配失败(不阻断): plan=%s", _vi, _pid)
# N=1 正式生成:渲染侧全局慢放兜底已删除(#1749),enqueue 前也必须按配音分配段长
if count == 1 and not request.is_preview:
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
try:
_voices = resolve_variant_voice_ids(
count=1,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
_single_vd: list[float] = _query_voice_durations(db, _voices)
_single_dur = _single_vd[0] if _single_vd else 0.0
_single_plan = request.source_edit_plan_id
if not _single_plan and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
_single_plan = _latest.id
except Exception:
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
if _single_dur > 0 and _single_plan:
from app.services.edit_plan_service import EditPlanService
try:
EditPlanService(db).apply_voice_duration_to_plan(_single_plan, _single_dur)
except Exception:
logger.exception("[生成任务] N=1 配音时长分配失败(不阻断): plan=%s", _single_plan)
except VariantVoiceError as ve:
raise HTTPException(status_code=400, detail=str(ve)) from ve
except Exception:
logger.exception("[生成任务] N=1 配音分配兜底异常(不阻断)")
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
try:
for task_index in range(count):
# #1749count>1 时每个变体(含变体0)都关联各自独立 planclone/reselect/variant-plans
if count > 1 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index]
else:
effective_plan_id = request.source_edit_plan_id
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
# 无源 plansource_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
effective_plan_id = request.source_edit_plan_id
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
@@ -672,6 +499,7 @@ def create_generation_task(
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=effective_plan_id,
asset_select_mode=request.asset_select_mode,
@@ -694,19 +522,7 @@ def create_generation_task(
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
# #1743:批量(count>1)场景严禁兜底共用——变体 plan 已在上方预生成,
# 走到这里还缺 plan 说明预生成漏配,直接报错中断,不允许 N 任务关联同一 plan。
if not task.source_edit_plan_id and count > 1:
logger.error(
"[生成任务] 批量任务缺少独立 plan(禁止共用兜底): task_index=%d task_id=%s",
task_index,
task.id,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:变体剪辑计划缺失,请重新预览后再批量生成。",
)
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
@@ -763,7 +579,7 @@ def create_generation_task(
if not created_tasks:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
detail="您的待处理任务过多,请等待完成后再提交",
) from _e
break
except GlobalQueueFull as _e:
@@ -771,7 +587,7 @@ def create_generation_task(
if not created_tasks:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
detail="系统繁忙,请稍后再试",
) from _e
break
except HTTPException:
@@ -873,6 +689,7 @@ def confirm_generation(
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,
@@ -896,15 +713,15 @@ def confirm_generation(
log_task_status=True,
):
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
except UserPendingLimitExceeded as _e:
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull as _e:
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
detail="系统繁忙,请稍后再试",
) from None
return BatchGenerationTaskResponse(
@@ -986,24 +803,12 @@ def retry_generation_task(
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(
UserPendingLimitExceeded(
user_id=user_id,
pending_count=user_pending,
limit=USER_PENDING_LIMIT,
),
generation_task_repository,
scope="user",
),
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(
GlobalQueueFull(pending_count=global_pending, limit=GLOBAL_PENDING_LIMIT),
generation_task_repository,
scope="global",
),
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
@@ -1016,6 +821,7 @@ def retry_generation_task(
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
@@ -1037,15 +843,15 @@ def retry_generation_task(
log_task_status=True,
):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded as _e:
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull as _e:
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
detail="系统繁忙,请稍后再试",
) from None
return _to_generation_task_response(retried)
@@ -1,178 +0,0 @@
"""轻量选片接口 POST /generation/variant-plans#1749)。
与正式生成共用同一套选片函数(EditPlanService.ensure_variant_plans →
clone_plan_for_variant / reselect_plan_for_variant → variant_plan_selector),
但**不建任务、不入队、不渲染**
- 仅为 N 个变体创建/选好 EditPlan + clips,返回 plan_id 与片段列表;
- 前端确认后调正式生成接口回传 variant_plan_ids,直接复用这些 plan
不再重复选片(回传后仍按各变体配音幂等重分配段长);
- 配音守卫:voice_library_ids 长度/缺值 → 400variant_voice_resolver),
禁静默 fallback
- 素材不足等选片失败 → 400(与正式生成同口径);除此之外不报错打断。
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
logger = logging.getLogger(__name__)
router = APIRouter()
class VariantPlanRequest(BaseModel):
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
template_id: str = Field(default="", description="模板 ID(无 source_edit_plan_id 时用于查找骨架 plan")
asset_ids: list[str] = Field(default_factory=list, description="批次素材池")
count: int = Field(default=1, ge=1, le=50, description="变体数量")
source_edit_plan_id: str = Field(default="", description="源剪辑计划 ID(优先)")
# 配音(可选;传独立配音时严格守卫)
voice_library_id: str = Field(default="", description="统一配音 ID")
voice_library_ids: list[str] = Field(default_factory=list, description="独立配音 ID 列表(长度须=count)")
@model_validator(mode="after")
def _validate(self) -> "VariantPlanRequest":
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
try:
resolve_variant_voice_ids(
count=self.count,
voice_library_id=self.voice_library_id,
voice_library_ids=self.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise ValueError(str(exc)) from exc
return self
class VariantPlanItem(BaseModel):
variant_index: int
plan_id: str
clips: list[dict[str, Any]] = Field(default_factory=list)
class VariantPlanResponse(BaseModel):
items: list[VariantPlanItem]
total: int
@router.post("/variant-plans", response_model=VariantPlanResponse)
def create_variant_plans(
request: VariantPlanRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> VariantPlanResponse:
"""轻量选片:为 N 个变体创建独立 EditPlan + clips,不建任务/不渲染。
Returns:
200 + {items: [{variant_index, plan_id, clips}], total}
"""
user_id = authenticated_user.user.id
# 配音严格守卫(schema 已校验,此处复用解析取每变体配音)
try:
voices = resolve_variant_voice_ids(
count=request.count,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
source_plan_id = request.source_edit_plan_id.strip()
if not source_plan_id and request.template_id.strip():
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
source_plan_id = _latest.id
except Exception:
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
if not source_plan_id:
raise HTTPException(
status_code=400,
detail="缺少剪辑计划:请先完成一次预览生成(或传入 source_edit_plan_id)后再试。",
)
# 配音时长(硬化:异常 → 0.0 不阻断选片)
try:
from app.api.routes.generation_tasks import _query_voice_durations
voice_durations = _query_voice_durations(db, voices)
except Exception:
logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
voice_durations = [0.0] * request.count
from app.services.edit_plan_service import EditPlanService
svc = EditPlanService(db)
try:
plan_ids = svc.ensure_variant_plans(
source_plan_id,
request.count,
list(dict.fromkeys(request.asset_ids or [])),
created_by_user_id=user_id,
voice_durations=voice_durations,
)
except ValueError as ve:
# 素材池为空/时长全未知等可预期错误 → 400(与正式生成同口径)
logger.warning("[variant-plans] 选片失败: %s", ve)
raise HTTPException(status_code=400, detail=f"变体选片失败:{ve}。请增加素材后重试。") from ve
except HTTPException:
raise
except Exception as e:
logger.error("[variant-plans] 选片异常: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
# 组装 clips 响应
items: list[VariantPlanItem] = []
for idx, pid in enumerate(plan_ids):
clips = svc.list_clips(pid)
clip_dicts = [
{
"id": c.id,
"order": c.order,
"asset_id": c.asset_id,
"start_time": float(c.start_time or 0.0),
"duration": float(c.duration or 0.0),
"clip_type": c.clip_type,
"transition_effect": c.transition_effect,
"transition_duration": float(c.transition_duration or 0.0),
"playback_speed": float(c.playback_speed or 1.0),
"text_content": c.text_content or "",
"status": c.status or "ready",
}
for c in clips
]
items.append(VariantPlanItem(variant_index=idx, plan_id=pid, clips=clip_dicts))
logger.info(
"[variant-plans] 轻量选片完成: user=%s source=%s count=%d plans=%d",
user_id,
source_plan_id,
request.count,
len(plan_ids),
)
return VariantPlanResponse(items=items, total=len(items))
+1 -7
View File
@@ -43,13 +43,7 @@ def submit_ingest_job(
)
)
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
if getattr(celery_result, "id", ""):
try:
job.celery_task_id = celery_result.id
ingest_job_repository.update(job)
except Exception: # noqa: BLE001
pass
celery_app.send_task("worker.ingest_asset", args=[job.id])
return IngestJobResponse(
id=job.id,
+1 -7
View File
@@ -375,13 +375,7 @@ def retry_project_task(
storage_key=job.storage_key,
)
)
celery_result = celery_app.send_task("worker.ingest_asset", args=[retried.id])
if getattr(celery_result, "id", ""):
try:
retried.celery_task_id = celery_result.id
ingest_job_repository.update(retried)
except Exception: # noqa: BLE001
pass
celery_app.send_task("worker.ingest_asset", args=[retried.id])
return ProjectTaskResponse(
id=f"ingest:{retried.id}",
task_type="ingest",
-5
View File
@@ -106,10 +106,6 @@ def list_templates(
tag: str | None = Query(None, description="按标签筛选"),
keyword: str | None = Query(None, description="按名称关键词搜索"),
mode: str | None = Query(None, description="按剪辑模式筛选"),
valid_only: bool = Query(
False,
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
@@ -120,7 +116,6 @@ def list_templates(
tag=tag,
keyword=keyword,
mode=mode,
valid_only=valid_only,
)
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
@@ -36,11 +36,17 @@ from app.services.asset_segment_tracker import (
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import (
_calc_random_start_time,
build_scene_segments,
@@ -393,42 +399,68 @@ def _safe_segment_duration(value, default: float) -> float:
def _get_template_segments(
template_id: str,
user_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
单一数据源:模板主表为 ``templates``(用户自建,归属 user_id/
``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs``
(由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。
不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流,
也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于归属校验)
tpl_svc: 模板编辑器服务
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
模板存在但未配置片段时返回空列表。
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
[(segment_order, duration_min, duration_max), ...] 按 order 排序
"""
clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id)
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
try:
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
direct_configs = direct_repo.list_by_template(template_id)
if direct_configs:
result = []
for cc in direct_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
result = []
for s in segments:
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((s.segment_order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("旧模板系统查询segments失败", exc_info=True)
return []
def _recommended_time_conflicts(
@@ -631,21 +663,13 @@ def create_clips_from_assets_editor(
7. 素材时长为 0 或缺失时报 400,不创建无效片段
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404;
# 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。
try:
segments = _get_template_segments(template_id, user_id, tpl_svc)
except TemplateNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在或无权访问",
) from exc
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
if not segments:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="模板未配置片段",
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
)
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
@@ -41,33 +41,29 @@ def get_draft_plan_id(
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
确保 template_id → plan_id 的映射始终存在。
模板读取遵循单一数据源、显式判定(不使用异常降级):
- 用户自建模板在旧表 ``templates``(归属 user_idis_active=True);
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
模板不存在、已删除或不归属于当前用户时,一律返回 404。
兼容策略:优先从新模板系统(edit_templates 表)查找,
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get_active(template_id, user_id)
is_global_template = tpl_svc.get_template(template_id) is not None
if old_template is None and not is_global_template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 1. 草稿已存在 → 直接返回
draft = tpl_svc.get_template_draft(template_id)
if draft is not None:
return draft.id
# 2. 全局模板(新系统)→ 用新服务创建草稿
if is_global_template:
# 2. 新系统有模板 → 用新服务创建草稿
if tpl_svc.get_template(template_id) is not None:
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
return draft.id
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
# 3. 回退到旧模板系统templates 表)
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get(template_id, user_id=user_id)
if old_template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 4. 基于旧模板创建草稿计划
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
@@ -150,7 +150,7 @@ def rollback_template(
try:
tpl = tpl_svc.rollback_to_version(template_id, request.version)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
clip_configs = tpl_svc.list_clip_configs(template_id)
return EditorRollbackResponse(
+58 -278
View File
@@ -85,22 +85,12 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str:
"""从 storage_key 推断 MIME 类型(与 worker 端保持一致)。"""
lower_filename = storage_key.rsplit("/", 1)[-1].lower()
_MIME_MAP = {
".mov": "video/quicktime",
".mp4": "video/mp4",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
".webm": "video/webm",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".flac": "audio/flac",
".m4a": "audio/x-m4a",
".mov": "video/quicktime", ".mp4": "video/mp4", ".avi": "video/x-msvideo",
".mkv": "video/x-matroska", ".webm": "video/webm",
".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp",
".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
".flac": "audio/flac", ".m4a": "audio/x-m4a",
}
for ext, mime in _MIME_MAP.items():
if lower_filename.endswith(ext):
@@ -108,137 +98,10 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str:
return "video/mp4" # default
# 兜底去重:无 file_hash / client_upload_id 且大小已知时,同库同名同大小近期活动记录视为重复
FALLBACK_DEDUP_WINDOW_MINUTES = 30
def _find_duplicate_asset(
asset_repository: Any,
*,
library_id: str,
file_hash: str,
client_upload_id: str,
filename: str,
file_size: int = 0,
) -> Any:
"""complete/上传幂等去重,按优先级查找已存在的素材。
1. client_upload_id(客户端幂等 token,同一次上传的重试保持一致)
2. file_hash(内容哈希,不同上传只要内容相同即去重)
3. 兜底(严格模式,宁可漏判不可误杀):file_hash 与 client_upload_id
均缺失、且 file_size > 0 时,同库 + 同文件名 + **同大小** 且 30 分钟内
仍处 uploading/processing 的记录才判重。
- file_hash 非空时跳过兜底(hash 已代表内容;同名但内容全新的视频
如 iPhone 的 IMG_xxxx.MOV 绝不能被同名占位误杀)
- file_size=0(未知)时不允许仅凭同名 + processing 判重,直接放行
全部为鸭子类型调用:旧仓储无对应方法时静默跳过,不破坏既有实现。
"""
if client_upload_id:
find = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
if callable(find):
existing = find(library_id=library_id, client_upload_id=client_upload_id)
if existing is not None:
logger.info(
"素材幂等命中(client_upload_id): library=%s token=%s asset=%s",
library_id,
client_upload_id,
getattr(existing, "id", "?"),
)
return existing
if file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=library_id,
file_hash=file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(file_hash): library=%s hash=%s asset=%s",
library_id,
file_hash,
existing.id,
)
return existing
# 同名兜底去重(最后防线,严格模式):
# - 仅当 file_hash / client_upload_id 均缺失时启用(hash 能代表内容时不靠同名猜)
# - file_size 必须 > 0 且与记录大小严格一致;大小未知(0)直接放行
# - 只命中近期 UPLOADING/PROCESSING 活动记录(READY 历史素材不拦)
if filename and not file_hash and not client_upload_id and file_size and file_size > 0:
find_recent = getattr(asset_repository, "find_recent_active_by_library_and_name", None)
if callable(find_recent):
existing = find_recent(
library_id=library_id,
name=filename,
within_minutes=FALLBACK_DEDUP_WINDOW_MINUTES,
file_size=file_size,
)
if existing is not None:
logger.info(
"素材幂等兜底命中(近期同名同大小活动记录): library=%s name=%s asset=%s status=%s size=%s",
library_id,
filename,
getattr(existing, "id", "?"),
getattr(existing, "status", None),
file_size,
)
return existing
elif filename and not file_hash and not client_upload_id and not file_size:
logger.debug(
"同名兜底去重跳过(file_size 未知,宁可放行不可误杀): library=%s name=%s",
library_id,
filename,
)
return None
def _create_pending_asset(
asset_repository,
project_id,
library_id,
storage_key,
filename,
mime_type,
user_id,
file_hash="",
client_upload_id="",
file_size: int = 0,
asset_repository, project_id, library_id, storage_key, filename, mime_type, user_id, file_hash=""
):
"""立即创建或复用一条 PROCESSING 状态的 Asset 记录
find-or-createprepare 阶段已按 file_hash/client_upload_id 预建的占位记录
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
"""
# 1. 按 client_upload_id / file_hash 查找现有记录
existing = None
if client_upload_id:
find_by_cuid = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
if callable(find_by_cuid):
existing = find_by_cuid(library_id=library_id, client_upload_id=client_upload_id)
if existing is None and file_hash:
existing = asset_repository.find_by_library_and_file_hash(library_id=library_id, file_hash=file_hash)
if existing is not None:
# 补齐字段(幂等:避免重复建记录,前端已拿到 asset_id)
changed = False
if file_hash and not existing.file_hash:
existing.file_hash = file_hash
changed = True
if client_upload_id and not existing.client_upload_id:
existing.client_upload_id = client_upload_id
changed = True
if file_size and not existing.file_size:
existing.file_size = file_size
changed = True
if existing.status not in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
existing.status = AssetStatus.PROCESSING
changed = True
if changed:
try:
asset_repository.update(existing)
except Exception: # noqa: BLE001 — 字段补齐失败不阻塞主流程
pass
return existing
"""立即创建一条 PROCESSING 状态的 Asset 记录,使前端能马上看到新素材。"""
asset = Asset.create(
project_id=project_id,
library_id=library_id,
@@ -248,30 +111,16 @@ def _create_pending_asset(
status=AssetStatus.PROCESSING,
uploaded_by_user_id=user_id,
file_hash=file_hash,
client_upload_id=client_upload_id,
file_size=file_size,
)
return asset_repository.create(asset)
def _persist_celery_task_id(repo: Any, job: Any, celery_task_id: str) -> None:
"""记录 celery 消息 ID 到任务行,供孤儿清理时 revoke/清除队列消息(#1714)。"""
if not celery_task_id:
return
try:
job.celery_task_id = celery_task_id
repo.update(job)
except Exception: # noqa: BLE001 — 记录失败不影响主流程(执行前状态守卫兜底)
pass
def _submit_ingest_job(
project_id: str,
library_id: str,
storage_key: str,
ingest_job_repository: Any,
file_hash: str = "",
asset_id: str = "",
) -> Any:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -280,11 +129,9 @@ def _submit_ingest_job(
library_id=library_id,
storage_key=storage_key,
file_hash=file_hash,
asset_id=asset_id,
)
)
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
_persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", ""))
celery_app.send_task("worker.ingest_asset", args=[job.id])
return job
@@ -294,15 +141,9 @@ async def prepare_direct_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadPrepareResponse:
"""创建浏览器直传 OSS 的短期表单签名,并在签名前按 file_hash/client_upload_id 去重。
命中去重:直接返回 duplicated=True + skip_transfer=True(前端跳过 OSS 直传),
未命中:正常签名 OSS 并立即预建一条 PROCESSING 状态的 asset 记录占住
file_hash 闸门,响应带 asset_id 供前端/后续 complete 关联。
"""
"""创建浏览器直传 OSS 的短期表单签名"""
settings = get_settings()
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
if request.file_size > max_size_bytes:
@@ -321,39 +162,8 @@ async def prepare_direct_upload(
asset_library_repository,
)
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
# ── prepare 阶段去重:OSS 签名之前先查已存在素材 ──
if request.file_hash or request.client_upload_id:
existing = _find_duplicate_asset(
asset_repository,
library_id=request.library_id,
file_hash=request.file_hash,
client_upload_id=request.client_upload_id,
filename=request.filename,
file_size=request.file_size,
)
if existing is not None:
logger.info(
"prepare 命中去重: library=%s hash=%s cuid=%s existing_asset=%s",
request.library_id,
request.file_hash,
request.client_upload_id,
existing.id,
)
return DirectUploadPrepareResponse(
upload_url="",
method="",
storage_key=existing.storage_key,
expires_at="",
fields={},
max_size_bytes=0,
duplicated=True,
skip_transfer=True,
asset_id=existing.id,
)
file_id = uuid4().hex[:8]
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
storage_key = f"uploads/{file_id}/{safe_filename}"
try:
payload = storage_service.create_direct_upload_post(
@@ -372,27 +182,6 @@ async def prepare_direct_upload(
detail=f"Failed to prepare upload: {type(error).__name__}",
) from error
# ── 预建 asset 占位:占住 file_hash/client_upload_id 闸门,避免并发重复上传 ──
pending_asset_id = ""
if request.file_hash or request.client_upload_id:
try:
pending = _create_pending_asset(
asset_repository=asset_repository,
project_id=request.project_id,
library_id=request.library_id,
storage_key=storage_key,
filename=safe_filename,
mime_type=validated_content_type,
user_id=authenticated_user.user.id,
file_hash=request.file_hash,
client_upload_id=request.client_upload_id,
file_size=request.file_size,
)
pending_asset_id = pending.id
except Exception as error:
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
return DirectUploadPrepareResponse(
upload_url=str(payload["url"]),
method=str(payload["method"]),
@@ -400,9 +189,6 @@ async def prepare_direct_upload(
expires_at=str(payload["expires_at"]),
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
max_size_bytes=max_size_bytes,
duplicated=False,
skip_transfer=False,
asset_id=pending_asset_id,
)
@@ -416,7 +202,7 @@ async def complete_direct_upload(
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务(幂等:重复 complete 返回同一素材)"""
"""确认浏览器直传完成并创建导入任务。"""
require_project_and_library(
request.project_id,
request.library_id,
@@ -426,29 +212,6 @@ async def complete_direct_upload(
normalized_key = storage_service._normalize_storage_key(request.storage_key)
if not normalized_key.startswith("uploads/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
filename = normalized_key.rsplit("/", 1)[-1]
# ── 幂等去重(放在 OSS 检查之前):complete 超时后前端重试时,
# 第一次 complete 可能已建好占位记录,此时即使 OSS 检查失败也必须返回
# 已存在记录,绝不能再建第二条。─
existing = _find_duplicate_asset(
asset_repository,
library_id=request.library_id,
file_hash=request.file_hash,
client_upload_id=request.client_upload_id,
filename=filename,
file_size=request.file_size,
)
if existing is not None:
return DirectUploadCompleteResponse(
storage_key=existing.storage_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(existing.storage_key),
)
try:
file_exists = storage_service.file_exists(normalized_key)
except Exception as error:
@@ -460,7 +223,29 @@ async def complete_direct_upload(
if not file_exists:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
if request.file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=request.library_id,
file_hash=request.file_hash,
)
if existing is not None:
logger.info(
"素材去重命中: library=%s hash=%s existing_asset=%s",
request.library_id,
request.file_hash,
existing.id,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(normalized_key),
)
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
filename = normalized_key.rsplit("/", 1)[-1]
mime_type = _infer_mime_type_from_storage_key(normalized_key)
pending_asset = _create_pending_asset(
asset_repository=asset_repository,
@@ -471,8 +256,6 @@ async def complete_direct_upload(
mime_type=mime_type,
user_id=authenticated_user.user.id,
file_hash=request.file_hash,
client_upload_id=request.client_upload_id,
file_size=request.file_size,
)
job = _submit_ingest_job(
@@ -481,7 +264,6 @@ async def complete_direct_upload(
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
asset_id=pending_asset.id,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
@@ -501,8 +283,7 @@ async def upload_asset(
project_id: str = Form(..., min_length=1, description="项目 ID"),
library_id: str = Form(..., min_length=1, description="素材库 ID"),
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
file_hash: str = Form(default="", description="文件哈希,用于去重检测"),
client_upload_id: str = Form(default="", description="客户端幂等 token(同一次上传的重试保持一致)"),
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
@@ -513,31 +294,32 @@ async def upload_asset(
"""上传素材文件并触发导入流水线。"""
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# P2-5: 服务端验证 MIME 类型(先验证,再幂等去重,避免非法类型绕过)
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
if file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=library_id,
file_hash=file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
library_id,
file_hash,
existing.id,
)
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(file.content_type)
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
# ── 幂等去重:client_upload_id → file_hash → 近期活动同名记录兜底 ──
# 放在 OSS 上传之前:重复提交直接返回,不占 OSS 流量、不建新记录。
existing = _find_duplicate_asset(
asset_repository,
library_id=library_id,
file_hash=file_hash,
client_upload_id=client_upload_id,
filename=safe_filename,
file_size=0,
)
if existing is not None:
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
file_id = uuid4().hex[:8]
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
storage_key = f"uploads/{file_id}/{safe_filename}"
try:
@@ -566,7 +348,6 @@ async def upload_asset(
mime_type=validated_content_type,
user_id=authenticated_user.user.id,
file_hash=file_hash,
client_upload_id=client_upload_id,
)
job = _submit_ingest_job(
@@ -575,7 +356,6 @@ async def upload_asset(
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
asset_id=pending_asset.id,
)
return UploadAssetResponse(
-8
View File
@@ -5,11 +5,3 @@ settings = get_settings()
celery_app = Celery("xiaoxia-saas-api")
celery_app.conf.broker_url = settings.CELERY_BROKER_URL
celery_app.conf.result_backend = settings.CELERY_RESULT_BACKEND
# #1714 队列隔离:视频生成走 generation 队列,素材转码走 transcode 队列
try:
from packages.shared.celery_queues import apply_queue_settings
apply_queue_settings(celery_app)
except Exception: # noqa: BLE001 — 队列配置失败不阻断 API 启动
pass
+3 -131
View File
@@ -8,145 +8,27 @@ logger = logging.getLogger(__name__)
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
# 限流错误码:前端据此区分"排队等待"与"创建失败"
ERROR_CODE_USER_QUEUE_FULL = "USER_QUEUE_FULL" # 429:用户自己的任务排队中
ERROR_CODE_SYSTEM_QUEUE_FULL = "SYSTEM_QUEUE_FULL" # 503:系统整体繁忙
class UserPendingLimitExceeded(Exception):
"""用户 pending 任务数超限,返回 429。"""
def __init__(
self,
user_id: str,
pending_count: int,
limit: int,
*,
running_count: int = 0,
requested_count: int = 1,
queue_ahead: int = 0,
estimated_wait_seconds: int = 0,
):
def __init__(self, user_id: str, pending_count: int, limit: int):
self.user_id = user_id
self.pending_count = pending_count
self.limit = limit
# 排队上下文(用于 429 结构化提示,前端展示"排队中"而非"创建失败"
self.running_count = running_count
self.requested_count = requested_count
self.queue_ahead = queue_ahead
self.estimated_wait_seconds = estimated_wait_seconds
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
class GlobalQueueFull(Exception):
"""全局限流,返回 503。"""
def __init__(
self,
pending_count: int,
limit: int,
*,
running_count: int = 0,
queue_ahead: int = 0,
estimated_wait_seconds: int = 0,
):
def __init__(self, pending_count: int, limit: int):
self.pending_count = pending_count
self.limit = limit
self.running_count = running_count
self.queue_ahead = queue_ahead
self.estimated_wait_seconds = estimated_wait_seconds
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
def _estimate_wait_seconds(queue_ahead: int, generation_task_repository: Any) -> int:
"""根据排队任务数 + worker 并发数 + 历史平均任务耗时估算等待秒数。
估算公式:ceil(排队任务数 / 并发数) × 平均单任务耗时。
拿不到历史数据时仓储层返回默认 120 秒。
"""
import math
if queue_ahead <= 0:
return 0
try:
estimator = getattr(generation_task_repository, "estimate_avg_duration_seconds", None)
avg_seconds = estimator() if estimator is not None else 120.0
except Exception:
avg_seconds = 120.0
return int(math.ceil(queue_ahead / WORKER_CONCURRENCY) * avg_seconds)
def build_rate_limit_detail(
exc: Exception,
generation_task_repository: Any,
*,
scope: str = "user",
) -> dict:
"""构造结构化限流响应体(HTTPException 的 detail)。
前端按 detail.code 判断场景:
- USER_QUEUE_FULL (429):用户自己的任务在排队,应提示"等待/继续排队",不是创建失败
- SYSTEM_QUEUE_FULL (503):系统繁忙,稍后重试
detail 字段:
- code: 错误码
- message: 可读中文提示(可直接展示)
- queued_count: 当前排队(pending)任务数
- running_count: 当前渲染中(running)任务数
- queue_ahead: 前方排队任务数(预计等待批次依据)
- estimated_wait_seconds: 预计等待秒数
- limit: 对应限流上限
"""
if scope == "user" and isinstance(exc, UserPendingLimitExceeded):
running = exc.running_count
if not running:
try:
counter = getattr(generation_task_repository, "count_running_by_user", None)
running = counter(exc.user_id) if counter is not None else 0
except Exception:
running = 0
queue_ahead = exc.queue_ahead or max(exc.pending_count, 0)
wait = exc.estimated_wait_seconds or _estimate_wait_seconds(queue_ahead, generation_task_repository)
wait_minutes = max(1, round(wait / 60))
message = (
f"您有 {exc.pending_count} 个任务正在排队、{running} 个正在渲染,"
f"同一时间最多提交 {exc.limit} 个任务。请等待约 {wait_minutes} 分钟后再提交"
)
return {
"code": ERROR_CODE_USER_QUEUE_FULL,
"message": message,
"queued_count": exc.pending_count,
"running_count": running,
"queue_ahead": queue_ahead,
"estimated_wait_seconds": wait,
"limit": exc.limit,
}
# 全局繁忙
pending = getattr(exc, "pending_count", 0)
running = getattr(exc, "running_count", 0)
if not running:
try:
counter = getattr(generation_task_repository, "count_running_total", None)
running = counter() if counter is not None else 0
except Exception:
running = 0
queue_ahead = getattr(exc, "queue_ahead", 0) or pending
wait = getattr(exc, "estimated_wait_seconds", 0) or _estimate_wait_seconds(queue_ahead, generation_task_repository)
wait_minutes = max(1, round(wait / 60))
return {
"code": ERROR_CODE_SYSTEM_QUEUE_FULL,
"message": f"系统繁忙:当前 {pending} 个任务排队中、{running} 个渲染中,预计等待约 {wait_minutes} 分钟,请稍后再试",
"queued_count": pending,
"running_count": running,
"queue_ahead": queue_ahead,
"estimated_wait_seconds": wait,
"limit": getattr(exc, "limit", GLOBAL_PENDING_LIMIT),
}
def check_queue_limits(
user_id: str,
generation_task_repository: Any,
@@ -279,17 +161,7 @@ def safe_enqueue_generation_task(
# ── 发送 Celery 任务 ──
try:
celery_result = celery_app.send_task("worker.generate_video", args=[task.id])
# 记录 celery 消息 ID:孤儿清理/超时作废时据此 revoke + 清除队列消息(#1714
celery_task_id = getattr(celery_result, "id", "")
if celery_task_id:
try:
task.celery_task_id = celery_task_id
generation_task_repository.update(task)
except Exception as persist_err: # noqa: BLE001
logger.warning(
"%s 持久化 celery_task_id 失败(不影响主流程): task_id=%s err=%s", log_prefix, task.id, persist_err
)
celery_app.send_task("worker.generate_video", args=[task.id])
except Exception as e:
logger.error(
"%s 入队失败,标记为失败: task_id=%s error=%s",
+10 -44
View File
@@ -36,13 +36,9 @@ class CreateGenerationTaskRequest(BaseModel):
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)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── variant-plans 轻量选片回传(#1749):正式生成直接复用,不再重选 ──
variant_plan_ids: list[str] = Field(
default_factory=list,
description="POST /generation/variant-plans 返回的各变体 plan_id(长度须=count);为空则走服务端选片",
)
# ── 标题配置(结构化)──
title_config: dict | None = Field(
default=None,
@@ -100,31 +96,11 @@ class CreateGenerationTaskRequest(BaseModel):
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
"""变体数组字段长度校验 + #1749 配音严格守卫。
- cover_urls/titles:空(回退单值)、长度 1(共用)或长度 = count(独立);
- voice_library_ids:独立配音长度必须恰好 = count 且逐项非空,禁止静默 fallback
(长度 1 的"共用"场景请用 voice_library_id 单值字段);
- variant_plan_ids:非空时长度必须 = count。
"""
for name in ("cover_urls", "titles"):
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = count(独立)。"""
for name in ("voice_library_ids", "cover_urls", "titles"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.count:
raise ValueError(f"{name} 长度必须为 1(共用)或 {self.count}(与 count 一致),当前为 {len(arr)}")
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
try:
resolve_variant_voice_ids(
count=self.count,
voice_library_id=self.voice_library_id,
voice_library_ids=self.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise ValueError(str(exc)) from exc
if self.variant_plan_ids and len(self.variant_plan_ids) != self.count:
raise ValueError(f"variant_plan_ids 长度({len(self.variant_plan_ids)})必须与 count({self.count})一致")
return self
@model_validator(mode="after")
@@ -134,9 +110,9 @@ class CreateGenerationTaskRequest(BaseModel):
if not has_project and not has_template:
raise ValueError("project_id 或 template_id 至少需要提供一个")
has_library = bool(self.asset_library_id.strip())
has_assets = bool(self.asset_ids or self.title_ids)
has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids)
if not has_library and not has_assets:
raise ValueError("asset_library_id 或 asset_ids/title_ids 至少需要提供一个")
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
return self
@@ -213,6 +189,7 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
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配音),对应配音选择页面选择的配音素材"
)
@@ -254,24 +231,13 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest":
"""变体数组字段长度校验 + #1749 配音严格守卫"""
for name in ("titles", "cover_urls"):
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = preview_count(独立)"""
for name in ("titles", "voice_library_ids", "cover_urls"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.preview_count:
raise ValueError(
f"{name} 长度必须为 1(共用)或 {self.preview_count}(与 preview_count 一致),当前为 {len(arr)}"
)
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
try:
resolve_variant_voice_ids(
count=self.preview_count,
voice_library_id=self.voice_library_id,
voice_library_ids=self.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise ValueError(str(exc)) from exc
return self
@model_validator(mode="after")
@@ -282,8 +248,8 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
@model_validator(mode="after")
def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest":
if not self.asset_ids and not self.title_ids:
raise ValueError("asset_ids/title_ids 至少需要提供一个")
if not self.asset_ids and not self.title_ids and not self.voice_ids:
raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个")
return self
+5 -11
View File
@@ -16,7 +16,6 @@ class DirectUploadPrepareRequest(BaseModel):
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
file_size: int = Field(..., gt=0)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
class DirectUploadPrepareResponse(BaseModel):
@@ -26,25 +25,20 @@ class DirectUploadPrepareResponse(BaseModel):
expires_at: str
fields: dict[str, str]
max_size_bytes: int
duplicated: bool = False
skip_transfer: bool = False
asset_id: str = ""
class DirectUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1)
storage_key: str = Field(..., min_length=1, max_length=255)
file_hash: str = Field(default="", max_length=64, description="文件哈希,用于去重检测")
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
file_size: int = Field(default=0, ge=0, description="文件大小(字节),用于无 hash 时的兜底去重")
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadCompleteResponse(BaseModel):
storage_key: str
ingest_job_id: str
duplicated: bool = Field(default=False, description="是否为重复素材/重复 complete(命中幂等去重)")
asset_id: str = Field(default="", description="素材 asset_id重复 complete 时返回已存在记录")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材 asset_idduplicated=true 时返回")
url: str = Field(default="", description="Public URL of uploaded file")
@@ -52,5 +46,5 @@ class UploadAssetResponse(BaseModel):
storage_key: str
ingest_job_id: str
url: str = Field(..., description="Public URL of uploaded file")
duplicated: bool = Field(default=False, description="是否为重复素材/重复提交(命中幂等去重)")
asset_id: str = Field(default="", description="素材 asset_id重复提交时返回已存在记录")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材 asset_idduplicated=true 时返回")
-395
View File
@@ -409,13 +409,8 @@ class EditPlanService:
clip_type=clip_item.get("clip_type", "main"),
order=order,
asset_id=clip_item.get("asset_id", ""),
text_content=clip_item.get("text_content", ""),
start_time=clip_item.get("start_time", 0.0),
duration=clip_item.get("duration", 0.0),
transition_effect=clip_item.get("transition_effect", "cut"),
transition_duration=clip_item.get("transition_duration", 0.0),
playback_speed=clip_item.get("playback_speed", 1.0),
config=clip_item.get("config") or None,
)
model = EditPlanClipModel(
id=clip.id,
@@ -464,159 +459,6 @@ class EditPlanService:
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
raise
def reselect_plan_for_variant(
self,
source_plan_id: str,
candidate_asset_ids: list[str],
*,
created_by_user_id: str = "",
name_suffix: str = "变体",
voice_duration: float = 0.0,
rng=None,
) -> EditPlan:
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,本方法:
- 源 plan 片段骨架(clip_type/order/duration/文案/转场)保留;
- 素材池 shuffle 随机分配 + main 片段顺序洗牌;
- 起点走场景镜头洗牌/随机起点/历史区间避让(与单视频同一入口);
- 批次内同素材区间重叠 >20% 自动重选起点;
- 新片段区间 record_used_segments 写回素材 metadata(跨变体/跨任务避让)。
Args:
source_plan_id: 源 plan(任务 0 / 预览源)。
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次素材)。
created_by_user_id: 新 plan 归属用户。
name_suffix: plan 名后缀。
rng: 可选随机数(测试注入种子)。
Raises:
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
from packages.domain.variant_plan_selector import reselect_clips_for_variant
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
raise ValueError(f"源 plan 无片段,无法生成变体: {source_plan_id}")
source_clips_data: list[dict[str, Any]] = [
{
"order": c.order if c.order is not None else i,
"asset_id": c.asset_id,
"start_time": float(c.start_time or 0.0),
"duration": float(c.duration or 0.0),
"clip_type": c.clip_type,
"playback_speed": float(c.playback_speed or 1.0),
"transition_effect": c.transition_effect,
"transition_duration": float(c.transition_duration or 0.0),
"text_content": c.text_content or "",
"config": c.config or {},
}
for i, c in enumerate(clips)
]
db = self._clip_repo.session
# #1749:配音时长 → 每段目标段长(片段数=模板片段数定死;素材不足由渲染末帧冻结铺满)
target_durations: list[float] | None = None
try:
voice = float(voice_duration or 0.0)
except (TypeError, ValueError):
voice = 0.0
if voice > 0 and source_clips_data:
from packages.domain.voice_duration_planner import plan_clip_durations
_effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data]
_tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data]
target_durations = plan_clip_durations(
len(source_clips_data),
voice,
transition_effects=_effects,
transition_durations=_tdurs,
)
if target_durations:
for _c, _d in zip(source_clips_data, target_durations, strict=False):
_c["duration"] = _d
# 素材池 = 源 plan 素材 ∪ 调用方传入素材(去重保序)
pool_ids: list[str] = []
seen = set()
for aid in [c.asset_id for c in clips if c.asset_id] + list(candidate_asset_ids or []):
if aid and aid not in seen:
seen.add(aid)
pool_ids.append(aid)
# 时长 + 场景点
durations: dict[str, float] = {}
scene_points: dict[str, list[float]] = {}
if pool_ids:
for m in db.query(AssetModel).filter(AssetModel.id.in_(pool_ids)).all():
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
pts = extract_scene_points_from_metadata(getattr(m, "metadata", None))
if pts:
scene_points[m.id] = pts
historical = get_used_segments(db, pool_ids)
# 创建新 plan(复制模板归属与 config
new_plan = self.create_plan(
template_id=source.template_id,
name=f"{source.name or '剪辑计划'} · {name_suffix}",
config=dict(source.config or {}),
total_duration=source.total_duration,
project_id=source.project_id or "",
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
)
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
batch_segments: dict[str, list[tuple[float, float]]] = {}
for c in clips:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
clips_data = reselect_clips_for_variant(
source_clips_data,
pool_ids,
asset_durations=durations,
asset_scene_points=scene_points,
historical_used_segments=historical,
batch_segments=batch_segments,
target_durations=target_durations,
rng=rng,
)
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit
for item in clips_data:
aid = item.get("asset_id", "")
if aid:
st = float(item.get("start_time", 0.0))
record_used_segments(db, aid, st, st + float(item.get("duration", 0.0)), new_plan.id)
self.replace_all_clips_transactional(new_plan.id, clips_data)
logger.info(
"变体独立选片完成: source=%s new=%s clips=%d assets=%d",
source_plan_id,
new_plan.id,
len(clips_data),
len(pool_ids),
)
return new_plan
def clone_plan_for_variant(
self,
source_plan_id: str,
@@ -727,243 +569,6 @@ class EditPlanService:
)
return new_plan
# ── #1749 配音时长分配 / 素材时长查询 / 批量变体 plan 确保 ──────────────
def get_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
"""批量查询素材时长(秒),O(N) 单查;缺失/异常返回 0.0。"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
ids = [a for a in dict.fromkeys(asset_ids or []) if a]
if not ids:
return {}
db = self._clip_repo.session
out: dict[str, float] = {}
for m in db.query(AssetModel).filter(AssetModel.id.in_(ids)).all():
try:
out[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
except (TypeError, ValueError):
out[m.id] = 0.0
return out
def apply_voice_duration_to_plan(self, plan_id: str, voice_duration: float) -> Optional[EditPlan]:
"""把配音时长分配到 plan 的每段(#1749)。
- 片段数保持不变(= 模板片段数,定死);
- 每段 duration 按 voice_duration_planner 分配(含转场重叠扣减);
- 素材短于段长 → start_time 钳制为 0(末帧冻结由渲染侧 tpad/apad 铺满);
- plan.total_duration 回写为成片净时长(≈ 配音时长);
- 幂等:配音时长相同则分配结果不变,可重复调用。
无配音(<=0)或无片段时直接返回 None,不报错。
"""
try:
voice = float(voice_duration or 0.0)
except (TypeError, ValueError):
return None
if voice <= 0:
return None
plan = self.get_plan(plan_id)
if plan is None:
return None
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
return None
clips.sort(key=lambda c: (c.order if c.order is not None else 0))
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
# #1764:从 plan config 读取节奏模板
rhythm_template = None
if plan and hasattr(plan, "config") and plan.config:
rhythm_template = plan.config.get("rhythm_template")
target = plan_clip_durations(
len(clips),
voice,
transition_effects=[c.transition_effect for c in clips],
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
rhythm_template=rhythm_template,
)
if not target:
return None
# 素材时长(短素材起点钳 0
asset_ids = [c.asset_id for c in clips if c.asset_id]
durations = self.get_asset_durations(asset_ids)
clips_data: list[dict] = []
for i, c in enumerate(clips):
dur = float(target[i])
total = durations.get(c.asset_id, 0.0)
start = float(c.start_time or 0.0)
if c.asset_id and total > 0:
# 素材短于段长:起点钳 0,段长超出部分渲染侧末帧冻结
max_start = max(0.0, total - min(dur, total))
start = min(start, max_start)
clips_data.append(
{
"order": c.order if c.order is not None else i,
"asset_id": c.asset_id or "",
"start_time": round(start, 3),
"duration": dur,
"clip_type": c.clip_type,
"playback_speed": float(c.playback_speed or 1.0),
"transition_effect": c.transition_effect,
"transition_duration": float(c.transition_duration or 0.0),
"text_content": c.text_content or "",
"config": c.config or {},
}
)
self.replace_all_clips_transactional(plan_id, clips_data)
net = total_output_duration(
target,
transition_effects=[c.transition_effect for c in clips],
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
)
try:
plan.total_duration = net
db = self._clip_repo.session
db.commit()
except Exception:
db.rollback()
logger.exception("回写 plan.total_duration 失败(不阻断): plan_id=%s", plan_id)
logger.info(
"配音时长分配完成: plan=%s clips=%d voice=%.2fs 成片净时长=%.2fs",
plan_id,
len(clips),
voice,
net,
)
return plan
def ensure_variant_plans(
self,
source_plan_id: str,
count: int,
candidate_asset_ids: list[str],
*,
created_by_user_id: str = "",
voice_durations: Optional[list[float]] = None,
rng=None,
) -> list[str]:
"""确保批量 N 个变体各自拥有独立 plan(#1749 批量正式生成/预览共用)。
- 变体 0clone 源 plan(不污染源 plan,片段独立可改),并按配音分配段长;
- 变体 1..N-1reselect_plan_for_variant 完整重跑选片(素材级去重);
- voice_durations:每个变体的配音时长(独立配音各自时长;统一配音同值);
缺省/为 0 时不分配(段长保持骨架/模板值)。
Returns:
plan_id 列表,长度 == countindex 即 variant_index。
"""
import random as _random
rng = rng or _random.Random()
plan_ids: list[str] = []
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
plan0 = self.clone_plan_for_variant(
source_plan_id,
created_by_user_id=created_by_user_id,
name_suffix="变体1",
)
v0_voice = 0.0
if voice_durations and len(voice_durations) > 0:
try:
v0_voice = float(voice_durations[0] or 0.0)
except (TypeError, ValueError):
v0_voice = 0.0
if v0_voice > 0:
try:
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
except Exception:
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
plan_ids.append(plan0.id)
# 变体 1..N-1:独立选片
for i in range(1, count):
voice = 0.0
if voice_durations and i < len(voice_durations):
try:
voice = float(voice_durations[i] or 0.0)
except (TypeError, ValueError):
voice = 0.0
variant = self.reselect_plan_for_variant(
source_plan_id,
candidate_asset_ids,
created_by_user_id=created_by_user_id,
name_suffix=f"变体{i + 1}",
voice_duration=voice,
rng=rng,
)
plan_ids.append(variant.id)
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
clip_count = 0
if voice_durations and len(voice_durations) > 0:
# 从源 plan 获取片段数
source_plan = self.get_plan(source_plan_id)
if source_plan and hasattr(source_plan, "clips"):
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
rhythm_templates_for_variants = []
if clip_count > 0:
for idx in range(len(plan_ids)):
# 每个变体用不同的 seed 选择节奏模板
variant_seed = rng.randint(0, 999999)
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
rhythm_templates_for_variants.append(template)
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
from packages.domain.variant_plan_selector import generate_visual_perturbation
for idx, pid in enumerate(plan_ids):
try:
perturbation = generate_visual_perturbation(rng)
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
if idx == 0:
perturbation["hflip"] = False
config_update = {"visual_perturbation": perturbation}
# #1764:写入节奏模板
if idx < len(rhythm_templates_for_variants):
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
# #1765:写入像素级扰动滤镜
from packages.domain.variant_plan_selector import generate_pixel_perturbation
pixel_pert = generate_pixel_perturbation(rng)
config_update["pixel_perturbation"] = pixel_pert
self.update_plan_config(pid, config_update)
logger.info("变体 %d 视觉扰动+像素扰动: plan=%s vis=%s pix=%s", idx, pid, perturbation, pixel_pert)
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
for pid in plan_ids:
try:
self.mark_clips_ready(pid)
except Exception:
logger.exception("标记 clips ready 失败(不阻断): plan=%s", pid)
return plan_ids
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
+1 -65
View File
@@ -34,17 +34,6 @@ from packages.domain.template_clip_converter import (
logger = logging.getLogger(__name__)
class TemplateNotFoundError(Exception):
"""模板不存在、已删除或当前用户无权访问.
"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
"""
def __init__(self, template_id: str) -> None:
self.template_id = template_id
super().__init__(f"模板不存在: {template_id}")
class EditTemplateService:
"""模板管理服务
@@ -228,14 +217,7 @@ class EditTemplateService:
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""列出模板的片段配置
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
"""
"""列出模板的片段配置"""
# 确保模板存在
self.get_template_or_raise(template_id)
return self._clip_config_repo.list_by_template(
@@ -245,52 +227,6 @@ class EditTemplateService:
limit=limit,
)
def list_clip_configs_for_editor(
self,
template_id: str,
user_id: str,
*,
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""编辑器读取模板片段配置的单一数据源入口.
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
模板主表按双表现状显式判定,不使用 try/except 控制流:
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于旧表模板归属校验)
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
"""
# 1) 用户自建模板(旧表 templates,归属 user_id
if self._clip_config_repo.template_owned_by(template_id, user_id):
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
if self._template_repo.get(template_id) is not None:
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 3) 两表都没有:不存在 / 已删除 / 无权限
raise TemplateNotFoundError(template_id)
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
"""获取片段配置详情"""
return self._clip_config_repo.get(config_id)
+24 -18
View File
@@ -185,13 +185,6 @@ test.describe("Core generation flow", () => {
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click()
// Step1 下一步弹出数量选择弹窗(Issue #1677 固定6步:模板→素材→配音→标题→确认生成→封面)
// 单视频流程:默认 1 个,点击「生成 1 个视频」进入步骤2
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
timeout: 10_000,
})
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// Step 2: select material (card grid UI)
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
@@ -262,19 +255,32 @@ test.describe("Core generation flow", () => {
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
// 单视频(N=1):点击「确认生成视频」后跳 Step 5确认生成,展示实时渲染进度
await expect(page.getByRole("heading", { name: "🎬 确认生成" })).toBeVisible({
timeout: 30_000,
// Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({
timeout: 15_000,
})
// 等待渲染完成:进度卡变为「视频生成完成」(最长等待 3 分钟)
await expect(page.getByText("视频生成完成")).toBeVisible({ timeout: 180_000 })
// 全部完成后「下一步:选择封面」解锁,点击进入 Step 6
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
// Step 5 → Step 6:等待渲染终态
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
const renderSucceeded = await page
.getByText("视频生成完成", { exact: false })
.waitFor({ timeout: 180_000 })
.then(() => true)
.catch(() => false)
if (renderSucceeded) {
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 15_000,
})
} else {
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示
-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#3b82f6"/>
<text x="32" y="44" font-size="34" text-anchor="middle">🦐</text>
</svg>

Before

Width:  |  Height:  |  Size: 194 B

+4 -16
View File
@@ -5,22 +5,10 @@ import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetLibraryItem } from "./types"
/**
* 获取当前用户的素材库
*
* @param kind 可选,按素材库类型过滤(video/voice/image)。
* 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的
* kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库
* 混进来(#1777:视频选择器只展示视频库)。
*/
export const getAssetLibraries = async (
kind?: AssetLibraryItem["kind"],
): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", {
params: kind ? { kind } : undefined,
})
const items = response.data.items || []
return kind ? items.filter((lib) => lib.kind === kind) : items
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries")
return response.data.items || []
}
/** 创建素材库(自动获取或创建默认项目以提供 project_id */
-11
View File
@@ -139,17 +139,6 @@ export interface DirectUploadPrepareResult {
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
*/
asset_id?: string
/**
* 后端 file_hash 命中素材库已有相同文件时为 true,前端应跳过 transfer + complete 阶段
* 直接按「去重命中」处理(不调 transfer、不调 complete、立即刷新素材列表)。
* 旧后端不返回该字段,前端降级为走老流程。
*/
duplicated?: boolean
/**
* 与 duplicated 语义一致:true 表示跳过传输,前端据此短路。
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
*/
skip_transfer?: boolean
}
/** 直传完成确认返回 */
+4 -58
View File
@@ -4,7 +4,6 @@
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
import { computeFileHash, makeClientUploadId } from "./uploadDedup"
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
@@ -13,13 +12,8 @@ export const prepareDirectUpload = async (data: {
filename: string
content_type: string
file_size: number
/** 前端算好的文件内容哈希(SHA-256 hex),打开后端 file_hash 去重闸门 */
file_hash?: string
/** 前端生成的上传幂等 token,同一次逻辑上传(含重试)保持不变 */
client_upload_id?: string
}): Promise<DirectUploadPrepareResult> => {
// prepare 单独放宽到 30s(全局 axios 实例只有 10sstaging 抖动时易超时)
const response = await apiClient.post("/upload/direct/prepare", data, { timeout: 30_000 })
const response = await apiClient.post("/upload/direct/prepare", data)
return response.data
}
@@ -28,16 +22,8 @@ export const completeDirectUpload = async (data: {
project_id: string
library_id: string
storage_key: string
/** 前端算好的文件内容哈希(与 prepare 一致),后端按 hash 幂等去重 */
file_hash?: string
/** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */
client_upload_id?: string
/** 文件字节数;后端同名兜底去重需用它做大小校验,缺失(=0)时同名记录一律不判重 */
file_size?: number
}): Promise<DirectUploadCompleteResult> => {
// complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s;
// 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件
const response = await apiClient.post("/upload/direct/complete", data, { timeout: 60_000 })
const response = await apiClient.post("/upload/direct/complete", data)
return response.data
}
@@ -123,20 +109,8 @@ export interface DirectUploadHandle {
export const prepareDirectUploadHandle = async (data: {
file: File
library_id: string
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
fileHash?: string
/** 本次逻辑上传的幂等 tokenprepare/complete 一致、重试复用 */
clientUploadId?: string
}): Promise<DirectUploadHandle> => {
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
// 不与 prepare 的签名接口错误混在一起
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
try {
project = await getOrCreateDefaultProject()
} catch (err) {
const reason = err instanceof Error ? err.message : "网络异常"
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
}
const project = await getOrCreateDefaultProject()
const prepared = await prepareDirectUpload({
project_id: project.id,
@@ -144,8 +118,6 @@ export const prepareDirectUploadHandle = async (data: {
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
})
return {
@@ -156,10 +128,6 @@ export const prepareDirectUploadHandle = async (data: {
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
// 透传文件字节数:后端同名兜底去重依赖大小校验,缺省会导致同名新视频被误判重复
file_size: data.file.size,
}),
}
}
@@ -169,30 +137,8 @@ export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
fileHash?: string
/** 幂等 token;未传时自动生成 */
clientUploadId?: string
}): Promise<DirectUploadCompleteResult> => {
// 自动补算哈希与幂等 token:确保 file_hash 去重闸门对所有上传链路生效
const fileHash = data.fileHash ?? (await computeFileHash(data.file))
const clientUploadId = data.clientUploadId ?? makeClientUploadId()
const handle = await prepareDirectUploadHandle({
file: data.file,
library_id: data.library_id,
fileHash,
clientUploadId,
})
// prepare 阶段后端 file_hash 命中素材库已有相同文件:跳过 transfer + complete
if (handle.prepared.skip_transfer || handle.prepared.duplicated) {
return {
storage_key: handle.prepared.storage_key,
ingest_job_id: "",
url: "",
duplicated: true,
asset_id: handle.prepared.asset_id,
}
}
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
await handle.transfer(data.onProgress)
return handle.complete()
}
-143
View File
@@ -1,143 +0,0 @@
/**
* 上传去重 / 幂等工具(Issue #1714
*
* 背景:同一文件被反复入队、complete 超时后盲目重传,导致后端创建大量重复
* PROCESSING 素材记录。本模块提供两类纯函数:
*
* 1. 文件指纹:
* - makeFileFingerprint():文件名+大小+lastModified,入队去重用(同步、零开销)
* - computeFileHash()SHA-256 内容哈希(小文件全量、大文件抽样头尾),
* prepare/complete 时发给后端打开 file_hash 去重闸门
* 2. 队列去重:findDuplicateInQueue() 判断文件是否已在队列中
* 3. 幂等 tokenmakeClientUploadId() 生成上传幂等 ID(每次"一次逻辑上传"一个,
* 重试复用同一 ID,重新入队才生成新 ID)
*/
/** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */
export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB
/** 抽样读取的头尾片段大小(各 16MB) */
export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024
/** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */
export type DedupExcludeStatus = "error" | "done"
/**
* 文件入队指纹:同库 + 文件名 + 大小 + 修改时间。
* 同一文件(File 对象由 <input> 重选或拖拽重复触发时三个字段均一致)稳定复现;
* 不同文件极小概率碰撞时可由后端 file_hash 内容去重兜底。
*/
export function makeFileFingerprint(file: Pick<File, "name" | "size" | "lastModified">): string {
return `${file.name}::${file.size}::${file.lastModified}`
}
/**
* 在现有队列项中查找同一文件的在途记录。
* 已失败(error)的项允许重试路径复用、已完成(done)的可跳过;
* 处于 preparing/uploading/ingesting 的在途项一律视为重复,禁止重复入队。
*
* 返回命中的队列项 id(tempId),未命中返回 null。
*/
export function findDuplicateInQueue<T extends { fileKey: string; status: string }>(
queue: T[],
fileKey: string,
excludeStatuses: DedupExcludeStatus[] = [],
): T | null {
const exclude = new Set<string>(excludeStatuses)
return queue.find((it) => it.fileKey === fileKey && !exclude.has(it.status)) ?? null
}
/** 生成上传幂等 token:一次"逻辑上传"一个,重试复用、重新入队换新 */
export function makeClientUploadId(): string {
const rand =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${Math.random()
.toString(36)
.slice(2, 10)}`
return `up_${Date.now().toString(36)}_${rand.replace(/-/g, "").slice(0, 16)}`
}
/** 读取 Blob/File 片段为 ArrayBuffer:优先 Blob.arrayBuffer(),老环境回退 FileReader */
function readAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
if (typeof blob.arrayBuffer === "function") {
return blob.arrayBuffer()
}
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(reader.error ?? new Error("FileReader read failed"))
reader.readAsArrayBuffer(blob)
})
}
/**
* 把 buffer 复制到当前 JS realm 的 Uint8Array 再哈希。
* jsdom/测试环境中 Blob.arrayBuffer() 可能返回另一 realm 的 ArrayBuffer
* Node WebCrypto 的 WebIDL instanceof 校验会拒绝跨 realm 参数。
*/
async function digestSha256(buffer: ArrayBuffer): Promise<ArrayBuffer> {
const subtle =
typeof globalThis !== "undefined" && globalThis.crypto ? globalThis.crypto.subtle : null
if (!subtle) throw new Error("crypto.subtle unavailable")
const local = new Uint8Array(buffer.byteLength)
local.set(new Uint8Array(buffer))
return subtle.digest("SHA-256", local)
}
function toHex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}
/**
* 计算文件内容 SHA-256hex64 字符,与后端 file_hash 字段长度一致)。
* - ≤64MB:全量哈希,内容一致必然一致
* - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、
* 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略,
* 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃
*
* 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串,
* 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。
*/
export async function computeFileHash(file: File): Promise<string> {
try {
const subtle =
typeof globalThis !== "undefined" &&
globalThis.crypto &&
typeof globalThis.crypto.subtle?.digest === "function"
? globalThis.crypto.subtle
: null
if (!subtle) return ""
if (file.size <= HASH_FULL_READ_LIMIT) {
const data = await readAsArrayBuffer(file.slice(0, file.size))
return toHex(await digestSha256(data))
}
// 大文件:头 8MB + 尾 8MB + 大小,拼成一段后哈希
const head = await readAsArrayBuffer(file.slice(0, HASH_SAMPLE_CHUNK))
const tail =
file.size > HASH_SAMPLE_CHUNK
? await readAsArrayBuffer(file.slice(Math.max(0, file.size - HASH_SAMPLE_CHUNK), file.size))
: new ArrayBuffer(0)
const merged = new Uint8Array(head.byteLength + tail.byteLength + 8)
merged.set(new Uint8Array(head), 0)
merged.set(new Uint8Array(tail), head.byteLength)
const sizeView = new DataView(merged.buffer, head.byteLength + tail.byteLength, 8)
// 文件大小以 64 位大端写入(BigInt 最稳;不支持 BigInt64 时手算高低位)
if (typeof sizeView.setBigUint64 === "function") {
sizeView.setBigUint64(0, BigInt(file.size), false)
} else {
sizeView.setUint32(0, Math.floor(file.size / 0x100000000), false)
sizeView.setUint32(4, file.size >>> 0, false)
}
return toHex(await digestSha256(merged.buffer))
} catch (err) {
console.warn("[uploadDedup] 计算文件哈希失败,降级为不传 file_hash:", err)
return ""
}
}
+3 -14
View File
@@ -12,18 +12,13 @@ export type {
UserResponse,
WechatAuthUrlResponse,
WechatCallbackResponse,
WechatBindUrlResponse,
WechatBindCompleteResponse,
WechatUnbindResponse,
UpdateProfileRequest,
UpdateProfileResponse,
SendVerificationCodeRequest,
BindContactRequest,
BindContactResponse,
} from "./types"
// 用户工具函数
export { normalizeUser, updateProfile } from "./user"
export { normalizeUser } from "./user"
// 登录/注册/登出/刷新
export { login, refreshAccessToken, register, logout } from "./login"
@@ -37,14 +32,8 @@ export { requestPasswordReset, resetPassword } from "./password"
// 邮箱验证
export { verifyEmail } from "./email"
// 微信登录 / 绑定
export {
getWechatAuthUrl,
wechatCallback,
getWechatBindUrl,
bindWechat,
unbindWechat,
} from "./wechat"
// 微信登录
export { getWechatAuthUrl, wechatCallback } from "./wechat"
// 联系方式
export { sendVerificationCode, bindContact } from "./contact"
-44
View File
@@ -34,17 +34,6 @@ export interface User {
is_email_verified: boolean
email_verified: boolean
created_at?: string
/** 微信是否已绑定 */
wechat_bound?: boolean
/** 微信昵称(绑定后展示) */
wechat_nickname?: string
/** 头像 URL(微信头像等) */
avatar_url?: string
/** 手机号 */
phone?: string
phone_verified?: boolean
/** 资料是否完善(微信新用户首次登录为 false,需填昵称引导) */
profile_completed?: boolean
}
export interface UserResponse {
@@ -56,12 +45,6 @@ export interface UserResponse {
is_email_verified?: boolean
email_verified?: boolean
created_at?: string
wechat_bound?: boolean
wechat_nickname?: string
avatar_url?: string
phone?: string
phone_verified?: boolean
profile_completed?: boolean
}
export interface WechatAuthUrlResponse {
@@ -97,30 +80,3 @@ export interface BindContactResponse {
success: boolean
user: User
}
/** 更新个人资料请求 */
export interface UpdateProfileRequest {
display_name?: string
}
/** 更新个人资料响应(返回最新用户信息) */
export interface UpdateProfileResponse {
user: UserResponse
}
/** 微信绑定授权链接响应 */
export interface WechatBindUrlResponse {
auth_url: string
state: string
}
/** 微信绑定完成响应 */
export interface WechatBindCompleteResponse {
success: boolean
user: UserResponse
}
/** 微信解绑响应 */
export interface WechatUnbindResponse {
success: boolean
}
+1 -16
View File
@@ -1,5 +1,4 @@
import apiClient from "../client"
import type { User, UserResponse, UpdateProfileRequest, UpdateProfileResponse } from "./types"
import type { User, UserResponse } from "./types"
/**
* 规范化用户数据,兼容不同后端返回格式
@@ -17,19 +16,5 @@ export const normalizeUser = (data: UserResponse): User => {
is_email_verified: emailVerified,
email_verified: emailVerified,
created_at: data.created_at,
wechat_bound: data.wechat_bound,
wechat_nickname: data.wechat_nickname,
avatar_url: data.avatar_url,
phone: data.phone,
phone_verified: data.phone_verified,
profile_completed: data.profile_completed,
}
}
/**
* 更新个人资料(昵称等)
*/
export const updateProfile = async (data: UpdateProfileRequest): Promise<User> => {
const response = await apiClient.patch<UpdateProfileResponse>("/auth/me", data)
return normalizeUser(response.data.user)
}
+2 -35
View File
@@ -1,14 +1,8 @@
import apiClient from "../client"
import type {
WechatAuthUrlResponse,
WechatCallbackResponse,
WechatBindUrlResponse,
WechatBindCompleteResponse,
WechatUnbindResponse,
} from "./types"
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
/**
* 获取微信授权链接(登录场景)
* 获取微信授权链接
*/
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
@@ -25,30 +19,3 @@ export const wechatCallback = async (
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
/**
* 获取微信绑定授权链接(已登录用户绑定场景)
*/
export const getWechatBindUrl = async (): Promise<WechatBindUrlResponse> => {
const response = await apiClient.get("/auth/wechat/bind/url")
return response.data
}
/**
* 微信绑定完成(扫码回调后用 code 绑定到当前登录账号)
*/
export const bindWechat = async (
code: string,
state: string,
): Promise<WechatBindCompleteResponse> => {
const response = await apiClient.post("/auth/wechat/bind", { code, state })
return response.data
}
/**
* 解绑微信
*/
export const unbindWechat = async (): Promise<WechatUnbindResponse> => {
const response = await apiClient.delete("/auth/wechat/bind")
return response.data
}
-112
View File
@@ -1,112 +0,0 @@
/**
* 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析
*
* 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
* 后挂载全局 window.WxLoginnew WxLogin({...}) 会在指定容器内渲染二维码 iframe。
* 本模块负责:动态加载该脚本(带超时/失败检测)、从后端返回的 auth_url 中解析
* WxLogin 所需的 appid / redirect_uri / state。
*/
const WX_LOGIN_SRC = "https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"
/** 脚本加载超时(毫秒):超时视为加载失败,调用方回退整页跳转 */
const WX_LOGIN_LOAD_TIMEOUT = 8000
/** WxLogin 构造参数(微信官方字段,保持原名) */
export interface WxLoginOptions {
/** 是否内嵌二维码(回调在 iframe 内完成) */
self_redirect: boolean
/** 二维码容器元素 id */
id: string
/** 微信开放平台 AppID */
appid: string
/** 应用授权作用域,网站应用固定 snsapi_login */
scope: "snsapi_login"
/** 回调地址(需与微信开放平台配置一致,WxLogin 内部会 encodeURIComponent */
redirect_uri: string
/** 防 CSRF 随机串,由后端 state store 生成并在回调时一次性消费 */
state: string
/** 二维码样式:black / white */
style?: "black" | "white"
/** 自定义样式链接(可选) */
href?: string
}
/** 微信脚本挂载到 window 上的全局构造函数类型 */
export interface WxLoginConstructor {
new (options: WxLoginOptions): unknown
}
declare global {
interface Window {
WxLogin?: WxLoginConstructor
}
}
let loadPromise: Promise<WxLoginConstructor> | null = null
/**
* 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。
* 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。
*/
export function loadWxLoginScript(): Promise<WxLoginConstructor> {
if (window.WxLogin) return Promise.resolve(window.WxLogin)
if (loadPromise) return loadPromise
loadPromise = new Promise<WxLoginConstructor>((resolve, reject) => {
const script = document.createElement("script")
script.src = WX_LOGIN_SRC
script.async = true
script.onload = () => {
if (window.WxLogin) {
resolve(window.WxLogin)
} else {
loadPromise = null
reject(new Error("微信登录脚本加载完成但 WxLogin 未挂载"))
}
}
script.onerror = () => {
loadPromise = null
script.remove()
reject(new Error("微信登录脚本加载失败"))
}
document.head.appendChild(script)
// 超时兜底:部分网络环境下脚本既不 onload 也不 onerror
window.setTimeout(() => {
if (window.WxLogin) {
resolve(window.WxLogin)
return
}
loadPromise = null
script.remove()
reject(new Error("微信登录脚本加载超时"))
}, WX_LOGIN_LOAD_TIMEOUT)
})
return loadPromise
}
/** 从微信授权链接 query 中解析出的 WxLogin 所需参数 */
export interface ParsedWxAuthParams {
appid: string
/** 已 URL 解码的回调地址(传给 WxLogin 时由其内部再次编码) */
redirect_uri: string
state: string
}
/**
* 从后端返回的微信授权链接(https://open.weixin.qq.com/connect/qrconnect?appid=...&redirect_uri=...&state=...
* 中解析 appid / redirect_uri / state。解析失败时返回 null,由调用方回退整页跳转。
*/
export function parseWxAuthUrl(authUrl: string, stateFallback?: string): ParsedWxAuthParams | null {
try {
const url = new URL(authUrl)
const appid = url.searchParams.get("appid")
const redirectUri = url.searchParams.get("redirect_uri")
const state = url.searchParams.get("state") || stateFallback || ""
if (!appid || !redirectUri || !state) return null
return { appid, redirect_uri: redirectUri, state }
} catch {
return null
}
}
-13
View File
@@ -55,19 +55,6 @@ apiClient.interceptors.response.use(
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
/**
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
*/
_silentErrorToast?: boolean
}
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
if (originalRequest?._silentErrorToast) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(error as any).__msgShown = true
return Promise.reject(error)
}
// 401 → 尝试刷新 Token
@@ -12,25 +12,17 @@ import type {
ListCategoriesResponse,
} from "./types"
/** 获取模板列表
*
* valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用,
* 避免选中无片段配置的模板导致 from-assets 400#1769/#1772);
* 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。
* 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。
*/
/** 获取模板列表 */
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
validOnly?: boolean
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
...(params?.validOnly ? { valid_only: true } : {}),
},
})
let list = response.data.items
+4 -8
View File
@@ -44,10 +44,8 @@ export interface BgmConfig {
export interface TemplateSegment {
id?: string
segment_order: number
/** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取,新模板可不传 */
duration_min?: number
/** @deprecated 同上 */
duration_max?: number
duration_min: number
duration_max: number
material_type: string | null
}
@@ -61,8 +59,7 @@ export interface EditingTemplate {
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
/** @deprecated 模板无时长概念(#1750 基线):成片时长由配音时长决定;字段保留兼容旧数据 */
estimated_duration?: number
estimated_duration: number
segments: TemplateSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
@@ -92,8 +89,7 @@ export interface SaveTemplatePayload {
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
/** @deprecated 模板无时长概念(#1750 基线):保留兼容旧数据 */
estimated_duration?: number
estimated_duration: number
segments: Omit<TemplateSegment, "id">[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
-132
View File
@@ -1,132 +0,0 @@
/**
* 统一错误信息提取
* 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、
* 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。
*
* 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast,
* 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。
*/
import type { AxiosError } from "axios"
/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg */
interface ErrorBody {
detail?: unknown
message?: unknown
msg?: unknown
}
/** FastAPI 422 校验错误单项 */
interface ValidationItem {
loc?: (string | number)[]
msg?: string
}
/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */
function extractBodyMessage(data: unknown): string {
if (!data || typeof data !== "object") return ""
const body = data as ErrorBody
const walk = (val: unknown): string => {
if (typeof val === "string") return val
if (Array.isArray(val)) {
// FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接
const parts = val
.map((item) => {
if (typeof item === "string") return item
if (item && typeof item === "object") {
const v = item as ValidationItem
if (typeof v.msg === "string") {
const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : ""
return field ? `${field}: ${v.msg}` : v.msg
}
return walk(item)
}
return ""
})
.filter(Boolean)
return parts.join("")
}
if (val && typeof val === "object") {
const obj = val as Record<string, unknown>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (obj.message && typeof obj.message === "object") return walk(obj.message)
if (obj.msg && typeof obj.msg === "object") return walk(obj.msg)
try {
return JSON.stringify(val)
} catch {
return ""
}
}
return ""
}
return walk(body.detail) || walk(body.message) || walk(body.msg)
}
/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */
function statusFallback(status: number): string {
switch (status) {
case 400:
return "请求参数有误(HTTP 400"
case 401:
return "登录状态已失效,请重新登录(HTTP 401)"
case 403:
return "没有权限执行该操作(HTTP 403"
case 404:
return "请求的资源不存在(HTTP 404"
case 409:
return "操作冲突,资源状态已变化(HTTP 409)"
case 413:
return "文件过大,请缩小后重试(HTTP 413)"
case 415:
return "不支持的文件格式(HTTP 415"
case 429:
return "操作过于频繁,请稍后再试(HTTP 429)"
case 503:
return "服务暂不可用,请稍后再试(HTTP 503)"
default:
if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status}`
return `请求失败(HTTP ${status}`
}
}
/**
* 从任意抛出值提取可展示的错误信息。
* @param fallback 全部提取失败时的兜底文案
*/
export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string {
if (!err) return fallback
// axios 错误(后端 JSON 响应 / HTTP 错误状态)
const ax = err as AxiosError<ErrorBody>
if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) {
// 超时
if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) {
return "请求超时,请检查网络后重试"
}
const resp = ax.response
if (resp) {
const bodyMsg = extractBodyMessage(resp.data)
if (bodyMsg) return bodyMsg
return statusFallback(resp.status)
}
// 请求已发出但无响应(断网/CORS/DNS)
if (ax.request) return "网络连接异常,请检查网络设置"
return ax.message || fallback
}
if (err instanceof Error) {
// XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message
if (err.message) return err.message
}
if (typeof err === "string") return err
return fallback
}
/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */
export function isErrorMsgShown(err: unknown): boolean {
return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown)
}
+6 -27
View File
@@ -33,36 +33,15 @@ export interface CreatePreviewRequest {
preset_id?: string
volume?: number
}
/** 批量预览数量(1~10),默认1。N>1 时返回 N 个独立变体任务 */
preview_count?: number
/** 各变体独立标题文字:长度1=共用,长度=preview_count=独立,空数组=使用 title_config.text */
titles?: string[]
/** 各变体独立配音素材库ID:长度1=共用,长度=preview_count=独立,空数组=回退 voice_library_id */
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=preview_count=独立(预览阶段通常为空) */
cover_urls?: string[]
}
/** 单个预览变体任务 */
export interface PreviewVariantItem {
task_id: string
status: string
progress: number
is_preview: boolean
variant_index: number
resolution: string
video_url: string
duration: number
error_message: string
title_text: string
voice_library_id: string
created_at?: string | null
}
/** 创建预览任务响应(单变体,preview_count=1 时 items 长度为1 */
/** 创建预览任务响应 */
export interface CreatePreviewResponse {
items: PreviewVariantItem[]
total: number
task_id: string
status: PreviewStatus
is_preview: boolean
resolution: string
created_at: string
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id */
source_edit_plan_id?: string
}
@@ -1,61 +0,0 @@
/**
* 批量变体剪辑计划 API(#1744)
*
* 批量预览时向后端申请 N 个变体的「独立剪辑计划片段」:
* - 变体 0 保留源 plan(用户在编辑器/智能选片产出的片段,含标题样式编辑结果);
* - 变体 1..N-1 由后端 reselect_plan_for_variant 完整重跑单视频选片流程
* (素材洗牌 + main 片段顺序洗牌 + 镜头/起点随机 + 跨变体 20% 区间避让 +
* 素材使用区间写回 metadata),与正式批量生成 POST /generation/tasks?count=N
* 使用同一套选片逻辑;
* - 正式生成时把 variant_plan_ids 原样回传,后端直接关联这些 plan 渲染,
* 不再重新选片 —— 预览所见即成片。
*
* 该接口只做选片/建 plan(秒级),不触发视频渲染,无渲染成本。
* 后端端点未上线(404)或选片失败(素材不足等)时前端降级为本地 variantSeed
* 模拟预览,不阻塞用户流程。
*/
import apiClient from "../client"
import type { EditPlanClip } from "../template-editor"
/** 批量变体计划请求体 */
export interface BatchVariantPlansRequest {
template_id: string
/** 本批次素材池(手动选择或智能匹配结果) */
asset_ids: string[]
/** 变体数量(≥1);=1 时只返回源 plan 片段 */
count: number
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
source_edit_plan_id?: string
}
/** 单个变体的计划片段 */
export interface VariantPlan {
/** 变体序号,从 0 开始 */
variant_index: number
/** 该变体关联的剪辑计划 ID(正式生成时回传,实现预览即成片) */
plan_id: string
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
clips: EditPlanClip[]
}
/** 批量变体计划响应 */
export interface BatchVariantPlansResponse {
items: VariantPlan[]
total: number
}
/**
* 创建批量变体剪辑计划并返回各变体片段。
*
* 注意:端点 404(后端未上线)/ 400(素材不足)等失败由调用方 catch 后降级,
* 不要抛 unhandled rejection。
*/
export async function createBatchVariantPlans(
params: BatchVariantPlansRequest,
): Promise<BatchVariantPlansResponse> {
const response = await apiClient.post<BatchVariantPlansResponse>(
"/generation/variant-plans",
params,
)
return response.data
}
-14
View File
@@ -92,20 +92,6 @@ export interface CreateGenerationTaskRequest {
preset_id?: string
volume?: number
}
/** 批量生成数量(1~10),默认1。不传=单条旧逻辑 */
count?: number
/** 各变体独立标题文字:长度1=共用,长度=count=独立,空数组=使用 title_config/custom_title */
titles?: string[]
/** 各变体独立配音素材库ID:长度1=共用,长度=count=独立,空数组=回退 voice_library_id */
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
cover_urls?: string[]
/**
* 批量变体剪辑计划 ID(#1744):预览阶段后端独立选片产出的 plan id 列表
* (按变体全量索引,长度=previewCount)。正式生成回传后后端直接关联这些
* plan 渲染、不再重新选片,保证预览所见即成片。后端未支持时忽略该字段。
*/
variant_plan_ids?: string[]
}
/** 单个生成任务详情(对齐后端 GenerationTaskResponse */
+2 -7
View File
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
assetIds: string[],
clipType = "main",
requiredClipsCount?: number,
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
opts?: { signal?: AbortSignal },
): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
@@ -104,12 +104,7 @@ export async function createClipsFromAssets(
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
body,
{
timeout: 60000,
signal: opts?.signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
{ timeout: 60000, signal: opts?.signal },
)
return response.data
}
@@ -43,17 +43,11 @@ export async function updateEditPlanClips(
templateId: string,
clips: EditPlanClipInput[],
signal?: AbortSignal,
/** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */
silentErrorToast?: boolean,
): Promise<{ count: number }> {
const response = await apiClient.put(
`/templates/${templateId}/editor/clips`,
{ clips },
{
signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast)
...(silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
{ signal },
)
return response.data
}
+4 -4
View File
@@ -42,10 +42,8 @@ export interface TemplateItem {
export interface TemplateSegment {
id?: string
segment_order: number
/** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取 */
duration_min?: number
/** @deprecated 同上 */
duration_max?: number
duration_min: number
duration_max: number
material_type: string | null
description?: string
}
@@ -57,6 +55,8 @@ export interface TemplateListParams {
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
@@ -1,74 +0,0 @@
.xx-wechat-qr-modal {
position: relative;
padding: 8px 0 4px;
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 常驻二维码容器(WxLogin 渲染目标) */
.xx-wechat-qr-container {
display: flex;
justify-content: center;
min-height: 260px;
}
/* loading / error 遮罩层,覆盖在二维码容器之上 */
.xx-wechat-qr-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
text-align: center;
color: #666;
}
.xx-wechat-qr-overlay p {
margin-top: 16px;
margin-bottom: 0;
}
.xx-wechat-qr-container iframe {
border: none;
}
.xx-wechat-qr-tip {
margin: 12px 0 0;
color: #666;
font-size: 14px;
}
.xx-wechat-qr-error {
text-align: center;
width: 100%;
}
.xx-wechat-qr-error-msg {
color: #ef4444;
font-size: 14px;
line-height: 1.6;
margin: 0 0 16px;
word-break: break-word;
}
.xx-wechat-qr-error-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.xx-wechat-qr-fallback {
background: none;
border: none;
color: var(--primary-color, #3b82f6);
cursor: pointer;
font-size: 13px;
padding: 0;
text-decoration: underline;
}
@@ -1,265 +0,0 @@
/**
* 微信扫码二维码弹窗(登录 / 绑定复用)
*
* 微信官方嵌入式二维码方案:弹窗内用 new WxLogin({ self_redirect: true }) 渲染二维码,
* 扫码后微信重定向到本站回调页(在二维码 iframe 内加载),回调页通过 postMessage
* 把成功/失败结果通知本弹窗(消息协议见 ./messages)。
*
* 兜底:获取授权链接成功但 WxLogin JS 加载失败/超时时,自动回退整页跳转授权
* (与旧流程一致);获取授权链接本身失败时在弹窗内展示错误并提供重试。
*/
import React, { useEffect, useRef, useState } from "react"
import { Spin } from "antd"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import {
getWechatAuthUrl,
getWechatBindUrl,
getCurrentUser,
normalizeUser,
type User,
} from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
import { getErrorMessage } from "@/api/errors"
import { loadWxLoginScript, parseWxAuthUrl } from "@/api/auth/wxLogin"
import { isWechatQrMessage, type WechatQrScene } from "./messages"
import "./WechatQrModal.css"
export interface WechatQrModalProps {
open: boolean
scene: WechatQrScene
onClose: () => void
/** 登录场景成功回调(needOnboarding=true 时调用方应跳昵称引导页) */
onLoginSuccess?: (needOnboarding: boolean) => void
/** 绑定场景成功回调(调用方刷新用户信息/提示) */
onBindSuccess?: () => void
}
type QrStatus = "loading" | "qrcode" | "error"
const CONTAINER_ID: Record<WechatQrScene, string> = {
login: "wechat-qr-login-container",
bind: "wechat-qr-bind-container",
}
const STATE_STORAGE_KEY: Record<WechatQrScene, string> = {
login: "wechat_state",
bind: "wechat_bind_state",
}
/**
* 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画,
* 父组件 effect 首次执行时容器可能尚未出现在 document 中。
*/
function waitForContainer(id: string, timeoutMs = 3000): Promise<HTMLElement | null> {
return new Promise((resolve) => {
const start = Date.now()
const check = () => {
const el = document.getElementById(id)
if (el) {
resolve(el)
return
}
if (Date.now() - start > timeoutMs) {
resolve(null)
return
}
setTimeout(check, 50)
}
check()
})
}
const WechatQrModal: React.FC<WechatQrModalProps> = ({
open,
scene,
onClose,
onLoginSuccess,
onBindSuccess,
}) => {
const setAuth = useAuthStore((state) => state.setAuth)
const setUser = useAuthStore((state) => state.setUser)
const [status, setStatus] = useState<QrStatus>("loading")
const [errorMsg, setErrorMsg] = useState("")
/** 刷新二维码计数:变化时重新请求授权链接并重渲染 */
const [renderSeq, setRenderSeq] = useState(0)
/** 最新授权链接,用于"整页打开"兜底 */
const authUrlRef = useRef<string | null>(null)
const isLogin = scene === "login"
// 初始化:获取授权链接 → 加载 WxLogin JS → 内嵌渲染二维码
useEffect(() => {
if (!open) return
let cancelled = false
authUrlRef.current = null
setStatus("loading")
setErrorMsg("")
const init = async () => {
try {
const fetchUrl = isLogin ? getWechatAuthUrl : getWechatBindUrl
const result = await fetchUrl()
if (cancelled) return
// 写 state(整页跳转兜底路径的回调页也会清理它)
localStorage.setItem(STATE_STORAGE_KEY[scene], result.state)
authUrlRef.current = result.auth_url
const params = parseWxAuthUrl(result.auth_url, result.state)
if (!params) {
// 授权链接格式异常:直接整页跳转,由微信侧/回调页兜底
window.location.href = result.auth_url
return
}
const WxLogin = await loadWxLoginScript()
if (cancelled) return
// 等 Modal portal 中的容器挂载完成
const container = await waitForContainer(CONTAINER_ID[scene])
if (cancelled) return
if (!container) {
window.location.href = result.auth_url
return
}
container.innerHTML = ""
new WxLogin({
self_redirect: true,
id: CONTAINER_ID[scene],
appid: params.appid,
scope: "snsapi_login",
redirect_uri: params.redirect_uri,
state: params.state,
style: "black",
})
if (!cancelled) setStatus("qrcode")
} catch (err) {
if (cancelled) return
if (authUrlRef.current) {
// 授权链接已拿到但二维码脚本加载失败/超时:回退整页跳转
window.location.href = authUrlRef.current
return
}
// 授权链接接口本身失败:弹窗内展示真实原因,允许重试
setErrorMsg(getErrorMessage(err, "微信服务暂不可用,请稍后重试"))
setStatus("error")
}
}
init()
return () => {
cancelled = true
}
}, [open, scene, isLogin, renderSeq])
// 监听 iframe 内回调页 postMessage 回来的扫码结果
useEffect(() => {
if (!open) return
const handleMessage = async (event: MessageEvent) => {
// 只接受同源消息
if (event.origin !== window.location.origin) return
if (!isWechatQrMessage(event.data, scene)) return
const msg = event.data
if (msg.success) {
if (isLogin) {
// iframe 内回调页已把 token 写入 localStorage(同源共享),
// 父窗口同步内存登录态后交给调用方跳转
try {
const userData = await getCurrentUser()
const user = normalizeUser(userData) as User
setAuth(
user,
localStorage.getItem("access_token") || "",
localStorage.getItem("refresh_token"),
)
scheduleProactiveRefresh()
} catch {
// token 已持久化,即使这里失败路由守卫/刷新也能恢复登录态
}
onLoginSuccess?.(msg.payload?.needOnboarding ?? false)
} else {
try {
const userData = await getCurrentUser()
setUser(normalizeUser(userData) as User)
} catch {
// 绑定结果以后端为准,调用方 invalidateQueries 会兜底刷新
}
onBindSuccess?.()
}
return
}
// 失败:弹窗内展示回调页透传的真实原因,提供刷新/整页跳转
setErrorMsg(msg.detail || "微信授权失败,请重试")
setStatus("error")
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [open, scene, isLogin, onLoginSuccess, onBindSuccess, setAuth, setUser])
const handleRefresh = () => setRenderSeq((seq) => seq + 1)
const handleFullPageRedirect = () => {
if (authUrlRef.current) {
window.location.href = authUrlRef.current
}
}
return (
<Modal
title={isLogin ? "微信扫码登录" : "绑定微信"}
open={open}
onCancel={onClose}
footer={null}
width={380}
maskClosable={false}
destroyOnHidden
>
<div className="xx-wechat-qr-modal">
{/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来,
不能按 status 条件渲染,否则 effect 里永远找不到容器 */}
<div
id={CONTAINER_ID[scene]}
className="xx-wechat-qr-container"
style={{ visibility: status === "qrcode" ? "visible" : "hidden" }}
/>
{status === "loading" && (
<div className="xx-wechat-qr-overlay">
<Spin size="large" />
<p>...</p>
</div>
)}
{status === "qrcode" && (
<p className="xx-wechat-qr-tip">使{isLogin ? "登录" : "绑定账号"}</p>
)}
{status === "error" && (
<div className="xx-wechat-qr-overlay xx-wechat-qr-error">
<p className="xx-wechat-qr-error-msg">{errorMsg}</p>
<div className="xx-wechat-qr-error-actions">
<Button buttonType="primary" buttonSize="md" onClick={handleRefresh}>
</Button>
{authUrlRef.current && (
<button
type="button"
className="xx-wechat-qr-fallback"
onClick={handleFullPageRedirect}
>
使
</button>
)}
</div>
</div>
)}
</div>
</Modal>
)
}
export default WechatQrModal
@@ -1,71 +0,0 @@
/**
* 微信扫码弹窗与 iframe 内回调页之间的 postMessage 消息协议
*
* 流程:弹窗内 WxLogin(self_redirect:true) 渲染的二维码 iframe 扫码后,
* 微信重定向到本站回调页(同源,在 iframe 内加载);回调页完成换 token/绑定后,
* 通过 window.parent.postMessage 把结果通知弹窗,弹窗负责关闭/展示错误/同步登录态。
*/
/** 扫码场景:登录 / 绑定 */
export type WechatQrScene = "login" | "bind"
export interface WechatQrSuccessPayload {
/** 登录场景:是否需要昵称引导(新用户或资料未完善) */
needOnboarding?: boolean
}
export interface WechatQrMessageData {
/** 固定协议标识,父窗口只认该 source */
source: "xiaoxia-wechat-qr"
/** 场景,需与弹窗发起时一致(login/bind),父窗口据此过滤 */
scene: WechatQrScene
/** 成功 / 失败 */
success: boolean
/** 失败时的真实原因(已在回调页拼好,含后端 detail) */
detail?: string
payload?: WechatQrSuccessPayload
}
export const WECHAT_QR_MESSAGE_SOURCE = "xiaoxia-wechat-qr"
/** 判断收到的 message 是否为本协议消息(且场景匹配) */
export function isWechatQrMessage(
data: unknown,
scene: WechatQrScene,
): data is WechatQrMessageData {
if (!data || typeof data !== "object") return false
const msg = data as Partial<WechatQrMessageData>
return msg.source === WECHAT_QR_MESSAGE_SOURCE && msg.scene === scene
}
/** 当前页面是否运行在 iframe(弹窗内嵌二维码)中 */
export function isInIframe(): boolean {
try {
return window.parent !== window
} catch {
// 跨域访问 window.parent 可能抛异常,按非 iframe 处理
return false
}
}
/**
* iframe 内回调页向父窗口上报扫码结果。同源回调页加载,targetOrigin 限定本站 origin。
*/
export function postWechatQrResult(
scene: WechatQrScene,
success: boolean,
options?: { detail?: string; needOnboarding?: boolean },
): void {
if (!isInIframe()) return
const data: WechatQrMessageData = {
source: WECHAT_QR_MESSAGE_SOURCE,
scene,
success,
detail: options?.detail,
payload:
success && options?.needOnboarding !== undefined
? { needOnboarding: options.needOnboarding }
: undefined,
}
window.parent.postMessage(data, window.location.origin)
}
@@ -1,85 +0,0 @@
/**
* 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏,
* 同时兜住页面级渲染崩溃,避免任何未捕获错误导致整页白屏无反馈。
*
* 捕获到 ChunkLoadError / Failed to fetch dynamically imported module
* 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取,
* 拿到新 chunk 引用,白屏自愈)
* 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新"
* 兜底界面,由用户手动点击
*
* 其他非 chunk 错误:显示通用错误页 + "返回首页"按钮(跳首页而非刷新当前 URL,
* 避免刷新后再次命中同一路由崩溃形成死循环)。
*/
import React from "react"
import { Button, Result } from "antd"
import {
getChunkReloadedAt,
goHomeRecover,
isChunkLoadError,
reloadForChunkError,
} from "@/utils/chunkLoadError"
interface Props {
children: React.ReactNode
}
interface State {
error: Error | null
isChunkError: boolean
/** 捕获错误时是否已经自动刷新过(决定显示自动刷新中还是手动兜底) */
alreadyReloaded: boolean
}
class ChunkErrorBoundary extends React.Component<Props, State> {
state: State = { error: null, isChunkError: false, alreadyReloaded: false }
static getDerivedStateFromError(error: Error): State {
const chunk = isChunkLoadError(error)
return {
error,
isChunkError: chunk,
alreadyReloaded: chunk ? getChunkReloadedAt() !== null : false,
}
}
componentDidCatch(error: Error): void {
// 仅 chunk 错误且本次会话没自动刷新过 → 打标记并整页刷新(自愈)
if (isChunkLoadError(error) && getChunkReloadedAt() === null) {
reloadForChunkError()
}
}
render(): React.ReactNode {
const { error, isChunkError, alreadyReloaded } = this.state
if (!error) return this.props.children
if (isChunkError && !alreadyReloaded) {
// 已打标记、componentDidCatch 里已触发 reload;极短瞬间展示加载中
return (
<Result status="info" title="系统正在更新" subTitle="检测到新版本,正在自动刷新页面…" />
)
}
// 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用;
// 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环
return (
<Result
status="warning"
title={isChunkError ? "系统已更新" : "页面出现异常"}
subTitle={
isChunkError
? "检测到新版本,请点击下方按钮回到首页加载最新内容。"
: "页面加载遇到问题,点击返回首页通常可以恢复,未保存的内容可能丢失。"
}
extra={
<Button type="primary" onClick={goHomeRecover}>
{isChunkError ? "刷新并返回首页" : "返回首页"}
</Button>
}
/>
)
}
}
export default ChunkErrorBoundary
+1 -4
View File
@@ -9,7 +9,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ConfigProvider, App as AntApp } from "antd"
import zhCN from "antd/locale/zh_CN"
import router from "./router"
import ChunkErrorBoundary from "./components/common/ChunkErrorBoundary"
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
@@ -100,9 +99,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN} theme={theme}>
<AntApp>
<ChunkErrorBoundary>
<RouterProvider router={router} />
</ChunkErrorBoundary>
<RouterProvider router={router} />
</AntApp>
</ConfigProvider>
</QueryClientProvider>
@@ -42,7 +42,6 @@ const AssetLibrary: React.FC = () => {
assetsError,
assetsErrorObj,
refetchAssets,
stalledAssetIds,
searchText,
setSearchText,
filterType,
@@ -78,7 +77,6 @@ const AssetLibrary: React.FC = () => {
removeUpload,
clearFinished,
uploading,
transferActive,
activeCount,
pendingCount,
} = useAssetUpload({ effectiveLibId })
@@ -170,7 +168,6 @@ const AssetLibrary: React.FC = () => {
{/* 上传区域 */}
<AssetUploadZone
uploading={uploading}
transferActive={transferActive}
activeCount={activeCount}
pendingCount={pendingCount}
onUpload={enqueueUploads}
@@ -217,7 +214,6 @@ const AssetLibrary: React.FC = () => {
selectedIds={selectedIds}
diagnosingId={diagnosingId}
uploadProgressMap={uploadProgressMap}
stalledAssetIds={stalledAssetIds}
onRetry={refetchAssets}
onToggleSelect={toggleSelect}
onDiagnose={handleDiagnose}
-36
View File
@@ -831,20 +831,6 @@
color: #ef4444;
}
.xx-upload-queue-error-detail {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: #ef4444;
word-break: break-word;
white-space: normal;
}
.xx-upload-queue-error-hint {
margin-top: 2px;
color: #b45309;
}
.xx-upload-queue-actions {
display: flex;
gap: 6px;
@@ -1055,25 +1041,3 @@
background: #fef2f2;
color: #dc2626;
}
/* 上传入口禁用态(直传进行中,防重复提交,Issue #1714 */
.xx-asset-upload-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.xx-asset-upload-btn:disabled:hover {
opacity: 0.6;
}
.xx-asset-upload-btn:disabled:active {
transform: none;
}
/* 处理超时遮罩:创建超过 10 分钟仍在处理中(疑似后端卡住),停止转圈并警示 */
.xx-asset-thumb-stalled {
background: rgba(217, 119, 6, 0.28);
color: #fde68a;
backdrop-filter: blur(2px);
}
.xx-asset-thumb-stalled :first-child {
font-size: var(--font-size-2xl);
}
@@ -22,8 +22,6 @@ export interface AssetCardProps {
diagnosing?: boolean
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
uploadProgress?: { progress: number; uploading: boolean }
/** 处理超过 10 分钟仍未就绪(疑似后端卡住):停止转圈并提示处理超时 */
stalled?: boolean
onToggle: () => void
onDiagnose: () => void
onPlay: () => void
@@ -35,7 +33,6 @@ const AssetCard: React.FC<AssetCardProps> = ({
selected,
diagnosing,
uploadProgress,
stalled,
onToggle,
onDiagnose,
onPlay,
@@ -70,15 +67,11 @@ const AssetCard: React.FC<AssetCardProps> = ({
</div>
)}
{/* 转码/处理中遮罩(卡死超过 10 分钟时停止转圈,提示超时) */}
{/* 转码/处理中遮罩 */}
{asset.loading && !isUploading && (
<div
className={`xx-asset-thumb-overlay ${
stalled ? "xx-asset-thumb-stalled" : "xx-asset-thumb-processing"
}`}
>
{stalled ? <CloseCircleOutlined /> : <LoadingOutlined />}
<span>{stalled ? "处理超时,可重试上传" : "转码处理中"}</span>
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
</div>
)}
@@ -136,7 +129,7 @@ const AssetCard: React.FC<AssetCardProps> = ({
</p>
<div className="xx-asset-meta">
<span className="xx-asset-meta-status">
<StatusPill status={asset.status} label={stalled ? "处理超时" : asset.statusLabel} />
<StatusPill status={asset.status} label={asset.statusLabel} />
</span>
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
</div>
@@ -19,8 +19,6 @@ export interface AssetGridSectionProps {
selectedIds: Set<string>
diagnosingId: string | null
uploadProgressMap?: UploadProgressMap
/** 创建超过 10 分钟仍在处理中的素材 id(疑似后端卡住),卡片提示处理超时 */
stalledAssetIds?: Set<string>
onRetry?: () => void
onToggleSelect: (id: string) => void
onDiagnose: (asset: AssetItem) => void
@@ -36,7 +34,6 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
selectedIds,
diagnosingId,
uploadProgressMap,
stalledAssetIds,
onRetry,
onToggleSelect,
onDiagnose,
@@ -79,7 +76,6 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
selected={selectedIds.has(asset.id)}
diagnosing={diagnosingId === asset.id}
uploadProgress={uploadProgressMap?.get(asset.id)}
stalled={stalledAssetIds?.has(asset.id)}
onToggle={() => onToggleSelect(asset.id)}
onDiagnose={() => onDiagnose(asset)}
onPlay={() => onPlay(asset)}
@@ -4,13 +4,10 @@
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
*/
import React, { useRef, useState } from "react"
import { message } from "antd"
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons"
export interface AssetUploadZoneProps {
uploading: boolean
/** 有文件正在本地指纹/prepare/直传(非服务端转码),此时禁用入口防重复提交 */
transferActive: boolean
activeCount: number
pendingCount: number
onUpload: (files: File[]) => void
@@ -18,7 +15,6 @@ export interface AssetUploadZoneProps {
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
uploading,
transferActive,
activeCount,
pendingCount,
onUpload,
@@ -31,11 +27,6 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return
// 直传进行中拦截重复触发:相同文件仍由入队去重兜底,这里先给明确反馈
if (transferActive) {
message.warning("文件正在上传中,请等待当前上传完成后再添加")
return
}
onUpload(Array.from(list))
}
@@ -67,18 +58,10 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
<button
type="button"
className="xx-asset-upload-btn"
disabled={transferActive}
title={transferActive ? "文件上传中,暂不能添加新文件" : undefined}
onClick={() => {
if (transferActive) {
message.warning("文件正在上传中,请等待当前上传完成后再添加")
return
}
inputRef.current?.click()
}}
onClick={() => inputRef.current?.click()}
>
<PlusOutlined />
{transferActive ? "上传中…" : "上传素材"}
</button>
<span className="xx-asset-upload-status">
{uploading ? (
@@ -12,8 +12,7 @@ import {
ReloadOutlined,
CloseOutlined,
} from "@ant-design/icons"
import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload"
import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload"
import type { UploadItem } from "../hooks/useAssetUpload"
export interface UploadQueuePanelProps {
items: UploadItem[]
@@ -30,13 +29,6 @@ const STATUS_TEXT: Record<UploadItem["status"], string> = {
error: "上传失败",
}
/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */
const FAIL_STAGE_TEXT: Record<UploadFailStage, string> = {
prepare: "准备上传阶段",
transfer: "文件传输阶段",
complete: "确认入库阶段",
}
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
items,
onRetry,
@@ -88,34 +80,16 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
) : null}
<div className="xx-upload-queue-status">
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
{it.status === "preparing" && it.hint ? `${it.hint}` : ""}
{it.status === "uploading" ? ` ${it.progress}%` : ""}
{it.status === "error" && it.failedStage
? `${FAIL_STAGE_TEXT[it.failedStage]}`
: ""}
{it.status === "error" && it.error ? `${it.error}` : ""}
</div>
{it.status === "error" && it.error ? (
<div className="xx-upload-queue-error-detail" title={it.error}>
{it.error.split("\n").map((line, idx) =>
line === COMPLETE_RETRY_HINT ? (
<div key={idx} className="xx-upload-queue-error-hint">
{line}
</div>
) : (
<div key={idx}>{line}</div>
),
)}
</div>
) : null}
</div>
<span className="xx-upload-queue-actions">
{it.status === "error" && (
<button
type="button"
className="xx-upload-queue-btn"
title={
it.failedStage === "complete" ? "安全重试(只确认,不重新上传)" : "重试上传"
}
title="重试"
onClick={() => onRetry(it.tempId)}
>
<ReloadOutlined />
+33 -218
View File
@@ -2,65 +2,34 @@ import { useState, useCallback, useRef, useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
import { MAX_FILE_SIZE } from "../constants"
import {
computeFileHash,
findDuplicateInQueue,
makeClientUploadId,
makeFileFingerprint,
} from "@/api/assets/uploadDedup"
/** 单文件上传状态机 */
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
/** 失败发生的阶段:complete 阶段失败时记录可能已在后端建成,禁止盲目重传整个文件 */
export type UploadFailStage = "prepare" | "transfer" | "complete"
export interface UploadItem {
/** 前端临时 idprepare 前无 asset_id 时用),同时作为队列项 key */
/** 前端临时 idprepare 前无 asset_id 时用) */
tempId: string
file: File
fileName: string
/** 进度 0~100(仅直传阶段有真实进度) */
progress: number
status: UploadItemStatus
/** 文件指纹(name+size+lastModified),入队去重用 */
fileKey: string
/** 本次逻辑上传的幂等 token:重试复用、重新入队才换新 */
clientUploadId: string
/** 上传前算好的文件内容哈希(SHA-256),prepare/complete 都带上 */
fileHash?: string
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
assetId?: string
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
duplicated?: boolean
/** 失败发生的阶段;complete 阶段失败点重试只重发 complete,不重新上传文件 */
failedStage?: UploadFailStage
/** 状态行补充提示(如"正在计算文件指纹…" */
hint?: string
error?: string
}
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
const MAX_CONCURRENT = 3
/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */
export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件"
/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */
const STAGE_LABEL: Record<UploadFailStage, string> = {
prepare: "准备上传",
transfer: "文件传输",
complete: "确认入库",
}
/**
* 素材批量上传 Hook
* - 入队按文件指纹(name+size+lastModified)去重:同一文件已在队列/上传中/处理中时不重复入队
* - 上传前计算文件 SHA-256prepare/complete 携带 file_hash + 幂等 tokenclientUploadId
* - complete 超时/失败不盲目重传:复用 handle 只重发 complete(幂等),prepare/transfer 失败才全量重跑
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
* - 失败卡片支持重试/移除
*/
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
@@ -70,13 +39,6 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
const itemsRef = useRef<UploadItem[]>([])
itemsRef.current = items
/**
* prepare 成功后的 handle 按 tempId 留存:
* complete 阶段失败(超时/网络)时 OSS 文件已存在、后端记录也可能已建成,
* 重试必须复用同一 handle 只重发 complete,绝不能重新 prepare+直传。
*/
const handlesRef = useRef<Map<string, DirectUploadHandle>>(new Map())
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
}, [])
@@ -90,71 +52,14 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient, effectiveLibId])
/**
* 执行单个文件的完整上传流程。
* @param completeOnly complete 阶段失败后的重试:跳过 hash/prepare/transfer,只重发 complete
* (OSS 文件已传完,重发由 file_hash + clientUploadId 保证幂等)
*/
/** 执行单个文件的完整上传流程(prepare→transfer→complete */
const runUpload = useCallback(
async (item: UploadItem, handle?: DirectUploadHandle, completeOnly = false) => {
let stage: UploadFailStage = "prepare"
async (item: UploadItem, handle?: DirectUploadHandle) => {
try {
let h = handle
if (completeOnly && h) {
// ── complete 重试:文件已在 OSS,直接幂等重发确认 ──
stage = "complete"
updateItem(item.tempId, {
status: "ingesting",
progress: 100,
error: undefined,
failedStage: undefined,
hint: undefined,
})
const result = await h.complete()
refreshList()
handlesRef.current.delete(item.tempId)
if (result.duplicated) {
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
} else {
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
return
}
// 1. 计算文件内容哈希(失败不阻塞,降级为不传 hash;后端仍有幂等 token 兜底)
updateItem(item.tempId, { hint: "正在计算文件指纹…" })
const fileHash = item.fileHash || (await computeFileHash(item.file))
updateItem(item.tempId, { fileHash, hint: undefined })
// 2. prepare(携带 file_hash + 幂等 token;重试时复用同一 clientUploadId
stage = "prepare"
h =
h ??
(await prepareDirectUploadHandle({
file: item.file,
library_id: effectiveLibId,
fileHash,
clientUploadId: item.clientUploadId,
}))
handlesRef.current.set(item.tempId, h)
// prepare 阶段后端 file_hash 命中素材库已有相同文件(skip_transfer / duplicated):
// 立即标记 done、调一次 refreshList 让已存在素材立即显示,跳过 transfer + complete
if (h.prepared.skip_transfer || h.prepared.duplicated) {
updateItem(item.tempId, {
status: "done",
duplicated: true,
assetId: h.prepared.asset_id,
})
handlesRef.current.delete(item.tempId)
refreshList()
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
return
}
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
const h =
handle ??
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
if (h.prepared.asset_id) {
updateItem(item.tempId, {
status: "uploading",
@@ -167,59 +72,26 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
updateItem(item.tempId, { status: "uploading", progress: 0 })
}
// 3. OSS 直传(真实进度)
stage = "transfer"
// 2. OSS 直传(真实进度)
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
// 4. complete:后端确认入库并创建 ingest jobfile_hash + 幂等 token 已在 handle 闭包中)
stage = "complete"
// 3. complete:后端创建 ingest job,素材进入转码
updateItem(item.tempId, { status: "ingesting", progress: 100 })
const result = await h.complete()
refreshList()
handlesRef.current.delete(item.tempId)
if (result.duplicated) {
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
} else {
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
updateItem(item.tempId, { status: "done" })
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
} catch (err: unknown) {
// 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail
// 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细)
const detail = getErrorMessage(err, "未知错误")
console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err)
if (stage === "complete") {
// complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试;
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作。
// 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传)
refreshList()
updateItem(item.tempId, {
status: "error",
failedStage: "complete",
error: `${detail}\n${COMPLETE_RETRY_HINT}`,
hint: undefined,
})
if (!isErrorMsgShown(err)) {
message.error(`"${item.fileName}" 确认入库失败:${detail}`)
}
} else {
// prepare / transfer 失败:后端尚无素材记录,可安全全量重跑
handlesRef.current.delete(item.tempId)
updateItem(item.tempId, {
status: "error",
failedStage: stage === "transfer" ? "transfer" : "prepare",
error: detail,
hint: undefined,
})
// 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹;
// OSS XHR 直传错误不走 axios,必须在这里弹
if (!isErrorMsgShown(err)) {
message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`)
}
}
const detail = err instanceof Error ? err.message : "上传失败"
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
updateItem(item.tempId, { status: "error", error: detail })
message.error(`"${item.fileName}" 上传失败:${detail}`)
}
},
[effectiveLibId, refreshList, updateItem],
@@ -241,9 +113,7 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
if (!next) return
claimedRef.current.add(next.tempId)
inFlightRef.current += 1
// complete 阶段失败的重试:复用留存的 handle,只重发 complete
const existingHandle = handlesRef.current.get(next.tempId)
void runUpload(next, existingHandle, existingHandle !== undefined).finally(() => {
void runUpload(next).finally(() => {
inFlightRef.current -= 1
claimedRef.current.delete(next.tempId)
// 一个任务结束(成功/失败)后继续拉起排队任务
@@ -256,11 +126,7 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
pumpRef.current()
}, [items])
/**
* 入队一个或多个文件(按文件指纹去重):
* - 同一文件已在队列且 preparing/uploading/ingesting/done → 跳过,不重复入队
* - 同一文件此前失败(error)→ 重新激活原队列项(复用 clientUploadId,保持幂等语义)
*/
/** 入队一个或多个文件 */
const enqueueUploads = useCallback(
(files: File[]) => {
if (!effectiveLibId) {
@@ -277,86 +143,37 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
}
if (valid.length === 0) return
let skipped = 0
let rearmed = 0
const newItems: UploadItem[] = []
for (const file of valid) {
const fileKey = makeFileFingerprint(file)
// error 项允许重新激活;其余状态(preparing/uploading/ingesting/done)都算重复
const dup = findDuplicateInQueue([...itemsRef.current, ...newItems], fileKey, ["error"])
if (dup) {
skipped += 1
continue
}
// 失败项重新激活:复用 tempId/clientUploadId,由 pump 按留存 handle 决定重试方式
const failed = itemsRef.current.find(
(it) => it.fileKey === fileKey && it.status === "error",
)
if (failed) {
rearmed += 1
updateItem(failed.tempId, {
status: "preparing",
progress: 0,
error: undefined,
failedStage: undefined,
hint: undefined,
})
continue
}
newItems.push({
tempId: `${Date.now()}-${newItems.length}-${Math.random().toString(36).slice(2, 8)}`,
file,
fileName: file.name,
progress: 0,
status: "preparing",
fileKey,
clientUploadId: makeClientUploadId(),
})
}
if (newItems.length > 0) {
setItems((prev) => [...prev, ...newItems])
}
if (skipped > 0) {
message.warning(`已跳过 ${skipped} 个重复文件(已在上传队列、处理中或本页已上传)`)
}
if (rearmed > 0) {
message.info(`已重新加入 ${rearmed} 个此前失败的文件`)
}
const newItems: UploadItem[] = valid.map((file, idx) => ({
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
file,
fileName: file.name,
progress: 0,
status: "preparing",
}))
setItems((prev) => [...prev, ...newItems])
},
[effectiveLibId, updateItem],
[effectiveLibId],
)
/**
* 重试失败任务(仅限 status=error):
* - complete 阶段失败:复用留存 handle 只重发 complete(幂等,不重新上传)
* - prepare/transfer 阶段失败:全量重跑(后端尚无记录,安全)
*/
/** 重试失败任务 */
const retryUpload = useCallback(
(tempId: string) => {
const target = itemsRef.current.find((it) => it.tempId === tempId)
if (!target || target.status !== "error") return
updateItem(tempId, { status: "preparing", progress: 0, error: undefined, hint: undefined })
// 状态更新后由 useEffect 触发 pumppump 会按 handlesRef 自动选择 completeOnly / 全量
if (!target) return
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
// 状态更新后由 useEffect 触发 pump
},
[updateItem],
)
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
const removeUpload = useCallback((tempId: string) => {
handlesRef.current.delete(tempId)
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
}, [])
/** 清空已完成/去重记录 */
const clearFinished = useCallback(() => {
setItems((prev) => {
for (const it of prev) {
if (it.status === "done") handlesRef.current.delete(it.tempId)
}
return prev.filter((it) => it.status !== "done")
})
setItems((prev) => prev.filter((it) => it.status !== "done"))
}, [])
const activeCount = items.filter(
@@ -371,10 +188,8 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
retryUpload,
removeUpload,
clearFinished,
/** 是否有进行中的上传(用于上传区文案/禁用入口 */
/** 是否有进行中的上传(用于上传区文案) */
uploading: hasActive,
/** 是否有文件正在本地处理或直传(用于禁用上传入口,防重复提交) */
transferActive: activeCount > 0,
activeCount,
pendingCount,
}
@@ -10,26 +10,6 @@ import {
import { getOrCreateDefaultProject } from "@/api/projects"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/** 处理中素材快速轮询(3s)的最大持续时间:超过后停止快轮询,避免孤儿任务永久转圈 */
const PROCESSING_POLL_MAX_MS = 10 * 60 * 1000 // 10 分钟
const isProcessingStatus = (st?: string | null): boolean =>
st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
/** 判断列表是否存在「创建超过 maxMs 仍在处理中」的卡死素材 */
const hasStalledProcessing = (
list: ApiAssetItem[],
maxMs: number = PROCESSING_POLL_MAX_MS,
): boolean => {
const now = Date.now()
return list.some((a) => {
if (!isProcessingStatus(a.status ?? "")) return false
if (!a.created_at) return false
const created = new Date(a.created_at).getTime()
return Number.isFinite(created) && now - created > maxMs
})
}
/**
* 素材库数据 Hook
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
@@ -83,15 +63,15 @@ export function useAssetsData() {
}),
enabled: !!effectiveLibId,
staleTime: 30_000,
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
// 但若处理中素材创建已超过 10 分钟仍未就绪(疑似后端卡住/孤儿任务),
// 停止快轮询避免无限转圈——卡死素材在网格中显示「处理超时」提示。
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
refetchInterval: (query) => {
const data = query.state.data as { items: ApiAssetItem[] } | undefined
const items = data?.items ?? []
const processing = items.some((a) => isProcessingStatus(a.status ?? ""))
if (!processing) return false
return hasStalledProcessing(items) ? false : 3000
const processing = items.some((a) => {
const st = a.status ?? ""
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
})
return processing ? 3000 : false
},
})
@@ -100,21 +80,6 @@ export function useAssetsData() {
[apiAssets],
)
/** 创建超过 10 分钟仍在处理中的素材(后端可能卡住),网格提示「处理超时」 */
const stalledAssetIds = useMemo(() => {
const list = Array.isArray(apiAssets?.items) ? apiAssets.items : []
const ids = new Set<string>()
const now = Date.now()
for (const a of list) {
if (!isProcessingStatus(a.status ?? "") || !a.created_at) continue
const created = new Date(a.created_at).getTime()
if (Number.isFinite(created) && now - created > PROCESSING_POLL_MAX_MS) {
ids.add(a.id)
}
}
return ids
}, [apiAssets])
/* ── 筛选状态 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
@@ -164,8 +129,6 @@ export function useAssetsData() {
assetsError,
assetsErrorObj,
refetchAssets,
stalledAssetIds,
hasStalledAssets: stalledAssetIds.size > 0,
// 筛选
searchText,
setSearchText,
+24 -32
View File
@@ -5,8 +5,8 @@ import React, { useState } from "react"
import { Form, Input, Checkbox, message } from "antd"
import { Link, useNavigate } from "react-router-dom"
import { useLogin } from "@/hooks/useAuth"
import { getWechatAuthUrl } from "@/api/auth"
import Button from "@/components/ui/Button"
import WechatQrModal from "@/components/auth/WechatQrModal"
import "./Login.css"
interface LoginFormValues {
@@ -19,7 +19,7 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const loginMutation = useLogin()
const [form] = Form.useForm()
const [wechatQrOpen, setWechatQrOpen] = useState(false)
const [wechatLoading, setWechatLoading] = useState(false)
const onFinish = async (values: LoginFormValues) => {
try {
@@ -35,28 +35,27 @@ const Login: React.FC = () => {
}
}
const handleWechatLogin = () => {
// 记录登录前的来源页,登录成功后(弹窗回调)跳回
const from = window.location.pathname + window.location.search
if (from !== "/login" && from !== "/register") {
localStorage.setItem("login_redirect", from)
} else {
localStorage.removeItem("login_redirect")
const handleWechatLogin = async () => {
try {
setWechatLoading(true)
const result = await getWechatAuthUrl()
// 保存 state 到 localStorage 用于回调时验证
localStorage.setItem("wechat_state", result.state)
// 记录登录前的来源页,登录成功后跳回
const from = window.location.pathname + window.location.search
if (from !== "/login" && from !== "/register") {
localStorage.setItem("login_redirect", from)
} else {
localStorage.removeItem("login_redirect")
}
// 跳转到微信授权页
window.location.href = result.auth_url
} catch (error) {
if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("微信登录暂不可用,请稍后重试")
} finally {
setWechatLoading(false)
}
setWechatQrOpen(true)
// 弹窗打开期间按钮 disabled;WxLogin 脚本加载失败/超时时弹窗内会自动回退整页跳转
}
// 弹窗扫码登录成功:登录态已由弹窗同步,按用户类型跳转
const handleWechatQrSuccess = (needOnboarding: boolean) => {
setWechatQrOpen(false)
if (needOnboarding) {
navigate("/welcome/wechat", { replace: true })
return
}
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
}
return (
@@ -127,10 +126,10 @@ const Login: React.FC = () => {
type="button"
className="xx-btn-wechat"
onClick={handleWechatLogin}
disabled={wechatQrOpen}
disabled={wechatLoading}
>
<span className="xx-wechat-icon">💬</span>
{wechatLoading ? "加载中..." : "微信登录"}
</button>
</div>
@@ -138,13 +137,6 @@ const Login: React.FC = () => {
<Link to="/register"></Link>
</div>
</div>
<WechatQrModal
open={wechatQrOpen}
scene="login"
onClose={() => setWechatQrOpen(false)}
onLoginSuccess={handleWechatQrSuccess}
/>
</div>
)
}
@@ -1,118 +0,0 @@
/**
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin } from "antd"
import { bindWechat, normalizeUser } from "@/api/auth"
import { getErrorMessage } from "@/api/errors"
import { useAuthStore } from "@/store/authStore"
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
const WechatBindCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setUser = useAuthStore((state) => state.setUser)
const [error, setError] = useState<string | null>(null)
const inIframe = isInIframe()
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
const fail = (message: string) => {
if (inIframe) {
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示
postWechatQrResult("bind", false, { detail: message })
return
}
setError(message)
}
if (!code || !state) {
fail("无效的回调参数,请回到设置页重新扫码绑定")
return
}
const handleBind = async () => {
// state 校验由后端 state store 一次性消费兜底(前端不再比对 localStorage
// 微信内打开/跨浏览器场景本地无 state 会误杀);清理绑定前写入的 state
localStorage.removeItem("wechat_bind_state")
try {
const result = await bindWechat(code, state)
setUser(normalizeUser(result.user))
if (inIframe) {
// 弹窗模式:通知父窗口关闭弹窗并刷新绑定状态
postWechatQrResult("bind", true)
return
}
// 用 replace 回设置页,query 携带成功标记由设置页提示
navigate("/app/profile?wechat_bind=success", { replace: true })
} catch (err) {
// 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走
fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
}
}
handleBind()
}, [searchParams, navigate, setUser, inIframe])
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/app/profile")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
export default WechatBindCallback
+77 -83
View File
@@ -1,63 +1,40 @@
/**
* 微信登录回调页
* 扫码授权后由微信重定向回来:用 code 换登录态,
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin } from "antd"
import { Spin, message } from "antd"
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
import { getErrorMessage } from "@/api/errors"
import { useAuthStore } from "@/store/authStore"
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
import BindContactModal from "@/components/auth/BindContactModal"
const WechatCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const [loading, setLoading] = useState(true)
const [showBindModal, setShowBindModal] = useState(false)
const [error, setError] = useState<string | null>(null)
const inIframe = isInIframe()
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
const fail = (message: string) => {
if (inIframe) {
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可
postWechatQrResult("login", false, { detail: message })
return
}
setError(message)
setLoading(false)
}
// 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示/上报原因
const wxErrorCode = searchParams.get("error")
const wxErrDesc = searchParams.get("error_description")
if (wxErrorCode || wxErrDesc) {
const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join("")
fail(`微信授权失败:${reason}`)
return
}
if (!code || !state) {
fail("无效的回调参数,请重新扫码登录")
setError("无效的回调参数")
setLoading(false)
return
}
const handleCallback = async () => {
try {
// state CSRF 校验由后端 state store 一次性消费兜底(前端不再比对
// localStorage——微信内打开、跨浏览器等场景本地没有 state,会误杀正常回调);
// 清理登录前写入的 state,避免残留
// 校验 state,防止 CSRF
const savedState = localStorage.getItem("wechat_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新登录")
setLoading(false)
return
}
localStorage.removeItem("wechat_state")
const result = await wechatCallback(code, state)
@@ -72,34 +49,41 @@ const WechatCallback: React.FC = () => {
const userData = await getCurrentUser()
const user: User = normalizeUser(userData)
setAuth(user, result.access_token, result.refresh_token)
scheduleProactiveRefresh()
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
const needOnboarding = result.is_new_user || user.profile_completed === false
if (inIframe) {
// 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转
postWechatQrResult("login", true, { needOnboarding })
return
if (result.binding_complete) {
// 已绑定,跳转到登录前页面或首页
message.success("登录成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} else {
// 未绑定,显示绑定弹窗
setLoading(false)
setShowBindModal(true)
}
if (needOnboarding) {
navigate("/welcome/wechat", { replace: true })
return
}
// 老用户:回登录前页面或首页
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch (err) {
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
setError("登录失败,请重试")
setLoading(false)
}
}
handleCallback()
}, [searchParams, navigate, setAuth, inIframe])
}, [searchParams, navigate, setAuth])
const handleBindSuccess = (user: User) => {
const setUser = useAuthStore.getState().setUser
setUser(user)
setShowBindModal(false)
message.success("绑定成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
}
const handleBindCancel = () => {
setShowBindModal(false)
navigate("/login")
}
if (loading) {
return (
@@ -114,39 +98,49 @@ const WechatCallback: React.FC = () => {
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
<BindContactModal
open={showBindModal}
onSuccess={handleBindSuccess}
onCancel={handleBindCancel}
/>
)
}
@@ -1,116 +0,0 @@
/**
* 微信新用户昵称引导页
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
*/
import React, { useRef } from "react"
import { Form, Input, message } from "antd"
import { Navigate, useNavigate } from "react-router-dom"
import { useMutation } from "@tanstack/react-query"
import { updateProfile } from "@/api/auth"
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
import { useAuthStore } from "@/store/authStore"
import Button from "@/components/ui/Button"
import "./Login.css"
interface OnboardingFormValues {
display_name: string
}
const WechatOnboarding: React.FC = () => {
const navigate = useNavigate()
const setUser = useAuthStore((state) => state.setUser)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const user = useAuthStore((state) => state.user)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
const [form] = Form.useForm<OnboardingFormValues>()
// 同步防连点守卫:antd loading 要等 React 重渲染后才禁用按钮,
// 连点两次时第一次的 mutation 刚触发、重渲染未发生,第二次 click 仍会进来
// (截图里 PATCH /me 405 出现两次就是连点导致的重复提交)
const submittingRef = useRef(false)
const saveMutation = useMutation({
mutationFn: (displayName: string) => updateProfile({ display_name: displayName }),
})
// 已登录且资料已完善的用户不该停留在引导页
if (isAuthenticated && hasAccessToken && user?.profile_completed === true) {
return <Navigate to="/app/dashboard" replace />
}
// 未登录(如手动输入 URL)回登录页
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
const onFinish = async (values: OnboardingFormValues) => {
if (submittingRef.current) return
submittingRef.current = true
try {
const updated = await saveMutation.mutateAsync(values.display_name.trim())
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
setUser({ ...updated, profile_completed: true })
message.success("欢迎加入小虾智剪!")
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch (err) {
// 透传后端真实原因(如接口异常/校验失败);拦截器已弹过的不重复弹
if (!isErrorMsgShown(err)) {
message.error(`昵称保存失败:${getErrorMessage(err, "请稍后重试")}`)
}
submittingRef.current = false
}
// 成功时页面跳走,不复位
}
return (
<div className="xx-auth-page">
<div className="xx-auth-card">
<div className="xx-auth-header">
<div className="xx-auth-brand">
<span className="xx-auth-logo">🦐</span>
<span className="xx-auth-brand-name"></span>
</div>
<p>使</p>
</div>
<Form
form={form}
name="wechat-onboarding"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
// 不预填:新微信用户必须自己输入昵称(user.display_name 可能是微信昵称/系统占位)
initialValues={{ display_name: "" }}
>
<Form.Item
name="display_name"
label="昵称"
rules={[
{ required: true, message: "请输入昵称" },
{ whitespace: true, message: "昵称不能为空白" },
{ min: 1, max: 20, message: "昵称长度需在 1-20 个字符之间" },
]}
extra="昵称将展示在您的作品和账户中,之后可在个人设置中修改"
>
<Input placeholder="请输入您的昵称" size="large" maxLength={20} showCount />
</Form.Item>
<Form.Item>
<Button
buttonType="primary"
buttonSize="lg"
htmlType="submit"
loading={saveMutation.isPending}
disabled={saveMutation.isPending}
style={{ width: "100%" }}
>
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
</Button>
</Form.Item>
</Form>
</div>
</div>
)
}
export default WechatOnboarding
@@ -108,10 +108,7 @@ export function usePlanLoading({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration:
seg.duration_min != null && seg.duration_max != null
? (seg.duration_min + seg.duration_max) / 2
: 5,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
@@ -43,10 +43,7 @@ export function useTemplateDetail({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration:
seg.duration_min != null && seg.duration_max != null
? (seg.duration_min + seg.duration_max) / 2
: 5,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
+95 -348
View File
@@ -1,12 +1,12 @@
/**
* 智能剪辑页面Issue #1677 多视频批量生成,修正版)
* 固定 6 步向导:模板(弹数量) → 素材 → 配音 → 标题 → 确认生成 → 封面,单视频与批量完全一致
* 智能剪辑页面 — 前端实时预览架构
* 6 步向导:选择模板 → 素材 → 配音 → 标题(含预览) → 确认生成 → 选择封面
*
* 架构:
* - 预览全部为纯前端 Canvas 实时播放(FrontendPreviewPlayer),不调任何后端渲染接口:
* N=1 单播放器;N>1 CanvasPreviewGridvariantSeed 让素材排布/起始点不同,画面有差异)
* - 步骤5确认生成:正式生成接口(count + titles[]/voice_library_ids[]/cover_urls[]),
* 批量时逐任务独立进度/失败重试(BatchGenerationGrid
* - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
* - 步骤 5 右侧内联播放生成中的/最终视频
* - 步骤 6 封面从最终成片中智能选帧(MediaKit)
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
*/
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd"
@@ -17,18 +17,13 @@ import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
import PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
@@ -45,7 +40,6 @@ const GeneratePage: React.FC = () => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -59,8 +53,10 @@ const GeneratePage: React.FC = () => {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
videoRatio,
@@ -76,51 +72,8 @@ const GeneratePage: React.FC = () => {
setStoredSourceEditPlanId,
serverClips,
setServerClips,
previewCount,
setPreviewCount,
previewTitles,
setPreviewTitles,
voiceModePerVideo,
setVoiceModePerVideo,
voiceLibraryIds,
setVoiceLibraryIds,
previewCovers,
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
} = formState
const isBatch = previewCount > 1
/* ── 配音选择同步:共用配音 ↔ 变体数组 ── */
// 触发场景:①共用配音变化 ②批量模式进入/退出 ③独立→共用切换(需把所有变体刷成共用配音)
// 独立模式下:仅同步变体[0](其选择器绑定共用配音),用户单独选择的其他变体不覆盖
const prevVoiceSyncRef = useRef({
voice: selectedVoice,
batch: isBatch,
perVideo: voiceModePerVideo,
})
useEffect(() => {
const prev = prevVoiceSyncRef.current
const voiceChanged = prev.voice !== selectedVoice
const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo
prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo }
if (!voiceChanged && !modeChanged) return
if (!isBatch) return
if (!voiceModePerVideo) {
// 共用模式(含刚从独立切回):所有变体跟随共用配音,未选择的补默认值
setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice))
} else if (voiceChanged) {
// 独立模式下共用配音变化:仅同步变体[0](与共用选择器绑定),其余不覆盖
setVoiceLibraryIds((prevIds) =>
(prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)),
)
}
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── 标题样式回调 ── */
const styleUpdaters = useTitleStyleUpdaters({
titleSettings,
@@ -135,23 +88,16 @@ const GeneratePage: React.FC = () => {
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
const ttsAbortRef = useRef<AbortController | null>(null)
// TTS 试听文案:批量跟随变体0标题(仅取首项,避免编辑其他变体标题触发多余 TTS 请求)
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
useEffect(() => {
// #1750:批量模式每变体独立/共用配音由 useVariantVoicePreview 接管(含独立模式每卡片挂各自 URL),
// 本 effect 只服务单视频,批量模式直接跳过(避免共用 TTS 结果错误下发给所有变体)
if (isBatch) return
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
if (voiceAsset?.file_url) {
setPreviewVoiceAudioUrl(voiceAsset.file_url)
return
}
// 单视频 TTS 文案跟随主标题
const ttsTitle = titleSettings.title
const voiceId = selectedClonedVoice || selectedVoice
if (!voiceId || !ttsTitle) {
if (!voiceId || !titleSettings.title) {
setPreviewVoiceAudioUrl(null)
return
}
@@ -161,7 +107,7 @@ const GeneratePage: React.FC = () => {
ttsAbortRef.current = controller
let cancelled = false
previewTts({ text: ttsTitle, voice_id: voiceId })
previewTts({ text: titleSettings.title, voice_id: voiceId })
.then((res) => {
if (!cancelled && res.audio_url) {
setPreviewVoiceAudioUrl(res.audio_url)
@@ -178,17 +124,10 @@ const GeneratePage: React.FC = () => {
cancelled = true
controller.abort()
}
}, [
selectedVoice,
selectedClonedVoice,
titleSettings.title,
variant0Title,
isBatch,
voiceMaterials,
])
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
/* ── 克隆声音 ── */
const { addClone } = useCloneProgress()
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
const handleCloneSuccess = (voice: VoiceClone) => {
addClone(voice)
@@ -217,67 +156,17 @@ const GeneratePage: React.FC = () => {
[bgm, currentTemplate],
)
/* ── 加载素材详情(供前端预览播放器使用) ── */
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验 ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const {
assets: previewAssets,
ready: previewAssetsReady,
ensureAssets,
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
previewAssetIds,
previewAssetsEnabled,
)
/* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */
const previewReady = previewAssetsReady && !!currentTemplate
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
const {
clipsByVariant: variantClips,
planIdsByVariant: variantPlanIds,
loading: variantClipsLoading,
error: variantClipsError,
retry: retryVariantClips,
} = useBatchVariantPlans({
enabled: isBatch && currentStep === 4 && previewAssetsReady,
count: previewCount,
templateId: selectedTemplate || "",
assetIds: previewAssetIds,
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
})
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
hook 内部自取配音素材库并做 TTS 缓存/串行/防竞态 */
const variantVoiceAudioUrls = useVariantVoicePreview({
enabled: isBatch,
count: previewCount,
perVideo: voiceModePerVideo,
sharedVoiceId: selectedVoice || "",
clonedVoiceId: selectedClonedVoice || "",
variantVoiceIds: voiceLibraryIds || [],
titles: previewTitles || [],
})
/* ── 变体计划 clips 引用的素材可能不在已选列表中(#1750):差集补拉素材详情,
保证播放器拿得到 file_url,而不是静默丢片段/用假数据冒充 */
const clipAssetIds = useMemo(() => {
if (!isBatch || !variantClips?.length) return []
const ids = new Set<string>()
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
return Array.from(ids)
}, [isBatch, variantClips])
useEffect(() => {
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
}, [clipAssetIds, ensureAssets])
/* ── 勾选变体 ── */
const toggleVariantSelect = useCallback(
(index: number) => {
setSelectedVariantIds((prev) => {
const list = prev || []
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
})
},
[setSelectedVariantIds],
/* ── 预览就绪:素材已加载,且有模板 ── */
const previewReady = useMemo(
() => previewAssetsReady && !!currentTemplate,
[previewAssetsReady, currentTemplate],
)
/* ── 视频生成核心逻辑 ── */
@@ -287,10 +176,8 @@ const GeneratePage: React.FC = () => {
generated,
generateError,
generatedVideos,
batchTasks,
generate: handleGenerate,
retry: handleRetryGenerate,
retryBatchTask: handleRetryBatchTask,
dismissError: handleDismissError,
download: handleDownload,
share: handleShare,
@@ -310,96 +197,29 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
// #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片
variantPlanIds,
previewTaskId,
bgmConfig,
previewCount,
variantTitles: previewTitles,
variantVoiceLibraryIds: voiceLibraryIds,
voiceModePerVideo,
variantCoverUrls: previewCovers,
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
onGenerationSuccess: () => {
setPreviewTaskId(null)
setStoredSourceEditPlanId(null)
},
})
/* ── 数量弹窗确认:设置数量 + 同步批量数组长度 + 进入步骤2 ── */
const handleCountConfirm = useCallback(
(count: number) => {
setPreviewCount(count)
setCountModalOpen(false)
// 同步批量数组长度
setPreviewTitles((prev) => {
const list = prev || []
const base = list[0] || titleSettings.title || ""
return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
setCurrentStep(2)
},
[
setPreviewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
setCurrentStep,
titleSettings.title,
selectedVoice,
],
)
/* ── 步骤4「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤5看实时进展 ── */
/* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
const handleConfirmGenerate = useCallback(async () => {
// 标题校验:批量只校验已勾选的变体;单视频校验主标题
if (isBatch) {
if (selectedVariantIds.length === 0) {
message.warning("请至少勾选一个视频")
return
}
const missing = selectedVariantIds.some((i) => !previewTitles[i]?.trim())
if (missing) {
message.warning("请为每个勾选的视频输入标题")
return
}
} else if (!titleSettings.title?.trim()) {
// 与 buildPayload.validateGenerateInputs 一致:AI 自动选标题模式(aiAutoSelect
// 允许空标题由后端生成;手动模式必须填写,避免提交空标题
if (!titleSettings.aiAutoSelect) {
message.warning("请先选择或输入标题")
return
}
if (!titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (!previewReady) {
message.warning("预览素材正在加载,请稍候")
message.warning("预览视频正在加载,请稍候")
return
}
const ok = await handleGenerate()
if (ok) {
// 单视频与批量一致:任务创建成功后进入步骤5「确认生成」看实时渲染进展
setCurrentStep(5)
}
}, [
isBatch,
selectedVariantIds,
previewTitles,
titleSettings.aiAutoSelect,
titleSettings.title,
previewReady,
handleGenerate,
setCurrentStep,
])
}, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
@@ -410,19 +230,13 @@ const GeneratePage: React.FC = () => {
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady,
generated,
onOpenCountModal: () => setCountModalOpen(true),
})
/* ── 最终成片(单视频右侧播放) ── */
/* ── 最终成片(步骤5/6 右侧播放) ── */
const finalVideo = generatedVideos[0]
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
const layoutClassName = useMemo(() => {
if (currentStep === 4) return "xx-generate-layout step4-layout"
return "xx-generate-layout full-width"
}, [currentStep])
/* ================================================================
渲染
================================================================ */
@@ -433,63 +247,8 @@ const GeneratePage: React.FC = () => {
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={layoutClassName}>
{/* ════ 步骤4:左侧预览大区域(纯前端 Canvas 实时预览) ════ */}
{currentStep === 4 && !!currentTemplate && (
<div className="xx-generate-preview-col">
{!isBatch ? (
/* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */
<FrontendPreviewPlayer
assets={previewAssets}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
) : (
/* 批量:N 个前端 Canvas 预览网格(不调任何后端渲染接口,秒开) */
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {previewCount} </h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
</span>
</div>
<CanvasPreviewGrid
count={previewCount}
assets={previewAssets}
videoRatio={videoRatio}
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls}
variantClips={variantClips}
clipsLoading={variantClipsLoading}
clipsError={variantClipsError}
onRetryClips={retryVariantClips}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
/>
</div>
)}
</div>
)}
{/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 确认生成进度 / 步骤6 封面 ════ */}
<div className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}`}>
{/* ════ 左侧:表单区 ════ */}
<div className="xx-generate-form">
<GenerateStepContent
currentStep={currentStep}
@@ -521,81 +280,25 @@ const GeneratePage: React.FC = () => {
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
onTemplateInvalid={handleInvalidTemplate}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
onSelectedClonedVoiceChange={setSelectedClonedVoice}
clonedVoices={clonedVoices}
addClone={addClone}
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={setCloneModalOpen}
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
onRetry={handleRetryGenerate}
onRetryBatchTask={handleRetryBatchTask}
onDismissError={handleDismissError}
batchTasks={batchTasks}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={setPreviewTitles}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={setVoiceModePerVideo}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={setVoiceLibraryIds}
previewCovers={previewCovers}
onPreviewCoversChange={setPreviewCovers}
selectedVariantIds={selectedVariantIds}
presetVoices={presetVoices}
/>
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
{currentStep === 5 && !isBatch && generated && finalVideo && (
<div
style={{
display: "flex",
justifyContent: "center",
marginTop: 16,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
background: "#000",
borderRadius: 12,
padding: 8,
maxWidth: 320,
width: "100%",
}}
>
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay
style={{
width: "auto",
maxWidth: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "contain",
borderRadius: 8,
}}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁
</button>
</div>
</div>
</div>
)}
<GenerateStepActions
currentStep={currentStep}
onPrev={goPrev}
@@ -604,18 +307,62 @@ const GeneratePage: React.FC = () => {
generating={generating}
generated={generated}
generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
</div>
</div>
{/* 数量选择弹窗 */}
<PreviewCountModal
open={countModalOpen}
defaultCount={1}
onConfirm={handleCountConfirm}
onCancel={() => setCountModalOpen(false)}
/>
{/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
<div className="xx-generate-right-col">
{currentStep === 4 && !!currentTemplate && (
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
)}
{currentStep >= 5 && generated && finalVideo && (
<div className="xx-inline-video-player">
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay={currentStep === 5}
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁
</button>
</div>
</div>
)}
</div>
</div>
{/* 音色克隆弹窗 */}
<CloneModal
@@ -1,134 +0,0 @@
/**
* 第5步「确认生成」— 批量渲染进度网格(Issue #1677
*
* N 个正式生成任务各自独立卡片:进度条 / 成功成片播放 / 失败原因 + 单独重试。
* 数据来自 useGenerateVideo 的 batchTasksuseGenerationPolling 实时回传)。
*/
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
import type { GeneratedVideo } from "@/api/template-editor"
interface BatchGenerationGridProps {
tasks: BatchTaskState[]
/** 变体标题(按变体序号取) */
titles: string[]
/** 失败任务重试 */
onRetryTask: (taskId: string) => void
}
const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
tasks,
titles,
onRetryTask,
}) => {
const sorted = [...tasks].sort(
(a, b) => Number(a.variantIndex || 0) - Number(b.variantIndex || 0),
)
return (
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {tasks.length} </h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
{tasks.filter((t) => t.status === "completed").length} / {tasks.length}
</span>
</div>
<div
className="xx-batch-gen-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 180px))",
justifyContent: "center",
justifyItems: "center",
gap: 14,
width: "100%",
}}
>
{sorted.map((task) => {
const title = titles[task.variantIndex] || `视频 ${task.variantIndex + 1}`
const video = (task.videos?.[0] || null) as GeneratedVideo | null
return (
<div
key={task.taskId}
className={`xx-batch-gen-card status-${task.status}`}
style={{ maxWidth: 240 }}
>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" ? (
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 6 }} />
) : task.status === "failed" ? (
<CloseCircleOutlined style={{ color: "#ef4444", marginRight: 6 }} />
) : (
<LoadingOutlined style={{ color: "#1677ff", marginRight: 6 }} />
)}
{task.variantIndex + 1}{title}
</span>
</div>
<div className="xx-batch-gen-card-body">
{task.status === "running" && (
<>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(task.progress, 100)}%` }}
/>
</div>
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
</>
)}
{task.status === "completed" && video && (
// 竖屏自适应容器(#1750):成片固定 1080×19209:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "transparent",
borderRadius: 8,
padding: 0,
}}
>
<video
src={video.download_url || video.file_url}
controls
style={{
width: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "cover",
borderRadius: 4,
background: "transparent",
}}
poster={video.thumbnail_url}
/>
</div>
)}
{task.status === "completed" && !video && (
<div className="xx-batch-gen-card-done"> </div>
)}
{task.status === "failed" && (
<div className="xx-batch-gen-card-failed">
<div className="xx-batch-gen-card-err">{task.error || "生成失败"}</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={() => onRetryTask(task.taskId)}
>
🔄
</button>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
export default BatchGenerationGrid
@@ -1,148 +0,0 @@
/**
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划)
*
* N 个 FrontendPreviewPlayer 网格排列:
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口;
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
* 加载中 → 9:16 占位防塌陷;
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
* - 勾选框决定提交时生成哪些变体;
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
*/
import React, { useState } from "react"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
import { Button } from "antd"
import type { TitleSettings } from "../types"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
interface CanvasPreviewGridProps {
count: number
assets: AssetItem[]
videoRatio: string
titles: string[]
titleSettings: TitleSettings
/**
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
*/
voiceAudioUrls?: (string | null)[]
/**
* 各变体的后端真实片段(#1744/#1750):长度=count。
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
*/
variantClips?: EditPlanClip[][]
/** 是否正在向后端申请变体计划 */
clipsLoading?: boolean
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
clipsError?: boolean
/** 用户点击错误态「重试」 */
onRetryClips?: () => void
/** 勾选的变体序号 */
selectedIds: number[]
onToggleSelect: (index: number) => void
/** 生成中禁止勾选 */
selectable?: boolean
}
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
count,
assets,
videoRatio,
titles,
titleSettings,
voiceAudioUrls,
variantClips,
clipsLoading = false,
clipsError = false,
onRetryClips,
selectedIds,
onToggleSelect,
selectable = true,
}) => {
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750 ──
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10clamp
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
return (
<div className="xx-canvas-grid">
{Array.from({ length: count }, (_, i) => {
const checked = selectedIds.includes(i)
return (
<div
key={i}
className={`xx-canvas-grid-card${checked ? " selected" : ""}`}
data-variant={i}
>
<div className="xx-canvas-grid-card-bar">
<label className="xx-canvas-grid-check">
<input
type="checkbox"
checked={checked}
disabled={!selectable}
onChange={() => onToggleSelect(i)}
/>
<span> {i + 1}</span>
</label>
</div>
{clipsError ? (
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
<div className="xx-variant-clips-status" role="alert">
<span className="xx-variant-clips-error-text"></span>
{i === 0 && onRetryClips ? (
<Button
size="small"
icon={<ReloadOutlined />}
onClick={onRetryClips}
style={{ marginTop: 12 }}
>
</Button>
) : null}
</div>
) : clipsLoading || !variantClips?.[i]?.length ? (
// ── 加载态:9:16 占位防塌陷 ──
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
<LoadingOutlined />
<span style={{ marginTop: 8 }}></span>
</div>
) : (
<FrontendPreviewPlayer
assets={assets}
videoRatio={videoRatio}
ready={assets.length > 0}
playToken={i}
serverClips={variantClips[i]}
variantTitle={titles[i] || ""}
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
activePlayToken={activePlayToken}
onPlayTokenChange={setActivePlayToken}
compact
titleSettings={{
title: titles[i] || "",
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
/>
)}
</div>
)
})}
</div>
)
}
export default CanvasPreviewGrid
@@ -13,19 +13,18 @@ import {
PauseCircleOutlined,
SoundOutlined,
LoadingOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
interface FrontendPreviewPlayerProps {
assets: AssetItem[]
template: EditingTemplate | null
videoRatio: string
ready: boolean
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
serverClips?: EditPlanClip[]
voiceAudioUrl?: string
titleSettings?: {
@@ -42,22 +41,6 @@ interface FrontendPreviewPlayerProps {
posY?: number | null
}
onTitlePositionChange?: (posX: number, posY: number) => void
/**
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
* 持有播放权的实例播放,其余自动暂停。
*/
playToken?: number
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
variantTitle?: string
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
compact?: boolean
/**
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
*/
activePlayToken?: number | null
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
onPlayTokenChange?: (token: number | null) => void
}
function formatTime(seconds: number): string {
@@ -67,107 +50,74 @@ function formatTime(seconds: number): string {
}
/**
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback
*
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
* 将素材映射为播放片段(复用原逻辑
*/
function buildPlaybackSegments(
assets: AssetItem[],
template: EditingTemplate | null,
serverClips?: EditPlanClip[],
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
speedFactor = 1,
): PlaybackSegment[] {
if (!assets.length || !serverClips || serverClips.length === 0) return []
if (!assets.length) return []
// Build asset lookup map
const assetMap = new Map(assets.map((a) => [a.id, a]))
const segments: PlaybackSegment[] = []
// speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速
const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1
for (const clip of serverClips) {
const asset = assetMap.get(clip.asset_id)
if (!asset) continue
const assetDuration = asset.duration || asset.metadata?.duration || 0
const startTime = clip.start_time || 0
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
const rawClipDuration = clip.duration || 0
// #1754:按 speedFactor 缩放片段时长,使总时长匹配配音
const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration
const endTime =
assetDuration > 0
? Math.min(startTime + clipDuration, assetDuration)
: startTime + clipDuration
const videoUrl = asset.file_url || asset.storage_key
segments.push({
assetId: asset.id,
videoUrl,
startTime,
endTime,
order: clip.order,
playbackRate: invSpeed !== 1 ? speedFactor : undefined,
})
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
if (serverClips && serverClips.length > 0) {
const segments: PlaybackSegment[] = []
for (const clip of serverClips) {
const asset = assetMap.get(clip.asset_id)
if (!asset) continue
const assetDuration = asset.duration || asset.metadata?.duration || 30
const startTime = clip.start_time || 0
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
const videoUrl = asset.file_url || asset.storage_key
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
}
if (segments.length > 0) {
return segments.sort((a, b) => a.order - b.order)
}
}
return segments.sort((a, b) => a.order - b.order)
// Fallback: 本地构建片段(与旧行为一致)
const templateSegments = template?.segments || []
const segments: PlaybackSegment[] = []
assets.forEach((asset, i) => {
const assetDuration = asset.duration || asset.metadata?.duration || 30
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
: Math.min(assetDuration, 10)
const startTime = 0
const endTime = Math.min(startTime + segDuration, assetDuration)
const videoUrl = asset.file_url || asset.storage_key
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
})
return segments
}
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets,
template,
videoRatio,
ready,
serverClips,
voiceAudioUrl,
titleSettings,
onTitlePositionChange,
playToken,
variantTitle,
compact = false,
activePlayToken = null,
onPlayTokenChange,
}) => {
// #1754:测量配音时长,计算缩放因子
const [voiceDuration, setVoiceDuration] = useState(0)
useEffect(() => {
if (!voiceAudioUrl) {
setVoiceDuration(0)
return
}
const audio = new Audio()
audio.preload = "metadata"
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration)) {
setVoiceDuration(audio.duration)
}
}
audio.addEventListener("loadedmetadata", onLoaded)
audio.src = voiceAudioUrl
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
}
}, [voiceAudioUrl])
// #1756clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
const rawClipsDuration = useMemo(() => {
if (!serverClips?.length) return 0
return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0)
}, [serverClips])
// #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速
const speedFactor = useMemo(() => {
if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1
const ratio = rawClipsDuration / voiceDuration
return Math.abs(ratio - 1) > 0.05 ? ratio : 1
}, [voiceDuration, rawClipsDuration])
const segments = useMemo(
() => buildPlaybackSegments(assets, serverClips, speedFactor),
[assets, serverClips, speedFactor],
() => buildPlaybackSegments(assets, template, serverClips),
[assets, template, serverClips],
)
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
const effectiveTitle = variantTitle ?? titleSettings?.title
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
const TITLE_MARGIN_TOP = 180
const TITLE_MARGIN_BOTTOM = 100
const TITLE_MARGIN_TOP = 120
const TITLE_MARGIN_BOTTOM = 60
const TITLE_MARGIN_SIDE = 40
const playRes = (() => {
switch (videoRatio) {
@@ -284,7 +234,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── Canvas 播放器(WebCodecs 路径) ──
const canvasTitle = titleSettings
? {
text: effectiveTitle || "标题预览",
text: titleSettings.title || "标题预览",
fontSize: titleSettings.size,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
@@ -339,7 +289,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
canPlay: videoCanPlay,
togglePlayPause: videoTogglePlayPause,
seekTo: videoSeekTo,
pause: videoPause,
videoRefs,
} = useSegmentScheduler(segments)
@@ -354,10 +303,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
const [muted, setMuted] = useState(false)
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
const hasVoice = !!voiceAudioUrl
useEffect(() => {
if (!voiceAudioUrl) {
@@ -375,8 +320,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
audioRef.current.muted = muted
}, [voiceAudioUrl, muted])
}, [voiceAudioUrl])
useEffect(() => {
const audio = audioRef.current
@@ -415,42 +359,17 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
)
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
useEffect(() => {
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) canvasControls.pause()
} else if (isPlaying) {
videoPause()
}
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayToken, playToken, effectiveUseWebCodecs])
const handleTogglePlay = useCallback(() => {
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) {
canvasControls.pause()
onPlayTokenChange?.(null)
} else {
if (playToken != null) onPlayTokenChange?.(playToken)
canvasControls.play()
}
} else {
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
videoTogglePlayPause()
}
}, [
effectiveUseWebCodecs,
canvasState.isPlaying,
canvasControls,
videoTogglePlayPause,
isPlaying,
playToken,
onPlayTokenChange,
])
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
@@ -601,15 +520,13 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
style={{
position: "relative",
width: "100%",
maxWidth: compact ? "100%" : 280,
margin: compact ? 0 : "0 auto",
maxWidth: 280,
aspectRatio: "9 / 16",
background: compact ? "transparent" : "#0a0a0a",
borderRadius: compact ? 10 : 24,
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow: compact
? "inset 0 0 0 1px rgba(255,255,255,0.06)"
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
}}
>
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
@@ -628,7 +545,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectFit: "contain",
}}
/>
</div>
@@ -639,7 +556,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
segments.map((seg, i) => (
<video
key={seg.assetId}
muted={hasVoice || muted}
muted
ref={(el) => {
videoRefs.current[i] = el
}}
@@ -650,7 +567,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
objectFit: "contain",
background: "#000",
zIndex: 1,
opacity: i === videoCurrentSegIdx ? 1 : 0,
@@ -693,8 +610,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
? { top: "50%", transform: "translate(-50%, -50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
pointerEvents: "auto",
cursor: onTitlePositionChange ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
@@ -724,7 +641,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
: undefined,
}}
>
{(effectiveTitle || "").split(/[/]/).map((part, i) => (
{titleSettings.title.split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
@@ -774,45 +691,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</button>
)}
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
{segments.length > 0 && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={() => setMuted((m) => !m)}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
@@ -823,9 +701,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontSize: 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
padding: "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
@@ -844,8 +722,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
gap: 10,
padding: "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
@@ -858,10 +736,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
fontSize: 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
width: 32,
height: 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
@@ -881,9 +759,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: compact ? 10 : 11,
fontSize: 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
minWidth: 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
@@ -0,0 +1,96 @@
import React from "react"
import { Typography } from "antd"
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
const { Text } = Typography
interface GenerateResultPanelProps {
generated: boolean
generating: boolean
progress: number
generateError: string | null
generatedVideos: GeneratedVideo[]
onVideoPreview: (video: GeneratedVideo) => void
onDownload: () => void
onShare: () => void
onGoToLibrary: () => void
}
const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
generated,
generating,
progress,
generateError,
generatedVideos,
onVideoPreview,
onDownload,
onShare,
onGoToLibrary,
}) => {
return (
<div className="xx-generate-result">
<div className="xx-result-header">
<h3></h3>
{generated && generatedVideos.length > 0 && (
<span className="xx-result-count">{generatedVideos.length} </span>
)}
</div>
{generating && <ProgressIndicator progress={progress} />}
{generateError && !generating && (
<div className="xx-result-empty">
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
<Text strong style={{ display: "block", marginBottom: 4 }}>
</Text>
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{typeof generateError === "string" ? generateError : "请重试"}
</Text>
</div>
)}
{!generated && !generating && !generateError && (
<div className="xx-result-empty">
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
</Text>
<Text style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
</Text>
</div>
)}
{generated && generatedVideos.length > 0 && (
<div className="xx-video-grid">
{generatedVideos.map((video, idx) => (
<ResultVideoCard
key={video.id || idx}
video={video}
index={idx}
onPreview={onVideoPreview}
onDownload={onDownload}
onShare={onShare}
/>
))}
</div>
)}
{generated && (
<div className="xx-result-footer">
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onGoToLibrary}>
</button>
</div>
)}
</div>
)
}
export default GenerateResultPanel
@@ -1,9 +1,9 @@
/**
* GeneratePage 步骤底部操作按钮Issue #1677 修正:固定 6 步)
* GeneratePage 步骤底部操作按钮
*
* 步骤 1~3:上一步 / 下一步
* 步骤 4选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤5
* 步骤 5(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步
* 步骤 4标题+预览):上一步 / 确认生成视频(点击后直接创建最终渲染任务,成功后跳步骤5
* 步骤 5(确认生成):上一步 / 下一步(渲染中禁用,渲染完成后可进入封面)
* 步骤 6(选择封面):仅上一步
*/
import React from "react"
@@ -12,16 +12,14 @@ export interface GenerateStepActionsProps {
currentStep: number
onPrev: () => void
onNext: () => void
/** 步骤4:确认生成视频(校验 + 创建渲染任务) */
/** 步骤4:确认生成视频(校验 + 创建渲染任务 + 成功后进入步骤5 */
onConfirmGenerate: () => void | Promise<void>
generating: boolean
generated: boolean
generateError: string | null
/** 批量模式下勾选的视频数量(N=1 时为1) */
selectedCount?: number
}
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
currentStep,
onPrev,
onNext,
@@ -29,10 +27,9 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
generating,
generated,
generateError,
selectedCount = 1,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步 */
/* 步骤 1~3:上一步 / 下一步(必填校验由 useStepNavigation.goNext 统一处理) */
if (currentStep < 4) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
@@ -41,46 +38,50 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 4选择标题 — 确认生成 */
/* 步骤 4:确认生成视频(触发按钮在标题页) */
if (currentStep === 4) {
if (generating) {
return (
<button className="xx-btn xx-btn-primary" disabled>
</button>
)
}
if (generateError) {
return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
🔄
🔄
</button>
)
}
if (generated) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
)
}
return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
{selectedCount > 1 ? `✨ 确认生成 ${selectedCount} 个视频` : "✨ 确认生成视频"}
</button>
)
}
/* 步骤 5确认生成进度页 — 全部完成后下一步进封面 */
/* 步骤 5渲染中禁用,完成后下一步进封面 */
if (currentStep === 5) {
if (generated) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
)
}
return (
<button className="xx-btn xx-btn-primary" disabled>
<button
className="xx-btn xx-btn-primary"
onClick={onNext}
disabled={generating || !generated}
>
{generating ? "视频生成中…" : "下一步 →"}
</button>
)
}
/* 步骤 6封面,最后一步):无主按钮 */
/* 步骤 6(最后一步):无主按钮 */
return null
}
@@ -1,20 +1,20 @@
/**
* GeneratePage 步骤内容渲染
* 步骤顺序(6步Issue #1677 修正):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
* 步骤4预览(Canvas 网格)与步骤5进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域。
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3VoiceWithMode from "./Step3VoiceWithMode"
import Step3VoiceSelect from "../components/Step5VoiceSelect"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate"
import Step6CoverSettings from "../components/Step6CoverSettings"
import BatchGenerationGrid from "./BatchGenerationGrid"
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
import type { GeneratedVideo } from "@/api/template-editor"
export interface GenerateStepContentProps {
@@ -50,8 +50,15 @@ export interface GenerateStepContentProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/** 当前模板创建片段被判失效(404/400/422)时的自动回退回调(#1777 */
onTemplateInvalid?: () => boolean
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (id: string) => void
clonedVoices: VoiceClone[]
addClone: (voice: VoiceClone) => void
hasProcessing: boolean
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
/* 生成 */
generating: boolean
generated: boolean
@@ -59,26 +66,13 @@ export interface GenerateStepContentProps {
progress: number
generatedVideos: GeneratedVideo[]
onRetry: () => void
onRetryBatchTask: (taskId: string) => void
onDismissError: () => void
/** 批量:每个正式生成任务的独立状态(步骤5进度网格) */
batchTasks: BatchTaskState[]
/* 其他 */
presetVoices: PresetVoiceItem[]
/** BGM 开关 */
bgm: boolean
/** BGM 配置(来自模板) */
bgmConfig?: { enabled: boolean; music_id?: string }
/* ── 批量生成(#1677)── */
previewCount: number
previewTitles: string[]
onPreviewTitlesChange: (titles: string[]) => void
voiceModePerVideo: boolean
onVoiceModePerVideoChange: (v: boolean) => void
voiceLibraryIds: string[]
onVoiceLibraryIdsChange: (ids: string[]) => void
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
/** 批量模式勾选的变体索引(封面卡片按勾选顺序展示) */
selectedVariantIds?: number[]
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -110,25 +104,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVoice,
onSelectedVoiceChange,
onServerClipsChange,
onTemplateInvalid,
voiceMode,
selectedClonedVoice,
clonedVoices,
generating,
generated,
generateError,
progress,
onRetry,
generatedVideos,
batchTasks,
onRetryBatchTask,
previewCount,
previewTitles,
onPreviewTitlesChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
onRetry,
onDismissError,
presetVoices,
} = props
/* 当前模板的 segments,传给 Step2 构建 clips */
@@ -156,19 +142,13 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedTemplate={selectedTemplate}
templateSegments={templateSegments}
onServerClipsChange={onServerClipsChange}
onTemplateInvalid={onTemplateInvalid}
/>
)
case 3:
return (
<Step3VoiceWithMode
previewCount={previewCount}
<Step3VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
/>
)
case 4:
@@ -187,58 +167,31 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
/>
)
case 5:
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态(完成后只显示成片播放器,播放器在按钮上方) */
if (previewCount > 1) {
return (
<BatchGenerationGrid
tasks={batchTasks}
titles={previewTitles}
onRetryTask={onRetryBatchTask}
/>
)
}
/* 单视频:生成中显示进度卡、失败显示重试卡;生成完成后不再渲染提示卡,页面只保留成片播放器+操作按钮 */
if (generated && !generating && !generateError) return null
return (
<div className="xx-form-section">
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{Math.round(progress)}%
</div>
<div className="xx-gen-progress-sub">
</div>
</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">{generateError}</div>
</div>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄
</button>
</div>
)}
</div>
<Step5ConfirmGenerate
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
selectedMaterials={selectedMaterials}
smartSelectedIds={smartSelectedIds}
title={titleSettings.title}
voiceMode={voiceMode}
selectedVoice={selectedVoice}
selectedClonedVoice={selectedClonedVoice}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
coverSettings={coverSettings}
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
onRetry={onRetry}
onDismissError={onDismissError}
/>
)
case 6:
return (
@@ -248,11 +201,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
generatedVideos={generatedVideos}
previewCount={previewCount}
previewTitles={previewTitles}
previewCovers={previewCovers}
onPreviewCoversChange={onPreviewCoversChange}
selectedVariantIndexes={selectedVariantIds}
/>
)
default:
@@ -1,127 +0,0 @@
/**
* 生成数量选择弹窗(Issue #1677
* Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10)
* 默认 1,回车 = 1(零额外操作)
*/
import React, { useState, useEffect, useRef } from "react"
import { MAX_PREVIEW_COUNT } from "../constants"
interface PreviewCountModalProps {
open: boolean
/** 默认值(上次选择,默认1) */
defaultCount?: number
onConfirm: (count: number) => void
onCancel: () => void
}
const PreviewCountModal: React.FC<PreviewCountModalProps> = ({
open,
defaultCount = 1,
onConfirm,
onCancel,
}) => {
const [count, setCount] = useState(defaultCount)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setCount(defaultCount)
// 弹窗打开后聚焦并选中,方便直接回车=默认1
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, defaultCount])
const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1))
const handleConfirm = () => {
onConfirm(clamp(count))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleConfirm()
}
if (e.key === "Escape") {
onCancel()
}
}
if (!open) return null
return (
<div className="xx-modal-mask" onClick={onCancel}>
<div className="xx-modal-box xx-count-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: "0 0 8px", fontSize: 18 }}></h3>
<p style={{ margin: "0 0 20px", fontSize: 13, color: "var(--text-secondary, #666)" }}>
AI
</p>
<div className="xx-count-selector">
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c - 1))}
disabled={count <= 1}
aria-label="减少"
>
</button>
<input
ref={inputRef}
type="number"
min={1}
max={MAX_PREVIEW_COUNT}
value={count}
onChange={(e) => setCount(clamp(parseInt(e.target.value, 10) || 1))}
onKeyDown={handleKeyDown}
className="xx-count-input"
/>
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c + 1))}
disabled={count >= MAX_PREVIEW_COUNT}
aria-label="增加"
>
+
</button>
</div>
<div className="xx-count-quick">
{[1, 3, 5, 10].map((n) => (
<button
key={n}
type="button"
className={`xx-count-chip ${count === n ? "active" : ""}`}
onClick={() => setCount(n)}
>
{n}
</button>
))}
</div>
<div className="xx-count-actions">
<button type="button" className="xx-btn xx-btn-ghost" onClick={onCancel}>
</button>
<button type="button" className="xx-btn xx-btn-primary" onClick={handleConfirm}>
{count === 1 ? "生成 1 个视频" : `生成 ${count} 个视频`}
</button>
</div>
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--text-tertiary, #999)",
textAlign: "center",
}}
>
= 1
</p>
</div>
</div>
)
}
export default PreviewCountModal
@@ -0,0 +1,380 @@
/**
* 右侧预览视频面板 — 服务器渲染预览架构
*
* Step4+: 显示预览面板
* Step5: 播放服务器渲染的真实视频(POST /generation/preview
*
* 架构:
* - 进入 Step4/5 时自动创建服务器预览渲染任务
* - 轮询完成后用 <video> 标签播放返回的 video_url
* - 标题样式编辑时 CSS TitleOverlay 实时叠加预览
* - 素材/配音/BGM 变更自动重新渲染
* - 标题文字/样式变更标记 stale,保留旧视频 + 显示"重新预览"按钮
*
* 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致。
*/
import React, { useMemo, useRef, useState, useEffect } from "react"
import { LoadingOutlined, ReloadOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
import { Button } from "antd"
import type { TitleSettings } from "../types"
import { getFontFamily } from "../constants"
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
interface PreviewVideoPanelProps {
/** 服务器预览状态 */
previewStatus: ServerPreviewStatus
/** 服务器渲染视频 URL */
videoUrl: string | null
/** 渲染进度 0-100 */
progress: number
/** 错误信息 */
error: string | null
/** 重新预览回调 */
onRetry: () => void
/** 视频比例 */
videoRatio: string
/** 标题设置 — CSS 实时预览层 */
titleSettings?: TitleSettings
/** 素材数量 */
assetCount?: number
}
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
const TITLE_MARGIN_TOP = 120
const TITLE_MARGIN_BOTTOM = 60
const TITLE_MARGIN_SIDE = 40
/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY */
function getResolution(ratio: string): { width: number; height: number } {
switch (ratio) {
case "16:9":
return { width: 1920, height: 1080 }
case "1:1":
return { width: 1080, height: 1080 }
case "9:16":
default:
return { width: 1080, height: 1920 }
}
}
function getPositionStyle(
position: string,
playResX: number,
playResY: number,
): React.CSSProperties {
const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100
switch (position) {
case "bottom":
return {
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
case "center":
return {
top: "50%",
transform: "translateY(-50%)",
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
case "top":
default:
return {
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
}
}
function buildTitleStyle(
settings: TitleSettings,
containerHeight: number,
playResY: number,
): React.CSSProperties {
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
const fontSizePx =
containerHeight > 0
? (settings.size / playResY) * containerHeight
: (settings.size / playResY) * 400
const base: React.CSSProperties = {
fontFamily: getFontFamily(settings.font),
fontSize: `${fontSizePx}px`,
color: settings.color || "#ffffff",
fontWeight: settings.bold ? 700 : 400,
fontStyle: settings.italic ? "italic" : "normal",
lineHeight: 1.05,
wordBreak: "break-word",
pointerEvents: "none",
userSelect: "none",
}
if (settings.stroke) base.WebkitTextStroke = "1px #000000"
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
return base
}
/** CSS 标题实时预览覆盖层 */
const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
titleSettings,
videoRatio,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400)
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.contentRect.height
if (h > 0) setContainerHeight(h)
}
})
ro.observe(el)
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
}, [])
const { width: playResX, height: playResY } = getResolution(videoRatio)
const positionStyle = useMemo(
() => getPositionStyle(titleSettings.position, playResX, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps
[titleSettings.position, playResX, playResY],
)
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
containerHeight,
titleSettings.font,
titleSettings.size,
titleSettings.color,
titleSettings.bold,
titleSettings.italic,
titleSettings.stroke,
titleSettings.shadow,
playResY,
],
)
const displayTitle = titleSettings.title?.trim() || "标题预览"
return (
<div
ref={containerRef}
style={{
position: "absolute",
inset: 0,
zIndex: 20,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
{displayTitle.split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</div>
</div>
)
}
/* ── 主组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
previewStatus,
videoUrl,
progress,
error,
onRetry,
videoRatio,
titleSettings,
assetCount,
}) => {
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
const isLoading = previewStatus === "loading"
const isReady = previewStatus === "ready" || previewStatus === "stale"
const isFailed = previewStatus === "failed"
const isIdle = previewStatus === "idle"
const isStale = previewStatus === "stale"
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
<h3></h3>
{isReady && !isStale && <span className="xx-preview-badge"></span>}
{isStale && (
<span
style={{
fontSize: 12,
color: "#faad14",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<ExclamationCircleOutlined />
</span>
)}
{isLoading && <span className="xx-preview-badge"></span>}
</div>
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
{/* 加载中 */}
{isLoading && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 5,
background: "rgba(0,0,0,0.6)",
}}
>
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.9)", fontSize: 14 }}>
{progress > 0 ? `...${progress}%` : "..."}
</p>
<p style={{ marginTop: 4, color: "rgba(255,255,255,0.5)", fontSize: 12 }}>
30-60
</p>
</div>
)}
{/* 空闲状态(尚未触发预览) */}
{isIdle && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.3)",
zIndex: 5,
}}
>
<p style={{ color: "rgba(255,255,255,0.7)", fontSize: 14 }}>...</p>
</div>
)}
{/* 服务器渲染的真实视频 */}
{isReady && videoUrl && (
<video
key={videoUrl}
src={videoUrl}
controls
autoPlay
loop
playsInline
style={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
inset: 0,
zIndex: 1,
}}
/>
)}
{/* 标题样式实时预览层(仅在有视频时叠加) */}
{isReady && titleSettings && (
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
)}
{/* stale 遮罩:配置变更提示 */}
{isStale && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: "10px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.85))",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
zIndex: 30,
}}
>
<span style={{ color: "rgba(255,255,255,0.9)", fontSize: 12 }}>
</span>
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetry}>
</Button>
</div>
)}
{/* 错误状态 */}
{isFailed && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.7)",
zIndex: 10,
padding: 24,
}}
>
<ExclamationCircleOutlined style={{ fontSize: 40, color: "#ff4d4f" }} />
<p
style={{
marginTop: 12,
color: "rgba(255,255,255,0.9)",
fontSize: 14,
textAlign: "center",
}}
>
{error || "预览渲染失败"}
</p>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={onRetry}
style={{ marginTop: 12 }}
>
</Button>
</div>
)}
</div>
{/* 素材信息 */}
{assetCount !== undefined && assetCount > 0 && (
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>{assetCount} </span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{videoRatio}</span>
</div>
</div>
)}
</div>
)
}
export default PreviewVideoPanel
@@ -23,8 +23,6 @@ interface Step2MaterialSelectProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -38,25 +36,16 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
{m.libraries.length === 0 && !m.materialsLoading ? (
<div className="xx-empty-state">
<p></p>
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
</p>
</div>
) : (
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
)}
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
</div>
{m.materialMode === "manual" && (
@@ -1,102 +0,0 @@
/**
* Step3 配音选择(Issue #1677 批量生成)
* - 单视频 / 共用模式:与原配音选择完全一致
* - 独立模式(开关开启):N 个配音选择器,每个视频独立选择
*/
import React from "react"
import Step3VoiceSelect from "./Step5VoiceSelect"
interface Step3VoiceWithModeProps {
previewCount: number
/** 共用配音ID */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
/** 是否独立配音 */
voiceModePerVideo: boolean
onVoiceModePerVideoChange: (v: boolean) => void
/** 各变体独立配音ID */
voiceLibraryIds: string[]
onVoiceLibraryIdsChange: (ids: string[]) => void
}
const Step3VoiceWithMode: React.FC<Step3VoiceWithModeProps> = ({
previewCount,
selectedVoice,
onSelectedVoiceChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
}) => {
const isBatch = previewCount > 1
if (!isBatch) {
return (
<Step3VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
/>
)
}
return (
<div className="xx-form-section">
{/* 共用/独立切换 */}
<div className="xx-title-ai-toggle" style={{ marginBottom: 16 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>
🎙 {voiceModePerVideo ? "每个视频独立配音" : "所有视频共用配音"}
</div>
<div style={{ fontSize: 12, color: "var(--text-tertiary, #999)", marginTop: 2 }}>
{voiceModePerVideo
? `${previewCount} 个视频分别选择不同配音`
: "所有视频使用同一个配音(默认)"}
</div>
</div>
<div
className={`xx-switch ${voiceModePerVideo ? "active" : ""}`}
onClick={() => onVoiceModePerVideoChange(!voiceModePerVideo)}
role="switch"
aria-checked={voiceModePerVideo}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onVoiceModePerVideoChange(!voiceModePerVideo)
}
}}
>
<div className="xx-switch-knob" />
</div>
</div>
{!voiceModePerVideo ? (
<Step3VoiceSelect
heading="🎙️ 共用配音"
description={`所有 ${previewCount} 个视频使用同一个配音,点击卡片可预览播放`}
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
/>
) : (
<div className="xx-per-voice-list">
{Array.from({ length: previewCount }, (_, i) => (
<Step3VoiceSelect
key={i}
heading={`🎙️ 视频 ${i + 1} 的配音`}
description="为这个视频单独选择配音"
compact
selectedVoice={voiceLibraryIds[i] || ""}
onSelectedVoiceChange={(id) => {
const next = [...voiceLibraryIds]
next[i] = id
onVoiceLibraryIdsChange(next)
}}
/>
))}
</div>
)}
</div>
)
}
export default Step3VoiceWithMode
@@ -1,23 +1,17 @@
/**
* Step 4 选择标题(Issue #1677 批量生成
* Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板
*
* 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格),
* 右侧边栏标题设置。本组件渲染在右侧边栏:
* - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归)
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成
* (一次生成 N 个标题,分别填入各变体,可单独换一个)
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
* 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设)
* 右侧FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染)
*/
import React, { useMemo, useState } from "react"
import { Input, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import React from "react"
import { AutoComplete } from "antd"
import { PlayCircleOutlined } from "@ant-design/icons"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
import TitleStylePanel from "./title/TitleStylePanel"
import { AI_TITLE_TEMPLATES } from "../constants"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
@@ -35,42 +29,6 @@ interface Step4TitleSettingsProps {
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* ── 批量生成(#1677)── */
/** 生成数量 */
previewCount?: number
/** 每个变体的标题文字(长度=previewCount */
previewTitles?: string[]
onPreviewTitlesChange?: (titles: string[]) => void
}
/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */
function buildBatchAiTitles(topic: string, count: number): string[] {
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const pool: string[] = []
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style] || []
templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic)))
})
// 洗牌后取前 count 个;不足则轮转补齐
const shuffled = [...pool].sort(() => Math.random() - 0.5)
const out: string[] = []
for (let i = 0; i < count; i++) {
out.push(shuffled[i % shuffled.length] || "")
}
return out
}
function extractTopic(text: string): string {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
return keywords.slice(0, 3).join("")
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -86,195 +44,122 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onApplyPreset,
activePreset,
titlePresets,
previewCount = 1,
previewTitles,
onPreviewTitlesChange,
} = props
const isBatch = previewCount > 1
const [batchAiLoading, setBatchAiLoading] = useState(false)
const [batchAiTopic, setBatchAiTopic] = useState("")
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
const updateVariantTitle = (index: number, val: string) => {
if (!previewTitles || !onPreviewTitlesChange) return
const next = [...previewTitles]
next[index] = val
onPreviewTitlesChange(next)
if (index === 0) {
t.updateTitle(val)
}
}
/** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */
const handleBatchAiGenerate = async (onlyEmpty = false) => {
if (!onPreviewTitlesChange || !previewTitles) return
const topic = (batchAiTopic || t.aiTitleInput || "").trim()
if (!topic) {
message.warning("请先输入主题词,例如:萌宠日常、旅行vlog")
return
}
setBatchAiLoading(true)
try {
// 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟)
await new Promise((resolve) => setTimeout(resolve, 800))
const picked = buildBatchAiTitles(extractTopic(topic), previewCount)
const next = [...previewTitles]
for (let i = 0; i < previewCount; i++) {
if (onlyEmpty && next[i]?.trim()) continue
if (picked[i]) next[i] = picked[i]
}
onPreviewTitlesChange(next)
if (next[0]) t.updateTitle(next[0])
message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`)
} finally {
setBatchAiLoading(false)
}
}
const titleOptions = useMemo(
() => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })),
[t.userTitles],
)
return (
<div className="xx-form-section xx-title-sidebar">
<div className="xx-form-section">
<h3>📝 </h3>
{!isBatch ? (
/* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */
{/* AI 自动选择模式 */}
{t.titleSettings.aiAutoSelect && (
<>
{t.titleSettings.aiAutoSelect ? (
<>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label> AI </label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄
</button>
</div>
</div>
</>
) : (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label></label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
/>
</div>
</>
)}
</>
) : (
/* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
<div className="xx-batch-titles">
<div
style={{
fontSize: 12,
color: "var(--text-secondary, #666)",
marginBottom: 10,
lineHeight: 1.6,
}}
>
//
</div>
{/* 批量 AI 标题 */}
<div className="xx-batch-ai-row">
<Input
placeholder="主题词,如:萌宠日常、旅行vlog"
value={batchAiTopic || t.aiTitleInput}
onChange={(e) => {
setBatchAiTopic(e.target.value)
t.setAiTitleInput(e.target.value)
}}
maxLength={30}
size="small"
style={{ flex: 1 }}
/>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(false)}
>
{batchAiLoading ? <LoadingOutlined /> : "✨"} {previewCount}
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(true)}
>
</button>
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i}>
<label> {i + 1} </label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
value={previewTitles?.[i] || ""}
onChange={(val) => updateVariantTitle(i, val)}
options={titleOptions}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
))}
</div>
</div>
{/* 显示当前 AI 选中的标题(只读)+ 换一个按钮 */}
<div className="xx-form-field">
<label> AI </label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>{t.titleSettings.title || "AI 将自动为你选择标题"}</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄
</button>
</div>
</div>
</>
)}
{/* 标题样式面板(全局共用) */}
{/* 手动选择模式 */}
{!t.titleSettings.aiAutoSelect && (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label></label>
<AutoComplete
placeholder="输入或从标题库选择…"
allowClear
maxLength={50}
style={{ width: "100%" }}
value={t.titleSettings.title || undefined}
onChange={(val) => t.updateTitle(val || "")}
options={t.userTitles.map((ut) => ({
label: ut.content,
value: ut.content,
}))}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
notFoundContent={
t.userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</span>
) : null
}
/>
</div>
</>
)}
{/* 标题样式面板(原 Step5) */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "rgba(59, 130, 246, 0.08)",
borderRadius: 8,
marginTop: 16,
marginBottom: 12,
border: "1px solid rgba(59, 130, 246, 0.15)",
}}
>
<PlayCircleOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
<span style={{ fontSize: 12, color: "var(--text-secondary, #666)" }}>
</span>
</div>
<TitleStylePanel
settings={t.titleSettings}
onUpdatePosition={onUpdatePosition}
@@ -12,12 +12,6 @@ import type { AssetItem } from "@/api/assets"
interface Step5VoiceSelectProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
/** 卡片标题(独立配音模式下显示"视频 N 的配音"),默认"选择配音" */
heading?: string
/** 描述文案 */
description?: string
/** 是否使用紧凑卡片样式(独立配音模式下 N 个并排) */
compact?: boolean
}
/** 获取素材实际时长(优先顶层 durationfallback 到 metadata.duration */
@@ -37,12 +31,6 @@ const isAiVoice = (item: AssetItem): boolean => {
return (!duration || duration <= 0) && (!size || size <= 0)
}
/** 格式化时长(秒 → "14.7秒"0/无效 → "时长未知",不做任何时长过滤) */
const formatDurationSeconds = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "时长未知"
return `${seconds.toFixed(1)}`
}
/** 格式化文件大小 */
const formatFileSize = (bytes?: number): string => {
if (!bytes || bytes <= 0) return "未知"
@@ -55,9 +43,6 @@ const formatFileSize = (bytes?: number): string => {
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
selectedVoice,
onSelectedVoiceChange,
heading = "🎙️ 选择配音",
description = "从配音库中选择已上传的素材,点击卡片可预览播放",
compact = false,
}) => {
const navigate = useNavigate()
const [playingId, setPlayingId] = useState<string | null>(null)
@@ -163,14 +148,14 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
return (
<div className="xx-form-section">
<h3>{heading}</h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>{description}</p>
<h3>🎙 </h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
</p>
<div
style={{
display: "grid",
gridTemplateColumns: compact
? "repeat(auto-fill, minmax(160px, 1fr))"
: "repeat(auto-fill, minmax(220px, 1fr))",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 12,
}}
>
@@ -267,9 +252,7 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
>
{isAiVoice(item) ? (
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI </span>
) : (
<span>{formatDurationSeconds(getDuration(item))}</span>
)}
) : null}
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
</div>
</div>
@@ -1,16 +1,9 @@
/**
* Step 5 选择封面(Issue #1677 批量生成改造)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
*/
import React, { useRef } from "react"
import React from "react"
import { Modal, Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import { useBatchCovers } from "../hooks/useBatchCovers"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
@@ -24,15 +17,6 @@ interface Step6CoverSettingsProps {
titleSettings?: TitleSettings
/** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[]
/* ── 批量生成(#1677)── */
previewCount?: number
/** 每个变体的标题文字 */
previewTitles?: string[]
/** 每个变体的封面URL(按变体索引) */
previewCovers?: string[]
onPreviewCoversChange?: (urls: string[]) => void
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
selectedVariantIndexes?: number[]
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
@@ -62,176 +46,13 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
generatedVideos: props.generatedVideos,
})
const previewCount = props.previewCount || 1
const isBatch = previewCount > 1
const previewTitles = props.previewTitles || []
const previewCovers = props.previewCovers || []
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
const cardIndexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: previewCount }, (_, i) => i)
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
const batchCovers = useBatchCovers({
selectedTemplate: props.selectedTemplate || "",
generatedVideos: props.generatedVideos,
titles: batchTitles,
titleStyle: {
font: props.titleSettings?.font || "思源黑体",
size: props.titleSettings?.size || 28,
color: props.titleSettings?.color || "#ffffff",
position: props.titleSettings?.position || "top",
bold: props.titleSettings?.bold ?? true,
stroke: props.titleSettings?.stroke ?? true,
shadow: props.titleSettings?.shadow ?? false,
},
covers: batchCoversList,
onCoversChange: (updater) => {
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
const next = [...(props.previewCovers || [])]
cardIndexes.forEach((vi, cardPos) => {
next[vi] = nextCardView[cardPos] || ""
})
props.onPreviewCoversChange?.(next)
},
})
const handleAutoGenerate = () => {
generateAutoCover()
}
// 预览图:优先 thumbnail_url,其次 upload_url
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
const handleUploadClick = (variantIndex: number) => {
uploadTargetRef.current = variantIndex
uploadInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) {
const variantIndex = uploadTargetRef.current
const cardPos = cardIndexes.indexOf(variantIndex)
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
}
}
/* ── 批量封面 ── */
if (isBatch) {
return (
<div className="xx-form-section">
<h3>🖼 </h3>
<div
style={{
padding: "10px 14px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(16, 185, 129, 0.15)",
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 {completedVideos.length}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
>
</Button>
</div>
<div className="xx-cover-grid">
{cardIndexes.map((variantIndex, cardPos) => {
const url = batchCoversList[cardPos]
const isLoading = batchCovers.busyIndexes.includes(cardPos)
const isUploading = batchCovers.uploadingIndexes.includes(cardPos)
const title = batchTitles[cardPos]
return (
<div className="xx-cover-card" key={variantIndex}>
<div className="xx-cover-card-title"> {variantIndex + 1}</div>
<div className="xx-cover-card-box">
{isLoading || isUploading ? (
<div className="xx-cover-card-loading">
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>{isLoading ? "AI 选帧中…" : "上传中…"}</span>
</div>
) : url ? (
<img
src={url}
alt={`视频${variantIndex + 1}封面`}
className="xx-cover-card-img"
/>
) : (
<div className="xx-cover-card-placeholder">
<span style={{ fontSize: 26 }}>🖼</span>
<span style={{ fontSize: 12 }}></span>
</div>
)}
<div className="xx-cover-card-ratio">9:16</div>
</div>
{title && (
<div
style={{
fontSize: 12,
color: "var(--text-secondary, #666)",
marginTop: 6,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={title}
>
{title}
</div>
)}
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => void batchCovers.generateOne(cardPos)}
disabled={isLoading || isUploading}
>
{url ? "🔄 重新生成" : "✨ 自动生成"}
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => handleUploadClick(variantIndex)}
disabled={isLoading || isUploading}
>
📤
</button>
</div>
</div>
)
})}
</div>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
)
}
/* ── 单视频:原有流程保持不变 ── */
return (
<div className="xx-form-section">
<h3>🖼 </h3>
@@ -254,7 +75,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
)}
<div className="xx-cover-actions">
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
<Button buttonType="primary" onClick={handleAutoGenerate} disabled={!finalVideo}>
</Button>
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
@@ -0,0 +1,79 @@
/**
* Step 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 { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate"
import SummaryCard from "./step7-confirm/SummaryCard"
import GenerationStatus from "./step7-confirm/GenerationStatus"
interface Step7ConfirmGenerateProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
title: string
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
onRetry: () => void
onDismissError: () => void
}
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
const {
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
handleScrollToPreview,
} = useStep7Generate(props)
const { onRetry, onDismissError } = props
return (
<div className="xx-form-section">
<h3> </h3>
<SummaryCard
templateName={templateName}
materialSummary={materialSummary}
title={title}
voiceName={voiceName}
coverSummary={coverSummary}
/>
<GenerationStatus
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
getGenerationPhase={getGenerationPhase}
onScrollToPreview={handleScrollToPreview}
onRetry={onRetry}
onDismissError={onDismissError}
/>
</div>
)
}
export default Step7ConfirmGenerate
@@ -0,0 +1,40 @@
import React from "react"
import { Typography } from "antd"
const { Text } = Typography
interface ProgressIndicatorProps {
progress: number
}
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
return (
<div className="xx-result-progress">
<div className="xx-progress-circle">
<svg viewBox="0 0 80 80">
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
<circle
cx="40"
cy="40"
r="36"
fill="none"
stroke="var(--primary-color)"
strokeWidth="6"
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
strokeLinecap="round"
transform="rotate(-90 40 40)"
/>
</svg>
<span className="xx-progress-percent">{Math.round(progress)}%</span>
</div>
<div className="xx-progress-text">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
</Text>
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
AI
</Text>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
import React from "react"
import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
import { formatDuration } from "@/api/voice-clone"
interface ResultVideoCardProps {
video: GeneratedVideo
index: number
onPreview: (video: GeneratedVideo) => void
onDownload: () => void
onShare: () => void
}
export const ResultVideoCard: React.FC<ResultVideoCardProps> = ({
video,
index,
onPreview,
onDownload,
onShare,
}) => {
return (
<div className="xx-video-card" onClick={() => onPreview(video)}>
<div className="xx-video-thumb">
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" />
) : (
<div className="xx-video-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-video-play-overlay">
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
</div>
{video.duration && (
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
)}
</div>
<div className="xx-video-info">
<div className="xx-video-title"> {index + 1}</div>
<div className="xx-video-actions">
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onDownload()
}}
>
<DownloadOutlined />
</button>
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onShare()
}}
>
<ShareAltOutlined />
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,117 @@
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
interface GenerationStatusProps {
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
getGenerationPhase: (progress: number) => { icon: string; label: string }
onScrollToPreview: () => void
onRetry: () => void
onDismissError: () => void
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
onScrollToPreview,
onRetry,
onDismissError,
}) => {
return (
<div style={{ marginTop: 16 }}>
{!generating && !generated && !generateError && (
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">🎬</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase"></div>
<div className="xx-gen-progress-sub">
</div>
</div>
</div>
</div>
)}
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub"> 1-2 </div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"></div>
<div className="xx-gen-success-sub">
{generatedVideos.length}
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onScrollToPreview}
>
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
</button>
</div>
</div>
)}
</div>
)
}
export default GenerationStatus
@@ -0,0 +1,44 @@
import React from "react"
interface SummaryCardProps {
templateName: string
materialSummary: string
title: string
voiceName: string
coverSummary: string
}
const SummaryCard: React.FC<SummaryCardProps> = ({
templateName,
materialSummary,
title,
voiceName,
coverSummary,
}) => {
return (
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
</div>
)
}
export default SummaryCard
@@ -1,75 +0,0 @@
/**
* 标题库 AutoCompleteIssue #1737
*
* 原生 antd AutoCompletecombobox 模式)的两个行为不符合产品预期:
* 1. combobox 默认 showAction=[],输入框聚焦时下拉不展开——用户必须先打字才能看到标题库,
* 且组件无下拉箭头,视觉上是"纯输入框",不知道标题库里已有标题可选。
* 2. 空态聚焦不展示任何标题库内容。
*
* 本组件封装修复:
* - 受控 open:聚焦(且标题库非空)即展开,展示全部标题;失焦/选中/Esc 关闭
* rc-select 失焦会主动 onToggleOpen(false)onOpenChange 同步状态即可,不会死循环)
* - suffixIcon 加下拉三角,视觉提示"可选择";有值时 allowClear 的清除按钮照常出现
* - 输入文字时由 filterOption 过滤(空串展示全部)
* - 保留 combobox 自由输入能力:用户可输入标题库之外的自定义标题
*/
import React, { useState } from "react"
import { AutoComplete } from "antd"
import { DownOutlined } from "@ant-design/icons"
import type { AutoCompleteProps } from "antd"
export interface TitleOption {
label: string
value: string
}
interface TitleLibraryAutoCompleteProps {
value: string
onChange: (val: string) => void
options: TitleOption[]
placeholder?: string
allowClear?: boolean
maxLength?: number
style?: React.CSSProperties
}
const TitleLibraryAutoComplete: React.FC<TitleLibraryAutoCompleteProps> = ({
value,
onChange,
options,
placeholder = "输入或从标题库选择",
allowClear = true,
maxLength = 50,
style,
}) => {
const [open, setOpen] = useState(false)
const hasTitles = options.length > 0
const filterOption: AutoCompleteProps["filterOption"] = (inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}
return (
<AutoComplete
value={value || undefined}
onChange={(val) => onChange(val || "")}
options={options}
filterOption={filterOption}
open={open}
onOpenChange={setOpen}
onFocus={() => {
// 标题库为空时不展开(避免弹出"暂无数据"空壳)
if (hasTitles) setOpen(true)
}}
onSelect={() => setOpen(false)}
suffixIcon={<DownOutlined style={{ color: "var(--text-secondary, #bbb)", fontSize: 12 }} />}
placeholder={placeholder}
allowClear={allowClear}
maxLength={maxLength}
style={{ width: "100%", ...style }}
/>
)
}
export default TitleLibraryAutoComplete
-4
View File
@@ -37,10 +37,6 @@ export const STEPS = [
{ key: 6, label: "选择封面" },
]
/* ── 批量生成限制 ── */
export const MAX_PREVIEW_COUNT = 10
export const MIN_PREVIEW_COUNT = 1
/* ── 标题位置选项 ── */
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
+27 -589
View File
@@ -117,6 +117,10 @@
grid-template-columns: 1fr;
}
.xx-generate-layout.full-width .xx-generate-right-col {
display: none;
}
/* ============================================================
左侧表单区 generate-form
============================================================ */
@@ -2319,6 +2323,28 @@
生成结果(右侧)
================================================================ */
.xx-generate-right-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ── 内联视频播放器(右侧) ── */
.xx-inline-video-player {
width: 100%;
max-width: 320px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.xx-inline-video-player video {
background: #000;
}
.xx-preview-header {
display: flex;
align-items: center;
@@ -2684,12 +2710,11 @@
/* ── 封面设置区域改造样式 ── */
/* 封面操作按钮区(单视频全宽页居中) */
/* 封面操作按钮区 */
.xx-cover-actions {
display: flex;
gap: 12px;
margin-bottom: 12px;
justify-content: center;
}
/* 已选模板文字 */
@@ -2875,590 +2900,3 @@
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
/* ================================================================
Issue #1677 多视频批量生成
================================================================ */
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr 380px;
align-items: start;
}
.xx-generate-preview-col {
min-width: 0;
position: sticky;
top: 16px;
}
.xx-generate-preview-col .xx-form-section {
margin: 0;
}
.xx-title-sidebar {
max-height: calc(100vh - 140px);
overflow-y: auto;
}
/* ── 数量选择弹窗 ── */
.xx-modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.xx-modal-box {
background: var(--bg-primary, #fff);
border-radius: 16px;
padding: 28px;
width: 420px;
max-width: calc(100vw - 32px);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
}
.xx-count-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 8px 0 16px;
}
.xx-count-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-primary, #d9d9d9);
background: var(--bg-secondary, #f5f5f5);
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--text-primary, #333);
transition: all 0.15s;
}
.xx-count-btn:hover:not(:disabled) {
border-color: #1677ff;
color: #1677ff;
}
.xx-count-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.xx-count-input {
width: 88px;
height: 52px;
text-align: center;
font-size: 26px;
font-weight: 700;
border: 2px solid var(--border-primary, #d9d9d9);
border-radius: 12px;
color: var(--text-primary, #333);
background: var(--bg-primary, #fff);
}
.xx-count-input:focus {
outline: none;
border-color: #1677ff;
}
/* 隐藏 number input 上下箭头 */
.xx-count-input::-webkit-outer-spin-button,
.xx-count-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.xx-count-input {
-moz-appearance: textfield;
appearance: textfield;
}
.xx-count-quick {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 20px;
}
.xx-count-chip {
padding: 6px 16px;
border-radius: 999px;
border: 1px solid var(--border-primary, #d9d9d9);
background: var(--bg-primary, #fff);
font-size: 13px;
cursor: pointer;
color: var(--text-secondary, #666);
transition: all 0.15s;
}
.xx-count-chip:hover {
border-color: #1677ff;
color: #1677ff;
}
.xx-count-chip.active {
background: #1677ff;
border-color: #1677ff;
color: #fff;
}
.xx-count-actions {
display: flex;
gap: 12px;
}
.xx-count-actions .xx-btn {
flex: 1;
}
/* ── 批量预览网格 ── */
.xx-variant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.xx-variant-card {
position: relative;
border: 2px solid var(--border-primary, #e8e8e8);
border-radius: 12px;
padding: 10px;
background: var(--bg-primary, #fff);
cursor: pointer;
transition: all 0.18s;
}
.xx-variant-card:hover {
border-color: #91caff;
}
.xx-variant-card.selected {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
}
.xx-variant-card.failed {
border-color: #ffccc7;
cursor: default;
}
.xx-variant-check {
position: absolute;
top: 14px;
left: 14px;
z-index: 3;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid #fff;
background: rgba(0, 0, 0, 0.35);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.xx-variant-check.checked {
background: #1677ff;
border-color: #1677ff;
}
.xx-variant-index {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 8px;
}
.xx-variant-video-wrap {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
aspect-ratio: 9 / 16;
max-height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
.xx-variant-loading,
.xx-variant-failed {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text-secondary, #999);
font-size: 12px;
padding: 16px;
text-align: center;
}
.xx-variant-progress {
width: 120px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
overflow: hidden;
}
.xx-variant-progress-bar {
height: 100%;
background: #1677ff;
border-radius: 2px;
transition: width 0.4s;
}
.xx-variant-progress-text {
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
}
.xx-variant-title-overlay {
position: absolute;
left: 8%;
right: 8%;
text-align: center;
font-weight: 700;
line-height: 1.3;
pointer-events: none;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
word-break: break-all;
}
.xx-variant-title-overlay.pos-top {
top: 8%;
}
.xx-variant-title-overlay.pos-center {
top: 50%;
transform: translateY(-50%);
}
.xx-variant-title-overlay.pos-bottom,
.xx-variant-title-overlay.pos-custom {
bottom: 10%;
}
.xx-variant-footer {
min-height: 22px;
margin-top: 8px;
font-size: 12px;
}
.xx-variant-ready-tag {
color: #52c41a;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-variant-skip-tag {
color: var(--text-tertiary, #999);
}
/* ── 批量配音列表 ── */
.xx-per-voice-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-per-voice-list .xx-form-section {
margin: 0;
}
/* ── 批量封面网格(单卡/少卡时居中排列,卡片限宽不拉伸) ── */
.xx-cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 220px));
justify-content: center;
gap: 16px;
}
.xx-cover-card {
border: 1px solid var(--border-primary, #e8e8e8);
border-radius: 12px;
padding: 10px;
background: var(--bg-primary, #fff);
}
.xx-cover-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 8px;
}
.xx-cover-card-box {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
aspect-ratio: 9 / 16;
max-height: 300px;
}
.xx-cover-card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.xx-cover-card-placeholder,
.xx-cover-card-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary, #999);
background: var(--bg-secondary, #f7f7f7);
font-size: 13px;
}
.xx-cover-card-ratio {
position: absolute;
right: 6px;
bottom: 6px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
}
/* ── 响应式:窄屏 Step4 回退单列 ── */
@media (max-width: 960px) {
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr;
}
.xx-generate-preview-col {
position: static;
}
.xx-title-sidebar {
max-height: none;
}
}
/* ============================================================
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
============================================================ */
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
.xx-canvas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
gap: 14px;
}
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
@media (max-width: 960px) {
.xx-canvas-grid {
grid-template-columns: minmax(0, 320px);
}
}
.xx-canvas-grid-card {
position: relative;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
overflow: hidden;
background: transparent;
transition: border-color 0.2s ease;
min-width: 0;
}
.xx-canvas-grid-card.selected {
border-color: var(--primary-color, #1677ff);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
}
.xx-canvas-grid-card-bar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
padding: 6px 10px;
background: var(--bg-surface, #fff);
border-bottom: 1px solid var(--border-primary, #e2e8f0);
}
.xx-canvas-grid-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
cursor: pointer;
user-select: none;
}
.xx-canvas-grid-check input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary-color, #1677ff);
}
/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */
.xx-variant-clips-status {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
max-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
background: #0a0a0a;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 20px 50px -12px rgba(0, 0, 0, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.xx-variant-clips-status .anticon {
font-size: 22px;
}
.xx-variant-clips-error-text {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
}
/* ============================================================
批量标题:AI 一键生成行(Issue #1677
============================================================ */
.xx-batch-ai-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: var(--bg-secondary, #f7f8fa);
border: 1px dashed var(--border-primary, #d9d9d9);
border-radius: 10px;
}
.xx-batch-ai-row .xx-form-field {
margin: 0;
flex: 1;
min-width: 140px;
}
.xx-batch-titles {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ============================================================
第5步确认生成:批量渲染进度网格(Issue #1677
============================================================ */
.xx-batch-gen-grid {
justify-items: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
gap: 14px;
}
.xx-batch-gen-card {
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
padding: 14px;
background: transparent;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.xx-batch-gen-card.status-completed {
border-color: rgba(82, 196, 26, 0.4);
background: rgba(82, 196, 26, 0.04);
}
.xx-batch-gen-card.status-failed {
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.04);
}
.xx-batch-gen-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.xx-batch-gen-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xx-batch-gen-card-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-batch-gen-card-pct {
font-size: 13px;
color: var(--text-secondary, #666);
text-align: right;
}
.xx-batch-gen-card-done {
font-size: 13px;
color: var(--success-color, #52c41a);
padding: 8px 0;
}
.xx-batch-gen-card-failed {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.xx-batch-gen-card-err {
font-size: 13px;
color: var(--error-color, #ef4444);
line-height: 1.5;
word-break: break-word;
}
/* ── 响应式:窄屏批量网格回退单列(.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
@media (max-width: 960px) {
.xx-batch-gen-grid {
justify-items: center;
grid-template-columns: minmax(0, 320px);
}
}
@@ -29,25 +29,6 @@ export interface UseGenerateVideoProps {
}
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
onGenerationSuccess?: () => void
/* ── 批量生成(#1677)── */
/** 生成数量(1=单条旧逻辑,>1=批量) */
previewCount?: number
/** 每个变体的标题文字(长度=count 时各自独立) */
variantTitles?: string[]
/** 独立配音模式下每个变体的配音ID(空数组=共用 selectedVoice */
variantVoiceLibraryIds?: string[]
/** 是否独立配音 */
voiceModePerVideo?: boolean
/** 每个变体的封面URL(空数组=回退 coverSettings */
variantCoverUrls?: string[]
/** 勾选要生成的变体索引(批量模式) */
selectedVariantIndexes?: number[]
/**
* ID#1744=previewCount
*
* /线
*/
variantPlanIds?: string[]
}
/** 生成阶段 */
@@ -63,7 +44,7 @@ export interface UseGenerateVideoResult {
generated: boolean
generateError: string | null
generatedVideos: GeneratedVideo[]
generate: () => Promise<boolean>
generate: () => Promise<void>
retry: () => void
dismissError: () => void
download: () => Promise<void>
@@ -1,28 +1,14 @@
import { useRef, useCallback, useState } from "react"
import { useRef, useCallback } from "react"
import { message } from "antd"
import axios from "axios"
import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks"
import { getGenerationTask } from "@/api/tasks/tasks"
import { getGenerationTaskResults } from "@/api/template-editor"
import { safeExtractError } from "./errorUtils"
/** 批量生成时单个任务的实时状态(Issue #1677 第5步确认生成页) */
export interface BatchTaskState {
taskId: string
/** 变体序号(0-based,与标题/封面数组对齐) */
variantIndex: number
status: "running" | "completed" | "failed"
progress: number
error: string | null
/** 完成后的成片视频 */
videos: unknown[]
}
interface UseGenerationPollingOptions {
onProgress: (progress: number) => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
/** 批量:单任务状态变化(第5步逐卡片展示) */
onBatchTaskUpdate?: (taskId: string, patch: Partial<BatchTaskState>) => void
}
/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */
@@ -31,30 +17,31 @@ const MAX_RETRYABLE_ERRORS = 10
const MAX_RESULTS_RETRIES = 3
/**
* Hookv4 +
* Hookv2 /generation/tasks/{task_id}
*
* startPolling(taskId)
* startPollingBatch(tasks) N
* - // onBatchTaskUpdate
* - onComplete
* - retryTask(taskId)
* GET /templates/{id}/editor/generation-status plan
* task_id使 POST /generation/tasks
* task_id plan
*
*
* - 4xx 404 onFailed
* - 5xx / MAX_RETRYABLE_ERRORS
* - MAX_RESULTS_RETRIES onFailed
*/
export function useGenerationPolling({
export const useGenerationPolling = ({
onProgress,
onComplete,
onFailed,
onBatchTaskUpdate,
}: UseGenerationPollingOptions) {
const progressTimer = useRef<ReturnType<typeof setTimeout>[]>([])
}: UseGenerationPollingOptions) => {
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
const cancelledRef = useRef(false)
/** 批量任务上下文:taskId → 变体序号 */
const batchContextRef = useRef<Map<string, number>>(new Map())
const [, forceTick] = useState(0)
const clearTimer = useCallback(() => {
cancelledRef.current = true
progressTimer.current.forEach((t) => clearTimeout(t))
progressTimer.current = []
if (progressTimer.current) {
clearTimeout(progressTimer.current)
progressTimer.current = undefined
}
}, [])
/** 任务完成后拉取结果列表,带重试 */
@@ -75,237 +62,85 @@ export function useGenerationPolling({
[],
)
const extractErrorMessage = (pollErr: unknown, status: number): string => {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)?.message) ||
`查询任务失败 (${status})`
return safeExtractError(msg)
}
/**
*
* - isBatch=true onBatchTaskUpdate onProgress/onComplete
* - resolve(videos) reject(Error)
*/
const pollSingleTask = useCallback(
(
taskId: string,
runId: number,
callbacks?: {
onTaskProgress?: (pct: number) => void
onTaskCompleted?: (videos: unknown[]) => void
onTaskFailed?: (msg: string) => void
},
): Promise<unknown[]> => {
return new Promise((resolve, reject) => {
let consecutiveErrors = 0
let done = false
const poll = async () => {
if (cancelledRef.current || done) return
try {
const task = await getGenerationTask(taskId)
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed") {
done = true
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
const msg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
return
}
callbacks?.onTaskCompleted?.(videos)
resolve(videos)
return
}
if (task.status === "failed" || task.status === "cancelled") {
done = true
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
const msg = safeExtractError(rawMsg)
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
return
}
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
callbacks?.onTaskProgress?.(pct)
if (!callbacks && runId === 0) {
onProgress(pct)
}
const timer = setTimeout(poll, 2000)
progressTimer.current.push(timer)
} catch (pollErr) {
if (cancelledRef.current || done) return
console.error("[轮询出错] taskId:", taskId, pollErr)
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
done = true
const msg = extractErrorMessage(pollErr, status)
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
done = true
const msg = "任务状态查询连续失败,请稍后在任务列表查看结果"
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
return
}
const timer = setTimeout(poll, 3000)
progressTimer.current.push(timer)
}
}
const timer = setTimeout(poll, 1500)
progressTimer.current.push(timer)
})
},
[onProgress, fetchResultsWithRetry],
)
/** 单任务轮询(单视频,兼容旧调用) */
const startPolling = useCallback(
(taskId: string) => {
cancelledRef.current = false
batchContextRef.current.clear()
pollSingleTask(taskId, 0)
.then((videos) => {
if (cancelledRef.current) return
onProgress(100)
onComplete(videos)
message.success("视频生成完成!")
})
.catch((err: Error) => {
if (cancelledRef.current) return
console.error("[生成失败] taskId:", taskId, err.message)
onFailed(err.message)
message.error(err.message)
})
},
[pollSingleTask, onProgress, onComplete, onFailed],
)
let consecutiveErrors = 0
/**
*
* - / onBatchTaskUpdate
* * onComplete
* - onFailed5+ onFailed
*/
const startPollingBatch = useCallback(
(tasks: { taskId: string; variantIndex: number }[]) => {
cancelledRef.current = false
const runId = Date.now()
const progressMap = new Map<string, number>()
const resultMap = new Map<string, unknown[]>()
const failureMap = new Map<string, string>()
batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex]))
const reportAggregateProgress = () => {
const poll = async () => {
if (cancelledRef.current) return
const values = tasks.map((t) => progressMap.get(t.taskId) ?? 0)
const avg = Math.round(values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1))
onProgress(Math.min(avg, 99))
}
try {
const task = await getGenerationTask(taskId)
consecutiveErrors = 0
const checkAllSettled = () => {
if (resultMap.size + failureMap.size < tasks.length) return
if (resultMap.size === tasks.length) {
onProgress(100)
const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat()
onComplete(ordered)
message.success(`全部 ${tasks.length} 个视频生成完成!`)
} else if (resultMap.size > 0) {
// 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮
onProgress(100)
const ordered = tasks
.filter((t) => resultMap.has(t.taskId))
.map((t) => resultMap.get(t.taskId) || [])
.flat()
onComplete(ordered)
message.warning(
`${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`,
)
} else {
const firstMsg = failureMap.get(tasks[0].taskId) || "全部视频生成失败"
onFailed(firstMsg)
if (task.status === "completed") {
onProgress(100)
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
console.error("[生成结果获取失败] taskId:", taskId)
onFailed(errorMsg)
message.error(errorMsg)
return
}
onComplete(videos)
message.success("视频生成完成!")
return
}
if (task.status === "failed" || task.status === "cancelled") {
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] taskId:", taskId, "响应:", task)
onFailed(errorMsg)
message.error(errorMsg)
return
}
// pending / waiting / running — 继续轮询
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
onProgress(pct)
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
if (cancelledRef.current) return
console.error("[轮询出错] taskId:", taskId, pollErr)
// 4xx 不可恢复,立即失败
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.message) ||
`查询任务失败 (${status})`
const errorMsg = safeExtractError(msg)
onFailed(errorMsg)
message.error(errorMsg)
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果"
onFailed(errorMsg)
message.error(errorMsg)
return
}
progressTimer.current = setTimeout(poll, 3000)
}
}
tasks.forEach(({ taskId, variantIndex }) => {
onBatchTaskUpdate?.(taskId, {
taskId,
variantIndex,
status: "running",
progress: 0,
error: null,
videos: [],
})
pollSingleTask(taskId, runId, {
onTaskProgress: (pct) => {
progressMap.set(taskId, pct)
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
reportAggregateProgress()
},
onTaskCompleted: (videos) => {
progressMap.set(taskId, 100)
resultMap.set(taskId, videos)
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
reportAggregateProgress()
checkAllSettled()
},
onTaskFailed: (msg) => {
failureMap.set(taskId, msg)
onBatchTaskUpdate?.(taskId, { status: "failed", error: msg })
checkAllSettled()
},
}).catch(() => {
// 失败已在 onTaskFailed 处理,这里吞掉 Promise rejection
})
})
progressTimer.current = setTimeout(poll, 1500)
},
[pollSingleTask, onProgress, onComplete, onFailed, onBatchTaskUpdate],
[onProgress, onComplete, onFailed, fetchResultsWithRetry],
)
/** 单独重试失败任务(第5步卡片「重试此视频」):先调后端重试接口,再轮询 */
const retryTask = useCallback(
async (taskId: string) => {
if (cancelledRef.current) cancelledRef.current = false
const variantIndex = batchContextRef.current.get(taskId) ?? 0
onBatchTaskUpdate?.(taskId, { status: "running", progress: 0, error: null, videos: [] })
try {
await retryGenerationTaskApi(taskId)
} catch (err) {
// 后端不支持重试或任务不可重试:直接重新轮询(任务可能已被自动恢复)
console.warn("[重试任务接口调用失败,改为直接轮询]", err)
}
pollSingleTask(taskId, Date.now(), {
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
onTaskCompleted: (videos) => {
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
message.success(`视频 ${variantIndex + 1} 重试成功`)
},
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
}).catch(() => {
/* 失败已在回调处理 */
})
forceTick((n) => n + 1)
return variantIndex
},
[pollSingleTask, onBatchTaskUpdate],
)
return { startPolling, startPollingBatch, retryTask, clearTimer }
return { startPolling, clearTimer }
}
@@ -8,22 +8,11 @@ import type { AssetItem } from "@/api/assets"
*
*/
export function useMaterialLibrary() {
/* API
* Step2 kind=video #1777
* kind getAssetLibraries("video")
* (voice)/(image)
* queryKey kind/ ["asset-libraries"]
*/
const { data: allLibraries = [] } = useQuery({
queryKey: ["asset-libraries", "video"],
queryFn: () => getAssetLibraries("video"),
staleTime: 60_000,
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
// 前端兜底过滤:仅保留 kind=video 的素材库(后端按 kind 查询参数过滤)
const libraries = useMemo(
() => allLibraries.filter((lib) => lib.kind === "video"),
[allLibraries],
)
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
@@ -13,22 +13,21 @@ interface UseSmartMatchOptions {
}
/** 默认 limit(拿不到目标时长时的兜底上限) */
const DEFAULT_LIMIT = 30
/**
* #1744 N 20%
* N metadata
* /15 1
*/
const ASSET_POOL_MULTIPLIER = 3
const DEFAULT_LIMIT = 10
/** 每个素材切片按 15 秒估算所需素材数 */
const SECONDS_PER_ASSET = 15
/**
* segments #1750
* = × 3 3 /
* + [1, 200] limit 200
* segments
* segment duration_min
* 15 / 1
* [1, 200] limit 200
*/
export function computeLimitFromSegments(segments?: TemplateSegment[]): number {
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
if (!segments || segments.length === 0) return DEFAULT_LIMIT
const limit = segments.length * ASSET_POOL_MULTIPLIER
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
if (totalSeconds <= 0) return DEFAULT_LIMIT
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET))
return Math.max(1, Math.min(limit, 200))
}
@@ -1,197 +0,0 @@
/**
* HookIssue #1677 / #1750
* N +
*
* #1750
* - patchCover updater onCoversChange (prev)=>next
* generateAll covers bug
* - loading/uploading busyIndexes/uploadingIndexes
* - generateOne booleangenerateAll /
* - generateAll 3
*/
import { useCallback, useState } from "react"
import { message } from "antd"
import { generateCover } from "@/api/generation"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
type CoversChangeFn = (updater: string[] | ((prev: string[]) => string[])) => void
interface UseBatchCoversOptions {
selectedTemplate: string
generatedVideos: GeneratedVideo[]
/** 每个变体的标题文字 */
titles: string[]
/** 标题样式(全局共用) */
titleStyle: {
font: string
size: number
color: string
position: string
bold: boolean
stroke: boolean
shadow: boolean
}
covers: string[]
onCoversChange: CoversChangeFn
}
export function useBatchCovers({
selectedTemplate,
generatedVideos,
titles,
titleStyle,
covers,
onCoversChange,
}: UseBatchCoversOptions) {
/** 正在自动生成封面的变体序号集合(数组支持多个卡片同时在途) */
const [busyIndexes, setBusyIndexes] = useState<number[]>([])
/** 正在上传自定义封面的变体序号集合 */
const [uploadingIndexes, setUploadingIndexes] = useState<number[]>([])
const addBusy = useCallback((i: number) => {
setBusyIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
}, [])
const removeBusy = useCallback((i: number) => {
setBusyIndexes((prev) => prev.filter((x) => x !== i))
}, [])
const addUploading = useCallback((i: number) => {
setUploadingIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
}, [])
const removeUploading = useCallback((i: number) => {
setUploadingIndexes((prev) => prev.filter((x) => x !== i))
}, [])
/** 函数式回写单个封面位置(基于最新 prev,不受闭包旧值影响) */
const patchCover = useCallback(
(index: number, url: string) => {
onCoversChange((prev) => {
const next = [...prev]
next[index] = url
return next
})
},
[onCoversChange],
)
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
return false
}
addBusy(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(selectedTemplate, {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
...(titleText
? {
title_config: {
text: titleText,
font: titleStyle.font,
font_size: titleStyle.size,
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
stroke: titleStyle.stroke,
shadow: titleStyle.shadow,
},
}
: {}),
})
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面生成成功`)
return true
}
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
return false
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
return false
} finally {
removeBusy(index)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
)
/** 为第 index 个视频上传自定义封面 */
const uploadOne = useCallback(
async (index: number, file: File) => {
addUploading(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
message.error("未找到素材库,请先创建")
return
}
const result = await uploadAssetDirect({
file,
library_id: imageLib.id,
})
const url = result?.url || ""
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面已上传`)
} else {
message.warning("上传完成但未获取到图片URL,请重试")
}
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 上传失败:`, err)
message.error("封面上传失败,请重试")
} finally {
removeUploading(index)
}
},
[patchCover, addUploading, removeUploading],
)
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
const pending = finalVideos.map((_, i) => i).filter((i) => !covers[i])
if (pending.length === 0) {
message.info("所有视频封面已生成")
return
}
let okCount = 0
let failCount = 0
for (const i of pending) {
// eslint-disable-next-line no-await-in-loop
const ok = await generateOne(i)
if (ok) okCount += 1
else failCount += 1
}
if (failCount === 0) {
message.success(`全部 ${total} 个封面已生成`)
} else if (okCount === 0) {
message.error("封面生成全部失败,请重试")
} else {
message.warning(`已生成 ${okCount} 个封面,${failCount} 个失败,请对失败项重试`)
}
}, [generatedVideos, covers, generateOne])
return {
busyIndexes,
uploadingIndexes,
generateOne,
uploadOne,
generateAll,
}
}
export default useBatchCovers
@@ -1,153 +0,0 @@
/**
* Hook#1744 / #1750
*
* N>1 4 POST /generation/variant-plans
* N
* - / plan
* - POST /generation/tasks?count=N
* reselect_plan_for_variant + main + +
* 20% + 使 metadata
* clips
* - plan_ids plan
*
* #1750 线
* - 404线/ 400 / / error
* UI
* - clips
*
* N=1 hook
*/
import { useCallback, useEffect, useRef, useState } from "react"
import type { EditPlanClip } from "@/api/template-editor"
import { createBatchVariantPlans, type VariantPlan } from "@/api/generation/variantPlans"
export interface BatchVariantClipsState {
/** 各变体的服务端真实片段(按 variant_index 排序);未就绪为空数组 */
clipsByVariant: EditPlanClip[][]
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
planIdsByVariant: string[]
/** 是否正在向后端申请变体计划 */
loading: boolean
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
ready: boolean
/** 加载失败(端点未上线/网络错误/数据不完整);true 时 UI 显示错误态+重试 */
error: boolean
/** 用户点击「重试」:强制重新申请变体计划 */
retry: () => void
}
interface UseBatchVariantPlansOptions {
/** 是否启用:仅批量(count>1)且素材已选时为 true */
enabled: boolean
count: number
templateId: string
assetIds: string[]
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
sourcePlanId?: string
}
export function useBatchVariantPlans({
enabled,
count,
templateId,
assetIds,
sourcePlanId = "",
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const requestSeqRef = useRef(0)
// 记录上次成功申请的入参指纹,素材/数量未变时不重复请求
const lastKeyRef = useRef("")
// 重试计数(state):用户点重试时 +1,驱动 effect 重新发起请求(即使指纹相同);
// 用 state 而非 ref + 直接调 load,避免 retry 与 effect 双发导致 seq 竞争
const [retryNonce, setRetryNonce] = useState(0)
const load = useCallback(async () => {
const seq = ++requestSeqRef.current
setLoading(true)
setError(false)
try {
const resp = await createBatchVariantPlans({
template_id: templateId,
asset_ids: assetIds,
count,
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
})
if (seq !== requestSeqRef.current) return
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
const planIds: string[] = Array.from({ length: count }, () => "")
for (const item of items) {
const idx = item.variant_index
if (idx < 0 || idx >= count) continue
planIds[idx] = item.plan_id || ""
clips[idx] = (item.clips || [])
.filter((c) => c && c.asset_id && c.status === "ready")
.sort((a, b) => a.order - b.order)
}
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
const incomplete = clips.some((list) => list.length === 0)
if (incomplete) {
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
setClipsByVariant([])
setPlanIdsByVariant([])
setError(true)
} else {
setClipsByVariant(clips)
setPlanIdsByVariant(planIds)
setError(false)
}
} catch (err) {
if (seq !== requestSeqRef.current) return
// 404(后端端点未上线)/ 400(参数错误)/ 网络错误 / 超时:统一错误态
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
setClipsByVariant([])
setPlanIdsByVariant([])
setError(true)
} finally {
if (seq === requestSeqRef.current) setLoading(false)
}
}, [templateId, count, sourcePlanId, assetIds])
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
const retry = useCallback(() => {
setRetryNonce((n) => n + 1)
}, [])
useEffect(() => {
if (!enabled || !templateId || assetIds.length === 0 || count <= 1) {
requestSeqRef.current += 1
// 函数式更新:已是目标值时返回 prevObject.is 相等 React 跳过渲染),
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
setLoading((prev) => (prev === false ? prev : false))
setError((prev) => (prev === false ? prev : false))
lastKeyRef.current = ""
return
}
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
.sort()
.join(",")}`
if (key === lastKeyRef.current) return
lastKeyRef.current = key
load()
}, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
return {
clipsByVariant,
planIdsByVariant,
loading,
ready,
error,
retry,
}
}
export default useBatchVariantPlans
@@ -1,6 +1,6 @@
/**
* GeneratePage
* 5 API URL
* 7 API URL
*/
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
@@ -40,8 +40,6 @@ export interface GenerateFormState {
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/** 当前选中模板在创建片段时被判失效(404/400/422)后的运行时自动回退 */
handleInvalidTemplate: () => boolean
/* 素材 */
selectedMaterials: string[]
@@ -102,26 +100,6 @@ export interface GenerateFormState {
/** 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) */
storedSourceEditPlanId: string | null
setStoredSourceEditPlanId: (planId: string | null) => void
/* ── 批量生成(Issue #1677)── */
/** 生成数量(1~10),1=单条旧逻辑 */
previewCount: number
setPreviewCount: (n: number) => void
/** 每个变体的标题文字,长度=previewCount[0] 与 titleSettings.title 保持同步 */
previewTitles: string[]
setPreviewTitles: (titles: string[] | ((prev: string[]) => string[])) => void
/** false=所有视频共用一个配音;true=每个视频独立配音 */
voiceModePerVideo: boolean
setVoiceModePerVideo: (v: boolean) => void
/** 独立配音模式下每个变体的配音素材ID,长度=previewCount */
voiceLibraryIds: string[]
setVoiceLibraryIds: (ids: string[] | ((prev: string[]) => string[])) => void
/** 每个变体的封面URL(自动生成或上传),长度=previewCount,空串=未设置 */
previewCovers: string[]
setPreviewCovers: (urls: string[] | ((prev: string[]) => string[])) => void
/** 确认生成时勾选的变体索引 */
selectedVariantIds: number[]
setSelectedVariantIds: (ids: number[] | ((prev: number[]) => number[])) => void
}
export const useGenerateFormState = (): GenerateFormState => {
@@ -133,8 +111,7 @@ export const useGenerateFormState = (): GenerateFormState => {
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates, handleInvalidTemplate } =
useTemplateSelection()
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
@@ -208,14 +185,6 @@ export const useGenerateFormState = (): GenerateFormState => {
null,
)
/* ── 批量生成状态(Issue #1677)── */
const [previewCount, setPreviewCount] = useState(1)
const [previewTitles, setPreviewTitles] = useState<string[]>([""])
const [voiceModePerVideo, setVoiceModePerVideo] = useState(false)
const [voiceLibraryIds, setVoiceLibraryIds] = useState<string[]>([""])
const [previewCovers, setPreviewCovers] = useState<string[]>([""])
const [selectedVariantIds, setSelectedVariantIds] = useState<number[]>([0])
/* ── 从 URL / 编辑计划加载配置 ── */
usePlanConfigLoader({
editPlanId,
@@ -231,7 +200,6 @@ export const useGenerateFormState = (): GenerateFormState => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -265,17 +233,5 @@ export const useGenerateFormState = (): GenerateFormState => {
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
previewCount,
setPreviewCount,
previewTitles,
setPreviewTitles,
voiceModePerVideo,
setVoiceModePerVideo,
voiceLibraryIds,
setVoiceLibraryIds,
previewCovers,
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
}
}
@@ -1,81 +0,0 @@
/**
* 退#1777
*
*
* PUT/POST /templates/{id}/editor/clips[...]/from-assets
* - 404 #1774
* - 400detail
* 422
*
*
*/
import type { EditingTemplate } from "@/api/editing-planner"
/** 失效模板相关的 HTTP 状态码 */
const INVALID_TEMPLATE_STATUSES = new Set([404, 400, 422])
/**
* axios HTTP
* axios / null
*/
export function getHttpStatus(err: unknown): number | null {
if (!err || typeof err !== "object") return null
const status = (err as { response?: { status?: number }; status?: number })?.response?.status
return typeof status === "number" ? status : null
}
/** 安全提取后端错误文本(detail/message/msg422 数组也兜底拼一下) */
function extractErrorText(err: unknown): string {
if (!err || typeof err !== "object") return ""
const data = (err as { response?: { data?: unknown } })?.response?.data
if (!data) return ""
try {
const text = JSON.stringify(data)
return typeof text === "string" ? text : ""
} catch {
return ""
}
}
/**
* clips/from-assets
*
* 400/422
* - 404/
* - 400
* - 422from-assets /segments
*/
export function isInvalidTemplateError(err: unknown): boolean {
const status = getHttpStatus(err)
if (status === null || !INVALID_TEMPLATE_STATUSES.has(status)) return false
if (status === 404) return true
const text = extractErrorText(err)
if (status === 400) {
// 后端当前返回:「模板没有片段配置,无法创建片段」
return /片段配置|没有片段|无片段|segments?|clip.*config/i.test(text)
}
// 422:FastAPI 校验错误,命中模板片段相关字段
return /segment|clip|片段|模板/i.test(text)
}
/**
*
*
* = is_active !== false true
*
* valid_only #1769/#1772
*/
export function isValidTemplate(template: EditingTemplate | null | undefined): boolean {
if (!template) return false
if (template.is_active === false) return false
return (template.segments?.length ?? 0) > 0
}
/** 从模板列表中取出第一个有效模板,没有则返回 null */
export function findFirstValidTemplate(
templates: EditingTemplate[] | null | undefined,
): EditingTemplate | null {
if (!Array.isArray(templates)) return null
return templates.find(isValidTemplate) ?? null
}
@@ -1,87 +1,22 @@
import { useState, useEffect, useRef, useCallback } from "react"
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { message } from "antd"
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { findFirstValidTemplate, isValidTemplate } from "./templateFallback"
/** 失效模板自动切换的提示文案 */
export const INVALID_TEMPLATE_FALLBACK_TOAST = "原模板已失效,已自动切换"
export function useTemplateSelection() {
// selectedTemplate 纯内存状态,绝不写入 localStorage/sessionStorage/URL
// 因此失效模板 ID 不会被持久化、刷新后也不会恢复(#1777 要求 4)
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: allTemplates = [] } = useQuery<EditingTemplate[]>({
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
queryKey: ["generate-templates"],
// valid_only:后端过滤掉没有片段配置的无效模板(#1769/#1772)。
// 旧后端忽略该 query 参数时,下方 isValidTemplate 前端兜底再过滤一次。
queryFn: () => getEditingTemplates({ validOnly: true }),
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
// 双保险:后端 valid_only 已过滤,前端再按 is_active + segments 兜底,
// 保证下拉/自动选择只包含可用于生成的有效模板
const validTemplates = allTemplates.filter(isValidTemplate)
const userTemplates = validTemplates
// 用 ref 持有最新值,供稳定回调 handleInvalidTemplate 使用(避免闭包拿到旧值)
const templatesRef = useRef(validTemplates)
templatesRef.current = validTemplates
const selectedRef = useRef(selectedTemplate)
selectedRef.current = selectedTemplate
// 已提示过失效的模板 ID,避免用户停留在失效模板上时 clips 防抖请求反复弹 toast
// 用户手动切换/成功切换后重置,保证下一个失效模板仍能提示
const fallbackNotifiedRef = useRef<string>("")
/*
* setSelectedTemplate selectedTemplate
* effect return#1777 4 */
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (selectedTemplate) return
const firstValid = validTemplates[0]
if (firstValid) {
setSelectedTemplate(firstValid.id)
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [validTemplates, selectedTemplate])
}, [userTemplates, selectedTemplate])
/** 用户手动选择模板:优先级最高,重置失效提示标记 */
const handleSelectTemplate = useCallback((id: string) => {
fallbackNotifiedRef.current = ""
setSelectedTemplate(id)
}, [])
/**
* 退#1777 3
* 404/ 400/422
* - toast
* - Step1
*
* true
*/
const handleInvalidTemplate = useCallback((): boolean => {
const current = selectedRef.current
// 同一个失效模板只提示一次(clips 防抖 effect 在素材/模板变化时会反复触发)
if (current && fallbackNotifiedRef.current === current) return true
const fallback = findFirstValidTemplate(templatesRef.current)
fallbackNotifiedRef.current = current || "__empty__"
if (fallback) {
setSelectedTemplate(fallback.id)
message.warning(INVALID_TEMPLATE_FALLBACK_TOAST)
} else {
// 没有任何有效模板:清空选择,交由 Step1 空状态引导用户去模板编辑器创建
setSelectedTemplate("")
message.warning("当前没有可用模板,请先在「模板编辑器」中创建并配置片段")
}
return true
}, [])
return {
selectedTemplate,
setSelectedTemplate: handleSelectTemplate,
userTemplates,
handleInvalidTemplate,
}
return { selectedTemplate, setSelectedTemplate, userTemplates }
}
@@ -2,13 +2,13 @@
* Hook
*
*/
import { useState, useCallback, useEffect } from "react"
import { useState, useCallback } from "react"
import { message } from "antd"
import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
import { createGenerationTask } from "@/api/tasks/tasks"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling, type BatchTaskState } from "./generate-video/useGenerationPolling"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
import { validateGenerateInputs } from "./generate-video/buildPayload"
import { calculateResolution } from "../utils/calculateResolution"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
@@ -22,32 +22,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial<BatchTaskState>) => {
setBatchTasks((prev) => {
const list = prev || []
const idx = list.findIndex((t) => t.taskId === taskId)
if (idx === -1) {
return [
...list,
{
taskId,
variantIndex: patch.variantIndex ?? 0,
status: "running",
progress: 0,
error: null,
videos: [],
...patch,
},
]
}
const next = [...list]
next[idx] = { ...next[idx], ...patch }
return next
})
}, [])
const handleProgress = useCallback((p: number) => setProgress(p), [])
const handleComplete = useCallback(
@@ -55,19 +29,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerating(false)
setGenerated(true)
setGeneratedVideos(videos as GeneratedVideo[])
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
setBatchTasks((prev) =>
(prev || []).map((t) =>
t.status === "completed" && t.videos.length === 0
? {
...t,
videos: (videos as GeneratedVideo[]).filter(
(v) => v.generation_task_id === t.taskId,
),
}
: t,
),
)
onGenerationSuccess?.()
},
[onGenerationSuccess],
@@ -77,30 +38,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(errorMsg)
}, [])
/*
6 */
useEffect(() => {
if (batchTasks.length === 0) return
const byVariant = new Map<number, GeneratedVideo>()
batchTasks.forEach((t) => {
if (t.status === "completed" && t.videos && t.videos.length > 0) {
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
}
})
const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v)
setGeneratedVideos((prev) => {
if (prev.length === ordered.length && prev.every((v, i) => v.id === ordered[i].id)) {
return prev
}
return ordered
})
}, [batchTasks])
const { startPolling, startPollingBatch, retryTask, clearTimer } = useGenerationPolling({
const { startPolling, clearTimer } = useGenerationPolling({
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
onBatchTaskUpdate: handleBatchTaskUpdate,
})
/*
@@ -116,7 +57,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setProgress(0)
setGenerated(false)
setGenerateError(null)
setBatchTasks([])
clearTimer()
try {
@@ -143,11 +83,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
}
const isBatch = (props.previewCount || 1) > 1
const hide = message.loading(
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
0,
)
const hide = message.loading("正在生成预览视频...", 0)
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
@@ -156,37 +92,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
const indexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = isBatch ? indexes.length : 1
// 标题文字数组:批量时按勾选顺序
const titlesArr =
isBatch && (props.variantTitles?.length || 0) >= batchCount
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
: []
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id
const voiceArr =
isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId)
: []
// 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面)
const coversArr =
isBatch && props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: []
// #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传,
// 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。
// 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。
const variantPlansArr =
isBatch && props.variantPlanIds?.length
? indexes.map((i) => props.variantPlanIds![i] || "")
: []
const hasVariantPlans = variantPlansArr.some((id) => !!id)
try {
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
@@ -204,11 +109,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(isBatch ? { count: batchCount } : {}),
...(titlesArr.length ? { titles: titlesArr } : {}),
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
...(coversArr.length ? { cover_urls: coversArr } : {}),
...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}),
...(props.titleSettings?.title
? {
title_config: {
@@ -233,17 +133,12 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: {}),
})
hide()
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
const taskId = taskResp.items?.[0]?.id
if (taskIds.length === 0) {
if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
if (taskIds.length > 1) {
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
} else {
startPolling(taskIds[0])
}
startPolling(taskId)
} catch (err) {
hide()
throw err
@@ -259,21 +154,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
return false
}
return true
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate])
}, [props, clearTimer, startPolling, selectedTemplate])
const retry = useCallback(() => {
setGenerateError(null)
generate()
}, [generate])
/** 第5步:单独重试某个失败任务 */
const retryBatchTask = useCallback(
(taskId: string) => {
retryTask(taskId)
},
[retryTask],
)
const dismissError = useCallback(() => {
setGenerateError(null)
}, [])
@@ -318,8 +205,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
generatedVideos,
generate,
retry,
retryBatchTask,
batchTasks,
dismissError,
download,
share,
@@ -44,12 +44,6 @@ interface UsePreviewAssetsReturn {
ready: boolean
/** 手动触发重新加载 */
reload: () => void
/**
* #1750 clips
* / assets asset_id
* URL
*/
ensureAssets: (ids: string[]) => Promise<void>
}
/**
@@ -97,41 +91,11 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
}
}, [stableAssetIds, enabled])
// 补拉用独立序号:不干扰主 load 的竞态守卫
const ensureSeqRef = useRef(0)
const assetsRef = useRef<AssetItem[]>([])
useEffect(() => {
assetsRef.current = assets
}, [assets])
/**
* #1750 clips asset_id
*
*/
const ensureAssets = useCallback(async (ids: string[]) => {
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
if (!validIds.length) return
const seq = ++ensureSeqRef.current
const missing = Array.from(new Set(validIds)).filter(
(id) => !assetsRef.current.some((a) => a.id === id),
)
if (!missing.length) return
const fetched = await fetchAssetsByIds(missing)
if (seq !== ensureSeqRef.current || !fetched.length) return
const existing = new Set(assetsRef.current.map((a) => a.id))
const additions = fetched.filter((a) => !existing.has(a.id))
if (!additions.length) return
const merged = [...assetsRef.current, ...additions]
assetsRef.current = merged
setAssets(merged)
setReady(merged.length > 0)
}, [])
useEffect(() => {
load()
}, [load])
return { assets, loading, ready, reload: load, ensureAssets }
return { assets, loading, ready, reload: load }
}
/**
@@ -15,8 +15,6 @@ export interface PlaybackSegment {
startTime: number
endTime: number
order: number
/** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */
playbackRate?: number
}
export interface SegmentSchedulerState {
@@ -133,11 +131,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (Math.abs(video.currentTime - localTime) > 0.05) {
video.currentTime = localTime
}
// #1754:按比例调速(配音时长≠clips 总时长时的前端兜底)
const rate = seg.playbackRate || 1
if (Math.abs(video.playbackRate - rate) > 0.01) {
video.playbackRate = rate
}
segIdxRef.current = index
setCurrentSegmentIndex(index)
@@ -249,12 +242,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
video.currentTime = seg.startTime
}
// #1754:调速
const rate = seg?.playbackRate || 1
if (Math.abs(video.playbackRate - rate) > 0.01) {
video.playbackRate = rate
}
try {
await video.play()
setIsPlaying(true)
@@ -0,0 +1,294 @@
/**
* Hook
*
*
* 1. POST /generation/preview
* 2. GET /generation/preview/{task_id}
* 3. URL <video>
* 4. stale
* 5. 2
*
*
* idle loading ready stale (config changed)
* failed idle (retry)
*/
import { useState, useCallback, useRef, useEffect } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import type { CreatePreviewRequest } from "@/api/generation/types"
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
interface UseServerPreviewOptions {
/** 是否启用预览(Step4+ 且有素材和模板时) */
enabled: boolean
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
buildRequest: () => CreatePreviewRequest
/** 预览任务创建成功回调 */
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
}
interface UseServerPreviewReturn {
status: ServerPreviewStatus
videoUrl: string | null
error: string | null
/** 进度 0-100 */
progress: number
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
triggerPreview: () => void
/** 当前预览任务 ID */
taskId: string | null
}
const POLL_INTERVAL = 2000
const POLL_TIMEOUT = 120_000
const MAX_NETWORK_RETRIES = 2
/**
*
*/
function buildFingerprint(req: CreatePreviewRequest): string {
return JSON.stringify({
t: req.template_id,
a: [...req.asset_ids].sort(),
d: req.duration,
r: req.video_ratio,
v: req.voice_library_id,
b: req.bgm_config,
title: req.title_config,
})
}
export function useServerPreview({
enabled,
buildRequest,
onPreviewTaskCreated,
}: UseServerPreviewOptions): UseServerPreviewReturn {
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
const [videoUrl, setVideoUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [taskId, setTaskId] = useState<string | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSeqRef = useRef(0)
const renderedFingerprintRef = useRef<string>("")
const mountedRef = useRef(true)
const networkRetriesRef = useRef(0)
// 始终持有最新的 buildRequest 和回调
const buildRequestRef = useRef(buildRequest)
buildRequestRef.current = buildRequest
const onCreatedRef = useRef(onPreviewTaskCreated)
onCreatedRef.current = onPreviewTaskCreated
/* ── 清理 ── */
const clearTimers = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
clearTimers()
}
}, [clearTimers])
/* ── 创建预览 + 轮询 ── */
const createAndPoll = useCallback(
async (request: CreatePreviewRequest, seq: number) => {
setStatus("loading")
setProgress(0)
setError(null)
networkRetriesRef.current = 0
try {
const resp = await createPreview(request)
if (seq !== requestSeqRef.current || !mountedRef.current) return
setTaskId(resp.task_id)
onCreatedRef.current?.(resp.task_id, resp.source_edit_plan_id)
let completed = false
// 超时保护
timeoutTimerRef.current = setTimeout(() => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
completed = true
clearTimers()
setStatus("failed")
setError("预览渲染超时(120秒),请重试")
}, POLL_TIMEOUT)
const poll = async () => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
try {
const st = await getPreviewStatus(resp.task_id)
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) {
completed = true
clearTimers()
renderedFingerprintRef.current = buildFingerprint(request)
setVideoUrl(st.video_url)
setProgress(100)
setStatus("ready")
setError(null)
return
}
if (st.status === "failed" || st.status === "cancelled") {
completed = true
clearTimers()
setStatus("failed")
setError(
st.status === "cancelled"
? "预览任务已取消"
: st.error_message || "预览渲染失败,请重试",
)
return
}
// pending / generating
if (typeof st.progress === "number") setProgress(st.progress)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
} catch (pollErr) {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
pollErr,
)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
} else {
completed = true
clearTimers()
setStatus("failed")
setError("网络错误,无法获取预览状态,请重试")
}
}
}
poll()
} catch (createErr) {
if (seq !== requestSeqRef.current || !mountedRef.current) return
console.error("[ServerPreview] 创建预览任务失败:", createErr)
const isNetworkError =
!!(createErr as { request?: unknown })?.request ||
(createErr as { code?: string })?.code === "ERR_NETWORK"
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
setTimeout(() => {
if (seq === requestSeqRef.current && mountedRef.current) {
createAndPoll(request, seq)
}
}, POLL_INTERVAL * 2)
return
}
const errData = (
createErr as { response?: { data?: { detail?: string; message?: string } } }
)?.response?.data
setStatus("failed")
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
}
},
[clearTimers],
)
/* ── 手动触发预览 ── */
const triggerPreview = useCallback(() => {
if (!enabled) return
const request = buildRequestRef.current()
if (!request.template_id || request.asset_ids.length === 0) return
clearTimers()
const seq = ++requestSeqRef.current
setVideoUrl(null)
setTaskId(null)
createAndPoll(request, seq)
}, [enabled, clearTimers, createAndPoll])
/* ── 自动触发 + 配置变更检测 ── */
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
const request = enabled ? buildRequest() : null
const currentFingerprint = request
? request.template_id && request.asset_ids.length > 0
? buildFingerprint(request)
: ""
: ""
// 首次进入自动触发
const didInitRef = useRef(false)
useEffect(() => {
if (!enabled || !currentFingerprint) {
didInitRef.current = false
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
requestSeqRef.current += 1
clearTimers()
return
}
if (!didInitRef.current) {
didInitRef.current = true
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
}
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
const prevFingerprintRef = useRef(currentFingerprint)
useEffect(() => {
if (!enabled || !currentFingerprint) return
const prev = prevFingerprintRef.current
prevFingerprintRef.current = currentFingerprint
if (!prev || prev === currentFingerprint) return
if (currentFingerprint === renderedFingerprintRef.current) return
// 配置已变更
// 判断是标题样式变更还是素材/配音/BGM 变更
const prevParsed = JSON.parse(prev) as Record<string, unknown>
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
const nonTitleChanged =
prevParsed.t !== currParsed.t ||
prevParsed.a !== currParsed.a ||
prevParsed.d !== currParsed.d ||
prevParsed.r !== currParsed.r ||
prevParsed.v !== currParsed.v ||
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
if (nonTitleChanged) {
// 素材/配音/BGM/模板等变化 → 自动重新渲染
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
} else {
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
// 实时预览由 CSS TitleOverlay 提供
setStatus((s) => (s === "ready" ? "stale" : s))
}
}, [enabled, currentFingerprint, triggerPreview])
return {
status,
videoUrl,
error,
progress,
triggerPreview,
taskId,
}
}
export default useServerPreview
@@ -10,7 +10,6 @@ import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
import { useDraftAutoSave } from "./useDraftAutoSave"
import { isInvalidTemplateError } from "./useGenerateFormState/templateFallback"
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
@@ -25,8 +24,6 @@ interface UseStep2MaterialsProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
export function useStep2Materials({
@@ -39,7 +36,6 @@ export function useStep2Materials({
selectedTemplate,
templateSegments,
onServerClipsChange,
onTemplateInvalid,
}: UseStep2MaterialsProps) {
const {
libraries,
@@ -106,8 +102,6 @@ export function useStep2Materials({
selectedTemplateRef.current = selectedTemplate
const onServerClipsChangeRef = useRef(onServerClipsChange)
onServerClipsChangeRef.current = onServerClipsChange
const onTemplateInvalidRef = useRef(onTemplateInvalid)
onTemplateInvalidRef.current = onTemplateInvalid
useEffect(() => {
const tid = selectedTemplateRef.current
@@ -129,12 +123,11 @@ export function useStep2Materials({
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
try {
// 1. 清空旧片段(静默全局 toast:模板失效时由下方回退统一提示)
await updateEditPlanClips(tid, [], controller.signal, true)
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal)
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底)
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
signal: controller.signal,
silentErrorToast: true,
})
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
const clipList = await getEditPlanClips(tid, { limit: 500 })
@@ -153,14 +146,6 @@ export function useStep2Materials({
message.error("智能选片失败,请重试")
return
}
// 模板失效(404 模板不存在 / 400/422 无片段配置):
// 清空失效选择并自动切到第一个有效模板 + toast,避免页面卡死无提示(#1777)
if (isInvalidTemplateError(err)) {
console.warn("[useStep2Materials] 当前模板已失效,触发自动回退:", err)
onServerClipsChangeRef.current?.([])
onTemplateInvalidRef.current?.()
return
}
console.warn("[useStep2Materials] 写入 clips 失败:", err)
}
}, 800)

Some files were not shown because too many files have changed in this diff Show More