Merge remote-tracking branch 'origin/main' into fix/hud-visible-after-recording-start

This commit is contained in:
wiiiii123
2026-07-11 12:28:38 +07:00
43 changed files with 2983 additions and 3426 deletions
-15
View File
@@ -1,15 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
};
+25
View File
@@ -0,0 +1,25 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.dll binary
*.exe binary
*.gif binary
*.icns binary
*.ico binary
*.jpeg binary
*.jpg binary
*.mov binary
*.mp3 binary
*.mp4 binary
*.node binary
*.pdf binary
*.png binary
*.ttf binary
*.wasm binary
*.wav binary
*.webm binary
*.woff binary
*.woff2 binary
*.zip binary
+65
View File
@@ -0,0 +1,65 @@
name: Code Quality
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Repository quality
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: true
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
id: install
run: npm ci --ignore-scripts
- name: Typecheck
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npx tsc --noEmit
- name: Lint
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npm run lint
- name: Check formatting (advisory)
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
continue-on-error: true
run: npm run format:check
- name: Test
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npm test
# Main currently has known locale-parity debt covered by PR #710. Keep the
# check visible without blocking unrelated PRs; make it required once that
# existing translation PR (or an equivalent fix) lands.
- name: Check translations (advisory)
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
continue-on-error: true
run: npm run i18n:check
+14 -34
View File
@@ -1,7 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false },
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!!**/dist",
"!!**/dist-electron",
"!!**/dist-ssr",
"!!**/release",
"!!**/.tmp",
"!!**/electron/native/**/build"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
@@ -92,42 +103,11 @@
"noWith": "error",
"useGetterReturn": "error"
}
},
"includes": ["**", "**/dist", "**/.eslintrc.cjs", "**", "**/dist", "**/.eslintrc.cjs"]
}
},
"javascript": { "formatter": { "quoteStyle": "double" } },
"css": { "parser": { "tailwindDirectives": true } },
"css": { "parser": { "cssModules": true, "tailwindDirectives": true } },
"overrides": [
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
},
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { formatMarketplaceHttpError } from "./errorUtils";
describe("formatMarketplaceHttpError", () => {
it("hides upstream HTML when the marketplace is unavailable", () => {
const html = "<!DOCTYPE html><html><body>SSL handshake failed</body></html>";
const message = formatMarketplaceHttpError({
status: 525,
contentType: "text/html; charset=UTF-8",
body: html,
});
expect(message).toBe(
"Marketplace is temporarily unavailable (HTTP 525). Please try again later.",
);
expect(message).not.toContain(html);
});
it("keeps a short JSON error for client-side request failures", () => {
expect(
formatMarketplaceHttpError({
status: 400,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid search query" }),
}),
).toBe("Marketplace request failed (HTTP 400): Invalid search query");
});
it("uses a JSON message when an error field is absent", () => {
expect(
formatMarketplaceHttpError({
status: 409,
contentType: "application/json",
body: JSON.stringify({ message: "Extension version already exists" }),
}),
).toBe("Marketplace request failed (HTTP 409): Extension version already exists");
});
it("prefers a string error when both JSON detail fields are present", () => {
expect(
formatMarketplaceHttpError({
status: 400,
contentType: "application/json",
body: JSON.stringify({ error: "Primary detail", message: "Secondary detail" }),
}),
).toBe("Marketplace request failed (HTTP 400): Primary detail");
});
it("hides malformed JSON bodies", () => {
const body = '{"error":"internal route details"';
const message = formatMarketplaceHttpError({
status: 400,
contentType: "application/json",
body,
});
expect(message).toBe("Marketplace request failed (HTTP 400).");
expect(message).not.toContain(body);
});
it("bounds long JSON details and marks truncation without splitting Unicode", () => {
const detail = `🚀${"x".repeat(200)}`;
const message = formatMarketplaceHttpError({
status: 400,
contentType: "application/problem+json",
body: JSON.stringify({ error: detail }),
});
expect(message).toBe(`Marketplace request failed (HTTP 400): 🚀${"x".repeat(198)}…`);
expect(Array.from(message.split(": ")[1])).toHaveLength(200);
});
it("does not expose non-JSON response bodies", () => {
expect(
formatMarketplaceHttpError({
status: 404,
contentType: "text/plain",
body: "internal route details",
}),
).toBe("Marketplace request failed (HTTP 404).");
});
});
+41 -1
View File
@@ -1,3 +1,43 @@
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}
const MAX_MARKETPLACE_ERROR_DETAIL_LENGTH = 200;
export function formatMarketplaceHttpError({
status,
contentType,
body,
}: {
status: number;
contentType: string | null;
body: string;
}): string {
if (status >= 500) {
return `Marketplace is temporarily unavailable (HTTP ${status}). Please try again later.`;
}
let detail: string | null = null;
if (contentType?.toLowerCase().includes("json")) {
try {
const payload: unknown = JSON.parse(body);
if (payload && typeof payload === "object") {
const { error, message } = payload as { error?: unknown; message?: unknown };
const value = typeof error === "string" ? error : message;
if (typeof value === "string" && value.trim()) {
const normalized = value.trim().replace(/\s+/g, " ");
const codePoints = Array.from(normalized);
detail =
codePoints.length > MAX_MARKETPLACE_ERROR_DETAIL_LENGTH
? `${codePoints.slice(0, MAX_MARKETPLACE_ERROR_DETAIL_LENGTH - 1).join("")}…`
: normalized;
}
}
} catch {
// Malformed or non-API responses are intentionally not exposed to the renderer.
}
}
const summary = `Marketplace request failed (HTTP ${status})`;
return detail ? `${summary}: ${detail}` : `${summary}.`;
}
+8 -2
View File
@@ -12,7 +12,7 @@ import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { app } from "electron";
import { getErrorMessage } from "./errorUtils";
import { formatMarketplaceHttpError, getErrorMessage } from "./errorUtils";
import { getRegisteredExtensions, installExtensionFromPath } from "./extensionLoader";
import type {
ExtensionReview,
@@ -97,7 +97,13 @@ async function marketplaceFetch<T>(
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Marketplace API error ${response.status}: ${text}`);
throw new Error(
formatMarketplaceHttpError({
status: response.status,
contentType: response.headers.get("content-type"),
body: text,
}),
);
}
return (await response.json()) as T;
+8
View File
@@ -27,6 +27,14 @@ describe("Windows native helper path resolution", () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
it("resolves a requested platform tag independently of the host platform", async () => {
const { getNativeArchTag } = await import("./binaries");
expect(getNativeArchTag("win32")).toBe(
process.arch === "arm64" ? "win32-arm64" : "win32-x64",
);
});
it("prefers the branch-staged helper over a stale local CMake build in dev", async () => {
const buildOutputPath = path.join(
appPath,
+11 -8
View File
@@ -29,24 +29,27 @@ export function getNativeCaptureHelperSourcePath(): string {
return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitRecorder.swift");
}
export function getNativeArchTag(): string {
if (process.platform === "darwin") {
export function getNativeArchTag(platform: NodeJS.Platform = process.platform): string {
if (platform === "darwin") {
return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
}
if (process.platform === "win32") {
if (platform === "win32") {
return process.arch === "arm64" ? "win32-arm64" : "win32-x64";
}
if (process.platform === "linux") {
if (platform === "linux") {
return process.arch === "arm64" ? "linux-arm64" : "linux-x64";
}
return `${process.platform}-${process.arch}`;
return `${platform}-${process.arch}`;
}
export function getPrebundledNativeHelperPath(binaryName: string): string {
return resolveUnpackedAppPath("electron", "native", "bin", getNativeArchTag(), binaryName);
export function getPrebundledNativeHelperPath(
binaryName: string,
archTag = getNativeArchTag(),
): string {
return resolveUnpackedAppPath("electron", "native", "bin", archTag, binaryName);
}
export function resolvePreferredWindowsNativeHelperPath(
@@ -61,7 +64,7 @@ export function resolvePreferredWindowsNativeHelperPath(
"Release",
binaryName,
);
const prebundledPath = getPrebundledNativeHelperPath(binaryName);
const prebundledPath = getPrebundledNativeHelperPath(binaryName, getNativeArchTag("win32"));
if (app.isPackaged && existsSync(prebundledPath)) {
return prebundledPath;
+112
View File
@@ -0,0 +1,112 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getProjectBackupPath, writeProjectFileAtomically } from "./atomicSave";
describe("writeProjectFileAtomically", () => {
let tempDir: string;
let projectPath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-atomic-project-"));
projectPath = path.join(tempDir, "demo.recordly");
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
async function expectNoTemporaryArtifacts() {
const entries = await fs.readdir(tempDir);
expect(entries.filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
}
it("commits a complete new project without creating a backup", async () => {
await writeProjectFileAtomically(projectPath, '{"version":1}');
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1}');
await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({
code: "ENOENT",
});
await expectNoTemporaryArtifacts();
await expectNoTemporaryArtifacts();
});
it("removes a stale backup when the target has no previous generation", async () => {
await fs.writeFile(getProjectBackupPath(projectPath), "stale-project");
await writeProjectFileAtomically(projectPath, '{"version":1}');
await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("preserves the previous complete generation before replacement", async () => {
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}');
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}');
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"new"}');
await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
'{"version":1,"name":"old"}',
);
await expectNoTemporaryArtifacts();
});
it("keeps the active generation unchanged when backup commit fails", async () => {
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}');
await fs.mkdir(getProjectBackupPath(projectPath));
await expect(
writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}'),
).rejects.toBeDefined();
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"old"}');
await expectNoTemporaryArtifacts();
await fs.rm(getProjectBackupPath(projectPath), { recursive: true });
await writeProjectFileAtomically(projectPath, '{"version":1,"name":"retry"}');
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe(
'{"version":1,"name":"retry"}',
);
await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
'{"version":1,"name":"old"}',
);
await expectNoTemporaryArtifacts();
});
it("serializes overlapping writes to the same project", async () => {
await writeProjectFileAtomically(projectPath, '{"revision":1}');
// Each call enters the queue synchronously before its first await, preserving invocation order.
await Promise.all([
writeProjectFileAtomically(projectPath, '{"revision":2}'),
writeProjectFileAtomically(projectPath, '{"revision":3}'),
]);
await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"revision":3}');
await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
'{"revision":2}',
);
await expectNoTemporaryArtifacts();
});
it.skipIf(process.platform === "win32")(
"preserves exact project permission bits despite the process umask",
async () => {
await fs.writeFile(projectPath, '{"revision":1}', { mode: 0o666 });
await fs.chmod(projectPath, 0o666);
const previousUmask = process.umask(0o077);
try {
await writeProjectFileAtomically(projectPath, '{"revision":2}');
} finally {
process.umask(previousUmask);
}
expect((await fs.stat(projectPath)).mode & 0o777).toBe(0o666);
},
);
});
+146
View File
@@ -0,0 +1,146 @@
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
const pendingWrites = new Map<string, Promise<void>>();
const unsupportedDirectorySyncErrors = new Set([
"EACCES",
"EINVAL",
"EISDIR",
"ENOSYS",
"ENOTSUP",
"EOPNOTSUPP",
"EPERM",
]);
export function getProjectBackupPath(projectPath: string): string {
return `${projectPath}.bak`;
}
function getQueueKey(projectPath: string): string {
const resolvedPath = path.resolve(projectPath);
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
}
function createTemporaryPath(parentDir: string, label: string): string {
return path.join(parentDir, `.recordly-${label}-${process.pid}-${randomUUID()}.tmp`);
}
async function getExistingFileMode(filePath: string): Promise<number | undefined> {
try {
return (await fs.stat(filePath)).mode & 0o777;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
throw error;
}
}
async function writeSyncedTemporaryFile(
filePath: string,
contents: string,
mode?: number,
): Promise<void> {
const handle = await fs.open(filePath, "wx", mode);
try {
await handle.writeFile(contents, "utf-8");
if (mode !== undefined) {
await handle.chmod(mode);
}
await handle.sync();
} finally {
await handle.close();
}
}
async function syncExistingFile(filePath: string): Promise<void> {
const handle = await fs.open(filePath, "r+");
try {
await handle.sync();
} finally {
await handle.close();
}
}
async function syncParentDirectory(parentDir: string): Promise<void> {
if (process.platform === "win32") {
return;
}
try {
const handle = await fs.open(parentDir, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (!code || !unsupportedDirectorySyncErrors.has(code)) {
throw error;
}
}
}
async function preservePreviousGeneration(
targetPath: string,
backupPath: string,
backupTemporaryPath: string,
): Promise<void> {
try {
await fs.copyFile(targetPath, backupTemporaryPath, fsConstants.COPYFILE_EXCL);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
await fs.rm(backupPath, { force: true });
return;
}
throw error;
}
await syncExistingFile(backupTemporaryPath);
await fs.rename(backupTemporaryPath, backupPath);
}
async function commitProjectFile(projectPath: string, contents: string): Promise<void> {
const targetPath = path.resolve(projectPath);
const parentDir = path.dirname(targetPath);
const backupPath = getProjectBackupPath(targetPath);
const temporaryPath = createTemporaryPath(parentDir, "project");
const backupTemporaryPath = createTemporaryPath(parentDir, "backup");
const existingMode = await getExistingFileMode(targetPath);
try {
await writeSyncedTemporaryFile(temporaryPath, contents, existingMode);
await preservePreviousGeneration(targetPath, backupPath, backupTemporaryPath);
await fs.rename(temporaryPath, targetPath);
await syncParentDirectory(parentDir);
} finally {
await Promise.all([
fs.rm(temporaryPath, { force: true }).catch(() => undefined),
fs.rm(backupTemporaryPath, { force: true }).catch(() => undefined),
]);
}
}
export async function writeProjectFileAtomically(
projectPath: string,
contents: string,
): Promise<void> {
const queueKey = getQueueKey(projectPath);
const previousWrite = pendingWrites.get(queueKey) ?? Promise.resolve();
const currentWrite = previousWrite
.catch(() => undefined)
.then(() => commitProjectFile(projectPath, contents));
pendingWrites.set(queueKey, currentWrite);
try {
await currentWrite;
} finally {
if (pendingWrites.get(queueKey) === currentWrite) {
pendingWrites.delete(queueKey);
}
}
}
@@ -0,0 +1,53 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveRecordedVideoStoragePath } from "./storagePath";
describe("resolveRecordedVideoStoragePath", () => {
const recordingsDir = path.resolve("recordings-root");
it.each([
"recording-0.webm",
"recording-1720588800000.mp4",
"recording-1720588800000-webcam.webm",
"recording-1720588800000-webcam.mp4",
])("accepts an app-generated recording name: %s", (fileName) => {
expect(resolveRecordedVideoStoragePath(recordingsDir, fileName)).toBe(
path.resolve(recordingsDir, fileName),
);
});
it.each([
"",
"../recording-1.webm",
"..\\recording-1.webm",
"nested/recording-1.webm",
"nested\\recording-1.webm",
"/tmp/recording-1.webm",
"C:\\temp\\recording-1.webm",
"\\\\server\\share\\recording-1.webm",
"recording-1.webm:payload",
"recording-1.webm\n",
"recording-1.webm\0",
"recording--1.webm",
"recording-1.5.webm",
"recording-1.mov",
"other-1.webm",
"recording-1-WEBCAM.webm",
])("rejects an untrusted recording name: %s", (fileName) => {
expect(() => resolveRecordedVideoStoragePath(recordingsDir, fileName)).toThrow(
"Invalid recording file name",
);
});
it.each([
{ label: "undefined", value: undefined },
{ label: "null", value: null },
{ label: "number", value: 1 },
{ label: "object", value: {} },
{ label: "array", value: [] },
])("rejects a non-string recording name: $label", ({ value }) => {
expect(() => resolveRecordedVideoStoragePath(recordingsDir, value)).toThrow(
"Invalid recording file name",
);
});
});
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
const RECORDED_VIDEO_FILE_NAME = /^recording-[0-9]+(?:-webcam)?\.(?:webm|mp4)$/;
export function resolveRecordedVideoStoragePath(recordingsDir: string, fileName: unknown): string {
if (typeof fileName !== "string" || RECORDED_VIDEO_FILE_NAME.exec(fileName)?.[0] !== fileName) {
throw new Error("Invalid recording file name");
}
const resolvedRecordingsDir = path.resolve(recordingsDir);
const candidatePath = path.resolve(resolvedRecordingsDir, fileName);
const relativePath = path.relative(resolvedRecordingsDir, candidatePath);
if (
relativePath.length === 0 ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw new Error("Invalid recording file name");
}
return candidatePath;
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function registerAssetHandlers() {
await fs.writeFile(thumbPath, jpegData)
})
// Keep the queue moving even if one fails
thumbGenerationQueue = generation.catch(() => {})
thumbGenerationQueue = generation.catch(() => undefined)
await generation
return { success: true, data: jpegData! }
+14 -3
View File
@@ -9,6 +9,7 @@ import {
LEGACY_PROJECT_FILE_EXTENSIONS,
PROJECT_FILE_EXTENSION,
} from "../constants";
import { getProjectBackupPath, writeProjectFileAtomically } from "../project/atomicSave";
import {
getProjectsDir,
getProjectThumbnailPath,
@@ -305,7 +306,10 @@ export function registerProjectHandlers() {
: null
if (trustedExistingProjectPath) {
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
await writeProjectFileAtomically(
trustedExistingProjectPath,
JSON.stringify(preparedProject.projectData, null, 2),
)
setCurrentProjectPath(trustedExistingProjectPath)
await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl)
await rememberRecentProject(trustedExistingProjectPath)
@@ -345,7 +349,10 @@ export function registerProjectHandlers() {
}
}
await fs.writeFile(result.filePath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
await writeProjectFileAtomically(
result.filePath,
JSON.stringify(preparedProject.projectData, null, 2),
)
setCurrentProjectPath(result.filePath)
await saveProjectThumbnail(result.filePath, thumbnailDataUrl)
await rememberRecentProject(result.filePath)
@@ -411,7 +418,10 @@ export function registerProjectHandlers() {
return overwriteCheck
}
await fs.writeFile(targetProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
await writeProjectFileAtomically(
targetProjectPath,
JSON.stringify(preparedProject.projectData, null, 2),
)
await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl)
await rememberRecentProject(targetProjectPath)
@@ -422,6 +432,7 @@ export function registerProjectHandlers() {
}
})
await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined)
await fs.rm(getProjectBackupPath(activeProjectPath), { force: true }).catch(() => undefined)
const recentProjectPaths = await loadRecentProjectPaths()
const filteredRecentProjectPaths: string[] = []
+3 -2
View File
@@ -68,6 +68,7 @@ import {
waitForNativeCaptureStart,
waitForNativeCaptureStop,
} from "../recording/mac";
import { resolveRecordedVideoStoragePath } from "../recording/storagePath";
import {
attachWindowsCaptureLifecycle,
isNativeWindowsCaptureAvailable,
@@ -1747,10 +1748,10 @@ export function registerRecordingHandlers(
},
);
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: unknown) => {
try {
const recordingsDir = await getRecordingsDir();
const videoPath = path.join(recordingsDir, fileName);
const videoPath = resolveRecordedVideoStoragePath(recordingsDir, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
return await finalizeStoredVideo(videoPath);
} catch (error) {
+108 -12
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
app,
BrowserWindow,
@@ -11,8 +11,10 @@ import {
Notification,
nativeImage,
session,
shell,
systemPreferences,
Tray,
webContents as electronWebContents,
} from "electron";
import { RECORDINGS_DIR } from "./appPaths";
import { showCursor } from "./cursorHider";
@@ -26,7 +28,12 @@ import {
registerIpcHandlers,
} from "./ipc/handlers";
import { ensureMediaServer } from "./mediaServer";
import { ensurePackagedRendererServer } from "./rendererServer";
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer";
import {
hardenWebContentsNavigation,
shouldHardenWebContentsType,
} from "./navigationPolicy";
import type { UpdateToastPayload } from "./updater";
import {
checkForAppUpdates,
@@ -73,6 +80,14 @@ app.commandLine.appendSwitch("ignore-gpu-blocklist");
app.commandLine.appendSwitch("enable-unsafe-webgpu");
app.commandLine.appendSwitch("enable-gpu-rasterization");
app.on("web-contents-created", (_event, contents) => {
if (!shouldHardenWebContentsType(contents.getType())) {
return;
}
hardenWebContentsNavigation(contents, (url) => shell.openExternal(url));
});
function configureGpuAccelerationSwitches() {
const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env);
if (useAngle) {
@@ -132,6 +147,30 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
function getTrustedCaptureDocumentBaseUrls(): string[] {
const trustedUrls = [pathToFileURL(path.join(RENDERER_DIST, "index.html")).href];
if (VITE_DEV_SERVER_URL) {
trustedUrls.push(VITE_DEV_SERVER_URL);
}
const packagedRendererBaseUrl = getPackagedRendererBaseUrl();
if (packagedRendererBaseUrl) {
trustedUrls.push(new URL("/", packagedRendererBaseUrl).href);
}
return trustedUrls;
}
function isHudWebContents(webContents: Electron.WebContents | null): boolean {
if (!webContents || webContents.isDestroyed()) {
return false;
}
const hudWindow = getHudOverlayWindow();
return Boolean(hudWindow && hudWindow.webContents === webContents);
}
// Window references
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
@@ -879,17 +918,47 @@ app.whenReady().then(async () => {
app.setAppUserModelId("dev.recordly.app");
}
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
return allowed.includes(permission);
});
session.defaultSession.setPermissionCheckHandler(
(webContents, permission, requestingOrigin, details) => {
return shouldGrantMediaPermission(
{
permission,
isTrustedCaptureWindow: isHudWebContents(webContents),
isMainFrame: details.isMainFrame,
currentDocumentUrl: webContents?.getURL() ?? "",
// Electron 39 may supply the last committed document URL, including its
// query, in the requestingOrigin argument for media checks.
requestingUrl: details.requestingUrl ?? requestingOrigin,
securityOrigins:
details.securityOrigin === undefined ? [] : [details.securityOrigin],
},
getTrustedCaptureDocumentBaseUrls(),
);
},
);
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
callback(allowed.includes(permission));
});
session.defaultSession.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
const securityOrigin = "securityOrigin" in details ? details.securityOrigin : undefined;
session.defaultSession.setDevicePermissionHandler((_details) => true);
callback(
shouldGrantMediaPermission(
{
permission,
isTrustedCaptureWindow: isHudWebContents(webContents),
isMainFrame: details.isMainFrame,
currentDocumentUrl: webContents.getURL(),
requestingUrl: details.requestingUrl,
securityOrigins: securityOrigin === undefined ? [] : [securityOrigin],
},
getTrustedCaptureDocumentBaseUrls(),
),
);
},
);
// Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default.
session.defaultSession.setDevicePermissionHandler(() => false);
if (process.platform === "darwin") {
const cameraStatus = systemPreferences.getMediaAccessStatus("camera");
@@ -1003,8 +1072,35 @@ app.whenReady().then(async () => {
// via an unsafe cast breaks Electron's internal cursor-constraint
// propagation and causes cursor: 'never' from the renderer to be silently
// ignored by the native capture pipeline.
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
try {
const frame = request.frame;
const isLiveFrame = Boolean(frame && !frame.isDestroyed());
const requestingWebContents =
isLiveFrame && frame ? electronWebContents.fromFrame(frame) : undefined;
const isHudMainFrame = Boolean(
isLiveFrame &&
requestingWebContents &&
isHudWebContents(requestingWebContents) &&
frame === requestingWebContents.mainFrame,
);
if (
!shouldGrantDisplayCapture(
{
isTrustedCaptureWindow: isHudMainFrame,
isMainFrame: Boolean(isLiveFrame && frame?.parent === null),
currentDocumentUrl: isLiveFrame ? (frame?.url ?? "") : "",
securityOrigin: request.securityOrigin,
videoRequested: request.videoRequested,
},
getTrustedCaptureDocumentBaseUrls(),
)
) {
callback({});
return;
}
const sourceId = getSelectedSourceId();
// On Linux/Wayland, calling desktopCapturer.getSources() itself
// invokes the xdg-desktop-portal picker. If we then return one of
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, it, vi } from "vitest";
import {
createWillNavigateHandler,
createWillRedirectHandler,
createWindowOpenHandler,
hardenWebContentsNavigation,
isInternalRendererTarget,
normalizeExternalHttpUrl,
shouldHardenWebContentsType,
} from "./navigationPolicy";
describe("normalizeExternalHttpUrl", () => {
it.each([
["https://example.com/docs", "https://example.com/docs"],
["http://127.0.0.1:3000/path?q=1", "http://127.0.0.1:3000/path?q=1"],
["HTTPS://Example.COM:443/docs", "https://example.com/docs"],
])("normalizes an external HTTP(S) URL: %s", (value, expected) => {
expect(normalizeExternalHttpUrl(value)).toBe(expected);
});
it.each([
"",
"not a URL",
"file:///tmp/recordly.html",
"data:text/html,hello",
"javascript:alert(1)",
"mailto:security@example.com",
"https://user:password@example.com/",
])("rejects an unsafe external URL: %s", (value) => {
expect(normalizeExternalHttpUrl(value)).toBeNull();
});
});
describe("shouldHardenWebContentsType", () => {
it("selects BrowserWindow contents only", () => {
expect(shouldHardenWebContentsType("window")).toBe(true);
expect(shouldHardenWebContentsType("webview")).toBe(false);
expect(shouldHardenWebContentsType("offscreen")).toBe(false);
});
});
describe("isInternalRendererTarget", () => {
it.each([
["http://localhost:5173/?windowType=editor", "http://localhost:5173/editor?reload=1"],
["http://127.0.0.1:43123/?windowType=editor", "http://127.0.0.1:43123/assets/index.js"],
[
"file:///opt/Recordly/dist/index.html?windowType=editor",
"file:///opt/Recordly/dist/index.html?windowType=hud-overlay#status",
],
])("identifies the current renderer origin/file", (currentUrl, targetUrl) => {
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(true);
});
it.each([
["http://localhost:5173/?windowType=editor", "http://localhost.example.com:5173/"],
["http://localhost:5173/", "http://localhost:5174/"],
["https://recordly.example/", "http://recordly.example/"],
["https://recordly.example/", "https://user:pass@recordly.example/"],
["file:///opt/Recordly/dist/index.html", "file:///etc/passwd"],
["file:///opt/Recordly/dist/index.html", "data:text/html,hello"],
["not a URL", "https://example.com/"],
])("distinguishes a target outside the current renderer", (currentUrl, targetUrl) => {
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(false);
});
});
describe("navigation event handlers", () => {
it("preserves an exact renderer reload", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "http://localhost:5173/editor?windowType=editor",
openExternal,
);
handler({
url: "http://localhost:5173/editor?windowType=editor",
preventDefault,
});
expect(preventDefault).not.toHaveBeenCalled();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops same-origin renderer navigation without externalizing it", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "http://localhost:5173/editor",
openExternal,
);
handler({ url: "http://localhost:5173/settings", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops same-file query mutation without externalizing it", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "file:///opt/Recordly/dist/index.html?windowType=editor",
openExternal,
);
handler({
url: "file:///opt/Recordly/dist/index.html?smokeExport=1",
preventDefault,
});
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops cross-origin navigation and opens HTTP(S) in the system browser", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "http://localhost:5173/editor",
openExternal,
);
handler({ url: "https://example.com/docs", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
});
it("stops unsafe schemes without opening them externally", () => {
const preventDefault = vi.fn();
const openExternal = vi.fn(async () => undefined);
const handler = createWillNavigateHandler(
() => "file:///opt/Recordly/dist/index.html",
openExternal,
);
handler({ url: "file:///etc/passwd", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("stops server redirects without externalizing the target", () => {
const preventDefault = vi.fn();
createWillRedirectHandler()({ url: "https://example.com/redirect", preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
});
it("always denies Electron child windows while preserving safe external links", () => {
const openExternal = vi.fn(async () => undefined);
const handler = createWindowOpenHandler(() => "http://localhost:5173/editor", openExternal);
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
expect(handler({ url: "http://localhost:5173/settings" })).toEqual({ action: "deny" });
expect(handler({ url: "javascript:alert(1)" })).toEqual({ action: "deny" });
expect(openExternal).toHaveBeenCalledOnce();
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
});
it("reports a system-browser failure without allowing the child window", async () => {
const error = new Error("browser unavailable");
const reportOpenError = vi.fn();
const handler = createWindowOpenHandler(
() => "http://localhost:5173/editor",
vi.fn(async () => {
throw error;
}),
reportOpenError,
);
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
await vi.waitFor(() => {
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
});
});
it("reports a synchronous browser-launch failure while still denying the child", () => {
const error = new Error("browser launch threw");
const reportOpenError = vi.fn();
const handler = createWindowOpenHandler(
() => "http://localhost:5173/editor",
vi.fn(() => {
throw error;
}),
reportOpenError,
);
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
});
it("attaches navigation, redirect, and window-open policies", () => {
const on = vi.fn();
const setWindowOpenHandler = vi.fn();
const webContents = {
getURL: () => "http://localhost:5173/",
on,
setWindowOpenHandler,
};
hardenWebContentsNavigation(
webContents,
vi.fn(async () => undefined),
);
expect(on).toHaveBeenCalledWith("will-navigate", expect.any(Function));
expect(on).toHaveBeenCalledWith("will-redirect", expect.any(Function));
expect(on).toHaveBeenCalledWith("did-navigate", expect.any(Function));
expect(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function));
});
it("does not trust a renderer-mutated URL as an exact reload", () => {
let currentUrl = "file:///opt/Recordly/dist/index.html?windowType=editor";
const on = vi.fn();
const webContents = {
getURL: () => currentUrl,
on,
setWindowOpenHandler: vi.fn(),
};
const openExternal = vi.fn(async () => undefined);
hardenWebContentsNavigation(webContents, openExternal);
// history.replaceState() changes getURL() without crossing a document-navigation boundary.
currentUrl = "file:///opt/Recordly/dist/index.html?windowType=source-selector";
const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1];
if (typeof willNavigate !== "function") {
throw new Error("will-navigate handler was not registered");
}
const preventDefault = vi.fn();
willNavigate({ url: currentUrl, preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
expect(openExternal).not.toHaveBeenCalled();
});
it("trusts an exact reload after a completed document navigation", () => {
const on = vi.fn();
const webContents = {
getURL: () => "",
on,
setWindowOpenHandler: vi.fn(),
};
hardenWebContentsNavigation(
webContents,
vi.fn(async () => undefined),
);
const didNavigate = on.mock.calls.find(([eventName]) => eventName === "did-navigate")?.[1];
const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1];
if (typeof didNavigate !== "function" || typeof willNavigate !== "function") {
throw new Error("navigation handlers were not registered");
}
const loadedUrl = "file:///opt/Recordly/dist/index.html?windowType=editor";
didNavigate({}, loadedUrl);
const preventDefault = vi.fn();
willNavigate({ url: loadedUrl, preventDefault });
expect(preventDefault).not.toHaveBeenCalled();
});
});
+151
View File
@@ -0,0 +1,151 @@
import type { WebContents } from "electron";
type OpenExternal = (url: string) => Promise<unknown>;
type ReportOpenError = (url: string, error: unknown) => void;
export type NavigationEvent = {
url: string;
preventDefault: () => void;
};
export function shouldHardenWebContentsType(type: ReturnType<WebContents["getType"]>): boolean {
return type === "window";
}
export function normalizeExternalHttpUrl(value: string): string | null {
try {
const url = new URL(value);
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
!url.hostname ||
url.username ||
url.password
) {
return null;
}
return url.href;
} catch {
return null;
}
}
export function isInternalRendererTarget(currentValue: string, targetValue: string): boolean {
try {
const currentUrl = new URL(currentValue);
const targetUrl = new URL(targetValue);
if (targetUrl.username || targetUrl.password) {
return false;
}
if (
(currentUrl.protocol === "http:" || currentUrl.protocol === "https:") &&
(targetUrl.protocol === "http:" || targetUrl.protocol === "https:")
) {
return currentUrl.origin === targetUrl.origin;
}
if (currentUrl.protocol === "file:" && targetUrl.protocol === "file:") {
return currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname;
}
return currentUrl.href === targetUrl.href;
} catch {
return false;
}
}
function isExactRendererLocation(currentValue: string, targetValue: string): boolean {
try {
const currentUrl = new URL(currentValue);
const targetUrl = new URL(targetValue);
return !targetUrl.username && !targetUrl.password && currentUrl.href === targetUrl.href;
} catch {
return false;
}
}
function openExternalIfSafe(
value: string,
openExternal: OpenExternal,
reportOpenError: ReportOpenError,
): void {
const safeUrl = normalizeExternalHttpUrl(value);
if (!safeUrl) {
return;
}
try {
void openExternal(safeUrl).catch((error) => reportOpenError(safeUrl, error));
} catch (error) {
reportOpenError(safeUrl, error);
}
}
const defaultReportOpenError: ReportOpenError = (url, error) => {
console.error("[navigation-policy] Failed to open external URL", { url, error });
};
export function createWillNavigateHandler(
getTrustedRendererUrl: () => string,
openExternal: OpenExternal,
reportOpenError: ReportOpenError = defaultReportOpenError,
) {
return (event: NavigationEvent): void => {
const trustedRendererUrl = getTrustedRendererUrl();
// Preserve an exact reload, but freeze all renderer-selected destination changes,
// including same-origin query mutations that can carry privileged local paths.
if (isExactRendererLocation(trustedRendererUrl, event.url)) {
return;
}
// The internal-target check only prevents app URLs from leaking into the system browser.
event.preventDefault();
if (isInternalRendererTarget(trustedRendererUrl, event.url)) {
return;
}
openExternalIfSafe(event.url, openExternal, reportOpenError);
};
}
export function createWillRedirectHandler() {
return (event: NavigationEvent): void => {
event.preventDefault();
};
}
export function createWindowOpenHandler(
getCurrentUrl: () => string,
openExternal: OpenExternal,
reportOpenError: ReportOpenError = defaultReportOpenError,
) {
return (details: { url: string }) => {
if (!isInternalRendererTarget(getCurrentUrl(), details.url)) {
openExternalIfSafe(details.url, openExternal, reportOpenError);
}
return { action: "deny" as const };
};
}
export function hardenWebContentsNavigation(
webContents: Pick<WebContents, "getURL" | "on" | "setWindowOpenHandler">,
openExternal: OpenExternal,
reportOpenError: ReportOpenError = defaultReportOpenError,
): void {
// Renderer history APIs mutate getURL() without a document navigation. Keep the last
// main-frame document URL as the reload trust boundary instead of trusting that live value.
let trustedRendererUrl = webContents.getURL();
webContents.on("did-navigate", (_event, url) => {
trustedRendererUrl = url;
});
webContents.on(
"will-navigate",
createWillNavigateHandler(() => trustedRendererUrl, openExternal, reportOpenError),
);
webContents.on("will-redirect", createWillRedirectHandler());
webContents.setWindowOpenHandler(
createWindowOpenHandler(() => webContents.getURL(), openExternal, reportOpenError),
);
}
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import {
isTrustedCaptureDocumentUrl,
shouldGrantDisplayCapture,
shouldGrantMediaPermission,
} from "./permissionPolicy";
const TRUSTED_DOCUMENT_BASE_URLS = [
"http://localhost:5173/",
"http://127.0.0.1:43127/",
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html",
];
const DEV_HUD_URL = "http://localhost:5173/?windowType=hud-overlay";
const PACKAGED_HUD_URL = "http://127.0.0.1:43127/?windowType=hud-overlay";
const FILE_HUD_URL =
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html?windowType=hud-overlay";
describe("isTrustedCaptureDocumentUrl", () => {
it.each([
DEV_HUD_URL,
PACKAGED_HUD_URL,
FILE_HUD_URL,
`${DEV_HUD_URL}#microphone`,
])("accepts a Recordly HUD document: %s", (candidateUrl) => {
expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
});
it.each([
"http://localhost:5173/",
"http://localhost:5173/?windowType=editor",
"http://localhost:5173/?windowType=HUD-OVERLAY",
"http://localhost:5173/?windowType=hud-overlay&debug=1",
"http://localhost:5173/?windowType=hud-overlay&windowType=hud-overlay",
"http://localhost:5174/?windowType=hud-overlay",
"http://localhost.evil.test:5173/?windowType=hud-overlay",
"http://localhost:5173.evil.test/?windowType=hud-overlay",
"http://user@localhost:5173/?windowType=hud-overlay",
"https://localhost:5173/?windowType=hud-overlay",
"http://127.0.0.1:43127/nested/?windowType=hud-overlay",
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/other.html?windowType=hud-overlay",
"file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html/child?windowType=hud-overlay",
"data:text/html,recordly?windowType=hud-overlay",
"not a url",
])("rejects a non-Recordly capture document: %s", (candidateUrl) => {
expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(false);
});
it("ignores malformed trusted base URLs instead of throwing", () => {
expect(
isTrustedCaptureDocumentUrl(DEV_HUD_URL, ["not a url", ...TRUSTED_DOCUMENT_BASE_URLS]),
).toBe(true);
});
});
describe("shouldGrantMediaPermission", () => {
const makeRequest = (
overrides: Partial<Parameters<typeof shouldGrantMediaPermission>[0]> = {},
): Parameters<typeof shouldGrantMediaPermission>[0] => ({
permission: "media",
isTrustedCaptureWindow: true,
isMainFrame: true,
currentDocumentUrl: DEV_HUD_URL,
requestingUrl: DEV_HUD_URL,
securityOrigins: ["http://localhost:5173"],
...overrides,
});
it("grants camera or microphone media to the trusted HUD main frame", () => {
expect(shouldGrantMediaPermission(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
});
it("accepts Chromium's trailing-slash HTTP origin serialization", () => {
expect(
shouldGrantMediaPermission(
makeRequest({ securityOrigins: ["http://localhost:5173/"] }),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it("accepts the packaged loopback renderer with its exact origin", () => {
expect(
shouldGrantMediaPermission(
makeRequest({
currentDocumentUrl: PACKAGED_HUD_URL,
requestingUrl: PACKAGED_HUD_URL,
securityOrigins: ["http://127.0.0.1:43127"],
}),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it.each([
"null",
"file://",
"file:///",
])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => {
expect(
shouldGrantMediaPermission(
makeRequest({
currentDocumentUrl: FILE_HUD_URL,
requestingUrl: FILE_HUD_URL,
securityOrigins: [securityOrigin],
}),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it.each([
["another permission", { permission: "display-capture" }],
["another BrowserWindow", { isTrustedCaptureWindow: false }],
["a subframe", { isMainFrame: false }],
["an untrusted current document", { currentDocumentUrl: "https://example.com/" }],
["a missing requesting document", { requestingUrl: "" }],
["an untrusted requesting document", { requestingUrl: "https://example.com/" }],
["a different trusted document", { requestingUrl: PACKAGED_HUD_URL }],
["a mismatched origin", { securityOrigins: ["http://localhost:5174"] }],
["an origin lookalike", { securityOrigins: ["http://localhost:5173.evil.test"] }],
["an origin with credentials", { securityOrigins: ["http://user@localhost:5173/"] }],
["an origin with a path", { securityOrigins: ["http://localhost:5173/other"] }],
["an origin with a query", { securityOrigins: ["http://localhost:5173/?debug=1"] }],
["a missing security origin", { securityOrigins: [] }],
["an empty origin", { securityOrigins: [""] }],
] as const)("denies %s", (_label, overrides) => {
expect(shouldGrantMediaPermission(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe(
false,
);
});
});
describe("shouldGrantDisplayCapture", () => {
const makeRequest = (
overrides: Partial<Parameters<typeof shouldGrantDisplayCapture>[0]> = {},
): Parameters<typeof shouldGrantDisplayCapture>[0] => ({
isTrustedCaptureWindow: true,
isMainFrame: true,
currentDocumentUrl: DEV_HUD_URL,
securityOrigin: "http://localhost:5173",
videoRequested: true,
...overrides,
});
it("grants selected-display video to the trusted HUD main frame", () => {
expect(shouldGrantDisplayCapture(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true);
});
it("accepts a trailing slash on the display security origin", () => {
expect(
shouldGrantDisplayCapture(
makeRequest({ securityOrigin: "http://localhost:5173/" }),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it("accepts the packaged loopback renderer with its exact origin", () => {
expect(
shouldGrantDisplayCapture(
makeRequest({
currentDocumentUrl: PACKAGED_HUD_URL,
securityOrigin: "http://127.0.0.1:43127",
}),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
});
it.each(["null", "file://", "file:///"])(
"accepts Chromium's packaged file origin form: %s",
(securityOrigin) => {
expect(
shouldGrantDisplayCapture(
makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }),
TRUSTED_DOCUMENT_BASE_URLS,
),
).toBe(true);
},
);
it.each([
["another BrowserWindow", { isTrustedCaptureWindow: false }],
["a subframe", { isMainFrame: false }],
["a non-Recordly document", { currentDocumentUrl: "https://example.com/" }],
["a mismatched origin", { securityOrigin: "http://127.0.0.1:43127" }],
["a malformed origin", { securityOrigin: "not an origin" }],
["an origin with a path", { securityOrigin: "http://localhost:5173/other" }],
["an audio-only request", { videoRequested: false }],
] as const)("denies %s", (_label, overrides) => {
expect(shouldGrantDisplayCapture(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe(
false,
);
});
});
+157
View File
@@ -0,0 +1,157 @@
const CAPTURE_WINDOW_TYPE = "hud-overlay";
const TRUSTED_RENDERER_PROTOCOLS = new Set(["http:", "https:", "file:"]);
const FILE_SECURITY_ORIGINS = new Set(["null", "file://", "file:///"]);
export interface MediaPermissionPolicyRequest {
permission: string;
isTrustedCaptureWindow: boolean;
isMainFrame: boolean;
currentDocumentUrl: string;
requestingUrl: string;
securityOrigins: readonly string[];
}
export interface DisplayCapturePolicyRequest {
isTrustedCaptureWindow: boolean;
isMainFrame: boolean;
currentDocumentUrl: string;
securityOrigin: string;
videoRequested: boolean;
}
function parseUrl(value: string): URL | null {
try {
return new URL(value);
} catch {
return null;
}
}
function hasExactCaptureWindowQuery(url: URL): boolean {
const entries = [...url.searchParams.entries()];
return (
entries.length === 1 &&
entries[0]?.[0] === "windowType" &&
entries[0][1] === CAPTURE_WINDOW_TYPE
);
}
function isValidTrustedBaseUrl(url: URL): boolean {
return (
TRUSTED_RENDERER_PROTOCOLS.has(url.protocol) &&
url.username === "" &&
url.password === "" &&
url.search === "" &&
url.hash === ""
);
}
function hasSameBaseLocation(candidate: URL, trustedBase: URL): boolean {
return (
candidate.protocol === trustedBase.protocol &&
candidate.hostname === trustedBase.hostname &&
candidate.port === trustedBase.port &&
candidate.pathname === trustedBase.pathname
);
}
export function isTrustedCaptureDocumentUrl(
candidateUrl: string,
trustedDocumentBaseUrls: readonly string[],
): boolean {
const candidate = parseUrl(candidateUrl);
if (
!candidate ||
!TRUSTED_RENDERER_PROTOCOLS.has(candidate.protocol) ||
candidate.username !== "" ||
candidate.password !== "" ||
!hasExactCaptureWindowQuery(candidate)
) {
return false;
}
return trustedDocumentBaseUrls.some((trustedBaseUrl) => {
const trustedBase = parseUrl(trustedBaseUrl);
return Boolean(
trustedBase &&
isValidTrustedBaseUrl(trustedBase) &&
hasSameBaseLocation(candidate, trustedBase),
);
});
}
function isSameDocument(firstUrl: string, secondUrl: string): boolean {
const first = parseUrl(firstUrl);
const second = parseUrl(secondUrl);
if (!first || !second) {
return false;
}
first.hash = "";
second.hash = "";
return first.href === second.href;
}
function isSecurityOriginForDocument(securityOrigin: string, documentUrl: string): boolean {
const document = parseUrl(documentUrl);
if (!document) {
return false;
}
if (document.protocol === "file:") {
return FILE_SECURITY_ORIGINS.has(securityOrigin);
}
const origin = parseUrl(securityOrigin);
return Boolean(
origin &&
(origin.protocol === "http:" || origin.protocol === "https:") &&
origin.username === "" &&
origin.password === "" &&
origin.origin === document.origin &&
origin.pathname === "/" &&
origin.search === "" &&
origin.hash === "",
);
}
export function shouldGrantMediaPermission(
request: MediaPermissionPolicyRequest,
trustedDocumentBaseUrls: readonly string[],
): boolean {
if (
request.permission !== "media" ||
!request.isTrustedCaptureWindow ||
!request.isMainFrame ||
!isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls)
) {
return false;
}
if (
!isTrustedCaptureDocumentUrl(request.requestingUrl, trustedDocumentBaseUrls) ||
!isSameDocument(request.currentDocumentUrl, request.requestingUrl)
) {
return false;
}
return (
request.securityOrigins.length > 0 &&
request.securityOrigins.every((securityOrigin) =>
isSecurityOriginForDocument(securityOrigin, request.currentDocumentUrl),
)
);
}
export function shouldGrantDisplayCapture(
request: DisplayCapturePolicyRequest,
trustedDocumentBaseUrls: readonly string[],
): boolean {
return (
request.isTrustedCaptureWindow &&
request.isMainFrame &&
request.videoRequested &&
isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls) &&
isSecurityOriginForDocument(request.securityOrigin, request.currentDocumentUrl)
);
}
+879 -3089
View File
File diff suppressed because it is too large Load Diff
+5 -11
View File
@@ -18,9 +18,10 @@
"dev": "vite --config vite.config.ts",
"postinstall": "node scripts/postinstall.mjs",
"build": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"format": "biome format --write .",
"format:check": "biome format .",
"preview": "vite preview --config vite.config.ts",
"rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi",
"build:native-helpers": "node scripts/build-native-helpers.mjs",
@@ -55,8 +56,6 @@
"@biomejs/biome": "2.3.13",
"@electron/rebuild": "^4.0.3",
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
"@pixi/filter-motion-blur": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -72,7 +71,6 @@
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
"@vitejs/plugin-react": "^4.2.1",
@@ -80,14 +78,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"electron": "^39.2.7",
"electron": "^39.8.10",
"electron-builder": "^26.7.0",
"electron-icon-builder": "^2.0.1",
"emoji-picker-react": "^4.16.1",
"fast-check": "^4.5.2",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
@@ -105,11 +100,10 @@
"tailwindcss-animate": "^1.0.7",
"terser": "^5.44.1",
"typescript": "^5.2.2",
"uuid": "^13.0.0",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.0.16",
"vitest": "^4.1.10",
"web-demuxer": "^4.0.0"
},
"main": "dist-electron/main.cjs"
+1 -1
View File
@@ -80,7 +80,7 @@ export const SourceSelectorContent = ({
windowSources = [],
selectedSource = "Screen",
loading = false,
onSourceSelect = () => {},
onSourceSelect = () => undefined,
}: Pick<SourceSelectorProps, "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect">) => {
const t = useScopedT("launch");
const renderSourceItem = (source: DesktopSource, index: number) => {
@@ -1,8 +1,8 @@
import { createContext, useContext } from "react";
import { createContext, type MouseEvent, useContext } from "react";
interface HudInteractionContextType {
onMouseEnter: () => void;
onMouseLeave: (event: any) => void;
onMouseLeave: (event: MouseEvent<HTMLDivElement>) => void;
}
export const HudInteractionContext = createContext<HudInteractionContextType | null>(null);
+69 -42
View File
@@ -87,7 +87,12 @@ import {
} from "@/utils/aspectRatioUtils";
import { planClipSpeedChange } from "./clipSpeedChange";
import { ExtensionIcon } from "./ExtensionIcon";
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
import {
calculateMp4ExportDimensions,
calculateMp4SourceDimensions,
type Mp4SupportProbeSnapshot,
shouldDebounceMp4SupportProbe,
} from "./exportDimensions";
import { resolveSavingExportProgress } from "./exportProgressState";
import { resolveExportStartSettings } from "./exportStartSettings";
import { resolveExportStatusModel } from "./exportStatusModel";
@@ -256,6 +261,7 @@ type CancelableExporter = {
};
const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
const MP4_CROP_PROBE_DEBOUNCE_MS = 200;
async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise<string | null> {
if (
@@ -701,6 +707,7 @@ export default function VideoEditor() {
const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0);
const cropSnapshotRef = useRef<CropRegion | null>(null);
const mp4SupportRequestRef = useRef(0);
const previousMp4SupportProbeRef = useRef<Mp4SupportProbeSnapshot | null>(null);
const smokeExportStartedRef = useRef(false);
const projectAutosaveTimeoutRef = useRef<number | null>(null);
const pendingProjectSaveDialogRef = useRef<PendingProjectSaveDialog | null>(null);
@@ -1485,15 +1492,22 @@ export default function VideoEditor() {
[gifSizePreset],
);
const desiredMp4SourceDimensions = useMemo(
() =>
calculateMp4SourceDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
aspectRatio,
),
[aspectRatio],
);
const mp4SourceDimensions = useMemo(() => {
const sourceVideo = isPreviewReady ? videoPlaybackRef.current?.video : null;
return {
width: sourceVideo?.videoWidth || 1920,
height: sourceVideo?.videoHeight || 1080,
};
}, [isPreviewReady]);
const desiredMp4SourceDimensions = useMemo(() => {
return calculateMp4SourceDimensions(
mp4SourceDimensions.width,
mp4SourceDimensions.height,
aspectRatio,
cropRegion,
);
}, [aspectRatio, cropRegion, mp4SourceDimensions.height, mp4SourceDimensions.width]);
const mp4OutputDimensions = useMemo(() => {
const baseWidth = supportedMp4SourceDimensions.encoderPath
@@ -1533,21 +1547,6 @@ export default function VideoEditor() {
);
}
setSupportedMp4SourceDimensions((current) => {
if (
current.width === result.width &&
current.height === result.height &&
current.capped === result.capped &&
current.encoderPath?.codec === result.encoderPath?.codec &&
current.encoderPath?.hardwareAcceleration ===
result.encoderPath?.hardwareAcceleration
) {
return current;
}
return result;
});
return result;
},
[desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width],
@@ -1557,6 +1556,19 @@ export default function VideoEditor() {
let cancelled = false;
const requestId = mp4SupportRequestRef.current + 1;
mp4SupportRequestRef.current = requestId;
const probeSnapshot: Mp4SupportProbeSnapshot = {
sourceWidth: mp4SourceDimensions.width,
sourceHeight: mp4SourceDimensions.height,
targetWidth: desiredMp4SourceDimensions.width,
targetHeight: desiredMp4SourceDimensions.height,
aspectRatio,
frameRate: mp4FrameRate,
};
const shouldDebounce = shouldDebounceMp4SupportProbe(
previousMp4SupportProbeRef.current,
probeSnapshot,
);
previousMp4SupportProbeRef.current = probeSnapshot;
setSupportedMp4SourceDimensions({
width: desiredMp4SourceDimensions.width,
height: desiredMp4SourceDimensions.height,
@@ -1564,33 +1576,48 @@ export default function VideoEditor() {
encoderPath: null,
});
void ensureSupportedMp4SourceDimensions(mp4FrameRate)
.then((result) => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions(result);
})
.catch(() => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions({
width: desiredMp4SourceDimensions.width,
height: desiredMp4SourceDimensions.height,
capped: false,
encoderPath: null,
const runProbe = () => {
void ensureSupportedMp4SourceDimensions(mp4FrameRate)
.then((result) => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions(result);
})
.catch(() => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions({
width: desiredMp4SourceDimensions.width,
height: desiredMp4SourceDimensions.height,
capped: false,
encoderPath: null,
});
});
});
};
const timeoutId = shouldDebounce
? window.setTimeout(runProbe, MP4_CROP_PROBE_DEBOUNCE_MS)
: null;
if (timeoutId === null) {
runProbe();
}
return () => {
cancelled = true;
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
};
}, [
aspectRatio,
desiredMp4SourceDimensions.height,
desiredMp4SourceDimensions.width,
ensureSupportedMp4SourceDimensions,
mp4FrameRate,
mp4SourceDimensions.height,
mp4SourceDimensions.width,
]);
// Extension-contributed standalone section pages (no parentSection)
+12 -39
View File
@@ -17,6 +17,10 @@ import {
enablePitchPreservingPlayback,
getMediaSyncPlaybackRate,
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import {
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
@@ -169,7 +173,6 @@ import {
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import {
layoutVideoContent as layoutVideoContentUtil,
scalePreviewBorderRadius,
} from "./videoPlayback/layoutUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
@@ -260,30 +263,6 @@ function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): st
return `No supported Pixi preview renderer was available. Attempted: ${details}`;
}
type PixiInitOptions = Parameters<Application["init"]>[0];
async function initApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
backend: PixiPreviewBackend,
): Promise<void> {
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(timeoutErrorMessage));
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
});
try {
await Promise.race([app.init(options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
function getCursorPositionAtTime(
telemetry: CursorTelemetryPoint[],
timeMs: number,
@@ -681,7 +660,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const initStarted =
typeof performance === "undefined" ? Date.now() : performance.now();
try {
await initApplicationWithTimeout(
await initializePixiApplicationWithTimeout(
rendererApp,
{
width: container.clientWidth,
@@ -695,6 +674,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
autoStart: true,
sharedTicker: false,
},
PIXI_RENDERER_INIT_TIMEOUT_MS,
backend,
);
const elapsed = Math.round(
@@ -723,7 +703,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
`[VideoPlayback] Failed to init ${backend} renderer (${statusMessage}) after ${elapsed}ms; trying fallback.`,
error,
);
rendererApp.destroy(true);
destroyPixiApplication(
rendererApp,
`${backend} preview renderer initialization`,
);
}
}
@@ -2127,11 +2110,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
destroyPixiApplication(app, "unmounted preview renderer");
return;
}
@@ -2227,13 +2206,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionBlurFilterRef.current?.destroy();
zoomBlurFilterRef.current = null;
motionBlurFilterRef.current = null;
if (app && app.renderer) {
app.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
}
destroyPixiApplication(app, "preview renderer");
appRef.current = null;
cameraContainerRef.current = null;
videoEffectsContainerRef.current = null;
@@ -6,7 +6,7 @@ type WaveformWorkerRequest = {
interface WorkerContext {
onmessage: (e: MessageEvent<WaveformWorkerRequest>) => void;
postMessage: (message: any, transfer?: Transferable[]) => void;
postMessage: (message: unknown, transfer?: Transferable[]) => void;
}
const workerScope = self as unknown as WorkerContext;
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
import {
calculateMp4ExportDimensions,
calculateMp4SourceDimensions,
shouldDebounceMp4SupportProbe,
} from "./exportDimensions";
describe("calculateMp4SourceDimensions", () => {
it("keeps native exports at the source dimensions", () => {
@@ -9,6 +13,18 @@ describe("calculateMp4SourceDimensions", () => {
});
});
it("uses the cropped source bounds for native exports", () => {
expect(
calculateMp4SourceDimensions(320, 180, "native", {
width: 1,
height: 0.8,
}),
).toEqual({
width: 320,
height: 144,
});
});
it("uses the rotated source bounds for 9:16 original exports", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "9:16")).toEqual({
width: 1080,
@@ -16,6 +32,18 @@ describe("calculateMp4SourceDimensions", () => {
});
});
it("ignores crop bounds for fixed-aspect exports", () => {
expect(
calculateMp4SourceDimensions(1920, 1080, "9:16", {
width: 0.5,
height: 0.5,
}),
).toEqual({
width: 1080,
height: 1920,
});
});
it("uses the rotated source bounds for portrait social ratios", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "4:5")).toEqual({
width: 1080,
@@ -70,3 +98,48 @@ describe("calculateMp4ExportDimensions", () => {
});
});
});
describe("shouldDebounceMp4SupportProbe", () => {
const baseSnapshot = {
sourceWidth: 1920,
sourceHeight: 1080,
targetWidth: 1920,
targetHeight: 1080,
aspectRatio: "native" as const,
frameRate: 30 as const,
};
it("debounces only native crop-driven target changes", () => {
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
targetHeight: 864,
}),
).toBe(true);
});
it("keeps non-crop probe changes immediate", () => {
expect(shouldDebounceMp4SupportProbe(null, baseSnapshot)).toBe(false);
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
frameRate: 60,
}),
).toBe(false);
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
sourceWidth: 1280,
sourceHeight: 720,
targetWidth: 1280,
targetHeight: 720,
}),
).toBe(false);
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
aspectRatio: "16:9",
}),
).toBe(false);
});
});
@@ -1,6 +1,36 @@
import type { ExportQuality } from "@/lib/exporter";
import type { ExportMp4FrameRate, ExportQuality } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
export type Mp4SupportProbeSnapshot = {
sourceWidth: number;
sourceHeight: number;
targetWidth: number;
targetHeight: number;
aspectRatio: AspectRatio;
frameRate: ExportMp4FrameRate;
};
export function shouldDebounceMp4SupportProbe(
previous: Mp4SupportProbeSnapshot | null,
current: Mp4SupportProbeSnapshot,
): boolean {
if (
!previous ||
current.aspectRatio !== "native" ||
previous.aspectRatio !== current.aspectRatio ||
previous.frameRate !== current.frameRate ||
previous.sourceWidth !== current.sourceWidth ||
previous.sourceHeight !== current.sourceHeight
) {
return false;
}
return (
previous.targetWidth !== current.targetWidth ||
previous.targetHeight !== current.targetHeight
);
}
function normalizeEvenDimension(value: number): number {
return Math.max(2, Math.floor(value / 2) * 2);
}
@@ -30,9 +60,15 @@ export function calculateMp4SourceDimensions(
sourceWidth: number,
sourceHeight: number,
aspectRatio: AspectRatio,
cropRegion?: { width: number; height: number },
): { width: number; height: number } {
const safeSourceWidth = normalizeEvenDimension(sourceWidth);
const safeSourceHeight = normalizeEvenDimension(sourceHeight);
const useCroppedBounds = aspectRatio === "native";
const safeSourceWidth = normalizeEvenDimension(
sourceWidth * (useCroppedBounds ? (cropRegion?.width ?? 1) : 1),
);
const safeSourceHeight = normalizeEvenDimension(
sourceHeight * (useCroppedBounds ? (cropRegion?.height ?? 1) : 1),
);
const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9;
const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio);
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import type { TimelineRegion } from "../core/timelineTypes";
interface UseTimelineSelectionParams {
@@ -56,7 +55,10 @@ export function useTimelineSelection({
if (totalMs === 0) return;
const time = Math.max(0, Math.min(currentTimeMs, totalMs));
if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return;
setKeyframes((prev) => [...prev, { id: uuidv4(), time }]);
setKeyframes((prev) => [
...prev,
{ id: globalThis.crypto.randomUUID(), time },
]);
}, [currentTimeMs, totalMs, keyframes]);
const deleteSelectedKeyframe = useCallback(() => {
+3 -6
View File
@@ -1,10 +1,7 @@
import { WebDemuxer } from "web-demuxer";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource";
import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder";
import {
createReadableMediaResourceFile,
resolveMediaResourceUrl,
} from "./localMediaSource";
const DEFAULT_MAX_DECODE_QUEUE = 12;
const DEFAULT_MAX_PENDING_FRAMES = 32;
@@ -59,7 +56,7 @@ export class ForwardFrameSource {
mediaInfo = await loadMediaInfo(resourceUrl);
} catch (error) {
console.warn(
"[ForwardFrameSource] Direct source load failed, retrying with file fallback:",
"[ForwardFrameSource] Direct source load failed, retrying with a fresh media source:",
error,
);
const currentDemuxer = this.demuxer;
@@ -70,7 +67,7 @@ export class ForwardFrameSource {
// Ignore cleanup errors before fallback re-init.
}
}
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
}
const videoStream = mediaInfo.streams.find(
+8 -31
View File
@@ -72,6 +72,10 @@ import {
clampMediaTimeToDuration,
getEffectiveVideoStreamDurationSeconds,
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import { renderAnnotations } from "./annotationRenderer";
import { renderCaptions } from "./captionRenderer";
@@ -173,30 +177,6 @@ function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error ?? "Unknown renderer init error");
}
type PixiInitOptions = Parameters<Application["init"]>[0];
async function initApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
backend: ExportRenderBackend,
): Promise<void> {
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(timeoutErrorMessage));
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
});
try {
await Promise.race([app.init(options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string {
const details = attempts.map((attempt) => `${attempt.backend}: ${attempt.message}`).join(" | ");
return `No supported Pixi export backend was available. Attempted: ${details}`;
@@ -358,12 +338,13 @@ export class FrameRenderer {
const app = new Application();
const initStarted = typeof performance === "undefined" ? Date.now() : performance.now();
try {
await initApplicationWithTimeout(
await initializePixiApplicationWithTimeout(
app,
{
...baseOptions,
preference: backend,
},
PIXI_RENDERER_INIT_TIMEOUT_MS,
backend,
);
const elapsed = Math.round(
@@ -389,7 +370,7 @@ export class FrameRenderer {
`[FrameRenderer] ${backend} renderer unavailable after ${elapsed}ms; trying next backend.`,
error,
);
app.destroy(true);
destroyPixiApplication(app, `${backend} export renderer initialization`);
}
}
@@ -2587,11 +2568,7 @@ export class FrameRenderer {
}
this.backgroundSprite = null;
if (this.app) {
this.app.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
destroyPixiApplication(this.app, "legacy export renderer");
this.app = null;
}
this.zoomBlurFilter?.destroy();
+46 -22
View File
@@ -1,18 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMediaElementSource } from "./localMediaSource";
import { createFallbackDemuxerSource, resolveMediaElementSource } from "./localMediaSource";
const readLocalFile = vi.fn();
const getLocalMediaUrl = vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
describe("resolveMediaElementSource", () => {
beforeEach(() => {
vi.restoreAllMocks();
readLocalFile.mockReset();
getLocalMediaUrl.mockReset();
getLocalMediaUrl.mockImplementation(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
Object.assign(globalThis, {
window: {
electronAPI: {
readLocalFile: vi.fn(),
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
})),
readLocalFile,
getLocalMediaUrl,
},
},
});
@@ -21,20 +30,16 @@ describe("resolveMediaElementSource", () => {
it("resolves file URLs through the local media server for media elements", async () => {
const result = await resolveMediaElementSource("file:///tmp/example.mp4");
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
"/tmp/example.mp4",
);
expect(readLocalFile).not.toHaveBeenCalled();
expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.mp4");
expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.mp4");
});
it("resolves absolute local paths through the local media server without copying them into blobs", async () => {
const result = await resolveMediaElementSource("/tmp/example.wav");
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
"/tmp/example.wav",
);
expect(readLocalFile).not.toHaveBeenCalled();
expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.wav");
expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.wav");
});
@@ -43,20 +48,39 @@ describe("resolveMediaElementSource", () => {
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4",
);
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect((window as any).electronAPI.getLocalMediaUrl).not.toHaveBeenCalled();
expect(result.src).toBe(
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4",
);
expect(readLocalFile).not.toHaveBeenCalled();
expect(getLocalMediaUrl).not.toHaveBeenCalled();
expect(result.src).toBe("http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4");
});
it("leaves remote URLs untouched", async () => {
const readLocalFile = vi.fn();
(window as any).electronAPI.readLocalFile = readLocalFile;
const result = await resolveMediaElementSource("https://example.com/video.mp4");
expect(result.src).toBe("https://example.com/video.mp4");
expect(readLocalFile).not.toHaveBeenCalled();
});
it("keeps local demuxer fallback on the range-streamed media URL", async () => {
const source = await createFallbackDemuxerSource("/tmp/large-recording.mp4");
expect(source).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Flarge-recording.mp4");
expect(readLocalFile).not.toHaveBeenCalled();
});
it("retains the readable File fallback for remote media", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "video/mp4" }),
}));
vi.stubGlobal("fetch", fetchMock);
try {
const source = await createFallbackDemuxerSource("https://example.com/video.mp4");
expect(source).toBeInstanceOf(File);
expect(fetchMock).toHaveBeenCalledWith("https://example.com/video.mp4");
} finally {
vi.unstubAllGlobals();
}
});
});
+14 -21
View File
@@ -116,36 +116,29 @@ export async function resolveMediaResourceUrl(resource: string): Promise<string>
return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath);
}
export async function createReadableMediaResourceFile(resource: string): Promise<File> {
const localFilePath = getLocalFilePath(resource);
const filename = (localFilePath ?? resource).split(/[\\/]/).pop()?.split("?")[0] || "media";
if (localFilePath && typeof window !== "undefined" && window.electronAPI?.readLocalFile) {
const result = await window.electronAPI.readLocalFile(localFilePath);
if (!result.success || !result.data) {
throw new Error(result.error || "Failed to read local media file");
}
const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data);
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
return new File([arrayBuffer], filename, { type: inferMimeType(filename) });
}
async function createReadableMediaResourceFile(resource: string): Promise<File> {
const filename = resource.split(/[\\/]/).pop()?.split("?")[0] || "media";
const resourceUrl = await resolveMediaResourceUrl(resource);
const response = await fetch(resourceUrl);
if (!response.ok) {
throw new Error(
`Failed to load media resource: ${response.status} ${response.statusText}`,
);
throw new Error(`Failed to load media resource: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
return new File([blob], filename, { type: blob.type || inferMimeType(filename) });
}
export async function createFallbackDemuxerSource(resource: string): Promise<string | File> {
// Local media already has a random-access transport: WebDemuxer issues bounded
// byte-range requests against this URL. Converting it to a File would copy the
// complete recording through Electron IPC and make memory use scale with file size.
if (getLocalFilePath(resource)) {
return resolveMediaResourceUrl(resource);
}
return createReadableMediaResourceFile(resource);
}
export async function resolveMediaElementSource(resource: string): Promise<{
src: string;
revoke: () => void;
+54 -1
View File
@@ -6,12 +6,21 @@ const {
destroyForwardFrameSourceMock,
getForwardFrameAtTimeMock,
initializeForwardFrameSourceMock,
pixiApplicationInstancesMock,
pixiInitializationErrorsMock,
resolveMediaElementSourceMock,
} = vi.hoisted(() => ({
cancelForwardFrameSourceMock: vi.fn(),
destroyForwardFrameSourceMock: vi.fn(async () => undefined),
getForwardFrameAtTimeMock: vi.fn(async () => null),
initializeForwardFrameSourceMock: vi.fn(async () => undefined),
pixiApplicationInstancesMock: [] as Array<{
destroy: ReturnType<typeof vi.fn>;
init: ReturnType<typeof vi.fn>;
renderer: { destroy: ReturnType<typeof vi.fn> };
stage: { destroy: ReturnType<typeof vi.fn> };
}>,
pixiInitializationErrorsMock: [] as Array<Error | undefined>,
resolveMediaElementSourceMock: vi.fn(async () => ({
src: "blob:background",
revoke: vi.fn(),
@@ -19,7 +28,21 @@ const {
}));
vi.mock("pixi.js", () => ({
Application: class {},
Application: class {
destroy = vi.fn(() => {
throw new TypeError("this._cancelResize is not a function");
});
init = vi.fn(async () => {
const error = pixiInitializationErrorsMock.shift();
if (error) throw error;
});
renderer = { destroy: vi.fn() };
stage = { destroy: vi.fn() };
constructor() {
pixiApplicationInstancesMock.push(this);
}
},
BlurFilter: class {},
Container: class {
visible = true;
@@ -179,6 +202,36 @@ function createRenderer() {
});
}
describe("ModernFrameRenderer Pixi lifecycle", () => {
it("continues to the next backend when failed-init cleanup would throw", async () => {
pixiApplicationInstancesMock.length = 0;
pixiInitializationErrorsMock.length = 0;
pixiInitializationErrorsMock.push(new Error("WebGPU initialization failed"), undefined);
vi.stubGlobal("navigator", { gpu: {} });
try {
const renderer = createRenderer() as unknown as {
config: { preferredRenderBackend?: "webgl" | "webgpu" };
createPixiApplication: (
canvas: HTMLCanvasElement,
) => Promise<{ backend: "webgl" | "webgpu" }>;
};
renderer.config.preferredRenderBackend = "webgpu";
await expect(renderer.createPixiApplication({} as HTMLCanvasElement)).resolves.toMatchObject({
backend: "webgl",
});
expect(pixiApplicationInstancesMock).toHaveLength(2);
expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled();
expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1);
expect(pixiApplicationInstancesMock[0].renderer.destroy).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
});
describe("ModernFrameRenderer blur export path", () => {
beforeEach(() => {
Object.assign(globalThis, {
+8 -31
View File
@@ -82,6 +82,10 @@ import {
clampMediaTimeToDuration,
getEffectiveVideoStreamDurationSeconds,
} from "@/lib/mediaTiming";
import {
destroyPixiApplication,
initializePixiApplicationWithTimeout,
} from "@/lib/pixiApplicationLifecycle";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import {
type AnnotationRenderAssets,
@@ -284,30 +288,6 @@ function isKnownRendererUnavailableError(error: unknown): boolean {
);
}
type PixiInitOptions = Parameters<Application["init"]>[0];
async function initApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
backend: ExportRenderBackend,
): Promise<void> {
const timeoutErrorMessage = `Initialization timed out after ${PIXI_RENDERER_INIT_TIMEOUT_MS}ms for ${backend} renderer`;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(timeoutErrorMessage));
}, PIXI_RENDERER_INIT_TIMEOUT_MS);
});
try {
await Promise.race([app.init(options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
interface RenderSnapshot {
timeMs: number;
cursorTimeMs: number;
@@ -720,12 +700,13 @@ export class FrameRenderer {
const app = new Application();
const initStarted = typeof performance === "undefined" ? Date.now() : performance.now();
try {
await initApplicationWithTimeout(
await initializePixiApplicationWithTimeout(
app,
{
...baseOptions,
preference: backend,
},
PIXI_RENDERER_INIT_TIMEOUT_MS,
backend,
);
const elapsed = Math.round(
@@ -754,7 +735,7 @@ export class FrameRenderer {
`[FrameRenderer] ${backend} export renderer unavailable (${rendererMessage}) after ${elapsed}ms; trying next backend:`,
error,
);
app.destroy(true);
destroyPixiApplication(app, `${backend} export renderer initialization`);
}
}
@@ -3921,11 +3902,7 @@ export class FrameRenderer {
this.motionBlurFilter?.destroy();
this.backgroundBlurFilter?.destroy();
this.app?.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
destroyPixiApplication(this.app, "Lightning export renderer");
for (const texture of texturesToDestroy) {
try {
@@ -238,7 +238,7 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(mocks.streamingDecoderLoadMetadata).not.toHaveBeenCalled();
}, 15_000);
it("retries the main decode path once with a readable file-backed source", async () => {
it("retries the main decode path once with a fresh media source", async () => {
mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1);
mocks.streamingDecoderDecodeAll
.mockRejectedValueOnce(
@@ -277,13 +277,13 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(mocks.streamingDecoderLoadMetadata.mock.calls[0]).toEqual([
"file:///recording.mp4",
{
forceReadableFileSource: false,
useFallbackMediaSource: false,
},
]);
expect(mocks.streamingDecoderLoadMetadata.mock.calls[1]).toEqual([
"file:///recording.mp4",
{
forceReadableFileSource: true,
useFallbackMediaSource: true,
},
]);
expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2);
+16 -18
View File
@@ -288,7 +288,7 @@ type NativeStaticLayoutZoomSample = {
};
const NATIVE_EXPORT_ENGINE_NAME = "Breeze";
const READABLE_SOURCE_RETRY_ERROR_TOKENS = [
const MEDIA_SOURCE_RETRY_ERROR_TOKENS = [
"readavpacket",
"get_media_info",
"avfoundation",
@@ -371,11 +371,11 @@ export class ModernVideoExporter {
}
async export(): Promise<ExportResult> {
let preferReadableFileSource = false;
let retriedWithReadableFileSource = false;
let useFallbackMediaSource = false;
let retriedWithFallbackMediaSource = false;
while (true) {
let shouldRetryWithReadableFileSource = false;
let shouldRetryWithFallbackMediaSource = false;
try {
this.cleanup();
this.cancelled = false;
@@ -522,7 +522,7 @@ export class ModernVideoExporter {
});
stageStartedAt = this.getNowMs();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl, {
forceReadableFileSource: preferReadableFileSource,
useFallbackMediaSource,
});
this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt;
const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo);
@@ -892,15 +892,15 @@ export class ModernVideoExporter {
};
} catch (error) {
if (
!preferReadableFileSource &&
!retriedWithReadableFileSource &&
this.shouldRetryWithReadableFileSource(error)
!useFallbackMediaSource &&
!retriedWithFallbackMediaSource &&
this.shouldRetryWithFallbackMediaSource(error)
) {
retriedWithReadableFileSource = true;
preferReadableFileSource = true;
shouldRetryWithReadableFileSource = true;
retriedWithFallbackMediaSource = true;
useFallbackMediaSource = true;
shouldRetryWithFallbackMediaSource = true;
console.warn(
"[VideoExporter] Primary decode path failed; retrying export once with a readable file-backed media source.",
"[VideoExporter] Primary decode path failed; retrying export once with a fresh media source.",
error,
);
} else {
@@ -921,7 +921,7 @@ export class ModernVideoExporter {
};
}
} finally {
if (!shouldRetryWithReadableFileSource && this.totalExportStartTimeMs > 0) {
if (!shouldRetryWithFallbackMediaSource && this.totalExportStartTimeMs > 0) {
console.log(
`[VideoExporter] Final metrics ${JSON.stringify(this.buildExportMetrics())}`,
);
@@ -929,20 +929,18 @@ export class ModernVideoExporter {
this.cleanup();
}
if (shouldRetryWithReadableFileSource) {
if (shouldRetryWithFallbackMediaSource) {
continue;
}
}
}
private shouldRetryWithReadableFileSource(error: unknown): boolean {
private shouldRetryWithFallbackMediaSource(error: unknown): boolean {
const resolvedError = this.encoderError ?? error;
const message =
resolvedError instanceof Error ? resolvedError.message : String(resolvedError);
const normalizedMessage = message.toLowerCase();
return READABLE_SOURCE_RETRY_ERROR_TOKENS.some((token) =>
normalizedMessage.includes(token),
);
return MEDIA_SOURCE_RETRY_ERROR_TOKENS.some((token) => normalizedMessage.includes(token));
}
private getPlatformLabel(): string {
+31 -18
View File
@@ -40,6 +40,12 @@ vi.mock("web-demuxer", () => ({
},
}));
const mockReadLocalFile = vi.fn();
const mockGetLocalMediaUrl = vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
describe("StreamingVideoDecoder local media loading", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -47,17 +53,20 @@ describe("StreamingVideoDecoder local media loading", () => {
mockDemuxerGetMediaInfo.mockClear();
mockDemuxerDestroy.mockClear();
mockDemuxerGetDecoderConfig.mockClear();
mockReadLocalFile.mockReset();
mockGetLocalMediaUrl.mockReset();
mockGetLocalMediaUrl.mockImplementation(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
Object.assign(globalThis, {
window: {
location: {
href: "http://localhost:5173/",
},
electronAPI: {
readLocalFile: vi.fn(),
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
})),
readLocalFile: mockReadLocalFile,
getLocalMediaUrl: mockGetLocalMediaUrl,
},
},
});
@@ -67,17 +76,17 @@ describe("StreamingVideoDecoder local media loading", () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4");
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled();
expect(mockDemuxerLoad).toHaveBeenCalledWith(
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4",
);
});
it("normalizes absolute local paths to file URLs before loading them", async () => {
it("resolves absolute local paths to range-streamed media URLs", async () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/capture.mp4");
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
expect(window.electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
"/tmp/capture.mp4",
);
expect(mockDemuxerLoad).toHaveBeenCalledWith(
@@ -85,27 +94,31 @@ describe("StreamingVideoDecoder local media loading", () => {
);
});
it("falls back to a readable File when direct loading fails", async () => {
it("retries the range-streamed URL when direct local loading fails", async () => {
mockDemuxerLoad.mockReset();
mockDemuxerLoad
.mockRejectedValueOnce(new Error("get_media_info failed: Failed after 3 attempts"))
.mockResolvedValueOnce(undefined);
(window as any).electronAPI.readLocalFile = vi.fn(async () => ({
success: true,
data: new Uint8Array([1, 2, 3]),
}));
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/fallback.mp4");
expect(mockDemuxerLoad).toHaveBeenNthCalledWith(
1,
2,
"http://127.0.0.1:4321/video?path=%2Ftmp%2Ffallback.mp4",
);
expect(mockDemuxerLoad.mock.calls[1]?.[0]).toBeInstanceOf(File);
expect((window as any).electronAPI.readLocalFile).toHaveBeenCalledWith(
"/tmp/fallback.mp4",
expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled();
});
it("keeps an explicit local retry on the range-streamed URL", async () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/retry.mp4", {
useFallbackMediaSource: true,
});
expect(mockDemuxerLoad).toHaveBeenCalledWith(
"http://127.0.0.1:4321/video?path=%2Ftmp%2Fretry.mp4",
);
expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled();
});
});
+6 -6
View File
@@ -1,7 +1,7 @@
import { WebDemuxer } from "web-demuxer";
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource";
import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource";
const DEFAULT_MAX_DECODE_QUEUE = 12;
const DEFAULT_MAX_PENDING_FRAMES = 32;
@@ -24,7 +24,7 @@ export interface DecodedVideoInfo {
}
interface StreamingVideoDecoderLoadOptions {
forceReadableFileSource?: boolean;
useFallbackMediaSource?: boolean;
}
/** Decoder retains ownership of the VideoFrame and closes it after use. */
@@ -126,14 +126,14 @@ export class StreamingVideoDecoder {
};
let mediaInfo;
if (options.forceReadableFileSource) {
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
if (options.useFallbackMediaSource) {
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
} else {
try {
mediaInfo = await loadMediaInfo(resourceUrl);
} catch (error) {
console.warn(
"[StreamingVideoDecoder] Direct source load failed, retrying with file fallback:",
"[StreamingVideoDecoder] Direct source load failed, retrying with a fresh media source:",
error,
);
const currentDemuxer = this.demuxer;
@@ -144,7 +144,7 @@ export class StreamingVideoDecoder {
// Ignore cleanup errors before fallback re-init.
}
}
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { Application } from "pixi.js";
import { describe, expect, it, vi } from "vitest";
import {
destroyPixiApplication,
initializePixiApplication,
initializePixiApplicationWithTimeout,
} from "./pixiApplicationLifecycle";
function createApplication(init: () => Promise<void> = async () => undefined) {
return {
init: vi.fn(init),
destroy: vi.fn(),
stage: { destroy: vi.fn() },
renderer: { destroy: vi.fn() },
} as unknown as Application;
}
describe("Pixi application lifecycle", () => {
it("cleans a failed initialization without running uninitialized plugins", async () => {
const initializationError = new Error("No available renderer");
const app = createApplication(async () => {
throw initializationError;
});
const applicationDestroy = vi.mocked(app.destroy);
applicationDestroy.mockImplementation(() => {
throw new TypeError("this._cancelResize is not a function");
});
await expect(initializePixiApplication(app, {})).rejects.toBe(initializationError);
expect(() => destroyPixiApplication(app, "test renderer init")).not.toThrow();
expect(applicationDestroy).not.toHaveBeenCalled();
expect(app.stage.destroy).toHaveBeenCalledWith({
children: true,
texture: false,
textureSource: false,
});
expect(app.renderer.destroy).toHaveBeenCalledWith({
removeView: true,
releaseGlobalResources: false,
});
});
it("destroys a successfully initialized application at most once", async () => {
const app = createApplication();
await initializePixiApplication(app, {});
destroyPixiApplication(app, "test renderer");
destroyPixiApplication(app, "test renderer");
expect(app.destroy).toHaveBeenCalledTimes(1);
expect(app.destroy).toHaveBeenCalledWith(
{ removeView: true, releaseGlobalResources: false },
{ children: true, texture: false, textureSource: false },
);
});
it("defers teardown until an in-flight initialization settles", async () => {
let finishInitialization: (() => void) | undefined;
const app = createApplication(
() =>
new Promise<void>((resolve) => {
finishInitialization = resolve;
}),
);
const initialization = initializePixiApplication(app, {});
destroyPixiApplication(app, "timed-out renderer init");
expect(app.destroy).not.toHaveBeenCalled();
finishInitialization?.();
await initialization;
expect(app.destroy).toHaveBeenCalledTimes(1);
});
it("reports cleanup errors without throwing or retrying unsafe teardown", async () => {
const app = createApplication();
const cleanupError = new Error("renderer cleanup failed");
vi.mocked(app.destroy).mockImplementation(() => {
throw cleanupError;
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await initializePixiApplication(app, {});
expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow();
expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow();
expect(app.destroy).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
"[PixiApplication] Failed to clean up test renderer:",
cleanupError,
);
warn.mockRestore();
});
it("reports the backend when initialization times out", async () => {
vi.useFakeTimers();
try {
const app = createApplication(() => new Promise<void>(() => undefined));
const initialization = initializePixiApplicationWithTimeout(app, {}, 250, "webgpu");
const rejection = initialization.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(250);
await expect(rejection).resolves.toEqual(
new Error("Initialization timed out after 250ms for webgpu renderer"),
);
} finally {
vi.useRealTimers();
}
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { Application } from "pixi.js";
type PixiInitializationState = "initializing" | "initialized" | "failed";
type PixiInitOptions = Parameters<Application["init"]>[0];
const initializationStates = new WeakMap<Application, PixiInitializationState>();
const destroyRequests = new WeakSet<Application>();
const destroyContexts = new WeakMap<Application, string>();
const completedCleanups = new WeakSet<Application>();
const RENDERER_DESTROY_OPTIONS = {
removeView: true,
releaseGlobalResources: false,
} as const;
const STAGE_DESTROY_OPTIONS = {
children: true,
texture: false,
textureSource: false,
} as const;
function reportCleanupError(app: Application, error: unknown): void {
const context = destroyContexts.get(app) ?? "Pixi application";
console.warn(`[PixiApplication] Failed to clean up ${context}:`, error);
}
function destroyFailedApplication(app: Application): void {
const partialApp = app as Partial<Application>;
try {
partialApp.stage?.destroy(STAGE_DESTROY_OPTIONS);
} catch (error) {
reportCleanupError(app, error);
}
try {
partialApp.renderer?.destroy(RENDERER_DESTROY_OPTIONS);
} catch (error) {
reportCleanupError(app, error);
}
}
function completeDestroy(app: Application): void {
if (completedCleanups.has(app)) return;
completedCleanups.add(app);
if (initializationStates.get(app) !== "initialized") {
destroyFailedApplication(app);
return;
}
try {
app.destroy(RENDERER_DESTROY_OPTIONS, STAGE_DESTROY_OPTIONS);
} catch (error) {
reportCleanupError(app, error);
}
}
export async function initializePixiApplication(
app: Application,
options: PixiInitOptions,
): Promise<void> {
if (initializationStates.has(app) || destroyRequests.has(app)) {
throw new Error("Pixi application lifecycle has already started");
}
initializationStates.set(app, "initializing");
try {
await app.init(options);
initializationStates.set(app, "initialized");
} catch (error) {
initializationStates.set(app, "failed");
if (destroyRequests.has(app)) completeDestroy(app);
throw error;
}
if (destroyRequests.has(app)) completeDestroy(app);
}
export async function initializePixiApplicationWithTimeout(
app: Application,
options: PixiInitOptions,
timeoutMs: number,
backendLabel: string,
): Promise<void> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(`Initialization timed out after ${timeoutMs}ms for ${backendLabel} renderer`),
);
}, timeoutMs);
});
try {
await Promise.race([initializePixiApplication(app, options), timeoutPromise]);
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
}
export function destroyPixiApplication(app: Application | null, context: string): void {
if (!app || destroyRequests.has(app) || completedCleanups.has(app)) return;
destroyRequests.add(app);
destroyContexts.set(app, context);
if (initializationStates.get(app) !== "initializing") completeDestroy(app);
}