feat: 标题样式面板迁移到Step5 + CSS实时预览 + 修复封面错误展示 #1408

Closed
xiaoxia wants to merge 1 commits from feat/title-style-panel-to-step5 into develop
6 changed files with 253 additions and 47 deletions
+22 -3
View File
@@ -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 = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 显示) */}
{/* 预览视频面板(Step4+ 显示,含 CSS 标题实时预览层 */}
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={step5Preview.previewStatus}
@@ -241,6 +259,7 @@ const GeneratePage: React.FC = () => {
progress={step5Preview.progress}
videoRatio={videoRatio}
onRegenerate={step5Preview.regeneratePreview}
titleSettings={titleSettings}
/>
)}
{currentStep >= 6 && (
@@ -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<GenerateStepContentProps> = (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<GenerateStepContentProps> = (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:
@@ -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 (
<div
className="xx-preview-title-overlay"
style={{
position: "absolute",
inset: 0,
zIndex: 20,
pointerEvents: "none",
}}
>
<div
style={{
...positionStyle,
...titleStyle,
position: "absolute",
}}
>
{titleSettings.title}
</div>
</div>
)
}
/* ── 主组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
previewStatus,
@@ -30,6 +151,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
progress,
videoRatio,
onRegenerate,
titleSettings,
}) => {
const hasPreview = previewStatus === "ready" && previewResult
const isLoading = previewStatus === "pending" || previewStatus === "generating"
@@ -89,8 +211,10 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
{/* 预览成功 */}
{hasPreview && (
<>
<div className="xx-preview-video" style={videoAspectStyle}>
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
<video src={previewResult.videoUrl} controls preload="metadata" />
{/* CSS 标题实时预览层 — 与 FFmpeg ASS 渲染坐标对齐 */}
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
</div>
<div className="xx-preview-info">
<div className="xx-preview-info-row">
@@ -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<Step4TitleSettingsProps> = (props) => {
</div>
</>
)}
{/* 样式面板始终可见,两种模式下都可调整标题展示样式 */}
<TitleStylePanel
settings={t.titleSettings}
onUpdatePosition={t.updatePosition}
onUpdateFont={t.updateFont}
onUpdateSize={t.updateSize}
onToggleBold={t.toggleBold}
onToggleItalic={t.toggleItalic}
onToggleStroke={t.toggleStroke}
onToggleShadow={t.toggleShadow}
onApplyPreset={t.applyPreset}
activePreset={t.activePreset}
titlePresets={t.titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
</div>
)
}
@@ -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<Step5GeneratePreviewProps> = ({
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<Step5GeneratePreviewProps> = ({
<div className="xx-form-section">
<h3>🎬 </h3>
{/* 标题样式面板 — 用户可实时调整标题样式,右侧预览同步更新 */}
<TitleStylePanel
settings={titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
{/* 预览数量选择器(仅在 idle 状态显示) */}
{isIdle && (
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
@@ -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)