diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index fef2ca907..89caf44f1 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -90,7 +90,7 @@ const Step4TitleSettings: React.FC = (props) => { ) return ( -
+

📝 选择标题

{!isBatch ? ( diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 95c5430c8..bf1325830 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -2880,11 +2880,6 @@ Issue #1677 多视频批量生成 ================================================================ */ -/* ── 标题设置区域(全宽区块,不再受侧边栏约束) ── */ -.xx-title-sidebar { - /* no max-height / overflow constraints */ -} - /* ── 数量选择弹窗 ── */ .xx-modal-mask { position: fixed; @@ -3224,13 +3219,6 @@ border-radius: 4px; } -/* ── 响应式:窄屏标题区域 ── */ -@media (max-width: 960px) { - .xx-title-sidebar { - max-height: none; - } -} - .xx-batch-titles { display: flex; flex-direction: column; diff --git a/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx b/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx deleted file mode 100644 index b1d798ab9..000000000 --- a/apps/web/src/test/pages/generate/CanvasPreviewGrid.audio.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/** - * CanvasPreviewGrid 单测(Issue #1741 / #1750) - * - * 验证: - * - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中) - * - 每个变体都拿到各自的后端真实片段 variantClips[i](serverClips),playToken 为 0 基变体序号 - * - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线) - * - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停) - * - 实例上报暂停(null)→ 播放权释放 - * - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器), - * 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips - * - #1750 加载态:渲染加载占位,不渲染播放器 - */ -import { describe, it, expect, beforeEach, vi } from "vitest" -import { render, fireEvent, screen } from "@testing-library/react" -import type { AssetItem } from "@/api/assets" -import type { EditPlanClip } from "@/api/template-editor" - -const playerCalls = vi.hoisted(() => [] as Array>) - -vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({ - default: (props: Record) => { - playerCalls.push(props) - const token = props.playToken as number - const active = props.activePlayToken as number | null - const change = props.onPlayTokenChange as (t: number | null) => void - return ( -
- {props.voiceAudioUrl ? "has-voice" : "no-voice"} - {active == null ? "none" : String(active)} - - -
- ) - }, -})) - -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, - } -} - -function makeClips(variant: number): EditPlanClip[] { - return ["a1", "a2"].map((assetId, order) => ({ - id: `clip-v${variant}-${assetId}`, - plan_id: `plan-${variant}`, - clip_type: "main", - order, - asset_id: assetId, - text_content: "", - start_time: 0, - duration: 5, - transition_effect: "none", - transition_duration: 0, - playback_speed: 1, - status: "ready", - config: {}, - })) -} - -const titleSettings = { - size: 36, - font: "思源黑体", - color: "#fff", - position: "bottom" as const, - bold: false, - italic: false, - stroke: true, - shadow: false, - posX: null, - posY: null, -} - -const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)] - -function renderGrid(overrides: Record = {}) { - playerCalls.length = 0 - return render( - {}} - {...overrides} - />, - ) -} - -describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => { - beforeEach(() => { - playerCalls.length = 0 - }) - - it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => { - renderGrid() - expect(playerCalls).toHaveLength(3) - playerCalls.forEach((p, i) => { - expect(p.playToken).toBe(i) - expect(p.serverClips).toBe(variantClips[i]) - expect(p.variantTitle).toBe(`标题${i + 1}`) - }) - // 独立配音:变体 0/2 有各自 URL;变体 1 为 null(TTS 合成中)→ 传 undefined - expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3") - expect(playerCalls[1].voiceAudioUrl).toBeUndefined() - expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3") - // 配音状态徽标 - expect(screen.getByTestId("voice-0").textContent).toBe("has-voice") - expect(screen.getByTestId("voice-1").textContent).toBe("no-voice") - expect(screen.getByTestId("voice-2").textContent).toBe("has-voice") - }) - - it("每个变体都接线 activePlayToken / onPlayTokenChange", () => { - renderGrid() - playerCalls.forEach((p) => { - expect(p.activePlayToken).toBeNull() - expect(typeof p.onPlayTokenChange).toBe("function") - }) - }) - - it("点击变体2播放:所有实例 activePlayToken 变为 1(0 基,其他实例自动暂停)", () => { - renderGrid() - fireEvent.click(screen.getByTestId("player-1").querySelector("button")!) - expect(screen.getByTestId("token-0").textContent).toBe("1") - expect(screen.getByTestId("token-1").textContent).toBe("1") - expect(screen.getByTestId("token-2").textContent).toBe("1") - }) - - it("正在播放实例上报暂停后,播放权释放(token 回 null)", () => { - renderGrid() - fireEvent.click(screen.getByTestId("player-2").querySelector("button")!) - expect(screen.getByTestId("token-0").textContent).toBe("2") - - fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1]) - expect(screen.getByTestId("token-2").textContent).toBe("none") - }) - - it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => { - const onRetry = vi.fn() - renderGrid({ clipsError: true, onRetryClips: onRetry }) - expect(screen.getAllByRole("alert")).toHaveLength(3) - expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3) - // 严禁假数据:错误态不渲染播放器 - expect(playerCalls).toHaveLength(0) - // 重试按钮仅变体 0 卡片出现 - const retryButtons = screen.getAllByText("重试") - expect(retryButtons).toHaveLength(1) - fireEvent.click(retryButtons[0]) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("clipsLoading:渲染加载占位,不渲染播放器", () => { - renderGrid({ clipsLoading: true }) - expect(screen.getAllByText("独立选片中…")).toHaveLength(3) - expect(screen.queryAllByRole("alert")).toHaveLength(0) - expect(playerCalls).toHaveLength(0) - }) -}) diff --git a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx b/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx deleted file mode 100644 index 1379c33fd..000000000 --- a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -/** - * FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750) - * - * useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑: - * - 有配音时 video 保持 muted(素材原声不与配音混音) - * - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声) - * - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换 - * - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause - * - 点击播放/暂停时上报播放权(onPlayTokenChange) - * - #1750:serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback) - */ -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" -import type { EditPlanClip } from "@/api/template-editor" - -const mocks = vi.hoisted(() => ({ - isPlaying: false, - pause: vi.fn(), - togglePlayPause: vi.fn(), -})) - -vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({ - // segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips); - // canPlay 与真实 hook 一致:无片段时不可播放 - useSegmentScheduler: vi.fn((segments: unknown[]) => ({ - isPlaying: mocks.isPlaying, - currentTime: 0, - totalDuration: 20, - currentSegmentIndex: 0, - canPlay: segments.length > 0, - 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, - } -} - -function makeClip(assetId: string, order: number): EditPlanClip { - return { - id: `clip-${assetId}`, - plan_id: "plan-1", - clip_type: "main", - order, - asset_id: assetId, - text_content: "", - start_time: 0, - duration: 5, - transition_effect: "none", - transition_duration: 0, - playback_speed: 1, - status: "ready", - config: {}, - } -} - -const serverClips = [makeClip("a1", 0), makeClip("a2", 1)] - -const baseProps = { - assets: [makeAsset("a1"), makeAsset("a2")], - videoRatio: "9:16", - ready: true, - serverClips, -} - -function videos(): HTMLVideoElement[] { - return Array.from(document.querySelectorAll("video")) -} - -describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => { - it("有配音时 video 保持 muted(素材原声不与配音混音)", () => { - render() - expect(videos()).toHaveLength(2) - videos().forEach((v) => expect(v.muted).toBe(true)) - }) - - it("无配音时 video 不 muted(素材原声兜底)", () => { - render() - videos().forEach((v) => expect(v.muted).toBe(false)) - }) - - it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => { - render() - 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( - {}} - compact - />, - ) - expect(mocks.pause).not.toHaveBeenCalled() - - // 播放权切给实例 2(0 基 token) - rerender( - {}} - compact - />, - ) - expect(mocks.pause).toHaveBeenCalledTimes(1) - - // token 切回自己:不重复暂停 - rerender( - {}} - compact - />, - ) - expect(mocks.pause).toHaveBeenCalledTimes(1) - }) - - it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => { - // mocks.isPlaying = false(beforeEach 重置) - const { rerender } = render( - , - ) - rerender() - expect(mocks.pause).not.toHaveBeenCalled() - }) - - it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => { - const onToken = vi.fn() - render( - , - ) - // 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个 - const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label")) - expect(playButtons.length).toBeGreaterThanOrEqual(1) - fireEvent.click(playButtons[0]) - expect(onToken).toHaveBeenCalledWith(2) - expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1) - }) - - it("每个卡片都渲染独立静音按钮", () => { - render( -
- - - -
, - ) - expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3) - }) - - it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => { - render() - expect(videos()).toHaveLength(0) - expect(screen.getByText("暂无可播放素材")).toBeInTheDocument() - }) -}) diff --git a/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx b/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx deleted file mode 100644 index 553cb4222..000000000 --- a/apps/web/src/test/pages/generate/step5-preview.smoke.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Step4+5 preview smoke test (legacy — preview components removed) - * 保留最小测试确保 vitest related 模式仍能匹配 generate 目录改动 - */ -import { describe, it, expect } from "vitest" - -import "@/pages/generate/hooks/useStepNavigation" -import "@/pages/generate/components/GenerateStepContent" -import "@/pages/generate/GeneratePage" - -describe("Generate page smoke test", () => { - it("should load generate modules", () => { - expect(true).toBe(true) - }) -})