mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 23:43:35 +00:00
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:
commit
d2ed0ad7b5
9 files changed
+252
-10
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user