From 03a0ab66dd6221fa85b6ac2ba6cc07db954b5a41 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 7 Sep 2026 13:45:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(generate):=20#1754=20=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=BB=91=E8=BE=B9+=E5=8D=A0=E6=AF=94?= =?UTF-8?q?=E8=BF=87=E5=A4=A7/=E9=85=8D=E9=9F=B3=E6=97=B6=E9=95=BF?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E8=B0=83=E9=80=9F/=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-前端1 黑边+卡片占比: - .xx-canvas-grid grid-template-columns: minmax(200px,220px) → minmax(160px,180px) - .xx-canvas-grid-card background: #000 → transparent - video objectFit: contain → cover(填满卡片不留黑边) - 播放器容器 compact 模式 background → transparent P0-前端2 配音时长兜底: - useSegmentScheduler PlaybackSegment 新增 playbackRate 字段 - switchToSegment/play 中设置 video.playbackRate - FrontendPreviewPlayer 内部用 Audio 测量 voiceDuration - speedFactor = rawClipsDuration / voiceDuration(偏差>5%时启用) - buildPlaybackSegments 按 1/speedFactor 缩放 endTime 并写入 playbackRate - 后端已按配音时长分配 clips(#1752),此处为前端安全兜底 P1-前端3 标题位置优化: - TITLE_MARGIN_TOP 120→180(ASS 坐标 9:16 下 6.25%→9.4%) - TITLE_MARGIN_BOTTOM 60→100(3.1%→5.2%) - 注:后端 ass_subtitle_builder.py 需同步更新以保持预览=成片 --- .../components/FrontendPreviewPlayer.tsx | 66 ++++++++++++++++--- apps/web/src/pages/generate/generate.css | 4 +- .../generate/hooks/useSegmentScheduler.ts | 13 ++++ 3 files changed, 73 insertions(+), 10 deletions(-) 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) -- 2.54.0