From f5100b7b5d89cc103f86fcdc7ac5b9ae516e98f5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 25 Aug 2026 16:08:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E7=9A=84=E9=85=8D?= =?UTF-8?q?=E9=9F=B3=E7=B4=A0=E6=9D=90=E7=9B=B4=E6=8E=A5=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=20file=5Furl=EF=BC=8C=E4=B8=8D=E8=B0=83=20TTS=20=E5=90=88?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配音预览逻辑优化:优先检查 selectedVoice 是否为已上传素材 - 如果是上传素材,直接使用其 file_url 作为预览音频 - 仅对预设音色或克隆音色调用 previewTts 接口合成 - 切换配音或标题时自动重新生成预览音频 解决用户反馈:Step3 选择上传的配音素材后预览无声音的问题 --- apps/api/app/api/routes/generation_preview.py | 47 +- apps/api/app/api/routes/generation_tasks.py | 158 +- apps/api/app/schemas/generation_task.py | 6 +- apps/api/app/services/edit_plan_service.py | 3 - apps/web/src/api/generation/types.ts | 6 - apps/web/src/api/tasks/types.ts | 8 - apps/web/src/pages/generate/GeneratePage.tsx | 168 +- .../components/FrontendPreviewPlayer.tsx | 220 +- .../components/GenerateStepContent.tsx | 19 - .../generate/components/PreviewVideoPanel.tsx | 257 +- .../components/Step5GeneratePreview.tsx | 15 +- .../components/Step6CoverSettings.tsx | 21 - apps/web/src/pages/generate/generate.css | 1 - .../generate/hooks/generate-video/types.ts | 9 - .../hooks/useGenerateFormState/index.ts | 37 - .../pages/generate/hooks/useGenerateVideo.ts | 70 +- .../pages/generate/hooks/usePersistedState.ts | 63 - .../pages/generate/hooks/useServerPreview.ts | 294 - .../src/pages/generate/hooks/useStep6Cover.ts | 53 +- .../pages/generate/hooks/useStepNavigation.ts | 10 +- .../generate/utils/calculateResolution.ts | 46 - .../worker/video_processing/render_adapter.py | 8 +- apps/worker/worker_app/tasks/generation.py | 1142 +- coverage.xml | 19875 ---------------- .../generation_task_repository.py | 3 + packages/application/generation_tasks.py | 2 + packages/domain/generation_task.py | 9 +- tests/integration/test_four_mode_rendering.py | 234 + tests/integration/test_full_pipeline.py | 189 + tests/unit/test_1280_preview_speedup.py | 8 + .../unit/test_1294_preview_voice_injection.py | 183 + tests/unit/test_bool_config_defense.py | 130 + tests/unit/test_confirm_generation.py | 11 +- .../test_create_reuse_preview_fallback.py | 409 - tests/unit/test_empty_plan_id_marks_failed.py | 32 - .../unit/test_generation_p3_optimizations.py | 389 + tests/unit/test_generation_preview.py | 5 +- tests/unit/test_generation_worker_fixes.py | 228 + tests/unit/test_oneclick_gen_p0_fixes.py | 440 + tests/unit/test_preview_title_config.py | 190 + tests/unit/test_preview_title_render.py | 256 + tests/unit/test_preview_voice_ids_fallback.py | 151 + .../unit/test_race_condition_fallback_plan.py | 98 - tests/unit/test_render_subtitles_pure.py | 2 +- tests/unit/test_temp_dir_cleanup_race.py | 56 - tests/unit/test_templates_editor_api.py | 27 +- tests/unit/test_three_bugs_fix.py | 401 - .../unit/test_worker_cover_meta_and_status.py | 9 +- ...test_worker_generate_video_task_binding.py | 49 +- 49 files changed, 3853 insertions(+), 22194 deletions(-) delete mode 100644 apps/web/src/pages/generate/hooks/usePersistedState.ts delete mode 100644 apps/web/src/pages/generate/hooks/useServerPreview.ts delete mode 100644 apps/web/src/pages/generate/utils/calculateResolution.ts delete mode 100644 coverage.xml create mode 100644 tests/integration/test_four_mode_rendering.py create mode 100644 tests/integration/test_full_pipeline.py create mode 100644 tests/unit/test_bool_config_defense.py delete mode 100644 tests/unit/test_create_reuse_preview_fallback.py delete mode 100644 tests/unit/test_empty_plan_id_marks_failed.py create mode 100644 tests/unit/test_generation_p3_optimizations.py create mode 100644 tests/unit/test_generation_worker_fixes.py create mode 100644 tests/unit/test_preview_title_config.py create mode 100644 tests/unit/test_preview_title_render.py create mode 100644 tests/unit/test_preview_voice_ids_fallback.py delete mode 100644 tests/unit/test_race_condition_fallback_plan.py delete mode 100644 tests/unit/test_temp_dir_cleanup_race.py delete mode 100644 tests/unit/test_three_bugs_fix.py diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index 03be31208..a7eda77a0 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json import logging from app.auth import AuthenticatedUser, get_current_user @@ -264,38 +265,22 @@ def create_preview_generation_task( if not video_ratio and request.template_id: video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id) - # 根据 video_ratio 计算输出分辨率(默认竖屏 1080x1920) - output_width, output_height = 1080, 1920 - if video_ratio: - parts = video_ratio.split(":") - if len(parts) == 2: - try: - w, h = int(parts[0]), int(parts[1]) - base = 1920 - if w < h: - # 竖屏 - output_width = round(base * w / h) - output_height = base - else: - # 横屏 - output_width = base - output_height = round(base * h / w) - # 对齐到偶数 - output_width = output_width - output_width % 2 - output_height = output_height - output_height % 2 - except (ValueError, ZeroDivisionError): - output_width, output_height = 1080, 1920 - resolution = f"{output_width}x{output_height}" - - logger.info( - "[预览生成] 分辨率: video_ratio=%s → %s (%dx%d)", - video_ratio, resolution, output_width, output_height, - ) - # 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数) strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id) + # 处理标题配置:如果有标题文本,序列化到 custom_title 字段传递给 worker title_config = request.title_config or {} + title_text = (title_config.get("text") or "").strip() + custom_title_value = "" + if title_text: + # 将标题文本和样式配置序列化为 JSON 存入 custom_title + # Worker 端会解析 JSON 获取完整标题配置 + custom_title_value = json.dumps(title_config, ensure_ascii=False) + logger.info( + "[预览生成] 标题配置: text=%s, config_keys=%s", + title_text[:30], + list(title_config.keys()), + ) use_case = CreateGenerationTaskUseCase(generation_task_repository) @@ -315,14 +300,12 @@ def create_preview_generation_task( asset_select_mode="", batch_id="", video_title=request.video_title, - resolution=resolution, + resolution="", bgm_config=request.bgm_config or {}, auto_retry_enabled=False, auto_retry_max=0, is_preview=True, - title_config=title_config, - output_width=output_width, - output_height=output_height, + custom_title=custom_title_value, ) ) except ValueError as e: diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index d6afc7219..450d43f2f 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -70,6 +70,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse: output_width=getattr(task, "output_width", 1280), output_height=getattr(task, "output_height", 720), cover_url=getattr(task, "cover_url", ""), + custom_title=getattr(task, "custom_title", ""), title_config=getattr(task, "title_config", {}) or {}, logs=getattr(task, "logs", "[]"), status=task.status, @@ -293,89 +294,6 @@ def create_generation_task( detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。", ) - # ── 兜底复用预览产物 ── - # 前端刷新后 previewTaskId 丢失,降级调 create 接口时, - # 如果同一 edit_plan 有已完成的预览任务,直接复用(秒出)。 - if request.source_edit_plan_id and not request.is_preview: - try: - from packages.adapters.sqlalchemy_impl.models import ( - GenerationTaskModel, - ) - - _preview_model = ( - db.query(GenerationTaskModel) - .filter( - GenerationTaskModel.source_edit_plan_id == request.source_edit_plan_id, - GenerationTaskModel.is_preview.is_(True), - GenerationTaskModel.status == "completed", - GenerationTaskModel.created_by_user_id == authenticated_user.user.id, - ) - .order_by(GenerationTaskModel.created_at.desc()) - .first() - ) - if _preview_model is not None: - # 校验分辨率一致性(与 confirm 端点逻辑相同) - req_w = request.output_width or 0 - req_h = request.output_height or 0 - src_w = getattr(_preview_model, "output_width", 0) or 0 - src_h = getattr(_preview_model, "output_height", 0) or 0 - resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h) - - if resolution_match: - from packages.adapters.sqlalchemy_impl.generation_task_repository import ( - _to_domain, - ) - - preview_task = _to_domain(_preview_model) - - # 如果传了标题,更新 title_config - fallback_title_config = None - if request.title_config and request.title_config.get("text", "").strip(): - fallback_title_config = dict(preview_task.title_config or {}) - fallback_title_config.update(request.title_config) - - preview_task.mark_confirmed( - cover_url=request.cover_url or preview_task.cover_url, - output_width=request.output_width or preview_task.output_width, - output_height=request.output_height or preview_task.output_height, - title_config=fallback_title_config, - ) - generation_task_repository.update(preview_task) - - # 同步标题到 EditPlan.config - if fallback_title_config: - _writeback_edit_plan_config( - plan_id=request.source_edit_plan_id, - task_id=preview_task.id, - title_config=fallback_title_config, - db=db, - ) - - logger.info( - "[生成任务] 兜底复用预览产物: preview_task_id=%s, plan_id=%s", - preview_task.id, - request.source_edit_plan_id, - ) - return BatchGenerationTaskResponse( - items=[_to_generation_task_response(preview_task)], - total=1, - ) - else: - logger.info( - "[生成任务] 兜底复用跳过(分辨率不一致): plan_id=%s, src=%sx%s, req=%sx%s", - request.source_edit_plan_id, - src_w, - src_h, - req_w, - req_h, - ) - except Exception: - logger.warning( - "[生成任务] 兜底复用预览产物异常(不影响主流程): plan_id=%s", - request.source_edit_plan_id, - exc_info=True, - ) - use_case = CreateGenerationTaskUseCase(generation_task_repository) count = request.count created_tasks = [] @@ -437,53 +355,11 @@ def create_generation_task( output_width=request.output_width, output_height=request.output_height, cover_url=request.cover_url, + custom_title=request.custom_title, title_config=request.title_config or {}, ) ) try: - # 兜底关联编辑计划:前端未传 source_edit_plan_id 时, - # 通过 template_id + user_id 在 DB 层直接查找最新的 plan。 - # 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件) - if not task.source_edit_plan_id and request.template_id: - try: - from packages.adapters.sqlalchemy_impl.models import EditPlanModel - - _plan_model = ( - db.query(EditPlanModel) - .filter( - EditPlanModel.template_id == request.template_id, - EditPlanModel.created_by_user_id == user_id, - ) - .order_by(EditPlanModel.created_at.desc()) - .first() - ) - if _plan_model: - task.source_edit_plan_id = _plan_model.id - generation_task_repository.update(task) - logger.info( - "[生成任务] 自动关联编辑计划: task_id=%s plan_id=%s", - task.id, - _plan_model.id, - ) - except Exception: - logger.warning( - "[生成任务] 查找关联编辑计划失败(不影响主流程): task_id=%s", - task.id, - exc_info=True, - ) - - # 回写 plan.config:必须在 enqueue 之前执行, - # 确保 worker 读取 plan 时 config 中已包含 generation_task_id。 - # 只在首个任务时回写一次,避免批量生成时循环覆盖。 - _effective_plan_id = task.source_edit_plan_id - if _effective_plan_id and len(created_tasks) == 0: - _writeback_edit_plan_config( - plan_id=_effective_plan_id, - task_id=task.id, - title_config=request.title_config, - db=db, - ) - if safe_enqueue_generation_task( task, generation_task_repository, @@ -492,6 +368,15 @@ def create_generation_task( log_task_status=True, ): created_tasks.append(task) + # 只在首个成功任务时回写一次 plan.config, + # 避免批量生成时循环覆盖 generation_task_id + if request.source_edit_plan_id and len(created_tasks) == 1: + _writeback_edit_plan_config( + plan_id=request.source_edit_plan_id, + task_id=task.id, + title_config=request.title_config, + db=db, + ) else: failed_tasks.append(task) except UserPendingLimitExceeded as _e: @@ -528,7 +413,6 @@ def confirm_generation( authenticated_user: AuthenticatedUser = Depends(get_current_user), generation_task_repository: Any = Depends(get_generation_task_repository), project_repository: Any = Depends(get_project_repository), - db: Session = Depends(get_db_session), ) -> BatchGenerationTaskResponse: """确认生成 -- 复用预览渲染产物(预览与正式品质一致)。 @@ -557,29 +441,13 @@ def confirm_generation( resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h) if resolution_match: - # 如果用户传了 custom_title,同步更新 title_config - confirmed_title_config = None - if request.custom_title and request.custom_title.strip(): - confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {}) - confirmed_title_config["text"] = request.custom_title.strip() - source_task.mark_confirmed( cover_url=request.cover_url, + custom_title=request.custom_title, output_width=request.output_width, output_height=request.output_height, - title_config=confirmed_title_config, ) generation_task_repository.update(source_task) - - # 同步标题到 EditPlan.config - if confirmed_title_config and source_task.source_edit_plan_id: - _writeback_edit_plan_config( - plan_id=source_task.source_edit_plan_id, - task_id=source_task.id, - title_config=confirmed_title_config, - db=db, - ) - logger.info( "[确认生成] 复用预览产物: task_id=%s, user_id=%s", task_id, @@ -621,6 +489,7 @@ def confirm_generation( output_width=request.output_width, output_height=request.output_height, cover_url=request.cover_url, + custom_title=request.custom_title, ) ) @@ -753,6 +622,7 @@ def retry_generation_task( output_width=getattr(task, "output_width", 1280), output_height=getattr(task, "output_height", 720), cover_url=getattr(task, "cover_url", ""), + custom_title=getattr(task, "custom_title", ""), ) ) try: diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 814cc5a20..8d27abd86 100755 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -10,7 +10,7 @@ class ConfirmGenerationRequest(BaseModel): output_width: int = Field(default=1080, ge=100, description="输出视频宽度") output_height: int = Field(default=1920, ge=100, description="输出视频高度") cover_url: str = Field(default="", description="自定义封面图片 URL") - custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划") + custom_title: str = Field(default="", description="自定义视频标题") class CreateGenerationTaskRequest(BaseModel): @@ -33,7 +33,7 @@ class CreateGenerationTaskRequest(BaseModel): voice_ids: list[str] = Field(default_factory=list) # ── 来源剪辑计划 ── source_edit_plan_id: str = "" - # ── 标题配置(结构化)── + # ── 标题配置(结构化,优先于 custom_title 纯文本)── title_config: dict | None = Field( default=None, description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。", @@ -77,6 +77,7 @@ class CreateGenerationTaskRequest(BaseModel): output_width: int = Field(default=1280, description="输出视频宽度") output_height: int = Field(default=720, description="输出视频高度") cover_url: str = Field(default="", description="封面图片 URL") + custom_title: str = Field(default="", description="自定义视频标题") @model_validator(mode="after") def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": @@ -112,6 +113,7 @@ class GenerationTaskResponse(BaseModel): output_width: int = 1280 output_height: int = 720 cover_url: str = "" + custom_title: str = "" title_config: dict = Field(default_factory=dict) status: str progress: float diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 926a7e47f..41f0afff6 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -423,9 +423,6 @@ class EditPlanService: ) db.add(model) - # flush 让新建 clip 写入当前事务(未 commit),后续查询才能找到它们 - db.flush() - # 3. 标记有 asset_id 的 clips 为 ready(不 commit) pending_with_asset = ( db.query(EditPlanClipModel) diff --git a/apps/web/src/api/generation/types.ts b/apps/web/src/api/generation/types.ts index 6b877681e..b110a1f95 100755 --- a/apps/web/src/api/generation/types.ts +++ b/apps/web/src/api/generation/types.ts @@ -13,10 +13,6 @@ export interface CreatePreviewRequest { video_title?: string duration?: number video_ratio?: string - /** 输出视频宽度(与 video_ratio 匹配,如 9:16 → 1080) */ - output_width?: number - /** 输出视频高度(与 video_ratio 匹配,如 9:16 → 1920) */ - output_height?: number /* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */ title_config?: { text?: string @@ -42,8 +38,6 @@ export interface CreatePreviewResponse { is_preview: boolean resolution: string created_at: string - /** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */ - source_edit_plan_id?: string } /** 预览任务详情响应 */ diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 42e41995b..3dc06469d 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -84,14 +84,6 @@ 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 ae0d0a2e3..8666cef76 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,18 +1,22 @@ /** - * 智能剪辑页面 — 前端实时预览架构 + * 智能剪辑页面 — V24 前端预览播放器架构改造 * 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成 * 左右布局:左侧 generate-form + 右侧 generate-preview * - * 架构: - * - Step4+ 右侧预览面板使用 FrontendPreviewPlayer 实时播放素材片段 - * - 标题样式编辑时 CSS 层实时叠加预览,所见即所得 - * - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务 + * 架构改造: + * - Step5 预览改为前端素材切片播放(FrontendPreviewPlayer) + * - 完全去除后端 FFmpeg 预览依赖 + * - 标题样式通过 CSS 层实时叠加,所见即所得 + * - 最终成片仍走后端 FFmpeg 渲染(Step7 确认生成) */ import React, { useMemo, useState, useEffect, useRef } from "react" import { Modal, 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 { getAssetsByKind } from "@/api/assets" +import { previewTts } from "@/api/tts" import CloneModal from "@/components/voice/CloneModal" import GenerateHeader from "./components/GenerateHeader" import { @@ -21,7 +25,7 @@ import { } from "./utils/calculateTotalVideoDuration" import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateResultPanel from "./components/GenerateResultPanel" -import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer" +import PreviewVideoPanel from "./components/PreviewVideoPanel" import GenerateStepContent from "./components/GenerateStepContent" import GenerateStepActions from "./components/GenerateStepActions" import { useGenerateFormState } from "./hooks/useGenerateFormState" @@ -29,7 +33,6 @@ import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" import { usePreviewAssets } from "./hooks/usePreviewAssets" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" -import { previewTts } from "@/api/tts" import "./generate.css" const GeneratePage: React.FC = () => { @@ -75,32 +78,79 @@ const GeneratePage: React.FC = () => { setPreviewVideo, previewModalOpen, setPreviewModalOpen, - previewTaskId, - setPreviewTaskId, - storedSourceEditPlanId, - setStoredSourceEditPlanId, } = formState - /* ── 标题样式回调 ── */ + /* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */ const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange: setTitleSettings, }) - /* ── 配音预览音频(TTS 试听)── */ + /* ── 克隆声音 ── */ + const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress() + + const handleCloneSuccess = (voice: VoiceClone) => { + addClone(voice) + setCloneModalOpen(false) + message.success("音色克隆成功!") + } + + /* ── 前端预览:加载选中素材的视频文件信息 ── */ + const previewAssetIds = useMemo( + () => (materialMode === "auto" ? smartSelectedIds : selectedMaterials), + [materialMode, smartSelectedIds, selectedMaterials], + ) + const previewAssetsEnabled = currentStep >= 4 && previewAssetIds.length > 0 + const { + assets: previewAssets, + loading: previewAssetsLoading, + ready: previewAssetsReady, + } = usePreviewAssets(previewAssetIds, previewAssetsEnabled) + + /* ── 当前模板对象(传给前端预览播放器) ── */ + const currentTemplate = useMemo( + () => userTemplates.find((t) => t.id === selectedTemplate) || null, + [userTemplates, selectedTemplate], + ) + + /* ── 视频总时长计算(用于配音时长校验) ── */ + const totalVideoDuration = useMemo(() => { + // 优先用素材精确时长;素材未加载时用模板 segments 的 duration_max 之和估算 + const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined) + if (exact > 0) return exact + return estimateTotalVideoDuration(currentTemplate ?? undefined) + }, [previewAssets, currentTemplate]) + + /* ── 配音音频 URL ── */ + const { data: voiceMaterials = [] } = useQuery({ + queryKey: ["assets", "voice"], + queryFn: () => getAssetsByKind("voice", { limit: 50 }), + }) + const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState(null) const ttsAbortRef = useRef(null) useEffect(() => { + // 如果 selectedVoice 是已上传的配音素材,直接用 file_url + const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice) + if (voiceAsset?.file_url) { + setPreviewVoiceAudioUrl(voiceAsset.file_url) + return + } + + // 没有选中的 voice 或标题,跳过 const voiceId = selectedClonedVoice || selectedVoice if (!voiceId || !titleSettings.title) { setPreviewVoiceAudioUrl(null) return } + + // 预设音色 / 克隆音色 → 调 TTS 合成 ttsAbortRef.current?.abort() const controller = new AbortController() ttsAbortRef.current = controller let cancelled = false + previewTts({ text: titleSettings.title, voice_id: voiceId, @@ -116,53 +166,14 @@ const GeneratePage: React.FC = () => { setPreviewVoiceAudioUrl(null) } }) + return () => { cancelled = true controller.abort() } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedVoice, selectedClonedVoice, titleSettings.title]) + }, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials]) - /* ── 克隆声音 ── */ - const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress() - - const handleCloneSuccess = (voice: VoiceClone) => { - addClone(voice) - setCloneModalOpen(false) - message.success("音色克隆成功!") - } - - /* ── 素材 ID 列表 ── */ - const previewAssetIds = useMemo( - () => (materialMode === "auto" ? smartSelectedIds : selectedMaterials), - [materialMode, smartSelectedIds, selectedMaterials], - ) - - /* ── 当前模板对象 ── */ - const currentTemplate = useMemo( - () => userTemplates.find((t) => t.id === selectedTemplate) || null, - [userTemplates, selectedTemplate], - ) - - /* ── BGM 配置 ── */ - const bgmConfig = useMemo( - () => ({ - enabled: bgm, - music_id: currentTemplate?.bgm_config?.music_id || "", - }), - [bgm, currentTemplate], - ) - - /* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */ - const previewAssetsEnabled = previewAssetIds.length > 0 - const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled) - - /* ── 视频总时长计算 ── */ - const totalVideoDuration = useMemo(() => { - const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined) - if (exact > 0) return exact - return estimateTotalVideoDuration(currentTemplate ?? undefined) - }, [previewAssets, currentTemplate]) + const voiceAudioUrl = previewVoiceAudioUrl || undefined /* ── 步骤导航 ── */ const { goNext, goPrev } = useStepNavigation({ @@ -173,6 +184,7 @@ const GeneratePage: React.FC = () => { selectedMaterials, smartSelectedIds, titleSettings, + previewReady: previewAssetsReady, }) /* ── 视频生成核心逻辑 ── */ @@ -203,25 +215,22 @@ const GeneratePage: React.FC = () => { autoSubtitles, bgm, generateCount, - sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId, - previewTaskId, - bgmConfig, - onGenerationSuccess: () => { - setPreviewTaskId(null) - setStoredSourceEditPlanId(null) - }, + sourceEditPlanId, }) /* ================================================================ - 渲染 + 渲染 — 主页面 ================================================================ */ return (
+ {/* ── 页头 ── */} + {/* ── 步骤条 ── */} + {/* ── 主布局 ── */}
{/* ════ 左侧:表单区 ════ */}
@@ -238,6 +247,7 @@ const GeneratePage: React.FC = () => { onSmartSelectedIdsChange={setSmartSelectedIds} titleSettings={titleSettings} onTitleSettingsChange={setTitleSettings} + /* 标题样式回调 */ onUpdatePosition={styleUpdaters.updatePosition} onUpdateFont={styleUpdaters.updateFont} onUpdateSize={styleUpdaters.updateSize} @@ -248,10 +258,6 @@ const GeneratePage: React.FC = () => { onApplyPreset={styleUpdaters.applyPreset} activePreset={styleUpdaters.activePreset} titlePresets={styleUpdaters.titlePresets} - onPreviewTaskCreated={setPreviewTaskId} - onSourceEditPlanIdExtracted={setStoredSourceEditPlanId} - bgm={bgm} - bgmConfig={bgmConfig} coverSettings={coverSettings} onCoverSettingsChange={setCoverSettings} duration={duration} @@ -290,26 +296,18 @@ const GeneratePage: React.FC = () => { />
- {/* ════ 右侧:预览 + 结果 ════ */} + {/* ════ 右侧:预览 + 生成结果 ════ */}
- {currentStep >= 4 && !!currentTemplate && ( - = 4 && ( + 0} - voiceAudioUrl={previewVoiceAudioUrl || undefined} - titleSettings={{ - title: titleSettings.title, - size: titleSettings.size, - font: titleSettings.font, - color: titleSettings.color, - position: titleSettings.position as "top" | "center" | "bottom", - bold: titleSettings.bold, - italic: titleSettings.italic, - stroke: titleSettings.stroke, - shadow: titleSettings.shadow, - }} + assetsReady={previewAssetsReady} + assetsLoading={previewAssetsLoading} + titleSettings={titleSettings} + voiceAudioUrl={voiceAudioUrl} /> )} {currentStep >= 6 && ( @@ -331,7 +329,7 @@ const GeneratePage: React.FC = () => {
- {/* 视频预览弹窗 */} + {/* ── 视频预览弹窗 ── */} { )} - {/* 音色克隆弹窗 */} + {/* ── 音色克隆弹窗 ── */} setCloneModalOpen(false)} diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 460434a57..3637af79c 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -277,29 +277,20 @@ const FrontendPreviewPlayer: React.FC = ({ if (!ready || !assets.length) { return (
- -

- 准备预览素材... -

-

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

+ +

准备预览素材...

+

加载素材后即可预览播放

) } @@ -309,48 +300,31 @@ const FrontendPreviewPlayer: React.FC = ({ const showDecodeError = forceVideoFallback && canvasState.hasDecodeError return (
{isBuffering ? ( <> - -

加载中...

+ +

加载中...

) : showDecodeError ? ( <> - -

+ +

视频解码失败

{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}

@@ -358,14 +332,10 @@ const FrontendPreviewPlayer: React.FC = ({ ) : ( <> -

- 暂无可播放素材 -

-

- 请先在左侧选择素材 -

+

暂无可播放素材

+

请先在左侧选择素材

)}
@@ -373,19 +343,7 @@ const FrontendPreviewPlayer: React.FC = ({ } return ( -
+ <> {/* ── Canvas 渲染层(WebCodecs 路径) ── */} {effectiveUseWebCodecs && (
= ({ /> ))} - {/* 标题CSS叠加层 — video fallback 路径也要渲染 */} - {titleSettings?.title && ( -
- - {titleSettings.title} - -
- )} - - {/* 中央播放按钮 */} + {/* 播放按钮 */} {!isPlaying && ( )} - {/* 片段指示器 — 右上角胶囊 */} + {/* 片段指示器 */}
- {`${videoCurrentSegIdx + 1} / ${segments.length}`} + {`片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
- {/* 控制条 — 手机风格毛玻璃 */} + {/* 控制条 */}
= ({ right: 0, display: "flex", alignItems: "center", - gap: 10, - padding: "12px 16px 16px", - background: "linear-gradient(transparent, rgba(0,0,0,0.7))", - backdropFilter: "blur(4px)", - WebkitBackdropFilter: "blur(4px)", + gap: 8, + padding: "8px 12px", + background: "linear-gradient(transparent, rgba(0,0,0,0.6))", zIndex: 10, }} >
-
+ ) } diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index de363c464..7f7cf93f8 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -76,14 +76,6 @@ export interface GenerateStepContentProps { onDismissError: () => void /* 其他 */ presetVoices: PresetVoiceItem[] - /** 预览任务创建回调——传递给 Step6CoverSettings */ - 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) => { @@ -129,10 +121,6 @@ export const GenerateStepContent: React.FC = (props) = onRetry, onDismissError, presetVoices, - onPreviewTaskCreated, - onSourceEditPlanIdExtracted, - bgm, - bgmConfig, } = props /* 当前模板的 segments,传给 Step2 构建 clips */ @@ -202,13 +190,6 @@ export const GenerateStepContent: React.FC = (props) = assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials} selectedTemplate={selectedTemplate} 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/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 28745a5e7..ee03f16b5 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -1,42 +1,39 @@ /** - * 右侧预览视频面板 — 服务器渲染预览架构 + * 右侧预览视频面板 + * Step4+: 显示预览视频面板 + * Step5: 前端实时预览 — 用原生 video 播放素材片段 + CSS 标题叠加 * - * Step4+: 显示预览面板 - * Step5: 播放服务器渲染的真实视频(POST /generation/preview) + * 架构改造:完全去除后端 FFmpeg 预览依赖 + * - 使用 FrontendPreviewPlayer 直接播放素材片段 + * - TitleOverlay CSS 层实时响应标题样式变化 * - * 架构: - * - 进入 Step4/5 时自动创建服务器预览渲染任务 - * - 轮询完成后用