From 7c4679aa44b38fd79122eaafde2dc8e062e6196a Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:33:31 +1000 Subject: [PATCH] Fix empty Home navigation and keep zoom motion within timeline blocks --- src/components/video-editor/VideoPlayback.tsx | 28 ++-- .../dashboard/DashboardSidebar.tsx | 2 +- .../project/useInitialEditorSource.ts | 3 +- .../videoPlayback/motionSmoothing.ts | 23 ++++ .../videoPlayback/zoomAnimation.test.ts | 107 +++++++++++++-- .../videoPlayback/zoomRegionUtils.ts | 126 +++++------------- src/lib/exporter/frameRenderer.ts | 36 +++-- src/lib/exporter/modernFrameRenderer.ts | 36 ++--- tests/ui/project-dashboard.spec.ts | 14 ++ 9 files changed, 217 insertions(+), 158 deletions(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 90a8533a..c4ec2c43 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -107,7 +107,7 @@ import { getZoomSpringConfig, resetSpringState, type SpringState, - stepSpringValue, + stepBoundedZoomSpring, } from "./videoPlayback/motionSmoothing"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { supportsPreviewPlaybackRate } from "./videoPlayback/playbackRate"; @@ -2094,24 +2094,20 @@ const VideoPlayback = forwardRef( let appliedY: number; if (motionMode === "spring" && contentAdvanced) { - appliedScale = stepSpringValue( - springScaleRef.current, - projectedTransform.scale, - deltaMs, - zoomSpringConfig, - ); - appliedX = stepSpringValue( - springXRef.current, - projectedTransform.x, - deltaMs, - zoomSpringConfig, - ); - appliedY = stepSpringValue( - springYRef.current, - projectedTransform.y, + const bounded = stepBoundedZoomSpring( + { + scale: springScaleRef.current, + x: springXRef.current, + y: springYRef.current, + }, + projectedTransform, deltaMs, zoomSpringConfig, + target.progress < 1, ); + appliedScale = bounded.scale; + appliedX = bounded.x; + appliedY = bounded.y; } else if (motionMode === "snap") { // Timeline seeks and classic mode intentionally evaluate the exact target. appliedScale = projectedTransform.scale; diff --git a/src/components/video-editor/dashboard/DashboardSidebar.tsx b/src/components/video-editor/dashboard/DashboardSidebar.tsx index be5d3a25..1dab7f8a 100644 --- a/src/components/video-editor/dashboard/DashboardSidebar.tsx +++ b/src/components/video-editor/dashboard/DashboardSidebar.tsx @@ -55,7 +55,7 @@ export function DashboardSidebar({ Recordly diff --git a/src/components/video-editor/project/useInitialEditorSource.ts b/src/components/video-editor/project/useInitialEditorSource.ts index cdb4b4bf..b4646ae4 100644 --- a/src/components/video-editor/project/useInitialEditorSource.ts +++ b/src/components/video-editor/project/useInitialEditorSource.ts @@ -181,7 +181,8 @@ export function useInitialEditorSource({ const currentVideo = await window.electronAPI.getCurrentVideoPath(); if (!currentVideo.success || !currentVideo.path) { - project.setError("No video to load. Please record or select a video."); + // An empty session is the normal dashboard launch, not a load failure. + project.setProjectBrowserOpen(true); return; } const sourcePath = fromFileUrl(currentVideo.path); diff --git a/src/components/video-editor/videoPlayback/motionSmoothing.ts b/src/components/video-editor/videoPlayback/motionSmoothing.ts index c4ec3852..cd6c7456 100644 --- a/src/components/video-editor/videoPlayback/motionSmoothing.ts +++ b/src/components/video-editor/videoPlayback/motionSmoothing.ts @@ -317,3 +317,26 @@ export function getZoomSpringConfig( tuning, ); } + +/** Keep spring lag inside the zoom envelope, including an exact neutral end frame. */ +export function stepBoundedZoomSpring( + springs: { scale: SpringState; x: SpringState; y: SpringState }, + target: { scale: number; x: number; y: number }, + deltaMs: number, + config: SpringConfig, + envelopeActive = true, +) { + const scale = stepSpringValue(springs.scale, target.scale, deltaMs, config); + const x = stepSpringValue(springs.x, target.x, deltaMs, config); + const y = stepSpringValue(springs.y, target.y, deltaMs, config); + const boundedScale = Math.max(1, envelopeActive ? Math.min(target.scale, scale) : scale); + if (target.scale <= 1 || boundedScale !== scale) { + const ratio = scale > 1 ? (boundedScale - 1) / (scale - 1) : 0; + const result = { scale: boundedScale, x: x * ratio, y: y * ratio }; + resetSpringState(springs.scale, result.scale); + resetSpringState(springs.x, result.x); + resetSpringState(springs.y, result.y); + return result; + } + return { scale, x, y }; +} diff --git a/src/components/video-editor/videoPlayback/zoomAnimation.test.ts b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts index b9898859..73748cf9 100644 --- a/src/components/video-editor/videoPlayback/zoomAnimation.test.ts +++ b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts @@ -10,6 +10,7 @@ import { type SpringConfig, type SpringState, stepSpringValue, + stepBoundedZoomSpring, } from "./motionSmoothing"; import { computeRegionStrength, findDominantRegion } from "./zoomRegionUtils"; @@ -313,29 +314,24 @@ describe("computeRegionStrength", () => { it("reaches full strength during the hold phase", () => { // Mid-region: after zoom-in completes, before zoom-out starts - expect(computeRegionStrength(region, 3500)).toBe(1); + expect(computeRegionStrength(region, 3600)).toBe(1); }); it("rises smoothly during zoom-in", () => { - // Zoom-in transitions from leadInStart .. zoomInEnd - // zoomInEnd = startMs + 500, leadInStart = zoomInEnd - 1500 = startMs - 1000 - // So at startMs the transition is partially done - const s = computeRegionStrength(region, region.startMs); + const s = computeRegionStrength(region, region.startMs + 200); expect(s).toBeGreaterThan(0); expect(s).toBeLessThan(1); }); it("falls smoothly during zoom-out", () => { - // Zoom-out now starts 200ms later than the original timing. - const zoomOutStart = region.endMs - 150; - const s = computeRegionStrength(region, zoomOutStart + 700); + const s = computeRegionStrength(region, region.endMs - 200); expect(s).toBeGreaterThan(0); expect(s).toBeLessThan(1); }); it("shifts zoom timing when custom durations are provided", () => { - const defaultStrength = computeRegionStrength(region, region.startMs); - const fasterStrength = computeRegionStrength(region, region.startMs, { + const defaultStrength = computeRegionStrength(region, region.startMs + 200); + const fasterStrength = computeRegionStrength(region, region.startMs + 200, { zoomInDurationMs: 300, zoomOutDurationMs: 300, }); @@ -360,7 +356,7 @@ describe("findDominantRegion", () => { const regions: ZoomRegion[] = [ { id: "a", startMs: 1000, endMs: 4000, depth: 2, focus: { cx: 0.3, cy: 0.3 } }, ]; - const result = findDominantRegion(regions, 2500); + const result = findDominantRegion(regions, 2550); expect(result.region).not.toBeNull(); expect(result.region!.id).toBe("a"); expect(result.strength).toBe(1); @@ -393,7 +389,7 @@ describe("findDominantRegion", () => { { id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } }, ]; - const result = findDominantRegion(regions, 3100, { connectZooms: true }); + const result = findDominantRegion(regions, 2900, { connectZooms: true }); expect(result.transition).toBeNull(); expect(result.region?.id).toBe("a"); expect(result.strength).toBeGreaterThan(0); @@ -428,7 +424,7 @@ describe("findDominantRegion", () => { { id: "b", startMs: 4300, endMs: 7000, depth: 3, focus: { cx: 0.7, cy: 0.7 } }, ]; - // After transition end (3000+200+1000=4200) but before b starts (4300) + // Between connected blocks, the next focus remains active. const result = findDominantRegion(regions, 4250, { connectZooms: true }); expect(result.strength).toBe(1); expect(result.region).not.toBeNull(); @@ -552,3 +548,88 @@ describe("spring damping regimes", () => { expect(s.value).toBeCloseTo(1, 2); }); }); + +describe("zoom block boundaries", () => { + it.each([100, 500, 3000])("keeps a %dms block and its springs inside its bounds", (length) => { + const region: ZoomRegion = { + id: "bounded", + startMs: 1000, + endMs: 1000 + length, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }; + const springs = { + scale: createSpringState(1), + x: createSpringState(), + y: createSpringState(), + }; + const config = getZoomSpringConfig(0.5); + for (let time = 900; time <= region.endMs + 100; time += 10) { + const strength = computeRegionStrength(region, time, { + zoomInDurationMs: 1500, + zoomOutDurationMs: 1000, + }); + const frame = stepBoundedZoomSpring( + springs, + { scale: 1 + strength, x: -100 * strength, y: -50 * strength }, + 10, + config, + ); + expect(frame.scale).toBeGreaterThanOrEqual(1); + expect(frame.scale).toBeLessThanOrEqual(1 + strength); + if (time <= region.startMs || time >= region.endMs) { + expect(strength).toBe(0); + expect(frame.scale).toBe(1); + expect(Math.abs(frame.x) + Math.abs(frame.y)).toBe(0); + } + } + const peak = region.startMs + length * 0.6; + expect( + computeRegionStrength(region, peak, { + zoomInDurationMs: 6000, + zoomOutDurationMs: 4000, + }), + ).toBeCloseTo(1); + expect( + computeRegionStrength(region, peak - 0.001, { + zoomInDurationMs: 6000, + zoomOutDurationMs: 4000, + }), + ).toBeCloseTo(1); + expect( + computeRegionStrength(region, peak + 0.001, { + zoomInDurationMs: 6000, + zoomOutDurationMs: 4000, + }), + ).toBeCloseTo(1); + }); + + it("bounds a connected chain to its first start and last end", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 1100, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, + { id: "b", startMs: 1400, endMs: 1500, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, + ]; + for (const time of [999, 1000, 1500, 1501]) + expect(findDominantRegion(regions, time, { connectZooms: true }).strength).toBe(0); + expect(findDominantRegion(regions, 1100, { connectZooms: true }).strength).toBe(1); + expect(findDominantRegion(regions, 1250, { connectZooms: false }).strength).toBe(0); + }); +}); + +it("preserves spring interpolation when connected zooms change depth at full strength", () => { + const springs = { + scale: createSpringState(3), + x: createSpringState(-200), + y: createSpringState(-100), + }; + for (const state of Object.values(springs)) state.initialized = true; + const frame = stepBoundedZoomSpring( + springs, + { scale: 2, x: -100, y: -50 }, + 16, + getZoomSpringConfig(0.5), + false, + ); + expect(frame.scale).toBeGreaterThan(2); + expect(frame.scale).toBeLessThan(3); +}); diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index ab1af945..5f7aa384 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -1,17 +1,10 @@ import type { ZoomFocus, ZoomRegion } from "../types"; import { ZOOM_DEPTH_SCALES } from "../types"; -import { - TRANSITION_WINDOW_MS, - ZOOM_IN_TRANSITION_WINDOW_MS, - ZOOM_OUT_EARLY_START_MS, -} from "./constants"; +import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants"; import { clampFocusToScale } from "./focusUtils"; import { clamp01, easeOutZoom } from "./mathUtils"; const CHAINED_ZOOM_PAN_GAP_MS = 1350; -const CONNECTED_ZOOM_PAN_DURATION_MS = 1000; -const ZOOM_IN_OVERLAP_MS = 1000; -const ZOOM_ANIMATION_LEAD_MS = 200; type DominantRegionOptions = { connectZooms?: boolean; @@ -23,7 +16,6 @@ type ConnectedRegionPair = { currentRegion: ZoomRegion; nextRegion: ZoomRegion; transitionStart: number; - transitionEnd: number; }; type ConnectedPanTransition = { @@ -41,34 +33,18 @@ export function computeRegionStrength( ) { const zoomInDurationMs = Math.max(1, options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS); const zoomOutDurationMs = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS); - const adjustedTimeMs = timeMs - ZOOM_ANIMATION_LEAD_MS; - const leadInStart = region.startMs + ZOOM_IN_OVERLAP_MS - ZOOM_IN_TRANSITION_WINDOW_MS; - let zoomOutStart = region.endMs - ZOOM_OUT_EARLY_START_MS; - let zoomInEnd = leadInStart + zoomInDurationMs; + const length = region.endMs - region.startMs; + if (length <= 0 || timeMs <= region.startMs || timeMs >= region.endMs) return 0; - if (zoomInEnd > zoomOutStart) { - const midpoint = (zoomInEnd + zoomOutStart) / 2; - zoomInEnd = midpoint; - zoomOutStart = midpoint; + // Short blocks proportionally compress both ramps, with no discontinuity at + // their meeting point. Every duration setting stays inside the block. + const fit = Math.min(1, length / (zoomInDurationMs + zoomOutDurationMs)); + const inDuration = zoomInDurationMs * fit; + const outDuration = zoomOutDurationMs * fit; + if (timeMs < region.startMs + inDuration) { + return easeOutZoom((timeMs - region.startMs) / inDuration); } - - const leadOutEnd = zoomOutStart + zoomOutDurationMs; - - if (adjustedTimeMs < leadInStart || adjustedTimeMs > leadOutEnd) { - return 0; - } - - if (adjustedTimeMs < zoomInEnd) { - const progress = (adjustedTimeMs - leadInStart) / zoomInDurationMs; - return easeOutZoom(progress); - } - - if (adjustedTimeMs <= zoomOutStart) { - return 1; - } - - const progress = clamp01((adjustedTimeMs - zoomOutStart) / zoomOutDurationMs); - return 1 - easeOutZoom(progress); + return 1 - easeOutZoom(clamp01((timeMs - (region.endMs - outDuration)) / outDuration)); } function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus { @@ -84,16 +60,14 @@ function getConnectedRegionPairs(regions: ZoomRegion[]) { const nextRegion = sortedRegions[index + 1]; const gapMs = nextRegion.startMs - currentRegion.endMs; - if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) { + if (gapMs < 0 || gapMs > CHAINED_ZOOM_PAN_GAP_MS) { continue; } pairs.push({ currentRegion, nextRegion, - transitionStart: currentRegion.endMs + ZOOM_ANIMATION_LEAD_MS, - transitionEnd: - currentRegion.endMs + ZOOM_ANIMATION_LEAD_MS + CONNECTED_ZOOM_PAN_DURATION_MS, + transitionStart: currentRegion.endMs, }); } @@ -109,33 +83,30 @@ function getActiveRegion( const activeRegions = regions .map((region) => { const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id); - if (outgoingPair) { - if (timeMs >= outgoingPair.transitionStart) { - return { region, strength: 0 }; - } - - const zoomOutStart = - outgoingPair.currentRegion.endMs - - ZOOM_OUT_EARLY_START_MS + - ZOOM_ANIMATION_LEAD_MS; - if (timeMs >= zoomOutStart) { - return { region, strength: 1 }; - } - } - const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id); - if (incomingPair) { - if (timeMs < incomingPair.transitionStart) { - return { region, strength: 0 }; - } - - const nextRegionZoomOutStart = - incomingPair.nextRegion.endMs - - ZOOM_OUT_EARLY_START_MS + - ZOOM_ANIMATION_LEAD_MS; - if (timeMs < nextRegionZoomOutStart) { - return { region, strength: 1 }; - } + const start = incomingPair?.transitionStart ?? region.startMs; + if (timeMs < start || (!incomingPair && timeMs === start) || timeMs >= region.endMs) + return { region, strength: 0 }; + if (incomingPair || outgoingPair) { + const inDuration = Math.max( + 1, + options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS, + ); + const outDuration = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS); + const total = (incomingPair ? 0 : inDuration) + (outgoingPair ? 0 : outDuration); + const fit = total > 0 ? Math.min(1, (region.endMs - start) / total) : 1; + const strengthIn = incomingPair + ? 1 + : easeOutZoom(clamp01((timeMs - start) / (inDuration * fit))); + const strengthOut = outgoingPair + ? 1 + : 1 - + easeOutZoom( + clamp01( + (timeMs - (region.endMs - outDuration * fit)) / (outDuration * fit), + ), + ); + return { region, strength: Math.min(strengthIn, strengthOut) }; } return { region, strength: computeRegionStrength(region, timeMs, options) }; @@ -166,24 +137,6 @@ function getActiveRegion( }; } -function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) { - for (const pair of connectedPairs) { - if (timeMs >= pair.transitionEnd && timeMs < pair.nextRegion.startMs) { - const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth]; - return { - region: { - ...pair.nextRegion, - focus: getResolvedFocus(pair.nextRegion, nextScale), - }, - strength: 1, - blendedScale: null, - }; - } - } - - return null; -} - export function findDominantRegion( regions: ZoomRegion[], timeMs: number, @@ -196,13 +149,6 @@ export function findDominantRegion( } { const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : []; - if (options.connectZooms) { - const connectedHold = getConnectedRegionHold(timeMs, connectedPairs); - if (connectedHold) { - return { ...connectedHold, transition: null }; - } - } - const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, options); return activeRegion ? { ...activeRegion, transition: null } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index d42f6a69..af662e52 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -40,11 +40,14 @@ import { getZoomSpringConfig, resetSpringState, type SpringState, - stepSpringValue, + stepBoundedZoomSpring, } from "@/components/video-editor/videoPlayback/motionSmoothing"; import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects"; import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion"; -import { getWebcamMediaTargetTimeSeconds, isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync"; +import { + getWebcamMediaTargetTimeSeconds, + isWebcamVisibleAtSourceTime, +} from "@/components/video-editor/videoPlayback/webcamSync"; import { applyZoomTransform, computeZoomTransform, @@ -76,7 +79,6 @@ import { renderCaptions } from "./captionRenderer"; import { ForwardFrameSource } from "./forwardFrameSource"; import { resolveMediaElementSource } from "./localMediaSource"; - interface FrameRenderConfig { timelineEffects?: boolean; width: number; @@ -1649,24 +1651,16 @@ export class FrameRenderer { resetSpringState(this.springX, state.x); resetSpringState(this.springY, state.y); } else { - state.appliedScale = stepSpringValue( - this.springScale, - projectedTransform.scale, - deltaMs, - zoomSpringConfig, - ); - state.x = stepSpringValue( - this.springX, - projectedTransform.x, - deltaMs, - zoomSpringConfig, - ); - state.y = stepSpringValue( - this.springY, - projectedTransform.y, + const bounded = stepBoundedZoomSpring( + { scale: this.springScale, x: this.springX, y: this.springY }, + projectedTransform, deltaMs, zoomSpringConfig, + target.progress < 1, ); + state.appliedScale = bounded.scale; + state.x = bounded.x; + state.y = bounded.y; } return Math.max( @@ -1739,7 +1733,11 @@ export class FrameRenderer { const webcam = this.config.webcam; const webcamDecodedFrame = this.webcamDecodedFrame; const webcamVideo = this.webcamVideoElement; - if (!webcam?.enabled || !isWebcamVisibleAtSourceTime(webcam, this.currentVideoTime) || (!webcamDecodedFrame && !webcamVideo)) { + if ( + !webcam?.enabled || + !isWebcamVisibleAtSourceTime(webcam, this.currentVideoTime) || + (!webcamDecodedFrame && !webcamVideo) + ) { return; } diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index c66c18d7..a73a6122 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -49,11 +49,14 @@ import { getZoomSpringConfig, resetSpringState, type SpringState, - stepSpringValue, + stepBoundedZoomSpring, } from "@/components/video-editor/videoPlayback/motionSmoothing"; import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects"; import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion"; -import { getWebcamMediaTargetTimeSeconds, isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync"; +import { + getWebcamMediaTargetTimeSeconds, + isWebcamVisibleAtSourceTime, +} from "@/components/video-editor/videoPlayback/webcamSync"; import { applyZoomTransform, computeZoomTransform, @@ -2725,7 +2728,12 @@ export class FrameRenderer { private updateWebcamOverlay(referenceTimeSeconds = this.currentVideoTime): void { const webcam = this.config.webcam; - if (!webcam?.enabled || !isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) || !this.webcamRootContainer || !this.webcamMaskGraphics) { + if ( + !webcam?.enabled || + !isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) || + !this.webcamRootContainer || + !this.webcamMaskGraphics + ) { if (this.webcamRootContainer) { this.webcamRootContainer.visible = false; } @@ -3144,24 +3152,16 @@ export class FrameRenderer { resetSpringState(this.springX, state.x); resetSpringState(this.springY, state.y); } else { - state.appliedScale = stepSpringValue( - this.springScale, - projectedTransform.scale, - deltaMs, - zoomSpringConfig, - ); - state.x = stepSpringValue( - this.springX, - projectedTransform.x, - deltaMs, - zoomSpringConfig, - ); - state.y = stepSpringValue( - this.springY, - projectedTransform.y, + const bounded = stepBoundedZoomSpring( + { scale: this.springScale, x: this.springX, y: this.springY }, + projectedTransform, deltaMs, zoomSpringConfig, + target.progress < 1, ); + state.appliedScale = bounded.scale; + state.x = bounded.x; + state.y = bounded.y; } return Math.max( diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index 26649b4c..2e517e00 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -522,3 +522,17 @@ test("project hover plays a muted five-second preview and stops on exit", async await page.mouse.move(0, 0); await expect(preview).toHaveCount(0); }); + +test("Home opens normally without a current recording", async ({ page }) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + window.electronAPI.getCurrentVideoPath = async () => ({ success: false }); + localStorage.setItem("recordly.open-dashboard", String(Date.now())); + }); + await page.goto("/?windowType=editor"); + const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true }); + await expect(home).toBeVisible(); + await expect(home.getByRole("textbox", { name: "Search projects" })).toBeVisible(); + await expect(page.getByText("No video to load.", { exact: false })).toHaveCount(0); + await expect(page.getByRole("alert")).toHaveCount(0); +});