From 3c296b68110d9bd8008c0f3476bd16d77a01a302 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 24 Aug 2026 22:28:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20previewTaskId=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20+=20=E9=85=8D=E9=9F=B3/BGM=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E4=BC=A0=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1. 刷新页面后 previewTaskId 丢失,confirmGeneration 无法触发 2. createPreview 和 createGenerationTask 均未传 voice_library_id, 导致预览和最终成片都没有用户选择的配音 3. BGM 配置未传给后端,预览和成片 BGM 与模板设置不一致 改动: - 新增 usePersistedState hook:基于 localStorage 的 state, key 按 editPlanId/templateId 区分,localStorage 不可用时降级为 useState - previewTaskId 和 storedSourceEditPlanId 使用 usePersistedState, 刷新页面后自动恢复;生成成功后清除 - useStep6Cover/createPreview 请求体新增 voice_library_id 和 bgm_config - useGenerateVideo/createGenerationTask fallback 请求体新增 voice_library_id(或 voice_ids)和 bgm_config - bgmConfig 从当前模板 bgm_config 派生,受 bgm 开关控制 - 生成成功后回调清除持久化状态 - CreateGenerationTaskRequest 类型补充 voice_library_id 和 bgm_config --- apps/web/src/api/tasks/types.ts | 8 +++ apps/web/src/pages/generate/GeneratePage.tsx | 16 +++++ .../components/GenerateStepContent.tsx | 11 ++++ .../components/Step6CoverSettings.tsx | 15 +++++ .../generate/hooks/generate-video/types.ts | 7 +++ .../hooks/useGenerateFormState/index.ts | 23 ++++++- .../pages/generate/hooks/useGenerateVideo.ts | 29 +++++++-- .../pages/generate/hooks/usePersistedState.ts | 63 +++++++++++++++++++ .../src/pages/generate/hooks/useStep6Cover.ts | 30 +++++++++ 9 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/pages/generate/hooks/usePersistedState.ts diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 3dc06469d..42e41995b 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -84,6 +84,14 @@ export interface CreateGenerationTaskRequest { } /** 关联的草稿 ID(编辑流程数据链路用) */ source_edit_plan_id?: string + /** 配音素材库 ID(用户上传的音频或 AI 配音素材) */ + voice_library_id?: string + /** 自定义 BGM 配置,覆盖模板 BGM 设置 */ + bgm_config?: { + enabled: boolean + preset_id?: string + volume?: number + } } /** 单个生成任务详情(对齐后端 GenerationTaskResponse) */ diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 6623f42f2..1e387083f 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -121,6 +121,15 @@ const GeneratePage: React.FC = () => { [userTemplates, selectedTemplate], ) + /* ── BGM 配置:从当前模板读取,受 bgm 开关控制 ── */ + const bgmConfig = useMemo( + () => ({ + enabled: bgm, + music_id: currentTemplate?.bgm_config?.music_id || "", + }), + [bgm, currentTemplate], + ) + /* ── 视频总时长计算(用于配音时长校验) ── */ const totalVideoDuration = useMemo(() => { // 优先用素材精确时长;素材未加载时用模板 segments 的 duration_max 之和估算 @@ -183,6 +192,11 @@ const GeneratePage: React.FC = () => { generateCount, sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId, previewTaskId, + bgmConfig, + onGenerationSuccess: () => { + setPreviewTaskId(null) + setStoredSourceEditPlanId(null) + }, }) /* ================================================================ @@ -227,6 +241,8 @@ const GeneratePage: React.FC = () => { titlePresets={styleUpdaters.titlePresets} onPreviewTaskCreated={setPreviewTaskId} onSourceEditPlanIdExtracted={setStoredSourceEditPlanId} + bgm={bgm} + bgmConfig={bgmConfig} coverSettings={coverSettings} onCoverSettingsChange={setCoverSettings} duration={duration} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 6fd23cf4e..de363c464 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -80,6 +80,10 @@ export interface GenerateStepContentProps { onPreviewTaskCreated?: (taskId: string) => void /** 从预览响应中提取到 source_edit_plan_id 时的回调 */ onSourceEditPlanIdExtracted?: (planId: string) => void + /** BGM 开关 */ + bgm: boolean + /** BGM 配置(来自模板) */ + bgmConfig?: { enabled: boolean; music_id?: string } } export const GenerateStepContent: React.FC = (props) => { @@ -127,6 +131,8 @@ export const GenerateStepContent: React.FC = (props) = presetVoices, onPreviewTaskCreated, onSourceEditPlanIdExtracted, + bgm, + bgmConfig, } = props /* 当前模板的 segments,传给 Step2 构建 clips */ @@ -198,6 +204,11 @@ export const GenerateStepContent: React.FC = (props) = titleSettings={titleSettings} onPreviewTaskCreated={onPreviewTaskCreated} onSourceEditPlanIdExtracted={onSourceEditPlanIdExtracted} + voiceMode={voiceMode} + selectedVoice={selectedVoice} + selectedClonedVoice={selectedClonedVoice} + bgm={bgm} + bgmConfig={bgmConfig} /> ) case 7: diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index bd72d9c95..1fc6f0572 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -20,6 +20,16 @@ interface Step6CoverSettingsProps { onPreviewTaskCreated?: (taskId: string) => void /** 从预览响应中提取到 source_edit_plan_id 时的回调 */ onSourceEditPlanIdExtracted?: (planId: string) => void + /** 配音模式 */ + voiceMode?: "preset" | "custom" | "clone" + /** 选中的配音素材 ID */ + selectedVoice?: string + /** 选中的克隆音色 ID */ + selectedClonedVoice?: string + /** BGM 开关 */ + bgm?: boolean + /** BGM 配置 */ + bgmConfig?: { enabled: boolean; music_id?: string } } const Step6CoverSettings: React.FC = (props) => { @@ -49,6 +59,11 @@ const Step6CoverSettings: React.FC = (props) => { titleSettings: props.titleSettings, onPreviewTaskCreated: props.onPreviewTaskCreated, onSourceEditPlanIdExtracted: props.onSourceEditPlanIdExtracted, + voiceMode: props.voiceMode, + selectedVoice: props.selectedVoice, + selectedClonedVoice: props.selectedClonedVoice, + bgm: props.bgm, + bgmConfig: props.bgmConfig, }) const handleAutoGenerate = () => { diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index a6352d553..c114cd2e1 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -23,6 +23,13 @@ export interface UseGenerateVideoProps { sourceEditPlanId?: string | null /** 预览任务 ID(由 useStep6Cover 创建后写入,供 confirmGeneration 复用预览产物) */ previewTaskId?: string | null + /** BGM 配置(来自模板 bgm_config,受 bgm 开关控制) */ + bgmConfig?: { + enabled: boolean + music_id?: string + } + /** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */ + onGenerationSuccess?: () => void } /** 生成阶段 */ diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index eba9b3870..87983e211 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -14,6 +14,7 @@ import { useTemplateSelection } from "./useTemplateSelection" import { useTitleCoverSync } from "./useTitleCoverSync" import { useVoiceState } from "./useVoiceState" import { usePlanConfigLoader } from "./usePlanConfigLoader" +import { usePersistedState } from "../usePersistedState" const DEFAULT_TITLE_SETTINGS: TitleSettings = { aiAutoSelect: false, @@ -169,10 +170,28 @@ export const useGenerateFormState = (): GenerateFormState => { const [previewModalOpen, setPreviewModalOpen] = useState(false) /* ── 预览任务 ID(useStep6Cover 创建预览时写入,useGenerateVideo 复用) ── */ - const [previewTaskId, setPreviewTaskId] = useState(null) + // 持久化到 localStorage,key 按 editPlanId/templateId 区分,刷新页面后可恢复 + const previewStorageKey = editPlanId + ? `preview_task_id_${editPlanId}` + : selectedTemplate + ? `preview_task_id_tpl_${selectedTemplate}` + : null + const [previewTaskId, setPreviewTaskId] = usePersistedState( + previewStorageKey, + null, + ) /* ── 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) ── */ - const [storedSourceEditPlanId, setStoredSourceEditPlanId] = useState(null) + // 持久化到 localStorage,刷新页面后 fallback 路径仍能正确传递 source_edit_plan_id + const planIdStorageKey = editPlanId + ? `source_edit_plan_id_${editPlanId}` + : selectedTemplate + ? `source_edit_plan_id_tpl_${selectedTemplate}` + : null + const [storedSourceEditPlanId, setStoredSourceEditPlanId] = usePersistedState( + planIdStorageKey, + null, + ) /* ── 从 URL / 编辑计划加载配置 ── */ usePlanConfigLoader({ diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index e8f1d14dc..d9eb41b0a 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -14,7 +14,7 @@ import { validateGenerateInputs } from "./generate-video/buildPayload" import { extractBackendError, translateError } from "./generate-video/errorUtils" export function useGenerateVideo(props: UseGenerateVideoProps) { - const { selectedTemplate } = props + const { selectedTemplate, onGenerationSuccess } = props /* ── 生成状态 ── */ const [generating, setGenerating] = useState(false) @@ -24,11 +24,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const [generatedVideos, setGeneratedVideos] = useState([]) const handleProgress = useCallback((p: number) => setProgress(p), []) - const handleComplete = useCallback((videos: unknown[]) => { - setGenerating(false) - setGenerated(true) - setGeneratedVideos(videos as GeneratedVideo[]) - }, []) + const handleComplete = useCallback( + (videos: unknown[]) => { + setGenerating(false) + setGenerated(true) + setGeneratedVideos(videos as GeneratedVideo[]) + // 生成成功后清除持久化的预览状态,避免下次进入复用旧任务 + onGenerationSuccess?.() + }, + [onGenerationSuccess], + ) const handleFailed = useCallback((errorMsg: string) => { setGenerating(false) setGenerateError(errorMsg) @@ -124,6 +129,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { } } + // 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice(配音素材库 asset ID) + const voiceLibraryId = + props.voiceMode === "clone" ? props.selectedClonedVoice || "" : props.selectedVoice || "" + // Fallback 路径:没有预览任务或 confirmGeneration 失败,创建新的生成任务 if (!taskId) { const taskResp = await createGenerationTask({ @@ -135,6 +144,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { custom_title: props.titleSettings?.title || "", duration: props.duration || undefined, video_ratio: props.videoRatio, + // 配音:优先用 voice_library_id(配音素材库 asset),兜底 voice_ids + ...(voiceLibraryId ? { voice_library_id: voiceLibraryId } : {}), + ...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}), + // BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM + bgm_config: { + enabled: props.bgm !== false, + ...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}), + }, ...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}), ...(props.titleSettings?.title ? { diff --git a/apps/web/src/pages/generate/hooks/usePersistedState.ts b/apps/web/src/pages/generate/hooks/usePersistedState.ts new file mode 100644 index 000000000..6f6524a72 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/usePersistedState.ts @@ -0,0 +1,63 @@ +import { useState, useEffect, useCallback, useRef } from "react" + +/** + * 持久化到 localStorage 的 state hook + * + * 用于在页面刷新后恢复 previewTaskId / sourceEditPlanId 等关键状态。 + * 当 localStorage 不可用(SSR、隐私模式等)时自动降级为普通 useState。 + * 当 key 变化时(例如切换模板),自动从新 key 重新读取并更新 state。 + */ +export function usePersistedState( + key: string | null | undefined, + defaultValue: T, +): [T, (value: T | ((prev: T) => T)) => void] { + const storageKey = key ? `xiaoxia_${key}` : null + // defaultValue 用 ref 持有,避免作为 useEffect 依赖导致频繁重跑 + const defaultValueRef = useRef(defaultValue) + defaultValueRef.current = defaultValue + + const readFromStorage = useCallback((k: string | null): T => { + if (!k) return defaultValueRef.current + try { + const stored = localStorage.getItem(k) + if (stored !== null) { + return JSON.parse(stored) as T + } + } catch (e) { + // localStorage 不可用或 JSON 解析失败,使用默认值 + console.warn("[usePersistedState] 读取 localStorage 失败:", e) + } + return defaultValueRef.current + }, []) + + const [state, setState] = useState(() => readFromStorage(storageKey)) + + // key 变化时(如切换模板/草稿),从新 key 重新读取,避免状态与存储不同步 + useEffect(() => { + setState(readFromStorage(storageKey)) + }, [storageKey, readFromStorage]) + + const setPersistedState = useCallback( + (value: T | ((prev: T) => T)) => { + setState((prev) => { + const nextValue = typeof value === "function" ? (value as (prev: T) => T)(prev) : value + if (storageKey) { + try { + if (nextValue === null || nextValue === undefined || nextValue === "") { + localStorage.removeItem(storageKey) + } else { + localStorage.setItem(storageKey, JSON.stringify(nextValue)) + } + } catch (e) { + // localStorage 写入失败(存储空间满/隐私模式),静默忽略 + console.warn("[usePersistedState] 写入 localStorage 失败:", e) + } + } + return nextValue + }) + }, + [storageKey], + ) + + return [state, setPersistedState] +} diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 1e601252f..26fdd1241 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -30,6 +30,16 @@ interface UseStep6CoverProps { onPreviewTaskCreated?: (taskId: string) => void /** 从预览响应中提取到 source_edit_plan_id 时的回调 */ onSourceEditPlanIdExtracted?: (planId: string) => void + /** 配音模式 */ + voiceMode?: "preset" | "custom" | "clone" + /** 选中的配音素材 ID(配音素材库 asset ID) */ + selectedVoice?: string + /** 选中的克隆音色 ID */ + selectedClonedVoice?: string + /** BGM 开关 */ + bgm?: boolean + /** BGM 配置(来自模板) */ + bgmConfig?: { enabled: boolean; music_id?: string } } export function useStep6Cover({ @@ -41,6 +51,11 @@ export function useStep6Cover({ titleSettings, onPreviewTaskCreated, onSourceEditPlanIdExtracted, + voiceMode, + selectedVoice, + selectedClonedVoice, + bgm, + bgmConfig, }: UseStep6CoverProps) { const [generating, setGenerating] = useState(false) // 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性 @@ -161,10 +176,20 @@ export function useStep6Cover({ try { // 记录当前参数指纹,用于任务完成时校验一致性(防竞态) previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings }) + // 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice + const previewVoiceLibraryId = + voiceMode === "clone" ? selectedClonedVoice || "" : selectedVoice || "" const previewResp = await createPreview({ template_id: selectedTemplate, asset_ids: assetIds, duration: duration || 30, + // 配音:voice_library_id 是配音素材库 asset ID(用户上传的音频或 AI 配音) + ...(previewVoiceLibraryId ? { voice_library_id: previewVoiceLibraryId } : {}), + // BGM 配置:受 bgm 开关控制 + bgm_config: { + enabled: bgm !== false, + ...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}), + }, ...(titleSettings?.title ? { title_config: { @@ -303,6 +328,11 @@ export function useStep6Cover({ titleSettings, onPreviewTaskCreated, onSourceEditPlanIdExtracted, + voiceMode, + selectedVoice, + selectedClonedVoice, + bgm, + bgmConfig, ]) // ── 模板操作方法 ── -- 2.54.0