diff --git a/electron/authCallback.ts b/electron/authCallback.ts index 0beb21dc..612847ce 100644 --- a/electron/authCallback.ts +++ b/electron/authCallback.ts @@ -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; diff --git a/electron/authCallbackFlowRouting.test.ts b/electron/authCallbackFlowRouting.test.ts index 2095dc96..3a020c0e 100644 --- a/electron/authCallbackFlowRouting.test.ts +++ b/electron/authCallbackFlowRouting.test.ts @@ -21,6 +21,9 @@ const state = vi.hoisted(() => ({ flows: new Map(), remote: new Map | "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 => '"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(); +}); diff --git a/electron/ipc/project/libraryDeletion.test.ts b/electron/ipc/project/libraryDeletion.test.ts index f9647a77..45a76f85 100644 --- a/electron/ipc/project/libraryDeletion.test.ts +++ b/electron/ipc/project/libraryDeletion.test.ts @@ -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 = {}) { const data: Record = { "/p/a": ["/r/a.mp4", "/external/import.mp4"], diff --git a/electron/ipc/project/libraryDeletion.ts b/electron/ipc/project/libraryDeletion.ts index e1cfb927..c82f97bb 100644 --- a/electron/ipc/project/libraryDeletion.ts +++ b/electron/ipc/project/libraryDeletion.ts @@ -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; + readSources: (path: string, kind?: "primary" | "recovery") => Promise; backupPaths: (path: string) => string[]; recordings: () => Promise>; activeSources?: () => string[] | Promise; @@ -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(); 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( diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 52c5f709..1b11950c 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -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 }); diff --git a/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts b/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts index f823508a..029471bb 100644 --- a/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts +++ b/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts @@ -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(() => { @@ -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, diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts index ac1a0584..f730a9e4 100644 --- a/src/components/video-editor/webcamOverlay.test.ts +++ b/src/components/video-editor/webcamOverlay.test.ts @@ -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()), + useCallback: (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 }; + 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[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"); +}); diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index a998f3c8..019d3289 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -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.", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 8321a66c..fb727252 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -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.", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 436f98ac..dcc0f548 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -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.", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index b9e99ef4..74e4b3d1 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -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.", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 979bf400..3399f8a3 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -43,7 +43,7 @@ "descriptionPlaceholder": "모든 피드백을 읽고 있습니다...", "attachFiles": "파일 첨부", "removeFile": "{{name}} 제거", - "diagnostics": "진단 로그가 첨부되었습니다. 피드백이 support@recordly.dev로 전송됩니다", + "diagnostics": "제출하면 진단 로그가 첨부되고 피드백이 support@recordly.dev로 전송됩니다.", "sending": "전송 중…", "errors": { "unavailable": "계정 서비스가 구성되어야 피드백을 보낼 수 있습니다.", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 09c1c808..f1debd87 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -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.", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 5e971cc1..8208f439 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -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.", diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 288252fc..093e2dd6 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -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(); diff --git a/src/lib/exporter/legacyFrameComposite.ts b/src/lib/exporter/legacyFrameComposite.ts index 7ea530df..b4c5c9b1 100644 --- a/src/lib/exporter/legacyFrameComposite.ts +++ b/src/lib/exporter/legacyFrameComposite.ts @@ -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, diff --git a/src/lib/exporter/modernFrameWebcam.test.ts b/src/lib/exporter/modernFrameWebcam.test.ts index 5be7a9ac..dfb9d930 100644 --- a/src/lib/exporter/modernFrameWebcam.test.ts +++ b/src/lib/exporter/modernFrameWebcam.test.ts @@ -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 }), + }), + ); + }); }); diff --git a/src/lib/exporter/modernFrameWebcam.ts b/src/lib/exporter/modernFrameWebcam.ts index ffbed007..ae36df48 100644 --- a/src/lib/exporter/modernFrameWebcam.ts +++ b/src/lib/exporter/modernFrameWebcam.ts @@ -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,