Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia a128dcd76d fix: 预览标题换行同时匹配半角/和全角/
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 49s
AI Code Review / AI Code Review (pull_request) Failing after 1m25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m22s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m44s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 30s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m28s
CI/CD Pipeline / CI Gate (pull_request) Successful in 12s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 526h32m54s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 526h32m56s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 526h32m58s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 526h32m58s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 526h33m0s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 526h33m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 526h42m56s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 526h43m8s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 526h43m10s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 526h43m54s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 526h43m56s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 526h43m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 526h43m58s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 526h44m0s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 526h44m0s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 526h44m0s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 527h6m51s
PreviewVideoPanel 的 displayTitle.split("/") 改为正则 split(/[//]/),
与 useCanvasPlayer.ts 保持一致,中文输入法下的全角斜杠也能触发换行。
2026-08-25 16:41:44 +08:00
2 changed files with 49 additions and 132 deletions
@@ -76,59 +76,12 @@ function buildPlaybackSegments(
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
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<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)
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
const useWebCodecs = false
@@ -421,7 +374,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
return (
<div
ref={playerContainerRef}
style={{
position: "relative",
width: "100%",
@@ -482,55 +434,48 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{/* 标题CSS叠加层 — video fallback 路径也要渲染 */}
{titleSettings?.title && (
<div
style={{
position: "absolute",
inset: 0,
left: 0,
right: 0,
zIndex: 5,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
overflow: "hidden",
...(titleSettings.position === "top"
? { top: "10%" }
: titleSettings.position === "center"
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: "15%" }),
}}
>
<div
<span
style={{
position: "absolute",
left: `${titleSidePct}%`,
right: `${titleSidePct}%`,
fontSize: titleSettings.size,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
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",
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: `${titleBottomPct}%` }),
lineHeight: 1.3,
wordBreak: "break-word",
}}
>
<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>
{titleSettings.title}
</span>
</div>
)}
@@ -40,33 +40,17 @@ interface PreviewVideoPanelProps {
}
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
const TITLE_MARGIN_TOP = 60
const TITLE_MARGIN_BOTTOM = 60
const TITLE_MARGIN_SIDE = 40
const ASS_VIDEO_HEIGHT = 720
const ASS_TITLE_MARGIN_TOP = 60
const ASS_TITLE_MARGIN_BOTTOM = 60
const ASS_TITLE_MARGIN_SIDE = 40
/** 根据视频比例返回后端实际渲染分辨率(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
function getPositionStyle(position: string): React.CSSProperties {
const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100
switch (position) {
case "bottom":
return {
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
@@ -82,7 +66,7 @@ function getPositionStyle(
case "top":
default:
return {
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
@@ -90,16 +74,11 @@ function getPositionStyle(
}
}
function buildTitleStyle(
settings: TitleSettings,
containerHeight: number,
playResY: number,
): React.CSSProperties {
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties {
const fontSizePx =
containerHeight > 0
? (settings.size / playResY) * containerHeight
: (settings.size / playResY) * 400
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400
const base: React.CSSProperties = {
fontFamily: getFontFamily(settings.font),
@@ -111,6 +90,8 @@ function buildTitleStyle(
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)"
@@ -118,10 +99,7 @@ function buildTitleStyle(
}
/** CSS 标题实时预览覆盖层 */
const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
titleSettings,
videoRatio,
}) => {
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400)
@@ -140,15 +118,12 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string
return () => ro.disconnect()
}, [])
const { width: playResX, height: playResY } = getResolution(videoRatio)
const positionStyle = useMemo(
() => getPositionStyle(titleSettings.position, playResX, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps
[titleSettings.position, playResX, playResY],
() => getPositionStyle(titleSettings.position),
[titleSettings.position],
)
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight, playResY),
() => buildTitleStyle(titleSettings, containerHeight),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
containerHeight,
@@ -159,7 +134,6 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string
titleSettings.italic,
titleSettings.stroke,
titleSettings.shadow,
playResY,
],
)
@@ -292,9 +266,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
)}
{/* 标题样式实时预览层(仅在有视频时叠加) */}
{isReady && titleSettings && (
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
)}
{isReady && titleSettings && <TitleOverlay titleSettings={titleSettings} />}
{/* stale 遮罩:配置变更提示 */}
{isStale && (