diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx
index 531af36ef..bd8bf2b82 100644
--- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx
+++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx
@@ -52,9 +52,16 @@ 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
}