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));
+}