diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index ed1f84b73..d8d52b6ab 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -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(null) const ttsAbortRef = useRef(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() @@ -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 = () => {
- {/* ════ 步骤4:左侧预览大区域(纯前端 Canvas 实时预览) ════ */} - {currentStep === 4 && !!currentTemplate && ( + {/* ════ 步骤3:左侧预览大区域 ════ */} + {currentStep === 3 && (
{!isBatch ? ( - /* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */ { onTitlePositionChange={styleUpdaters.updateTitlePosition} /> ) : ( - /* 批量:N 个前端 Canvas 预览网格(不调任何后端渲染接口,秒开) */

🎬 {previewCount} 个视频预览

- + 实时预览,勾选要生成的视频
@@ -529,19 +513,19 @@ const GeneratePage: React.FC = () => {
)} - {/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 确认生成进度 / 步骤6 封面 ════ */} + {/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
{ 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 && (
{ }} poster={finalVideo.thumbnail_url || undefined} /> -
+
@@ -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")} />