From eeebdbfdc9fdf1880998a4c3e12130766d9d1b86 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 23 Sep 2026 21:47:27 +0800 Subject: [PATCH 1/2] refactor(generate): remove frontend video preview & AI title generation, adjust step3 layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 - Delete all frontend video preview code: - Remove FrontendPreviewPlayer, CanvasPreviewGrid, PreviewControls, PreviewProgressBar - Remove usePreviewAssets, usePreviewAudio, useVariantVoicePreview, useSegmentScheduler hooks - Clean up GeneratePage: remove preview state/effects/callbacks/render block - Remove onServerClipsChange prop chain (GenerateStepContent → Step2 still optional) - Layout: currentStep===3 now uses full-width (no more step4-layout dual column) Task 2 - Delete AI title generation: - Remove AiTitleGenerator, AiTitleCard components - Remove useAiTitleGenerator hook - Remove AI_TITLE_TEMPLATES from constants.ts - Remove AiTitleResult type from types.ts - Clean up Step4TitleSettings: remove AI toggle, AI generate buttons, batch AI row - Clean up useStep4Title: remove all aiTitle* fields and auto-generate effect Task 3 - Step3 title page layout adjustment: - generate.css: remove step4-layout grid, preview-col rules, title-sidebar max-height - Step4TitleSettings: title inputs max-width 640px, template grid uses full width - TitleStylePanel with enableTemplates gets 4-5 columns via auto-fill responsive grid Preserved: useBatchVariantPlans (used by useGenerateVideo), PreviewCountModal, aiAutoSelect field in TitleSettings (used by buildPayload/ai-avatar), TitleMiniPreview (shared with TitleStylePanel for AI数字人) --- apps/web/src/pages/generate/GeneratePage.tsx | 205 +----- .../generate/components/CanvasPreviewGrid.tsx | 168 ----- .../components/FrontendPreviewPlayer.tsx | 633 ------------------ .../components/GenerateStepContent.tsx | 11 - .../generate/components/PreviewControls.tsx | 195 ------ .../components/PreviewProgressBar.tsx | 108 --- .../components/Step4TitleSettings.tsx | 195 +----- .../generate/components/title/AiTitleCard.tsx | 35 - .../components/title/AiTitleGenerator.tsx | 105 --- apps/web/src/pages/generate/constants.ts | 25 - apps/web/src/pages/generate/generate.css | 144 +--- .../pages/generate/hooks/usePreviewAssets.ts | 155 ----- .../pages/generate/hooks/usePreviewAudio.ts | 131 ---- .../generate/hooks/useSegmentScheduler.ts | 384 ----------- .../generate/hooks/useStep4Title/index.ts | 50 +- .../useStep4Title/useAiTitleGenerator.ts | 124 ---- .../generate/hooks/useVariantVoicePreview.ts | 165 ----- apps/web/src/pages/generate/types.ts | 8 - .../src/test/pages/generate/smoke.test.tsx | 6 - .../generate/step5-preview.smoke.test.tsx | 11 +- 20 files changed, 33 insertions(+), 2825 deletions(-) delete mode 100644 apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx delete mode 100644 apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx delete mode 100644 apps/web/src/pages/generate/components/PreviewControls.tsx delete mode 100644 apps/web/src/pages/generate/components/PreviewProgressBar.tsx delete mode 100644 apps/web/src/pages/generate/components/title/AiTitleCard.tsx delete mode 100644 apps/web/src/pages/generate/components/title/AiTitleGenerator.tsx delete mode 100644 apps/web/src/pages/generate/hooks/usePreviewAssets.ts delete mode 100644 apps/web/src/pages/generate/hooks/usePreviewAudio.ts delete mode 100644 apps/web/src/pages/generate/hooks/useSegmentScheduler.ts delete mode 100644 apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts delete mode 100644 apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 92c52dc96..85e764d2a 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -8,15 +8,12 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react" import { message } from "antd" import { useNavigate } from "react-router-dom" import type { VoiceClone } from "@/api/voice-clone" -import { useQuery } from "@tanstack/react-query" import { useCloneProgress } from "@/hooks/useCloneProgress" import CloneModal from "@/components/voice/CloneModal" import VoiceSelectModal from "./components/VoiceSelectModal" import ScriptSelectModal from "./components/ScriptSelectModal" import TtsVoiceModal from "./components/TtsVoiceModal" import GenerateHeader from "./components/GenerateHeader" -import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer" -import CanvasPreviewGrid from "./components/CanvasPreviewGrid" import PreviewCountModal from "./components/PreviewCountModal" import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateStepContent from "./components/GenerateStepContent" @@ -25,12 +22,8 @@ import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" -import { usePreviewAssets } from "./hooks/usePreviewAssets" import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans" -import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" -import { getAssetsByKind } from "@/api/assets" -import { previewTts } from "@/api/tts" import { usePointsStore } from "@/store/pointsStore" import { hasEnoughPoints } from "./hooks/pointsCost" import { ENABLE_CREDIT_SYSTEM } from "@/config/features" @@ -101,8 +94,6 @@ const GeneratePage: React.FC = () => { setPreviewTaskId, storedSourceEditPlanId, setStoredSourceEditPlanId, - serverClips, - setServerClips, previewCount, setPreviewCount, previewTitles, @@ -159,62 +150,6 @@ const GeneratePage: React.FC = () => { onTitleSettingsChange: setTitleSettings, }) - /* ── 配音素材库(TTS 试听)── */ - const { data: voiceMaterials = [] } = useQuery({ - queryKey: ["assets", "voice"], - queryFn: () => getAssetsByKind("voice", { limit: 50 }), - }) - - const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState(null) - const ttsAbortRef = useRef(null) - const variant0Title = isBatch ? previewTitles?.[0] || "" : "" - - useEffect(() => { - if (isBatch) return - const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice) - if (voiceAsset?.file_url) { - setPreviewVoiceAudioUrl(voiceAsset.file_url) - return - } - - const ttsTitle = titleSettings.title - const voiceId = selectedClonedVoice || selectedVoice - if (!voiceId || !ttsTitle) { - setPreviewVoiceAudioUrl(null) - return - } - - ttsAbortRef.current?.abort() - const controller = new AbortController() - ttsAbortRef.current = controller - let cancelled = false - - previewTts({ text: ttsTitle, voice_id: voiceId }) - .then((res) => { - if (!cancelled && res.audio_url) { - setPreviewVoiceAudioUrl(res.audio_url) - } - }) - .catch((err) => { - if (!cancelled) { - console.warn("[预览配音生成失败]", err) - setPreviewVoiceAudioUrl(null) - } - }) - - return () => { - cancelled = true - controller.abort() - } - }, [ - selectedVoice, - selectedClonedVoice, - titleSettings.title, - variant0Title, - isBatch, - voiceMaterials, - ]) - /* ── 克隆声音 ── */ const { addClone } = useCloneProgress() @@ -239,30 +174,11 @@ const GeneratePage: React.FC = () => { [bgm], ) - /* ── 加载素材详情 ── */ - const previewAssetsEnabled = previewAssetIds.length > 0 - const { - assets: previewAssets, - ready: previewAssetsReady, - ensureAssets, - } = usePreviewAssets(previewAssetIds, previewAssetsEnabled) - - /* ── 预览就绪 ── */ - // #1899: 不再依赖 currentTemplate,素材加载完即可预览 - const previewReady = previewAssetsReady - /* ── 批量变体真实片段(#1744) ── */ const batchVoiceLibraryId = voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || "" - const { - clipsByVariant: variantClips, - planIdsByVariant: variantPlanIds, - voiceDurationsByVariant: variantVoiceDurations, - loading: variantClipsLoading, - error: variantClipsError, - retry: retryVariantClips, - } = useBatchVariantPlans({ - enabled: isBatch && currentStep === 3 && previewAssetsReady, + const { planIdsByVariant: variantPlanIds } = useBatchVariantPlans({ + enabled: isBatch && currentStep === 3, count: previewCount, templateId: selectedTemplate || "", assetIds: previewAssetIds, @@ -272,39 +188,6 @@ const GeneratePage: React.FC = () => { voiceModePerVideo, }) - /* ── 批量变体配音预览 URL ── */ - const variantVoiceAudioUrls = useVariantVoicePreview({ - enabled: isBatch, - count: previewCount, - perVideo: voiceModePerVideo, - sharedVoiceId: selectedVoice || "", - clonedVoiceId: selectedClonedVoice || "", - variantVoiceIds: voiceLibraryIds || [], - titles: previewTitles || [], - }) - - /* ── 变体 clips 引用素材补拉 ── */ - const clipAssetIds = useMemo(() => { - if (!isBatch || !variantClips?.length) return [] - const ids = new Set() - variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id))) - return Array.from(ids) - }, [isBatch, variantClips]) - useEffect(() => { - if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds) - }, [clipAssetIds, ensureAssets]) - - /* ── 勾选变体 ── */ - const toggleVariantSelect = useCallback( - (index: number) => { - setSelectedVariantIds((prev) => { - const list = prev || [] - return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort() - }) - }, - [setSelectedVariantIds], - ) - /* ── 视频生成核心逻辑 ── */ const { generating, @@ -484,10 +367,6 @@ const GeneratePage: React.FC = () => { return } } - if (!previewReady) { - message.warning("预览素材正在加载,请稍候") - return - } const ok = await handleGenerate() if (ok) { setCurrentStep(4) @@ -498,7 +377,6 @@ const GeneratePage: React.FC = () => { previewTitles, titleSettings.aiAutoSelect, titleSettings.title, - previewReady, handleGenerate, setCurrentStep, balance, @@ -530,10 +408,7 @@ const GeneratePage: React.FC = () => { const finalVideo = generatedVideos[0] /* ── 布局 class ── */ - const layoutClassName = useMemo(() => { - if (currentStep === 3) return "xx-generate-layout step4-layout" - return "xx-generate-layout full-width" - }, [currentStep]) + const layoutClassName = "xx-generate-layout full-width" /* ── 积分消耗估算(步骤3确认生成展示用) ── */ const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1 @@ -561,78 +436,7 @@ const GeneratePage: React.FC = () => {
- {/* ════ 步骤3:左侧预览大区域 ════ */} - {currentStep === 3 && ( -
- {!isBatch ? ( - 0} - serverClips={serverClips} - voiceAudioUrl={previewVoiceAudioUrl || undefined} - titleSettings={{ - title: titleSettings.title, - size: titleSettings.size, - font: titleSettings.font, - color: titleSettings.color, - position: titleSettings.position as "top" | "center" | "bottom" | "custom", - bold: titleSettings.bold, - italic: titleSettings.italic, - stroke: titleSettings.stroke, - strokeWidth: titleSettings.strokeWidth, - strokeColor: titleSettings.strokeColor, - shadow: titleSettings.shadow, - shadowOffsetX: titleSettings.shadowOffsetX, - shadowOffsetY: titleSettings.shadowOffsetY, - shadowBlur: titleSettings.shadowBlur, - shadowColor: titleSettings.shadowColor, - bgEnabled: titleSettings.bgEnabled, - bgColor: titleSettings.bgColor, - bgPadding: titleSettings.bgPadding, - bgRadius: titleSettings.bgRadius, - lineHeight: titleSettings.lineHeight, - maxCharsPerLine: titleSettings.maxCharsPerLine, - posX: titleSettings.posX, - posY: titleSettings.posY, - }} - onTitlePositionChange={styleUpdaters.updateTitlePosition} - /> - ) : ( -
-
-

🎬 {previewCount} 个视频预览

- - 实时预览,勾选要生成的视频 - -
- -
- )} -
- )} - - {/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */} + {/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
{ ttsVoiceId={ttsVoiceId} ttsVoiceSource={ttsVoiceSource} onSelectedVoiceChange={setSelectedVoice} - onServerClipsChange={setServerClips} generating={generating} generated={generated} generateError={generateError} diff --git a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx deleted file mode 100644 index abc94f147..000000000 --- a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** - * 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划) - * - * N 个 FrontendPreviewPlayer 网格排列: - * - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口; - * - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实 - * clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片; - * 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览); - * 加载中 → 9:16 占位防塌陷; - * - 各自叠加独立标题浮层(variantTitle),标题样式全局共用; - * - 勾选框决定提交时生成哪些变体; - * - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥: - * 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。 - */ -import React, { useState } from "react" -import type { AssetItem } from "@/api/assets" -import type { EditPlanClip } from "@/api/template-editor" -import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons" -import { Button } from "antd" -import type { TitleSettings } from "../types" -import FrontendPreviewPlayer from "./FrontendPreviewPlayer" - -interface CanvasPreviewGridProps { - count: number - assets: AssetItem[] - videoRatio: string - titles: string[] - titleSettings: TitleSettings - /** - * 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条; - * 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中)) - */ - voiceAudioUrls?: (string | null)[] - /** - * 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined, - * 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。 - * 长度=count,undefined 项表示该变体未提供后端时长。 - */ - voiceDurations?: (number | undefined)[] - /** - * 各变体的后端真实片段(#1744/#1750):长度=count。 - * 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。 - */ - variantClips?: EditPlanClip[][] - /** 是否正在向后端申请变体计划 */ - clipsLoading?: boolean - /** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */ - clipsError?: boolean - /** 用户点击错误态「重试」 */ - onRetryClips?: () => void - /** 勾选的变体序号 */ - selectedIds: number[] - onToggleSelect: (index: number) => void - /** 生成中禁止勾选 */ - selectable?: boolean -} - -const CanvasPreviewGrid: React.FC = ({ - count, - assets, - videoRatio, - titles, - titleSettings, - voiceAudioUrls, - voiceDurations, - variantClips, - clipsLoading = false, - clipsError = false, - onRetryClips, - selectedIds, - onToggleSelect, - selectable = true, -}) => { - // ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750) ── - const [activePlayToken, setActivePlayToken] = useState(null) - - // count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp, - // 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节 - return ( -
- {Array.from({ length: count }, (_, i) => { - const checked = selectedIds.includes(i) - return ( -
-
- -
- {clipsError ? ( - // ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ── -
- 预览加载失败,请重试 - {i === 0 && onRetryClips ? ( - - ) : null} -
- ) : clipsLoading || !variantClips?.[i]?.length ? ( - // ── 加载态:9:16 占位防塌陷 ── -
- - 独立选片中… -
- ) : ( - 0} - playToken={i} - serverClips={variantClips[i]} - variantTitle={titles[i] || ""} - voiceAudioUrl={voiceAudioUrls?.[i] || undefined} - voiceDurationHint={voiceDurations?.[i]} - 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, - strokeWidth: titleSettings.strokeWidth, - strokeColor: titleSettings.strokeColor, - shadow: titleSettings.shadow, - shadowOffsetX: titleSettings.shadowOffsetX, - shadowOffsetY: titleSettings.shadowOffsetY, - shadowBlur: titleSettings.shadowBlur, - shadowColor: titleSettings.shadowColor, - bgEnabled: titleSettings.bgEnabled, - bgColor: titleSettings.bgColor, - bgPadding: titleSettings.bgPadding, - bgRadius: titleSettings.bgRadius, - lineHeight: titleSettings.lineHeight, - maxCharsPerLine: titleSettings.maxCharsPerLine, - posX: titleSettings.posX, - posY: titleSettings.posY, - }} - /> - )} -
- ) - })} -
- ) -} - -export default CanvasPreviewGrid diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx deleted file mode 100644 index 7c7fc126d..000000000 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ /dev/null @@ -1,633 +0,0 @@ -/** - * 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI) - * - * 架构: - * - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度), - * 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。 - * UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。 - * - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除), - * 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。 - * - * 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。 - */ -import React, { useMemo, useCallback, useState, useRef, useEffect } from "react" -import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons" -import type { AssetItem } from "@/api/assets" -import type { EditPlanClip } from "@/api/template-editor" -import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler" -import { usePreviewAudio } from "../hooks/usePreviewAudio" -import { PreviewControls } from "./PreviewControls" -import { getFontFamily } from "@/components/title/constants" - -interface FrontendPreviewPlayerProps { - assets: AssetItem[] - videoRatio: string - ready: boolean - /** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */ - serverClips?: EditPlanClip[] - voiceAudioUrl?: string - titleSettings?: { - title: string - size: number - font: string - color: string - position: "top" | "center" | "bottom" | "custom" - bold?: boolean - italic?: boolean - stroke?: boolean - shadow?: boolean - strokeWidth?: number - strokeColor?: string - shadowOffsetX?: number - shadowOffsetY?: number - shadowBlur?: number - shadowColor?: string - bgEnabled?: boolean - bgColor?: string - bgPadding?: number - bgRadius?: number - lineHeight?: number - maxCharsPerLine?: number - posX?: number | null - posY?: number | null - } - onTitlePositionChange?: (posX: number, posY: number) => void - /** - * 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。 - * 持有播放权的实例播放,其余自动暂停。 - */ - playToken?: number - /** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */ - variantTitle?: string - /** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */ - compact?: boolean - /** - * 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。 - * 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。 - */ - activePlayToken?: number | null - /** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */ - onPlayTokenChange?: (token: number | null) => void - /** - * 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点; - * 未提供则在 audio loadedmetadata 后自测兜底。 - */ - voiceDurationHint?: number -} - -/** - * 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback) - * - * 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。 - * 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。 - */ -function buildPlaybackSegments( - assets: AssetItem[], - serverClips?: EditPlanClip[], - /** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */ - speedFactor = 1, -): PlaybackSegment[] { - if (!assets.length || !serverClips || serverClips.length === 0) return [] - - 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, - }) - } - return segments.sort((a, b) => a.order - b.order) -} - -const FrontendPreviewPlayer: React.FC = ({ - assets, - videoRatio, - ready, - serverClips, - voiceAudioUrl, - voiceDurationHint, - titleSettings, - onTitlePositionChange, - playToken, - variantTitle, - compact = false, - activePlayToken = null, - onPlayTokenChange, -}) => { - // #1754→P0:配音时长作为音画时长锚点。 - // 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。 - const [voiceDuration, setVoiceDuration] = useState(() => - voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0, - ) - - // #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位) - 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], - ) - // 批量变体:标题文字取 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_SIDE = 40 - const playRes = (() => { - switch (videoRatio) { - 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 } - } - })() - const customTitleXPct = - titleSettings?.posX != null && playRes.width > 0 - ? (titleSettings.posX / playRes.width) * 100 - : null - const customTitleYPct = - titleSettings?.posY != null && playRes.height > 0 - ? (titleSettings.posY / playRes.height) * 100 - : null - - // ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)── - const draggingTitleRef = useRef(false) - const titleDragRef = useRef(null) - const playerContainerRef = useRef(null) - const handleTitlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (!onTitlePositionChange || !playerContainerRef.current) return - e.preventDefault() - e.stopPropagation() - ;(e.target as Element).setPointerCapture(e.pointerId) - draggingTitleRef.current = true - ;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing" - }, - [onTitlePositionChange], - ) - const handleTitlePointerMove = useCallback((e: React.PointerEvent) => { - if (!draggingTitleRef.current || !playerContainerRef.current) return - e.preventDefault() - e.stopPropagation() - if (titleDragRef.current) { - const rect = playerContainerRef.current.getBoundingClientRect() - const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) - const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)) - const xpct = (relX / rect.width) * 100 - const ypct = (relY / rect.height) * 100 - titleDragRef.current.style.left = `${xpct}%` - titleDragRef.current.style.top = `${ypct}%` - } - }, []) - const handleTitlePointerUp = useCallback( - (e: React.PointerEvent) => { - if (!draggingTitleRef.current) return - draggingTitleRef.current = false - if (onTitlePositionChange && playerContainerRef.current) { - const rect = playerContainerRef.current.getBoundingClientRect() - const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) - const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)) - const posX = Math.round((relX / rect.width) * playRes.width) - const posY = Math.round((relY / rect.height) * playRes.height) - onTitlePositionChange(posX, posY) - } - ;(e.currentTarget as HTMLDivElement).style.cursor = "grab" - try { - if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) { - ;(e.currentTarget as Element).releasePointerCapture(e.pointerId) - } - } catch { - /* ignore */ - } - }, - [onTitlePositionChange, playRes.width, playRes.height], - ) - - const [containerHeight, setContainerHeight] = useState(0) - useEffect(() => { - const el = playerContainerRef.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() - }, []) - - // 标题字号按容器高度与 PlayResY 的比例缩放 - const titleFontSizePx = - containerHeight > 0 - ? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight - : (titleSettings?.size ?? 36) - const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100 - const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100 - const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100 - // 标题缩放到容器:字号 px = (settings.size / playRes.height) * containerHeight - // 描边/阴影:读 titleSettings 真实值,按容器比例缩放 - const titleStrokeWidth = Math.max( - 1, - ((titleSettings?.strokeWidth ?? 4) / playRes.height) * containerHeight, - ) - const titleStrokeColor = titleSettings?.strokeColor ?? "#000000" - const titleShadowBlur = ((titleSettings?.shadowBlur ?? 4) / playRes.height) * containerHeight - const titleShadowOffsetX = - ((titleSettings?.shadowOffsetX ?? 2) / playRes.height) * containerHeight - const titleShadowOffsetY = - ((titleSettings?.shadowOffsetY ?? 2) / playRes.height) * containerHeight - const titleShadowColor = titleSettings?.shadowColor ?? "rgba(0,0,0,0.8)" - const titleShadowStr = `${titleShadowOffsetX}px ${titleShadowOffsetY}px ${titleShadowBlur}px ${titleShadowColor}` - // 标题背景色块 - const titleBgEnabled = !!titleSettings?.bgEnabled - const titleBgColor = titleSettings?.bgColor ?? "rgba(0,0,0,0.5)" - const titleBgPadding = ((titleSettings?.bgPadding ?? 12) / playRes.height) * containerHeight - const titleBgRadius = ((titleSettings?.bgRadius ?? 8) / playRes.height) * containerHeight - - // ── Video 播放器(默认路径,浏览器原生硬件解码) ── - const { - isPlaying, - currentTime, - totalDuration, - currentSegmentIndex, - canPlay, - togglePlayPause, - seekTo, - pause, - videoRefs, - } = useSegmentScheduler(segments) - - // P0 fix:以配音时长为音画同步锚点。 - // 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。 - const effectiveTotalDuration = - !!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration - - // 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片 - const [muted, setMuted] = useState(false) - // 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底 - const hasVoice = !!voiceAudioUrl - - // 音频 ended:兜底触发暂停与释放播放权 - const handleAudioEnded = useCallback(() => { - if (!isPlaying) return - pause() - if (playToken != null) onPlayTokenChange?.(null) - }, [isPlaying, pause, playToken, onPlayTokenChange]) - - const { - seekTo: audioSeekTo, - ensurePlayingAt: audioEnsurePlayingAt, - pause: audioPause, - } = usePreviewAudio({ - voiceAudioUrl, - voiceDurationHint, - muted, - isPlaying, - currentTime, - onVoiceDurationChange: setVoiceDuration, - onEnded: handleAudioEnded, - }) - - // 片段切换时同步音频时间(video fallback) - useEffect(() => { - if (!isPlaying) return - audioSeekTo(currentTime) - // 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSegmentIndex, isPlaying]) - - // P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。 - // 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。 - const [tailCurrentTime, setTailCurrentTime] = useState(null) - const tailStartRef = useRef(0) - const tailBaseRef = useRef(0) - const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }) - tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause } - - useEffect(() => { - const needTail = - !!voiceAudioUrl && - voiceDuration > 0 && - !isPlaying && - typeof currentTime === "number" && - currentTime >= totalDuration - 0.1 && - currentTime < voiceDuration - 0.1 - if (needTail && tailCurrentTime === null) { - tailBaseRef.current = currentTime - tailStartRef.current = performance.now() - setTailCurrentTime(currentTime) - tailAudioRef.current.ensurePlayingAt(currentTime) - return - } - if (!needTail && tailCurrentTime !== null) { - setTailCurrentTime(null) - } - }, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime]) - - useEffect(() => { - if (tailCurrentTime === null) return - let raf = 0 - const tick = () => { - const elapsed = (performance.now() - tailStartRef.current) / 1000 - const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current) - setTailCurrentTime(t) - tailAudioRef.current.ensurePlayingAt(t) - if (t >= (voiceDuration || 0) - 0.05) { - tailAudioRef.current.pause() - if (playToken != null) onPlayTokenChange?.(null) - setTailCurrentTime(null) - return - } - raf = requestAnimationFrame(tick) - } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) - }, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange]) - - // 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间 - const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime - - const handleSeekTo = useCallback( - (time: number) => { - setTailCurrentTime(null) - seekTo(time) - audioSeekTo(time) - }, - [seekTo, audioSeekTo], - ) - - // ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ── - useEffect(() => { - if (activePlayToken == null || playToken == null || activePlayToken === playToken) return - if (isPlaying) { - pause() - } - // isPlaying 不放依赖:只在 token 变化时执行一次暂停 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activePlayToken, playToken]) - - const handleTogglePlay = useCallback(() => { - if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken) - togglePlayPause() - }, [togglePlayPause, isPlaying, playToken, onPlayTokenChange]) - - // P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结) - useEffect(() => { - if (!isPlaying) return - if (!voiceAudioUrl || voiceDuration <= 0) return - if (displayCurrentTime < voiceDuration - 0.08) return - pause() - audioPause() - if (playToken != null) onPlayTokenChange?.(null) - }, [ - isPlaying, - displayCurrentTime, - voiceAudioUrl, - voiceDuration, - pause, - audioPause, - playToken, - onPlayTokenChange, - ]) - - // ── 未就绪 ── - if (!ready || !assets.length) { - return ( -
- -

- 准备预览素材... -

-

- 加载素材后即可预览播放 -

-
- ) - } - - // ── 无播放片段 ── - if (!canPlay) { - return ( -
- -

- 暂无可播放素材 -

-

- 请先在左侧选择素材 -

-
- ) - } - - return ( -
- {/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */} - {segments.map((seg, i) => ( -
- ) -} - -export default FrontendPreviewPlayer diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 12661187d..f3dacfef2 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -4,7 +4,6 @@ * 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。 */ import React from "react" -import type { EditPlanClip } from "@/api/template-editor" import type { CoverConfig } from "../types/cover" import type { TitleSettings } from "../types" import type { ScriptItem } from "@/api/scripts" @@ -69,7 +68,6 @@ export interface GenerateStepContentProps { /* ── 配音 ── */ selectedVoice: string onSelectedVoiceChange: (id: string) => void - onServerClipsChange: (clips: EditPlanClip[]) => void /* ── 生成 ── */ generating: boolean generated: boolean @@ -137,7 +135,6 @@ export const GenerateStepContent: React.FC = (props) = titlePresets, coverSettings, onCoverSettingsChange, - onServerClipsChange, generating, generateError, progress, @@ -155,13 +152,6 @@ export const GenerateStepContent: React.FC = (props) = ttsVoiceSource, } = props - const handleClipsChange = React.useCallback( - (clips: EditPlanClip[], _templateId?: string) => { - onServerClipsChange(clips) - }, - [onServerClipsChange], - ) - switch (currentStep) { case 1: return ( @@ -189,7 +179,6 @@ export const GenerateStepContent: React.FC = (props) = clipCount={clipCount} onClipCountChange={onClipCountChange} selectedTemplate={selectedTemplate} - onServerClipsChange={handleClipsChange} /> ) case 3: diff --git a/apps/web/src/pages/generate/components/PreviewControls.tsx b/apps/web/src/pages/generate/components/PreviewControls.tsx deleted file mode 100644 index 41d0889e4..000000000 --- a/apps/web/src/pages/generate/components/PreviewControls.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React from "react" -import { - PlayCircleOutlined, - PauseCircleOutlined, - AudioOutlined, - AudioMutedOutlined, -} from "@ant-design/icons" -import { PreviewProgressBar } from "./PreviewProgressBar" - -interface PreviewControlsProps { - isPlaying: boolean - onTogglePlay: () => void - muted: boolean - onToggleMute: () => void - hasSegments: boolean - segmentIndex: number - segmentCount: number - currentTime: number - totalDuration: number - onSeek: (time: number) => void - compact?: boolean -} - -/** - * 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条) - */ -export const PreviewControls: React.FC = ({ - isPlaying, - onTogglePlay, - muted, - onToggleMute, - hasSegments, - segmentIndex, - segmentCount, - currentTime, - totalDuration, - onSeek, - compact = false, -}) => { - return ( - <> - {/* 静音/有声切换(#1741):左上角 */} - {hasSegments && ( - - )} - - {/* 片段指示器 — 右上角胶囊 */} -
- {`${segmentIndex + 1} / ${segmentCount}`} -
- - {/* 中央播放按钮 */} - {!isPlaying && ( - - )} - - {/* 控制条 — 手机风格毛玻璃 */} -
- - - -
- - ) -} diff --git a/apps/web/src/pages/generate/components/PreviewProgressBar.tsx b/apps/web/src/pages/generate/components/PreviewProgressBar.tsx deleted file mode 100644 index f3fe337a7..000000000 --- a/apps/web/src/pages/generate/components/PreviewProgressBar.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react" -import { formatDuration } from "../utils/formatDuration" - -interface PreviewProgressBarProps { - currentTime: number - totalDuration: number - onSeek: (time: number) => void - compact?: boolean -} - -/** - * 进度条组件:点击/拖拽 seek - */ -export const PreviewProgressBar: React.FC = ({ - currentTime, - totalDuration, - onSeek, - compact = false, -}) => { - const progressRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) - - const seekByClientX = useCallback( - (clientX: number) => { - if (!progressRef.current || totalDuration <= 0) return - const rect = progressRef.current.getBoundingClientRect() - const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) - onSeek(ratio * totalDuration) - }, - [totalDuration, onSeek], - ) - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - setIsDragging(true) - seekByClientX(e.clientX) - }, - [seekByClientX], - ) - - useEffect(() => { - if (!isDragging) return - const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX) - const handleMouseUp = () => setIsDragging(false) - window.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - return () => { - window.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - } - }, [isDragging, seekByClientX]) - - const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0 - - return ( - <> - - {formatDuration(currentTime)} / {formatDuration(totalDuration)} - - -
-
-
-
- - ) -} diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 8cdcc5f90..fef2ca907 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -1,24 +1,18 @@ /** * Step 4 选择标题(Issue #1677 批量生成) * - * 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格), - * 右侧边栏标题设置。本组件渲染在右侧边栏: - * - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归) - * - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成 - * (一次生成 N 个标题,分别填入各变体,可单独换一个) + * 布局:全宽区域,标题输入 + 标题模板卡片网格。 + * - 单视频:TitleLibraryAutoComplete 标题库输入 + * - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择) * - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一 */ -import React, { useMemo, useState } from "react" -import { Input, message } from "antd" -import { LoadingOutlined } from "@ant-design/icons" +import React, { useMemo } from "react" import type { TitleSettings } from "../types" import { POSITION_OPTIONS } from "../constants" import { FONT_OPTIONS } from "@/components/title/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" import type { TitleTemplate } from "@/components/title/template-types" interface Step4TitleSettingsProps { @@ -55,36 +49,6 @@ interface Step4TitleSettingsProps { onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void } -/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */ -function buildBatchAiTitles(topic: string, count: number): string[] { - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - const pool: string[] = [] - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] || [] - templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic))) - }) - // 洗牌后取前 count 个;不足则轮转补齐 - const shuffled = [...pool].sort(() => Math.random() - 0.5) - const out: string[] = [] - for (let i = 0; i < count; i++) { - out.push(shuffled[i % shuffled.length] || "") - } - return out -} - -function extractTopic(text: string): string { - const keywords = text - .replace(/[,。!?、,.!?]/g, " ") - .split(/\s+/) - .filter(Boolean) - if (keywords.length === 0) return "这个话题" - return keywords.slice(0, 3).join("") -} - const Step4TitleSettings: React.FC = (props) => { const t = useStep4Title(props) const { @@ -108,8 +72,6 @@ const Step4TitleSettings: React.FC = (props) => { } = props const isBatch = previewCount > 1 - const [batchAiLoading, setBatchAiLoading] = useState(false) - const [batchAiTopic, setBatchAiTopic] = useState("") /** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */ const updateVariantTitle = (index: number, val: string) => { @@ -122,32 +84,6 @@ const Step4TitleSettings: React.FC = (props) => { } } - /** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */ - const handleBatchAiGenerate = async (onlyEmpty = false) => { - if (!onPreviewTitlesChange || !previewTitles) return - const topic = (batchAiTopic || t.aiTitleInput || "").trim() - if (!topic) { - message.warning("请先输入主题词,例如:萌宠日常、旅行vlog") - return - } - setBatchAiLoading(true) - try { - // 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟) - await new Promise((resolve) => setTimeout(resolve, 800)) - const picked = buildBatchAiTitles(extractTopic(topic), previewCount) - const next = [...previewTitles] - for (let i = 0; i < previewCount; i++) { - if (onlyEmpty && next[i]?.trim()) continue - if (picked[i]) next[i] = picked[i] - } - onPreviewTitlesChange(next) - if (next[0]) t.updateTitle(next[0]) - message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`) - } finally { - setBatchAiLoading(false) - } - } - const titleOptions = useMemo( () => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })), [t.userTitles], @@ -158,81 +94,21 @@ const Step4TitleSettings: React.FC = (props) => {

📝 选择标题

{!isBatch ? ( - /* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */ - <> - {t.titleSettings.aiAutoSelect ? ( - <> -
- AI 自动选择标题 -
-
-
-
-
- -
- - {(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"} - - -
-
- - ) : ( - <> - - -
- AI 自动选择标题 -
-
-
-
-
- - { - t.updateTitle(val || "") - onPreviewTitlesChange?.([val || ""]) - }} - options={titleOptions} - /> -
- - )} - + /* ── 单视频:标题输入框 ── */ +
+ + { + t.updateTitle(val || "") + onPreviewTitlesChange?.([val || ""]) + }} + options={titleOptions} + /> +
) : ( - /* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */ + /* ── 批量:N 个独立标题输入框 ── */
= (props) => { lineHeight: 1.6, }} > - 为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。 -
- - {/* 批量 AI 标题 */} -
- { - setBatchAiTopic(e.target.value) - t.setAiTitleInput(e.target.value) - }} - maxLength={30} - size="small" - style={{ flex: 1 }} - /> - - + 为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
{Array.from({ length: previewCount }, (_, i) => ( -
+
void -} - -const AiTitleCard: React.FC = ({ - title, - highlight, - style, - selected, - onClick, -}) => { - return ( -
-
{title}
-
{highlight}
- {selected && ( -
- -
- )} -
- ) -} - -export default AiTitleCard diff --git a/apps/web/src/pages/generate/components/title/AiTitleGenerator.tsx b/apps/web/src/pages/generate/components/title/AiTitleGenerator.tsx deleted file mode 100644 index e980f0e7e..000000000 --- a/apps/web/src/pages/generate/components/title/AiTitleGenerator.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * AI 智能生成标题 - * 输入框 + 生成按钮 + 结果列表 + 加载状态 - */ -import React from "react" -import { LoadingOutlined } from "@ant-design/icons" -import AiTitleCard from "./AiTitleCard" - -interface AiTitleItem { - title: string - highlight: string - style: "catchy" | "emotional" | "informative" -} - -interface AiTitleGeneratorProps { - inputValue: string - onInputChange: (value: string) => void - generating: boolean - onGenerate: () => void - results: AiTitleItem[] - hasGenerated: boolean - onSelect: (title: string) => void - selectedTitle: string - onRefresh: () => void -} - -const AiTitleGenerator: React.FC = ({ - inputValue, - onInputChange, - generating, - onGenerate, - results, - hasGenerated, - onSelect, - selectedTitle, - onRefresh, -}) => { - return ( -
-
- ✨ AI 智能生成标题 -
-
- onInputChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") onGenerate() - }} - /> - -
- - {/* 生成结果 */} - {hasGenerated && !generating && results.length > 0 && ( -
-
- 为你生成 {results.length} 个标题 - -
-
- {results.map((item, idx) => ( - onSelect(item.title)} - /> - ))} -
-
- )} - - {/* 生成中 */} - {generating && ( -
- - AI 正在为你创作标题… -
- )} -
- ) -} - -export default AiTitleGenerator diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index 31dd48b48..fd4aa51c4 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -535,31 +535,6 @@ export const SMART_MATCH_REASONS = [ "人物表情生动", ] -/* ── AI 标题模板 ── */ -export const AI_TITLE_TEMPLATES: Record = { - catchy: [ - "震惊!{topic}居然还能这样操作", - "99%的人都不知道的{topic}秘诀", - "{topic}的终极指南,看完直接封神", - "别再走弯路了!{topic}看这一篇就够", - "一个视频讲透{topic},建议收藏", - ], - emotional: [ - "致每一个在{topic}路上坚持的人", - "关于{topic},我想说句真心话", - "{topic}背后的故事,看完沉默了", - "为什么我劝你一定要了解{topic}", - "这才是{topic}最动人的样子", - ], - informative: [ - "{topic}完整科普:从入门到精通", - "深度解析{topic}的核心原理", - "{topic}行业趋势报告|2026最新版", - "三分钟带你全面了解{topic}", - "{topic}常见问题与解决方案汇总", - ], -} - /* ── 默认封面设置 ── */ export const DEFAULT_COVER_SETTINGS: CoverConfig = { enabled: true, diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index cb5eff27e..95c5430c8 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -2880,25 +2880,9 @@ Issue #1677 多视频批量生成 ================================================================ */ -/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */ -.xx-generate-layout.step4-layout { - grid-template-columns: 1fr 380px; - align-items: start; -} - -.xx-generate-preview-col { - min-width: 0; - position: sticky; - top: 16px; -} - -.xx-generate-preview-col .xx-form-section { - margin: 0; -} - +/* ── 标题设置区域(全宽区块,不再受侧边栏约束) ── */ .xx-title-sidebar { - max-height: calc(100vh - 140px); - overflow-y: auto; + /* no max-height / overflow constraints */ } /* ── 数量选择弹窗 ── */ @@ -3240,135 +3224,13 @@ border-radius: 4px; } -/* ── 响应式:窄屏 Step4 回退单列 ── */ +/* ── 响应式:窄屏标题区域 ── */ @media (max-width: 960px) { - .xx-generate-layout.step4-layout { - grid-template-columns: 1fr; - } - - .xx-generate-preview-col { - position: static; - } - .xx-title-sidebar { max-height: none; } } -/* ============================================================ - 批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览) - ============================================================ */ -/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中), - 中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */ -.xx-canvas-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 180px)); - justify-content: center; - gap: 14px; -} - -/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */ -@media (max-width: 960px) { - .xx-canvas-grid { - grid-template-columns: minmax(0, 320px); - } -} - -.xx-canvas-grid-card { - position: relative; - border: 2px solid var(--border-primary, #e2e8f0); - border-radius: 12px; - overflow: hidden; - background: transparent; - transition: border-color 0.2s ease; - min-width: 0; -} - -.xx-canvas-grid-card.selected { - border-color: var(--primary-color, #1677ff); - box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15); -} - -.xx-canvas-grid-card-bar { - position: relative; - z-index: 2; - display: flex; - align-items: center; - padding: 6px 10px; - background: var(--bg-surface, #fff); - border-bottom: 1px solid var(--border-primary, #e2e8f0); -} - -.xx-canvas-grid-check { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary, #1a1a1a); - cursor: pointer; - user-select: none; -} - -.xx-canvas-grid-check input[type="checkbox"] { - width: 15px; - height: 15px; - cursor: pointer; - accent-color: var(--primary-color, #1677ff); -} - -/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */ -.xx-variant-clips-status { - position: relative; - width: 100%; - aspect-ratio: 9 / 16; - max-height: 70vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - font-size: 13px; - color: rgba(255, 255, 255, 0.75); - background: #0a0a0a; - border-radius: 16px; - overflow: hidden; - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.3), - 0 20px 50px -12px rgba(0, 0, 0, 0.5), - inset 0 0 0 1px rgba(255, 255, 255, 0.06); -} - -.xx-variant-clips-status .anticon { - font-size: 22px; -} - -.xx-variant-clips-error-text { - color: rgba(255, 255, 255, 0.85); - font-size: 13px; -} - -/* ============================================================ - 批量标题:AI 一键生成行(Issue #1677) - ============================================================ */ -.xx-batch-ai-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - padding: 10px 12px; - margin-bottom: 12px; - background: var(--bg-secondary, #f7f8fa); - border: 1px dashed var(--border-primary, #d9d9d9); - border-radius: 10px; -} - -.xx-batch-ai-row .xx-form-field { - margin: 0; - flex: 1; - min-width: 140px; -} - .xx-batch-titles { display: flex; flex-direction: column; diff --git a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts b/apps/web/src/pages/generate/hooks/usePreviewAssets.ts deleted file mode 100644 index c43172d99..000000000 --- a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 预览素材加载 Hook - * 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等) - * 供前端预览播放器使用 - * - * 注意:后端没有批量接口(/assets/batch 返回 405), - * 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id} - */ -import { useState, useEffect, useCallback, useRef } from "react" -import type { AssetItem } from "@/api/assets" -import type { AxiosResponse } from "axios" - -/** - * 通过 ID 列表逐个获取素材(并发) - * 使用 Promise.allSettled 确保单个失败不影响整体 - */ -async function fetchAssetsByIds(ids: string[]): Promise { - // 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求 - const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0) - if (!validIds.length) return [] - - try { - const { default: apiClient } = await import("@/api/client") - const results = await Promise.allSettled( - validIds.map((id) => apiClient.get(`/assets/${id}`)), - ) - return results - .filter( - (r): r is PromiseFulfilledResult> => - r.status === "fulfilled" && !!r.value?.data, - ) - .map((r) => r.value.data) - } catch { - return [] - } -} - -interface UsePreviewAssetsReturn { - /** 加载后的素材列表 */ - assets: AssetItem[] - /** 是否正在加载 */ - loading: boolean - /** 是否已就绪(加载完成) */ - ready: boolean - /** 手动触发重新加载 */ - reload: () => void - /** - * 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材 - * (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并, - * 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。 - */ - ensureAssets: (ids: string[]) => Promise -} - -/** - * usePreviewAssets — 加载选中素材的视频文件信息 - */ -export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn { - const [assets, setAssets] = useState([]) - const [loading, setLoading] = useState(false) - const [ready, setReady] = useState(false) - const requestIdRef = useRef(0) - - // 稳定化 assetIds:只有内容真正变化时才更新引用 - const stableAssetIds = useStableArray(assetIds) - - const load = useCallback(async () => { - const validIds = stableAssetIds.filter( - (id): id is string => typeof id === "string" && id.length > 0, - ) - if (!validIds.length || !enabled) { - setAssets([]) - setReady(false) - return - } - - const thisRequestId = ++requestIdRef.current - setLoading(true) - setReady(false) - - try { - const result = await fetchAssetsByIds(validIds) - // 防止竞态:只保留最新请求的结果 - if (requestIdRef.current === thisRequestId) { - setAssets(result) - setReady(result.length > 0) - } - } catch { - if (requestIdRef.current === thisRequestId) { - setAssets([]) - setReady(false) - } - } finally { - if (requestIdRef.current === thisRequestId) { - setLoading(false) - } - } - }, [stableAssetIds, enabled]) - - // 补拉用独立序号:不干扰主 load 的竞态守卫 - const ensureSeqRef = useRef(0) - const assetsRef = useRef([]) - 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 } -} - -/** - * useStableArray — 数组内容稳定化 Hook - * 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新 - */ -function useStableArray(array: T[]): T[] { - const ref = useRef(array) - - // 比较数组内容是否真正变化 - const hasChanged = - array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index]) - - if (hasChanged) { - ref.current = array - } - - return ref.current -} - -export default usePreviewAssets diff --git a/apps/web/src/pages/generate/hooks/usePreviewAudio.ts b/apps/web/src/pages/generate/hooks/usePreviewAudio.ts deleted file mode 100644 index 1de9ad6f1..000000000 --- a/apps/web/src/pages/generate/hooks/usePreviewAudio.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useCallback, useEffect, useRef } from "react" - -interface UsePreviewAudioOptions { - voiceAudioUrl: string | undefined - voiceDurationHint: number | undefined - muted: boolean - isPlaying: boolean - currentTime: number - onVoiceDurationChange: (d: number) => void - onEnded: () => void -} - -interface UsePreviewAudioReturn { - seekTo: (time: number) => void - ensurePlayingAt: (time: number) => void - pause: () => void -} - -/** - * 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、 - * ended 事件回调、seek 同步、末帧冻结期间续播。 - */ -export function usePreviewAudio({ - voiceAudioUrl, - voiceDurationHint, - muted, - isPlaying, - currentTime, - onVoiceDurationChange, - onEnded, -}: UsePreviewAudioOptions): UsePreviewAudioReturn { - const audioRef = useRef(null) - const prevIsPlayingRef = useRef(false) - - // 外部 hint 初始化(自测值前的兜底) - useEffect(() => { - if (voiceDurationHint && voiceDurationHint > 0) { - onVoiceDurationChange(voiceDurationHint) - } - }, [voiceDurationHint, onVoiceDurationChange]) - - // 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended - useEffect(() => { - if (!voiceAudioUrl) { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current.src = "" - audioRef.current = null - } - return - } - if (!audioRef.current) { - audioRef.current = new Audio() - audioRef.current.preload = "auto" - } - if (audioRef.current.src !== voiceAudioUrl) { - audioRef.current.src = voiceAudioUrl - } - audioRef.current.muted = muted - - const audio = audioRef.current - const onLoaded = () => { - if (audio.duration && isFinite(audio.duration) && audio.duration > 0) { - onVoiceDurationChange(audio.duration) - } - } - const onEndedHandler = () => onEnded() - audio.addEventListener("loadedmetadata", onLoaded) - audio.addEventListener("ended", onEndedHandler) - return () => { - audio.removeEventListener("loadedmetadata", onLoaded) - audio.removeEventListener("ended", onEndedHandler) - } - }, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded]) - - // mute 变化即时同步 - useEffect(() => { - if (audioRef.current) audioRef.current.muted = muted - }, [muted]) - - // 播放/暂停同步(跟随视频 isPlaying) - useEffect(() => { - const audio = audioRef.current - if (!audio || !audio.src) return - if (isPlaying && !prevIsPlayingRef.current) { - if (Math.abs(audio.currentTime - currentTime) > 0.3) { - try { - audio.currentTime = currentTime - } catch { - /* ignore */ - } - } - audio.play().catch(() => {}) - } else if (!isPlaying && prevIsPlayingRef.current) { - audio.pause() - } - prevIsPlayingRef.current = isPlaying - }, [isPlaying, currentTime]) - - const seekTo = useCallback((time: number) => { - const audio = audioRef.current - if (audio && audio.src) { - try { - audio.currentTime = time - } catch { - /* ignore */ - } - } - }, []) - - const ensurePlayingAt = useCallback((time: number) => { - const audio = audioRef.current - if (!audio || !audio.src) return - try { - if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time - if (audio.paused) audio.play().catch(() => {}) - } catch { - /* ignore */ - } - }, []) - - const pause = useCallback(() => { - try { - audioRef.current?.pause() - } catch { - /* ignore */ - } - }, []) - - return { seekTo, ensurePlayingAt, pause } -} diff --git a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts deleted file mode 100644 index 5e0676693..000000000 --- a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * 素材片段调度器 Hook(多 video 元素方案 v3) - * - * v3 修复: - * - 所有动态状态存入 ref,tick 为稳定函数,彻底消除 RAF 闭包陷阱 - * - 片段切换时先启动下一个 video 再切可见性,消除冻屏间隔 - * - 进度更新 200ms 节流 - */ - -import { useState, useRef, useCallback, useEffect, useMemo } from "react" - -export interface PlaybackSegment { - assetId: string - videoUrl: string - startTime: number - endTime: number - order: number - /** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */ - playbackRate?: number -} - -export interface SegmentSchedulerState { - isPlaying: boolean - currentTime: number - totalDuration: number - currentSegmentIndex: number - segmentLocalTime: number - isEnded: boolean - canPlay: boolean - play: () => void - pause: () => void - togglePlayPause: () => void - seekTo: (time: number) => void - videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]> -} - -function findSegmentAtTime( - segments: PlaybackSegment[], - globalTime: number, -): { index: number; localTime: number } { - let accumulated = 0 - for (let i = 0; i < segments.length; i++) { - const seg = segments[i] - const segDuration = seg.endTime - seg.startTime - if (globalTime < accumulated + segDuration || i === segments.length - 1) { - return { index: i, localTime: seg.startTime + (globalTime - accumulated) } - } - accumulated += segDuration - } - return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime } -} - -function buildTimeline(segments: PlaybackSegment[]): number[] { - const starts: number[] = [] - let acc = 0 - for (const seg of segments) { - starts.push(acc) - acc += seg.endTime - seg.startTime - } - return starts -} - -export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState { - const videoRefs = useRef<(HTMLVideoElement | null)[]>([]) - const [isPlaying, setIsPlaying] = useState(false) - const [currentTime, setCurrentTime] = useState(0) - const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0) - const [isEnded, setIsEnded] = useState(false) - const rafRef = useRef(0) - const isSeekingRef = useRef(false) - const lastTimeUpdateRef = useRef(0) - - // 所有动态值存入 ref,tick 始终读取最新值,不依赖闭包 - const segIdxRef = useRef(0) - const segmentsRef = useRef(segments) - const timelineStartsData = useMemo(() => buildTimeline(segments), [segments]) - const totalDurationData = useMemo( - () => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0), - [segments], - ) - const timelineStartsRef = useRef(timelineStartsData) - const totalDurationRef = useRef(totalDurationData) - const isPlayingRef = useRef(false) - - segmentsRef.current = segments - timelineStartsRef.current = timelineStartsData - totalDurationRef.current = totalDurationData - - const canPlay = segments.length > 0 - - useEffect(() => { - segIdxRef.current = currentSegmentIndex - }, [currentSegmentIndex]) - - useEffect(() => { - isPlayingRef.current = isPlaying - }, [isPlaying]) - - const waitForReady = useCallback((video: HTMLVideoElement, timeout = 3000): Promise => { - if (video.readyState >= 3) return Promise.resolve() - return new Promise((resolve) => { - const onCanPlay = () => { - video.removeEventListener("canplay", onCanPlay) - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - video.removeEventListener("canplay", onCanPlay) - resolve() - }, timeout) - video.addEventListener("canplay", onCanPlay) - }) - }, []) - - const switchToSegment = useCallback( - async (index: number, seekToLocalTime?: number) => { - const segs = segmentsRef.current - const video = videoRefs.current[index] - if (!video || index >= segs.length) return - - const seg = segs[index] - const localTime = seekToLocalTime ?? seg.startTime - const oldIdx = segIdxRef.current - const oldVideo = videoRefs.current[oldIdx] - - if (oldVideo && oldVideo !== video) oldVideo.pause() - - if (!video.src && seg.videoUrl) { - video.src = seg.videoUrl - video.load() - } - - 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) - - await waitForReady(video) - }, - [waitForReady], - ) - - // 稳定的 tick 函数,空依赖,所有值从 ref 读取 - const tick = useCallback(() => { - const segs = segmentsRef.current - const idx = segIdxRef.current - const video = videoRefs.current[idx] - - if (!video || isSeekingRef.current) { - rafRef.current = requestAnimationFrame(tick) - return - } - - const seg = segs[idx] - if (!seg) return - - // 预加载下一个片段 - const nextIndex = idx + 1 - if (nextIndex < segs.length) { - const nextVideo = videoRefs.current[nextIndex] - if (nextVideo) { - const timeToEnd = seg.endTime - video.currentTime - if (timeToEnd <= 2 && nextVideo.readyState < 3) { - const nextSeg = segs[nextIndex] - if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.5) { - nextVideo.currentTime = nextSeg.startTime - } - } - } - } - - // 检测片段边界 - if (video.currentTime >= seg.endTime - 0.1) { - if (nextIndex < segs.length) { - const nextVideo = videoRefs.current[nextIndex] - const nextSeg = segs[nextIndex] - const accumulatedTime = - (timelineStartsRef.current[idx] || 0) + (seg.endTime - seg.startTime) - - if (nextVideo) { - if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.1) { - nextVideo.currentTime = nextSeg.startTime - } - // 先启动下一个视频(muted,可安全同时播放) - nextVideo - .play() - .catch((e) => console.warn("[useSegmentScheduler] next segment play failed:", e)) - } - - // 立即切换可见性 - segIdxRef.current = nextIndex - setCurrentSegmentIndex(nextIndex) - setCurrentTime(accumulatedTime) - lastTimeUpdateRef.current = 0 - setIsPlaying(true) - - // 下一帧暂停旧视频(让新视频先渲染,避免冻屏) - const oldVideo = video - requestAnimationFrame(() => { - oldVideo.pause() - }) - - rafRef.current = requestAnimationFrame(tick) - return - } else { - video.pause() - setIsPlaying(false) - setIsEnded(true) - setCurrentTime(totalDurationRef.current) - return - } - } - - const globalTime = (timelineStartsRef.current[idx] || 0) + (video.currentTime - seg.startTime) - const now = performance.now() - if (now - lastTimeUpdateRef.current >= 200) { - lastTimeUpdateRef.current = now - setCurrentTime(Math.max(0, Math.min(globalTime, totalDurationRef.current))) - } - - rafRef.current = requestAnimationFrame(tick) - }, []) - - const play = useCallback(async () => { - if (!canPlay) return - setIsEnded(false) - const idx = segIdxRef.current - const video = videoRefs.current[idx] - if (!video) return - - if (idx === 0 && video.readyState < 2) { - if (!video.src && segmentsRef.current[0]?.videoUrl) { - video.src = segmentsRef.current[0].videoUrl - video.load() - } - await waitForReady(video) - } - - // 播放前 seek 到片段起始时间,确保 progress 计算正确 - const seg = segmentsRef.current[idx] - if (seg && Math.abs(video.currentTime - seg.startTime) > 0.1) { - 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) - cancelAnimationFrame(rafRef.current) - rafRef.current = requestAnimationFrame(tick) - } catch (err) { - console.warn("[useSegmentScheduler] 播放失败:", err) - } - }, [canPlay, waitForReady, tick]) - - const pause = useCallback(() => { - const video = videoRefs.current[segIdxRef.current] - if (video) video.pause() - setIsPlaying(false) - cancelAnimationFrame(rafRef.current) - }, []) - - const togglePlayPause = useCallback(() => { - if (isPlayingRef.current) { - pause() - } else { - if (isEnded) { - setIsEnded(false) - lastTimeUpdateRef.current = 0 - const firstVideo = videoRefs.current[0] - if (firstVideo) { - videoRefs.current.forEach((v, i) => { - if (v && i !== 0) v.pause() - }) - firstVideo.currentTime = segmentsRef.current[0]?.startTime || 0 - segIdxRef.current = 0 - setCurrentSegmentIndex(0) - setCurrentTime(0) - firstVideo - .play() - .then(() => { - setIsPlaying(true) - cancelAnimationFrame(rafRef.current) - rafRef.current = requestAnimationFrame(tick) - }) - .catch((e) => console.warn("[useSegmentScheduler] restart failed:", e)) - } - } else { - play() - } - } - }, [isEnded, pause, play, tick]) - - const seekTo = useCallback( - async (time: number) => { - if (!canPlay) return - const clampedTime = Math.max(0, Math.min(time, totalDurationRef.current)) - const { index, localTime } = findSegmentAtTime(segmentsRef.current, clampedTime) - - isSeekingRef.current = true - cancelAnimationFrame(rafRef.current) - - if (index !== segIdxRef.current) { - await switchToSegment(index, localTime) - } else { - const video = videoRefs.current[index] - if (video) video.currentTime = localTime - } - - setCurrentTime(clampedTime) - setIsEnded(false) - lastTimeUpdateRef.current = 0 - - if (isPlayingRef.current) { - const video = videoRefs.current[index] - if (video) { - video.play().catch(() => {}) - } - rafRef.current = requestAnimationFrame(tick) - } - - setTimeout(() => { - isSeekingRef.current = false - }, 200) - }, - [canPlay, switchToSegment, tick], - ) - - useEffect(() => { - videoRefs.current = videoRefs.current.slice(0, segments.length) - while (videoRefs.current.length < segments.length) { - videoRefs.current.push(null) - } - }, [segments]) - - useEffect(() => { - return () => { - cancelAnimationFrame(rafRef.current) - } - }, []) - - useEffect(() => { - cancelAnimationFrame(rafRef.current) - segIdxRef.current = 0 - setIsPlaying(false) - setCurrentTime(0) - setCurrentSegmentIndex(0) - setIsEnded(false) - }, [segments]) - - const currentSegment = segments[currentSegmentIndex] || null - const segmentLocalTime = currentSegment - ? currentTime - (timelineStartsRef.current[currentSegmentIndex] || 0) + currentSegment.startTime - : 0 - - return { - isPlaying, - currentTime, - totalDuration: totalDurationData, - currentSegmentIndex, - segmentLocalTime, - isEnded, - canPlay, - play, - pause, - togglePlayPause, - seekTo, - videoRefs, - } -} - -export default useSegmentScheduler diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts index ba7baa2f6..3f0e234dc 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts @@ -1,9 +1,8 @@ -import { useEffect, useRef } from "react" +import { useEffect } from "react" import { useQuery } from "@tanstack/react-query" // #1894: 标题候选从文案库 scripts[].title 获取,不再调用废弃的 /api/titles import { getScripts } from "@/api/scripts" import type { TitleSettings } from "../../types" -import { useAiTitleGenerator } from "./useAiTitleGenerator" import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters" import { useDraftAutoSave } from "../useDraftAutoSave" @@ -16,7 +15,7 @@ interface UseStep4TitleProps { /** * Step 4 标题设置 Hook - * 封装 AI 标题生成、标题样式设置等逻辑 + * 封装标题样式设置等逻辑 */ export function useStep4Title({ titleSettings, @@ -34,26 +33,9 @@ export function useStep4Title({ staleTime: 30_000, }) - // AI 标题生成 - const { - aiTitleInput, - setAiTitleInput, - aiTitleGenerating, - aiTitleResults, - hasGeneratedTitles, - handleGenerateAiTitles, - handleSelectAiTitle, - handleRefreshAiTitles, - autoGenerateTitle, - } = useAiTitleGenerator({ titleSettings, onTitleSettingsChange }) - // 样式更新 const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange }) - // 追踪 AI 自动选择开关的上一次值 & 是否首次挂载 - const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect) - const isFirstMount = useRef(true) - /* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */ const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate) useEffect(() => { @@ -86,40 +68,12 @@ export function useStep4Title({ scheduleTitleSave, ]) - // 当 AI 自动选择开关打开时,自动生成/选择一个标题填入 - // 首次挂载时如果开关已经是 true 且无标题,也需要触发 - useEffect(() => { - if (isFirstMount.current) { - isFirstMount.current = false - if (titleSettings.aiAutoSelect && !titleSettings.title) { - autoGenerateTitle() - } - prevAiAutoSelect.current = titleSettings.aiAutoSelect - return - } - if (titleSettings.aiAutoSelect && !prevAiAutoSelect.current && !titleSettings.title) { - autoGenerateTitle() - } - prevAiAutoSelect.current = titleSettings.aiAutoSelect - }, [titleSettings.aiAutoSelect, titleSettings.title, autoGenerateTitle]) - return { // 数据 userTitles, titleSettings, - // AI 标题状态 - aiTitleInput, - setAiTitleInput, - aiTitleGenerating, - aiTitleResults, - hasGeneratedTitles, activePreset: styleUpdaters.activePreset, titlePresets: styleUpdaters.titlePresets, - // AI 标题操作 - handleGenerateAiTitles, - handleSelectAiTitle, - handleRefreshAiTitles, - autoGenerateTitle, // 标题设置操作 updateTitle: styleUpdaters.updateTitle, toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect, diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts deleted file mode 100644 index b765259a6..000000000 --- a/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { useState, useCallback } from "react" -import { message } from "antd" -import { AI_TITLE_TEMPLATES } from "../../constants" -import type { TitleSettings } from "../../types" - -export interface AiTitleItem { - title: string - highlight: string - style: "catchy" | "emotional" | "informative" -} - -interface UseAiTitleGeneratorOptions { - titleSettings: TitleSettings - onTitleSettingsChange: (settings: TitleSettings) => void -} - -/** - * AI 标题生成 Hook - * 封装 AI 标题生成、刷新、选择等逻辑 - */ -export function useAiTitleGenerator({ - titleSettings, - onTitleSettingsChange, -}: UseAiTitleGeneratorOptions) { - const [aiTitleInput, setAiTitleInput] = useState("") - const [aiTitleGenerating, setAiTitleGenerating] = useState(false) - const [aiTitleResults, setAiTitleResults] = useState([]) - const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false) - - const extractTopic = (text: string): string => { - const keywords = text - .replace(/[,。!?、,.!?]/g, " ") - .split(/\s+/) - .filter(Boolean) - if (keywords.length === 0) return "这个话题" - return keywords.slice(0, 3).join("") - } - - const generateTitlesFromTopic = (topic: string): AiTitleItem[] => { - const results: AiTitleItem[] = [] - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" } - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] - const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) - shuffled.forEach((tpl) => { - results.push({ - title: tpl.replace(/\{topic\}/g, topic), - highlight: highlights[style], - style, - }) - }) - }) - results.sort(() => Math.random() - 0.5) - return results - } - - const handleGenerateAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) { - message.warning("请先输入视频描述或关键词") - return - } - setAiTitleGenerating(true) - setHasGeneratedTitles(true) - await new Promise((resolve) => setTimeout(resolve, 1200)) - const topic = extractTopic(aiTitleInput) - setAiTitleResults(generateTitlesFromTopic(topic)) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - /** - * 自动生成标题(供 AI 自动选择开关使用) - * 如果已有生成结果,直接从中选一个;否则用默认关键词生成 - */ - const autoGenerateTitle = useCallback((): string => { - if (aiTitleResults.length > 0) { - const picked = aiTitleResults[Math.floor(Math.random() * aiTitleResults.length)] - if (!picked) return "" - onTitleSettingsChange({ ...titleSettings, title: picked.title }) - return picked.title - } - // 没有已有结果,用默认关键词生成 - const results = generateTitlesFromTopic("短视频") - setAiTitleResults(results) - setHasGeneratedTitles(true) - const picked = results[Math.floor(Math.random() * results.length)] - if (!picked) return "" - onTitleSettingsChange({ ...titleSettings, title: picked.title }) - return picked.title - }, [aiTitleResults, titleSettings, onTitleSettingsChange]) - - const handleSelectAiTitle = useCallback( - (title: string) => { - onTitleSettingsChange({ ...titleSettings, title }) - message.success("已选用此标题") - }, - [titleSettings, onTitleSettingsChange], - ) - - const handleRefreshAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) return - setAiTitleGenerating(true) - await new Promise((resolve) => setTimeout(resolve, 800)) - const topic = extractTopic(aiTitleInput) - setAiTitleResults(generateTitlesFromTopic(topic)) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - return { - aiTitleInput, - setAiTitleInput, - aiTitleGenerating, - aiTitleResults, - hasGeneratedTitles, - handleGenerateAiTitles, - handleSelectAiTitle, - handleRefreshAiTitles, - autoGenerateTitle, - } -} diff --git a/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts b/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts deleted file mode 100644 index 5eb6adc19..000000000 --- a/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts +++ /dev/null @@ -1,165 +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>(new Map()) - // 在途请求 AbortController - const abortRef = useRef(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 { - 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 diff --git a/apps/web/src/pages/generate/types.ts b/apps/web/src/pages/generate/types.ts index 3b2669bd0..f44396b92 100644 --- a/apps/web/src/pages/generate/types.ts +++ b/apps/web/src/pages/generate/types.ts @@ -81,14 +81,6 @@ export interface SmartMatchResult { reasons: string[] } -/* ── AI 标题结果 ── */ -export interface AiTitleResult { - title: string - style: string - styleLabel: string - highlights: string[] -} - /* ── 配音推荐结果 ── */ export interface VoiceRecommendation { voiceId: string diff --git a/apps/web/src/test/pages/generate/smoke.test.tsx b/apps/web/src/test/pages/generate/smoke.test.tsx index e9d79f898..04d830150 100755 --- a/apps/web/src/test/pages/generate/smoke.test.tsx +++ b/apps/web/src/test/pages/generate/smoke.test.tsx @@ -19,7 +19,6 @@ import "@/pages/generate/components/Step2MaterialSelect" import "@/pages/generate/components/Step4TitleSettings" import "@/pages/generate/components/Step5VoiceSelect" import "@/pages/generate/components/Step3VoiceWithMode" -import "@/pages/generate/components/CanvasPreviewGrid" import "@/pages/generate/components/BatchGenerationGrid" import "@/pages/generate/components/PreviewCountModal" import "@/pages/generate/components/GenerateStepContent" @@ -33,8 +32,6 @@ import "@/pages/generate/components/material/MaterialModeTabs" import "@/pages/generate/components/material/ManualMaterialList" import "@/pages/generate/components/material/SmartMatchInput" import "@/pages/generate/components/material/SmartMatchResults" -import "@/pages/generate/components/title/AiTitleGenerator" -import "@/pages/generate/components/title/AiTitleCard" import "@/pages/generate/components/title/TitleStylePanel" import "@/pages/generate/components/title/TitlePresetsGrid" import "@/pages/generate/utils/formatDuration" @@ -48,9 +45,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" import "@/pages/generate/hooks/useGenerateFormState" import "@/pages/generate/hooks/useGenerateFormState/useVoiceState" diff --git a/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx b/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx index a8dfcf8ec..553cb4222 100644 --- a/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx +++ b/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx @@ -1,18 +1,15 @@ /** - * Step4+5 merged preview smoke test - * 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动 + * Step4+5 preview smoke test (legacy — preview components removed) + * 保留最小测试确保 vitest related 模式仍能匹配 generate 目录改动 */ import { describe, it, expect } from "vitest" -import "@/pages/generate/hooks/usePreviewAssets" -import "@/pages/generate/hooks/useSegmentScheduler" -import "@/pages/generate/components/FrontendPreviewPlayer" import "@/pages/generate/hooks/useStepNavigation" import "@/pages/generate/components/GenerateStepContent" import "@/pages/generate/GeneratePage" -describe("Merged Step4+5 preview module smoke test", () => { - it("should load all merged step4+5 preview modules", () => { +describe("Generate page smoke test", () => { + it("should load generate modules", () => { expect(true).toBe(true) }) }) -- 2.54.0 From 355e5117a09e07620687a7154ccbfbb4d87d5fdd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 23 Sep 2026 21:55:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore(generate):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=B7=B2=E5=BA=9F=E5=BC=83=E7=BB=84=E4=BB=B6=E7=9A=84=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=EF=BC=8C=E6=B8=85=E7=90=86=E5=AD=A4=E5=84=BF=20CSS=20?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 CanvasPreviewGrid.audio.test.tsx / FrontendPreviewPlayer.audio.test.tsx / step5-preview.smoke.test.tsx(对应组件已删除) - 清理 generate.css 中无意义的空 .xx-title-sidebar 规则及其响应式块 - tsc/eslint 均零错误 --- .../components/Step4TitleSettings.tsx | 2 +- apps/web/src/pages/generate/generate.css | 12 - .../generate/CanvasPreviewGrid.audio.test.tsx | 176 --------------- .../FrontendPreviewPlayer.audio.test.tsx | 213 ------------------ .../generate/step5-preview.smoke.test.tsx | 15 -- 5 files changed, 1 insertion(+), 417 deletions(-) delete mode 100644 apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx delete mode 100644 apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx delete mode 100644 apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index fef2ca907..89caf44f1 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -90,7 +90,7 @@ const Step4TitleSettings: React.FC = (props) => { ) return ( -
+

📝 选择标题

{!isBatch ? ( diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 95c5430c8..bf1325830 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -2880,11 +2880,6 @@ Issue #1677 多视频批量生成 ================================================================ */ -/* ── 标题设置区域(全宽区块,不再受侧边栏约束) ── */ -.xx-title-sidebar { - /* no max-height / overflow constraints */ -} - /* ── 数量选择弹窗 ── */ .xx-modal-mask { position: fixed; @@ -3224,13 +3219,6 @@ border-radius: 4px; } -/* ── 响应式:窄屏标题区域 ── */ -@media (max-width: 960px) { - .xx-title-sidebar { - max-height: none; - } -} - .xx-batch-titles { display: flex; flex-direction: column; diff --git a/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx b/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx deleted file mode 100644 index b1d798ab9..000000000 --- a/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx +++ /dev/null @@ -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>) - -vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({ - default: (props: Record) => { - 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 ( -
- {props.voiceAudioUrl ? "has-voice" : "no-voice"} - {active == null ? "none" : String(active)} - - -
- ) - }, -})) - -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 = {}) { - playerCalls.length = 0 - return render( - {}} - {...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 为 null(TTS 合成中)→ 传 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 变为 1(0 基,其他实例自动暂停)", () => { - 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) - }) -}) diff --git a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx b/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx deleted file mode 100644 index 1379c33fd..000000000 --- a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -/** - * FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750) - * - * useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑: - * - 有配音时 video 保持 muted(素材原声不与配音混音) - * - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声) - * - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换 - * - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause - * - 点击播放/暂停时上报播放权(onPlayTokenChange) - * - #1750:serverClips 是唯一片段来源,缺失时不渲染任何 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() - expect(videos()).toHaveLength(2) - videos().forEach((v) => expect(v.muted).toBe(true)) - }) - - it("无配音时 video 不 muted(素材原声兜底)", () => { - render() - videos().forEach((v) => expect(v.muted).toBe(false)) - }) - - it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => { - render() - 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( - {}} - compact - />, - ) - expect(mocks.pause).not.toHaveBeenCalled() - - // 播放权切给实例 2(0 基 token) - rerender( - {}} - compact - />, - ) - expect(mocks.pause).toHaveBeenCalledTimes(1) - - // token 切回自己:不重复暂停 - rerender( - {}} - compact - />, - ) - expect(mocks.pause).toHaveBeenCalledTimes(1) - }) - - it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => { - // mocks.isPlaying = false(beforeEach 重置) - const { rerender } = render( - , - ) - rerender() - expect(mocks.pause).not.toHaveBeenCalled() - }) - - it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => { - const onToken = vi.fn() - render( - , - ) - // 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 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( -
- - - -
, - ) - expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3) - }) - - it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => { - render() - expect(videos()).toHaveLength(0) - expect(screen.getByText("暂无可播放素材")).toBeInTheDocument() - }) -}) diff --git a/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx b/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx deleted file mode 100644 index 553cb4222..000000000 --- a/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Step4+5 preview smoke test (legacy — preview components removed) - * 保留最小测试确保 vitest related 模式仍能匹配 generate 目录改动 - */ -import { describe, it, expect } from "vitest" - -import "@/pages/generate/hooks/useStepNavigation" -import "@/pages/generate/components/GenerateStepContent" -import "@/pages/generate/GeneratePage" - -describe("Generate page smoke test", () => { - it("should load generate modules", () => { - expect(true).toBe(true) - }) -}) -- 2.54.0