diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 37559d3ec..8077c0a2c 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -75,24 +75,37 @@ function formatTime(seconds: number): string { function buildPlaybackSegments( assets: AssetItem[], serverClips?: EditPlanClip[], + /** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */ + speedFactor = 1, ): PlaybackSegment[] { if (!assets.length || !serverClips || serverClips.length === 0) return [] const assetMap = new Map(assets.map((a) => [a.id, a])) const segments: PlaybackSegment[] = [] + // speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速 + const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1 for (const clip of serverClips) { const asset = assetMap.get(clip.asset_id) if (!asset) continue const assetDuration = asset.duration || asset.metadata?.duration || 0 const startTime = clip.start_time || 0 // 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界 - const clipDuration = clip.duration || 0 + const rawClipDuration = clip.duration || 0 + // #1754:按 speedFactor 缩放片段时长,使总时长匹配配音 + const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration const endTime = assetDuration > 0 ? Math.min(startTime + clipDuration, assetDuration) : startTime + clipDuration const videoUrl = asset.file_url || asset.storage_key - segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order }) + segments.push({ + assetId: asset.id, + videoUrl, + startTime, + endTime, + order: clip.order, + playbackRate: invSpeed !== 1 ? speedFactor : undefined, + }) } return segments.sort((a, b) => a.order - b.order) } @@ -111,13 +124,50 @@ const FrontendPreviewPlayer: React.FC = ({ activePlayToken = null, onPlayTokenChange, }) => { - const segments = useMemo(() => buildPlaybackSegments(assets, serverClips), [assets, serverClips]) + // #1754:测量配音时长,计算缩放因子 + const [voiceDuration, setVoiceDuration] = useState(0) + useEffect(() => { + if (!voiceAudioUrl) { + setVoiceDuration(0) + return + } + const audio = new Audio() + audio.preload = "metadata" + const onLoaded = () => { + if (audio.duration && isFinite(audio.duration)) { + setVoiceDuration(audio.duration) + } + } + audio.addEventListener("loadedmetadata", onLoaded) + audio.src = voiceAudioUrl + return () => { + audio.removeEventListener("loadedmetadata", onLoaded) + } + }, [voiceAudioUrl]) + + // 计算 clips 原始总时长 + const rawClipsDuration = useMemo(() => { + if (!serverClips?.length) return 0 + return serverClips.reduce((sum, c) => sum + (c.duration || 0), 0) + }, [serverClips]) + + // #1754:配音时长可用且与 clips 总时长偏差 > 5% 时,按比例调速 + const speedFactor = useMemo(() => { + if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1 + const ratio = rawClipsDuration / voiceDuration + return Math.abs(ratio - 1) > 0.05 ? ratio : 1 + }, [voiceDuration, rawClipsDuration]) + + const segments = useMemo( + () => buildPlaybackSegments(assets, serverClips, speedFactor), + [assets, serverClips, speedFactor], + ) // 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制 const effectiveTitle = variantTitle ?? titleSettings?.title // ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── - const TITLE_MARGIN_TOP = 120 - const TITLE_MARGIN_BOTTOM = 60 + const TITLE_MARGIN_TOP = 180 + const TITLE_MARGIN_BOTTOM = 100 const TITLE_MARGIN_SIDE = 40 const playRes = (() => { switch (videoRatio) { @@ -554,7 +604,7 @@ const FrontendPreviewPlayer: React.FC = ({ maxWidth: compact ? "100%" : 280, margin: compact ? 0 : "0 auto", aspectRatio: "9 / 16", - background: "#0a0a0a", + background: compact ? "transparent" : "#0a0a0a", borderRadius: compact ? 10 : 24, overflow: "hidden", boxShadow: compact @@ -578,7 +628,7 @@ const FrontendPreviewPlayer: React.FC = ({ style={{ width: "100%", height: "100%", - objectFit: "contain", + objectFit: "cover", }} /> @@ -600,7 +650,7 @@ const FrontendPreviewPlayer: React.FC = ({ inset: 0, width: "100%", height: "100%", - objectFit: "contain", + objectFit: "cover", background: "#000", zIndex: 1, opacity: i === videoCurrentSegIdx ? 1 : 0, diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 96302ede3..f49a01b5d 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -3286,7 +3286,7 @@ 中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */ .xx-canvas-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 220px)); + grid-template-columns: repeat(auto-fill, minmax(160px, 180px)); justify-content: center; gap: 14px; } @@ -3303,7 +3303,7 @@ border: 2px solid var(--border-primary, #e2e8f0); border-radius: 12px; overflow: hidden; - background: #000; + background: transparent; transition: border-color 0.2s ease; min-width: 0; } diff --git a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts index 62374469e..5e0676693 100644 --- a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts +++ b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts @@ -15,6 +15,8 @@ export interface PlaybackSegment { startTime: number endTime: number order: number + /** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */ + playbackRate?: number } export interface SegmentSchedulerState { @@ -131,6 +133,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul if (Math.abs(video.currentTime - localTime) > 0.05) { video.currentTime = localTime } + // #1754:按比例调速(配音时长≠clips 总时长时的前端兜底) + const rate = seg.playbackRate || 1 + if (Math.abs(video.playbackRate - rate) > 0.01) { + video.playbackRate = rate + } segIdxRef.current = index setCurrentSegmentIndex(index) @@ -242,6 +249,12 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul video.currentTime = seg.startTime } + // #1754:调速 + const rate = seg?.playbackRate || 1 + if (Math.abs(video.playbackRate - rate) > 0.01) { + video.playbackRate = rate + } + try { await video.play() setIsPlaying(true)