feat(#1741): 批量预览卡片缩小至3/5 + 每个视频播放都有声音(配音全挂载+播放互斥+静音按钮) #1742

Merged
auto-approve-bot merged 1 commits from feat/1741-preview-grid-audio into develop 2026-09-06 15:41:36 +08:00
5 changed files with 423 additions and 19 deletions
@@ -6,8 +6,10 @@
* - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用
* - 勾选框决定提交时生成哪些变体
* - 每个变体都挂载同一条配音 URL(浏览器缓存不重复下载);播放互斥:
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)
*/
import React from "react"
import React, { useState } from "react"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import type { TitleSettings } from "../types"
@@ -20,7 +22,7 @@ interface CanvasPreviewGridProps {
videoRatio: string
titles: string[]
titleSettings: TitleSettings
/** 共用配音预览音频(仅第 1 个变体播放,避免多路音频重叠 */
/** 共用配音预览音频 URL(#1741:每个变体都挂载,播放互斥保证同一时刻只有一路发声 */
voiceAudioUrl?: string
/** 勾选的变体序号 */
selectedIds: number[]
@@ -41,6 +43,10 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
onToggleSelect,
selectable = true,
}) => {
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权,点击其他卡片自动暂停当前卡片 ──
// token 用 variantSeedi+1),与 FrontendPreviewPlayer 内部 variantSeed 一致
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10clamp
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
return (
@@ -71,7 +77,9 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
ready={assets.length > 0}
variantSeed={i + 1}
variantTitle={titles[i] || ""}
voiceAudioUrl={i === 0 ? voiceAudioUrl : undefined}
voiceAudioUrl={voiceAudioUrl}
activePlayToken={activePlayToken}
onPlayTokenChange={setActivePlayToken}
compact
titleSettings={{
title: titles[i] || "",
@@ -13,6 +13,8 @@ import {
PauseCircleOutlined,
SoundOutlined,
LoadingOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
@@ -51,6 +53,13 @@ interface FrontendPreviewPlayerProps {
variantTitle?: string
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
compact?: boolean
/**
* 批量网格播放互斥(#1741):当前持有播放权的实例 tokenvariantSeed)。
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
*/
activePlayToken?: number | null
/** 播放权变化回调:本实例请求播放时传自身 variantSeed,暂停时传 null */
onPlayTokenChange?: (token: number | null) => void
}
function formatTime(seconds: number): string {
@@ -157,6 +166,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
variantSeed = 0,
variantTitle,
compact = false,
activePlayToken = null,
onPlayTokenChange,
}) => {
const segments = useMemo(
() => buildPlaybackSegments(assets, template, serverClips, variantSeed),
@@ -339,6 +350,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
canPlay: videoCanPlay,
togglePlayPause: videoTogglePlayPause,
seekTo: videoSeekTo,
pause: videoPause,
videoRefs,
} = useSegmentScheduler(segments)
@@ -353,6 +365,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
const [muted, setMuted] = useState(false)
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
const hasVoice = !!voiceAudioUrl
useEffect(() => {
if (!voiceAudioUrl) {
@@ -370,7 +386,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
}, [voiceAudioUrl])
audioRef.current.muted = muted
}, [voiceAudioUrl, muted])
useEffect(() => {
const audio = audioRef.current
@@ -409,17 +426,42 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
)
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
useEffect(() => {
if (activePlayToken == null || activePlayToken === variantSeed) return
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) canvasControls.pause()
} else if (isPlaying) {
videoPause()
}
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayToken, variantSeed, effectiveUseWebCodecs])
const handleTogglePlay = useCallback(() => {
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) {
canvasControls.pause()
onPlayTokenChange?.(null)
} else {
onPlayTokenChange?.(variantSeed)
canvasControls.play()
}
} else {
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
onPlayTokenChange?.(isPlaying ? null : variantSeed)
videoTogglePlayPause()
}
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
}, [
effectiveUseWebCodecs,
canvasState.isPlaying,
canvasControls,
videoTogglePlayPause,
isPlaying,
variantSeed,
onPlayTokenChange,
])
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
@@ -608,7 +650,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
segments.map((seg, i) => (
<video
key={seg.assetId}
muted
muted={hasVoice || muted}
ref={(el) => {
videoRefs.current[i] = el
}}
@@ -743,6 +785,45 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</button>
)}
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
{segments.length > 0 && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={() => setMuted((m) => !m)}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
@@ -753,9 +834,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: 10,
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: "2px 8px",
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
@@ -774,8 +855,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
right: 0,
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 16px 16px",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
@@ -788,10 +869,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: 16,
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: 32,
height: 32,
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
@@ -811,9 +892,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: 11,
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: 72,
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
+13 -4
View File
@@ -3282,10 +3282,20 @@
/* ============================================================
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
============================================================ */
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
.xx-canvas-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 220px));
justify-content: center;
gap: 14px;
}
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
@media (max-width: 960px) {
.xx-canvas-grid {
grid-template-columns: minmax(0, 320px);
}
}
.xx-canvas-grid-card {
@@ -3435,9 +3445,8 @@
word-break: break-word;
}
/* ── 响应式:窄屏批量网格回退单列 ── */
/* ── 响应式:窄屏批量网格回退单列.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
@media (max-width: 960px) {
.xx-canvas-grid,
.xx-batch-gen-grid {
grid-template-columns: 1fr;
}
@@ -0,0 +1,123 @@
/**
* CanvasPreviewGrid 单测(Issue #1741
*
* 验证:
* - N=3 时每个变体都拿到 voiceAudioUrl(修复前仅 index 0 有,视频 2/3 无声)
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
* - 实例上报暂停(null)→ 播放权释放
*/
import { describe, it, expect, vi } from "vitest"
import { render, fireEvent } from "@testing-library/react"
import type { AssetItem } from "@/api/assets"
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
default: (props: Record<string, unknown>) => {
playerCalls.push(props)
const seed = props.variantSeed as number
const token = props.activePlayToken as number | null
const change = props.onPlayTokenChange as (t: number | null) => void
return (
<div data-testid={`player-${seed}`}>
<span data-testid={`voice-${seed}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
<span data-testid={`token-${seed}`}>{token == null ? "none" : String(token)}</span>
<button type="button" onClick={() => change(seed)}>
play-{seed}
</button>
<button type="button" onClick={() => change(null)}>
pause-{seed}
</button>
</div>
)
},
}))
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10 },
duration: 10,
}
}
const titleSettings = {
size: 36,
font: "思源黑体",
color: "#fff",
position: "bottom" as const,
bold: false,
italic: false,
stroke: true,
shadow: false,
posX: null,
posY: null,
}
function renderGrid(count = 3) {
playerCalls.length = 0
return render(
<CanvasPreviewGrid
count={count}
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
template={null}
videoRatio="9:16"
titles={["标题1", "标题2", "标题3"]}
titleSettings={titleSettings}
voiceAudioUrl="https://cdn.example.com/tts.mp3"
selectedIds={[0, 1, 2]}
onToggleSelect={() => {}}
/>,
)
}
describe("CanvasPreviewGrid 配音与播放互斥 (#1741)", () => {
it("N=3 时每个变体都拿到 voiceAudioUrl(不再只有 index 0", () => {
renderGrid(3)
expect(playerCalls).toHaveLength(3)
playerCalls.forEach((p, i) => {
expect(p.voiceAudioUrl).toBe("https://cdn.example.com/tts.mp3")
expect(p.variantSeed).toBe(i + 1)
})
})
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
renderGrid(3)
playerCalls.forEach((p) => {
expect(p.activePlayToken).toBeNull()
expect(typeof p.onPlayTokenChange).toBe("function")
})
})
it("点击实例2播放:所有实例 activePlayToken 变为 2(其他实例自动暂停)", () => {
const { getByTestId } = renderGrid(3)
fireEvent.click(getByTestId("player-2").querySelector("button")!)
expect(getByTestId("token-1").textContent).toBe("2")
expect(getByTestId("token-2").textContent).toBe("2")
expect(getByTestId("token-3").textContent).toBe("2")
})
it("正在播放实例上报暂停后,播放权释放(token 回 null", () => {
const { getByTestId } = renderGrid(3)
fireEvent.click(getByTestId("player-3").querySelector("button")!)
expect(getByTestId("token-1").textContent).toBe("3")
fireEvent.click(getByTestId("player-3").querySelectorAll("button")[1])
expect(getByTestId("token-3").textContent).toBe("none")
})
it("渲染 N 个勾选标签(视频1..N)且配音状态全部 has-voice", () => {
const { getByTestId } = renderGrid(3)
expect(getByTestId("voice-1").textContent).toBe("has-voice")
expect(getByTestId("voice-2").textContent).toBe("has-voice")
expect(getByTestId("voice-3").textContent).toBe("has-voice")
})
})
@@ -0,0 +1,183 @@
/**
* FrontendPreviewPlayer 音频行为单测(Issue #1741
*
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
* - 批量播放互斥:activePlayToken 变为其他实例且本实例在播放时,调用 pause
* - 点击播放/暂停时上报播放权(onPlayTokenChange
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
import type { AssetItem } from "@/api/assets"
const mocks = vi.hoisted(() => ({
isPlaying: false,
pause: vi.fn(),
togglePlayPause: vi.fn(),
}))
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
useSegmentScheduler: () => ({
isPlaying: mocks.isPlaying,
currentTime: 0,
totalDuration: 20,
currentSegmentIndex: 0,
canPlay: true,
togglePlayPause: mocks.togglePlayPause,
seekTo: vi.fn(),
pause: mocks.pause,
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
}),
}))
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
useCanvasPlayer: () => ({
state: {
isPlaying: false,
isReady: false,
isBuffering: false,
currentTime: 0,
duration: 0,
errorMessage: "",
hasDecodeError: false,
},
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
}),
}))
beforeEach(() => {
mocks.isPlaying = false
mocks.pause.mockClear()
mocks.togglePlayPause.mockClear()
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10, width: 1080, height: 1920 },
duration: 10,
}
}
const baseProps = {
assets: [makeAsset("a1"), makeAsset("a2")],
template: null,
videoRatio: "9:16",
ready: true,
}
function videos(): HTMLVideoElement[] {
return Array.from(document.querySelectorAll("video"))
}
describe("FrontendPreviewPlayer 音频行为 (#1741)", () => {
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
expect(videos()).toHaveLength(2)
videos().forEach((v) => expect(v.muted).toBe(true))
})
it("无配音时 video 不 muted(素材原声兜底)", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
const vs = videos()
expect(vs[0].muted).toBe(false)
fireEvent.click(screen.getByRole("button", { name: "静音" }))
videos().forEach((v) => expect(v.muted).toBe(true))
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
mocks.isPlaying = true
const { rerender } = render(
<FrontendPreviewPlayer
{...baseProps}
variantSeed={2}
activePlayToken={2}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).not.toHaveBeenCalled()
// 播放权切给实例 3
rerender(
<FrontendPreviewPlayer
{...baseProps}
variantSeed={2}
activePlayToken={3}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
// token 切回自己:不重复暂停
rerender(
<FrontendPreviewPlayer
{...baseProps}
variantSeed={2}
activePlayToken={2}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
})
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
// mocks.isPlaying = falsebeforeEach 重置)
const { rerender } = render(
<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={1} compact />,
)
rerender(<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={2} compact />)
expect(mocks.pause).not.toHaveBeenCalled()
})
it("暂停状态下点击播放按钮:上报播放权为自身 variantSeed 并触发播放", () => {
const onToken = vi.fn()
render(
<FrontendPreviewPlayer {...baseProps} variantSeed={3} onPlayTokenChange={onToken} compact />,
)
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
expect(playButtons.length).toBeGreaterThanOrEqual(1)
fireEvent.click(playButtons[0])
expect(onToken).toHaveBeenCalledWith(3)
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
})
it("每个卡片都渲染独立静音按钮", () => {
render(
<div>
<FrontendPreviewPlayer {...baseProps} variantSeed={1} compact />
<FrontendPreviewPlayer {...baseProps} variantSeed={2} compact />
<FrontendPreviewPlayer {...baseProps} variantSeed={3} compact />
</div>,
)
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
})
})