Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fc62c9476 |
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user