diff --git a/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx b/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx new file mode 100644 index 000000000..d16ce4492 --- /dev/null +++ b/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx @@ -0,0 +1,235 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/ShortcutsContext", async () => { + const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts"); + return { + useShortcuts: () => ({ + shortcuts: DEFAULT_SHORTCUTS, + isMac: false, + isConfigOpen: false, + openConfig: vi.fn(), + closeConfig: vi.fn(), + setShortcuts: vi.fn(), + persistShortcuts: () => Promise.resolve(true), + }), + }; +}); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ + locale: "en", + setLocale: vi.fn(), + }), + useScopedT: () => (key: string) => key, +})); + +import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext"; +import { AUDIO_ROW_EXPANSION_PX } from "@/lib/ai-edition/document/audioTracks"; +import { createAudioTrack, createEmptyDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { + DEFAULT_TIMELINE_HEIGHT_PX, + MAX_TIMELINE_HEIGHT_PX, + MIN_TIMELINE_HEIGHT_PX, + NewEditorShell, +} from "./NewEditorShell"; + +function renderShell() { + return render( + + + , + ); +} + +describe("NewEditorShell timeline height", () => { + beforeEach(() => { + localStorage.clear(); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + onAiEditionChatEvent: () => () => { + /* unsubscribe */ + }, + sendAiEditionChatPrompt: () => { + /* mock */ + }, + setTitleBarOverlay: () => { + /* noop */ + }, + setHasUnsavedChanges: () => { + /* noop */ + }, + onRequestCloseConfirm: () => () => { + /* unsubscribe */ + }, + onRequestSaveBeforeClose: () => () => { + /* unsubscribe */ + }, + sendCloseConfirmResponse: () => { + /* noop */ + }, + findRecordingCamera: () => Promise.resolve(null), + preparePreviewAudioTrack: () => Promise.resolve(null), + isAppPackaged: () => false, + }; + Element.prototype.scrollTo = () => { + /* no scrolling in jsdom */ + }; + (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class { + observe() { + /* noop */ + } + unobserve() { + /* noop */ + } + disconnect() { + /* noop */ + } + }; + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it("exports expected constants allowing all lanes to fit without vertical clipping", () => { + expect(DEFAULT_TIMELINE_HEIGHT_PX).toBe(392); + expect(MIN_TIMELINE_HEIGHT_PX).toBe(160); + expect(MAX_TIMELINE_HEIGHT_PX).toBe(560); + }); + + it("uses DEFAULT_TIMELINE_HEIGHT_PX on first opening when localStorage has no saved height", () => { + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + expect(root).not.toBeNull(); + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); + }); + + it("migrates legacy cramped 308px default to DEFAULT_TIMELINE_HEIGHT_PX and persists it", () => { + localStorage.setItem("os-editor-timeline-height", "308"); + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); + expect(localStorage.getItem("os-editor-timeline-height")).toBe( + String(DEFAULT_TIMELINE_HEIGHT_PX), + ); + expect(localStorage.getItem("os-editor-timeline-height-migrated")).toBe("true"); + }); + + it("migrates intermediate 344px default to DEFAULT_TIMELINE_HEIGHT_PX and persists it", () => { + localStorage.setItem("os-editor-timeline-height", "344"); + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); + expect(localStorage.getItem("os-editor-timeline-height")).toBe( + String(DEFAULT_TIMELINE_HEIGHT_PX), + ); + }); + + it("preserves an intentional user choice of 308px after migration has run", () => { + localStorage.setItem("os-editor-timeline-height-migrated", "true"); + localStorage.setItem("os-editor-timeline-height", "308"); + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + expect(root.style.gridTemplateRows).toBe("58px 1fr 308px"); + }); + + it("respects a custom user preference saved in localStorage within valid bounds", () => { + localStorage.setItem("os-editor-timeline-height", "450"); + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + expect(root.style.gridTemplateRows).toBe("58px 1fr 450px"); + }); + + it("clamps out-of-bounds custom heights from localStorage at lower and upper bounds", () => { + localStorage.setItem("os-editor-timeline-height-migrated", "true"); + localStorage.setItem("os-editor-timeline-height", "50"); + const { container: c1 } = renderShell(); + const root1 = c1.firstElementChild as HTMLElement; + expect(root1.style.gridTemplateRows).toBe(`58px 1fr ${MIN_TIMELINE_HEIGHT_PX}px`); + + cleanup(); + localStorage.setItem("os-editor-timeline-height", "999"); + const { container: c2 } = renderShell(); + const root2 = c2.firstElementChild as HTMLElement; + expect(root2.style.gridTemplateRows).toBe(`58px 1fr ${MAX_TIMELINE_HEIGHT_PX}px`); + }); + + it("clamps pointer resizing through startTimelineResize to MIN and MAX bounds", () => { + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + const handle = container.querySelector( + '[role="separator"][aria-orientation="horizontal"]', + ) as HTMLElement; + expect(handle).not.toBeNull(); + + // Drag down significantly (clientY increases): should clamp to MIN_TIMELINE_HEIGHT_PX + act(() => { + fireEvent.pointerDown(handle, { clientY: 400 }); + fireEvent.pointerMove(window, { clientY: 1000 }); + }); + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${MIN_TIMELINE_HEIGHT_PX}px`); + act(() => { + fireEvent.pointerUp(window); + }); + expect(localStorage.getItem("os-editor-timeline-height")).toBe(String(MIN_TIMELINE_HEIGHT_PX)); + + // Drag up significantly (clientY decreases): should clamp to MAX_TIMELINE_HEIGHT_PX + act(() => { + fireEvent.pointerDown(handle, { clientY: 400 }); + fireEvent.pointerMove(window, { clientY: -500 }); + }); + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${MAX_TIMELINE_HEIGHT_PX}px`); + act(() => { + fireEvent.pointerUp(window); + }); + expect(localStorage.getItem("os-editor-timeline-height")).toBe(String(MAX_TIMELINE_HEIGHT_PX)); + }); + + it("dynamically expands height by AUDIO_ROW_EXPANSION_PX when transitioning from 1 to 2 audio lanes and shrinks back", () => { + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); + + // Add voiceover and music tracks (creating 2 audio rows) + const doc = createEmptyDocument({ projectId: "p", title: "t" }); + doc.audioTracks = [ + createAudioTrack({ + assetId: "a1", + durationSec: 5, + timelineStartSec: 0, + spanSec: 5, + kind: "voiceover", + }), + createAudioTrack({ + assetId: "a2", + durationSec: 5, + timelineStartSec: 0, + spanSec: 5, + kind: "music", + }), + ]; + + act(() => { + useProjectStore.setState({ document: doc }); + }); + + const expectedExpandedHeight = DEFAULT_TIMELINE_HEIGHT_PX + AUDIO_ROW_EXPANSION_PX; + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${expectedExpandedHeight}px`); + + // Remove music track (returning to 1 audio row) + const singleTrackDoc = { + ...doc, + audioTracks: [doc.audioTracks[0]], + }; + + act(() => { + useProjectStore.setState({ document: singleTrackDoc }); + }); + + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); + }); +}); diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 6404c3fdb..1a5be2ab5 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -5,6 +5,10 @@ import { toFileUrl } from "@/components/video-editor/projectPersistence"; import { useEditorDialogActions } from "@/contexts/EditorDialogsContext"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { + AUDIO_ROW_EXPANSION_PX, + computeAudioRowCount, +} from "@/lib/ai-edition/document/audioTracks"; import { createId } from "@/lib/ai-edition/document/ids"; import { migrateProjectDataToAxcutDocument, @@ -106,6 +110,10 @@ function NativePlaybackSync({ return null; } +export const DEFAULT_TIMELINE_HEIGHT_PX = 392; +export const MIN_TIMELINE_HEIGHT_PX = 160; +export const MAX_TIMELINE_HEIGHT_PX = 560; + export function NewEditorShell() { const te = useScopedT("editor"); const document = useProjectStore((s) => s.document); @@ -132,8 +140,39 @@ export function NewEditorShell() { const [chatWidthPx, setChatWidthPx] = useState( () => Number(localStorage.getItem("os-editor-chat-width")) || 392, ); - const [timelineHeightPx, setTimelineHeightPx] = useState( - () => Number(localStorage.getItem("os-editor-timeline-height")) || 308, + const [timelineBaseHeightPx, setTimelineBaseHeightPx] = useState(() => { + const raw = localStorage.getItem("os-editor-timeline-height"); + // One-time migration: if legacy 308px default exists without the migration marker, + // migrate it once to DEFAULT_TIMELINE_HEIGHT_PX so existing users see all lanes, + // but allow them to intentionally choose 308px in the future. + // Also migrate intermediate 344px default to DEFAULT_TIMELINE_HEIGHT_PX. + const migrated = localStorage.getItem("os-editor-timeline-height-migrated"); + if (!migrated && (!raw || Number(raw) === 308 || Number(raw) === 344)) { + localStorage.setItem("os-editor-timeline-height-migrated", "true"); + localStorage.setItem("os-editor-timeline-height", String(DEFAULT_TIMELINE_HEIGHT_PX)); + return DEFAULT_TIMELINE_HEIGHT_PX; + } + if (raw === "344") { + localStorage.setItem("os-editor-timeline-height", String(DEFAULT_TIMELINE_HEIGHT_PX)); + return DEFAULT_TIMELINE_HEIGHT_PX; + } + const val = raw ? Number(raw) : 0; + if (!val) { + return DEFAULT_TIMELINE_HEIGHT_PX; + } + return Math.min(MAX_TIMELINE_HEIGHT_PX, Math.max(MIN_TIMELINE_HEIGHT_PX, val)); + }); + + // The timeline height automatically expands by AUDIO_ROW_EXPANSION_PX (+29px) + // whenever the project transitions between 1 and 2 stacked audio rows (e.g. voiceover + music). + const audioRowCount = useMemo( + () => computeAudioRowCount(document?.audioTracks ?? []), + [document?.audioTracks], + ); + const extraAudioHeightPx = Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX; + const timelineHeightPx = Math.min( + MAX_TIMELINE_HEIGHT_PX, + Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraAudioHeightPx), ); const [inspectorOpen, setInspectorOpen] = useState(true); const [facet, setFacet] = useState("effects"); @@ -1391,23 +1430,27 @@ export function NewEditorShell() { (e: React.PointerEvent) => { e.preventDefault(); const startY = e.clientY; - const startHeight = timelineHeightPx; - let latest = startHeight; + const startBase = timelineBaseHeightPx; + let latestBase = startBase; const move = (ev: PointerEvent) => { // Dragging the handle up (negative clientY delta) enlarges the // timeline, since it sits below the handle. - latest = Math.min(480, Math.max(160, startHeight - (ev.clientY - startY))); - setTimelineHeightPx(latest); + const renderedTarget = Math.min( + MAX_TIMELINE_HEIGHT_PX, + Math.max(MIN_TIMELINE_HEIGHT_PX, startBase + extraAudioHeightPx - (ev.clientY - startY)), + ); + latestBase = Math.max(MIN_TIMELINE_HEIGHT_PX, renderedTarget - extraAudioHeightPx); + setTimelineBaseHeightPx(latestBase); }; const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); - localStorage.setItem("os-editor-timeline-height", String(latest)); + localStorage.setItem("os-editor-timeline-height", String(latestBase)); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }, - [timelineHeightPx], + [timelineBaseHeightPx, extraAudioHeightPx], ); const transcriptProps = { diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 3fd5a82ce..a4b41ff32 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -34,6 +34,9 @@ import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useAudioPeaks } from "@/hooks/useAudioPeaks"; import { + AUDIO_LANE_PAD_PX, + AUDIO_ROW_GAP_PX, + AUDIO_ROW_HEIGHT_PX, audioGhostExtent, collapseTracksToPills, packAudioTrackRows, @@ -141,14 +144,8 @@ const PILL_HANDLE_OUT_PX = PILL_HANDLE_PX + PILL_MOVE_GAP_PX; const PILL_CONTENT_MIN_PX = 34; /** Edge-snap radius while dragging a pill, in screen px. */ const PILL_SNAP_PX = 8; -// One audio pill's height, and the vertical step between stacked rows. The lane -// grows by a row for each track that overlaps one already placed — see -// `packAudioTrackRows`. -const AUDIO_ROW_HEIGHT_PX = 26; -const AUDIO_ROW_GAP_PX = 3; -// Breathing room above the first row and below the last, so a pill never sits -// flush against the lane's rounded edge. -const AUDIO_LANE_PAD_PX = 3; +// One audio pill's height, the vertical step between stacked rows, and lane padding +// are defined in audioTracks.ts and imported above. // The size a newly created pill aims for (PILL_CREATE_PX) lives in // timeline/newRegionDuration, because the keyboard shortcuts create regions too // and they are handled in NewEditorShell, outside this component. diff --git a/src/lib/ai-edition/document/audioTracks.test.ts b/src/lib/ai-edition/document/audioTracks.test.ts index 064771150..25702630a 100644 --- a/src/lib/ai-edition/document/audioTracks.test.ts +++ b/src/lib/ai-edition/document/audioTracks.test.ts @@ -4,6 +4,7 @@ import { anchorAudioTrackFragments, audioGhostExtent, collapseTracksToPills, + computeAudioRowCount, packAudioTrackRows, patchAudioTrack, removeAudioTrack, @@ -356,3 +357,37 @@ describe("slipAudioOffsetMs", () => { expect(Number.isInteger(slipAudioOffsetMs(0, 4_000, 60, 1234.567) ?? 0)).toBe(true); }); }); + +describe("computeAudioRowCount", () => { + it("returns 1 row for an empty tracks list (showing the empty lane hint)", () => { + expect(computeAudioRowCount([])).toBe(1); + }); + + it("returns 1 row for a single voiceover track", () => { + const vo = track({ id: "vo1", kind: "voiceover", startMs: 0, endMs: 5000 }); + expect(computeAudioRowCount([vo])).toBe(1); + }); + + it("returns 1 row for a single music track", () => { + const bgm = track({ id: "bgm1", kind: "music", startMs: 0, endMs: 5000 }); + expect(computeAudioRowCount([bgm])).toBe(1); + }); + + it("returns 2 rows when both voiceover and music tracks are present", () => { + const vo = track({ id: "vo1", kind: "voiceover", startMs: 0, endMs: 5000 }); + const bgm = track({ id: "bgm1", kind: "music", startMs: 0, endMs: 5000 }); + expect(computeAudioRowCount([vo, bgm])).toBe(2); + }); + + it("keeps non-overlapping tracks of the same kind on 1 row", () => { + const bgm1 = track({ id: "bgm1", kind: "music", startMs: 0, endMs: 5000 }); + const bgm2 = track({ id: "bgm2", kind: "music", startMs: 6000, endMs: 10000 }); + expect(computeAudioRowCount([bgm1, bgm2])).toBe(1); + }); + + it("stacks overlapping tracks of the same kind on 2 rows", () => { + const bgm1 = track({ id: "bgm1", kind: "music", startMs: 0, endMs: 5000 }); + const bgm2 = track({ id: "bgm2", kind: "music", startMs: 3000, endMs: 8000 }); + expect(computeAudioRowCount([bgm1, bgm2])).toBe(2); + }); +}); diff --git a/src/lib/ai-edition/document/audioTracks.ts b/src/lib/ai-edition/document/audioTracks.ts index 232d72af8..b0055cb3a 100644 --- a/src/lib/ai-edition/document/audioTracks.ts +++ b/src/lib/ai-edition/document/audioTracks.ts @@ -500,3 +500,22 @@ export function packAudioTrackRows(tracks: Array<{ id: string; startMs: number; } return { rowOf, rowCount: Math.max(1, rowEnds.length) }; } + +export const AUDIO_ROW_HEIGHT_PX = 26; +export const AUDIO_ROW_GAP_PX = 3; +export const AUDIO_LANE_PAD_PX = 3; +export const AUDIO_ROW_EXPANSION_PX = AUDIO_ROW_HEIGHT_PX + AUDIO_ROW_GAP_PX; + +/** + * Compute the total stacked rows required to render the project's audio tracks, + * mirroring V4Timeline's packing logic (separating voiceovers and background music). + */ +export function computeAudioRowCount(tracks: AxcutAudioTrack[]): number { + const pills = collapseTracksToPills(tracks); + const voice = pills.filter((p) => p.kind === "voiceover"); + const music = pills.filter((p) => p.kind !== "voiceover"); + const voiceRows = packAudioTrackRows(voice); + const musicRows = packAudioTrackRows(music); + const base = voice.length > 0 ? voiceRows.rowCount : 0; + return Math.max(1, base + (music.length > 0 ? musicRows.rowCount : 0)); +}