diff --git a/docs/cloud-sharing.md b/docs/cloud-sharing.md
index f11b71c5..05fd358f 100644
--- a/docs/cloud-sharing.md
+++ b/docs/cloud-sharing.md
@@ -26,7 +26,7 @@ The service provides R2 video storage, D1 metadata, range streaming, a responsiv
## Comments
-Anyone with a valid link can watch a public recording. Leaving timestamped feedback requires a signed-in account; Recordly Cloud uses the viewer’s verified Recordly account and profile name. The server derives comment authorship from the session, and viewers can edit or delete their own comments. Account and IP rate limits and the 2,000-character comment limit apply. Self-hosted viewers sign in to the account system configured by that service.
+Anyone with a valid link can watch a public recording. Leaving timestamped feedback requires a signed-in account; Recordly Cloud uses the viewer’s verified Recordly account and profile name. The server derives comment authorship from the session, and viewers can edit or delete their own comments. Account and IP rate limits and the 2,000-character comment limit apply. The self-hosted worker requires a separate, signed-in account local to that instance; a supplied display name alone does not authorize a comment.
## Third-party licensing
diff --git a/electron/authFlowRelay.test.ts b/electron/authFlowRelay.test.ts
index cdafeb91..c4e98daa 100644
--- a/electron/authFlowRelay.test.ts
+++ b/electron/authFlowRelay.test.ts
@@ -324,3 +324,82 @@ it.each([
])("rejects invalid callback before registry/relay lookup: %s", (url) => {
expect(parseFlowCallback(url, "recordly")).toBeNull();
});
+
+it.each([
+ "decrypt",
+ "JSON",
+ "shape",
+])("preserves unrelated %s failures while registering a new exact route", async (failure) => {
+ const a = relay();
+ const launcher = relay();
+ const records = path.join(directory, "flows");
+ await fs.mkdir(records, { recursive: true });
+ const damagedId = "flow_broken";
+ const damaged = path.join(records, `recordly.${damagedId}.${"a".repeat(32)}.enc`);
+ const bytes =
+ failure === "decrypt"
+ ? Buffer.from("unreadable ciphertext")
+ : failure === "JSON"
+ ? encrypt("{invalid JSON")
+ : encrypt(JSON.stringify({ version: 1, protocol: "recordly" }));
+ await fs.writeFile(damaged, bytes);
+ const current = owner("new-owner");
+ await a.instance.register("flow_alice", "exact-verifier", current);
+ expect(await launcher.instance.route(callback("flow_alice"))).toBe("forwarded");
+ expect(a.deliver).toHaveBeenCalledExactlyOnceWith(current, callback("flow_alice"));
+ expect(await launcher.instance.route(callback(damagedId))).toBe("unavailable");
+ await expect(
+ launcher.instance.register(damagedId, "must-not-replace", owner("other")),
+ ).rejects.toThrow("unlock");
+ expect(await fs.readFile(damaged)).toEqual(bytes);
+ expect(a.hasVerifier).toHaveBeenCalledExactlyOnceWith("exact-verifier");
+ expect(launcher.deliver).not.toHaveBeenCalled();
+});
+
+it("counts unreadable generations toward the 128-route limit without deleting them", async () => {
+ const a = relay();
+ const launcher = relay();
+ const records = path.join(directory, "flows");
+ await fs.mkdir(records, { recursive: true });
+ const bytes = Buffer.from("unreadable ciphertext");
+ const files = Array.from({ length: 127 }, (_, n) =>
+ path.join(records, `recordly.corrupt_${String(n).padStart(3, "0")}.${"a".repeat(32)}.enc`),
+ );
+ await Promise.all(files.map((file) => fs.writeFile(file, bytes)));
+ await a.instance.register("flow_alice", "exact-verifier", owner("a"));
+ expect(await fs.readdir(records)).toHaveLength(128);
+ expect(await launcher.instance.route(callback("flow_alice"))).toBe("forwarded");
+ await expect(
+ launcher.instance.register("flow_bob00", "other-verifier", owner("b")),
+ ).rejects.toThrow("Too many sign-in return routes");
+ expect(await fs.readdir(records)).toHaveLength(128);
+ for (const file of files) expect(await fs.readFile(file)).toEqual(bytes);
+});
+
+it("still removes only readable proven-dead generations while preserving ancient live and unreadable records", async () => {
+ const a = relay();
+ const b = relay();
+ await a.instance.register("flow_alice", "alice-verifier", owner("a"));
+ const live = await record("flow_alice");
+ const liveBytes = await fs.readFile(live.file);
+ await fs.utimes(live.file, new Date(0), new Date(0));
+ const deadPid = 2147483647;
+ const deadNonce = "b".repeat(32);
+ const deadRecord = { ...live.value, flowId: "flow_dead0", pid: deadPid, nonce: deadNonce };
+ const deadFile = path.join(directory, "flows", `recordly.flow_dead0.${deadNonce}.enc`);
+ await fs.writeFile(deadFile, encrypt(JSON.stringify(deadRecord)));
+ const damaged = path.join(directory, "flows", `recordly.flow_broken.${"c".repeat(32)}.enc`);
+ const damagedBytes = Buffer.from("unreadable ciphertext");
+ await fs.writeFile(damaged, damagedBytes);
+ const realKill = process.kill.bind(process);
+ vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
+ if (pid === deadPid)
+ throw Object.assign(new Error("Exited fixture process"), { code: "ESRCH" });
+ return realKill(pid, signal);
+ });
+ await b.instance.register("flow_bob00", "bob-verifier", owner("b"));
+ await expect(fs.access(deadFile)).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await fs.readFile(live.file)).toEqual(liveBytes);
+ expect(await fs.readFile(damaged)).toEqual(damagedBytes);
+ expect(await b.instance.route(callback("flow_alice"))).toBe("forwarded");
+});
diff --git a/electron/authFlowRelay.ts b/electron/authFlowRelay.ts
index d1f4f07c..048a48d0 100644
--- a/electron/authFlowRelay.ts
+++ b/electron/authFlowRelay.ts
@@ -392,7 +392,15 @@ export function createAuthFlowRelay(options: {
let live = 0;
for (const name of await fs.readdir(recordsDirectory)) {
if (!name.startsWith(`${options.protocol}.`) || !name.endsWith(".enc")) continue;
- const prior = await readRecord(name);
+ let prior: Registration | null;
+ try {
+ prior = await readRecord(name);
+ } catch {
+ // Unreadable unrelated generations still occupy capacity. Preserve
+ // them without blocking a different, verifiable sign-in flow.
+ live++;
+ continue;
+ }
if (!prior) continue;
if (dead(prior.pid))
await fs
diff --git a/electron/desktopWallpaper.test.ts b/electron/desktopWallpaper.test.ts
index 0c1e54da..927de72d 100644
--- a/electron/desktopWallpaper.test.ts
+++ b/electron/desktopWallpaper.test.ts
@@ -1,3 +1,4 @@
+import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -56,7 +57,7 @@ describe("desktop wallpaper source", () => {
});
describe("modern macOS wallpaper providers", () => {
- const assetID = "6511D2B5-E185-4886-9505-B4004E920D27";
+ const assetID = randomUUID().toUpperCase();
const preference = JSON.stringify({
provider: "com.apple.wallpaper.choice.aerials",
assetID,
diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts
index e5f2cd55..d2dd34f7 100644
--- a/electron/ipc/project/manager.test.ts
+++ b/electron/ipc/project/manager.test.ts
@@ -244,7 +244,20 @@ describe("local media path policy", () => {
// Keep the fractional revision in the past so an immediate edit is always newer.
await fs.utimes(projectPath, new Date(), Math.floor(Date.now() / 1000) - 2 + 0.123456);
preview = await manager.readProjectPreview(projectPath);
- await manager.cacheProjectThumbnail(projectPath, preview.revision!, dataUrl);
+ // A cache volume may retain less timestamp precision than the project volume.
+ const writeTimes = fs.utimes.bind(fs);
+ const stamp = vi.spyOn(fs, "utimes").mockImplementationOnce((file, accessed, modified) =>
+ writeTimes(
+ file,
+ accessed,
+ typeof modified === "number" ? Math.floor(modified * 1000) / 1000 : modified,
+ ),
+ );
+ try {
+ await manager.cacheProjectThumbnail(projectPath, preview.revision!, dataUrl);
+ } finally {
+ stamp.mockRestore();
+ }
expect((await manager.buildProjectLibraryEntry(projectPath, tempRoot))?.thumbnailPath).toBe(
target,
);
diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts
index 478ccd9a..ea59de8e 100644
--- a/electron/ipc/project/manager.ts
+++ b/electron/ipc/project/manager.ts
@@ -403,7 +403,7 @@ export async function cacheProjectThumbnail(projectPath: string, revision: strin
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
await fs.writeFile(temporary, png, { flag: "wx" });
// A subsequent project edit must invalidate the cached frame, even if it races this write.
- await fs.utimes(temporary, new Date(), stats.mtimeMs / 1000);
+ await fs.utimes(temporary, new Date(), Math.ceil(stats.mtimeMs) / 1000);
if (!sameProjectRevision(stats, await fs.stat(normalizedPath))) return null;
await fs.rename(temporary, target);
return target;
diff --git a/electron/ipc/register/cloudShare.ts b/electron/ipc/register/cloudShare.ts
index 372f0f82..0ade7300 100644
--- a/electron/ipc/register/cloudShare.ts
+++ b/electron/ipc/register/cloudShare.ts
@@ -271,6 +271,7 @@ export function registerCloudShareHandlers() {
ownerId: event.sender.id,
token,
};
+ activeUpload.token = token;
activeUpload.finishing = true;
const controller = activeUpload.controller;
activeUploads.set(uploadId, activeUpload);
diff --git a/electron/ipc/register/cloudShareHandlers.test.ts b/electron/ipc/register/cloudShareHandlers.test.ts
index d46c04a9..82c3afad 100644
--- a/electron/ipc/register/cloudShareHandlers.test.ts
+++ b/electron/ipc/register/cloudShareHandlers.test.ts
@@ -192,7 +192,12 @@ describe("cloud share IPC lifecycle", () => {
});
}
function server(holdMetadata = false, partBytes?: number) {
- const requests: { path: string; method?: string; body?: string }[] = [];
+ const requests: {
+ path: string;
+ method?: string;
+ body?: string;
+ authorization: string | null;
+ }[] = [];
let releaseMetadata: (() => void) | undefined;
const metadataGate = holdMetadata
? new Promise((resolve) => {
@@ -208,6 +213,7 @@ describe("cloud share IPC lifecycle", () => {
path: url.pathname,
method: init.method,
body: typeof init.body === "string" ? init.body : undefined,
+ authorization: new Headers(init.headers).get("authorization"),
});
if (url.pathname === "/api/upload")
return json({
@@ -326,6 +332,105 @@ describe("cloud share IPC lifecycle", () => {
expect(owner.listenerCount("destroyed")).toBe(0);
});
+ it("uses the validated final-upload token for an existing progressive upload without a separate refresh IPC", async () => {
+ const remote = server();
+ const id = uploadId();
+ expect(begin(id)).toEqual({ success: true });
+ await vi.waitFor(() =>
+ expect(
+ remote.requests.some((request) => request.path.includes("upload-multipart")),
+ ).toBe(true),
+ );
+ expect(
+ remote.requests.find((request) => request.path === "/api/upload")?.authorization,
+ ).toBe("Bearer token");
+ expect(
+ await call("cloud-share-upload", owner, {
+ endpoint,
+ token: "fresh-final-token",
+ uploadId: id,
+ filePath: finalPath,
+ }),
+ ).toMatchObject({ success: true });
+ for (const path of [
+ "upload-part/",
+ "upload-complete/",
+ "/api/metadata/",
+ "upload-thumbnail/",
+ ]) {
+ const requests = remote.requests.filter((request) => request.path.includes(path));
+ expect(requests.length).toBeGreaterThan(0);
+ expect(
+ requests.every((request) => request.authorization === "Bearer fresh-final-token"),
+ ).toBe(true);
+ }
+ expect(remote.requests.filter((request) => request.path === "/api/upload")).toHaveLength(1);
+ expect(owner.listenerCount("destroyed")).toBe(0);
+ });
+
+ it("uses the fresh final-upload token to clean up a progressive reservation after invalid final input", async () => {
+ const remote = server();
+ const id = uploadId();
+ expect(begin(id)).toEqual({ success: true });
+ await vi.waitFor(() =>
+ expect(
+ remote.requests.some((request) => request.path.includes("upload-multipart")),
+ ).toBe(true),
+ );
+ expect(
+ await call("cloud-share-upload", owner, {
+ endpoint,
+ token: "fresh-cleanup-token",
+ uploadId: id,
+ filePath: "",
+ }),
+ ).toMatchObject({ success: false, error: "Export a video before sharing it." });
+ await vi.waitFor(() =>
+ expect(remote.requests.some((request) => request.path === "/api/delete/abc123")).toBe(
+ true,
+ ),
+ );
+ expect(
+ remote.requests.find((request) => request.path === "/api/delete/abc123")?.authorization,
+ ).toBe("Bearer fresh-cleanup-token");
+ expect(owner.listenerCount("destroyed")).toBe(0);
+ });
+
+ it.each([
+ "another renderer",
+ "invalid token",
+ ])("does not replace progressive credentials from %s during final upload", async (attempt) => {
+ const remote = server();
+ const id = uploadId();
+ expect(begin(id)).toEqual({ success: true });
+ await vi.waitFor(() =>
+ expect(
+ remote.requests.some((request) => request.path.includes("upload-multipart")),
+ ).toBe(true),
+ );
+ expect(
+ await call("cloud-share-upload", attempt === "another renderer" ? stranger : owner, {
+ endpoint,
+ token: attempt === "invalid token" ? "invalid\ntoken" : "foreign-token",
+ uploadId: id,
+ filePath: finalPath,
+ }),
+ ).toMatchObject({ success: false });
+ expect(owner.listenerCount("destroyed")).toBe(1);
+ expect(call("cloud-share-cancel", owner, id)).toEqual({ success: true });
+ await vi.waitFor(() =>
+ expect(remote.requests.some((request) => request.path === "/api/delete/abc123")).toBe(
+ true,
+ ),
+ );
+ expect(
+ remote.requests.find((request) => request.path === "/api/delete/abc123")?.authorization,
+ ).toBe("Bearer token");
+ expect(remote.requests.some((request) => request.path.includes("/api/metadata/"))).toBe(
+ false,
+ );
+ });
+
it("uses parallel multipart for finished clips below the old 50 MiB cutoff", async () => {
const partBytes = 5 * 1024 * 1024;
await fs.truncate(finalPath, partBytes * 2 + 16);
diff --git a/src/components/auth/OnboardingController.test.tsx b/src/components/auth/OnboardingController.test.tsx
index de19f749..5c9b983f 100644
--- a/src/components/auth/OnboardingController.test.tsx
+++ b/src/components/auth/OnboardingController.test.tsx
@@ -332,3 +332,52 @@ it("persists the dev dismissal before native destruction and restores it if clos
expect(render()?.props.open).toBe(false);
error.mockRestore();
});
+
+it("opens production permission recovery only once for each new request nonce", () => {
+ vi.stubEnv("DEV", false);
+ window.location.search = "?windowType=editor";
+ state.settings.set(seenKey, true);
+ vi.mocked(window.electronAPI.showOnboardingWindow).mockResolvedValue(true);
+ render({ standalone: false, requestNonce: 1 });
+ render({ standalone: false, requestNonce: 1, user: { ...user } });
+ expect(state.settings.get(pendingKey)).toBe(true);
+ expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledExactlyOnceWith({
+ preview: false,
+ });
+ render({ standalone: false, requestNonce: 2 });
+ render({ standalone: false, requestNonce: 2, user: { ...user } });
+ expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledTimes(2);
+ expect(vi.mocked(window.electronAPI.showOnboardingWindow).mock.calls[1]).toEqual([
+ { preview: false },
+ ]);
+ expect(state.settings.get(seenKey)).toBe(true);
+ expect(state.writes.some(([key]) => key === seenKey)).toBe(false);
+});
+
+it("opens real permission recovery from a native event even after the dev tour was dismissed", async () => {
+ window.location.search = "?windowType=editor";
+ state.settings.set(seenKey, true);
+ state.settings.set("recordly.dev.onboardingDismissed", true);
+ render({ standalone: false });
+ expect(window.electronAPI.showOnboardingWindow).not.toHaveBeenCalled();
+ const requestPermissions = vi
+ .mocked(window.electronAPI.onRecordingPermissionsRequested)
+ .mock.calls.at(-1)?.[0];
+ expect(requestPermissions).toBeTypeOf("function");
+ requestPermissions!();
+ expect(state.settings.get(pendingKey)).toBe(true);
+ expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledExactlyOnceWith({
+ preview: false,
+ });
+ await vi.waitFor(() => {
+ const recovery = render({ standalone: false });
+ expect(recovery?.type).toBe(RecordingPermissionsDialog);
+ expect(recovery?.props.open).toBe(true);
+ });
+ expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledOnce();
+ expect(state.settings.get(seenKey)).toBe(true);
+ expect(state.settings.get("recordly.dev.onboardingDismissed")).toBe(true);
+ expect(
+ state.writes.some(([key]) => key === seenKey || key === "recordly.dev.onboardingDismissed"),
+ ).toBe(false);
+});
diff --git a/src/components/billing/PlusFeatureDetails.tsx b/src/components/billing/PlusFeatureDetails.tsx
index f00b9eb2..add9efc7 100644
--- a/src/components/billing/PlusFeatureDetails.tsx
+++ b/src/components/billing/PlusFeatureDetails.tsx
@@ -56,10 +56,10 @@ export function PlusFeatureDetails({
Briefcase,
FileText,
Monitor,
- Link,
+ InfinityIcon,
CloudArrowUp,
...(limits?.plusWeeklyUploadLimit ? [CloudArrowUp] : []),
- InfinityIcon,
+ Link,
ArrowClockwise,
Sparkle,
];
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index a8d98726..cc97ff79 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -475,7 +475,7 @@ function LaunchWindowContent() {
className={`${styles.recordingControls} ${styles.recordingFinalizing} gap-2 text-sm text-muted-foreground`}
>
- Finishing recording…
+ {captureT("finishingRecording", "Finishing recording…")}
) : recording || restarting ? (
recordingControls
diff --git a/src/components/video-editor/dashboard/DashboardGrid.tsx b/src/components/video-editor/dashboard/DashboardGrid.tsx
index 23855ef6..8dd600d9 100644
--- a/src/components/video-editor/dashboard/DashboardGrid.tsx
+++ b/src/components/video-editor/dashboard/DashboardGrid.tsx
@@ -125,7 +125,9 @@ export function DashboardGrid({
{errorRecordingSource && errorRecordingSource !== errorSource && (
- Recording: {errorSourceName(errorRecordingSource)}
+ {uiText("ui.recordingSource", "Recording: {{name}}", {
+ name: errorSourceName(errorRecordingSource),
+ })}
)}
diff --git a/src/components/video-editor/dashboard/DashboardSettings.tsx b/src/components/video-editor/dashboard/DashboardSettings.tsx
index 8465f22d..64013aa1 100644
--- a/src/components/video-editor/dashboard/DashboardSettings.tsx
+++ b/src/components/video-editor/dashboard/DashboardSettings.tsx
@@ -93,7 +93,11 @@ export function DashboardSettings({
);
setHideHud(protection.enabled);
} catch (error) {
- if (active) setFeedback({ area: "hud", error: String(error) });
+ if (active)
+ setFeedback({
+ area: "hud",
+ error: error instanceof Error ? error.message : String(error),
+ });
}
})();
return () => {
@@ -282,6 +286,19 @@ export function DashboardSettings({
{status("hud")}
)}
+ {!captureSupported && status("hud")}
+ {hideHud === undefined && feedback?.area === "hud" && feedback.error && (
+
+ )}
diff --git a/src/components/video-editor/project/relinkContext.test.ts b/src/components/video-editor/project/relinkContext.test.ts
index 654a5c82..20bb09c2 100644
--- a/src/components/video-editor/project/relinkContext.test.ts
+++ b/src/components/video-editor/project/relinkContext.test.ts
@@ -249,3 +249,53 @@ it("does not invalidate an active relink for a discarded render of another proje
expect.any(Function),
);
});
+
+it.each([
+ false,
+ true,
+])("keeps toast retry on committed handlers after discarded render, missing recording=%s", async (missing) => {
+ const open = vi
+ .fn()
+ .mockResolvedValueOnce({
+ success: false,
+ error: missing
+ ? "Project video file not found: missing.mp4"
+ : "Project temporarily busy",
+ })
+ .mockResolvedValue({ success: true, project: result.project, path: target });
+ const relink = vi.fn().mockResolvedValue(result);
+ bridge({ openProjectFileAtPath: open, relinkProjectRecording: relink });
+ const project = initialProject();
+ const applyCommitted = vi.fn().mockResolvedValue(true);
+ const actions = render(project, applyCommitted);
+ await actions.handleOpenProjectFromLibrary(target);
+ const retry = vi.mocked(toast.error).mock.calls.at(-1)?.[1]?.action?.onClick;
+ expect(retry).toBeTypeOf("function");
+ const saveDiscarded = vi.fn().mockResolvedValue(false);
+ render(
+ { ...project, currentProjectPath: "/projects/discarded.recordly" },
+ vi.fn(),
+ { hasUnsavedChanges: true, saveProject: saveDiscarded },
+ false,
+ );
+ retry?.();
+ await vi.waitFor(() =>
+ expect(applyCommitted).toHaveBeenCalledWith(
+ result.project,
+ target,
+ ...(missing ? [expect.any(Function)] : []),
+ ),
+ );
+ expect(saveDiscarded).not.toHaveBeenCalled();
+ expect(missing ? relink : open).toHaveBeenCalledTimes(missing ? 1 : 2);
+ // The same persistent toast action must adopt a later committed handler.
+ const saveNext = vi.fn().mockResolvedValue(true);
+ const applyNext = vi.fn().mockResolvedValue(true);
+ render({ ...project, currentProjectPath: "/projects/committed.recordly" }, applyNext, {
+ hasUnsavedChanges: true,
+ saveProject: saveNext,
+ });
+ retry?.();
+ await vi.waitFor(() => expect(applyNext).toHaveBeenCalled());
+ expect(saveNext).toHaveBeenCalledOnce();
+});
diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts
index 0cc7032d..f967b0da 100644
--- a/src/components/video-editor/project/useProjectOpenActions.ts
+++ b/src/components/video-editor/project/useProjectOpenActions.ts
@@ -176,7 +176,9 @@ export function useProjectOpenActions({
refreshProjectLibrary,
],
);
- latestRelink.current = handleRelinkProjectRecording;
+ useLayoutEffect(() => {
+ latestRelink.current = handleRelinkProjectRecording;
+ }, [handleRelinkProjectRecording]);
const handleOpenProjectFromLibrary = useCallback(
async (projectPath: string) => {
@@ -216,7 +218,9 @@ export function useProjectOpenActions({
reportOpenError,
],
);
- latestOpen.current = handleOpenProjectFromLibrary;
+ useLayoutEffect(() => {
+ latestOpen.current = handleOpenProjectFromLibrary;
+ }, [handleOpenProjectFromLibrary]);
const handleImportMediaOrProject = useCallback(async () => {
if (opening.current) return;
diff --git a/src/components/video-editor/settings/useBackgroundSettings.ts b/src/components/video-editor/settings/useBackgroundSettings.ts
index b4e74091..1a8c9011 100644
--- a/src/components/video-editor/settings/useBackgroundSettings.ts
+++ b/src/components/video-editor/settings/useBackgroundSettings.ts
@@ -163,13 +163,13 @@ export function useBackgroundSettings(
const result = await window.electronAPI.importWallpaperVideo(file);
if (!result.success || !result.path) throw new Error("Unreadable video");
const filePath = result.path;
- setCustomImages((prev) => [filePath, ...prev]);
+ setCustomImages((prev) => (prev.includes(filePath) ? prev : [filePath, ...prev]));
onWallpaperChange(filePath);
toast.success(te("videoBackgroundAdded", "Video background added"));
return;
}
const dataUrl = await prepareWallpaperImage(file);
- setCustomImages((prev) => [...prev, dataUrl]);
+ setCustomImages((prev) => (prev.includes(dataUrl) ? prev : [...prev, dataUrl]));
onWallpaperChange(dataUrl);
toast.success(tSettings("background.uploadSuccess"));
} catch {
@@ -196,7 +196,7 @@ export function useBackgroundSettings(
});
return;
}
- setCustomImages((prev) => [filePath, ...prev]);
+ setCustomImages((prev) => (prev.includes(filePath) ? prev : [filePath, ...prev]));
onWallpaperChange(filePath);
toast.success(te("videoBackgroundAdded", "Video background added"));
} catch {
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 98c0f550..80dba986 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -379,12 +379,15 @@ const TimelineEditor = forwardRef(
ids: string[];
clips: ClipRegion[];
} | null>(null);
- const selectedClipIds =
- clipSelection?.primaryId === selectedClipId && clipSelection?.clips === clipRegions
- ? clipSelection.ids
- : selectedClipId
- ? [selectedClipId]
- : [];
+ const selectedClipIds = useMemo(
+ () =>
+ clipSelection?.primaryId === selectedClipId && clipSelection?.clips === clipRegions
+ ? clipSelection.ids
+ : selectedClipId
+ ? [selectedClipId]
+ : [],
+ [clipSelection, selectedClipId, clipRegions],
+ );
useEffect(() => {
if (
clipSelection &&
diff --git a/src/i18n/locales/de/capture.json b/src/i18n/locales/de/capture.json
index 6fda8db2..5c618233 100644
--- a/src/i18n/locales/de/capture.json
+++ b/src/i18n/locales/de/capture.json
@@ -12,6 +12,7 @@
"actions": "Aktionen",
"action": "Aktion",
"recordingControls": "Aufnahmesteuerung",
+ "finishingRecording": "Aufnahme wird abgeschlossen…",
"discardingRecording": "Aufnahme wird verworfen",
"recording": "Aufnahme läuft",
"discardRecording": "Aufnahme verwerfen",
diff --git a/src/i18n/locales/en/capture.json b/src/i18n/locales/en/capture.json
index e8e5f941..a2726be7 100644
--- a/src/i18n/locales/en/capture.json
+++ b/src/i18n/locales/en/capture.json
@@ -12,6 +12,7 @@
"actions": "Actions",
"action": "Action",
"recordingControls": "Recording controls",
+ "finishingRecording": "Finishing recording…",
"discardingRecording": "Discarding recording",
"recording": "Recording",
"discardRecording": "Discard recording",
diff --git a/src/i18n/locales/es/capture.json b/src/i18n/locales/es/capture.json
index 1c429510..5678110c 100644
--- a/src/i18n/locales/es/capture.json
+++ b/src/i18n/locales/es/capture.json
@@ -12,6 +12,7 @@
"actions": "Acciones",
"action": "Acción",
"recordingControls": "Controles de grabación",
+ "finishingRecording": "Finalizando la grabación…",
"discardingRecording": "Descartando grabación",
"recording": "Grabando",
"discardRecording": "Descartar grabación",
diff --git a/src/i18n/locales/fr/capture.json b/src/i18n/locales/fr/capture.json
index 49462a43..f5c7a093 100644
--- a/src/i18n/locales/fr/capture.json
+++ b/src/i18n/locales/fr/capture.json
@@ -12,6 +12,7 @@
"actions": "Actions",
"action": "Action",
"recordingControls": "Commandes d’enregistrement",
+ "finishingRecording": "Finalisation de l’enregistrement…",
"discardingRecording": "Suppression de l’enregistrement",
"recording": "Enregistrement en cours",
"discardRecording": "Supprimer l’enregistrement",
diff --git a/src/i18n/locales/it/capture.json b/src/i18n/locales/it/capture.json
index 942439cd..e5b7bbc3 100644
--- a/src/i18n/locales/it/capture.json
+++ b/src/i18n/locales/it/capture.json
@@ -12,6 +12,7 @@
"actions": "Azioni",
"action": "Azione",
"recordingControls": "Controlli di registrazione",
+ "finishingRecording": "Finalizzazione della registrazione…",
"discardingRecording": "Eliminazione della registrazione",
"recording": "Registrazione in corso",
"discardRecording": "Elimina registrazione",
diff --git a/src/i18n/locales/ja/capture.json b/src/i18n/locales/ja/capture.json
index 7eba427a..9024c755 100644
--- a/src/i18n/locales/ja/capture.json
+++ b/src/i18n/locales/ja/capture.json
@@ -12,6 +12,7 @@
"actions": "操作",
"action": "操作",
"recordingControls": "録画コントロール",
+ "finishingRecording": "録画を仕上げています…",
"discardingRecording": "録画を破棄中",
"recording": "録画中",
"discardRecording": "録画を破棄",
diff --git a/src/i18n/locales/ko/capture.json b/src/i18n/locales/ko/capture.json
index bf80ad49..d9ee2a89 100644
--- a/src/i18n/locales/ko/capture.json
+++ b/src/i18n/locales/ko/capture.json
@@ -12,6 +12,7 @@
"actions": "작업",
"action": "작업",
"recordingControls": "녹화 제어",
+ "finishingRecording": "녹화를 마무리하는 중…",
"discardingRecording": "녹화 삭제 중",
"recording": "녹화 중",
"discardRecording": "녹화 삭제",
diff --git a/src/i18n/locales/nl/capture.json b/src/i18n/locales/nl/capture.json
index f18830c4..e91f0b9b 100644
--- a/src/i18n/locales/nl/capture.json
+++ b/src/i18n/locales/nl/capture.json
@@ -12,6 +12,7 @@
"actions": "Acties",
"action": "Actie",
"recordingControls": "Opnamebediening",
+ "finishingRecording": "Opname afronden…",
"discardingRecording": "Opname wordt verwijderd",
"recording": "Opname bezig",
"discardRecording": "Opname verwijderen",
diff --git a/src/i18n/locales/pt-BR/capture.json b/src/i18n/locales/pt-BR/capture.json
index 5fd0d8d0..a705b085 100644
--- a/src/i18n/locales/pt-BR/capture.json
+++ b/src/i18n/locales/pt-BR/capture.json
@@ -12,6 +12,7 @@
"actions": "Ações",
"action": "Ação",
"recordingControls": "Controles de gravação",
+ "finishingRecording": "Finalizando a gravação…",
"discardingRecording": "Descartando gravação",
"recording": "Gravando",
"discardRecording": "Descartar gravação",
diff --git a/src/i18n/locales/zh-CN/capture.json b/src/i18n/locales/zh-CN/capture.json
index a497690c..79681ef5 100644
--- a/src/i18n/locales/zh-CN/capture.json
+++ b/src/i18n/locales/zh-CN/capture.json
@@ -12,6 +12,7 @@
"actions": "操作",
"action": "操作",
"recordingControls": "录制控制",
+ "finishingRecording": "正在完成录制…",
"discardingRecording": "正在放弃录制",
"recording": "正在录制",
"discardRecording": "放弃录制",
diff --git a/src/i18n/locales/zh-TW/capture.json b/src/i18n/locales/zh-TW/capture.json
index 39d26425..930f370d 100644
--- a/src/i18n/locales/zh-TW/capture.json
+++ b/src/i18n/locales/zh-TW/capture.json
@@ -12,6 +12,7 @@
"actions": "操作",
"action": "操作",
"recordingControls": "錄製控制",
+ "finishingRecording": "正在完成錄製…",
"discardingRecording": "正在捨棄錄製",
"recording": "錄製中",
"discardRecording": "捨棄錄製",
diff --git a/src/lib/exporter/modernExportPrepare.ts b/src/lib/exporter/modernExportPrepare.ts
index 00551876..fdd83956 100644
--- a/src/lib/exporter/modernExportPrepare.ts
+++ b/src/lib/exporter/modernExportPrepare.ts
@@ -56,9 +56,11 @@ export async function prepareExportAttempt(
session.native.nativeStaticLayoutBackgroundSkipReason = null;
session.sourceVideoInfo = null;
session.metrics.totalExportStartTimeMs = getNowMs();
- const backendPreference = control.nativeFailure
- ? "webcodecs"
- : (session.config.backendPreference ?? "auto");
+ // Forced software constrains routing too, including cloud and native fallbacks.
+ const backendPreference =
+ control.nativeFailure || session.config.forceSoftwareEncoding
+ ? "webcodecs"
+ : (session.config.backendPreference ?? "auto");
const runtimePlatform = hooks.getRuntimePlatform();
let useNativeEncoder = false;
let triedNativeStaticLayoutWithProbe = false;
@@ -79,8 +81,12 @@ export async function prepareExportAttempt(
reason: "windows-auto-static-layout-disabled",
});
}
- const cloudCompression = session.config.deliveryProfile === "cloud" && !control.nativeFailure;
+ const cloudCompression =
+ session.config.deliveryProfile === "cloud" &&
+ !control.nativeFailure &&
+ !session.config.forceSoftwareEncoding;
const shouldTryNativeStaticLayout =
+ !session.config.forceSoftwareEncoding &&
!cloudCompression &&
!control.nativeFailure &&
(backendPreference === "breeze" ||
diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts
index 5cdd2134..91913f60 100644
--- a/src/lib/exporter/modernVideoExporter.fallback.test.ts
+++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts
@@ -110,6 +110,134 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(initialize).toHaveBeenCalledTimes(scenario === "success" ? 0 : 1);
expect(exporterInternals(exporter).session.config.bitrate).toBe(6_000_000);
});
+ it.each(
+ [
+ {
+ name: "cloud",
+ platform: "MacIntel",
+ backendPreference: "webcodecs",
+ deliveryProfile: "cloud",
+ staticRoute: false,
+ },
+ {
+ name: "platform auto",
+ platform: "MacIntel",
+ backendPreference: "auto",
+ staticRoute: false,
+ },
+ {
+ name: "auto software escalation",
+ platform: "Linux x86_64",
+ backendPreference: "auto",
+ staticRoute: false,
+ },
+ {
+ name: "Breeze static",
+ platform: "Linux x86_64",
+ backendPreference: "breeze",
+ staticRoute: true,
+ },
+ {
+ name: "experimental static",
+ platform: "Linux x86_64",
+ backendPreference: "webcodecs",
+ experimentalNvidiaCudaExport: true,
+ staticRoute: true,
+ },
+ ].flatMap((scenario) =>
+ [undefined, false, true].map((forceSoftwareEncoding) => ({
+ ...scenario,
+ forceSoftwareEncoding,
+ })),
+ ),
+ )("respects forceSoftwareEncoding=$forceSoftwareEncoding across $name export routing", async ({
+ platform,
+ backendPreference,
+ deliveryProfile,
+ experimentalNvidiaCudaExport,
+ staticRoute,
+ forceSoftwareEncoding,
+ }) => {
+ vi.stubGlobal("navigator", { platform });
+ const exporter = new ModernVideoExporter({
+ videoUrl: "file:///recording.mp4",
+ width: 1920,
+ height: 1080,
+ frameRate: 30,
+ bitrate: 8_000_000,
+ wallpaper: "#000000",
+ backendPreference,
+ preferredRenderBackend: "webgpu",
+ deliveryProfile,
+ experimentalNvidiaCudaExport,
+ forceSoftwareEncoding,
+ } as never);
+ const operations = exporterInternals(exporter).operations;
+ const nativeStart = vi
+ .spyOn(operations, "tryStartNativeVideoExport")
+ .mockResolvedValue(true);
+ const staticLayout = vi
+ .spyOn(operations, "tryExportNativeStaticLayout")
+ .mockResolvedValue({ success: true, tempFilePath: "/tmp/static.mp4" });
+ vi.spyOn(operations, "loadNativeStaticLayoutVideoInfo").mockResolvedValue(mocks.videoInfo);
+ vi.spyOn(operations, "finishNativeVideoExport").mockResolvedValue({
+ success: true,
+ tempFilePath: "/tmp/native.mp4",
+ });
+ const initialize = vi
+ .spyOn(operations, "initializeEncoder")
+ .mockResolvedValue({ codec: "avc1.640033", hardwareAcceleration: "prefer-software" });
+ const result = await exporter.export();
+ expect(result.success).toBe(true);
+ if (forceSoftwareEncoding) {
+ expect(nativeStart).not.toHaveBeenCalled();
+ expect(staticLayout).not.toHaveBeenCalled();
+ expect(initialize).toHaveBeenCalledOnce();
+ const { FrameRenderer } = await import("./modernFrameRenderer");
+ expect(FrameRenderer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ width: 1920,
+ height: 1080,
+ preferredRenderBackend: "webgpu",
+ }),
+ );
+ } else {
+ expect(staticLayout).toHaveBeenCalledTimes(staticRoute ? 1 : 0);
+ expect(nativeStart).toHaveBeenCalledTimes(staticRoute ? 0 : 1);
+ }
+ expect(exporterInternals(exporter).session.config.bitrate).toBe(8_000_000);
+ });
+ it("fails forced software cleanly when software WebCodecs is unavailable without retrying native hardware", async () => {
+ vi.stubGlobal("navigator", { platform: "Linux x86_64" });
+ const exporter = new ModernVideoExporter({
+ videoUrl: "file:///recording.mp4",
+ width: 1920,
+ height: 1080,
+ frameRate: 30,
+ bitrate: 8_000_000,
+ wallpaper: "#000000",
+ backendPreference: "auto",
+ forceSoftwareEncoding: true,
+ } as never);
+ const operations = exporterInternals(exporter).operations;
+ const nativeStart = vi
+ .spyOn(operations, "tryStartNativeVideoExport")
+ .mockResolvedValue(true);
+ const staticLayout = vi.spyOn(operations, "tryExportNativeStaticLayout");
+ vi.spyOn(operations, "initializeEncoder").mockRejectedValue(
+ new Error("Software encoder unavailable"),
+ );
+ vi.spyOn(operations, "finishNativeVideoExport").mockResolvedValue({
+ success: true,
+ tempFilePath: "/tmp/native.mp4",
+ });
+ const result = await exporter.export();
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Software encoder unavailable");
+ expect(nativeStart).not.toHaveBeenCalled();
+ expect(staticLayout).not.toHaveBeenCalled();
+ });
+
it("removes failed native writes without creating an unhandled rejection", async () => {
const exporter = new ModernVideoExporter({} as never);
const write = Promise.reject(new Error("Native write failed"));
diff --git a/src/lib/wallpapers.test.ts b/src/lib/wallpapers.test.ts
index 215ac26f..726b17a0 100644
--- a/src/lib/wallpapers.test.ts
+++ b/src/lib/wallpapers.test.ts
@@ -98,3 +98,121 @@ describe("wallpapers", () => {
);
});
});
+
+const wallpaperHooks = vi.hoisted(() => ({
+ states: [] as unknown[],
+ index: 0,
+ effects: [] as (() => void)[],
+ prepare: vi.fn(async (file: File) => `data:image/png;base64,${file.name}`),
+ save: vi.fn(),
+}));
+vi.mock("react", async (original) => ({
+ ...(await original()),
+ useState: (initial: unknown) => {
+ const index = wallpaperHooks.index++;
+ if (!(index in wallpaperHooks.states))
+ wallpaperHooks.states[index] = typeof initial === "function" ? initial() : initial;
+ return [
+ wallpaperHooks.states[index],
+ (update: unknown) => {
+ wallpaperHooks.states[index] =
+ typeof update === "function" ? update(wallpaperHooks.states[index]) : update;
+ },
+ ];
+ },
+ useEffect: (effect: () => void) => wallpaperHooks.effects.push(effect),
+ useMemo: (factory: () => unknown) => factory(),
+ useRef: (value: unknown) => ({ current: value }),
+}));
+vi.mock("@/components/video-editor/editorPreferences", () => ({
+ DEFAULT_EDITOR_PREFERENCES: { wallpaper: "#000000", backgroundBlur: 0 },
+ loadEditorPreferences: () => ({ customWallpapers: [] }),
+ saveEditorPreferences: wallpaperHooks.save,
+}));
+vi.mock("@/components/video-editor/useWallpaperPreviews", () => ({
+ useWallpaperPreviews: () => ({ wallpapers: [], previews: {} }),
+}));
+vi.mock("@/components/video-editor/settings/useSettingsTranslations", () => ({
+ useSettingsTranslations: () => ({
+ te: (_key: string, fallback: string) => fallback,
+ tSettings: (key: string) => key,
+ }),
+}));
+vi.mock("@/lib/assetPath", () => ({ peekWallpaperThumbnailUrl: () => undefined }));
+vi.mock("@/lib/wallpaperUpload", async (original) => ({
+ ...(await original()),
+ prepareWallpaperImage: wallpaperHooks.prepare,
+}));
+vi.mock("@/components/ui/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+import { useBackgroundSettings } from "@/components/video-editor/settings/useBackgroundSettings";
+
+describe("uploaded wallpaper list", () => {
+ beforeEach(() => {
+ wallpaperHooks.states = [];
+ wallpaperHooks.index = 0;
+ wallpaperHooks.effects = [];
+ vi.clearAllMocks();
+ });
+ afterEach(() => vi.unstubAllGlobals());
+
+ function render(onWallpaperChange: (path: string) => void) {
+ wallpaperHooks.index = 0;
+ wallpaperHooks.effects = [];
+ // biome-ignore lint/correctness/useHookAtTopLevel: React hooks are intentionally mocked to exercise the upload callbacks and state commits.
+ const settings = useBackgroundSettings(
+ { selected: "#000000", onWallpaperChange },
+ { customWallpapers: ["older"] } as never,
+ true,
+ );
+ for (const effect of wallpaperHooks.effects) effect();
+ return settings;
+ }
+ it.each([
+ "image",
+ "video-file",
+ "video-picker",
+ ])("keeps repeated %s imports unique without reordering existing entries", async (kind) => {
+ const onWallpaperChange = vi.fn();
+ const suffix = kind === "image" ? "png" : "mp4";
+ const paths = ["first", "second", "first"].map((name) =>
+ kind === "image"
+ ? `data:image/png;base64,${name}.${suffix}`
+ : `/imports/${name}.${suffix}`,
+ );
+ const picker = vi
+ .fn()
+ .mockResolvedValueOnce({ success: true, path: paths[0] })
+ .mockResolvedValueOnce({ success: true, path: paths[1] })
+ .mockResolvedValueOnce({ success: true, path: paths[2] });
+ vi.stubGlobal("window", {
+ electronAPI: {
+ openVideoFilePicker: picker,
+ importWallpaperVideo: vi.fn(async (file: File) => ({
+ success: true,
+ path: `/imports/${file.name}`,
+ })),
+ },
+ });
+ let settings = render(onWallpaperChange);
+ for (const name of ["first", "second", "first"]) {
+ if (kind === "video-picker") await settings.handleVideoUpload();
+ else
+ await settings.handleImageUpload({
+ target: {
+ files: [
+ new File([name], `${name}.${suffix}`, {
+ type: kind === "image" ? "image/png" : "video/mp4",
+ }),
+ ],
+ value: "selected",
+ },
+ } as unknown as React.ChangeEvent);
+ settings = render(onWallpaperChange);
+ }
+ const expected =
+ kind === "image" ? ["older", paths[0], paths[1]] : [paths[1], paths[0], "older"];
+ expect(settings.customImages).toEqual(expected);
+ expect(wallpaperHooks.save).toHaveBeenLastCalledWith({ customWallpapers: expected });
+ expect(onWallpaperChange.mock.calls.map(([path]) => path)).toEqual(paths);
+ });
+});
diff --git a/tests/ui/capture-picker.spec.ts b/tests/ui/capture-picker.spec.ts
index f52ff74e..3a3322db 100644
--- a/tests/ui/capture-picker.spec.ts
+++ b/tests/ui/capture-picker.spec.ts
@@ -511,7 +511,7 @@ test("a queued selection can still be canceled with Escape", async ({ page }) =>
await page.mouse.click(1380, 200);
await expect(page.locator("html")).toHaveAttribute("data-queued", "true");
await page.keyboard.press("Escape");
- expect(await pick(page)).toBeNull();
+ await expect.poll(() => pick(page)).toBeNull();
});
test("a window expanding from another display keeps its frontmost hit-test rank", async ({
diff --git a/tests/ui/dashboard-settings.spec.ts b/tests/ui/dashboard-settings.spec.ts
index e753d13e..987d2802 100644
--- a/tests/ui/dashboard-settings.spec.ts
+++ b/tests/ui/dashboard-settings.spec.ts
@@ -82,6 +82,62 @@ for (const fails of [false, true]) {
});
}
+for (const failure of ["result", "error", "non-error"] as const) {
+ test(`HUD visibility read failure can retry in place (${failure})`, async ({ page }) => {
+ await installSettingsBridge(page);
+ await installDesktopBridgeOverrides(page, (failure) => {
+ let failing = true;
+ let reads = 0;
+ window.addEventListener("test-allow-hud-read", () => {
+ failing = false;
+ });
+ window.electronAPI.getHudOverlayCaptureProtection = async () => {
+ document.documentElement.dataset.hudProtectionReads = String(++reads);
+ if (failing) {
+ if (failure === "error") throw new Error("HUD bridge unavailable");
+ if (failure === "non-error") return Promise.reject("HUD bridge disconnected");
+ return { success: false, enabled: true };
+ }
+ return { success: true, enabled: false };
+ };
+ window.electronAPI.setHudOverlayCaptureProtection = async (enabled) => {
+ document.documentElement.dataset.hideHudRequested = String(enabled);
+ return { success: true, enabled };
+ };
+ }, failure);
+ const home = await openDashboard(page);
+ await home.getByRole("button", { name: "Recordly account" }).click();
+ await page.getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true }).click();
+ await home.getByRole("tab", { name: "Recording", exact: true }).click();
+ const panel = home.getByRole("tabpanel", { name: /^Recording settings/ });
+ const retry = panel.getByRole("button", { name: "Try again", exact: true });
+ await expect(panel.getByRole("alert")).toBeVisible();
+ await expect(retry).toBeVisible();
+ const failureText = failure === "result"
+ ? "Could not update capture protection"
+ : failure === "error" ? "HUD bridge unavailable" : "HUD bridge disconnected";
+ await panel.getByRole("button", { name: "Technical details", exact: true }).click();
+ await expect(panel.getByText(failureText, { exact: true })).toBeVisible();
+ await expect(panel.getByText(`Error: ${failureText}`, { exact: true })).toHaveCount(0);
+ const reads = Number(await page.locator("html").getAttribute("data-hud-protection-reads"));
+ const url = page.url();
+ await page.evaluate(() => window.dispatchEvent(new Event("test-allow-hud-read")));
+ await retry.click();
+ const toggle = panel.getByRole("switch", {
+ name: "Hide recording controls from captured video", exact: true,
+ });
+ await expect(toggle).toBeEnabled();
+ await expect(toggle).not.toBeChecked();
+ await expect(panel.getByRole("alert")).toHaveCount(0);
+ await expect(retry).toHaveCount(0);
+ await expect.poll(async () => Number(await page.locator("html")
+ .getAttribute("data-hud-protection-reads"))).toBeGreaterThan(reads);
+ await expect(page.locator("html")).not.toHaveAttribute("data-hide-hud-requested");
+ expect(page.url()).toBe(url);
+ });
+}
+
test("Settings uses compact themes and keyboard tabs, and persists appearance", async ({
page,
}) => {
diff --git a/tests/ui/videos-library.spec.ts b/tests/ui/videos-library.spec.ts
index 41883871..5ce1800a 100644
--- a/tests/ui/videos-library.spec.ts
+++ b/tests/ui/videos-library.spec.ts
@@ -126,7 +126,7 @@ test("Clips supports selection, remove all, undo and real timeline drag insertio
await expect(panel.locator("[data-recording-path]")).toHaveCount(2);
await expect(panel.getByRole("img", { name: "Preview of second.mp4" })).toBeVisible();
await page.screenshot({
- path: "test-results/videos-panel.png",
+ path: test.info().outputPath("videos-panel.png"),
animations: "disabled",
});
await page.getByRole("radio", { name: "Scene", exact: true }).click();
@@ -265,7 +265,7 @@ test("dashboard project list scrolls vertically with long names", async ({ page
await entry.last().scrollIntoViewIfNeeded();
await expect(entry.last()).toBeInViewport();
await page.screenshot({
- path: "test-results/dashboard-projects-vertical.png",
+ path: test.info().outputPath("dashboard-projects-vertical.png"),
});
});
@@ -355,7 +355,7 @@ test("Clips shows original filenames and truncates long names beside real still
.toBe(true);
expect(await panel.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
await page.screenshot({
- path: "test-results/videos-filenames.png",
+ path: test.info().outputPath("videos-filenames.png"),
animations: "disabled",
});
});
diff --git a/tests/ui/webcam-settings-flow.spec.ts b/tests/ui/webcam-settings-flow.spec.ts
index c89c63a9..5303ffd8 100644
--- a/tests/ui/webcam-settings-flow.spec.ts
+++ b/tests/ui/webcam-settings-flow.spec.ts
@@ -50,11 +50,23 @@ test("webcam import, anchor placement, precise adjustment and removal stay disco
"true",
);
await expect(page.getByRole("switch", { name: "Custom position", exact: true })).toHaveCount(0);
+ const topRight = page.getByRole("row", { name: "Top right", exact: true });
+ await topRight.click();
+ await expect(topRight).toHaveAttribute("aria-selected", "true");
+ const beforeAdjustment = (await camera.boundingBox())!;
+ const preview = (await page.locator("[data-preview-overlay]").boundingBox())!;
+ const horizontalTravel = preview.width - beforeAdjustment.width;
+ expect(horizontalTravel).toBeGreaterThan(0);
const horizontal = page.getByRole("slider", { name: "Horizontal", exact: true });
await horizontal.focus();
await horizontal.press("Home");
await horizontal.press("ArrowRight");
await expect(horizontal).toHaveValue("1");
+ // Manual position must move the rendered camera across the available preview,
+ // rather than merely updating the slider or hiding a redundant label.
+ await expect
+ .poll(async () => beforeAdjustment.x - (await camera.boundingBox())!.x)
+ .toBeGreaterThan(horizontalTravel / 2);
await expect(sidebar.getByText("Custom position", { exact: true })).toHaveCount(0);
await page.getByRole("row", { name: "Center", exact: true }).click();
await expect(horizontal).toHaveValue("50");