diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 460434a57..464129ed8 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -76,12 +76,59 @@ function buildPlaybackSegments( const FrontendPreviewPlayer: React.FC = ({ assets, template, - videoRatio: _videoRatio, + videoRatio, ready, voiceAudioUrl, titleSettings, }) => { const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template]) + + // ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── + const TITLE_MARGIN_TOP = 60 + const TITLE_MARGIN_BOTTOM = 60 + 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 playerContainerRef = useRef(null) + 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 + // 描边/阴影也要按缩放比例放大 + const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1 + const titleStrokeWidth = Math.max(1, 2 * titleScale) + const titleShadowBlur = 4 * titleScale + const titleShadowOffset = 2 * titleScale + // 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI) // WebCodecs 仅在明确需要时启用(保留代码作为兜底) const useWebCodecs = false @@ -374,6 +421,7 @@ const FrontendPreviewPlayer: React.FC = ({ return (
= ({ /> ))} - {/* 标题CSS叠加层 — video fallback 路径也要渲染 */} + {/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */} {titleSettings?.title && (
- - {titleSettings.title} - + + {titleSettings.title.split(/[//]/).map((part, i) => ( + + {i > 0 &&
} + {part} +
+ ))} +
+
)} diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index d126931c3..9e3e10232 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -40,17 +40,33 @@ interface PreviewVideoPanelProps { } /* ── 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 +const TITLE_MARGIN_TOP = 60 +const TITLE_MARGIN_BOTTOM = 60 +const TITLE_MARGIN_SIDE = 40 -function getPositionStyle(position: string): React.CSSProperties { - const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100 +/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY) */ +function getResolution(ratio: string): { width: number; height: number } { + switch (ratio) { + 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 } + } +} + +function getPositionStyle( + position: string, + playResX: number, + playResY: number, +): React.CSSProperties { + const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100 switch (position) { case "bottom": return { - bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`, + bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`, left: `${sidePercent}%`, right: `${sidePercent}%`, textAlign: "center", @@ -66,7 +82,7 @@ function getPositionStyle(position: string): React.CSSProperties { case "top": default: return { - top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`, + top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`, left: `${sidePercent}%`, right: `${sidePercent}%`, textAlign: "center", @@ -74,11 +90,16 @@ function getPositionStyle(position: string): React.CSSProperties { } } -function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties { +function buildTitleStyle( + settings: TitleSettings, + containerHeight: number, + playResY: number, +): React.CSSProperties { + // 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致) const fontSizePx = containerHeight > 0 - ? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight - : (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 + ? (settings.size / playResY) * containerHeight + : (settings.size / playResY) * 400 const base: React.CSSProperties = { fontFamily: getFontFamily(settings.font), @@ -90,8 +111,6 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac wordBreak: "break-word", pointerEvents: "none", userSelect: "none", - paddingLeft: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`, - paddingRight: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`, } if (settings.stroke) base.WebkitTextStroke = "1px #000000" if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)" @@ -99,7 +118,10 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac } /** CSS 标题实时预览覆盖层 */ -const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => { +const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({ + titleSettings, + videoRatio, +}) => { const containerRef = useRef(null) const [containerHeight, setContainerHeight] = useState(400) @@ -118,12 +140,15 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting return () => ro.disconnect() }, []) + const { width: playResX, height: playResY } = getResolution(videoRatio) + const positionStyle = useMemo( - () => getPositionStyle(titleSettings.position), - [titleSettings.position], + () => getPositionStyle(titleSettings.position, playResX, playResY), + // eslint-disable-next-line react-hooks/exhaustive-deps + [titleSettings.position, playResX, playResY], ) const titleStyle = useMemo( - () => buildTitleStyle(titleSettings, containerHeight), + () => buildTitleStyle(titleSettings, containerHeight, playResY), // eslint-disable-next-line react-hooks/exhaustive-deps [ containerHeight, @@ -134,6 +159,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting titleSettings.italic, titleSettings.stroke, titleSettings.shadow, + playResY, ], ) @@ -266,7 +292,9 @@ export const PreviewVideoPanel: React.FC = ({ )} {/* 标题样式实时预览层(仅在有视频时叠加) */} - {isReady && titleSettings && } + {isReady && titleSettings && ( + + )} {/* stale 遮罩:配置变更提示 */} {isStale && (