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:
webadderall committed 2026-11-08 07:51:11 +11:00
commit f6df69068f
18 files changed
+463 -32

No files matched your search

+7 -2
View File
@@ -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;
+71
View File
@@ -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();
});
+179 -1
View File
@@ -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"],
+3 -3
View File
@@ -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(
+4 -2
View File
@@ -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");
});
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -43,7 +43,7 @@
"descriptionPlaceholder": "모든 피드백을 읽고 있습니다...",
"attachFiles": "파일 첨부",
"removeFile": "{{name}} 제거",
"diagnostics": "진단 로그가 첨부되었습니다. 피드백이 support@recordly.dev로 전송됩니다",
"diagnostics": "제출하면 진단 로그가 첨부되고 피드백이 support@recordly.dev로 전송됩니다.",
"sending": "전송 중…",
"errors": {
"unavailable": "계정 서비스가 구성되어야 피드백을 보낼 수 있습니다.",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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.",
+39
View File
@@ -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();
+5 -4
View File
@@ -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 }),
}),
);
});
});
+6 -5
View File
@@ -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,