Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 1fc62c9476 fix: 预览标题坐标系与后端 ASS 烧录 1:1 对齐
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m16s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 3m36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m59s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m24s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 36s
CI/CD Pipeline / CI Gate (pull_request) Successful in 10s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
问题:CSS 预览层硬编码 1280x720 坐标系,后端竖屏实际输出 1080x1920,
导致标题位置、字号、边距与最终渲染视频不一致,视觉上出现"标题重叠"。

修复(PreviewVideoPanel.tsx + FrontendPreviewPlayer.tsx):
- 根据 videoRatio 动态选择后端实际分辨率:
  · 9:16 → 1080x1920
  · 16:9 → 1920x1080
  · 1:1 → 1080x1080
- 字号缩放分母从 720 改为实际 PlayResY,不设 96 上限
- 水平边距百分比分母从 1280 改为实际 PlayResX
- 垂直边距(top/bottom)按实际 PlayResY 计算百分比
- 描边宽度、阴影模糊/偏移按容器缩放比例同步放大
- FrontendPreviewPlayer 标题叠加层同时支持半角/和全角/换行
- 移除 paddingLeft/paddingRight hack,改用 left/right 边距(与 ASS MarginL/R 一致)
2026-08-25 21:17:25 +08:00
2 changed files with 132 additions and 49 deletions
@@ -76,12 +76,59 @@ function buildPlaybackSegments(
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets, assets,
template, template,
videoRatio: _videoRatio, videoRatio,
ready, ready,
voiceAudioUrl, voiceAudioUrl,
titleSettings, titleSettings,
}) => { }) => {
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template]) 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<HTMLDivElement>(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) // 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
// WebCodecs 仅在明确需要时启用(保留代码作为兜底) // WebCodecs 仅在明确需要时启用(保留代码作为兜底)
const useWebCodecs = false const useWebCodecs = false
@@ -374,6 +421,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
return ( return (
<div <div
ref={playerContainerRef}
style={{ style={{
position: "relative", position: "relative",
width: "100%", width: "100%",
@@ -434,48 +482,55 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
/> />
))} ))}
{/* 标题CSS叠加层 — video fallback 路径也要渲染 */} {/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && ( {titleSettings?.title && (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
left: 0, inset: 0,
right: 0,
zIndex: 5, zIndex: 5,
display: "flex",
justifyContent: "center",
pointerEvents: "none", pointerEvents: "none",
...(titleSettings.position === "top" overflow: "hidden",
? { top: "10%" }
: titleSettings.position === "center"
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: "15%" }),
}} }}
> >
<span <div
style={{ style={{
fontSize: titleSettings.size, position: "absolute",
fontFamily: titleSettings.font || "思源黑体", left: `${titleSidePct}%`,
color: titleSettings.color || "#ffffff", right: `${titleSidePct}%`,
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
textShadow: [
titleSettings.shadow ? "0 2px 8px rgba(0,0,0,0.7)" : undefined,
titleSettings.stroke
? "1px 1px 0 rgba(0,0,0,0.5), -1px -1px 0 rgba(0,0,0,0.5), 1px -1px 0 rgba(0,0,0,0.5), -1px 1px 0 rgba(0,0,0,0.5)"
: undefined,
"0 1px 3px rgba(0,0,0,0.4)",
]
.filter(Boolean)
.join(", "),
maxWidth: "90%",
textAlign: "center", textAlign: "center",
lineHeight: 1.3, ...(titleSettings.position === "top"
wordBreak: "break-word", ? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: `${titleBottomPct}%` }),
}} }}
> >
{titleSettings.title} <span
</span> style={{
fontSize: `${titleFontSizePx}px`,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
lineHeight: 1.3,
wordBreak: "break-word",
WebkitTextStroke: titleSettings.stroke
? `${titleStrokeWidth}px #000000`
: undefined,
textShadow: titleSettings.shadow
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
: undefined,
}}
>
{titleSettings.title.split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</span>
</div>
</div> </div>
)} )}
@@ -40,17 +40,33 @@ interface PreviewVideoPanelProps {
} }
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */ /* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
const ASS_VIDEO_HEIGHT = 720 const TITLE_MARGIN_TOP = 60
const ASS_TITLE_MARGIN_TOP = 60 const TITLE_MARGIN_BOTTOM = 60
const ASS_TITLE_MARGIN_BOTTOM = 60 const TITLE_MARGIN_SIDE = 40
const ASS_TITLE_MARGIN_SIDE = 40
function getPositionStyle(position: string): React.CSSProperties { /** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY */
const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100 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) { switch (position) {
case "bottom": case "bottom":
return { return {
bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`, bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
left: `${sidePercent}%`, left: `${sidePercent}%`,
right: `${sidePercent}%`, right: `${sidePercent}%`,
textAlign: "center", textAlign: "center",
@@ -66,7 +82,7 @@ function getPositionStyle(position: string): React.CSSProperties {
case "top": case "top":
default: default:
return { return {
top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`, top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
left: `${sidePercent}%`, left: `${sidePercent}%`,
right: `${sidePercent}%`, right: `${sidePercent}%`,
textAlign: "center", 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 = const fontSizePx =
containerHeight > 0 containerHeight > 0
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight ? (settings.size / playResY) * containerHeight
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 : (settings.size / playResY) * 400
const base: React.CSSProperties = { const base: React.CSSProperties = {
fontFamily: getFontFamily(settings.font), fontFamily: getFontFamily(settings.font),
@@ -90,8 +111,6 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac
wordBreak: "break-word", wordBreak: "break-word",
pointerEvents: "none", pointerEvents: "none",
userSelect: "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.stroke) base.WebkitTextStroke = "1px #000000"
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)" 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 标题实时预览覆盖层 */ /** CSS 标题实时预览覆盖层 */
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => { const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
titleSettings,
videoRatio,
}) => {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400) const [containerHeight, setContainerHeight] = useState(400)
@@ -118,12 +140,15 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
return () => ro.disconnect() return () => ro.disconnect()
}, []) }, [])
const { width: playResX, height: playResY } = getResolution(videoRatio)
const positionStyle = useMemo( const positionStyle = useMemo(
() => getPositionStyle(titleSettings.position), () => getPositionStyle(titleSettings.position, playResX, playResY),
[titleSettings.position], // eslint-disable-next-line react-hooks/exhaustive-deps
[titleSettings.position, playResX, playResY],
) )
const titleStyle = useMemo( const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight), () => buildTitleStyle(titleSettings, containerHeight, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [
containerHeight, containerHeight,
@@ -134,6 +159,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
titleSettings.italic, titleSettings.italic,
titleSettings.stroke, titleSettings.stroke,
titleSettings.shadow, titleSettings.shadow,
playResY,
], ],
) )
@@ -266,7 +292,9 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
)} )}
{/* 标题样式实时预览层(仅在有视频时叠加) */} {/* 标题样式实时预览层(仅在有视频时叠加) */}
{isReady && titleSettings && <TitleOverlay titleSettings={titleSettings} />} {isReady && titleSettings && (
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
)}
{/* stale 遮罩:配置变更提示 */} {/* stale 遮罩:配置变更提示 */}
{isStale && ( {isStale && (