fix(1895): pages/generate/GeneratePage.tsx - fix TS errors (props, arg count, RechargeOutlined, stale GeneratePage, no-unused)
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 3s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1m55s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 2m27s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m51s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m51s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 29h38m12s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 29h38m16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 29h37m45s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 29h37m42s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 29h37m47s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 29h37m49s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 29h37m51s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 29h37m48s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 29h37m35s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 29h37m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 29h37m39s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 29h37m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 29h37m40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 29h37m40s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 29h37m44s

This commit is contained in:
2026-09-15 09:45:10 +08:00
parent 3db85c89ad
commit bc32dec0c3
+87 -98
View File
@@ -1,12 +1,8 @@
/**
* 智能剪辑页面(Issue #1677 多视频批量生成,修正版)
* 固定 6 步向导:模板(弹数量) → 素材 → 配音 → 标题 → 确认生成 → 封面,单视频与批量完全一致
*
* 架构:
* - 预览全部为纯前端 Canvas 实时播放(FrontendPreviewPlayer),不调任何后端渲染接口:
* N=1 单播放器;N>1 CanvasPreviewGrid(variantSeed 让素材排布/起始点不同,画面有差异)
* - 步骤5确认生成:正式生成接口(count + titles[]/voice_library_ids[]/cover_urls[]),
* 批量时逐任务独立进度/失败重试(BatchGenerationGrid)
* 智能剪辑页面(#1899 简化为 5 步)
* 5 步向导:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5)
* 模板选择已删除,模板在后台自动加载/选择(后端 API 兼容)。
* 新增片段数量输入(默认3),系统按片段数智能匹配素材。
*/
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd"
@@ -32,30 +28,27 @@ import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
// v2: 积分校验
import { usePointsStore } from "@/store/pointsStore"
import { hasEnoughPoints } from "./hooks/pointsCost"
import { estimatePointsCost } from "./hooks/pointsCost"
import "./generate.css"
import "./generate-points.css"
const GeneratePage: React.FC = () => {
const navigate = useNavigate()
/* ── v2: 积分余额 ── */
const { balance: pointsBalance, init: initPoints } = usePointsStore()
/* ── 表单状态 ── */
const formState = useGenerateFormState()
/* ── 积分状态 ── */
const { balance, init: initPoints } = usePointsStore()
useEffect(() => {
initPoints()
}, [initPoints])
/* ── 表单状态 ── */
const formState = useGenerateFormState()
const {
currentStep,
setCurrentStep,
clipCount,
setClipCount,
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -102,9 +95,7 @@ const GeneratePage: React.FC = () => {
const isBatch = previewCount > 1
/* ── 配音选择同步:共用配音 ↔ 变体数组 ── */
// 触发场景:①共用配音变化 ②批量模式进入/退出 ③独立→共用切换(需把所有变体刷成共用配音)
// 独立模式下:仅同步变体[0](其选择器绑定共用配音),用户单独选择的其他变体不覆盖
/* ── 配音选择同步 ── */
const prevVoiceSyncRef = useRef({
voice: selectedVoice,
batch: isBatch,
@@ -114,14 +105,16 @@ const GeneratePage: React.FC = () => {
const prev = prevVoiceSyncRef.current
const voiceChanged = prev.voice !== selectedVoice
const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo
prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo }
prevVoiceSyncRef.current = {
voice: selectedVoice,
batch: isBatch,
perVideo: voiceModePerVideo,
}
if (!voiceChanged && !modeChanged) return
if (!isBatch) return
if (!voiceModePerVideo) {
// 共用模式(含刚从独立切回):所有变体跟随共用配音,未选择的补默认值
setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice))
} else if (voiceChanged) {
// 独立模式下共用配音变化:仅同步变体[0](与共用选择器绑定),其余不覆盖
setVoiceLibraryIds((prevIds) =>
(prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)),
)
@@ -145,12 +138,9 @@ const GeneratePage: React.FC = () => {
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
const ttsAbortRef = useRef<AbortController | null>(null)
// TTS 试听文案:批量跟随变体0标题(仅取首项,避免编辑其他变体标题触发多余 TTS 请求)
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
useEffect(() => {
// #1750:批量模式每变体独立/共用配音由 useVariantVoicePreview 接管(含独立模式每卡片挂各自 URL),
// 本 effect 只服务单视频,批量模式直接跳过(避免共用 TTS 结果错误下发给所有变体)
if (isBatch) return
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
if (voiceAsset?.file_url) {
@@ -158,7 +148,6 @@ const GeneratePage: React.FC = () => {
return
}
// 单视频 TTS 文案跟随主标题
const ttsTitle = titleSettings.title
const voiceId = selectedClonedVoice || selectedVoice
if (!voiceId || !ttsTitle) {
@@ -212,22 +201,16 @@ const GeneratePage: React.FC = () => {
[materialMode, smartSelectedIds, selectedMaterials],
)
/* ── 当前模板对象 ── */
const currentTemplate = useMemo(
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
[userTemplates, selectedTemplate],
)
/* ── BGM 配置 ── */
const bgmConfig = useMemo(
() => ({
enabled: bgm,
music_id: currentTemplate?.bgm_config?.music_id || "",
music_id: "",
}),
[bgm, currentTemplate],
[bgm],
)
/* ── 加载素材详情(供前端预览播放器使用) ── */
/* ── 加载素材详情 ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const {
assets: previewAssets,
@@ -235,13 +218,11 @@ const GeneratePage: React.FC = () => {
ensureAssets,
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */
const previewReady = previewAssetsReady && !!currentTemplate
/* ── 预览就绪 ── */
// #1899: 不再依赖 currentTemplate,素材加载完即可预览
const previewReady = previewAssetsReady
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
// P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位
/* ── 批量变体真实片段(#1744) ── */
const batchVoiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
const {
@@ -252,7 +233,7 @@ const GeneratePage: React.FC = () => {
error: variantClipsError,
retry: retryVariantClips,
} = useBatchVariantPlans({
enabled: isBatch && currentStep === 4 && previewAssetsReady,
enabled: isBatch && currentStep === 3 && previewAssetsReady,
count: previewCount,
templateId: selectedTemplate || "",
assetIds: previewAssetIds,
@@ -262,8 +243,7 @@ const GeneratePage: React.FC = () => {
voiceModePerVideo,
})
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
hook 内部自取配音素材库并做 TTS 缓存/串行/防竞态 */
/* ── 批量变体配音预览 URL ── */
const variantVoiceAudioUrls = useVariantVoicePreview({
enabled: isBatch,
count: previewCount,
@@ -274,8 +254,7 @@ const GeneratePage: React.FC = () => {
titles: previewTitles || [],
})
/* ── 变体计划 clips 引用的素材可能不在已选列表中(#1750):差集补拉素材详情,
保证播放器拿得到 file_url,而不是静默丢片段/用假数据冒充 */
/* ── 变体 clips 引用素材补拉 ── */
const clipAssetIds = useMemo(() => {
if (!isBatch || !variantClips?.length) return []
const ids = new Set<string>()
@@ -327,7 +306,6 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
// #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片
variantPlanIds,
previewTaskId,
bgmConfig,
@@ -343,12 +321,11 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 数量弹窗确认:设置数量 + 同步批量数组长度 + 进入步骤2 ── */
/* ── 数量弹窗确认 ── */
const handleCountConfirm = useCallback(
(count: number) => {
setPreviewCount(count)
setCountModalOpen(false)
// 同步批量数组长度
setPreviewTitles((prev) => {
const list = prev || []
const base = list[0] || titleSettings.title || ""
@@ -377,18 +354,22 @@ const GeneratePage: React.FC = () => {
],
)
/* ── 步骤4「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤5看实时进展 ── */
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
const handleConfirmGenerate = useCallback(async () => {
// 标题校验:批量只校验已勾选的变体;单视频校验主标题
const videoCount = isBatch ? selectedVariantIds.length : 1
// v2: 积分校验
const pointsCheck = hasEnoughPoints(pointsBalance, videoCount)
if (!pointsCheck.sufficient) {
message.warning(pointsCheck.reason ?? "积分不足,请充值后再试")
// 积分预检查
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const est = estimatePointsCost({
scene: "generate_video",
units,
isMember: !!balance?.is_member,
freeClipsUsedToday: balance?.free_clips_used_today ?? 0,
dailyFreeQuota: balance?.daily_free_quota ?? 2,
})
const currentBalance = balance?.balance ?? 0
if (est.pointsCost > 0 && currentBalance < est.pointsCost) {
message.error(`积分不足,还需 ${est.pointsCost - currentBalance} 积分,请充值`)
return
}
if (isBatch) {
if (selectedVariantIds.length === 0) {
message.warning("请至少勾选一个视频")
@@ -400,8 +381,6 @@ const GeneratePage: React.FC = () => {
return
}
} else if (!titleSettings.title?.trim()) {
// 与 buildPayload.validateGenerateInputs 一致:AI 自动选标题模式(aiAutoSelect)
// 允许空标题由后端生成;手动模式必须填写,避免提交空标题
if (!titleSettings.aiAutoSelect) {
message.warning("请先选择或输入标题")
return
@@ -413,8 +392,7 @@ const GeneratePage: React.FC = () => {
}
const ok = await handleGenerate()
if (ok) {
// 单视频与批量一致:任务创建成功后进入步骤5「确认生成」看实时渲染进展
setCurrentStep(5)
setCurrentStep(4)
}
}, [
isBatch,
@@ -425,26 +403,13 @@ const GeneratePage: React.FC = () => {
previewReady,
handleGenerate,
setCurrentStep,
pointsBalance,
balance,
])
/* v2: 积分消耗估算(步骤4按钮展示用) */
const pointsInfo = useMemo(() => {
const videoCount = isBatch ? selectedVariantIds.length : 1
const check = hasEnoughPoints(pointsBalance, videoCount)
return {
cost: check.cost,
insufficient: !check.sufficient,
reason: check.reason,
freeUsed: check.freeClipsUsed,
}
}, [isBatch, selectedVariantIds.length, pointsBalance])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
currentStep,
setCurrentStep,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
@@ -453,15 +418,31 @@ const GeneratePage: React.FC = () => {
onOpenCountModal: () => setCountModalOpen(true),
})
/* ── 最终成片(单视频右侧播放) ── */
/* ── 最终成片 ── */
const finalVideo = generatedVideos[0]
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
/* ── 布局 class ── */
const layoutClassName = useMemo(() => {
if (currentStep === 4) return "xx-generate-layout step4-layout"
if (currentStep === 3) return "xx-generate-layout step4-layout"
return "xx-generate-layout full-width"
}, [currentStep])
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(
() =>
estimatePointsCost({
scene: "generate_video",
units: unitsForCost,
isMember: !!balance?.is_member,
freeClipsUsedToday: balance?.free_clips_used_today ?? 0,
dailyFreeQuota: balance?.daily_free_quota ?? 2,
}),
[unitsForCost, balance],
)
const insufficientPoints =
pointsEstimate.pointsCost > 0 && (balance?.balance ?? 0) < pointsEstimate.pointsCost
/* ================================================================
渲染
================================================================ */
@@ -473,11 +454,10 @@ const GeneratePage: React.FC = () => {
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={layoutClassName}>
{/* ════ 步骤4:左侧预览大区域(纯前端 Canvas 实时预览) ════ */}
{currentStep === 4 && !!currentTemplate && (
{/* ════ 步骤3:左侧预览大区域 ════ */}
{currentStep === 3 && (
<div className="xx-generate-preview-col">
{!isBatch ? (
/* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */
<FrontendPreviewPlayer
assets={previewAssets}
videoRatio={videoRatio}
@@ -500,11 +480,15 @@ const GeneratePage: React.FC = () => {
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
) : (
/* 批量:N 个前端 Canvas 预览网格(不调任何后端渲染接口,秒开) */
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {previewCount} 个视频预览</h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
<span
style={{
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
实时预览,勾选要生成的视频
</span>
</div>
@@ -529,19 +513,19 @@ const GeneratePage: React.FC = () => {
</div>
)}
{/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 确认生成进度 / 步骤6 封面 ════ */}
{/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
<div className="xx-generate-form">
<GenerateStepContent
currentStep={currentStep}
userTemplates={userTemplates}
selectedTemplate={selectedTemplate}
onSelectTemplate={setSelectedTemplate}
clipCount={clipCount}
onClipCountChange={setClipCount}
materialMode={materialMode}
onMaterialModeChange={setMaterialMode}
selectedMaterials={selectedMaterials}
onSelectedMaterialsChange={setSelectedMaterials}
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={setSmartSelectedIds}
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
onTitleSettingsChange={setTitleSettings}
onUpdatePosition={styleUpdaters.updatePosition}
@@ -561,7 +545,6 @@ const GeneratePage: React.FC = () => {
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
onTemplateInvalid={handleInvalidTemplate}
generating={generating}
generated={generated}
generateError={generateError}
@@ -583,8 +566,8 @@ const GeneratePage: React.FC = () => {
selectedVariantIds={selectedVariantIds}
/>
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
{currentStep === 5 && !isBatch && generated && finalVideo && (
{/* ════ 步骤4(单视频):成片播放器 ════ */}
{currentStep === 4 && !isBatch && generated && finalVideo && (
<div
style={{
display: "flex",
@@ -629,7 +612,14 @@ const GeneratePage: React.FC = () => {
}}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<div
style={{
display: "flex",
gap: 8,
marginTop: 12,
justifyContent: "center",
}}
>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
⬇️ 下载
</button>
@@ -656,10 +646,9 @@ const GeneratePage: React.FC = () => {
generated={generated}
generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
estimatedCost={pointsInfo.cost}
pointsInsufficient={pointsInfo.insufficient}
insufficientReason={pointsInfo.reason}
freeClipsUsedThisTime={pointsInfo.freeUsed}
estimatedCost={pointsEstimate.pointsCost}
freeClipsUsedThisTime={pointsEstimate.usingFreeClips}
pointsInsufficient={insufficientPoints}
onRecharge={() => navigate("/app/points/recharge")}
/>
</div>