From f561136afa932f68107558b06bc51bbe10508605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Mon, 17 Aug 2026 20:12:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=87=E9=A2=98=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=BF=81=E7=A7=BB=E5=88=B0Step5=20+=20CSS?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88=20+=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E9=94=99=E8=AF=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step4 删除 TitleStylePanel,仅保留标题输入 + AI 生成 - Step5 接收 TitleStylePanel,用户可实时调整标题样式 - PreviewVideoPanel 添加 CSS 标题覆盖层,与 FFmpeg ASS 坐标对齐 - 渲染参数(位置/字体/字号/粗体/斜体/描边/阴影/颜色)与后端完全一致 - 修复封面生成 400 错误双重 toast(检查 __msgShown 避免重复弹出) - useTitleStyleUpdaters 提升到 GeneratePage,Step5 和 PreviewVideoPanel 共用 --- apps/web/src/pages/generate/GeneratePage.tsx | 25 +++- .../components/GenerateStepContent.tsx | 34 ++++- .../generate/components/PreviewVideoPanel.tsx | 134 +++++++++++++++++- .../components/Step4TitleSettings.tsx | 21 +-- .../components/Step5GeneratePreview.tsx | 44 ++++++ .../src/pages/generate/hooks/useStep6Cover.ts | 42 +++--- 6 files changed, 253 insertions(+), 47 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index ed61d4205..cb1cea796 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,6 +1,6 @@ /** - * 智能剪辑页面 — V22 多预览 + 配音前置 - * 7 步向导:选择模板 → 选择素材 → 选择配音 → 选择标题 → 生成预览 → 选择封面 → 确认生成 + * 智能剪辑页面 — V23 标题样式面板迁移到 Step5 + CSS 实时预览 + * 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成 * 左右布局:左侧 generate-form + 右侧 generate-preview * 主组件仅保留整体布局与事件编排 * 状态管理 → hooks/useGenerateFormState @@ -25,6 +25,7 @@ import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" import { useStep5Preview } from "./hooks/useStep5Preview" +import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" import "./generate.css" const GeneratePage: React.FC = () => { @@ -71,6 +72,12 @@ const GeneratePage: React.FC = () => { setPreviewModalOpen, } = formState + /* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */ + const styleUpdaters = useTitleStyleUpdaters({ + titleSettings, + onTitleSettingsChange: setTitleSettings, + }) + /* ── 克隆声音 ── */ const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress() @@ -183,6 +190,17 @@ const GeneratePage: React.FC = () => { onSmartSelectedIdsChange={setSmartSelectedIds} titleSettings={titleSettings} onTitleSettingsChange={setTitleSettings} + /* 标题样式回调 */ + onUpdatePosition={styleUpdaters.updatePosition} + onUpdateFont={styleUpdaters.updateFont} + onUpdateSize={styleUpdaters.updateSize} + onToggleBold={styleUpdaters.toggleBold} + onToggleItalic={styleUpdaters.toggleItalic} + onToggleStroke={styleUpdaters.toggleStroke} + onToggleShadow={styleUpdaters.toggleShadow} + onApplyPreset={styleUpdaters.applyPreset} + activePreset={styleUpdaters.activePreset} + titlePresets={styleUpdaters.titlePresets} coverSettings={coverSettings} onCoverSettingsChange={setCoverSettings} duration={duration} @@ -232,7 +250,7 @@ const GeneratePage: React.FC = () => { {/* ════ 右侧:预览 + 生成结果 ════ */}
- {/* 预览视频面板(Step4+ 显示) */} + {/* 预览视频面板(Step4+ 显示,含 CSS 标题实时预览层) */} {currentStep >= 4 && ( { progress={step5Preview.progress} videoRatio={videoRatio} onRegenerate={step5Preview.regeneratePreview} + titleSettings={titleSettings} /> )} {currentStep >= 6 && ( diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index e32ab7ad9..98dd023d0 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -35,6 +35,17 @@ export interface GenerateStepContentProps { /* 标题 */ titleSettings: TitleSettings onTitleSettingsChange: (settings: TitleSettings) => void + /* 标题样式回调 — Step5 样式面板使用 */ + onUpdatePosition: (position: string) => void + onUpdateFont: (font: string) => void + onUpdateSize: (size: number) => void + onToggleBold: () => void + onToggleItalic: () => void + onToggleStroke: () => void + onToggleShadow: () => void + onApplyPreset: (presetKey: string) => void + activePreset: string | null + titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] /* 封面 */ coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void @@ -63,7 +74,7 @@ export interface GenerateStepContentProps { onDismissError: () => void /* 其他 */ presetVoices: PresetVoiceItem[] - /* Step4 预览(多预览) */ + /* Step5 预览(多预览) */ previewCount: number onPreviewCountChange: (count: number) => void previewItems: PreviewItem[] @@ -89,6 +100,16 @@ export const GenerateStepContent: React.FC = (props) = onSmartSelectedIdsChange, titleSettings, onTitleSettingsChange, + onUpdatePosition, + onUpdateFont, + onUpdateSize, + onToggleBold, + onToggleItalic, + onToggleStroke, + onToggleShadow, + onApplyPreset, + activePreset, + titlePresets, coverSettings, onCoverSettingsChange, duration, @@ -164,6 +185,17 @@ export const GenerateStepContent: React.FC = (props) = anyGenerating={previewAnyGenerating} onGeneratePreview={onGeneratePreview} onRegeneratePreview={onRegeneratePreview} + titleSettings={titleSettings} + onUpdatePosition={onUpdatePosition} + onUpdateFont={onUpdateFont} + onUpdateSize={onUpdateSize} + onToggleBold={onToggleBold} + onToggleItalic={onToggleItalic} + onToggleStroke={onToggleStroke} + onToggleShadow={onToggleShadow} + onApplyPreset={onApplyPreset} + activePreset={activePreset} + titlePresets={titlePresets} /> ) case 6: diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 375dfde55..35a6e9791 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -1,16 +1,18 @@ /** * 右侧预览视频面板 * Step4+: 显示预览视频面板 - * Step5+: 显示后端生成的预览视频(标题已由 FFmpeg 烧录) + * Step5+: 显示后端生成的预览视频 * * 设计说明: * - Step4(标题设置页):右侧显示空状态提示,引导用户输入标题 * - Step5(预览生成页):显示后端返回的预览视频 - * - Canvas 预览已删除(统一由后端 FFmpeg 渲染标题) + * - 标题 CSS 实时预览层:在预览视频上方叠加 CSS 标题,所见即所得 + * 渲染参数与后端 FFmpeg ASS 字幕完全对齐(PR #1401 坐标系) */ -import React from "react" +import React, { useMemo } from "react" import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons" import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview" +import type { TitleSettings } from "../types" interface PreviewVideoPanelProps { previewStatus: PreviewStatus @@ -19,9 +21,128 @@ interface PreviewVideoPanelProps { progress: number videoRatio: string onRegenerate: () => void + /** 标题设置 — 用于 CSS 实时预览层 */ + titleSettings?: TitleSettings } -/* ── 组件 ── */ +/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */ +/** 后端默认视频高度,用于百分比换算 */ +const ASS_VIDEO_HEIGHT = 720 +const ASS_TITLE_MARGIN_TOP = 60 +const ASS_TITLE_MARGIN_BOTTOM = 60 +const ASS_TITLE_MARGIN_SIDE = 40 + +/** + * 根据 position 计算 CSS 垂直定位 + * 与后端 position_to_ass_alignment() 对齐:top→8, center→5, bottom→2 + * 使用百分比,与容器尺寸无关 + */ +function getPositionStyle(position: string): React.CSSProperties { + const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100 // ≈ 3.125% + + switch (position) { + case "bottom": + return { + bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`, + left: `${sidePercent}%`, + right: `${sidePercent}%`, + textAlign: "center", + } + case "center": + return { + top: "50%", + transform: "translateY(-50%)", + left: `${sidePercent}%`, + right: `${sidePercent}%`, + textAlign: "center", + } + case "top": + default: + return { + top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`, + left: `${sidePercent}%`, + right: `${sidePercent}%`, + textAlign: "center", + } + } +} + +/** + * 构建 CSS 标题层的样式 + * 所有渲染参数与后端 FFmpeg ASS 字幕一致 + */ +function buildTitleStyle(settings: TitleSettings): React.CSSProperties { + // 字号缩放:后端在 720p 视频上渲染,CSS 在预览容器上渲染 + // 预览容器 max-height: 400px,用容器高度的百分比实现等比缩放 + // fontSize = (settings.size / 720) * containerHeight → 用百分比表达 + // 例如 size=28 → (28/720)*100 ≈ 3.89% of container height + const fontSizePercent = (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * 100 + + const base: React.CSSProperties = { + fontFamily: settings.font || "思源黑体", + fontSize: `${fontSizePercent}%`, + color: settings.color || "#ffffff", + fontWeight: settings.bold ? 700 : 400, + fontStyle: settings.italic ? "italic" : "normal", + lineHeight: 1.3, + wordBreak: "break-word", + pointerEvents: "none", + userSelect: "none", + // 水平内边距与 ASS margin_l/r 对齐 + paddingLeft: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`, + paddingRight: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`, + } + + // 描边(stroke)→ CSS -webkit-text-stroke(与后端 outline_width=2 对齐) + if (settings.stroke) { + base.WebkitTextStroke = "1px #000000" + } + + // 阴影(shadow)→ CSS text-shadow(与后端 shadow_blur=4, offset=(2,2) 对齐) + if (settings.shadow) { + base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)" + } + + return base +} + +/** + * CSS 标题预览覆盖层 + * 仅在预览视频已生成(status === ready)且有标题文字时显示 + */ +const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => { + const positionStyle = useMemo( + () => getPositionStyle(titleSettings.position), + [titleSettings.position], + ) + const titleStyle = useMemo(() => buildTitleStyle(titleSettings), [titleSettings]) + + if (!titleSettings.title?.trim()) return null + + return ( +
+
+ {titleSettings.title} +
+
+ ) +} + +/* ── 主组件 ── */ export const PreviewVideoPanel: React.FC = ({ previewStatus, @@ -30,6 +151,7 @@ export const PreviewVideoPanel: React.FC = ({ progress, videoRatio, onRegenerate, + titleSettings, }) => { const hasPreview = previewStatus === "ready" && previewResult const isLoading = previewStatus === "pending" || previewStatus === "generating" @@ -89,8 +211,10 @@ export const PreviewVideoPanel: React.FC = ({ {/* 预览成功 */} {hasPreview && ( <> -
+
diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 698dbe5d0..d01dbf48d 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -1,13 +1,13 @@ /** * Step 4 标题设置组件 + * 仅包含标题文字输入 + AI 标题生成 + * 标题样式面板已迁移到 Step5(生成预览页面) */ import React from "react" import { AutoComplete } from "antd" -import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants" import type { TitleSettings } from "../types" import { useStep4Title } from "../hooks/useStep4Title" import AiTitleGenerator from "./title/AiTitleGenerator" -import TitleStylePanel from "./title/TitleStylePanel" interface Step4TitleSettingsProps { titleSettings: TitleSettings @@ -110,23 +110,6 @@ const Step4TitleSettings: React.FC = (props) => {
)} - - {/* 样式面板始终可见,两种模式下都可调整标题展示样式 */} -
) } diff --git a/apps/web/src/pages/generate/components/Step5GeneratePreview.tsx b/apps/web/src/pages/generate/components/Step5GeneratePreview.tsx index 598e1b770..d91cf1f4c 100644 --- a/apps/web/src/pages/generate/components/Step5GeneratePreview.tsx +++ b/apps/web/src/pages/generate/components/Step5GeneratePreview.tsx @@ -2,6 +2,7 @@ * Step 5 生成预览组件(支持多预览) * 调用后端预览生成接口,展示预览生成状态 * 预览视频由右侧 PreviewVideoPanel 展示,本组件不重复显示预览网格 + * 标题样式面板(TitleStylePanel)在此展示,用户可实时调整标题样式 */ import React from "react" import { @@ -12,7 +13,10 @@ import { ClockCircleOutlined, } from "@ant-design/icons" import { InputNumber } from "antd" +import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants" +import type { TitleSettings } from "../types" import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview" +import TitleStylePanel from "./title/TitleStylePanel" interface Step5GeneratePreviewProps { previewCount: number @@ -24,6 +28,18 @@ interface Step5GeneratePreviewProps { anyGenerating: boolean onGeneratePreview: () => void onRegeneratePreview: () => void + /* 标题样式 */ + titleSettings: TitleSettings + onUpdatePosition: (position: string) => void + onUpdateFont: (font: string) => void + onUpdateSize: (size: number) => void + onToggleBold: () => void + onToggleItalic: () => void + onToggleStroke: () => void + onToggleShadow: () => void + onApplyPreset: (presetKey: string) => void + activePreset: string | null + titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] } /** 预览数量选项 */ @@ -43,6 +59,17 @@ const Step5GeneratePreview: React.FC = ({ anyGenerating, onGeneratePreview, onRegeneratePreview, + titleSettings, + onUpdatePosition, + onUpdateFont, + onUpdateSize, + onToggleBold, + onToggleItalic, + onToggleStroke, + onToggleShadow, + onApplyPreset, + activePreset, + titlePresets, }) => { const isIdle = overallStatus === "idle" const isError = overallStatus === "error" && !items.some((it) => it.status === "ready") @@ -51,6 +78,23 @@ const Step5GeneratePreview: React.FC = ({

🎬 生成预览

+ {/* 标题样式面板 — 用户可实时调整标题样式,右侧预览同步更新 */} + + {/* 预览数量选择器(仅在 idle 状态显示) */} {isIdle && (
diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 9b8b804b2..53473da54 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -109,26 +109,30 @@ export function useStep6Cover({ console.error("[Step6] 智能封面生成失败:", err) // 提取详细错误信息 - let errorMsg = "封面生成失败" - const e = err as { - response?: { data?: { detail?: string; message?: string }; status?: number } - request?: unknown - message?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyErr = err as any + // 如果 API 拦截器已经弹出了后端返回的具体错误信息,这里跳过重复 toast + if (anyErr?.__msgShown) { + // 拦截器已处理,不再重复弹出 + } else { + let errorMsg = "封面生成失败" + const e = anyErr as { + response?: { data?: { detail?: string; message?: string }; status?: number } + request?: unknown + message?: string + } + if (e.response) { + const detail = e.response.data?.detail || e.response.data?.message || "" + errorMsg = detail || `后端错误 (${e.response.status})` + console.error("[Step6] 后端返回:", e.response.data) + } else if (e.request) { + errorMsg = "服务器无响应,请检查网络连接" + console.error("[Step6] 请求无响应:", e.request) + } else if (e.message) { + errorMsg = e.message + } + message.error(errorMsg) } - if (e.response) { - // 后端返回错误 - const detail = e.response.data?.detail || e.response.data?.message || "" - errorMsg = detail || `后端错误 (${e.response.status})` - console.error("[Step6] 后端返回:", e.response.data) - } else if (e.request) { - // 请求已发送但无响应 - errorMsg = "服务器无响应,请检查网络连接" - console.error("[Step6] 请求无响应:", e.request) - } else if (e.message) { - errorMsg = e.message - } - - message.error(errorMsg) } finally { clearTimeout(timeoutId) setGenerating(false) -- 2.54.0