From 608a75ea155b99a3825390b59c9254dee28bdd59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=8F=E6=9D=B0?= Date: Tue, 18 Aug 2026 14:57:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E8=A7=86=E9=A2=91=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=92=8C=E6=A0=87=E9=A2=98=E4=B8=8D=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题1:视频无法播放 - useSegmentScheduler.play() 在 switchToSegment 后立即调用 video.play() - 但 switchToSegment 设置 src 后需要等待 loadedmetadata 事件 - 修复:switchToSegment 返回 Promise,play() 等待加载完成后再播放 - 添加调试日志:打印 videoUrl、加载错误,方便排查 URL 问题 问题2:标题不显示 - TitleOverlay 在 title 为空时返回 null,不渲染任何内容 - PreviewVideoPanel 的条件 {hasTitle && ...} 导致标题为空时不显示 - 修复:标题为空时显示占位文本标题预览 - 始终渲染 TitleOverlay,让用户能看到样式效果 --- .../components/FrontendPreviewPlayer.tsx | 7 +- .../generate/components/PreviewVideoPanel.tsx | 12 +- .../generate/hooks/useSegmentScheduler.ts | 117 +++++++++++------- 3 files changed, 88 insertions(+), 48 deletions(-) diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 531af36ef..3a6af0ee8 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -52,9 +52,14 @@ function buildPlaybackSegments( const startTime = 0 const endTime = Math.min(startTime + segDuration, assetDuration) + const videoUrl = asset.file_url || asset.storage_key + + // 调试日志:打印每个片段的 videoUrl + console.log(`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl}, file_url=${asset.file_url}, storage_key=${asset.storage_key}`) + segments.push({ assetId: asset.id, - videoUrl: asset.file_url || asset.storage_key, + videoUrl, startTime, endTime, order: i, diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 2976a8156..430a44713 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -103,6 +103,7 @@ function buildTitleStyle(settings: TitleSettings): React.CSSProperties { /** * CSS 标题预览覆盖层 + * 始终渲染:有标题显示标题,无标题显示占位文本"标题预览" */ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => { const positionStyle = useMemo( @@ -124,7 +125,8 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting ], ) - if (!titleSettings.title?.trim()) return null + // 标题为空时显示占位文本 + const displayTitle = titleSettings.title?.trim() || "标题预览" return (
= ({ titleSetting position: "absolute", }} > - {titleSettings.title} + {displayTitle}
) @@ -160,7 +162,6 @@ export const PreviewVideoPanel: React.FC = ({ titleSettings, }) => { const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") } - const hasTitle = !!titleSettings?.title?.trim() return (
@@ -200,8 +201,9 @@ export const PreviewVideoPanel: React.FC = ({ )} {/* CSS 标题实时预览层 — 与 FFmpeg ASS 渲染坐标对齐 - 始终渲染在内容层之上(z-index: 30),只要 titleSettings 有标题文字就显示 */} - {hasTitle && } + 始终渲染在内容层之上(z-index: 30) + 有标题显示标题,无标题显示"标题预览"占位文本 */} + {titleSettings && }
{/* 素材信息 */} diff --git a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts index 28d430bd5..8cd05cf96 100644 --- a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts +++ b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts @@ -109,34 +109,69 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul ? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime : 0 - /** 切换到指定片段 */ + /** 切换到指定片段,返回 Promise 在视频可播放后 resolve */ const switchToSegment = useCallback( - (index: number, seekToLocalTime?: number) => { - const video = videoRef.current - if (!video || index >= segments.length) return - - const seg = segments[index] - video.src = seg.videoUrl - - const localTime = seekToLocalTime ?? seg.startTime - // 等待 src 设置后再设置 currentTime - const onLoaded = () => { - video.currentTime = localTime - video.removeEventListener("loadedmetadata", onLoaded) - } - video.addEventListener("loadedmetadata", onLoaded) - - setCurrentSegmentIndex(index) - - // 预加载下一段 - if (index + 1 < segments.length) { - const nextSeg = segments[index + 1] - if (!preloadVideoRef.current) { - preloadVideoRef.current = document.createElement("video") - preloadVideoRef.current.preload = "auto" + (index: number, seekToLocalTime?: number): Promise => { + return new Promise((resolve) => { + const video = videoRef.current + if (!video || index >= segments.length) { + resolve() + return } - preloadVideoRef.current.src = nextSeg.videoUrl - } + + const seg = segments[index] + const videoUrl = seg.videoUrl + + // 调试日志:检查 videoUrl 是否有效 + if (!videoUrl || videoUrl === "") { + console.error( + `[useSegmentScheduler] 片段 ${index} 的 videoUrl 为空!assetId: ${seg.assetId}`, + ) + resolve() + return + } + + // 检查是否是完整的 HTTP URL + if (!videoUrl.startsWith("http://") && !videoUrl.startsWith("https://")) { + console.warn( + `[useSegmentScheduler] 片段 ${index} 的 videoUrl 不是完整 URL: ${videoUrl},可能导致加载失败`, + ) + } + + video.src = videoUrl + + const localTime = seekToLocalTime ?? seg.startTime + // 等待 src 设置后再设置 currentTime 并 resolve + const onLoaded = () => { + video.currentTime = localTime + video.removeEventListener("loadedmetadata", onLoaded) + resolve() + } + video.addEventListener("loadedmetadata", onLoaded) + + // 监听加载错误 + const onError = () => { + console.error( + `[useSegmentScheduler] 视频加载失败: ${videoUrl}`, + video.error?.message || "unknown error", + ) + video.removeEventListener("error", onError) + resolve() // 即使失败也 resolve,让调用方继续处理 + } + video.addEventListener("error", onError) + + setCurrentSegmentIndex(index) + + // 预加载下一段 + if (index + 1 < segments.length) { + const nextSeg = segments[index + 1] + if (!preloadVideoRef.current) { + preloadVideoRef.current = document.createElement("video") + preloadVideoRef.current.preload = "auto" + } + preloadVideoRef.current.src = nextSeg.videoUrl + } + }) }, [segments], ) @@ -156,7 +191,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul if (video.currentTime >= seg.endTime - 0.15) { const nextIndex = currentSegmentIndex + 1 if (nextIndex < segments.length) { - // 切到下一段 + // 切到下一段(不等待,直接继续 tick) switchToSegment(nextIndex) const accumulatedTime = (timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime) @@ -186,18 +221,21 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul setIsEnded(false) - // 如果还没设置 src(首次播放),先加载第一段 + // 如果还没设置 src(首次播放),先加载第一段并等待 if (!video.src || video.src === "") { - switchToSegment(0) + console.log("[useSegmentScheduler] 首次播放,加载片段 0...") + await switchToSegment(0) + // switchToSegment 会等待 loadedmetadata } try { await video.play() + console.log("[useSegmentScheduler] 播放开始") setIsPlaying(true) rafRef.current = requestAnimationFrame(tick) - } catch { + } catch (err) { // 自动播放可能被浏览器阻止,忽略 - console.warn("[useSegmentScheduler] auto-play blocked by browser") + console.warn("[useSegmentScheduler] 播放失败:", err) } }, [canPlay, switchToSegment, tick]) @@ -217,18 +255,15 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul if (isEnded) { // 播放结束后再次播放,从头开始 setIsEnded(false) - switchToSegment(0, segments[0]?.startTime) - const video = videoRef.current - if (video) { - const onSeeked = () => { + switchToSegment(0, segments[0]?.startTime).then(() => { + const video = videoRef.current + if (video) { video.play().catch(() => {}) setIsPlaying(true) setCurrentTime(0) rafRef.current = requestAnimationFrame(tick) - video.removeEventListener("seeked", onSeeked) } - video.addEventListener("seeked", onSeeked) - } + }) } else { play() } @@ -237,7 +272,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul /** 跳转到指定全局时间 */ const seekTo = useCallback( - (time: number) => { + async (time: number) => { const video = videoRef.current if (!video || !canPlay) return @@ -248,9 +283,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul // 如果片段变了,需要切换 src if (index !== currentSegmentIndex) { - switchToSegment(index, localTime) - // switchToSegment 会设置 src 并在 loadedmetadata 后设置 currentTime - // 所以这里不需要再设置 + await switchToSegment(index, localTime) } else { video.currentTime = localTime } -- 2.54.0 From 4456c54f8c8f211bf78d3f40ab1b23aaa6ade771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=8F=E6=9D=B0?= Date: Tue, 18 Aug 2026 15:04:20 +0800 Subject: [PATCH 2/2] style: prettier format --- .../src/pages/generate/components/FrontendPreviewPlayer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 3a6af0ee8..bd8bf2b82 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -55,7 +55,9 @@ function buildPlaybackSegments( const videoUrl = asset.file_url || asset.storage_key // 调试日志:打印每个片段的 videoUrl - console.log(`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl}, file_url=${asset.file_url}, storage_key=${asset.storage_key}`) + console.log( + `[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl}, file_url=${asset.file_url}, storage_key=${asset.storage_key}`, + ) segments.push({ assetId: asset.id, -- 2.54.0