mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
Fix Recordly 2.0 part 8 follow-up review findings
Preserve existing PR history and integrate validated second-cycle fixes.
This commit is contained in:
commit
f6df69068f
18 files changed
+463
-32
No files matched your search
@@ -100,8 +100,13 @@ export function createAuthCallbackController({
|
||||
if (present.has(flow.verifierKey)) continue;
|
||||
// A peer SDK may have explicitly evicted this verifier. Index
|
||||
// absence alone never proves inactivity; exact slot absence does.
|
||||
await relay.release(id, flow.owner);
|
||||
if (flowOwners.get(id) === flow) flowOwners.delete(id);
|
||||
try {
|
||||
await relay.release(id, flow.owner);
|
||||
if (flowOwners.get(id) === flow) flowOwners.delete(id);
|
||||
} catch {
|
||||
// Keep failed housekeeping for retry; registration below
|
||||
// still enforces ownership, storage access and capacity.
|
||||
}
|
||||
}
|
||||
}
|
||||
const owner = event.sender;
|
||||
|
||||
@@ -21,6 +21,9 @@ const state = vi.hoisted(() => ({
|
||||
flows: new Map<string, { owner: Owner; key: string }>(),
|
||||
remote: new Map<string, Promise<"forwarded" | "unavailable"> | "forwarded" | "unavailable">(),
|
||||
routed: vi.fn(),
|
||||
releaseError: undefined as Error | undefined,
|
||||
registerError: undefined as Error | undefined,
|
||||
releaseAttempts: [] as string[],
|
||||
getVerifier: vi.fn(async (_key: string): Promise<string | null> => '"verifier"'),
|
||||
windows: [] as Owner[],
|
||||
}));
|
||||
@@ -55,12 +58,15 @@ vi.mock("./authFlowRelay", () => ({
|
||||
state.options = options;
|
||||
return {
|
||||
register: vi.fn(async (flowId: string, key: string, owner: Owner) => {
|
||||
if (state.registerError) throw state.registerError;
|
||||
state.flows.set(flowId, { owner, key });
|
||||
return async () => {
|
||||
state.flows.delete(flowId);
|
||||
};
|
||||
}),
|
||||
release: vi.fn(async (flowId: string) => {
|
||||
state.releaseAttempts.push(flowId);
|
||||
if (state.releaseError) throw state.releaseError;
|
||||
state.flows.delete(flowId);
|
||||
}),
|
||||
close: vi.fn(async () => undefined),
|
||||
@@ -109,6 +115,9 @@ beforeEach(() => {
|
||||
state.remote.clear();
|
||||
state.routed.mockClear();
|
||||
state.flows.clear();
|
||||
state.releaseError = undefined;
|
||||
state.registerError = undefined;
|
||||
state.releaseAttempts = [];
|
||||
state.windows = [];
|
||||
state.getVerifier.mockReset().mockResolvedValue('"verifier"');
|
||||
});
|
||||
@@ -450,3 +459,65 @@ it("does not release an owned flow when another project removes the same textual
|
||||
expect(await controller.route(url)).toBe("local");
|
||||
controller.close();
|
||||
});
|
||||
|
||||
it.each(["AuthStorageBusyError", "EPERM"])(
|
||||
"retries failed stale-route cleanup without blocking a new verifier (%s)",
|
||||
async (failure) => {
|
||||
const { createAuthCallbackController } = await import("./authCallback");
|
||||
const controller = createAuthCallbackController({
|
||||
isDev: false,
|
||||
focusApp: vi.fn(),
|
||||
flowRoutingEnabled: true,
|
||||
});
|
||||
const alice = owner(1),
|
||||
bob = owner(2);
|
||||
state.windows = [alice, bob];
|
||||
const bobKey = "sb-recordlytest-auth-token-flow-flow_bob00-code-verifier";
|
||||
const nextKey = "sb-recordlytest-auth-token-flow-flow_next0-code-verifier";
|
||||
await controller.flowStorageHooks!.prepareSet(event(alice), key, []);
|
||||
state.getVerifier.mockImplementation(async (candidate) =>
|
||||
candidate === key ? null : '"verifier"',
|
||||
);
|
||||
state.releaseError = Object.assign(new Error(failure), { code: failure });
|
||||
await expect(
|
||||
controller.flowStorageHooks!.prepareSet(event(bob), bobKey, []),
|
||||
).resolves.toEqual(expect.any(Function));
|
||||
expect(state.flows.has("flow_alice")).toBe(true);
|
||||
expect(await controller.route(url)).toBe("unavailable");
|
||||
expect(alice.send).not.toHaveBeenCalledWith("auth:callback", url);
|
||||
const bobUrl = url.replace("flow_alice", "flow_bob00");
|
||||
expect(await controller.route(bobUrl)).toBe("local");
|
||||
expect(bob.send).toHaveBeenCalledWith("auth:callback", bobUrl);
|
||||
state.releaseError = undefined;
|
||||
await controller.flowStorageHooks!.prepareSet(event(bob), nextKey, [bobKey]);
|
||||
expect(state.releaseAttempts).toEqual(["flow_alice", "flow_alice"]);
|
||||
expect(state.flows.has("flow_alice")).toBe(false);
|
||||
expect(state.flows.has("flow_bob00")).toBe(true);
|
||||
await controller.flowStorageHooks!.prepareSet(event(bob), nextKey, [bobKey, nextKey]);
|
||||
expect(state.releaseAttempts).toEqual(["flow_alice", "flow_alice"]);
|
||||
controller.close();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"Too many pending sign-in flows.",
|
||||
"This sign-in flow already belongs to another window.",
|
||||
])("preserves strict registration rejection after cleanup failure: %s", async (message) => {
|
||||
const { createAuthCallbackController } = await import("./authCallback");
|
||||
const controller = createAuthCallbackController({
|
||||
isDev: false,
|
||||
focusApp: vi.fn(),
|
||||
flowRoutingEnabled: true,
|
||||
});
|
||||
const alice = owner(1);
|
||||
await controller.flowStorageHooks!.prepareSet(event(alice), key, []);
|
||||
state.releaseError = new Error("Cleanup unavailable");
|
||||
state.registerError = new Error(message);
|
||||
const nextKey = "sb-recordlytest-auth-token-flow-flow_next0-code-verifier";
|
||||
await expect(
|
||||
controller.flowStorageHooks!.prepareSet(event(alice), nextKey, []),
|
||||
).rejects.toBe(state.registerError);
|
||||
expect(state.flows.has("flow_alice")).toBe(true);
|
||||
expect(state.flows.has("flow_next0")).toBe(false);
|
||||
controller.close();
|
||||
});
|
||||
@@ -1,13 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { getProjectBackupPaths } from "./atomicSave";
|
||||
import { canonicalMediaSources } from "./canonicalMediaSources";
|
||||
import {
|
||||
prepareLibraryDeletion,
|
||||
removableLibrarySources,
|
||||
protectedLibrarySources,
|
||||
type LibraryDeletionDependencies,
|
||||
} from "./libraryDeletion";
|
||||
|
||||
vi.mock("../utils", () => ({ normalizeVideoSourcePath: (value: string) => value }));
|
||||
|
||||
function productionDependencies(projects: string[], recordings: string[]) {
|
||||
const declaration = (relativeFile: string, name: string) => {
|
||||
const file = ts.createSourceFile(
|
||||
relativeFile,
|
||||
readFileSync(new URL(relativeFile, import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const fn = file.statements.find(
|
||||
(node) => ts.isFunctionDeclaration(node) && node.name?.text === name,
|
||||
);
|
||||
if (!fn) throw new Error(`Missing production function: ${name}`);
|
||||
return fn.getText(file).replace(/^export /, "");
|
||||
};
|
||||
// Execute the real main-process reader and validator without booting Electron.
|
||||
const source = [
|
||||
declaration("./manager.ts", "isLoadableProjectData"),
|
||||
declaration("../register/project.ts", "libraryDeletionDependencies"),
|
||||
"libraryDeletionDependencies();",
|
||||
].join("\n");
|
||||
return runInNewContext(
|
||||
ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } })
|
||||
.outputText,
|
||||
{
|
||||
fs,
|
||||
listProjectLibraryEntries: async () => ({
|
||||
entries: projects.map((file) => ({ path: file })),
|
||||
}),
|
||||
getProjectBackupPaths,
|
||||
listRecordings: async () => recordings.map((file) => ({ path: file })),
|
||||
currentVideoPath: null,
|
||||
currentRecordingSession: null,
|
||||
normalizeVideoSourcePath: (value: unknown) =>
|
||||
typeof value === "string" ? value : null,
|
||||
parseJsonWithByteOrderMark: JSON.parse,
|
||||
canonicalMediaSources,
|
||||
},
|
||||
) as LibraryDeletionDependencies;
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("listed primary project symlinks", () => {
|
||||
it("keeps canonical media protected while allowing deletion reviews beside a linked recent project", async () => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "recordly-primary-link-")),
|
||||
);
|
||||
try {
|
||||
const media = path.join(root, "media"),
|
||||
alias = path.join(root, "media-alias");
|
||||
await fs.mkdir(media);
|
||||
await fs.symlink(media, alias);
|
||||
const shared = path.join(media, "shared.mp4"),
|
||||
companion = path.join(media, "shared.mic.wav"),
|
||||
unique = path.join(media, "unique.mp4");
|
||||
for (const file of [shared, companion, unique]) await fs.writeFile(file, "media bytes");
|
||||
const target = path.join(root, "target.recordly"),
|
||||
linked = path.join(root, "recent.recordly"),
|
||||
selected = path.join(root, "selected.recordly");
|
||||
const targetContents = JSON.stringify({
|
||||
version: 2,
|
||||
videoPath: path.join(alias, "shared.mp4"),
|
||||
editor: {},
|
||||
});
|
||||
await fs.writeFile(target, targetContents);
|
||||
await fs.symlink(target, linked);
|
||||
await fs.writeFile(
|
||||
selected,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
videoPath: unique,
|
||||
editor: { audioRegions: [{ audioPath: shared }] },
|
||||
}),
|
||||
);
|
||||
const deps = productionDependencies([selected, linked], [shared, companion, unique]);
|
||||
const reads = vi.spyOn(deps, "readSources");
|
||||
expect((await protectedLibrarySources(deps)).has(shared)).toBe(true);
|
||||
const plan = await prepareLibraryDeletion([selected], deps);
|
||||
expect(Object.keys(plan.sourceOwners)).toEqual([unique]);
|
||||
expect(plan.preservedSources).toEqual([shared, companion]);
|
||||
expect((await prepareLibraryDeletion([linked], deps)).preservedSources).toEqual([
|
||||
shared,
|
||||
companion,
|
||||
]);
|
||||
// A regular recovery generation continues protecting its own references.
|
||||
const backup = getProjectBackupPaths(linked)[0];
|
||||
await fs.mkdir(path.dirname(backup), { recursive: true });
|
||||
await fs.writeFile(
|
||||
backup,
|
||||
JSON.stringify({ version: 2, videoPath: unique, editor: {} }),
|
||||
);
|
||||
expect((await prepareLibraryDeletion([selected], deps)).sourceOwners).toEqual({});
|
||||
for (const primary of [selected, linked]) {
|
||||
expect(reads).toHaveBeenCalledWith(primary, "primary");
|
||||
for (const recovery of getProjectBackupPaths(primary)) {
|
||||
expect(reads).toHaveBeenCalledWith(recovery, "recovery");
|
||||
}
|
||||
}
|
||||
expect((await fs.lstat(linked)).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.readFile(target, "utf8")).resolves.toBe(targetContents);
|
||||
for (const file of [shared, companion, unique])
|
||||
await expect(fs.readFile(file, "utf8")).resolves.toBe("media bytes");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, false],
|
||||
[0, true],
|
||||
[1, false],
|
||||
[1, true],
|
||||
[2, false],
|
||||
[2, true],
|
||||
])("still rejects recovery-copy generation %s symlinks (dangling: %s)", async (generation, dangling) => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "recordly-recovery-link-")),
|
||||
);
|
||||
try {
|
||||
const actual = path.join(root, "actual"),
|
||||
alias = path.join(root, "alias");
|
||||
await fs.mkdir(actual);
|
||||
await fs.symlink(actual, alias);
|
||||
const primary = path.join(alias, "primary.recordly"),
|
||||
backupTarget = path.join(root, "other.recordly");
|
||||
const contents = JSON.stringify({
|
||||
version: 2,
|
||||
videoPath: path.join(root, "capture.mp4"),
|
||||
editor: {},
|
||||
});
|
||||
await fs.writeFile(primary, contents);
|
||||
if (!dangling) await fs.writeFile(backupTarget, contents);
|
||||
const backups = getProjectBackupPaths(primary);
|
||||
expect(backups).toHaveLength(3);
|
||||
const backup = backups[generation as number];
|
||||
await fs.mkdir(path.dirname(backup), { recursive: true });
|
||||
await fs.symlink(backupTarget, backup);
|
||||
await expect(
|
||||
protectedLibrarySources(productionDependencies([primary], [])),
|
||||
).rejects.toThrow("not a regular file");
|
||||
expect((await fs.lstat(backup)).isSymbolicLink()).toBe(true);
|
||||
if (!dangling) await expect(fs.readFile(backupTarget, "utf8")).resolves.toBe(contents);
|
||||
await expect(fs.readFile(primary, "utf8")).resolves.toBe(contents);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"missing",
|
||||
"directory",
|
||||
"corrupt",
|
||||
])("fails closed for a %s primary symlink target", async (kind) => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "recordly-invalid-primary-")),
|
||||
);
|
||||
try {
|
||||
const target = path.join(root, "target"),
|
||||
linked = path.join(root, "recent.recordly");
|
||||
if (kind === "directory") await fs.mkdir(target);
|
||||
if (kind === "corrupt") await fs.writeFile(target, "invalid JSON");
|
||||
await fs.symlink(target, linked);
|
||||
await expect(
|
||||
protectedLibrarySources(productionDependencies([linked], [])),
|
||||
).rejects.toThrow();
|
||||
expect((await fs.lstat(linked)).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function fixture(overrides: Partial<LibraryDeletionDependencies> = {}) {
|
||||
const data: Record<string, string[]> = {
|
||||
"/p/a": ["/r/a.mp4", "/external/import.mp4"],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { canonicalSourceReference } from "./canonicalSourceReference";
|
||||
type Sources = { sourcePaths: string[]; sourcePathsComplete: boolean };
|
||||
export type LibraryDeletionDependencies = {
|
||||
list: () => Promise<{ entries: Array<{ path: string }> }>;
|
||||
readSources: (path: string) => Promise<Sources | null>;
|
||||
readSources: (path: string, kind?: "primary" | "recovery") => Promise<Sources | null>;
|
||||
backupPaths: (path: string) => string[];
|
||||
recordings: () => Promise<Array<{ path: string }>>;
|
||||
activeSources?: () => string[] | Promise<string[]>;
|
||||
@@ -44,7 +44,7 @@ export async function protectedLibrarySources(
|
||||
for (const entry of (await deps.list()).entries) {
|
||||
if (excluding.has(path.resolve(entry.path))) continue;
|
||||
for (const file of [entry.path, ...deps.backupPaths(entry.path)]) {
|
||||
const data = await deps.readSources(file);
|
||||
const data = await deps.readSources(file, file === entry.path ? "primary" : "recovery");
|
||||
if (!data && file !== entry.path) continue;
|
||||
if (!data || !data.sourcePathsComplete)
|
||||
throw new Error(
|
||||
@@ -72,7 +72,7 @@ export async function prepareLibraryDeletion(paths: unknown, deps: LibraryDeleti
|
||||
for (const project of selected) {
|
||||
const references = new Set<string>();
|
||||
for (const file of [project, ...deps.backupPaths(project)]) {
|
||||
const data = await deps.readSources(file);
|
||||
const data = await deps.readSources(file, file === project ? "primary" : "recovery");
|
||||
if (!data && file !== project) continue;
|
||||
if (!data || !data.sourcePathsComplete)
|
||||
throw new Error(
|
||||
|
||||
@@ -74,9 +74,11 @@ function libraryDeletionDependencies(protectedSources: unknown = []) {
|
||||
backupPaths: getProjectBackupPaths,
|
||||
recordings: () => listRecordings(true),
|
||||
activeSources: () => [currentVideoPath, currentRecordingSession?.videoPath, currentRecordingSession?.webcamPath, ...additionalSources].map((file) => normalizeVideoSourcePath(file)).filter((file): file is string => Boolean(file)),
|
||||
readSources: async (file: string) => {
|
||||
readSources: async (file: string, kind: "primary" | "recovery" = "recovery") => {
|
||||
try {
|
||||
if (!(await fs.lstat(file)).isFile()) throw new Error("Project reference is not a regular file");
|
||||
// Listed primaries may be leaf symlinks; recovery generations remain strict.
|
||||
const stat = kind === "primary" ? await fs.stat(file) : await fs.lstat(file);
|
||||
if (!stat.isFile()) throw new Error("Project reference is not a regular file");
|
||||
const project = parseJsonWithByteOrderMark(await fs.readFile(file, "utf-8"));
|
||||
if (!isLoadableProjectData(project)) throw new Error("Project reference could not be read");
|
||||
return canonicalMediaSources(project, { preserveMissing: true });
|
||||
|
||||
@@ -64,6 +64,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
} = input;
|
||||
|
||||
const webcamEnabled = webcam?.enabled ?? false;
|
||||
const webcamShape = webcam?.shape ?? "squircle";
|
||||
|
||||
const webcamMargin = webcam?.margin ?? 24;
|
||||
|
||||
@@ -96,7 +97,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
webcamVideoDimensions?.width,
|
||||
webcamVideoDimensions?.height,
|
||||
webcamCropRegion,
|
||||
webcam?.shape,
|
||||
webcamShape,
|
||||
);
|
||||
|
||||
const webcamCropPreviewContentStyle = useMemo<React.CSSProperties>(() => {
|
||||
@@ -109,7 +110,10 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
webcamVideoDimensions.width,
|
||||
webcamVideoDimensions.height,
|
||||
);
|
||||
const targetAspect = Math.max(0.01, webcamWidth) / Math.max(0.01, webcamHeight);
|
||||
const targetAspect =
|
||||
webcamShape === "circle"
|
||||
? 1
|
||||
: Math.max(0.01, webcamWidth) / Math.max(0.01, webcamHeight);
|
||||
const coverScale = Math.max(targetAspect / sw, 1 / sh);
|
||||
const drawWidth = webcamVideoDimensions.width * coverScale;
|
||||
const drawHeight = webcamVideoDimensions.height * coverScale;
|
||||
@@ -124,7 +128,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
maxWidth: "none",
|
||||
willChange: "left, top, width, height",
|
||||
};
|
||||
}, [webcamCropRegion, webcamHeight, webcamVideoDimensions, webcamWidth]);
|
||||
}, [webcamCropRegion, webcamHeight, webcamVideoDimensions, webcamWidth, webcamShape]);
|
||||
|
||||
const applyWebcamBubbleLayout = useCallback(
|
||||
(zoomScale: number) => {
|
||||
@@ -155,7 +159,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
const scaledMargin = scaleWebcamOverlayPixels(webcamMargin, overlay.clientWidth);
|
||||
|
||||
const scaledDimensions = getWebcamOverlayDimensionsPx({
|
||||
shape: webcam?.shape,
|
||||
shape: webcamShape,
|
||||
containerWidth: overlay.clientWidth,
|
||||
containerHeight: overlay.clientHeight,
|
||||
widthPercent: webcamWidth,
|
||||
@@ -168,7 +172,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
webcamRoundness,
|
||||
scaledDimensions.width,
|
||||
scaledDimensions.height,
|
||||
webcam?.shape,
|
||||
webcamShape,
|
||||
);
|
||||
const { x, y } = getWebcamOverlayPosition({
|
||||
containerWidth: overlay.clientWidth,
|
||||
@@ -192,7 +196,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
webcamRoundness,
|
||||
scaledDimensions.width,
|
||||
scaledDimensions.height,
|
||||
webcam?.shape,
|
||||
webcamShape,
|
||||
);
|
||||
const shadowSize = Math.min(scaledDimensions.width, scaledDimensions.height);
|
||||
bubble.style.filter = getWebcamShadowFilter(shadowSize, webcamShadow);
|
||||
@@ -217,7 +221,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
layout: { x, y, ...scaledDimensions, radius: scaledRadius },
|
||||
// Supersample small cameras even on standard-density displays.
|
||||
resolution: Math.max(2, window.devicePixelRatio || 1),
|
||||
shape: webcam?.shape,
|
||||
shape: webcamShape,
|
||||
timeMs: timelineTimeRef.current * 1000,
|
||||
playing: isPlayingRef.current && !isSeekingRef.current,
|
||||
amount: zoomMotionBlurRef.current,
|
||||
@@ -250,6 +254,7 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
|
||||
webcamCorner,
|
||||
webcamRoundness,
|
||||
webcam,
|
||||
webcamShape,
|
||||
webcamEnabled,
|
||||
webcamMargin,
|
||||
webcamPositionPreset,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getSquirclePathPoints } from "@/lib/geometry/squircle";
|
||||
import {
|
||||
convertLegacyWebcamRadiusToRoundness,
|
||||
@@ -234,3 +234,88 @@ it("keeps a Square camera square when its source crop has a different aspect", (
|
||||
expect(getCropMatchedWebcamHeightPercent(40, 40, 1920, 1080, crop, "squircle")).toBe(40);
|
||||
expect(getCropMatchedWebcamHeightPercent(40, 40, 1920, 1080, crop, "rectangle")).toBe(45);
|
||||
});
|
||||
|
||||
import { usePlaybackWebcamLayout } from "./videoPlayback/usePlaybackWebcamLayout";
|
||||
|
||||
const previewMemo = vi.hoisted(() => ({
|
||||
dependencies: null as readonly unknown[] | null,
|
||||
value: undefined as unknown,
|
||||
}));
|
||||
vi.mock("react", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react")>()),
|
||||
useCallback: <T>(callback: T) => callback,
|
||||
useEffect: () => undefined,
|
||||
useMemo: (factory: () => unknown, dependencies: readonly unknown[]) => {
|
||||
if (
|
||||
!previewMemo.dependencies ||
|
||||
dependencies.some(
|
||||
(value, index) => !Object.is(value, previewMemo.dependencies?.[index]),
|
||||
)
|
||||
) {
|
||||
previewMemo.value = factory();
|
||||
previewMemo.dependencies = [...dependencies];
|
||||
}
|
||||
return previewMemo.value;
|
||||
},
|
||||
}));
|
||||
|
||||
function previewWebcamFixture(shape?: "circle" | "rectangle" | "squircle") {
|
||||
previewMemo.dependencies = null;
|
||||
const bubble = { style: {} as Record<string, string> };
|
||||
const input = {
|
||||
webcam: {
|
||||
enabled: true,
|
||||
shape,
|
||||
width: 30,
|
||||
height: 30,
|
||||
cropRegion: { x: 0, y: 0, width: 0.5, height: 1 },
|
||||
},
|
||||
webcamVideoDimensions: { width: 1920, height: 1080 },
|
||||
webcamBubbleRef: { current: bubble },
|
||||
webcamBlurCanvasRef: { current: null },
|
||||
webcamBubbleInnerRef: { current: { style: { setProperty: vi.fn() } } },
|
||||
overlayRef: { current: { clientWidth: 1920, clientHeight: 1080 } },
|
||||
webcamVideoPath: "/camera.webm",
|
||||
timelineTimeRef: { current: 0 },
|
||||
clipRegionsRef: { current: [{ id: "clip", startMs: 0, endMs: 1000, speed: 1 }] },
|
||||
currentTimeRef: { current: 0 },
|
||||
webcamMotionBlurRef: { current: { reset: vi.fn(), destroy: vi.fn() } },
|
||||
webcamVideoRef: { current: null },
|
||||
webcamMediaSynchronized: false,
|
||||
isPlayingRef: { current: false },
|
||||
isSeekingRef: { current: false },
|
||||
zoomMotionBlurRef: { current: 0 },
|
||||
animationStateRef: { current: { appliedScale: 1 } },
|
||||
isPlaying: false,
|
||||
} as unknown as Parameters<typeof usePlaybackWebcamLayout>[0];
|
||||
// This harness evaluates geometry with mocked hooks, including dependency-aware memoization.
|
||||
const useLayout = () => usePlaybackWebcamLayout(input);
|
||||
return { input, bubble, layout: useLayout };
|
||||
}
|
||||
|
||||
it("keeps circle preview content proportional after switching from unequal rectangle dimensions", () => {
|
||||
const fixture = previewWebcamFixture("rectangle");
|
||||
fixture.input.webcam = { ...fixture.input.webcam!, width: 30, height: 20 };
|
||||
const rectangle = fixture.layout().webcamCropPreviewContentStyle;
|
||||
expect(
|
||||
(Number.parseFloat(String(rectangle.width)) / Number.parseFloat(String(rectangle.height))) *
|
||||
1.5,
|
||||
).toBeCloseTo(1920 / 1080);
|
||||
fixture.input.webcam = { ...fixture.input.webcam!, shape: "circle" };
|
||||
const circle = fixture.layout();
|
||||
circle.applyWebcamBubbleLayout(1);
|
||||
expect(fixture.bubble.style.width).toBe(fixture.bubble.style.height);
|
||||
expect(
|
||||
Number.parseFloat(String(circle.webcamCropPreviewContentStyle.width)) /
|
||||
Number.parseFloat(String(circle.webcamCropPreviewContentStyle.height)),
|
||||
).toBeCloseTo(1920 / 1080);
|
||||
});
|
||||
|
||||
it("keeps direct preview inputs with an omitted shape square despite their source crop", () => {
|
||||
const fixture = previewWebcamFixture();
|
||||
const layout = fixture.layout();
|
||||
layout.applyWebcamBubbleLayout(1);
|
||||
expect(layout.webcamHeight).toBe(30);
|
||||
expect(fixture.bubble.style.width).toBe("324px");
|
||||
expect(fixture.bubble.style.height).toBe("324px");
|
||||
});
|
||||
@@ -43,7 +43,7 @@
|
||||
"descriptionPlaceholder": "Wir lesen jedes Feedback...",
|
||||
"attachFiles": "Dateien anhängen",
|
||||
"removeFile": "{{name}} entfernen",
|
||||
"diagnostics": "Diagnoseprotokolle angehängt. Feedback an support@recordly.dev gesendet",
|
||||
"diagnostics": "Beim Absenden werden Diagnoseprotokolle angehängt. Feedback wird an support@recordly.dev gesendet.",
|
||||
"sending": "Wird gesendet…",
|
||||
"errors": {
|
||||
"unavailable": "Feedback ist erst verfügbar, wenn die Kontodienste eingerichtet sind.",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"descriptionPlaceholder": "Leemos todos los comentarios...",
|
||||
"attachFiles": "Adjuntar archivos",
|
||||
"removeFile": "Eliminar {{name}}",
|
||||
"diagnostics": "Registros de diagnóstico adjuntos. Comentarios enviados a support@recordly.dev",
|
||||
"diagnostics": "Se adjuntarán los registros de diagnóstico. Los comentarios se enviarán a support@recordly.dev.",
|
||||
"sending": "Enviando…",
|
||||
"errors": {
|
||||
"unavailable": "Los comentarios no están disponibles hasta que se configuren los servicios de cuenta.",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"descriptionPlaceholder": "Nous lisons tous les commentaires...",
|
||||
"attachFiles": "Joindre des fichiers",
|
||||
"removeFile": "Retirer {{name}}",
|
||||
"diagnostics": "Journaux de diagnostic joints. Commentaires envoyés à support@recordly.dev",
|
||||
"diagnostics": "Les journaux de diagnostic seront joints. Les commentaires seront envoyés à support@recordly.dev.",
|
||||
"sending": "Envoi…",
|
||||
"errors": {
|
||||
"unavailable": "Les commentaires seront disponibles une fois les services de compte configurés.",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"descriptionPlaceholder": "Leggiamo tutti i commenti...",
|
||||
"attachFiles": "Allega file",
|
||||
"removeFile": "Rimuovi {{name}}",
|
||||
"diagnostics": "Registri diagnostici allegati. Feedback inviato a support@recordly.dev",
|
||||
"diagnostics": "I registri diagnostici verranno allegati. Il feedback verrà inviato a support@recordly.dev.",
|
||||
"sending": "Invio…",
|
||||
"errors": {
|
||||
"unavailable": "I commenti saranno disponibili dopo la configurazione dei servizi dell’account.",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"descriptionPlaceholder": "모든 피드백을 읽고 있습니다...",
|
||||
"attachFiles": "파일 첨부",
|
||||
"removeFile": "{{name}} 제거",
|
||||
"diagnostics": "진단 로그가 첨부되었습니다. 피드백이 support@recordly.dev로 전송됩니다",
|
||||
"diagnostics": "제출하면 진단 로그가 첨부되고 피드백이 support@recordly.dev로 전송됩니다.",
|
||||
"sending": "전송 중…",
|
||||
"errors": {
|
||||
"unavailable": "계정 서비스가 구성되어야 피드백을 보낼 수 있습니다.",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"descriptionPlaceholder": "We lezen alle feedback...",
|
||||
"attachFiles": "Bestanden bijvoegen",
|
||||
"removeFile": "{{name}} verwijderen",
|
||||
"diagnostics": "Diagnostische logboeken bijgevoegd. Feedback verzonden naar support@recordly.dev",
|
||||
"diagnostics": "Diagnostische logboeken zullen worden bijgevoegd. Feedback zal naar support@recordly.dev worden verzonden.",
|
||||
"sending": "Verzenden…",
|
||||
"errors": {
|
||||
"unavailable": "Feedback is pas beschikbaar nadat de accountdiensten zijn ingesteld.",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"descriptionPlaceholder": "Lemos todos os comentários...",
|
||||
"attachFiles": "Anexar arquivos",
|
||||
"removeFile": "Remover {{name}}",
|
||||
"diagnostics": "Registros de diagnóstico anexados. Comentários enviados para support@recordly.dev",
|
||||
"diagnostics": "Os registros de diagnóstico serão anexados. Os comentários serão enviados para support@recordly.dev.",
|
||||
"sending": "Enviando…",
|
||||
"errors": {
|
||||
"unavailable": "O envio de comentários ficará disponível após a configuração dos serviços da conta.",
|
||||
|
||||
@@ -468,6 +468,45 @@ describe("FrameRenderer webcam export path", () => {
|
||||
expect(renderer.webcamSeekPromise).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a cropped camera without an explicit shape square in legacy export", () => {
|
||||
const renderer = createRenderer({
|
||||
webcam: {
|
||||
enabled: true,
|
||||
shape: undefined,
|
||||
width: 30,
|
||||
height: 30,
|
||||
cropRegion: { x: 0, y: 0, width: 0.5, height: 1 },
|
||||
},
|
||||
}) as unknown as FrameRendererTestAccess & {
|
||||
webcamMotionBlur: { render: (...args: unknown[]) => null };
|
||||
};
|
||||
const cached = createMockCanvas();
|
||||
cached.width = 640;
|
||||
cached.height = 360;
|
||||
renderer.webcamVideoElement = new FakeVideoElement({
|
||||
currentTime: 0,
|
||||
readyState: 0,
|
||||
videoWidth: 640,
|
||||
videoHeight: 360,
|
||||
});
|
||||
renderer.webcamFrameCacheCanvas = cached;
|
||||
renderer.webcamFrameCacheCtx = cached.getContext("2d");
|
||||
renderer.currentVideoTime = 0;
|
||||
renderer.animationState.appliedScale = 1;
|
||||
const render = vi.spyOn(renderer.webcamMotionBlur, "render").mockReturnValue(null);
|
||||
renderer.drawWebcamOverlay(
|
||||
createMockContext() as unknown as CanvasRenderingContext2D,
|
||||
1280,
|
||||
720,
|
||||
);
|
||||
expect(render).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
shape: "squircle",
|
||||
layout: expect.objectContaining({ width: 216, height: 216 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the cached webcam frame when the live video is out of sync", () => {
|
||||
const renderer = createRenderer() as unknown as FrameRendererTestAccess;
|
||||
const outputContext = createMockContext();
|
||||
|
||||
@@ -105,6 +105,7 @@ export function drawWebcamOverlay(
|
||||
height: number,
|
||||
): void {
|
||||
const webcam = context.config.webcam;
|
||||
const webcamShape = webcam?.shape ?? "squircle";
|
||||
const webcamDecodedFrame = context.webcamDecodedFrame;
|
||||
const webcamVideo = context.webcamVideoElement;
|
||||
if (
|
||||
@@ -212,10 +213,10 @@ export function drawWebcamOverlay(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
webcam.cropRegion,
|
||||
webcam.shape,
|
||||
webcamShape,
|
||||
);
|
||||
const dimensions = getWebcamOverlayDimensionsPx({
|
||||
shape: webcam.shape,
|
||||
shape: webcamShape,
|
||||
containerWidth: width,
|
||||
containerHeight: height,
|
||||
widthPercent,
|
||||
@@ -239,14 +240,14 @@ export function drawWebcamOverlay(
|
||||
webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
webcam.shape,
|
||||
webcamShape,
|
||||
);
|
||||
// Keep fractional layout coordinates in one backing store; rounding a small
|
||||
// bubble canvas and stretching it back shifts the clipped video at the edge.
|
||||
const overlay = context.webcamMotionBlur.render({
|
||||
renderStill: true,
|
||||
layout: { x, y, ...dimensions, radius },
|
||||
shape: webcam.shape,
|
||||
shape: webcamShape,
|
||||
timeMs: context.webcamMotionTimeMs,
|
||||
playing: true,
|
||||
amount: context.config.zoomMotionBlur ?? 0,
|
||||
|
||||
@@ -446,4 +446,48 @@ describe("ModernFrameRenderer webcam export fallback", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
it("keeps a cropped camera without an explicit shape square in modern export", () => {
|
||||
const renderer = createRenderer();
|
||||
renderer.config.webcam = {
|
||||
...DEFAULT_WEBCAM_OVERLAY,
|
||||
enabled: true,
|
||||
shape: undefined,
|
||||
width: 30,
|
||||
height: 30,
|
||||
cropRegion: { x: 0, y: 0, width: 0.5, height: 1 },
|
||||
};
|
||||
renderer.currentVideoTime = 0;
|
||||
renderer.webcamMedia.lastSyncedWebcamTime = 0;
|
||||
renderer.webcamMedia.webcamVideoElement = {
|
||||
currentTime: 0,
|
||||
readyState: 2,
|
||||
seeking: false,
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
duration: Number.NaN,
|
||||
};
|
||||
renderer.scene.webcamRootContainer = {
|
||||
visible: false,
|
||||
addChild: vi.fn(),
|
||||
position: { set: vi.fn() },
|
||||
};
|
||||
renderer.scene.webcamContainer = { addChildAt: vi.fn() };
|
||||
renderer.scene.webcamMaskGraphics = {
|
||||
clear: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
};
|
||||
renderer.webcamShadowLayers = [];
|
||||
renderer.motion.animationState = { appliedScale: 1 };
|
||||
const render = vi.spyOn(renderer.webcam.webcamMotionBlur, "render").mockReturnValue(null);
|
||||
renderer.webcam.updateWebcamOverlay();
|
||||
expect(render).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
shape: "squircle",
|
||||
layout: expect.objectContaining({ width: 324, height: 324 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,7 @@ export class FrameWebcam {
|
||||
frameTimeMs = referenceTimeSeconds * 1000,
|
||||
): void {
|
||||
const webcam = this.context.config.webcam;
|
||||
const webcamShape = webcam?.shape ?? "squircle";
|
||||
if (
|
||||
!webcam?.enabled ||
|
||||
!isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) ||
|
||||
@@ -230,10 +231,10 @@ export class FrameWebcam {
|
||||
aspectSourceWidth,
|
||||
aspectSourceHeight,
|
||||
webcam.cropRegion,
|
||||
webcam.shape,
|
||||
webcamShape,
|
||||
);
|
||||
const dimensions = getWebcamOverlayDimensionsPx({
|
||||
shape: webcam.shape,
|
||||
shape: webcamShape,
|
||||
containerWidth: this.context.config.width,
|
||||
containerHeight: this.context.config.height,
|
||||
widthPercent,
|
||||
@@ -257,13 +258,13 @@ export class FrameWebcam {
|
||||
webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
webcam.shape,
|
||||
webcamShape,
|
||||
);
|
||||
|
||||
this.context.scene.webcamRootContainer.visible = true;
|
||||
|
||||
const nextLayout: WebcamLayoutCache = {
|
||||
exponent: getWebcamShapeExponent(webcam.shape),
|
||||
exponent: getWebcamShapeExponent(webcamShape),
|
||||
sourceWidth: renderableWebcamSource.width,
|
||||
sourceHeight: renderableWebcamSource.height,
|
||||
width: dimensions.width,
|
||||
@@ -279,7 +280,7 @@ export class FrameWebcam {
|
||||
const blur = this.webcamMotionBlur.render({
|
||||
renderStill: true,
|
||||
layout: { x: position.x, y: position.y, ...dimensions, radius },
|
||||
shape: webcam.shape,
|
||||
shape: webcamShape,
|
||||
timeMs: frameTimeMs,
|
||||
playing: true,
|
||||
amount: this.context.config.zoomMotionBlur ?? 0,
|
||||
|
||||
Reference in new issue
Block a user