diff --git a/docs/beta-upgrade.md b/docs/beta-upgrade.md index c4f03c3d..1a682606 100644 --- a/docs/beta-upgrade.md +++ b/docs/beta-upgrade.md @@ -4,13 +4,15 @@ Before installing the beta, quit Recordly and back up your projects, their origi A `.recordly` project references media files on disk; it does not contain them. Include the screen recording, webcam recording, imported audio, and any accompanying audio, cursor, or session files in your backup. Preserve their folder locations, or retain a backup that can be restored to those locations. Include a custom recordings folder if you chose one. -The default settings and recordings folder is: +The default settings folder is: - macOS: `~/Library/Application Support/Recordly/` - Windows: `%APPDATA%\Recordly\` - Linux: `~/.config/Recordly/` (or `$XDG_CONFIG_HOME/Recordly/` if configured) -Copy the whole folder while Recordly is closed. Keep that backup outside the folder the beta will use. Also back up projects and media stored elsewhere. +Recordings normally use a `recordings` subfolder there. For Windows Store installations, recordings and projects instead default to your `Videos\Recordly\` folder; settings remain in `%APPDATA%\Recordly\`. Back up both folders. + +Copy the whole settings folder while Recordly is closed. Keep that backup outside the folder the beta will use. Also back up projects and media stored elsewhere. ## Open a v1.4 project in the beta diff --git a/src/components/video-editor/export/exportRunnerSupport.ts b/src/components/video-editor/export/exportRunnerSupport.ts index 09c22c32..302fa5eb 100644 --- a/src/components/video-editor/export/exportRunnerSupport.ts +++ b/src/components/video-editor/export/exportRunnerSupport.ts @@ -5,6 +5,7 @@ import { translate } from "@/i18n/runtime"; import type { ExportQuality, SupportedMp4Dimensions } from "@/lib/exporter"; import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; import type { ShareResolution } from "../cloud/shareResolution"; +import type { Mp4ExportRouting } from "../mp4ExportRouting"; import type { getSmokeExportConfig } from "../smokeExportConfig"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useTimelineState } from "../state/useTimelineState"; @@ -35,6 +36,7 @@ export type ExportRunnerInput = { frameRate: ReturnType["mp4FrameRate"], quality?: ExportQuality, shareResolution?: ShareResolution, + route?: Pick, ) => Promise; captionSidecarPayload?: PendingExportSave["captionSidecar"]; experimentalNvidiaCudaExport: boolean; diff --git a/src/components/video-editor/export/runMp4Export.recovery.test.ts b/src/components/video-editor/export/runMp4Export.recovery.test.ts index 50c1ded8..43672adc 100644 --- a/src/components/video-editor/export/runMp4Export.recovery.test.ts +++ b/src/components/video-editor/export/runMp4Export.recovery.test.ts @@ -339,9 +339,56 @@ it("applies the new compression profile exclusively to cloud shares", async () = backendPreference: "breeze", }), ); - expect(share.input.ensureSupportedMp4SourceDimensions).toHaveBeenCalledWith(30, "source", 720); + expect(share.input.ensureSupportedMp4SourceDimensions).toHaveBeenCalledWith(30, "source", 720, { + pipelineModel: "modern", + backendPreference: "breeze", + }); }); +it.each([ + { + name: "default native auto", + settings: {}, + smoke: {}, + expected: { pipelineModel: "modern", backendPreference: "auto" }, + }, + { + name: "legacy", + settings: { pipelineModel: "legacy" }, + smoke: {}, + expected: { pipelineModel: "legacy", backendPreference: "webcodecs" }, + }, + { + name: "browser-only smoke", + settings: {}, + smoke: { enabled: true, useNativeExport: false }, + expected: { pipelineModel: "modern", backendPreference: "webcodecs" }, + }, + { + name: "native smoke", + settings: {}, + smoke: { enabled: true, useNativeExport: true }, + expected: { pipelineModel: "modern", backendPreference: "breeze" }, + }, +] as const)("validates download dimensions using the $name export route", async ({ + settings, + smoke, + expected, +}) => { + const { value, input } = context(); + Object.assign(value.settings, settings); + Object.assign(input.smokeExportConfig, smoke); + input.ensureSupportedMp4SourceDimensions.mockRejectedValueOnce( + new Error("dimension validation"), + ); + await expect(runMp4Export(value)).rejects.toThrow("dimension validation"); + expect(input.ensureSupportedMp4SourceDimensions).toHaveBeenCalledWith( + 30, + "medium", + undefined, + expected, + ); +}); describe("local exports without a Cloud subscription", () => { it.each([ "high", diff --git a/src/components/video-editor/export/runMp4Export.ts b/src/components/video-editor/export/runMp4Export.ts index 0741f07c..a6bede2d 100644 --- a/src/components/video-editor/export/runMp4Export.ts +++ b/src/components/video-editor/export/runMp4Export.ts @@ -111,6 +111,9 @@ export async function runMp4Export(context: ExportBranchContext): Promise, ) => { const requested = calculateMp4ExportDimensions( desiredSourceDimensions.width, @@ -142,10 +144,13 @@ export function useExportDimensions({ const target = shareResolution ? capShareDimensions(requested, shareResolution) : requested; - // Cloud frames are encoded by bundled FFmpeg. Browser encoder support - // must not cap or reject a resolution that this encoder can handle. + // Modern native routes can encode raw frames with bundled FFmpeg when + // WebCodecs cannot encode this size. Browser-only routes still probe below. + const canUseNativeEncoder = route + ? route.pipelineModel === "modern" && route.backendPreference !== "webcodecs" + : shareResolution !== undefined; if ( - shareResolution && + canUseNativeEncoder && typeof window.electronAPI?.nativeVideoExportStart === "function" ) { return { ...target, capped: false, encoderPath: null }; diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts index a97301da..30070109 100644 --- a/src/components/video-editor/exportDimensions.test.ts +++ b/src/components/video-editor/exportDimensions.test.ts @@ -315,6 +315,7 @@ vi.mock("@/lib/exporter", () => ({ probeSupportedMp4Dimensions: probes.probe, })); import { useExportDimensions } from "./export/useExportDimensions"; +import { startNativeVideoExport } from "@/lib/exporter/modernNativeStreamStart"; const encoderPath = { codec: "avc1.640034", hardwareAcceleration: "prefer-hardware" }; function sourceInput() { return { @@ -407,3 +408,98 @@ describe("source support probe lifecycle", () => { expect(hooks.writes()).toBe(writes); }); }); + +describe("MP4 dimension validation follows the export route", () => { + it.each([ + "auto", + "breeze", + ] as const)("preserves local custom dimensions for the modern %s rawvideo fallback", async (backendPreference) => { + const start = vi.fn(async () => ({ success: true, sessionId: "raw-session" })); + window.electronAPI.nativeVideoExportStart = start; + vi.stubGlobal("VideoEncoder", undefined); + const input = sourceInput(); + input.customExportResolution = { width: 3840, height: 2160 }; + const dimensions = hooks.render(() => useExportDimensions(input)); + await flushProbe(); + probes.probe.mockClear(); + probes.probe.mockResolvedValue({ + width: 1920, + height: 1080, + capped: true, + encoderPath, + }); + const supported = await dimensions.ensureSupportedMp4SourceDimensions( + 60, + "custom", + undefined, + { pipelineModel: "modern", backendPreference }, + ); + expect(supported).toEqual({ width: 3840, height: 2160, capped: false, encoderPath: null }); + expect(probes.probe).not.toHaveBeenCalled(); + const session = { + config: { ...supported, frameRate: 60, bitrate: 32_000_000, encodingMode: "quality" }, + cancelled: false, + native: {}, + flow: {}, + metrics: {}, + } as unknown as Parameters[0]; + await expect(startNativeVideoExport(session, {})).resolves.toBe(true); + expect(start).toHaveBeenCalledWith({ + width: 3840, + height: 2160, + frameRate: 60, + bitrate: 32_000_000, + encodingMode: "quality", + inputMode: "rawvideo", + }); + }); + it.each([ + { + name: "browser-only modern", + native: true, + route: { pipelineModel: "modern", backendPreference: "webcodecs" }, + }, + { + name: "legacy", + native: true, + route: { pipelineModel: "legacy", backendPreference: "webcodecs" }, + }, + { + name: "missing native bridge", + native: false, + route: { pipelineModel: "modern", backendPreference: "auto" }, + }, + { name: "source support probe", native: true, route: undefined }, + ] as const)("keeps browser resolution validation for $name", async ({ native, route }) => { + if (native) window.electronAPI.nativeVideoExportStart = vi.fn(); + const dimensions = hooks.render(() => useExportDimensions(sourceInput())); + await flushProbe(); + probes.probe.mockResolvedValue({ width: 1280, height: 720, capped: true, encoderPath }); + await expect( + dimensions.ensureSupportedMp4SourceDimensions(60, "high", undefined, route), + ).rejects.toThrow("This system cannot export"); + probes.probe.mockResolvedValue({ + width: 3840, + height: 2160, + capped: false, + encoderPath: null, + }); + await expect( + dimensions.ensureSupportedMp4SourceDimensions(60, "high", undefined, route), + ).rejects.toThrow("Video encoding not supported"); + }); + it("retains the cloud cap when its native route does not support browser encoding", async () => { + window.electronAPI.nativeVideoExportStart = vi.fn(); + const dimensions = hooks.render(() => useExportDimensions(sourceInput())); + await flushProbe(); + probes.probe.mockClear(); + probes.probe.mockRejectedValue(new Error("No WebCodecs encoder")); + await expect( + dimensions.ensureSupportedMp4SourceDimensions(30, "source", 720, { + pipelineModel: "modern", + backendPreference: "breeze", + }), + ).resolves.toEqual({ width: 1280, height: 720, capped: false, encoderPath: null }); + expect(probes.probe).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/video-editor/videoPlayback/usePlaybackWallpaper.ts b/src/components/video-editor/videoPlayback/usePlaybackWallpaper.ts index 005ccaa5..2ee131aa 100644 --- a/src/components/video-editor/videoPlayback/usePlaybackWallpaper.ts +++ b/src/components/video-editor/videoPlayback/usePlaybackWallpaper.ts @@ -1,6 +1,11 @@ import type React from "react"; import { useEffect, useState } from "react"; -import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath"; +import { + getAssetPath, + getRenderableAssetUrl, + getRenderableVideoUrl, + isAbsoluteLocalAssetPath, +} from "@/lib/assetPath"; import { clampMediaTimeToDuration } from "@/lib/mediaTiming"; import { DEFAULT_WALLPAPER_PATH, @@ -109,7 +114,7 @@ export function usePlaybackWallpaper(input: usePlaybackWallpaperInput) { if ( wallpaper.startsWith("http") || wallpaper.startsWith("file://") || - wallpaper.startsWith("/") + isAbsoluteLocalAssetPath(wallpaper) ) { const renderable = await getRenderableAssetUrl(wallpaper); if (mounted) { diff --git a/src/lib/desktopWallpaper.test.ts b/src/lib/desktopWallpaper.test.ts index 8a674ea1..4c3d4397 100644 --- a/src/lib/desktopWallpaper.test.ts +++ b/src/lib/desktopWallpaper.test.ts @@ -5,10 +5,90 @@ import { getDesktopWallpaperPath, } from "./desktopWallpaper"; import { getAvailableWallpapers } from "./wallpapers"; +import { usePlaybackWallpaper } from "../components/video-editor/videoPlayback/usePlaybackWallpaper"; + +const playback = vi.hoisted(() => ({ values: [] as unknown[], cursor: 0 })); +vi.mock("react", () => ({ + useState: (initial: unknown) => { + const index = playback.cursor++; + playback.values[index] = initial; + return [ + initial, + (value: unknown) => { + playback.values[index] = value; + }, + ]; + }, + useEffect: (effect: () => void) => effect(), +})); afterEach(() => vi.unstubAllGlobals()); describe("desktop wallpaper", () => { + it.each([ + [ + String.raw`C:\Users\Example\desktop image.png`, + "file:///C:/Users/Example/desktop%20image.png", + ], + [String.raw`\\server\share\desktop image.png`, "file://server/share/desktop%20image.png"], + ["/Users/Example/desktop image.png", "file:///Users/Example/desktop%20image.png"], + ])("previews an absolute desktop image without treating %s as a bundled asset", async (path, url) => { + const getAssetBasePath = vi.fn(async () => "file:///app/assets/"); + vi.stubGlobal("window", { + location: { protocol: "http:", origin: "http://localhost" }, + electronAPI: { + getDesktopWallpaper: vi.fn(async () => ({ success: true, path })), + getAssetBasePath, + readLocalFile: vi.fn(async () => ({ success: false })), + }, + }); + const originalPath = await getDesktopWallpaperPath(); + playback.values = []; + playback.cursor = 0; + usePlaybackWallpaper({ + wallpaper: originalPath ?? "", + previewViewportWidth: 1000, + backgroundBlur: 0, + showShadow: false, + shadowIntensity: 0, + lockedVideoDimensionsRef: { current: null }, + videoRef: { current: null }, + } as Parameters[0]); + await vi.waitFor(() => expect(playback.values[0]).toBe(url)); + expect(playback.values[1]).toBe("image"); + expect(getAssetBasePath).not.toHaveBeenCalled(); + expect(originalPath).toBe(path); + }); + + it.each([ + "/wallpapers/tahoe-light.jpg", + "wallpapers/tahoe-light.jpg", + ])("preserves bundled wallpaper URL resolution for %s", async (path) => { + const getAssetBasePath = vi.fn(async () => "file:///app/assets/"); + vi.stubGlobal("window", { + location: { protocol: "http:", origin: "http://localhost" }, + electronAPI: { getAssetBasePath }, + }); + playback.values = []; + playback.cursor = 0; + usePlaybackWallpaper({ + wallpaper: path, + previewViewportWidth: 1000, + backgroundBlur: 0, + showShadow: false, + shadowIntensity: 0, + lockedVideoDimensionsRef: { current: null }, + videoRef: { current: null }, + } as Parameters[0]); + await vi.waitFor(() => + expect(playback.values[0]).toBe( + "http://localhost/__recordly_assets/wallpapers/tahoe-light.jpg", + ), + ); + expect(playback.values[1]).toBe("image"); + expect(getAssetBasePath).toHaveBeenCalledOnce(); + }); + it("shares a single snapshot request and places it before bundled wallpapers", async () => { const path = `/Users/example/Recordly/desktop-wallpapers/${"a".repeat(64)}.png`; const getDesktopWallpaper = vi.fn(async () => ({ success: true, path })); diff --git a/tests/ui/paused-motion.tsx b/tests/ui/paused-motion.tsx index 57a0a69d..4d735dd8 100644 --- a/tests/ui/paused-motion.tsx +++ b/tests/ui/paused-motion.tsx @@ -195,13 +195,15 @@ function Preview() { - + )}