-
diff --git a/src/components/video-editor/timeline/timelineLayout.test.ts b/src/components/video-editor/timeline/timelineLayout.test.ts
index 44623938..9ab716fe 100644
--- a/src/components/video-editor/timeline/timelineLayout.test.ts
+++ b/src/components/video-editor/timeline/timelineLayout.test.ts
@@ -3,6 +3,7 @@ import {
getTimelineContentMinHeightPx,
getTimelineRowsMinHeightPx,
getTimelineViewportStretchFactor,
+ TIMELINE_CLIP_ROW_HEIGHT_PX,
TIMELINE_AXIS_HEIGHT_PX,
TIMELINE_ROW_MIN_HEIGHT_PX,
TIMELINE_VISIBLE_ROW_COUNT,
@@ -10,9 +11,14 @@ import {
describe("timelineLayout", () => {
it("reserves vertical space for every rendered timeline row", () => {
- expect(getTimelineRowsMinHeightPx(5)).toBe(5 * TIMELINE_ROW_MIN_HEIGHT_PX);
+ expect(getTimelineRowsMinHeightPx(5)).toBe(
+ TIMELINE_CLIP_ROW_HEIGHT_PX + 4 * TIMELINE_ROW_MIN_HEIGHT_PX + 10,
+ );
expect(getTimelineContentMinHeightPx(5)).toBe(
- TIMELINE_AXIS_HEIGHT_PX + 5 * TIMELINE_ROW_MIN_HEIGHT_PX,
+ TIMELINE_AXIS_HEIGHT_PX +
+ TIMELINE_CLIP_ROW_HEIGHT_PX +
+ 4 * TIMELINE_ROW_MIN_HEIGHT_PX +
+ 10,
);
});
@@ -25,9 +31,11 @@ describe("timelineLayout", () => {
});
it("floors fractional row counts", () => {
- expect(getTimelineRowsMinHeightPx(2.9)).toBe(2 * TIMELINE_ROW_MIN_HEIGHT_PX);
+ expect(getTimelineRowsMinHeightPx(2.9)).toBe(
+ TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4,
+ );
expect(getTimelineContentMinHeightPx(2.9)).toBe(
- TIMELINE_AXIS_HEIGHT_PX + 2 * TIMELINE_ROW_MIN_HEIGHT_PX,
+ TIMELINE_AXIS_HEIGHT_PX + TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4,
);
});
diff --git a/src/components/video-editor/timeline/timelineLayout.ts b/src/components/video-editor/timeline/timelineLayout.ts
index 4f5b6bfc..d9c94bca 100644
--- a/src/components/video-editor/timeline/timelineLayout.ts
+++ b/src/components/video-editor/timeline/timelineLayout.ts
@@ -1,5 +1,6 @@
export const TIMELINE_AXIS_HEIGHT_PX = 32;
-export const TIMELINE_ROW_MIN_HEIGHT_PX = 28;
+export const TIMELINE_ROW_MIN_HEIGHT_PX = 32;
+export const TIMELINE_CLIP_ROW_HEIGHT_PX = TIMELINE_ROW_MIN_HEIGHT_PX * 2;
export const TIMELINE_VISIBLE_ROW_COUNT = 3;
function normalizeRowCount(rowCount: number) {
@@ -11,7 +12,11 @@ function normalizeRowCount(rowCount: number) {
}
export function getTimelineRowsMinHeightPx(rowCount: number) {
- return normalizeRowCount(rowCount) * TIMELINE_ROW_MIN_HEIGHT_PX;
+ const count = normalizeRowCount(rowCount);
+ // The first lane is a double-height filmstrip; reserve its extra space.
+ return count
+ ? TIMELINE_CLIP_ROW_HEIGHT_PX + (count - 1) * TIMELINE_ROW_MIN_HEIGHT_PX + count * 2
+ : 0;
}
export function getTimelineContentMinHeightPx(rowCount: number) {
diff --git a/tests/ui/clip-origin.spec.ts b/tests/ui/clip-origin.spec.ts
index 6722ca5b..75287939 100644
--- a/tests/ui/clip-origin.spec.ts
+++ b/tests/ui/clip-origin.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("selecting an untouched clip does not introduce a leading gap", async ({ page }) => {
- test.setTimeout(60000);
+ test.setTimeout(120000);
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.onMenuSaveProject = (callback) => {
@@ -46,7 +46,9 @@ test("selecting an untouched clip does not introduce a leading gap", async ({ pa
window.dispatchEvent(new Event("test-save-project"));
});
await expect
- .poll(() => page.evaluate(() => sessionStorage.getItem("test-saved-clips")))
+ .poll(() => page.evaluate(() => sessionStorage.getItem("test-saved-clips")), {
+ timeout: 20000,
+ })
.not.toBeNull();
const clips = await page.evaluate(() =>
JSON.parse(sessionStorage.getItem("test-saved-clips")!),
diff --git a/tests/ui/editor.spec.ts b/tests/ui/editor.spec.ts
index efe15b90..13896ad3 100644
--- a/tests/ui/editor.spec.ts
+++ b/tests/ui/editor.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("editor loads video, switches tools and edits export options", async ({ page }) => {
- test.setTimeout(60000);
+ test.setTimeout(120000);
const errors: string[] = [];
page.on("pageerror", (e) => {
errors.push(e.message);
diff --git a/tests/ui/timeline-presentation.spec.ts b/tests/ui/timeline-presentation.spec.ts
new file mode 100644
index 00000000..189b9640
--- /dev/null
+++ b/tests/ui/timeline-presentation.spec.ts
@@ -0,0 +1,100 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("editor uses skeletons until media opens", async ({ page }) => {
+ test.setTimeout(60000);
+ let release!: () => void;
+ let releaseMedia!: () => void;
+ const mediaPending = new Promise((resolve) => {
+ releaseMedia = resolve;
+ });
+ await page.route("**/tests/ui/fixtures/preview.mp4", async (route) => {
+ await mediaPending;
+ await route.continue();
+ });
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ await page.exposeFunction("waitForTestMedia", () => pending);
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.getCurrentVideoPath = async () => {
+ await (
+ window as unknown as { waitForTestMedia: () => Promise }
+ ).waitForTestMedia();
+ return { success: true, path: `${location.origin}/tests/ui/fixtures/preview.mp4` };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const loading = page.getByRole("status", { name: "Loading editor" });
+ await expect(loading).toBeVisible({ timeout: 20000 });
+ await expect(loading.locator(".skeleton").first()).toBeVisible();
+ await page.screenshot({ path: "test-results/editor-skeleton.png", animations: "disabled" });
+ release();
+ await expect(loading).toHaveCount(0);
+ await expect(page.getByLabel("Loading preview", { exact: true })).toBeVisible();
+ releaseMedia();
+ await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, {
+ timeout: 20000,
+ });
+ await expect(page.locator('[data-variant="clip"]')).toBeVisible();
+});
+
+test("filmstrips have white handles, conditional speed badges and centered navigation", async ({
+ page,
+}) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const clip = page.locator('[data-timeline-item][data-variant="clip"]');
+ await expect(clip.locator("img").first()).toBeVisible({ timeout: 20000 });
+ await expect(clip).toHaveText("");
+ await page.mouse.move(1000, 300);
+ for (const side of ["left", "right"]) {
+ await expect(clip.getByTitle(`Resize ${side}`)).toHaveCSS("opacity", "1");
+ await expect(clip.getByTitle(`Resize ${side}`)).toHaveCSS(
+ "background-color",
+ "rgb(255, 255, 255)",
+ );
+ }
+ await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
+ const zoom = page.locator('[data-variant="zoom"] .timeline-block');
+ await expect(zoom).toBeVisible();
+ const clipHeight = (await clip.locator(".timeline-block").boundingBox())!.height;
+ const zoomHeight = (await zoom.boundingBox())!.height;
+ expect(Math.abs(clipHeight - zoomHeight * 2)).toBeLessThan(1);
+ const colors = await zoom.evaluate((node) => {
+ const probe = document.createElement("span");
+ probe.style.background = "var(--accent)";
+ document.body.append(probe);
+ const accent = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return [getComputedStyle(node).backgroundColor, accent];
+ });
+ expect(colors[0]).toBe(colors[1]);
+ for (const width of [1440, 1250, 800]) {
+ await page.setViewportSize({ width, height: 800 });
+ await expect(clip.locator("img").first()).toBeVisible({ timeout: 20000 });
+ await page.evaluate(
+ (dark) => document.documentElement.classList.toggle("dark", dark),
+ width === 1250,
+ );
+ const panel = (await page.locator("aside").boundingBox())!;
+ const nav = page.getByRole("navigation", { name: "Editor tools" });
+ for (const control of await nav.locator('button, [role="radio"]').all()) {
+ const box = (await control.boundingBox())!;
+ expect(Math.abs(box.x + box.width / 2 - panel.x / 2)).toBeLessThan(1);
+ }
+ await page.screenshot({
+ path: `test-results/clip-rail-${width}.png`,
+ animations: "disabled",
+ });
+ }
+ await clip.click();
+ const speed = page.getByRole("slider", { name: "Speed", exact: true });
+ await speed.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(clip).toHaveText("1.25x");
+ await page.keyboard.press("ArrowLeft");
+ await expect(clip).toHaveText("");
+});