Compare commits

..

5 Commits

Author SHA1 Message Date
xiaoxia dbfb1e2bcc chore: 合入最新develop触发合入检查
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker 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 / 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 Web Image (pull_request) Successful in 39s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m43s
AI Code Review / AI Code Review (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m33s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 24m9s
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 / 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
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m33s
2026-09-05 22:01:46 +08:00
xiaoxia e061685982 Merge remote-tracking branch 'origin/develop' into fix/1718-callback-error-observability 2026-09-05 22:01:46 +08:00
xiaoxia 9a0b9c3234 chore: 触发CI重新合入检查(rebase develop)
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API 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 / 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 / Frontend Lint (pull_request) Successful in 1m54s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m33s
AI Code Review / AI Code Review (pull_request) Successful in 6m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m38s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 18m39s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-05 21:29:54 +08:00
xiaoxia 8156bb1e13 Merge remote-tracking branch 'origin/develop' into fix/1718-callback-error-observability 2026-09-05 21:29:54 +08:00
xiaoxia 6f4499afff fix(#1718/#1714): 微信回调state误杀修复+错误透传防连点、上传失败完整可观测、哈希阈值降至64MB
AI Code Review / AI Code Review (pull_request) Successful in 1m55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m6s
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 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker 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 / Frontend Unit Tests (pull_request) Successful in 2m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m56s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m56s
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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m40s
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 / Validate - Style (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 17m20s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
- WechatCallback/WechatBindCallback: 删除localStorage state前置校验(后端state store一次性消费兜底,微信内/跨浏览器误杀正常回调);catch透传后端真实detail;处理微信重定向error参数
- Login: 微信登录按钮加ref同步防连点(连点两次state互相覆盖)
- 新增api/errors.ts统一错误提取(axios detail/422数组/状态码/网络超时)
- useAssetUpload: 失败卡片记录失败阶段+HTTP状态码+OSS XML错误明细;getOrCreateDefaultProject失败独立提示;toast与拦截器去重
- UploadQueuePanel: 失败卡片多行展示完整原因(阶段+错误+安全重试提示)
- uploadDedup: 全量哈希阈值256MB→64MB,抽样片段8MB→16MB,避免100~256MB视频整文件读入内存卡死
- WechatOnboarding: 昵称输入框不预填,用户必须自己输入
- 测试: 新增22用例,全量644通过
2026-09-05 20:12:47 +08:00
168 changed files with 2613 additions and 11717 deletions
@@ -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",
+19 -65
View File
@@ -1,5 +1,4 @@
"""
from __future__ import annotations
Canonical authentication API routes.
The route layer is intentionally thin: repository construction lives in
@@ -16,7 +15,7 @@ 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.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
@@ -86,22 +85,6 @@ class CurrentUserResponse(BaseModel):
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):
@@ -291,51 +274,9 @@ async def get_current_user_info(
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
@@ -566,20 +507,34 @@ class WechatBindCompleteRequest(BaseModel):
state: str = ""
class WechatBindUserProfile(BaseModel):
"""绑定/解绑后返回的用户信息(字段对齐 /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
class WechatBindCompleteResponse(BaseModel):
success: bool
user: UserProfileResponse
user: WechatBindUserProfile
class WechatUnbindResponse(BaseModel):
success: bool
def _user_profile(user) -> UserProfileResponse:
def _wechat_user_profile(user) -> WechatBindUserProfile:
binding_complete = bool(
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return UserProfileResponse(
return WechatBindUserProfile(
user_id=user.id,
email=user.email,
username=user.username,
@@ -589,7 +544,6 @@ def _user_profile(user) -> UserProfileResponse:
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
profile_completed=user.profile_completed,
)
@@ -635,7 +589,7 @@ async def wechat_bind(
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))
return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user))
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
+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,
+14 -89
View File
@@ -390,6 +390,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 +420,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 +466,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(
+44 -225
View File
@@ -57,30 +57,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 +123,6 @@ def _select_assets_from_library(
assets: list,
mode: str,
count: int,
rng=None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -155,8 +130,6 @@ def _select_assets_from_library(
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 smart 模式有效)
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
测试可注入固定种子或零噪声随机源获得确定性结果。
Returns:
选中的素材 ID 列表
@@ -169,9 +142,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 视频素材
@@ -460,199 +432,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 +500,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 +523,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
@@ -873,6 +690,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,
@@ -1016,6 +834,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", ""),
@@ -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))
+12 -125
View File
@@ -108,8 +108,9 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str:
return "video/mp4" # default
# 兜底去重:无 file_hash / client_upload_id 且大小已知时,同库同名同大小近期活动记录视为重复
# 兜底去重:无 file_hash / client_upload_id 时,同库同名近期活动记录视为重复
FALLBACK_DEDUP_WINDOW_MINUTES = 30
ACTIVE_ASSET_STATUSES = (AssetStatus.UPLOADING, AssetStatus.PROCESSING)
def _find_duplicate_asset(
@@ -125,12 +126,8 @@ def _find_duplicate_asset(
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 判重,直接放行
3. 兜底:同库 + 同文件名(+同大小)且 30 分钟内仍处 uploading/processing
的记录——旧客户端不传 hash/token 时,防止 complete 超时重试反复建占位。
全部为鸭子类型调用:旧仓储无对应方法时静默跳过,不破坏既有实现。
"""
@@ -159,35 +156,24 @@ def _find_duplicate_asset(
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:
if filename:
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,
file_size=file_size or 0,
)
if existing is not None:
if existing is not None and getattr(existing, "status", None) in ACTIVE_ASSET_STATUSES:
logger.info(
"素材幂等兜底命中(近期同名同大小活动记录): library=%s name=%s asset=%s status=%s size=%s",
"素材幂等兜底命中(近期活动同名记录): library=%s name=%s asset=%s status=%s",
library_id,
filename,
getattr(existing, "id", "?"),
getattr(existing, "status", None),
file_size,
getattr(existing, "status", "?"),
)
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
@@ -201,44 +187,8 @@ def _create_pending_asset(
user_id,
file_hash="",
client_upload_id="",
file_size: int = 0,
):
"""立即创建或复用一条 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,
@@ -249,7 +199,6 @@ def _create_pending_asset(
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)
@@ -294,15 +243,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 +264,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 +284,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 +291,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,
)
@@ -472,7 +360,6 @@ async def complete_direct_upload(
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(
+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
-4
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,9 +25,6 @@ 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):
-361
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,209 +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
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],
)
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)
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
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
self.update_plan_config(pid, {"visual_perturbation": perturbation})
logger.info("变体 %d 视觉扰动: plan=%s perturbation=%s", idx, pid, perturbation)
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]:
-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

-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
}
/** 直传完成确认返回 */
-14
View File
@@ -32,8 +32,6 @@ export const completeDirectUpload = async (data: {
file_hash?: string
/** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */
client_upload_id?: string
/** 文件字节数;后端同名兜底去重需用它做大小校验,缺失(=0)时同名记录一律不判重 */
file_size?: number
}): Promise<DirectUploadCompleteResult> => {
// complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s;
// 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件
@@ -158,8 +156,6 @@ export const prepareDirectUploadHandle = async (data: {
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
// 透传文件字节数:后端同名兜底去重依赖大小校验,缺省会导致同名新视频被误判重复
file_size: data.file.size,
}),
}
}
@@ -183,16 +179,6 @@ export const uploadAssetDirect = async (data: {
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,
}
}
await handle.transfer(data.onProgress)
return handle.complete()
}
-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
}
}
+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
@@ -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
}
-6
View File
@@ -100,12 +100,6 @@ export interface CreateGenerationTaskRequest {
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
cover_urls?: string[]
/**
* 批量变体剪辑计划 ID(#1744):预览阶段后端独立选片产出的 plan id 列表
* (按变体全量索引,长度=previewCount)。正式生成回传后后端直接关联这些
* plan 渲染、不再重新选片,保证预览所见即成片。后端未支持时忽略该字段。
*/
variant_plan_ids?: string[]
}
/** 单个生成任务详情(对齐后端 GenerationTaskResponse */
+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>
@@ -141,20 +141,6 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
}))
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
}
if (h.prepared.asset_id) {
updateItem(item.tempId, {
status: "uploading",
+35 -33
View File
@@ -1,12 +1,13 @@
/**
* 登录页面 - V21 完全对标
*/
import React, { useState } from "react"
import React, { useRef, 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 { getErrorMessage, isErrorMsgShown } from "@/api/errors"
import Button from "@/components/ui/Button"
import WechatQrModal from "@/components/auth/WechatQrModal"
import "./Login.css"
interface LoginFormValues {
@@ -19,7 +20,10 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const loginMutation = useLogin()
const [form] = Form.useForm()
const [wechatQrOpen, setWechatQrOpen] = useState(false)
const [wechatLoading, setWechatLoading] = useState(false)
// 同步防连点守卫:state 更新有渲染间隙,连点两次会各自请求授权 URL,
// 后一次的 state 覆盖前一次写入 localStorage 的 state,导致回调校验失败
const wechatStartingRef = useRef(false)
const onFinish = async (values: LoginFormValues) => {
try {
@@ -35,28 +39,33 @@ 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 () => {
if (wechatStartingRef.current) return
wechatStartingRef.current = true
setWechatLoading(true)
try {
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) {
// 跳走前才可能回到这里;拦截器已弹过后端 detail 时不重复弹,
// 否则透传真实原因(如微信服务未配置、网络异常)
if (!isErrorMsgShown(error)) {
message.error(`微信登录启动失败:${getErrorMessage(error, "请稍后重试")}`)
}
wechatStartingRef.current = false
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 })
// 成功时 window.location 跳走,不复位 loading(页面即将卸载)
}
return (
@@ -127,10 +136,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 +147,6 @@ const Login: React.FC = () => {
<Link to="/register"></Link>
</div>
</div>
<WechatQrModal
open={wechatQrOpen}
scene="login"
onClose={() => setWechatQrOpen(false)}
onLoginSuccess={handleWechatQrSuccess}
/>
</div>
)
}
+4 -27
View File
@@ -1,11 +1,6 @@
/**
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
@@ -13,30 +8,19 @@ 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("无效的回调参数,请回到设置页重新扫码绑定")
setError("无效的回调参数,请回到设置页重新扫码绑定")
return
}
@@ -48,23 +32,16 @@ const WechatBindCallback: React.FC = () => {
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, "请回到设置页重试")}`)
// 绑定失败直接在本页展示真实原因(如微信已被其他账号绑定),不静默跳走
setError(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
}
}
handleBind()
}, [searchParams, navigate, setUser, inIframe])
}, [searchParams, navigate, setUser])
if (error) {
return (
+8 -29
View File
@@ -2,11 +2,6 @@
* 微信登录回调页
* 扫码授权后由微信重定向回来:用 code 换登录态,
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
@@ -15,7 +10,6 @@ import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/
import { getErrorMessage } from "@/api/errors"
import { useAuthStore } from "@/store/authStore"
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
const WechatCallback: React.FC = () => {
const [searchParams] = useSearchParams()
@@ -23,33 +17,24 @@ const WechatCallback: React.FC = () => {
const setAuth = useAuthStore((state) => state.setAuth)
const [loading, setLoading] = useState(true)
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)直接展示/上报原因
// 微信重定向出错时(如用户拒绝授权 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}`)
setError(`微信授权失败:${reason}`)
setLoading(false)
return
}
if (!code || !state) {
fail("无效的回调参数,请重新扫码登录")
setError("无效的回调参数,请重新扫码登录")
setLoading(false)
return
}
@@ -76,13 +61,6 @@ const WechatCallback: React.FC = () => {
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
const needOnboarding = result.is_new_user || user.profile_completed === false
if (inIframe) {
// 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转
postWechatQrResult("login", true, { needOnboarding })
return
}
if (needOnboarding) {
navigate("/welcome/wechat", { replace: true })
return
@@ -94,12 +72,13 @@ const WechatCallback: React.FC = () => {
navigate(redirect, { replace: true })
} catch (err) {
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
setError(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
setLoading(false)
}
}
handleCallback()
}, [searchParams, navigate, setAuth, inIframe])
}, [searchParams, navigate, setAuth])
if (loading) {
return (
+3 -16
View File
@@ -2,12 +2,11 @@
* 微信新用户昵称引导页
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
*/
import React, { useRef } from "react"
import React 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"
@@ -23,10 +22,6 @@ const WechatOnboarding: React.FC = () => {
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 }),
@@ -42,8 +37,6 @@ const WechatOnboarding: React.FC = () => {
}
const onFinish = async (values: OnboardingFormValues) => {
if (submittingRef.current) return
submittingRef.current = true
try {
const updated = await saveMutation.mutateAsync(values.display_name.trim())
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
@@ -52,14 +45,9 @@ const WechatOnboarding: React.FC = () => {
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
} catch {
message.error("保存失败,请重试")
}
// 成功时页面跳走,不复位
}
return (
@@ -101,7 +89,6 @@ const WechatOnboarding: React.FC = () => {
buttonSize="lg"
htmlType="submit"
loading={saveMutation.isPending}
disabled={saveMutation.isPending}
style={{ width: "100%" }}
>
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
@@ -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,
+14 -138
View File
@@ -27,8 +27,6 @@ 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"
@@ -138,17 +136,14 @@ const GeneratePage: React.FC = () => {
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
// 批量模式下 TTS 文案跟随变体0标题;单视频跟随主标题
const ttsTitle = isBatch ? variant0Title || "" : titleSettings.title
const voiceId = selectedClonedVoice || selectedVoice
if (!voiceId || !ttsTitle) {
setPreviewVoiceAudioUrl(null)
@@ -218,56 +213,14 @@ const GeneratePage: React.FC = () => {
/* ── 加载素材详情(供前端预览播放器使用) ── */
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) => {
@@ -309,8 +262,6 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
// #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片
variantPlanIds,
previewTaskId,
bgmConfig,
previewCount,
@@ -420,9 +371,7 @@ const GeneratePage: React.FC = () => {
const layoutClassName = useMemo(() => {
if (currentStep < 4) return "xx-generate-layout full-width"
if (currentStep === 4) return "xx-generate-layout step4-layout"
// 步骤5:全宽+内容居中(单视频视频播放器居中,批量网格居中)
if (currentStep === 5) return "xx-generate-layout full-width"
// 步骤6:封面选择保持两栏布局
// 步骤5/6:批量网格需要整行宽度;单视频保持 表单+右侧成片 两栏
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
}, [currentStep, isBatch])
@@ -444,6 +393,7 @@ const GeneratePage: React.FC = () => {
/* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
@@ -475,14 +425,11 @@ const GeneratePage: React.FC = () => {
<CanvasPreviewGrid
count={previewCount}
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls}
variantClips={variantClips}
clipsLoading={variantClipsLoading}
clipsError={variantClipsError}
onRetryClips={retryVariantClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
@@ -555,88 +502,17 @@ const GeneratePage: React.FC = () => {
generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
{/* ════ 步骤5(单视频):成片播放器内联居中(#1761) ════ */}
{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
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>
)}
</div>
{/* ════ 步骤6(单视频):右侧成片播放器 ════ */}
{currentStep >= 6 && !isBatch && generated && finalVideo && (
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
{currentStep >= 5 && !isBatch && generated && finalVideo && (
<div className="xx-generate-right-col">
<div
className="xx-inline-video-player"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#000",
borderRadius: 12,
padding: 8,
}}
>
<div className="xx-inline-video-player">
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay={currentStep === 5}
// 竖屏自适应(#1750):成片固定 1080×19209:16),元数据到达前按 9:16 占位,
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
style={{
width: "auto",
maxWidth: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "contain",
borderRadius: 8,
}}
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
@@ -34,26 +34,12 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
{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%",
}}
>
<div className="xx-batch-gen-grid">
{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 key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" ? (
@@ -80,32 +66,12 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
</>
)}
{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>
<video
src={video.download_url || video.file_url}
controls
style={{ width: "100%", borderRadius: 8, background: "#000", maxHeight: 280 }}
poster={video.thumbnail_url}
/>
)}
{task.status === "completed" && !video && (
<div className="xx-batch-gen-card-done"> </div>
@@ -1,47 +1,27 @@
/**
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划
* 批量前端 Canvas 实时预览网格(Issue #1677 修正方案
*
* N 个 FrontendPreviewPlayer 网格排列:
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
* 加载中 → 9:16 占位防塌陷;
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
* - 勾选框决定提交时生成哪些变体;
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口
* - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用
* - 勾选框决定提交时生成哪些变体
*/
import React, { useState } from "react"
import React 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 { EditingTemplate } from "@/api/editing-planner"
import type { TitleSettings } from "../types"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
interface CanvasPreviewGridProps {
count: number
assets: AssetItem[]
template: EditingTemplate | null
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
/** 共用配音预览音频(仅第 1 个变体播放,避免多路音频重叠) */
voiceAudioUrl?: string
/** 勾选的变体序号 */
selectedIds: number[]
onToggleSelect: (index: number) => void
@@ -52,21 +32,15 @@ interface CanvasPreviewGridProps {
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
count,
assets,
template,
videoRatio,
titles,
titleSettings,
voiceAudioUrls,
variantClips,
clipsLoading = false,
clipsError = false,
onRetryClips,
voiceAudioUrl,
selectedIds,
onToggleSelect,
selectable = true,
}) => {
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750 ──
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10clamp
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
return (
@@ -90,54 +64,29 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
<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,
}}
/>
)}
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assets.length > 0}
variantSeed={i + 1}
variantTitle={titles[i] || ""}
voiceAudioUrl={i === 0 ? voiceAudioUrl : undefined}
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>
)
})}
@@ -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?: {
@@ -43,21 +42,15 @@ interface FrontendPreviewPlayerProps {
}
onTitlePositionChange?: (posX: number, posY: number) => void
/**
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
* 持有播放权的实例播放,其余自动暂停
* 变体种子(批量生成 #1677):同一批素材在不同变体中采用不同的素材顺序与
* 片段起始点,让 N 个 Canvas 预览画面有差异(纯前端随机剪辑模拟,不调后端)
* 0 / 不传 = 单视频,排布与旧版完全一致(零回归)。
*/
playToken?: number
variantSeed?: 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 +60,114 @@ function formatTime(seconds: number): string {
}
/**
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback
*
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
* 将素材映射为播放片段(复用原逻辑
*/
/** 简单可复现随机数(mulberry32),同一种子产出稳定排布,避免每次渲染抖动 */
function seededRandom(seed: number): () => number {
let a = seed >>> 0
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function buildPlaybackSegments(
assets: AssetItem[],
template: EditingTemplate | null,
serverClips?: EditPlanClip[],
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
speedFactor = 1,
variantSeed = 0,
): 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: 本地构建片段
// variantSeed=0(单视频):与旧行为完全一致(素材原序、起始点 0),零回归
// variantSeed>0(批量变体):素材顺序按种子轮换 + 片段起始点在素材内偏移,
// 模拟后端"AI 随机剪辑出不同版本",让 N 个预览画面有可见差异
const templateSegments = template?.segments || []
const segments: PlaybackSegment[] = []
const orderedAssets = variantSeed > 0 ? [...assets] : assets
if (variantSeed > 0 && orderedAssets.length > 1) {
const rand = seededRandom(variantSeed * 7919 + 13)
// 素材轮换:把数组旋转 (seed % n) 位,再对后半段做一次稳定交换
const n = orderedAssets.length
const rotate = variantSeed % n
orderedAssets.push(...orderedAssets.splice(0, rotate))
const swapA = Math.floor(rand() * n)
const swapB = Math.floor(rand() * n)
if (swapA !== swapB) {
;[orderedAssets[swapA], orderedAssets[swapB]] = [orderedAssets[swapB], orderedAssets[swapA]]
}
}
orderedAssets.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)
let startTime = 0
if (variantSeed > 0 && assetDuration - segDuration > 1) {
const rand = seededRandom(variantSeed * 104729 + i * 31 + 7)
// 起始点在素材可用区间内随机偏移(至少留 0.5s 余量)
const maxStart = Math.max(0, assetDuration - segDuration - 0.5)
startTime = Math.round(rand() * maxStart * 10) / 10
}
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,
variantSeed = 0,
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, variantSeed),
[assets, template, serverClips, variantSeed],
)
// 批量变体:标题文字取 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) {
@@ -339,7 +339,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
canPlay: videoCanPlay,
togglePlayPause: videoTogglePlayPause,
seekTo: videoSeekTo,
pause: videoPause,
videoRefs,
} = useSegmentScheduler(segments)
@@ -354,10 +353,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 +370,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 +409,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)
@@ -604,7 +573,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
maxWidth: compact ? "100%" : 280,
margin: compact ? 0 : "0 auto",
aspectRatio: "9 / 16",
background: compact ? "transparent" : "#0a0a0a",
background: "#0a0a0a",
borderRadius: compact ? 10 : 24,
overflow: "hidden",
boxShadow: compact
@@ -628,7 +597,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectFit: "contain",
}}
/>
</div>
@@ -639,7 +608,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 +619,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 +662,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: onTitlePositionChange && variantSeed === 0 ? "auto" : "none",
cursor: onTitlePositionChange && variantSeed === 0 ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
@@ -774,45 +743,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 +753,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 +774,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 +788,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 +811,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
@@ -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
@@ -9,13 +9,12 @@
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/
import React, { useMemo, useState } from "react"
import { Input, message } from "antd"
import { AutoComplete, Input, message } from "antd"
import { LoadingOutlined } 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"
@@ -202,14 +201,21 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
</div>
<div className="xx-form-field">
<label></label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
<AutoComplete
placeholder="输入标题文字…"
allowClear
maxLength={50}
style={{ width: "100%" }}
value={(previewTitles?.[0] ?? t.titleSettings.title) || undefined}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
/>
</div>
</>
@@ -263,11 +269,17 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
{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)}
<AutoComplete
placeholder={`视频 ${i + 1} 的标题`}
maxLength={50}
style={{ width: "100%" }}
value={previewTitles?.[i] || undefined}
onChange={(val) => updateVariantTitle(i, val || "")}
options={titleOptions}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
/>
</div>
))}
@@ -37,12 +37,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 "未知"
@@ -267,9 +261,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>
@@ -91,13 +91,11 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
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
onCoversChange: (urls) => {
// 按卡片顺序写回对应变体索引
const next = [...(props.previewCovers || [])]
cardIndexes.forEach((vi, cardPos) => {
next[vi] = nextCardView[cardPos] || ""
next[vi] = urls[cardPos] || ""
})
props.onPreviewCoversChange?.(next)
},
@@ -144,8 +142,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
disabled={completedVideos.length === 0 || batchCovers.loadingIndex !== null}
>
</Button>
@@ -154,8 +151,8 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<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 isLoading = batchCovers.loadingIndex === cardPos
const isUploading = batchCovers.uploadingIndex === cardPos
const title = batchTitles[cardPos]
return (
<div className="xx-cover-card" key={variantIndex}>
@@ -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>
)
}
@@ -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
+9 -53
View File
@@ -3282,28 +3282,17 @@
/* ============================================================
批量前端 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);
}
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.xx-canvas-grid-card {
position: relative;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
overflow: hidden;
background: transparent;
background: #000;
transition: border-color 0.2s ease;
min-width: 0;
}
@@ -3341,37 +3330,6 @@
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
============================================================ */
@@ -3403,18 +3361,16 @@
第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;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.xx-batch-gen-card {
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
padding: 14px;
background: transparent;
background: var(--bg-surface, #fff);
display: flex;
flex-direction: column;
gap: 10px;
@@ -3479,10 +3435,10 @@
word-break: break-word;
}
/* ── 响应式:窄屏批量网格回退单列.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
/* ── 响应式:窄屏批量网格回退单列 ── */
@media (max-width: 960px) {
.xx-canvas-grid,
.xx-batch-gen-grid {
justify-items: center;
grid-template-columns: minmax(0, 320px);
grid-template-columns: 1fr;
}
}
@@ -42,12 +42,6 @@ export interface UseGenerateVideoProps {
variantCoverUrls?: string[]
/** 勾选要生成的变体索引(批量模式) */
selectedVariantIndexes?: number[]
/**
* 批量变体剪辑计划 ID(#1744,按变体全量索引长度=previewCount):
* 预览阶段后端独立选片产出,正式生成按勾选顺序回传,实现预览即成片;
* 为空(降级本地模拟/后端未上线)时不传,后端自行独立选片。
*/
variantPlanIds?: 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,13 +1,6 @@
/**
* 批量封面 HookIssue #1677 / #1750 修复
* 批量封面 HookIssue #1677
* 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"
@@ -15,9 +8,6 @@ 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[]
@@ -34,7 +24,7 @@ interface UseBatchCoversOptions {
shadow: boolean
}
covers: string[]
onCoversChange: CoversChangeFn
onCoversChange: (urls: string[]) => void
}
export function useBatchCovers({
@@ -45,46 +35,28 @@ export function useBatchCovers({
covers,
onCoversChange,
}: UseBatchCoversOptions) {
/** 正在自动生成封面的变体序号集合(数组支持多个卡片同时在途) */
const [busyIndexes, setBusyIndexes] = useState<number[]>([])
/** 正在上传自定义封面的变体序号集合 */
const [uploadingIndexes, setUploadingIndexes] = useState<number[]>([])
const [loadingIndex, setLoadingIndex] = useState<number | null>(null)
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null)
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
})
const next = [...covers]
next[index] = url
onCoversChange(next)
},
[onCoversChange],
[covers, onCoversChange],
)
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
/** 为第 index 个视频自动生成封面 */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
async (index: number) => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
return false
return
}
addBusy(index)
setLoadingIndex(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(selectedTemplate, {
@@ -110,25 +82,23 @@ export function useBatchCovers({
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面生成成功`)
return true
} else {
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
}
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
return false
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
return false
} finally {
removeBusy(index)
setLoadingIndex(null)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover],
)
/** 为第 index 个视频上传自定义封面 */
const uploadOne = useCallback(
async (index: number, file: File) => {
addUploading(index)
setUploadingIndex(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
@@ -151,43 +121,26 @@ export function useBatchCovers({
console.error(`[封面] 视频 ${index + 1} 上传失败:`, err)
message.error("封面上传失败,请重试")
} finally {
removeUploading(index)
setUploadingIndex(null)
}
},
[patchCover, addUploading, removeUploading],
[patchCover],
)
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示 */
/** 一键全部自动生成(串行,避免队列限流) */
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) {
for (let i = 0; i < finalVideos.length; i++) {
if (covers[i]) continue // 已有封面跳过
// 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} 个失败,请对失败项重试`)
await generateOne(i)
}
message.success("全部封面已生成")
}, [generatedVideos, covers, generateOne])
return {
busyIndexes,
uploadingIndexes,
loadingIndex,
uploadingIndex,
generateOne,
uploadOne,
generateAll,
@@ -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
@@ -178,14 +178,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
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({
@@ -208,7 +200,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
...(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: {
@@ -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,297 @@
/**
* 服务器渲染预览 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
// 兼容批量响应 {items, total}:取第一个变体
const firstTask = resp.items?.[0]
const taskId = firstTask?.task_id || ""
setTaskId(taskId)
onCreatedRef.current?.(taskId, 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(taskId)
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
@@ -1,166 +0,0 @@
/**
* 批量变体配音预览音频解析(#1750)
*
* 独立配音模式下每个变体挂载各自的配音 URL:
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
*
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
*/
import { useEffect, useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
import { previewTts } from "@/api/tts"
interface UseVariantVoicePreviewOptions {
/** 是否批量模式(count>1 */
enabled: boolean
/** 变体数量 */
count: number
/** 是否每个视频独立配音 */
perVideo: boolean
/** 共用配音 ID(独立模式下为变体 0 的配音) */
sharedVoiceId: string
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice */
clonedVoiceId?: string
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
variantVoiceIds: string[]
/** 各变体标题(TTS 合成文案源) */
titles: string[]
}
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
if (!id) return null
const m = materials.find((x) => x.id === id)
return m?.file_url || null
}
export function useVariantVoicePreview({
enabled,
count,
perVideo,
sharedVoiceId,
clonedVoiceId = "",
variantVoiceIds,
titles,
}: UseVariantVoicePreviewOptions): (string | null)[] {
const [urls, setUrls] = useState<(string | null)[]>([])
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
const { data: voiceMaterialsData } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
const ttsCacheRef = useRef<Map<string, string>>(new Map())
// 在途请求 AbortController
const abortRef = useRef<AbortController | null>(null)
const seqRef = useRef(0)
useEffect(() => {
if (!enabled || count <= 1) {
setUrls((prev) => (prev.length === 0 ? prev : []))
return
}
const seq = ++seqRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
// 每个变体实际使用的配音 ID
const voiceIds = Array.from({ length: count }, (_, i) =>
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
)
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
const result: (string | null)[] = voiceIds.map((id, i) => {
const materialUrl = findMaterialUrl(id, voiceMaterials)
if (materialUrl) return materialUrl
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
if (!perVideo && i === 0 && clonedVoiceId) {
return findMaterialUrl(clonedVoiceId, voiceMaterials)
}
return null
})
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
voiceIds.forEach((id, i) => {
if (result[i]) return
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
const title = titles[i] || ""
if (!ttsVoiceId || !title) return
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
})
setUrls((prev) =>
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
)
if (ttsJobs.length === 0) return
let cancelled = false
void (async () => {
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
for (const job of ttsJobs) {
const cacheKey = `${job.voiceId}|${job.title}`
const cached = ttsCacheRef.current.get(cacheKey)
if (cached) {
if (seq === seqRef.current) {
setUrls((prev) => {
if (prev[job.index] === cached) return prev
const next = [...prev]
next[job.index] = cached
return next
})
}
continue
}
try {
// eslint-disable-next-line no-await-in-loop
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
const audioUrl = res.audio_url || ""
if (audioUrl) {
ttsCacheRef.current.set(cacheKey, audioUrl)
setUrls((prev) => {
if (prev[job.index] === audioUrl) return prev
const next = [...prev]
next[job.index] = audioUrl
return next
})
}
} catch (err) {
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
}
}
})()
return () => {
cancelled = true
controller.abort()
}
}, [
enabled,
count,
perVideo,
sharedVoiceId,
clonedVoiceId,
variantVoiceIds,
titles,
voiceMaterials,
])
return urls
}
export default useVariantVoicePreview
@@ -0,0 +1,54 @@
/**
* 共享:根据素材列表和模板片段计算总视频时长
* GeneratePage(配音校验)和 FrontendPreviewPlayer(播放控制)共用
*/
export interface DurationAsset {
id?: string
duration?: number
metadata?: { duration?: number }
}
export interface DurationTemplateSegment {
duration_min?: number
duration_max?: number
}
/**
* 计算总视频时长
* @param assets 素材列表
* @param template 模板(含 segments
* @returns 总时长(秒),无有效数据时返回 0
*/
export function calculateTotalVideoDuration(
assets: DurationAsset[] | undefined,
template: { segments?: DurationTemplateSegment[] } | undefined,
): number {
if (!assets || assets.length === 0 || !template) return 0
const templateSegments = template.segments || []
return assets.reduce((sum, 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 ?? assetDuration,
Math.max(tplSeg.duration_min ?? 0, assetDuration),
)
: Math.min(assetDuration, 10)
return sum + segDuration
}, 0)
}
/**
* 估算总视频时长(仅依赖模板 segments)
* 当素材未加载或加载失败时,用各片段 duration_max 之和作为估算值
* 确保配音时长校验不会因素材未就绪而跳过
*/
export function estimateTotalVideoDuration(
template: { segments?: DurationTemplateSegment[] } | undefined,
): number {
if (!template?.segments || template.segments.length === 0) return 0
return template.segments.reduce((sum, seg) => sum + (seg.duration_max || 0), 0)
}
@@ -66,7 +66,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="mt-card-meta">
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length}
{tpl.segments.length} · ~{tpl.estimated_duration}s
</Text>
{tpl.category && <Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>}
</div>
@@ -57,6 +57,11 @@ export function useMyTemplates() {
title_config: tpl.title_config,
subtitle_config: tpl.subtitle_config,
bgm_config: tpl.bgm_config,
estimated_duration:
tpl.estimated_duration ??
Math.round(
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
),
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
}),
onSuccess: () => {
+10 -16
View File
@@ -8,10 +8,9 @@ import { useSearchParams } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { Button, Input, Modal } from "@/components/ui"
import { getCurrentUser, updateProfile, unbindWechat } from "@/api/auth"
import { getCurrentUser, updateProfile, getWechatBindUrl, unbindWechat } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import PageHead from "@/components/layout/PageHead"
import WechatQrModal from "@/components/auth/WechatQrModal"
import "./ProfileSettings.css"
const Settings: React.FC = () => {
@@ -20,7 +19,6 @@ const Settings: React.FC = () => {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [displayName, setDisplayName] = useState(user?.display_name || "")
const [wechatBindOpen, setWechatBindOpen] = useState(false)
const bindTipShownRef = useRef(false)
// 拉取最新用户信息(微信绑定状态以后端为准)
@@ -64,11 +62,14 @@ const Settings: React.FC = () => {
},
})
// 弹窗扫码绑定成功:关闭弹窗,刷新用户信息并提示
const handleBindSuccess = () => {
setWechatBindOpen(false)
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
message.success("微信绑定成功")
const handleBindWechat = async () => {
try {
const result = await getWechatBindUrl()
localStorage.setItem("wechat_bind_state", result.state)
window.location.href = result.auth_url
} catch {
message.error("微信绑定暂不可用,请稍后重试")
}
}
const unbindMutation = useMutation({
@@ -176,20 +177,13 @@ const Settings: React.FC = () => {
</Button>
) : (
<Button buttonType="primary" buttonSize="md" onClick={() => setWechatBindOpen(true)}>
<Button buttonType="primary" buttonSize="md" onClick={handleBindWechat}>
</Button>
)}
</div>
</div>
</div>
<WechatQrModal
open={wechatBindOpen}
scene="bind"
onClose={() => setWechatBindOpen(false)}
onBindSuccess={handleBindSuccess}
/>
</div>
)
}
@@ -1,7 +1,7 @@
/**
* 模板库页面(升级版)— V21 设计系统
* 对接后端模板管理 API
* - 分页查询(page/page_size/category/keyword
* - 分页查询(page/page_size/category/keyword/duration_range
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
* - 复制模板 / 从模板生成
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
@@ -24,6 +24,7 @@ const TemplateLibrary: React.FC = () => {
error,
searchText,
activeType,
durationRange,
page,
pageSize,
setPage,
@@ -33,6 +34,7 @@ const TemplateLibrary: React.FC = () => {
handleCreate,
handleSearchChange,
handleCategoryChange,
handleDurationChange,
} = useTemplateLibrary()
const {
@@ -60,6 +62,8 @@ const TemplateLibrary: React.FC = () => {
onSearchChange={handleSearchChange}
activeType={activeType}
onTypeChange={handleCategoryChange}
durationRange={durationRange}
onDurationChange={handleDurationChange}
/>
{/* 模板展示区 */}
@@ -73,6 +77,7 @@ const TemplateLibrary: React.FC = () => {
errorMessage={error?.message}
searchText={searchText}
activeType={activeType}
durationRange={durationRange}
onPageChange={setPage}
onPreview={handlePreview}
onToggleFavorite={toggleFavorite}
@@ -1,7 +1,7 @@
import React from "react"
import { Tag } from "antd"
import type { TemplateItem } from "@/api/templates"
import { gradientForCategory, getTypeColor } from "../../utils/templateLibrary"
import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary"
interface TemplateCardProps {
template: TemplateItem
@@ -37,7 +37,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{template.clip_count || template.segments?.length || 0}
{formatDuration(template.estimated_duration ?? template.target_duration)}
</span>
</div>
<div className="xx-template-preview-hint"></div>
@@ -5,8 +5,9 @@ import PreviewArea from "./template-detail-modal/PreviewArea"
import SegmentList from "./template-detail-modal/SegmentList"
import StyleConfig from "./template-detail-modal/StyleConfig"
import DetailFooter from "./template-detail-modal/DetailFooter"
import { getTypeColor } from "../../utils/templateLibrary"
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
@@ -17,6 +18,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
onCopy,
}) => {
const segments = template.segments ?? []
const totalSegmentDuration = calcTotalSegmentDuration(segments)
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return (
@@ -62,6 +64,11 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
size="small"
className="xx-template-modal-desc-table"
items={[
{
key: "duration",
label: "目标时长",
children: formatDuration(template.estimated_duration ?? template.target_duration),
},
{
key: "clips",
label: "片段数量",
@@ -80,7 +87,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
]}
/>
<SegmentList segments={segments} />
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
<StyleConfig template={template} />
<DetailFooter
template={template}
@@ -14,6 +14,7 @@ interface TemplateGridProps {
errorMessage?: string
searchText: string
activeType: string
durationRange: string
onPageChange: (page: number) => void
onPreview: (template: TemplateItem) => void
onToggleFavorite: (id: string, e: React.MouseEvent) => void
@@ -30,6 +31,7 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
errorMessage,
searchText,
activeType,
durationRange,
onPageChange,
onPreview,
onToggleFavorite,
@@ -62,7 +64,7 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
// 空状态
if (templates.length === 0) {
const hasFilter = !!searchText || activeType !== "全部"
const hasFilter = !!searchText || activeType !== "全部" || !!durationRange
return (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
@@ -1,13 +1,15 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import type { EditTemplateType } from "../../types/templateLibrary"
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
import type { EditTemplateType, DurationRange } from "../../types/templateLibrary"
import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary"
interface TemplateToolbarProps {
searchText: string
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
activeType: EditTemplateType | "全部"
onTypeChange: (type: EditTemplateType | "全部") => void
durationRange: DurationRange
onDurationChange: (value: DurationRange) => void
}
export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
@@ -15,6 +17,8 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
onSearchChange,
activeType,
onTypeChange,
durationRange,
onDurationChange,
}) => {
return (
<div className="xx-templates-toolbar">
@@ -42,8 +46,18 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
</button>
))}
</div>
{/* 时长筛选 */}
<div className="xx-templates-duration-filter">
{DURATION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
onClick={() => onDurationChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
export default TemplateToolbar
@@ -1,13 +1,15 @@
import React from "react"
import { Tooltip } from "antd"
import type { TemplateSegment } from "@/api/templates"
import { getMaterialTypeLabel, getTypeColor } from "../../../utils/templateLibrary"
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
interface SegmentListProps {
segments: TemplateSegment[]
totalDuration: number
}
/** 素材规则 / 片段列表(#1750:模板只定片段数/结构,无时长概念) */
const SegmentList: React.FC<SegmentListProps> = ({ segments }) => {
/** 素材规则 / 片段列表 */
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
if (segments.length === 0) return null
return (
@@ -33,9 +35,17 @@ const SegmentList: React.FC<SegmentListProps> = ({ segments }) => {
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalDuration))}
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import type { EditTemplateType } from "../types/templateLibrary"
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
export const TEMPLATE_TYPES: Array<{
type: EditTemplateType | "全部"
@@ -15,6 +15,16 @@ export const TEMPLATE_TYPES: Array<{
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
]
export const DURATION_OPTIONS: Array<{
value: DurationRange
label: string
}> = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
@@ -9,7 +9,7 @@ import {
type TemplateItem,
type TemplateListParams,
} from "@/api/templates"
import type { EditTemplateType } from "../types/templateLibrary"
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary"
export const useTemplateLibrary = () => {
@@ -19,6 +19,7 @@ export const useTemplateLibrary = () => {
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
const [durationRange, setDurationRange] = useState<DurationRange>("")
const [page, setPage] = useState(1)
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
@@ -30,8 +31,9 @@ export const useTemplateLibrary = () => {
}
if (activeType !== "全部") params.category = activeType
if (searchText.trim()) params.keyword = searchText.trim()
if (durationRange) params.duration_range = durationRange
return params
}, [page, pageSize, activeType, searchText])
}, [page, pageSize, activeType, searchText, durationRange])
/* 获取模板列表 */
const {
@@ -110,6 +112,12 @@ export const useTemplateLibrary = () => {
setPage(1)
}, [])
/* 切换时长筛选 */
const handleDurationChange = useCallback((value: DurationRange) => {
setDurationRange(value)
setPage(1)
}, [])
return {
/* 状态 */
templates,
@@ -119,6 +127,7 @@ export const useTemplateLibrary = () => {
error,
searchText,
activeType,
durationRange,
page,
pageSize,
/* mutations */
@@ -133,5 +142,6 @@ export const useTemplateLibrary = () => {
handleCreate,
handleSearchChange,
handleCategoryChange,
handleDurationChange,
}
}
@@ -2,6 +2,7 @@
export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
/** 时长筛选值 */
export type DurationRange = "" | "short" | "medium" | "long"
/** 配置展示字段 */
export interface ConfigDisplayFields {
@@ -5,6 +5,7 @@ import {
DEFAULT_GRADIENT,
MATERIAL_TYPE_LABELS,
} from "../constants/templateLibrary"
import type { TemplateSegment } from "@/api/templates"
/** 获取类型对应颜色 */
export const getTypeColor = (type: string): string => {
@@ -17,6 +18,16 @@ export const gradientForCategory = (category: string): string => {
return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT
}
/** 格式化时长 */
export const formatDuration = (seconds: number | undefined | null): string => {
if (!seconds || seconds <= 0) return "0秒"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
if (m === 0) return `${s}`
return `${m}${s > 0 ? `${s}` : ""}`
}
/** 格式化配置对象为可读文本 */
export const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认"
@@ -36,3 +47,8 @@ export const getMaterialTypeLabel = (materialType: string | null | undefined): s
if (!materialType) return "不限"
return MATERIAL_TYPE_LABELS[materialType] ?? materialType
}
/** 计算片段总时长(取每个片段 min/max 的平均值) */
export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => {
return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0)
}
+112 -29
View File
@@ -1,7 +1,6 @@
import { Navigate, type RouteObject } from "react-router-dom"
import MainLayout from "@/components/layout/MainLayout"
import { ProtectedRoute } from "./ProtectedRoute"
import { lazyRoute } from "./lazyRoute"
/**
* 受保护的 /app 子路由
@@ -14,118 +13,202 @@ const appChildren: RouteObject[] = [
},
{
path: "dashboard",
lazy: lazyRoute(() => import("@/pages/dashboard/Dashboard")),
lazy: () =>
import("@/pages/dashboard/Dashboard").then((m) => ({
Component: m.default,
})),
},
{
path: "assets",
lazy: lazyRoute(() => import("@/pages/assets/AssetLibrary")),
lazy: () =>
import("@/pages/assets/AssetLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "titles",
lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")),
lazy: () =>
import("@/pages/titles/TitleLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "voices",
lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")),
lazy: () =>
import("@/pages/voices/VoiceLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "templates",
lazy: lazyRoute(() => import("@/pages/templates/TemplateLibrary")),
lazy: () =>
import("@/pages/templates/TemplateLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "generate",
lazy: lazyRoute(() => import("@/pages/generate/GeneratePage")),
lazy: () =>
import("@/pages/generate/GeneratePage").then((m) => ({
Component: m.default,
})),
},
{
path: "history",
lazy: lazyRoute(() => import("@/pages/history/TaskHistory")),
lazy: () =>
import("@/pages/history/TaskHistory").then((m) => ({
Component: m.default,
})),
},
{
path: "products",
lazy: lazyRoute(() => import("@/pages/products/ProductLibrary")),
lazy: () =>
import("@/pages/products/ProductLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "products/:id",
lazy: lazyRoute(() => import("@/pages/products/ProductDetail")),
lazy: () =>
import("@/pages/products/ProductDetail").then((m) => ({
Component: m.default,
})),
},
{
path: "tasks",
lazy: lazyRoute(() => import("@/pages/tasks/TaskCenter")),
lazy: () =>
import("@/pages/tasks/TaskCenter").then((m) => ({
Component: m.default,
})),
},
{
path: "editing-planner",
lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")),
lazy: () =>
import("@/pages/editing-planner/EditingPlanner").then((m) => ({
Component: m.default,
})),
},
{
path: "my-templates",
lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")),
lazy: () =>
import("@/pages/my-templates/MyTemplates").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-clone",
lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")),
lazy: () =>
import("@/pages/voice-clone/VoiceClone").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-materials",
lazy: lazyRoute(() => import("@/pages/voice-materials/VoiceMaterialLibrary")),
lazy: () =>
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "my-voices",
lazy: lazyRoute(() => import("@/pages/my-voices/MyVoices")),
lazy: () =>
import("@/pages/my-voices/MyVoices").then((m) => ({
Component: m.default,
})),
},
{
path: "accounts",
lazy: lazyRoute(() => import("@/pages/accounts/Accounts")),
lazy: () =>
import("@/pages/accounts/Accounts").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication",
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationUpload")),
lazy: () =>
import("@/pages/duplication/DuplicationUpload").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication/results",
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationResults")),
lazy: () =>
import("@/pages/duplication/DuplicationResults").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication/:id",
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationDetail")),
lazy: () =>
import("@/pages/duplication/DuplicationDetail").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription",
lazy: lazyRoute(() => import("@/pages/subscription/Plans")),
lazy: () =>
import("@/pages/subscription/Plans").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription/upgrade",
lazy: lazyRoute(() => import("@/pages/subscription/UpgradeSubscription")),
lazy: () =>
import("@/pages/subscription/UpgradeSubscription").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription/billing",
lazy: lazyRoute(() => import("@/pages/subscription/Billing")),
lazy: () =>
import("@/pages/subscription/Billing").then((m) => ({
Component: m.default,
})),
},
{
path: "profile",
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
lazy: () =>
import("@/pages/profile/Settings").then((m) => ({
Component: m.default,
})),
},
{
path: "admin",
children: [
{
index: true,
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "users",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "analytics",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "monitor",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "logs",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
],
},
-40
View File
@@ -1,40 +0,0 @@
import type { LazyRouteFunction, RouteObject } from "react-router-dom"
import { isChunkLoadError } from "@/utils/chunkLoadError"
/**
* 给 React Router data router 的路由懒加载包一层自动重试:
*
* - 网络抖动 / 瞬态失败:自动重试最多 2 次(间隔 300ms / 800ms),用户无感恢复
* - 发版后旧 chunk 404(chunk 文件名已不存在):重试也拿不到旧文件名,
* 重试耗尽后抛出,由全局 ChunkErrorBoundary 捕获并引导整页刷新
* (刷新后 index.html 是 no-cache 的,会拿到新 chunk 引用)
*/
const RETRY_DELAYS_MS = [300, 800]
const RETRY_COUNT = RETRY_DELAYS_MS.length
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export const lazyRoute = (
factory: () => Promise<{ default: React.ComponentType }>,
): LazyRouteFunction<RouteObject> => {
return async () => {
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
try {
const mod = await factory()
if (!mod.default) {
throw new Error("lazyRoute: 目标模块缺少 default 导出")
}
return { Component: mod.default }
} catch (err) {
lastError = err
// 非 chunk 加载错误(代码 bug 等)立即抛出,不浪费重试
if (!isChunkLoadError(err)) throw err
if (attempt < RETRY_COUNT) {
await sleep(RETRY_DELAYS_MS[attempt])
}
}
}
throw lastError
}
}
-122
View File
@@ -242,20 +242,6 @@ describe("assets API", () => {
await expect(completeDirectUpload({ name: "test-item" })).resolves.not.toThrow()
})
it("请求体携带 file_size(后端同名兜底去重的大小校验依赖它)", async () => {
await completeDirectUpload({
project_id: "p-1",
library_id: "l-1",
storage_key: "uploads/k.mp4",
file_size: 12345,
} as never)
const completeCalls = mockPost.mock.calls.filter(
([u]: [string]) => u === "/upload/direct/complete",
)
expect(completeCalls).toHaveLength(1)
expect(completeCalls[0][1]).toMatchObject({ file_size: 12345 })
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
@@ -273,114 +259,6 @@ describe("assets API", () => {
})
})
describe("uploadAssetDirect skip_transfer 短路", () => {
it("prepare 返回 skip_transfer=true → 直接返回 duplicated,不调 transfer/complete", async () => {
mockPost.mockImplementation((url: string) => {
if (url === "/upload/direct/prepare") {
return Promise.resolve({
data: {
upload_url: "https://oss/x",
method: "POST",
storage_key: "uploads/skip/y.mp4",
expires_at: "2099",
fields: {},
max_size_bytes: 1e9,
asset_id: "existing-asset",
skip_transfer: true,
duplicated: true,
},
})
}
if (url === "/upload/direct/complete") {
throw new Error("complete 不应被调用")
}
throw new Error("unexpected url " + url)
})
const putSpy = vi.spyOn(globalThis, "XMLHttpRequest")
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
expect(result.duplicated).toBe(true)
expect(result.asset_id).toBe("existing-asset")
// complete 未被调用(mockPost 只记录 preparecomplete 若调用会抛 "不应被调用"
const completeCalls = mockPost.mock.calls.filter(
([u]: [string]) => u === "/upload/direct/complete",
)
expect(completeCalls).toHaveLength(0)
putSpy.mockRestore()
})
it("prepare 返回 skip_transfer=false → 走老流程(complete 被调用)", async () => {
mockPost.mockImplementation((url: string) => {
if (url === "/upload/direct/prepare") {
return Promise.resolve({
data: {
upload_url: "https://oss/x",
method: "POST",
storage_key: "uploads/normal/y.mp4",
expires_at: "2099",
fields: {},
max_size_bytes: 1e9,
asset_id: "new-asset",
},
})
}
if (url === "/upload/direct/complete") {
return Promise.resolve({
data: {
storage_key: "uploads/normal/y.mp4",
ingest_job_id: "job-1",
url: "https://oss/y.mp4",
duplicated: false,
asset_id: "new-asset",
},
})
}
throw new Error("unexpected url " + url)
})
// mock XMLHttpRequestsend 之后下一 tick 触发 onload 让 transfer 立即成功
const origOpen = XMLHttpRequest.prototype.open
const origSend = XMLHttpRequest.prototype.send
const origSetReadyState = Object.getOwnPropertyDescriptor(
XMLHttpRequest.prototype,
"readyState",
) as PropertyDescriptor | undefined
const origStatus = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, "status")
Object.defineProperty(XMLHttpRequest.prototype, "readyState", {
configurable: true,
writable: true,
value: 4,
})
Object.defineProperty(XMLHttpRequest.prototype, "status", {
configurable: true,
writable: true,
value: 200,
})
XMLHttpRequest.prototype.open = vi.fn() as unknown as typeof origOpen
XMLHttpRequest.prototype.send = vi.fn(function (this: XMLHttpRequest) {
// 下一 tick 触发 onload(模拟 XHR 异步完成)
setTimeout(() => this.onload?.(new ProgressEvent("load")), 0)
}) as unknown as typeof origSend
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
expect(result.duplicated).toBeFalsy()
expect(result.asset_id).toBe("new-asset")
const completeCalls = mockPost.mock.calls.filter(
([u]: [string]) => u === "/upload/direct/complete",
)
expect(completeCalls).toHaveLength(1)
// complete 请求必须带上 file_size,否则后端同名兜底会误杀同名新视频
expect(completeCalls[0][1]).toMatchObject({ file_size: file.size })
XMLHttpRequest.prototype.open = origOpen
XMLHttpRequest.prototype.send = origSend
if (origSetReadyState) {
Object.defineProperty(XMLHttpRequest.prototype, "readyState", origSetReadyState)
}
if (origStatus) {
Object.defineProperty(XMLHttpRequest.prototype, "status", origStatus)
}
})
})
describe("getIngestJob", () => {
it("should resolve successfully", async () => {
await expect(getIngestJob("test-jobId")).resolves.not.toThrow()
-64
View File
@@ -1,64 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
describe("wxLogin 工具", () => {
describe("parseWxAuthUrl", () => {
it("从微信授权链接解析出 appid/redirect_uri/stateredirect_uri 解码)", async () => {
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
const authUrl =
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback" +
"&response_type=code&scope=snsapi_login&state=abc123#wechat_redirect"
const params = parseWxAuthUrl(authUrl)
expect(params).not.toBeNull()
expect(params?.appid).toBe("wxb7ae80b48e53980d")
expect(params?.redirect_uri).toBe("https://staging.xiaoxiajianji.com/auth/wechat/callback")
expect(params?.state).toBe("abc123")
})
it("链接里缺 state 时回退使用 stateFallback", async () => {
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
const authUrl =
"https://open.weixin.qq.com/connect/qrconnect?appid=wx123" +
"&redirect_uri=https%3A%2F%2Fexample.com%2Fcb"
const params = parseWxAuthUrl(authUrl, "fallback-state")
expect(params?.state).toBe("fallback-state")
})
it("缺 appid 或 redirect_uri 时返回 null(调用方应回退整页跳转)", async () => {
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
expect(parseWxAuthUrl("https://open.weixin.qq.com/connect/qrconnect?appid=wx123")).toBeNull()
expect(parseWxAuthUrl("not a url")).toBeNull()
})
})
describe("loadWxLoginScript", () => {
beforeEach(() => {
vi.resetModules()
document.head.querySelectorAll("script[src*='wxLogin']").forEach((el) => el.remove())
delete (window as unknown as { WxLogin?: unknown }).WxLogin
})
afterEach(() => {
vi.restoreAllMocks()
})
it("window.WxLogin 已存在时直接复用,不重复插入 script", async () => {
const fakeCtor = vi.fn()
;(window as unknown as { WxLogin: unknown }).WxLogin = fakeCtor
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
const ctor = await loadWxLoginScript()
expect(ctor).toBe(fakeCtor)
expect(document.head.querySelector("script[src*='wxLogin']")).toBeNull()
})
it("脚本 onerror 时 reject(调用方据此回退整页跳转)", async () => {
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
const promise = loadWxLoginScript()
const script = document.head.querySelector(
"script[src*='wxLogin']",
) as HTMLScriptElement | null
expect(script).not.toBeNull()
script?.dispatchEvent(new Event("error"))
await expect(promise).rejects.toThrow(/加载失败/)
})
})
})
@@ -1,79 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { Button } from "antd"
import { useState } from "react"
import ChunkErrorBoundary from "@/components/common/ChunkErrorBoundary"
import * as chunkUtils from "@/utils/chunkLoadError"
// reload 函数 mock 掉(jsdom 不支持真实 window.location.reload
vi.mock("@/utils/chunkLoadError", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/chunkLoadError")>()
return {
...actual,
reloadForChunkError: vi.fn(),
goHomeRecover: vi.fn(),
}
})
const { reloadForChunkError, goHomeRecover } = vi.mocked(chunkUtils)
/** 渲染时直接抛错的子组件 */
const Boom: React.FC<{ error: Error }> = ({ error }) => {
throw error
}
/** 点击按钮后才抛 chunk 错误的子组件 */
const ChunkBoomButton: React.FC = () => {
const [boom, setBoom] = useState(false)
if (boom) {
throw new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
}
return <Button onClick={() => setBoom(true)}>boom</Button>
}
const renderBoundary = (ui: React.ReactNode) =>
render(<ChunkErrorBoundary>{ui}</ChunkErrorBoundary>)
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
// error boundary 捕获后 React 会打 error log,静默掉
vi.spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
sessionStorage.clear()
})
describe("ChunkErrorBoundary", () => {
it("正常渲染 children", () => {
renderBoundary(<div>hello-child</div>)
expect(screen.getByText("hello-child")).toBeInTheDocument()
})
it("首次捕获 chunk 错误 → 自动刷新(reloadForChunkError)并显示自动刷新提示", () => {
renderBoundary(<ChunkBoomButton />)
fireEvent.click(screen.getByText("boom"))
expect(reloadForChunkError).toHaveBeenCalledTimes(1)
expect(screen.getByText(/正在自动刷新/)).toBeInTheDocument()
})
it("已刷新过仍失败 → 不再自动刷新,显示手动兜底按钮", () => {
// 模拟"本会话已经自动刷新过一次"
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now()))
renderBoundary(
<Boom error={new TypeError("Failed to fetch dynamically imported module: /assets/y.js")} />,
)
expect(reloadForChunkError).not.toHaveBeenCalled()
expect(screen.getByText("系统已更新")).toBeInTheDocument()
// 点击兜底按钮 → goHomeRecover(跳首页,不刷新当前 URL)
fireEvent.click(screen.getByText("刷新并返回首页"))
expect(goHomeRecover).toHaveBeenCalledTimes(1)
})
it("非 chunk 错误 → 显示通用错误页,不触发 chunk 自动刷新", () => {
renderBoundary(<Boom error={new Error("普通业务报错")} />)
expect(reloadForChunkError).not.toHaveBeenCalled()
expect(screen.getByText("页面出现异常")).toBeInTheDocument()
})
})
@@ -1,165 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"
import WechatQrModal from "@/components/auth/WechatQrModal"
const { mockWxLoginCtor, mockGetAuthUrl, mockGetBindUrl, mockGetCurrentUser } = vi.hoisted(() => ({
mockWxLoginCtor: vi.fn(),
mockGetAuthUrl: vi.fn(),
mockGetBindUrl: vi.fn(),
mockGetCurrentUser: vi.fn(),
}))
vi.mock("@/api/auth", () => ({
getWechatAuthUrl: (...args: unknown[]) => mockGetAuthUrl(...args),
getWechatBindUrl: (...args: unknown[]) => mockGetBindUrl(...args),
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
normalizeUser: (u: unknown) => u,
}))
vi.mock("@/api/auth/wxLogin", () => ({
loadWxLoginScript: vi.fn(async () => mockWxLoginCtor),
parseWxAuthUrl: vi.fn(() => ({
appid: "wxb7ae80b48e53980d",
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
state: "state-from-url",
})),
}))
vi.mock("@/api/auth/tokenRefresh", () => ({
scheduleProactiveRefresh: vi.fn(),
cancelProactiveRefresh: vi.fn(),
}))
const { mockSetAuth, mockSetUser } = vi.hoisted(() => ({
mockSetAuth: vi.fn(),
mockSetUser: vi.fn(),
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (s: unknown) => unknown) =>
selector({ setAuth: mockSetAuth, setUser: mockSetUser }),
}))
const AUTH_URL =
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback&state=st123"
const postMessage = (data: Record<string, unknown>) =>
window.dispatchEvent(new MessageEvent("message", { data, origin: window.location.origin }))
beforeEach(() => {
vi.clearAllMocks()
mockGetAuthUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
mockGetBindUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
mockGetCurrentUser.mockResolvedValue({ id: 1, display_name: "测试用户" })
localStorage.clear()
})
afterEach(() => cleanup())
describe("WechatQrModal", () => {
it("open=false 时不渲染弹窗内容", () => {
render(<WechatQrModal open={false} scene="login" onClose={vi.fn()} />)
expect(screen.queryByText("微信扫码登录")).toBeNull()
})
it("登录场景:open 后请求授权链接、写入 state、用 WxLogin 渲染二维码", async () => {
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(1))
expect(localStorage.getItem("wechat_state")).toBe("st123")
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
expect(mockWxLoginCtor).toHaveBeenCalledWith(
expect.objectContaining({
self_redirect: true,
appid: "wxb7ae80b48e53980d",
scope: "snsapi_login",
state: "state-from-url",
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
}),
)
expect(screen.getByText(/请使用微信扫描二维码登录/)).toBeTruthy()
})
it("绑定场景:请求 bind/url 且写入 wechat_bind_state", async () => {
render(<WechatQrModal open scene="bind" onClose={vi.fn()} />)
await waitFor(() => expect(mockGetBindUrl).toHaveBeenCalledTimes(1))
expect(mockGetAuthUrl).not.toHaveBeenCalled()
expect(localStorage.getItem("wechat_bind_state")).toBe("st123")
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
})
it("获取授权链接失败时弹窗内展示错误并提供刷新", async () => {
mockGetAuthUrl.mockRejectedValueOnce({
response: { status: 500, data: { detail: "微信服务内部错误" } },
})
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
expect(await screen.findByText(/微信服务内部错误/)).toBeTruthy()
expect(screen.getByText("刷新二维码")).toBeTruthy()
// 点刷新后重新请求
fireEvent.click(screen.getByText("刷新二维码"))
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(2))
})
it("登录成功消息:同步登录态并回调 onLoginSuccess(needOnboarding)", async () => {
const onSuccess = vi.fn()
localStorage.setItem("access_token", "tok-123")
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
postMessage({
source: "xiaoxia-wechat-qr",
scene: "login",
success: true,
payload: { needOnboarding: true },
})
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(true))
expect(mockGetCurrentUser).toHaveBeenCalled()
expect(mockSetAuth).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), "tok-123", null)
})
it("登录失败消息:弹窗内展示回调页透传的真实原因", async () => {
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
postMessage({
source: "xiaoxia-wechat-qr",
scene: "login",
success: false,
detail: "微信登录失败:state 已过期或已被使用",
})
expect(await screen.findByText(/state 已过期或已被使用/)).toBeTruthy()
})
it("绑定成功消息:刷新用户并回调 onBindSuccess", async () => {
const onBindSuccess = vi.fn()
render(<WechatQrModal open scene="bind" onClose={vi.fn()} onBindSuccess={onBindSuccess} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
await waitFor(() => expect(onBindSuccess).toHaveBeenCalledTimes(1))
expect(mockSetUser).toHaveBeenCalled()
})
it("忽略跨源消息和其他场景的消息", async () => {
const onSuccess = vi.fn()
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
// 跨源
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "xiaoxia-wechat-qr", scene: "login", success: true },
origin: "https://evil.example.com",
}),
)
// 场景不符(bind 消息发给 login 弹窗)
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
// 无协议标识
postMessage({ foo: "bar" })
await new Promise((r) => setTimeout(r, 50))
expect(onSuccess).not.toHaveBeenCalled()
})
})
@@ -26,8 +26,6 @@ interface FakeHandle {
fields: Record<string, string>
max_size_bytes: number
asset_id: string
duplicated?: boolean
skip_transfer?: boolean
}
transfer: ReturnType<typeof vi.fn>
complete: ReturnType<typeof vi.fn>
@@ -48,8 +46,6 @@ const makeFakeHandle = (opts: {
duplicated?: boolean
failTransfer?: boolean
completeAuto?: boolean
/** prepare 阶段就命中去重:prepare 响应 skip_transfer/duplicated=true */
prepareDedup?: boolean
}) => {
const h: FakeHandle = {
prepared: {
@@ -60,8 +56,6 @@ const makeFakeHandle = (opts: {
fields: {},
max_size_bytes: 2_000_000_000,
asset_id: opts.id,
duplicated: opts.prepareDedup ? true : undefined,
skip_transfer: opts.prepareDedup ? true : undefined,
},
transfer: vi.fn(),
complete: vi.fn(),
@@ -386,27 +380,4 @@ describe("useAssetUpload", () => {
expect(it.failedStage).toBe("prepare")
expect(it.error).toContain("签名服务内部错误")
})
it("prepare 返回 skip_transfer=true 时立即跳过 transfer+complete,标记 done+duplicated", async () => {
const h = makeFakeHandle({ id: "a-skip", prepareDedup: true })
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
async () => h,
)
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads([mp4("skip-transfer.mp4")])
})
await waitFor(() => {
expect(h.transfer).not.toHaveBeenCalled()
expect(h.complete).not.toHaveBeenCalled()
const it = result.current.uploadItems[0]
expect(it?.status).toBe("done")
expect(it?.duplicated).toBe(true)
expect(it?.assetId).toBe("a-skip")
})
})
})
@@ -41,16 +41,6 @@ vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setUser: mockSetUser }),
}))
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
mockIsInIframe: vi.fn(() => false),
mockPostResult: vi.fn(),
}))
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
isInIframe: () => mockIsInIframe(),
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
}))
const renderPage = () =>
render(
<MemoryRouter>
@@ -65,7 +55,6 @@ describe("WechatBindCallback Page", () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsInIframe.mockReturnValue(false)
bindError = null
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
mockParams.set("code", "bind_code")
@@ -113,32 +102,4 @@ describe("WechatBindCallback Page", () => {
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
})
})
describe("iframe(弹窗内嵌二维码)场景", () => {
it("绑定成功时 postMessage 通知父窗口,不做 navigate", async () => {
mockIsInIframe.mockReturnValue(true)
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("bind", true)
})
expect(mockSetUser).toHaveBeenCalled()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("绑定失败时把真实原因 postMessage 给父窗口", async () => {
mockIsInIframe.mockReturnValue(true)
bindError = {
isAxiosError: true,
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
}
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("bind", false, {
detail: expect.stringContaining("该微信已绑定其他账号"),
})
})
expect(screen.queryByText(/返回设置/)).toBeNull()
expect(mockNavigate).not.toHaveBeenCalled()
})
})
})
@@ -53,16 +53,6 @@ vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }),
}))
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
mockIsInIframe: vi.fn(() => false),
mockPostResult: vi.fn(),
}))
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
isInIframe: () => mockIsInIframe(),
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
}))
const renderPage = () =>
render(
<MemoryRouter>
@@ -77,7 +67,6 @@ describe("WechatCallback Page", () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsInIframe.mockReturnValue(false)
callbackError = null
// 默认正常回调参数;用例可改写 mockParams 模拟 error 重定向
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
@@ -170,54 +159,4 @@ describe("WechatCallback Page", () => {
renderPage()
expect(screen.getByText("微信登录中...")).toBeTruthy()
})
describe("iframe(弹窗内嵌二维码)场景", () => {
it("登录成功时 postMessage 通知父窗口(needOnboarding=false),不做 navigate", async () => {
mockIsInIframe.mockReturnValue(true)
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: false })
})
expect(mockSetAuth).toHaveBeenCalled()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("新用户成功时上报 needOnboarding=true", async () => {
mockIsInIframe.mockReturnValue(true)
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: true })
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("后端报错时把真实原因 postMessage 给父窗口,页面不渲染错误/按钮", async () => {
mockIsInIframe.mockReturnValue(true)
callbackError = {
isAxiosError: true,
response: { status: 400, data: { detail: "state 已过期或已被使用" } },
}
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
detail: expect.stringContaining("state 已过期或已被使用"),
})
})
expect(screen.queryByText(/返回登录/)).toBeNull()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("微信重定向 error(拒绝授权)在 iframe 内也上报父窗口", async () => {
mockIsInIframe.mockReturnValue(true)
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
mockParams.set("error", "access_denied")
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
detail: expect.stringContaining("access_denied"),
})
})
})
})
})
@@ -122,54 +122,6 @@ describe("WechatOnboarding 昵称引导页", () => {
)
})
it("连点提交按钮只触发一次请求(防重复提交)", async () => {
// mutation 挂起不立即完成,模拟慢网络下连续双击
let resolveSubmit: (v: unknown) => void = () => {}
updateProfileMock = vi.fn(
() =>
new Promise((resolve) => {
resolveSubmit = resolve
}),
)
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
const btn = screen.getByText("进入小虾智剪")
fireEvent.click(btn)
// 第一次点击后立即再点(此时重渲染/loading 可能还没生效)
fireEvent.click(btn)
fireEvent.click(btn)
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledTimes(1)
})
// 释放挂起的 Promise,避免泄漏
resolveSubmit({ id: "u1", display_name: "小虾用户", profile_completed: true })
})
it("提交失败后守卫复位,允许再次提交", async () => {
updateProfileMock = vi
.fn()
.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { detail: "服务内部错误" } },
})
.mockResolvedValueOnce({ id: "u1", display_name: "小虾用户", profile_completed: true })
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledTimes(1)
})
// 失败后再点一次,应能重新提交
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledTimes(2)
})
})
it("提交失败显示错误且不跳转", async () => {
updateProfileMock = vi.fn(async () => {
throw new Error("500")
@@ -1,176 +0,0 @@
/**
* CanvasPreviewGrid 单测(Issue #1741 / #1750
*
* 验证:
* - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中)
* - 每个变体都拿到各自的后端真实片段 variantClips[i]serverClips),playToken 为 0 基变体序号
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
* - 实例上报暂停(null)→ 播放权释放
* - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器),
* 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips
* - #1750 加载态:渲染加载占位,不渲染播放器
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, fireEvent, screen } from "@testing-library/react"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
default: (props: Record<string, unknown>) => {
playerCalls.push(props)
const token = props.playToken as number
const active = props.activePlayToken as number | null
const change = props.onPlayTokenChange as (t: number | null) => void
return (
<div data-testid={`player-${token}`}>
<span data-testid={`voice-${token}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
<span data-testid={`token-${token}`}>{active == null ? "none" : String(active)}</span>
<button type="button" onClick={() => change(token)}>
play-{token}
</button>
<button type="button" onClick={() => change(null)}>
pause-{token}
</button>
</div>
)
},
}))
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10 },
duration: 10,
}
}
function makeClips(variant: number): EditPlanClip[] {
return ["a1", "a2"].map((assetId, order) => ({
id: `clip-v${variant}-${assetId}`,
plan_id: `plan-${variant}`,
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}))
}
const titleSettings = {
size: 36,
font: "思源黑体",
color: "#fff",
position: "bottom" as const,
bold: false,
italic: false,
stroke: true,
shadow: false,
posX: null,
posY: null,
}
const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)]
function renderGrid(overrides: Record<string, unknown> = {}) {
playerCalls.length = 0
return render(
<CanvasPreviewGrid
count={3}
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
videoRatio="9:16"
titles={["标题1", "标题2", "标题3"]}
titleSettings={titleSettings}
variantClips={variantClips}
voiceAudioUrls={["https://cdn.example.com/v0.mp3", null, "https://cdn.example.com/v2.mp3"]}
selectedIds={[0, 1, 2]}
onToggleSelect={() => {}}
{...overrides}
/>,
)
}
describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => {
beforeEach(() => {
playerCalls.length = 0
})
it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => {
renderGrid()
expect(playerCalls).toHaveLength(3)
playerCalls.forEach((p, i) => {
expect(p.playToken).toBe(i)
expect(p.serverClips).toBe(variantClips[i])
expect(p.variantTitle).toBe(`标题${i + 1}`)
})
// 独立配音:变体 0/2 有各自 URL;变体 1 为 nullTTS 合成中)→ 传 undefined
expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3")
expect(playerCalls[1].voiceAudioUrl).toBeUndefined()
expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3")
// 配音状态徽标
expect(screen.getByTestId("voice-0").textContent).toBe("has-voice")
expect(screen.getByTestId("voice-1").textContent).toBe("no-voice")
expect(screen.getByTestId("voice-2").textContent).toBe("has-voice")
})
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
renderGrid()
playerCalls.forEach((p) => {
expect(p.activePlayToken).toBeNull()
expect(typeof p.onPlayTokenChange).toBe("function")
})
})
it("点击变体2播放:所有实例 activePlayToken 变为 10 基,其他实例自动暂停)", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-1").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("1")
expect(screen.getByTestId("token-1").textContent).toBe("1")
expect(screen.getByTestId("token-2").textContent).toBe("1")
})
it("正在播放实例上报暂停后,播放权释放(token 回 null", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-2").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("2")
fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1])
expect(screen.getByTestId("token-2").textContent).toBe("none")
})
it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => {
const onRetry = vi.fn()
renderGrid({ clipsError: true, onRetryClips: onRetry })
expect(screen.getAllByRole("alert")).toHaveLength(3)
expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3)
// 严禁假数据:错误态不渲染播放器
expect(playerCalls).toHaveLength(0)
// 重试按钮仅变体 0 卡片出现
const retryButtons = screen.getAllByText("重试")
expect(retryButtons).toHaveLength(1)
fireEvent.click(retryButtons[0])
expect(onRetry).toHaveBeenCalledTimes(1)
})
it("clipsLoading:渲染加载占位,不渲染播放器", () => {
renderGrid({ clipsLoading: true })
expect(screen.getAllByText("独立选片中…")).toHaveLength(3)
expect(screen.queryAllByRole("alert")).toHaveLength(0)
expect(playerCalls).toHaveLength(0)
})
})
@@ -1,213 +0,0 @@
/**
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750
*
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
* - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause
* - 点击播放/暂停时上报播放权(onPlayTokenChange
* - #1750serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const mocks = vi.hoisted(() => ({
isPlaying: false,
pause: vi.fn(),
togglePlayPause: vi.fn(),
}))
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
// segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips);
// canPlay 与真实 hook 一致:无片段时不可播放
useSegmentScheduler: vi.fn((segments: unknown[]) => ({
isPlaying: mocks.isPlaying,
currentTime: 0,
totalDuration: 20,
currentSegmentIndex: 0,
canPlay: segments.length > 0,
togglePlayPause: mocks.togglePlayPause,
seekTo: vi.fn(),
pause: mocks.pause,
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
})),
}))
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
useCanvasPlayer: () => ({
state: {
isPlaying: false,
isReady: false,
isBuffering: false,
currentTime: 0,
duration: 0,
errorMessage: "",
hasDecodeError: false,
},
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
}),
}))
beforeEach(() => {
mocks.isPlaying = false
mocks.pause.mockClear()
mocks.togglePlayPause.mockClear()
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10, width: 1080, height: 1920 },
duration: 10,
}
}
function makeClip(assetId: string, order: number): EditPlanClip {
return {
id: `clip-${assetId}`,
plan_id: "plan-1",
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}
}
const serverClips = [makeClip("a1", 0), makeClip("a2", 1)]
const baseProps = {
assets: [makeAsset("a1"), makeAsset("a2")],
videoRatio: "9:16",
ready: true,
serverClips,
}
function videos(): HTMLVideoElement[] {
return Array.from(document.querySelectorAll("video"))
}
describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => {
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
expect(videos()).toHaveLength(2)
videos().forEach((v) => expect(v.muted).toBe(true))
})
it("无配音时 video 不 muted(素材原声兜底)", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
const vs = videos()
expect(vs[0].muted).toBe(false)
fireEvent.click(screen.getByRole("button", { name: "静音" }))
videos().forEach((v) => expect(v.muted).toBe(true))
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
mocks.isPlaying = true
const { rerender } = render(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).not.toHaveBeenCalled()
// 播放权切给实例 2(0 基 token)
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={2}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
// token 切回自己:不重复暂停
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
})
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
// mocks.isPlaying = falsebeforeEach 重置)
const { rerender } = render(
<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={0} compact />,
)
rerender(<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={1} compact />)
expect(mocks.pause).not.toHaveBeenCalled()
})
it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => {
const onToken = vi.fn()
render(
<FrontendPreviewPlayer {...baseProps} playToken={2} onPlayTokenChange={onToken} compact />,
)
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
expect(playButtons.length).toBeGreaterThanOrEqual(1)
fireEvent.click(playButtons[0])
expect(onToken).toHaveBeenCalledWith(2)
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
})
it("每个卡片都渲染独立静音按钮", () => {
render(
<div>
<FrontendPreviewPlayer {...baseProps} playToken={0} compact />
<FrontendPreviewPlayer {...baseProps} playToken={1} compact />
<FrontendPreviewPlayer {...baseProps} playToken={2} compact />
</div>,
)
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
})
it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => {
render(<FrontendPreviewPlayer {...baseProps} serverClips={undefined} />)
expect(videos()).toHaveLength(0)
expect(screen.getByText("暂无可播放素材")).toBeInTheDocument()
})
})
@@ -1,40 +0,0 @@
/**
* 智能匹配候选池数量规则单测(#1744/#1750
*
* #1750 基线:模板只有片段数/结构、没有时长概念——候选池纯按「片段数 × 3」
* (每个片段至少 3 个候选供变体间洗牌避让),不再参考任何 duration 字段;
* 结果钳制到 [1, 200](后端 limit 上限 200);无片段信息时兜底 30。
*/
import { describe, it, expect } from "vitest"
import { computeLimitFromSegments } from "@/pages/generate/hooks/step2-materials/useSmartMatch"
import type { TemplateSegment } from "@/api/templates/types"
function segs(n: number): TemplateSegment[] {
// 新规则只看片段数量,duration 字段已废弃,这里不再构造
return Array.from({ length: n }, () => ({}) as TemplateSegment)
}
describe("computeLimitFromSegments (#1750 候选池 = 片段数×3", () => {
it("无 segments 时返回兜底 30", () => {
expect(computeLimitFromSegments(undefined)).toBe(30)
expect(computeLimitFromSegments([])).toBe(30)
})
it("候选池 = 片段数 × 3(与任何时长字段无关)", () => {
expect(computeLimitFromSegments(segs(1))).toBe(3)
expect(computeLimitFromSegments(segs(3))).toBe(9)
expect(computeLimitFromSegments(segs(5))).toBe(15)
expect(computeLimitFromSegments(segs(10))).toBe(30)
})
it("超过上限钳制为 200(后端 limit 上限)", () => {
expect(computeLimitFromSegments(segs(67))).toBe(200) // 67×3=201 → 200
expect(computeLimitFromSegments(segs(100))).toBe(200) // 100×3=300 → 200
})
it("任何正常片段数下 limit 均 ≥ 1", () => {
for (let n = 0; n <= 10; n++) {
expect(computeLimitFromSegments(segs(n))).toBeGreaterThanOrEqual(1)
}
})
})
@@ -5,6 +5,7 @@
*
* 重构记录:
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
* - Phase1 预览生成:新增 generation API 层 + PreviewVideoPanel
*/
import { describe, it, expect } from "vitest"
@@ -22,6 +23,8 @@ import "@/pages/generate/components/Step3VoiceWithMode"
import "@/pages/generate/components/CanvasPreviewGrid"
import "@/pages/generate/components/BatchGenerationGrid"
import "@/pages/generate/components/PreviewCountModal"
import "@/pages/generate/components/PreviewVideoPanel"
import "@/pages/generate/components/GenerateResultPanel"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/components/voice/VoiceRecommendSection"
import "@/pages/generate/components/voice/VoiceChoiceCard"
@@ -47,8 +50,6 @@ describe("GeneratePage module smoke test", () => {
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/useBatchCovers"
import "@/pages/generate/hooks/useBatchVariantPlans"
import "@/pages/generate/hooks/useVariantVoicePreview"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
@@ -1,126 +0,0 @@
/**
* TitleLibraryAutoComplete 单测(Issue #1737
*
* 覆盖:
* - 聚焦空输入框 → 下拉立即展开,展示标题库全部标题(原生 AutoComplete 聚焦不展开,此为本工单核心修复)
* - 输入关键词 → 下拉只显示匹配项
* - 点击下拉项 → onChange 回填所选标题
* - 自由输入自定义标题 → onChange 正常透传,不被下拉干扰
* - 标题库为空 → 聚焦不展开(不出"暂无数据"空壳)
* - 选中后下拉关闭
*/
import { describe, it, expect, vi } from "vitest"
import { render, screen, waitFor, fireEvent } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
const OPTIONS = [
{ label: "永康这家面馆绝了", value: "永康这家面馆绝了" },
{ label: "永康美食探店vlog", value: "永康美食探店vlog" },
{ label: "萌宠日常第一天", value: "萌宠日常第一天" },
]
function renderBox(initialValue = "", opts = OPTIONS) {
const onChange = vi.fn()
const result = render(
<TitleLibraryAutoComplete
value={initialValue}
onChange={onChange}
options={opts}
placeholder="输入或从标题库选择"
/>,
)
return { onChange, ...result }
}
/** 聚焦输入框(combobox role */
function focusInput() {
const input = screen.getByRole("combobox") as HTMLInputElement
fireEvent.focus(input)
return input
}
/** 取下拉中实际可见的选项(rc-virtual-list 渲染为 .ant-select-item-optionrole=option 的 listbox 是 a11y 哨兵) */
function getVisibleOptions(): HTMLElement[] {
const dropdown = document.querySelector(".ant-select-dropdown:not(.ant-select-dropdown-hidden)")
if (!dropdown) return []
return Array.from(dropdown.querySelectorAll(".ant-select-item-option")) as HTMLElement[]
}
describe("TitleLibraryAutoComplete (#1737)", () => {
it("聚焦空输入框时下拉展开并展示标题库全部标题", async () => {
renderBox()
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
focusInput()
await screen.findByRole("listbox")
await waitFor(() => expect(getVisibleOptions()).toHaveLength(3))
const options = getVisibleOptions()
expect(options[0]).toHaveTextContent("永康这家面馆绝了")
expect(options[2]).toHaveTextContent("萌宠日常第一天")
})
it("输入关键词时下拉只显示匹配项", async () => {
const user = userEvent.setup()
renderBox()
const input = screen.getByRole("combobox")
await user.click(input)
await screen.findByRole("listbox")
await user.type(input, "永康")
await waitFor(() => expect(getVisibleOptions()).toHaveLength(2))
const options = getVisibleOptions()
expect(options.every((o) => o.textContent?.includes("永康"))).toBe(true)
})
it("点击下拉项后 onChange 回填标题且下拉关闭", async () => {
const user = userEvent.setup()
const { onChange } = renderBox()
const input = screen.getByRole("combobox") as HTMLInputElement
await user.click(input)
await screen.findByRole("listbox")
await user.click(screen.getByText("萌宠日常第一天"))
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith("萌宠日常第一天")
})
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
})
it("自由输入自定义标题时 onChange 正常透传(不被下拉干扰)", async () => {
const user = userEvent.setup()
const { onChange } = renderBox()
const input = screen.getByRole("combobox")
await user.click(input)
await user.type(input, "我自己编的标题XYZ")
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith("我自己编的标题XYZ")
})
// 输入无匹配关键词,下拉无 option 时不阻塞输入
expect(input).toHaveValue("我自己编的标题XYZ")
})
it("标题库为空时聚焦不展开下拉", async () => {
renderBox("", [])
focusInput()
// 等一帧确认没有 listbox
await new Promise((r) => setTimeout(r, 50))
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("渲染下拉箭头图标作为可选择提示", () => {
const { container } = renderBox()
// antd 后缀图标在 .ant-select-arrow 内
expect(container.querySelector(".ant-select-arrow")).toBeInTheDocument()
})
it("有初始值时输入框正常展示", () => {
renderBox("已有标题")
expect(screen.getByRole("combobox")).toHaveValue("已有标题")
})
})
@@ -1,230 +0,0 @@
/**
* useBatchVariantPlans 单测(#1750 修正:严禁假数据冒充预览)
* - 批量(N>1)时向后端申请变体计划,返回 clips/planIds
* - 404(端点未上线)/ 400(参数错误)/ 网络错误:一律 error=trueUI 可重试
* - 数据不完整(某变体 clips 为空):error=true
* - retry() 重新发起请求,成功后 ready=true
* - N=1 / 无素材 / disabled 不发请求
* - 入参变化重新申请;未变化不重复请求
*/
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { useBatchVariantPlans } from "@/pages/generate/hooks/useBatchVariantPlans"
import type { EditPlanClip } from "@/api/template-editor"
vi.mock("@/api/generation/variantPlans", () => ({
createBatchVariantPlans: vi.fn(),
}))
import { createBatchVariantPlans } from "@/api/generation/variantPlans"
const mockCreate = vi.mocked(createBatchVariantPlans)
function makeClip(
partial: Partial<EditPlanClip> & { asset_id: string; order: number },
): EditPlanClip {
return {
id: `clip-${partial.asset_id}-${partial.order}`,
plan_id: "plan-x",
clip_type: "main",
start_time: 0,
duration: 5,
text_content: "",
transition_effect: "",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
...partial,
}
}
function buildResp(count: number) {
return {
total: count,
items: Array.from({ length: count }, (_, i) => ({
variant_index: i,
plan_id: `plan-${i}`,
clips: [makeClip({ asset_id: `a${(i % 3) + 1}`, order: 0, start_time: i * 2 })],
})),
}
}
beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockReset()
})
describe("useBatchVariantPlans (#1750 错误态)", () => {
it("批量 N=3 时申请变体计划并返回按索引对齐的 clips/planIds", async () => {
mockCreate.mockResolvedValueOnce(buildResp(3))
const { result } = renderHook(() =>
useBatchVariantPlans({
enabled: true,
count: 3,
templateId: "tpl-1",
assetIds: ["a1", "a2", "a3"],
sourcePlanId: "plan-src",
}),
)
await waitFor(() => expect(result.current.ready).toBe(true))
expect(mockCreate).toHaveBeenCalledTimes(1)
expect(mockCreate).toHaveBeenCalledWith({
template_id: "tpl-1",
asset_ids: ["a1", "a2", "a3"],
count: 3,
source_edit_plan_id: "plan-src",
})
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1", "plan-2"])
expect(result.current.clipsByVariant[1]?.[0]?.start_time).toBe(2)
expect(result.current.error).toBe(false)
expect(result.current.loading).toBe(false)
})
it("sourcePlanId 为空时不传 source_edit_plan_id 字段", async () => {
mockCreate.mockResolvedValueOnce(buildResp(2))
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.ready).toBe(true))
const arg = mockCreate.mock.calls[0][0]
expect(arg).not.toHaveProperty("source_edit_plan_id")
})
it("404(后端端点未上线)→ error=true,不再静默降级", async () => {
mockCreate.mockRejectedValueOnce({ response: { status: 404 } })
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
expect(result.current.clipsByVariant).toEqual([])
})
it("400(参数错误)→ error=true", async () => {
mockCreate.mockRejectedValueOnce({ response: { status: 400, data: { detail: "参数错误" } } })
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
})
it("网络错误 → error=true", async () => {
mockCreate.mockRejectedValue(new Error("Network Error"))
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
})
it("某变体 clips 为空(数据不完整)→ error=true,严禁假数据冒充", async () => {
mockCreate.mockResolvedValueOnce({
total: 2,
items: [
{ variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] },
{ variant_index: 1, plan_id: "plan-1", clips: [] },
],
})
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
expect(result.current.clipsByVariant).toEqual([])
})
it("retry() 失败后重试:成功则 error 清除、ready=true", async () => {
mockCreate.mockRejectedValueOnce({ response: { status: 404 } })
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.error).toBe(true))
mockCreate.mockResolvedValueOnce(buildResp(3))
act(() => {
result.current.retry()
})
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.error).toBe(false)
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it("N=1 不发请求(单视频零回归)", () => {
renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 1, templateId: "tpl-1", assetIds: ["a1"] }),
)
expect(mockCreate).not.toHaveBeenCalled()
})
it("无素材/enabled=false 不发请求", () => {
const { rerender } = renderHook(
(props: { enabled: boolean; ids: string[] }) =>
useBatchVariantPlans({
enabled: props.enabled,
count: 3,
templateId: "tpl-1",
assetIds: props.ids,
}),
{ initialProps: { enabled: true, ids: [] as string[] } },
)
expect(mockCreate).not.toHaveBeenCalled()
rerender({ enabled: false, ids: ["a1"] })
expect(mockCreate).not.toHaveBeenCalled()
})
it("响应乱序/越界 variant_index:越界丢弃、其余按索引对齐", async () => {
mockCreate.mockResolvedValueOnce({
total: 2,
items: [
{ variant_index: 99, plan_id: "plan-bad", clips: [makeClip({ asset_id: "a9", order: 0 })] },
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a2", order: 0 })] },
{ variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] },
],
})
const { result } = renderHook(() =>
useBatchVariantPlans({
enabled: true,
count: 2,
templateId: "tpl-1",
assetIds: ["a1", "a2"],
}),
)
await waitFor(() => expect(result.current.ready).toBe(true))
// 越界 99 丢弃;乱序返回的 0/1 按索引对齐
expect(result.current.error).toBe(false)
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1"])
expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1")
expect(result.current.clipsByVariant[1]?.[0]?.asset_id).toBe("a2")
})
it("非 ready 状态的 clip 被过滤;过滤后每变体仍有片段则 ready", async () => {
mockCreate.mockResolvedValueOnce({
total: 2,
items: [
{
variant_index: 0,
plan_id: "plan-0",
clips: [
makeClip({ asset_id: "a1", order: 0 }),
makeClip({ asset_id: "a2", order: 1, status: "pending" }),
],
},
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a3", order: 0 })] },
],
})
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.error).toBe(false)
expect(result.current.clipsByVariant[0]).toHaveLength(1)
expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1")
})
})
@@ -1,44 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest"
import { lazyRoute } from "@/router/lazyRoute"
const chunkErr = () => new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
/** fake 模块 */
const Comp = function Comp() {}
const factoryOk = vi.fn(async () => ({ default: Comp }))
afterEach(() => {
vi.clearAllMocks()
})
describe("lazyRoute", () => {
it("首次成功直接返回 Component", async () => {
const result = await lazyRoute(factoryOk)()
expect(result).toEqual({ Component: Comp })
expect(factoryOk).toHaveBeenCalledTimes(1)
})
it("chunk 失败重试:前两次失败、第三次成功 → 不抛出", async () => {
const f = vi
.fn()
.mockRejectedValueOnce(chunkErr())
.mockRejectedValueOnce(chunkErr())
.mockResolvedValueOnce({ default: Comp })
const result = await lazyRoute(f as never)()
expect(result).toEqual({ Component: Comp })
expect(f).toHaveBeenCalledTimes(3)
})
it("chunk 失败重试 2 次仍失败 → 抛出", async () => {
const f = vi.fn().mockRejectedValue(chunkErr())
await expect(lazyRoute(f as never)()).rejects.toThrow(/dynamically imported/)
expect(f).toHaveBeenCalledTimes(3)
})
it("非 chunk 错误立即抛出,不重试", async () => {
const f = vi.fn().mockRejectedValue(new Error("业务模块内部报错"))
await expect(lazyRoute(f as never)()).rejects.toThrow("业务模块内部报错")
expect(f).toHaveBeenCalledTimes(1)
})
})
@@ -1,80 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import {
getChunkReloadedAt,
goHomeRecover,
isChunkLoadError,
reloadForChunkError,
} from "@/utils/chunkLoadError"
describe("isChunkLoadError", () => {
it("识别 Vite 动态 import 失败", () => {
const err = new TypeError(
"Failed to fetch dynamically imported module: https://x/assets/AssetLibrary-abc.js",
)
expect(isChunkLoadError(err)).toBe(true)
})
it("识别 Webpack 风格 ChunkLoadError", () => {
const err = new Error("Loading chunk 12 failed.")
err.name = "ChunkLoadError"
expect(isChunkLoadError(err)).toBe(true)
})
it("识别字符串形式错误", () => {
expect(isChunkLoadError("Error loading dynamically imported module")).toBe(true)
})
it("普通错误不命中", () => {
expect(isChunkLoadError(new Error("Cannot read properties of undefined"))).toBe(false)
expect(isChunkLoadError(null)).toBe(false)
expect(isChunkLoadError(undefined)).toBe(false)
expect(isChunkLoadError({ status: 500 })).toBe(false)
})
})
describe("reload 标记", () => {
beforeEach(() => {
sessionStorage.clear()
// jsdom 未实现真实导航,reload 仅打 "not implemented" 警告,静默掉
vi.spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
sessionStorage.clear()
})
it("无标记返回 null", () => {
expect(getChunkReloadedAt()).toBeNull()
})
it("reloadForChunkError 写入刷新标记", () => {
expect(() => reloadForChunkError()).not.toThrow()
expect(getChunkReloadedAt()).not.toBeNull()
})
it("标记过期(>10min)返回 null", () => {
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now() - 11 * 60 * 1000))
expect(getChunkReloadedAt()).toBeNull()
})
it("goHomeRecover 清掉标记", () => {
reloadForChunkError()
expect(getChunkReloadedAt()).not.toBeNull()
expect(() => goHomeRecover()).not.toThrow()
expect(sessionStorage.getItem("chunk_error_reloaded_at")).toBeNull()
})
it("sessionStorage 抛异常(无痕模式)时降级不崩溃", () => {
const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("Storage disabled")
})
const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("Storage disabled")
})
expect(getChunkReloadedAt()).toBeNull()
expect(() => reloadForChunkError()).not.toThrow()
expect(() => goHomeRecover()).not.toThrow()
spy.mockRestore()
setSpy.mockRestore()
})
})
-84
View File
@@ -1,84 +0,0 @@
/**
* 发版后旧标签页懒加载 chunk 失效(白屏)的识别与恢复工具。
*
* 背景:页面 React Router 的 lazy 动态 import,发版后旧 chunk 文件名被删除,
* 停留在旧标签页的用户点菜单时 import 404,抛出
* "Failed to fetch dynamically imported module"Vite/ ChunkLoadError
* 不捕获就是整页白屏。
*/
/** sessionStorage 标记:最近已经为 chunk 失效自动刷新过一次(带时间戳,10min 有效) */
const RELOAD_FLAG_KEY = "chunk_error_reloaded_at"
/** 标记有效期:超过后允许再次自动刷新,避免用户手动正常刷新后标记永久残留 */
const RELOAD_FLAG_TTL_MS = 10 * 60 * 1000
/**
* Storage 在 Safari 无痕模式 / 禁用 Cookie 的浏览器 / 严格 iframe 策略下
* 访问可能抛异常;此处统一容错,拿不到存储就降级为"无标记",绝不能让
* 错误边界本身因读存储而崩溃。
*/
const safeStorage = {
getItem: (key: string): string | null => {
try {
return sessionStorage.getItem(key)
} catch {
return null
}
},
setItem: (key: string, value: string): void => {
try {
sessionStorage.setItem(key, value)
} catch {
/* 存储不可用时静默降级:仅丢失"已刷新"标记,不影响恢复动作 */
}
},
removeItem: (key: string): void => {
try {
sessionStorage.removeItem(key)
} catch {
/* ignore */
}
},
}
/** 判断错误是否为懒加载 chunk 加载失败(发版 404 / 网络中断 / 动态 import 失败) */
export const isChunkLoadError = (error: unknown): boolean => {
if (!error) return false
// Vite: Failed to fetch dynamically imported module: /assets/xxx-yyy.js
// Webpack: ChunkLoadError: Loading chunk xxx failed.
const needle =
error instanceof Error
? `${error.name} ${error.message}`
: typeof error === "string"
? error
: ""
return /failed to fetch dynamically imported module|chunkloaderror|loading chunk \d+ failed|error loading dynamically imported module|importing a module script failed/i.test(
needle,
)
}
/** 读取上次自动刷新时间戳;过期或不存在返回 null */
export const getChunkReloadedAt = (): number | null => {
const raw = safeStorage.getItem(RELOAD_FLAG_KEY)
if (!raw) return null
const ts = Number(raw)
if (!Number.isFinite(ts)) return null
if (Date.now() - ts > RELOAD_FLAG_TTL_MS) return null
return ts
}
/** 标记"已为 chunk 失效自动刷新过",然后刷新页面 */
export const reloadForChunkError = (): void => {
safeStorage.setItem(RELOAD_FLAG_KEY, String(Date.now()))
window.location.reload()
}
/**
* 硬恢复:清掉标记后回到首页(整页导航,不是当前 URL 刷新)。
* - chunk 失效兜底:回到首页会拉取最新 index.html,彻底脱离旧 chunk 引用
* - 非 chunk 的页面级崩溃:跳首页能绕开当前报错路由,避免"刷新-再崩"死循环
*/
export const goHomeRecover = (): void => {
safeStorage.removeItem(RELOAD_FLAG_KEY)
window.location.href = "/"
}
+4 -14
View File
@@ -34,9 +34,7 @@ def create_video_record_and_dedup(
fps: float = 25.0,
name: str = "",
thumbnail_url: str = "",
) -> dict:
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
) -> int:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
采用两阶段持久化:先计算所有指纹/查重数据(内存),
@@ -78,7 +76,6 @@ def create_video_record_and_dedup(
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
deduplicator = VideoDeduplicator()
fingerprint = None
batch_similarity: float | None = None
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
@@ -108,11 +105,9 @@ def create_video_record_and_dedup(
)
# (b) 批次内查重(仅当有 batch_id 时)
batch_similarity: float | None = None
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
batch_similarity = float(duplicate_result.get("similarity", 0.0))
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
@@ -166,12 +161,7 @@ def create_video_record_and_dedup(
generated_video.is_duplicate,
generated_video.duplicate_rate,
)
return {
"video_count": 1,
"is_duplicate": bool(generated_video.is_duplicate),
"batch_similarity": batch_similarity,
"duplicate_of": generated_video.duplicate_of,
}
return 1
except Exception as e:
logger.error(
"Failed to create video record / dedup for task %s: %s",
@@ -179,4 +169,4 @@ def create_video_record_and_dedup(
e,
)
session.rollback()
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
return 0
+4 -11
View File
@@ -16,9 +16,6 @@ from typing import Any
from shared.ffmpeg_utils import ( # noqa: F401
DEFAULT_FFMPEG_TIMEOUT,
FFMPEG_BIN,
FFMPEG_ENCODE_CRF,
FFMPEG_ENCODE_PRESET,
FFMPEG_ENCODE_THREADS,
FFPROBE_BIN,
run_ffmpeg,
)
@@ -294,11 +291,9 @@ def normalize_video(
"-c:v",
"libx264",
"-crf",
FFMPEG_ENCODE_CRF,
"23",
"-preset",
FFMPEG_ENCODE_PRESET,
"-threads",
FFMPEG_ENCODE_THREADS,
"medium",
"-c:a",
"aac",
"-b:a",
@@ -409,11 +404,9 @@ def random_edge_crop(
"-c:v",
"libx264",
"-preset",
FFMPEG_ENCODE_PRESET,
"fast",
"-crf",
FFMPEG_ENCODE_CRF,
"-threads",
FFMPEG_ENCODE_THREADS,
"18",
"-c:a",
"copy",
"-movflags",
+2 -3
View File
@@ -9,7 +9,6 @@ from dataclasses import dataclass
from typing import List
import ffmpeg
from shared.ffmpeg_utils import FFMPEG_ENCODE_CRF, FFMPEG_ENCODE_PRESET
logger = logging.getLogger(__name__)
@@ -91,8 +90,8 @@ class VideoProcessor:
acodec="aac",
s=f"{width}x{height}",
r=fps,
preset=FFMPEG_ENCODE_PRESET,
crf=int(FFMPEG_ENCODE_CRF),
preset="medium",
crf=23,
)
.overwrite_output()
.run(capture_stdout=True, capture_stderr=True)
@@ -599,7 +599,7 @@ class RenderAdapter:
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, task_id=job_id, num_frames=5, title_text=""
str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text=""
)
if cover_candidates:
logger.info(
+13 -55
View File
@@ -47,23 +47,13 @@ class RenderContext:
def clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长#1749:目标段长始终为准)。
"""计算 clip 的有效时长.
与 UnifiedRenderService._clip_effective_duration 逻辑一致;素材短于段长的
部分由末帧冻结 tpad / 音频 apad 铺满,不在此处钳制。
与 UnifiedRenderService._clip_effective_duration 逻辑一致
"""
if clip.duration > 0:
return float(clip.duration)
return float(clip.actual_duration) if clip.actual_duration > 0 else 0.0
def _clip_freeze_seconds(clip: ResolvedClip) -> float:
"""读取 #1749 末帧冻结秒数(_resolve_clips 写入 config['_freeze_seconds'])。"""
cfg = getattr(clip, "config", None) or {}
try:
return max(0.0, float(cfg.get("_freeze_seconds", 0.0) or 0.0))
except (TypeError, ValueError):
return 0.0
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
@@ -283,9 +273,6 @@ def concat_main_audio(
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
# #1749 末帧冻结秒数(音频需 apad 补静音与视频等长)
freeze_seconds = _clip_freeze_seconds(clip)
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
has_reverse = reverse_config.enabled and reverse_config.reverse_audio
@@ -294,28 +281,20 @@ def concat_main_audio(
if not has_speed and not has_reverse:
# 无调速无倒放:根据是否需要裁剪/音量选择最高效的路径。
vol = _clip_volume(clip)
need_trim = (
trim_start > 0 or freeze_seconds > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
)
need_trim = trim_start > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
need_volume = abs(vol - 1.0) >= 1e-6
if need_trim:
# 需要裁剪:用 atrim 滤镜在滤镜链中精确裁剪(采样点级精度,不浪费解码)。
# 滤镜顺序:atrim → asetpts → apad(冻结补静音) → volume
# 滤镜顺序:atrim → asetpts → volume(先裁剪再调音量,避免处理被丢弃的数据)
af_parts: list[str] = []
# 冻结场景:素材内可用时长 = 目标段长 − freeze(截掉超出素材的部分后补静音)
atrim_dur = final_duration
if freeze_seconds > 0:
atrim_dur = max(0.0, final_duration - freeze_seconds)
if trim_start > 0 and atrim_dur > 0:
af_parts.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
if trim_start > 0 and effective_duration > 0:
af_parts.append(f"atrim=start={trim_start:.3f}:duration={final_duration:.3f}")
elif trim_start > 0:
af_parts.append(f"atrim=start={trim_start:.3f}")
elif atrim_dur > 0:
af_parts.append(f"atrim=duration={atrim_dur:.3f}")
elif final_duration > 0:
af_parts.append(f"atrim=duration={final_duration:.3f}")
af_parts.append("asetpts=PTS-STARTPTS")
if freeze_seconds > 0:
af_parts.append(f"apad=whole_dur={final_duration:.3f}")
if need_volume:
af_parts.append(f"volume={vol:.4f}")
command = [
@@ -355,13 +334,8 @@ def concat_main_audio(
"-ac",
"2",
]
af_simple: list[str] = []
if need_volume:
af_simple.append(f"volume={vol:.4f}")
if freeze_seconds > 0:
af_simple.append(f"apad=whole_dur={final_duration:.3f}")
if af_simple:
command.extend(["-af", ",".join(af_simple)])
command.extend(["-af", f"volume={vol:.4f}"])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
@@ -395,10 +369,6 @@ def concat_main_audio(
if abs(vol - 1.0) >= 1e-6:
audio_filters.append(f"volume={vol:.4f}")
# #1749 冻结补静音(调速后时长已变,apad 补齐到最终时长)
if freeze_seconds > 0:
audio_filters.append(f"apad=whole_dur={final_duration:.3f}")
# aformat 归一化:统一输出格式为 48000Hz + stereo + fltp
audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
@@ -440,13 +410,10 @@ def concat_main_audio(
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
# #1749 末帧冻结:素材内可用时长 = 目标段长 − freeze,apad 补静音
freeze_seconds = _clip_freeze_seconds(clip)
atrim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
audio_filters: list[str] = []
if effective_duration > 0:
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频调速 — atempo 多级串联
@@ -468,10 +435,6 @@ def concat_main_audio(
if reverse_filter:
audio_filters.append(reverse_filter)
# #1749 冻结补静音:视频 tpad 延长后音频等长补齐(concat 时间轴对齐)
if freeze_seconds > 0:
audio_filters.append(f"apad=whole_dur={effective_duration:.3f}")
# 音量(0=静音,1=原声)
vol = _clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
@@ -543,17 +506,12 @@ def mix_with_independent_audio(
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
freeze_seconds = _clip_freeze_seconds(clip)
atrim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
clip_filters = []
if effective_duration > 0:
clip_filters.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
clip_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
clip_filters.append("asetpts=PTS-STARTPTS")
else:
clip_filters.append("asetpts=PTS-STARTPTS")
# #1749 冻结补静音(与视频 tpad 等长)
if freeze_seconds > 0:
clip_filters.append(f"apad=whole_dur={effective_duration:.3f}")
vol = _clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
clip_filters.append(f"volume={vol:.4f}")
@@ -279,25 +279,21 @@ def extract_and_upload_cover_frames(
plan_id: str,
*,
task_id: str = "",
num_frames: int = 5, # 抽 5 帧候选,通过质量评分选出最佳帧
num_frames: int = 3,
title_text: str = "",
title_color: str = "#ffffff",
title_position: str = "bottom",
title_font_size: int | None = None,
) -> list[dict]:
"""从视频中抽取多帧作为封面候选,通过质量评分选出最佳帧,上传到 OSS。
"""从视频中抽取多帧作为封面候选,上传到 OSS。
流程:
1. 优先使用 MediaKit 智能抽帧(多抽一些供选择)
2. MediaKit 不足时降级到 ffmpeg 均匀抽帧
3. 对所有候选帧进行质量评分(清晰度/亮度/色彩丰富度)
4. 按分数从高到低排序返回
优先使用 MediaKit 智能抽帧,失败时降级到 ffmpeg 直接抽帧。
Args:
video_path: 视频文件路径
plan_id: 编辑计划 ID(用于生成 storage key
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
num_frames: 抽取候选帧数(默认 5,通过质量评分选出最佳帧
num_frames: 抽取帧数(默认 3
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
title_color: 标题字体颜色(#RRGGBB
@@ -305,7 +301,7 @@ def extract_and_upload_cover_frames(
title_font_size: 标题字号,None 时自动计算
Returns:
封面候选列表(按质量分数降序),每项包含 {"url": str, "position": float, "score": float}
封面候选列表,每项包含 {"url": str, "position": float}
"""
import httpx
from video_processing.ffmpeg_utils import probe_duration
@@ -317,125 +313,80 @@ def extract_and_upload_cover_frames(
duration = 0.0
candidates: list[dict] = []
_temp_paths: list[str] = [] # 收集所有临时文件路径,最后统一清理
try:
# ── 阶段 1:抽帧 ──────────────────────────────────────────────
# 优先尝试 MediaKit 智能抽帧
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
if mediakit_frames:
for i, frame in enumerate(mediakit_frames):
frame_url = frame.get("image_url")
if not frame_url:
continue
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
_temp_paths.append(tmp.name)
try:
# 下载 MediaKit 返回的帧图
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
with open(tmp.name, "wb") as f:
f.write(resp.content)
# 优先尝试 MediaKit 智能抽帧
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
if mediakit_frames:
for i, frame in enumerate(mediakit_frames):
frame_url = frame.get("image_url")
if not frame_url:
continue
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
# 下载 MediaKit 返回的帧图
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
with open(tmp.name, "wb") as f:
f.write(resp.content)
# 叠加标题文字(如需要)
if title_text and title_text.strip():
apply_title_overlay(
tmp.name,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
candidates.append(
{
"url": url,
"position": round(seek_time, 2),
"image_path": tmp.name,
}
)
except Exception as e:
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
if len(candidates) >= num_frames:
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d", len(candidates))
else:
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
# Fallback: ffmpeg 直接抽帧(仅当 MediaKit 不足时)
if len(candidates) < num_frames:
logger.info("[thumbnail] 使用 ffmpeg 抽帧补充")
# 均匀分布抽帧点:从 10% 到 90%
for i in range(num_frames):
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
_temp_paths.append(tmp.name)
try:
frame_path = extract_first_frame(
video_path,
output_path=tmp.name,
seek_ratio=ratio,
min_seek_seconds=0.5,
# 叠加标题文字(如需要)
if title_text and title_text.strip():
apply_title_overlay(
tmp.name,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
if title_text and title_text.strip():
apply_title_overlay(
frame_path,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
candidates.append(
{
"url": url,
"position": round(seek_time, 2),
"image_path": tmp.name,
}
)
except Exception as e:
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
# ── 阶段 2:质量评分 ────────────────────────────────────────────
if len(candidates) > 1:
try:
from packages.shared.cover_frame_scorer import score_frames
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
candidates.append({"url": url, "position": round(seek_time, 2)})
except Exception as e:
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
finally:
Path(tmp.name).unlink(missing_ok=True)
candidates = score_frames(candidates)
logger.info(
"[thumbnail] 封面帧质量评分完成: plan_id=%s count=%d best_score=%.1f",
plan_id,
len(candidates),
candidates[0].get("score", 0.0) if candidates else 0.0,
)
except Exception:
logger.warning(
"[thumbnail] 封面帧质量评分失败,保持原始顺序: plan_id=%s",
plan_id,
exc_info=True,
if len(candidates) >= num_frames:
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d", len(candidates))
return candidates[:num_frames]
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
# Fallback: ffmpeg 直接抽帧
logger.info("[thumbnail] 使用 ffmpeg 抽帧")
# 均匀分布抽帧点:从 10% 到 90%
for i in range(num_frames):
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
frame_path = extract_first_frame(
video_path,
output_path=tmp.name,
seek_ratio=ratio,
min_seek_seconds=0.5,
)
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
if title_text and title_text.strip():
apply_title_overlay(
frame_path,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
candidates.append({"url": url, "position": round(seek_time, 2)})
except Exception as e:
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
finally:
Path(tmp.name).unlink(missing_ok=True)
# ── 阶段 3:清理临时文件 ────────────────────────────────────────
# 移除 image_path(不再需要),但临时文件统一清理
for c in candidates:
c.pop("image_path", None)
return candidates
finally:
# 统一清理所有临时文件
for path in _temp_paths:
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
return candidates
@@ -35,9 +35,6 @@ from video_processing.ffmpeg_utils import (
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
FFMPEG_BIN,
FFMPEG_ENCODE_CRF,
FFMPEG_ENCODE_PRESET,
FFMPEG_ENCODE_THREADS,
probe_duration,
probe_has_audio,
probe_video_info,
@@ -521,45 +518,120 @@ class UnifiedRenderService:
layers: list[RenderLayer],
voice_duration: float,
) -> None:
"""#1749:配音对齐仅保留 ±5% 守卫日志,不再做全局裁剪/慢放
"""调整片段时长以对齐配音时长
段长已在选片阶段由 voice_duration_planner 按配音时长精确分配
(成片总时长=配音,素材不足走末帧冻结 tpad/apad)。渲染期任何全局
裁剪会截断配音、全局慢放会导致失真——两者均已删除。此处仅在偏差
超阈值时记录 warning 供排查,不修改任何 clip。
核心逻辑:
- 计算片段总时长配音时长的比例
- ±5% 以内不调整
- ratio < 1(片段比配音长):按比例裁剪每段末尾
- ratio > 1(片段比配音短):按比例慢放每段
Args:
layers: 渲染图层列表
voice_duration: 配音时长(秒)
"""
if voice_duration <= 0:
return
# 只调整视频图层(main/broll/background),不调整音频图层
video_layers = [layer for layer in layers if layer.role in ("main", "broll", "background")]
if not video_layers:
return
# 计算所有视频图层的总时长
total_clips_duration = 0.0
for layer in video_layers:
for clip in layer.clips:
total_clips_duration += self._clip_adjusted_duration(clip)
clip_dur = self._clip_adjusted_duration(clip)
total_clips_duration += clip_dur
if total_clips_duration <= 0:
return
estimated = self._estimate_total_duration(video_layers)
ratio = voice_duration / total_clips_duration if total_clips_duration > 0 else 1.0
if abs(ratio - 1.0) > 0.05:
logger.warning(
"[voice-align] 段长合计与配音偏差>5%%(仅守卫,不裁剪/不慢放): "
"ratio=%.4f voice=%.3f clips_sum=%.3f estimated_final=%.3f",
ratio = voice_duration / total_clips_duration
# ±5% 以内不调整
if abs(ratio - 1.0) <= 0.05:
logger.info(
"[voice-align] 比例接近1:1,跳过调整: ratio=%.4f voice=%.3f clips=%.3f",
ratio,
voice_duration,
total_clips_duration,
estimated,
)
return
logger.info(
"[voice-align] 开始调整片段时长: ratio=%.4f voice=%.3f clips=%.3f",
ratio,
voice_duration,
total_clips_duration,
)
# 收集所有视频 clip
all_clips: list[tuple[RenderLayer, ResolvedClip]] = []
for layer in video_layers:
for clip in layer.clips:
all_clips.append((layer, clip))
if not all_clips:
return
if ratio < 1.0:
# 片段比配音长,按比例裁剪每段末尾
# 减少每个 clip 的 duration
for _layer, clip in all_clips:
old_duration = clip.duration if clip.duration > 0 else clip.actual_duration
new_duration = old_duration * ratio
# 更新 duration
clip.duration = max(0.1, new_duration) # 至少 0.1s
# 如果有 trim_config,也需要调整
if clip.trim_config is not None:
new_trim_duration = clip.trim_config.duration * ratio
clip.trim_config = TrimConfig(
start_time=clip.trim_config.start_time,
duration=max(0.1, new_trim_duration),
)
logger.debug(
"[voice-align] trim clip=%s: %.3f -> %.3f",
clip.clip_id,
old_duration,
clip.duration,
)
else:
logger.info(
"[voice-align] 段长与配音偏差≤5%%,无需处理: ratio=%.4f voice=%.3f estimated=%.3f",
ratio,
voice_duration,
estimated,
)
# ratio > 1.0: 片段比配音短,按比例慢放每段
# 降低 playback_speed
for _layer, clip in all_clips:
old_speed = clip.playback_speed if clip.playback_speed > 0 else 1.0
# speed = old_speed / ratio 会使视频变慢(ratio > 1 时)
new_speed = old_speed / ratio
# 下限 0.25x(避免过慢)
new_speed = max(0.25, round(new_speed, 4))
clip.playback_speed = new_speed
logger.debug(
"[voice-align] slowdown clip=%s: speed %.4f -> %.4f",
clip.clip_id,
old_speed,
new_speed,
)
# 调整后重新计算总时长用于日志
new_total = 0.0
for layer in video_layers:
for clip in layer.clips:
new_total += self._clip_adjusted_duration(clip)
logger.info(
"[voice-align] 调整完成: 新总时长=%.3fs (目标=%.3fs, 差异=%.3fs)",
new_total,
voice_duration,
abs(new_total - voice_duration),
)
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
"""根据 plan.config 生成 ASS 字幕文件。
@@ -1300,26 +1372,15 @@ class UnifiedRenderService:
# trim
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
# #1749 冻结时素材内截取时长 = 目标段长 − freeze
trim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
if trim_dur > 0:
filters.append(f"trim=duration={trim_dur}")
if effective_duration > 0:
filters.append(f"trim=duration={effective_duration}")
filters.append("setpts=PTS-STARTPTS")
# #1749 末帧冻结(禁慢放)
if freeze_seconds > 0:
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
# 视觉扰动(plan 级别,直通模式同样适用)
vp = self._get_visual_perturbation()
# 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor
# 调速 — 与 filter_complex 路径一致
speed = UnifiedRenderService._clip_speed(clip)
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
effective_speed = speed * vp_speed
if abs(effective_speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{effective_speed:.4f}")
if abs(speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{speed:.4f}")
# 倒放滤镜
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
@@ -1328,10 +1389,6 @@ class UnifiedRenderService:
if reverse_filter:
filters.append(reverse_filter)
# 视觉扰动:hflip(在 scale 之前)
if vp:
self._apply_visual_perturbation_pre_scale(filters, vp)
# scale + pad(等比缩放+留黑边)
if role in ("overlay", "corner_voice"):
pip_w = int(self.output_width * _PIP_SCALE)
@@ -1346,10 +1403,6 @@ class UnifiedRenderService:
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
if vp:
self._apply_visual_perturbation_post_scale(filters, vp)
# 调色滤镜
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
@@ -1371,10 +1424,6 @@ class UnifiedRenderService:
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
# #1749 末帧冻结:素材短于目标段长时克隆末帧铺满(禁慢放)
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
if freeze_seconds > 0:
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
filters.append("format=yuv420p")
# 字幕叠加
@@ -1401,11 +1450,9 @@ class UnifiedRenderService:
"-c:v",
"libx264",
"-crf",
FFMPEG_ENCODE_CRF,
"23",
"-preset",
FFMPEG_ENCODE_PRESET,
"-threads",
FFMPEG_ENCODE_THREADS,
"medium",
"-pix_fmt",
"yuv420p",
"-movflags",
@@ -1468,10 +1515,6 @@ class UnifiedRenderService:
if abs(clip_volume - 1.0) >= 1e-6:
af_parts.append(f"volume={clip_volume:.4f}")
# #1749 音频冻结:视频 tpad 延长后音频需等长补静音,否则时间轴错位
if freeze_seconds > 0:
af_parts.append(f"apad=whole_dur={final_duration:.3f}")
if af_parts:
command.extend(["-af", ",".join(af_parts)])
@@ -1581,9 +1624,7 @@ class UnifiedRenderService:
continue
# 单段裁剪(或无裁剪)
# #1749 定稿:目标段长(clip.duration,配音分配结果)始终为准。
# trim 只决定素材内截取区间;素材短于段长 → 末帧冻结(tpad)/音频补静音(apad)
# 铺满,禁止慢放、禁止截断配音。
# 解析裁剪配置:config 优先,否则用 clip.start_time + clip.duration
trim_config = extract_trim_from_clip_config(clip_config)
if trim_config is None and (clip.start_time > 0 or clip.duration > 0):
# 用旧字段构造
@@ -1592,48 +1633,42 @@ class UnifiedRenderService:
duration=clip.duration,
)
# 钳制到实际素材时长
effective_trim: TrimConfig | None = None
# 目标段长:配音分配后的 clip.duration 始终为准(不被素材时长钳制)
target_duration = float(clip.duration or 0.0)
final_start = clip.start_time
final_duration = target_duration
final_duration = clip.duration
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
freeze_seconds = 0.0
if trim_config is not None and actual_duration > 0:
resolved_trim = trim_config.validate_and_resolve(actual_duration)
if resolved_trim.is_valid:
# trim 仅决定素材内截取区间 [start, min(start+dur, actual)]
final_start = resolved_trim.start_time
avail_in_asset = max(0.0, actual_duration - final_start)
effective_trim = TrimConfig(
start_time=final_start, duration=min(resolved_trim.duration, avail_in_asset)
)
effective_trim = trim_config.validate_and_resolve(actual_duration)
if effective_trim.is_valid:
final_start = effective_trim.start_time
final_duration = effective_trim.duration
else:
# 裁剪无效 → 从素材头取
logger.warning("裁剪配置无效,使用素材起点: clip_id=%s", clip.id)
# 裁剪无效 → 使用完整素材
logger.warning("裁剪配置无效,使用完整素材: clip_id=%s", clip.id)
effective_trim = None
final_start = 0.0
avail_in_asset = actual_duration
else:
final_start = 0.0
avail_in_asset = actual_duration if actual_duration > 0 else target_duration
final_duration = actual_duration
# 素材内可用时长不足目标段长 → 末帧冻结(禁慢放)
if target_duration > 0 and avail_in_asset < target_duration - 0.05:
freeze_seconds = round(target_duration - avail_in_asset, 3)
# 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿
# 例如:配置4s但素材只有3s → speed=0.75x,用满3s素材达到4s输出
if actual_duration > 0 and final_duration > actual_duration + 0.05:
compensated_speed = actual_duration / final_duration
# 保留用户设置的速度(如果已减速则叠加)
final_speed = configured_speed * compensated_speed
# 下限 0.25x
final_speed = max(0.25, round(final_speed, 4))
logger.info(
"[freeze] clip=%s target=%.3f avail=%.3f末帧冻结 %.3fs(禁慢放)",
"[debug] clip=%s duration=%.3f actual=%.3f减速补偿 speed=%.4f (configured=%.3f)",
clip.id,
target_duration,
avail_in_asset,
freeze_seconds,
final_duration,
actual_duration,
final_speed,
configured_speed,
)
final_speed = configured_speed
# freeze 标记写入 config,供视频 tpad / 音频 apad 读取
resolved_config = dict(clip_config)
if freeze_seconds > 0:
resolved_config["_freeze_seconds"] = freeze_seconds
else:
final_speed = configured_speed
rc = ResolvedClip(
clip_id=clip.id,
@@ -1646,7 +1681,7 @@ class UnifiedRenderService:
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=final_speed,
config=resolved_config,
config=clip_config,
actual_duration=actual_duration,
trim_config=effective_trim,
)
@@ -1655,14 +1690,13 @@ class UnifiedRenderService:
# Debug日志:记录每个clip的时长信息
eff_dur = _clip_effective_duration_pure(final_duration, actual_duration)
logger.info(
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f freeze=%.3f",
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f",
clip.id,
final_duration,
actual_duration,
eff_dur,
final_speed,
final_start,
freeze_seconds,
)
# 按 order 排序
@@ -1747,8 +1781,6 @@ class UnifiedRenderService:
# Step 1: 预处理每个 clip — trim + scale + setpts
# 为每个 clip 生成预处理后的标签 [v0], [v1], ...
preprocessed_labels: list[str] = []
# 视觉扰动(plan 级别,所有 clip 共享同一套扰动参数)
vp = self._get_visual_perturbation()
for i, clip in enumerate(all_clips):
label = f"v{i}"
role = _resolve_layer_role(clip.clip_type, clip.config)
@@ -1758,23 +1790,18 @@ class UnifiedRenderService:
# trim — 裁剪到指定区间,精确到帧
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
# #1749 冻结时素材内截取时长 = 目标段长 − freeze(末帧冻结由 tpad 铺满,与直通路径同口径)
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
trim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
if trim_dur > 0:
if effective_duration > 0:
if trim_start > 0:
filters.append(f"trim=start={trim_start:.3f}:duration={trim_dur:.3f}")
filters.append(f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}")
else:
filters.append(f"trim=duration={trim_dur:.3f}")
filters.append(f"trim=duration={effective_duration:.3f}")
filters.append("setpts=PTS-STARTPTS")
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor
# 调速 — 基于 setpts 改变播放速度
speed = UnifiedRenderService._clip_speed(clip)
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
effective_speed = speed * vp_speed
if abs(effective_speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{effective_speed:.4f}")
if abs(speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{speed:.4f}")
# 倒放滤镜(在 trim 之后、scale 之前应用)
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
@@ -1783,10 +1810,6 @@ class UnifiedRenderService:
if reverse_filter:
filters.append(reverse_filter)
# 视觉扰动:hflip(在 scale 之前,翻转原始画面)
if vp:
self._apply_visual_perturbation_pre_scale(filters, vp)
# scale
if role in ("overlay", "corner_voice"):
pip_w = int(self.output_width * _PIP_SCALE)
@@ -1801,10 +1824,6 @@ class UnifiedRenderService:
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
if vp:
self._apply_visual_perturbation_post_scale(filters, vp)
# 调色滤镜(每个 clip 独立的 color grade 配置)
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
@@ -1827,9 +1846,6 @@ class UnifiedRenderService:
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
# #1749 末帧冻结:素材短于目标段长时克隆末帧铺满(禁慢放)
if freeze_seconds > 0:
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
filter_str = f"[{i}:v]{','.join(filters)}[{label}]"
filter_parts.append(filter_str)
@@ -2030,11 +2046,9 @@ class UnifiedRenderService:
"-c:v",
"libx264",
"-crf",
FFMPEG_ENCODE_CRF,
"23",
"-preset",
FFMPEG_ENCODE_PRESET,
"-threads",
FFMPEG_ENCODE_THREADS,
"medium",
"-pix_fmt",
"yuv420p",
"-movflags",
@@ -2114,11 +2128,16 @@ class UnifiedRenderService:
@staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(#1749:目标段长为准,素材不足走末帧冻结)。
"""计算 clip 的有效时长(原速 trim 后时长)。
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration
duration>0 直接返回;慢放补偿已删除)
如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration
而非 min(duration, actual_duration)
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
"""
speed = getattr(clip, "playback_speed", 1.0) or 1.0
# 减速场景:duration 已通过降低 playback_speed 补偿,返回配置的 duration
if speed < 1.0 - 1e-6 and clip.duration > 0:
return clip.duration
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
@@ -2222,35 +2241,6 @@ class UnifiedRenderService:
"""
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
def _get_visual_perturbation(self) -> dict:
# 读取当前 plan 的视觉扰动参数(plan.config.visual_perturbation
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
if not perturbation:
return {}
return {
"hflip": bool(perturbation.get("hflip", False)),
"zoom_ratio": max(1.0, min(1.2, float(perturbation.get("zoom_ratio", 1.0) or 1.0))),
"speed_factor": max(0.8, min(1.2, float(perturbation.get("speed_factor", 1.0) or 1.0))),
"brightness_shift": max(-30, min(30, int(perturbation.get("brightness_shift", 0) or 0))),
}
def _apply_visual_perturbation_pre_scale(self, filters: list[str], perturbation: dict) -> None:
# scale+pad 之前的扰动(hflip),就地修改 filters
if perturbation.get("hflip"):
filters.append("hflip")
def _apply_visual_perturbation_post_scale(self, filters: list[str], perturbation: dict) -> None:
# scale+pad 之后的扰动(zoom/brightness),就地修改 filters
zoom = perturbation.get("zoom_ratio", 1.0)
if zoom > 1.0 + 1e-6:
zoomed_w = int(self.output_width * zoom)
zoomed_h = int(self.output_height * zoom)
filters.append(f"scale={zoomed_w}:{zoomed_h}")
filters.append(f"crop={self.output_width}:{self.output_height}")
brightness = perturbation.get("brightness_shift", 0)
if brightness != 0:
filters.append(f"eq=brightness={brightness / 100.0:.3f}")
@staticmethod
def _clip_volume(clip: ResolvedClip) -> float:
"""获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。"""
-13
View File
@@ -17,12 +17,6 @@ apply_queue_settings(celery_app)
# 长渲染任务预取 1,避免任务被预取占住导致调度不均
celery_app.conf.worker_prefetch_multiplier = GENERATION_WORKER_PREFETCH_MULTIPLIER
celery_app.conf.task_acks_late = True # worker 崩溃时未完成任务重回队列,由执行前守卫丢弃作废消息
# worker 进程被 OOM/容器硬杀时拒绝 ack,消息留在队列由其他 worker 接手
celery_app.conf.task_reject_on_worker_lost = True
# Redis broker 消息可见性超时(#1714):acks_late 下,消息被预取后 visibility_timeout
# 内未 ack 才会重投。长任务(ingest HEVC 转码 20-30 分钟、生成硬超时 11 分钟)
# 必须远大于最长执行时间,否则正常任务会在执行中被误重投;4 小时覆盖最长转码 + 余量。
celery_app.conf.broker_transport_options = {"visibility_timeout": 4 * 60 * 60}
celery_app.conf.imports = (
"worker_app.tasks.health",
@@ -54,11 +48,4 @@ celery_app.conf.beat_schedule = {
"schedule": 300.0, # 每 5 分钟(秒)
"options": {"expires": 240},
},
# 上传/转码链路孤儿巡检:worker 重启丢 prefetch 消息后,卡 pending/processing
# 的 ingest_job + asset 占位超时标终态(#1714)。转码任务较长,10 分钟一轮
"cleanup-stale-ingest-jobs": {
"task": "worker.cleanup_stale_ingest_jobs",
"schedule": 600.0, # 每 10 分钟(秒)
"options": {"expires": 540},
},
}
+2 -12
View File
@@ -1,20 +1,10 @@
from datetime import datetime, timezone
def mark_asset_used_for_generation(asset, times: int = 1) -> None:
"""标记素材在成片中被使用,累加使用次数。
Args:
asset: Asset 实体(metadata 就地更新)
times: 本次成片实际使用次数(= 最终成片 plan 中引用该素材的片段数)。
按「成片实际渲染的片段」计数而非请求传入的 asset_ids 列表——
请求列表可能含未被 plan 选用的素材(不应计数),同一素材在多片段
复用时应按片段数累加(高频排除/未使用偏好才与真实渲染强度挂钩)。
"""
times = max(1, int(times or 1))
def mark_asset_used_for_generation(asset) -> None:
asset.metadata = {
**asset.metadata,
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + times,
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + 1,
"last_used_at": datetime.now(timezone.utc).isoformat(),
"review_status": asset.metadata.get("review_status") or "pending_review",
}
-29
View File
@@ -257,32 +257,3 @@ def _on_worker_ready(sender, **kwargs): # pragma: no cover
result = cleanup_all_stale_tasks()
total = result["generation_tasks"] + result["jobs"]
logger.info("Worker 启动清理完成,共清理 %d 个孤儿任务", total)
@worker_ready.connect
def _recover_stuck_ingest_jobs_on_ready(sender, **kwargs): # pragma: no cover
"""Worker 启动完成后恢复卡死在 processing 的 ingest_job#1714)。
容器重启/进程 OOM 导致 transcode 队列 unacked 消息未重投时,processing
ingest_job 会永久卡死。启动时扫描 processing 超 10 分钟的 jobCAS 重置
pending 并重新派单;Redis 锁保证同容器 generation/transcode 双 worker
只有一个执行恢复。旧消息若后来重投,ingest_asset 执行前守卫会丢弃。
"""
try:
from packages.application.ingest_orphan_cleanup import (
make_redis_recovery_lock,
recover_stuck_ingest_jobs_on_startup,
)
session = SessionLocal()
try:
recovered = recover_stuck_ingest_jobs_on_startup(
session,
lock_acquire=make_redis_recovery_lock(),
stuck_minutes=10,
)
finally:
session.close()
logger.info("Worker 启动 ingest 恢复完成,共重新派单 %d 个卡死任务", recovered)
except Exception as e: # noqa: BLE001 — 启动恢复失败不能阻断 worker 起服
logger.error("启动 ingest 恢复扫描失败(beat 巡检仍会兜底标 failed: %s", e, exc_info=True)
-56
View File
@@ -16,12 +16,6 @@ from worker_app.tasks._startup import (
cleanup_stale_pending_tasks,
)
from packages.application.ingest_orphan_cleanup import (
ASSET_ORPHAN_TIMEOUT_MINUTES,
INGEST_PENDING_TIMEOUT_MINUTES,
INGEST_PROCESSING_TIMEOUT_MINUTES,
)
logger = logging.getLogger(__name__)
@@ -75,53 +69,3 @@ def scheduled_cleanup_stale_running(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_M
timeout_minutes,
)
return {"generation_tasks": gen_count, "jobs": job_count}
@shared_task(name="worker.cleanup_stale_ingest_jobs")
def scheduled_cleanup_stale_ingest_jobs(
processing_timeout_minutes: int = INGEST_PROCESSING_TIMEOUT_MINUTES,
pending_timeout_minutes: int = INGEST_PENDING_TIMEOUT_MINUTES,
orphan_asset_timeout_minutes: int = ASSET_ORPHAN_TIMEOUT_MINUTES,
) -> dict:
"""Celery Beat 调度:清理上传/转码链路(IngestJob + Asset)孤儿记录。
每 10 分钟执行一次。worker 容器重启/进程 OOM 时,已 prefetch 的 transcode
celery 消息会丢失(队列里也不存在),ingest_job 永久卡 pending/processing、
asset 永久卡 processing/uploading,没有兜底永远不会恢复(#1714)。
- ingest_job processing > processing_timeout_minutes / pending > pending_timeout_minutes
→ 标 failed;关联 asset 占位(processing/uploading)联动标 error
- 无 ingest_job 关联、created_at > orphan_asset_timeout_minutes 的占位 asset
→ 标 error
- 作废 celery 消息 revoke + 物理清除(防重投,执行前守卫是第二道防线)
"""
from worker_app.db import SessionLocal
from packages.application.ingest_orphan_cleanup import (
cleanup_orphan_processing_assets,
cleanup_stale_ingest_jobs,
revoke_stale_ingest_messages,
)
session = SessionLocal()
try:
job_items, asset_ids = cleanup_stale_ingest_jobs(
session,
processing_timeout_minutes=processing_timeout_minutes,
pending_timeout_minutes=pending_timeout_minutes,
)
orphan_asset_ids = cleanup_orphan_processing_assets(session, timeout_minutes=orphan_asset_timeout_minutes)
finally:
session.close()
purged = revoke_stale_ingest_messages(job_items) if job_items else 0
total_jobs = len(job_items)
total_assets = len(set(asset_ids) | set(orphan_asset_ids))
if total_jobs or total_assets:
logger.warning(
"[Beat] 清理 ingest 链路孤儿: stale_jobs=%d, assets→error=%d, 队列清除消息=%d",
total_jobs,
total_assets,
purged,
)
return {"stale_jobs": total_jobs, "assets_to_error": total_assets, "purged_messages": purged}
+80 -277
View File
@@ -376,137 +376,42 @@ def _load_task_info(task_id: str) -> dict | None:
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
"cover_url": getattr(gen_task, "cover_url", "") or "",
"title_config": dict(getattr(gen_task, "title_config", {}) or {}),
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
"source_edit_plan_id": getattr(gen_task, "source_edit_plan_id", "") or "",
}
finally:
session.close()
# ── #1743 批量变体重渲/封面判定(纯函数,便于单测) ──────────────────────
BATCH_RENDER_SIMILARITY_LIMIT = 0.20
"""批次内成片查重相似度阈值:超过则重选独立 plan 重渲一次(20%)。"""
def should_rerender_for_batch_dedup(*, batch_id: str, render_attempt: int, batch_similarity) -> bool:
"""批次内查重后判定是否需要重选 plan 重渲。
条件(全部满足才重渲):批次任务、首版(attempt==0)、查重率已得出、相似度 > 20%
非批次任务 / 已是重渲版 / 查重率缺失 / 相似度达标 → 不重渲。
"""
if not batch_id:
return False
if render_attempt >= 1:
return False
if batch_similarity is None:
return False
return float(batch_similarity) > BATCH_RENDER_SIMILARITY_LIMIT
def pick_batch_cover_index(task_id: str, candidate_count: int) -> int:
"""批次变体封面帧选取:按 task_id md5 稳定哈希分散到候选帧。
同任务重试结果稳定;批次内不同 task_id 哈希后分散,避免 N 个变体都抽 frame_0
导致封面雷同。非批次调用方应直接取 0(主流程按 batch_id 区分)。
"""
if candidate_count <= 1:
return 0
import hashlib
return int(hashlib.md5(task_id.encode()).hexdigest(), 16) % candidate_count
def _count_plan_clip_asset_usage(session, plan_id: str) -> dict[str, int]:
"""统计最终成片 plan 中每个素材被片段引用的次数。
计数口径(#1743):以成片实际渲染的 edit_plan_clips 为准——
同一素材在多个片段复用按片段数累加;未被 plan 选用的素材(即使
出现在请求 asset_ids 中)不计数。
"""
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
rows = session.query(EditPlanClipModel.asset_id).filter(EditPlanClipModel.plan_id == plan_id).all()
counts: dict[str, int] = {}
for (asset_id,) in rows:
if asset_id:
counts[asset_id] = counts.get(asset_id, 0) + 1
return counts
def _record_rendered_asset_usage(
session,
plan_id: str,
task_id: str,
fallback_asset_ids: list[str] | None = None,
) -> int:
"""按最终成片 plan 的实际片段统计素材使用次数并回写 metadata。
plan 无有效片段素材(异常数据)时退回 fallback_asset_ids 每个计 1 次,
保证使用统计不因数据异常完全丢失。单素材回写失败不影响其他素材。
Returns: 实际回写次数的素材数量。
"""
from worker_app.core.asset_usage import mark_asset_used_for_generation
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
used_counts = _count_plan_clip_asset_usage(session, plan_id)
if not used_counts and fallback_asset_ids:
used_counts = {aid: 1 for aid in fallback_asset_ids if aid}
if not used_counts:
logger.info("[task_id=%s] 素材使用计数: plan=%s 无有效片段素材,跳过", task_id, plan_id)
return 0
asset_repo = SQLAlchemyAssetRepository(session)
written = 0
for aid, times in used_counts.items():
try:
asset = asset_repo.get(aid)
if asset:
mark_asset_used_for_generation(asset, times=times)
asset_repo.update(asset)
written += 1
except Exception:
logger.warning(
"[task_id=%s] 更新素材使用次数失败: asset_id=%s times=%d",
task_id,
aid,
times,
exc_info=True,
)
logger.info(
"[task_id=%s] 素材使用计数回写完成(plan=%s): %d 个素材, 片段引用 %d",
task_id,
plan_id,
written,
sum(used_counts.values()),
)
return written
def _upload_rendered_video(
def _upload_and_record(
task_id: str,
output_path: Path,
project_id: str,
*,
attempt: int = 0,
) -> tuple[str, str]:
"""上传成片到 OSS(不落库)。attempt>0 时文件名带轮次后缀,避免覆盖首版。
batch_id: str,
editing_mode,
user_id: str = "",
video_name: str = "",
thumbnail_url: str = "",
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
Returns: (file_url, storage_key)
Returns:
(file_url, duration, file_size, video_count)
"""
suffix = f"_v{attempt}" if attempt > 0 else ""
stem = output_path.stem
name = f"{stem}{suffix}{output_path.suffix or '.mp4'}"
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, name) if p]
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
storage_key = "/".join(path_parts)
file_size = output_path.stat().st_size
logger.info("[task_id=%s] [OSS上传] 开始上传(attempt=%d): size=%d", task_id, attempt, output_path.stat().st_size)
# 上传 OSS
logger.info("[task_id=%s] [OSS上传] 开始上传: size=%d", task_id, file_size)
upload_start = time.monotonic()
file_url = upload_to_oss(output_path, storage_key)
upload_elapsed = time.monotonic() - upload_start
if not file_url:
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
# 校验 URL 可达性(P0-2: 私有 bucket 用预签名 + object_exists 降级)
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
if not _verify_url_accessible(verify_url):
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
@@ -515,64 +420,25 @@ def _upload_rendered_video(
key = normalize_storage_key(file_url)
if not (bucket and bucket.object_exists(key)):
raise RuntimeError(
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, storage_key={storage_key}"
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
)
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
return file_url, storage_key
logger.info(
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
key,
)
logger.info(
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
task_id,
upload_elapsed,
file_url,
)
def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict) -> str | None:
"""批次内查重超阈值后,为当前任务重新独立选片生成新 plan(#1743 自动重渲)。
复用 API 侧同一套 EditPlanService.reselect_plan_for_variantpackages 层
variant_plan_selector 纯核心),素材池来自任务 asset_ids + 源 plan 素材。
成功返回新 plan_id;失败返回 None(调用方放弃重渲,保留首版)。
"""
try:
from app.services.edit_plan_service import EditPlanService
db = SessionLocal()
try:
svc = EditPlanService(db)
asset_pool = list(task_info.get("task_asset_ids") or [])
_voice_dur = 0.0
try:
_voice_dur = float(task_info.get("voice_duration", 0.0) or 0.0)
except (TypeError, ValueError):
_voice_dur = 0.0
new_plan = svc.reselect_plan_for_variant(
plan_id,
asset_pool,
created_by_user_id=task_info.get("user_id", ""),
name_suffix="重渲变体",
voice_duration=_voice_dur,
)
return new_plan.id
finally:
db.close()
except Exception:
logger.warning("[task_id=%s] 批次重渲前重选 plan 失败,放弃重渲", task_id, exc_info=True)
return None
def _record_video_and_dedup(
*,
task_id: str,
project_id: str,
batch_id: str,
editing_mode,
user_id: str,
file_url: str,
file_size: int,
video_path: str,
video_name: str = "",
thumbnail_url: str = "",
) -> dict:
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
duration = probe_duration(Path(video_path))
# 创建 GeneratedVideo 记录 + 查重
duration = probe_duration(output_path)
dedup_session = SessionLocal()
try:
result = create_video_record_and_dedup(
video_count = create_video_record_and_dedup(
generation_task_id=task_id,
project_id=project_id,
user_id=user_id,
@@ -580,7 +446,7 @@ def _record_video_and_dedup(
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=video_path,
video_path=str(output_path),
mode=editing_mode.value,
session=dedup_session,
name=video_name,
@@ -588,12 +454,11 @@ def _record_video_and_dedup(
)
finally:
dedup_session.close()
result["duration"] = duration
return result
return file_url, duration, file_size, video_count or 1
# ── Celery Task ──────────────────────────────────────────────────────────────
# ── Celery Task ──────────────────────────────────────────────────────────────
def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None:
@@ -653,8 +518,8 @@ def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) ->
# 配音下载
voiceover_path: str | None = None
voice_library_id = task_info.get("voice_library_id", "")
# #1749voice_ids 冗余字段已移除;配音一律以 voice_library_id 为准(独立配音每变体各自绑定)
effective_voice_id = voice_library_id or ""
voice_ids = task_info.get("voice_ids", []) or []
effective_voice_id = voice_library_id or (voice_ids[0] if voice_ids else "")
if effective_voice_id:
import tempfile
@@ -864,29 +729,19 @@ def generate_video(self, task_id: str) -> dict:
gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)")
_flush_logs(task_id, gen_task)
# ── 渲染→上传→查重→(批次超阈值则重选 plan 重渲一次)循环(#1743)──
current_plan_id = source_edit_plan_id
file_url = ""
duration = 0.0
file_size = 0
video_count = 1
file_size_final = 0
for render_attempt in range(2): # 首版 + 最多 1 次重渲
(
output_path,
render_duration,
cover_candidates,
voiceover_tmp_path,
render_temp_dir,
thumbnail_url,
) = _render_from_edit_plan(
output_path, render_duration, cover_candidates, voiceover_tmp_path, render_temp_dir, thumbnail_url = (
_render_from_edit_plan(
task_id=task_id,
source_edit_plan_id=current_plan_id,
source_edit_plan_id=source_edit_plan_id,
task_info=task_info,
)
)
# 从这里开始,render_temp_dir 已赋值,必须确保异常时也能清理
try:
if gen_task:
gen_task.append_log("渲染", f"渲染完成(第{render_attempt + 1}版), 时长={render_duration:.1f}s")
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
_flush_logs(task_id, gen_task)
_update_task_progress(task_id, 80, "渲染完成")
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
@@ -896,7 +751,7 @@ def generate_video(self, task_id: str) -> dict:
cropped_path = random_edge_crop(output_path)
if cropped_path != output_path:
output_path = cropped_path
if gen_task and render_attempt == 0:
if gen_task:
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
_flush_logs(task_id, gen_task)
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
@@ -907,110 +762,45 @@ def generate_video(self, task_id: str) -> dict:
crop_err,
exc_info=True,
)
if gen_task:
gen_task.append_log("边缘裁剪", f"裁剪失败,使用原始视频: {crop_err}")
_flush_logs(task_id, gen_task)
# ── 4. 上传 OSS(不落库) ───────────────────────────────
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────
_update_task_progress(task_id, 85, "开始上传")
file_url, _storage_key = _upload_rendered_video(
file_url, duration, file_size, video_count = _upload_and_record(
task_id=task_id,
output_path=output_path,
project_id=project_id,
attempt=render_attempt,
)
file_size = output_path.stat().st_size
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
dedup_info = _record_video_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
editing_mode=editing_mode,
user_id=user_id,
file_url=file_url,
file_size=file_size,
video_path=str(output_path),
video_name=task_info.get("video_title", ""),
thumbnail_url=thumbnail_url,
)
duration = dedup_info.get("duration", render_duration)
video_count = dedup_info.get("video_count", 1)
batch_sim = dedup_info.get("batch_similarity")
if gen_task:
gen_task.append_log(
"OSS上传",
f"{render_attempt + 1}上传成功, 大小={file_size}"
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
f"上传成功, 大小={file_size}",
file_size=file_size,
file_url=file_url,
)
_flush_logs(task_id, gen_task)
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
if not should_rerender_for_batch_dedup(
batch_id=batch_id,
render_attempt=render_attempt,
batch_similarity=batch_sim,
):
file_size_final = file_size
break
# 批次内相似度过高:重选独立 plan 后重渲一次
logger.warning(
"[task_id=%s] 批次内查重相似度 %.2f 超阈值 %.2f,重选 plan 重渲",
task_id,
batch_sim,
BATCH_RENDER_SIMILARITY_LIMIT,
)
if gen_task:
gen_task.append_log("批次查重", f"与批次内成片相似度过高({batch_sim:.0%}),重新选片渲染")
_flush_logs(task_id, gen_task)
new_plan_id = _reselect_plan_for_batch_retry(task_id, current_plan_id, task_info)
if not new_plan_id:
logger.warning("[task_id=%s] 重选 plan 失败,保留首版", task_id)
file_size_final = file_size
break
# 回写任务关联的 plan(重渲版以新 plan 渲染)
try:
_ps = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_pr = SQLAlchemyGenerationTaskRepository(_ps)
_gt = _pr.get(task_id)
if _gt:
_gt.source_edit_plan_id = new_plan_id
_pr.update(_gt)
finally:
_ps.close()
except Exception:
logger.warning("[task_id=%s] 回写重渲 plan_id 失败", task_id, exc_info=True)
current_plan_id = new_plan_id
# 清理本轮临时目录,下一轮重新渲染
_update_task_progress(task_id, 95, "上传完成")
finally:
# 清理渲染临时目录(无论后续步骤成功与否都清理)
if render_temp_dir:
import shutil
shutil.rmtree(render_temp_dir, ignore_errors=True)
render_temp_dir = None
file_size = file_size_final or file_size
_update_task_progress(task_id, 95, "上传完成")
# 渲染结束后清理临时目录(重渲循环内每轮已清理,此处兜底最后一轮)
if render_temp_dir:
import shutil
shutil.rmtree(render_temp_dir, ignore_errors=True)
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
try:
if cover_candidates:
# #1743:批量变体封面差异化——候选帧按 task_id 稳定哈希分散选取
# (同任务重试稳定,批次内不同任务落在不同帧位),非批次取首帧。
_cover_idx = pick_batch_cover_index(task_id, len(cover_candidates)) if batch_id else 0
first = cover_candidates[_cover_idx]
first = cover_candidates[0]
cover_frame_url = first.get("image_url") or first.get("url") or ""
if cover_frame_url:
_cover_session = SessionLocal()
@@ -1069,22 +859,35 @@ def generate_video(self, task_id: str) -> dict:
logger.warning("[task_id=%s] 更新标题使用次数异常", task_id, exc_info=True)
# 5.2 更新素材使用次数
# 按「最终成片 plan 实际渲染的片段」计数(#1743):不用请求传入的
# task.asset_ids(可能含未被 plan 选用的素材),同一素材多片段复用
# 按片段数累加,使 unused_bonus / 高频排除与真实渲染强度挂钩。
# current_plan_id 是重渲循环结束后最终成片所用 plan(首版或重渲版)。
try:
from worker_app.core.asset_usage import mark_asset_used_for_generation
_asset_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_fallback_ids: list[str] = []
_gt_for_assets = SQLAlchemyGenerationTaskRepository(_asset_session).get(task_id)
if _gt_for_assets:
_fallback_ids = list(_gt_for_assets.asset_ids or [])
_record_rendered_asset_usage(_asset_session, current_plan_id, task_id, _fallback_ids)
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
_gen_task = _task_repo.get(task_id)
if _gen_task and _gen_task.asset_ids:
for _aid in _gen_task.asset_ids:
try:
_asset = _asset_repo.get(_aid)
if _asset:
mark_asset_used_for_generation(_asset)
_asset_repo.update(_asset)
except Exception:
logger.warning(
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
task_id,
_aid,
exc_info=True,
)
finally:
_asset_session.close()
except Exception:
-4
View File
@@ -14,10 +14,6 @@ server {
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
location / {
try_files $uri /index.html;
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
add_header Cache-Control "no-cache" always;
}
# API proxy — Production 环境代理到 production API 容器
-4
View File
@@ -21,10 +21,6 @@ server {
# SPA fallback
location / {
try_files $uri /index.html;
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
add_header Cache-Control "no-cache" always;
}
# API proxy — Staging 环境代理到 staging API 容器

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