Fix Recordly 2.0 part 7 follow-up review findings

Preserve existing PR history and integrate validated second-cycle fixes.
This commit is contained in:
webadderall committed 2026-11-07 19:54:45 +11:00
commit d2ed0ad7b5
9 files changed
+252 -10

No files matched your search

+4 -2
View File
@@ -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
@@ -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<typeof useExportSettings>["mp4FrameRate"],
quality?: ExportQuality,
shareResolution?: ShareResolution,
route?: Pick<Mp4ExportRouting, "pipelineModel" | "backendPreference">,
) => Promise<SupportedMp4Dimensions>;
captionSidecarPayload?: PendingExportSave["captionSidecar"];
experimentalNvidiaCudaExport: boolean;
@@ -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",
@@ -111,6 +111,9 @@ export async function runMp4Export(context: ExportBranchContext): Promise<Export
sharing
? getShareResolution(plan?.loading ? undefined : plan?.status?.billing.plan)
: undefined,
sharing
? { pipelineModel: "modern", backendPreference: "breeze" }
: { pipelineModel, backendPreference },
);
if (exportWasCancelled()) return { exit: true };
const { width: exportWidth, height: exportHeight } = supportedSourceDimensions;
@@ -21,6 +21,7 @@ import {
fitCustomExportResolution,
} from "../exportDimensions";
import type { CropRegion } from "../types";
import type { Mp4ExportRouting } from "../mp4ExportRouting";
import type { VideoPlaybackRef } from "../VideoPlayback";
import { capShareDimensions, type ShareResolution } from "../cloud/shareResolution";
@@ -132,6 +133,7 @@ export function useExportDimensions({
frameRate: ExportMp4FrameRate,
quality: ExportQuality = "source",
shareResolution?: ShareResolution,
route?: Pick<Mp4ExportRouting, "pipelineModel" | "backendPreference">,
) => {
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 };
@@ -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<typeof startNativeVideoExport>[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();
});
});
@@ -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) {
+80
View File
@@ -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<typeof usePlaybackWallpaper>[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<typeof usePlaybackWallpaper>[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 }));
+4 -2
View File
@@ -195,13 +195,15 @@ function Preview() {
<button
onClick={() =>
playback.current?.seekTimeline(
(4000 + timingPhases.zoomOutStart) / 2000,
(zooms[0].endMs + timingPhases.zoomOutStart) / 2000,
)
}
>
Seek exit midpoint
</button>
<button onClick={() => playback.current?.seekTimeline(4)}>Seek zoom end</button>
<button onClick={() => playback.current?.seekTimeline(zooms[0].endMs / 1000)}>
Seek zoom end
</button>
</>
)}
<button