Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fc62c9476 |
@@ -76,12 +76,59 @@ 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
|
||||
@@ -374,6 +421,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
@@ -434,48 +482,55 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 标题CSS叠加层 — video fallback 路径也要渲染 */}
|
||||
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
|
||||
{titleSettings?.title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
inset: 0,
|
||||
zIndex: 5,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
...(titleSettings.position === "top"
|
||||
? { top: "10%" }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: "15%" }),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<div
|
||||
style={{
|
||||
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%",
|
||||
position: "absolute",
|
||||
left: `${titleSidePct}%`,
|
||||
right: `${titleSidePct}%`,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.3,
|
||||
wordBreak: "break-word",
|
||||
...(titleSettings.position === "top"
|
||||
? { 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(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<PreviewVideoPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* 标题样式实时预览层(仅在有视频时叠加) */}
|
||||
{isReady && titleSettings && <TitleOverlay titleSettings={titleSettings} />}
|
||||
{isReady && titleSettings && (
|
||||
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
|
||||
)}
|
||||
|
||||
{/* stale 遮罩:配置变更提示 */}
|
||||
{isStale && (
|
||||
|
||||
Reference in New Issue
Block a user