Merge remote-tracking branch 'refs/remotes/origin/pr/681' into feat/export-caption-sidecars

# Conflicts:
#	electron/ipc/register/export.ts
#	src/components/video-editor/VideoEditor.tsx
This commit is contained in:
webadderall
2026-06-19 12:08:16 +10:00
17 changed files with 558 additions and 48 deletions
+38 -8
View File
@@ -5,6 +5,12 @@ import path from "node:path";
import type { Readable, Writable } from "node:stream";
import type { SaveDialogOptions } from "electron";
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import {
parseCaptionSidecarPayload,
type CaptionSidecarPayload,
withCaptionSidecarMessage,
writeCaptionSidecarsBestEffort,
} from "./exportCaptionSidecars";
import {
closeExportStream,
isOwnedExportPath,
@@ -987,13 +993,19 @@ export function registerExportHandlers() {
}
await fs.writeFile(result.filePath, Buffer.from(videoData));
await writeCaptionSidecars(result.filePath, sidecarPayload);
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
result.filePath,
sidecarPayload,
);
approveUserPath(result.filePath);
return {
success: true,
path: result.filePath,
message: "Video exported successfully",
message: withCaptionSidecarMessage(
"Video exported successfully",
captionSidecarResult,
),
};
} catch (error) {
console.error("Failed to save exported video:", error);
@@ -1029,13 +1041,19 @@ export function registerExportHandlers() {
const resolvedPath = path.resolve(outputPath);
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, Buffer.from(videoData));
await writeCaptionSidecars(resolvedPath, sidecarPayload);
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
resolvedPath,
sidecarPayload,
);
approveUserPath(resolvedPath);
return {
success: true,
path: resolvedPath,
message: "Video exported successfully",
message: withCaptionSidecarMessage(
"Video exported successfully",
captionSidecarResult,
),
canceled: false,
};
} catch (error) {
@@ -1088,14 +1106,20 @@ export function registerExportHandlers() {
if (payload.outputPath) {
const resolvedPath = path.resolve(payload.outputPath);
await moveExportedTempFile(tempPath, resolvedPath);
await writeCaptionSidecars(resolvedPath, sidecarPayload);
releaseOwnedExportPath(tempPath);
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
resolvedPath,
sidecarPayload,
);
approveUserPath(resolvedPath);
return {
success: true,
path: resolvedPath,
canceled: false,
message: "Video exported successfully",
message: withCaptionSidecarMessage(
"Video exported successfully",
captionSidecarResult,
),
};
}
@@ -1126,15 +1150,21 @@ export function registerExportHandlers() {
}
await moveExportedTempFile(tempPath, result.filePath);
await writeCaptionSidecars(result.filePath, sidecarPayload);
releaseOwnedExportPath(tempPath);
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
result.filePath,
sidecarPayload,
);
approveUserPath(result.filePath);
return {
success: true,
path: result.filePath,
canceled: false,
message: "Video exported successfully",
message: withCaptionSidecarMessage(
"Video exported successfully",
captionSidecarResult,
),
};
} catch (error) {
console.error("Failed to finalize exported video:", error);
@@ -0,0 +1,112 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
parseCaptionSidecarPayload,
serializeSrt,
serializeVtt,
withCaptionSidecarMessage,
writeCaptionSidecarsBestEffort,
} from "./exportCaptionSidecars";
describe("exportCaptionSidecars", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("serializes SRT cues with stable numbering and timestamps", () => {
expect(
serializeSrt([
{
startMs: 1234,
endMs: 5678,
text: "Hello\nworld",
},
]),
).toBe("1\n00:00:01,234 --> 00:00:05,678\nHello\nworld");
});
it("serializes VTT cues with header and dot timestamps", () => {
expect(
serializeVtt([
{
startMs: 0,
endMs: 2000,
text: "Caption",
},
]),
).toBe("WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nCaption");
});
it("drops malformed cues when parsing sidecar payloads", () => {
expect(
parseCaptionSidecarPayload({
format: "both",
cues: [
{ startMs: 0, endMs: 1000, text: "ok" },
{ startMs: 2000, endMs: 1000, text: "bad range" },
{ startMs: 1000, endMs: 2000, text: " " },
],
}),
).toEqual({
format: "both",
cues: [{ startMs: 0, endMs: 1000, text: "ok" }],
});
});
it("returns a warning result instead of throwing when sidecar writes fail", async () => {
const writeFileSpy = vi.spyOn(fs, "writeFile").mockRejectedValueOnce(new Error("disk full"));
await expect(
writeCaptionSidecarsBestEffort("/tmp/export.mp4", {
format: "srt",
cues: [{ startMs: 0, endMs: 1000, text: "Caption" }],
}),
).resolves.toEqual({
wroteAny: false,
error: "disk full",
});
expect(writeFileSpy).toHaveBeenCalledTimes(1);
});
it("writes requested caption sidecars when the filesystem succeeds", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-sidecar-test-"));
const videoPath = path.join(tempDir, "clip.mp4");
try {
await expect(
writeCaptionSidecarsBestEffort(videoPath, {
format: "both",
cues: [{ startMs: 0, endMs: 1000, text: "Caption" }],
}),
).resolves.toEqual({ wroteAny: true, error: null });
await expect(fs.readFile(path.join(tempDir, "clip.srt"), "utf8")).resolves.toContain(
"00:00:00,000 --> 00:00:01,000",
);
await expect(fs.readFile(path.join(tempDir, "clip.vtt"), "utf8")).resolves.toContain(
"WEBVTT",
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("appends a non-fatal caption warning only when sidecar writes fail", () => {
expect(
withCaptionSidecarMessage("Video exported successfully", {
wroteAny: false,
error: "disk full",
}),
).toBe("Video exported successfully Captions could not be saved alongside the video.");
expect(
withCaptionSidecarMessage("Video exported successfully", {
wroteAny: true,
error: null,
}),
).toBe("Video exported successfully");
});
});
@@ -0,0 +1,157 @@
import fs from "node:fs/promises";
import path from "node:path";
export type CaptionSidecarCue = {
startMs: number;
endMs: number;
text: string;
};
export type CaptionSidecarPayload = {
format: "srt" | "vtt" | "both";
cues: CaptionSidecarCue[];
};
export type CaptionSidecarWriteResult = {
wroteAny: boolean;
error: string | null;
};
function toSrtTimestamp(totalMs: number): string {
const ms = Math.max(0, Math.round(totalMs));
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`;
}
function toVttTimestamp(totalMs: number): string {
const ms = Math.max(0, Math.round(totalMs));
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
}
function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] {
if (!Array.isArray(cues)) {
return [];
}
return cues
.filter((cue): cue is CaptionSidecarCue => {
return (
typeof cue === "object" &&
cue !== null &&
typeof cue.startMs === "number" &&
typeof cue.endMs === "number" &&
typeof cue.text === "string" &&
Number.isFinite(cue.startMs) &&
Number.isFinite(cue.endMs) &&
cue.endMs > cue.startMs &&
cue.text.trim().length > 0
);
})
.map((cue) => ({
startMs: cue.startMs,
endMs: cue.endMs,
text: cue.text.replace(/\r\n/g, "\n").trim(),
}));
}
export function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null {
if (typeof payload !== "object" || payload === null) {
return null;
}
const candidate = payload as {
format?: unknown;
cues?: unknown;
};
const format =
candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both"
? candidate.format
: null;
if (!format) {
return null;
}
const cues = normalizeCaptionSidecarCues(candidate.cues);
if (cues.length === 0) {
return null;
}
return { format, cues };
}
export function serializeSrt(cues: CaptionSidecarCue[]): string {
return cues
.map((cue, index) => {
return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`;
})
.join("\n\n");
}
export function serializeVtt(cues: CaptionSidecarCue[]): string {
const body = cues
.map((cue) => {
return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`;
})
.join("\n\n");
return `WEBVTT\n\n${body}`;
}
export async function writeCaptionSidecars(
videoPath: string,
payload: CaptionSidecarPayload | null,
) {
if (!payload) {
return;
}
const parsed = path.parse(videoPath);
const basePath = path.join(parsed.dir, parsed.name);
if (payload.format === "srt" || payload.format === "both") {
await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8");
}
if (payload.format === "vtt" || payload.format === "both") {
await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8");
}
}
export async function writeCaptionSidecarsBestEffort(
videoPath: string,
payload: CaptionSidecarPayload | null,
): Promise<CaptionSidecarWriteResult> {
if (!payload) {
return { wroteAny: false, error: null };
}
try {
await writeCaptionSidecars(videoPath, payload);
return { wroteAny: true, error: null };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[export] Failed to write caption sidecars:", {
videoPath,
message,
});
return { wroteAny: false, error: message };
}
}
export function withCaptionSidecarMessage(
baseMessage: string,
captionSidecarResult: CaptionSidecarWriteResult,
) {
if (!captionSidecarResult.error) {
return baseMessage;
}
return `${baseMessage} Captions could not be saved alongside the video.`;
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
getScreenSourceIdForDisplay,
LINUX_PORTAL_SCREEN_SOURCE_ID,
} from "./sourceMapping";
describe("getScreenSourceIdForDisplay", () => {
it("keeps the live Electron screen source when one is available", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
matchedSourceId: "screen:42:0",
platform: "linux",
}),
).toBe("screen:42:0");
});
it("routes unmatched Linux Wayland screens through the portal sentinel", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" },
matchedSourceId: null,
platform: "linux",
}),
).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID);
});
it("keeps unmatched Linux X11 screens on the explicit fallback id", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" },
matchedSourceId: null,
platform: "linux",
}),
).toBe("screen:fallback:42");
});
it("keeps non-Linux unmatched screens on the explicit fallback id", () => {
expect(
getScreenSourceIdForDisplay({
displayId: "42",
matchedSourceId: undefined,
platform: "win32",
}),
).toBe("screen:fallback:42");
});
});
+35
View File
@@ -0,0 +1,35 @@
export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal";
export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) {
const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase();
if (sessionType === "wayland") {
return true;
}
if (sessionType === "x11") {
return false;
}
return Boolean(env.WAYLAND_DISPLAY);
}
export function getScreenSourceIdForDisplay({
displayId,
env = process.env,
matchedSourceId,
platform,
}: {
displayId: string;
env?: NodeJS.ProcessEnv;
matchedSourceId?: string | null;
platform: NodeJS.Platform | string;
}) {
if (matchedSourceId) {
return matchedSourceId;
}
if (platform === "linux" && isLikelyLinuxWaylandSession(env)) {
return LINUX_PORTAL_SCREEN_SOURCE_ID;
}
return `screen:fallback:${displayId}`;
}
+7 -1
View File
@@ -6,6 +6,7 @@ import { selectedSource, setSelectedSource } from "../state";
import type { SelectedSource } from "../types";
import { getScreen, parseWindowId } from "../utils";
import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg";
import { getScreenSourceIdForDisplay } from "./sourceMapping";
import {
getNativeMacWindowSources,
resolveMacWindowBounds,
@@ -125,7 +126,12 @@ export function registerSourceHandlers({
: `Screen ${index + 1}`;
return {
id: matchedSource?.id ?? `screen:fallback:${displayId}`,
id: getScreenSourceIdForDisplay({
displayId,
env: process.env,
matchedSourceId: matchedSource?.id,
platform: process.platform,
}),
name: displayName,
originalName: matchedSource?.name ?? displayName,
display_id: displayId,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "recordly",
"version": "1.3.3",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recordly",
"version": "1.3.3",
"version": "1.3.4",
"hasInstallScript": true,
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
+1 -1
View File
@@ -12,7 +12,7 @@
"url": "https://github.com/webadderallorg/Recordly/issues"
},
"private": true,
"version": "1.3.3",
"version": "1.3.4",
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts",
@@ -73,6 +73,7 @@ import type {
ZoomTransitionEasing,
} from "./types";
import {
ADVANCED_VERTICAL_PADDING_MAX,
DEFAULT_AUTO_CAPTION_SETTINGS,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_CLICK_BOUNCE,
@@ -2413,7 +2414,7 @@ export function SettingsPanel({
value={padding.top}
defaultValue={DEFAULT_PADDING.top}
min={0}
max={100}
max={ADVANCED_VERTICAL_PADDING_MAX}
step={1}
onChange={(v) => handlePaddingSideChange("top", v)}
formatValue={(v) => `${v}%`}
@@ -2424,7 +2425,7 @@ export function SettingsPanel({
value={padding.bottom}
defaultValue={DEFAULT_PADDING.bottom}
min={0}
max={100}
max={ADVANCED_VERTICAL_PADDING_MAX}
step={1}
onChange={(v) => handlePaddingSideChange("bottom", v)}
formatValue={(v) => `${v}%`}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { normalizeProjectEditor } from "./projectPersistence";
import { ADVANCED_VERTICAL_PADDING_MAX } from "./types";
describe("normalizeProjectEditor", () => {
it("preserves the extended advanced vertical padding range", () => {
const editor = normalizeProjectEditor({
padding: {
top: 240,
bottom: ADVANCED_VERTICAL_PADDING_MAX,
left: 22,
right: 22,
linked: false,
},
});
expect(editor.padding).toMatchObject({
top: 240,
bottom: ADVANCED_VERTICAL_PADDING_MAX,
left: 22,
right: 22,
linked: false,
});
});
it("keeps linked padding clamped to the original range", () => {
const editor = normalizeProjectEditor({
padding: {
top: ADVANCED_VERTICAL_PADDING_MAX,
bottom: ADVANCED_VERTICAL_PADDING_MAX,
left: ADVANCED_VERTICAL_PADDING_MAX,
right: ADVANCED_VERTICAL_PADDING_MAX,
linked: true,
},
});
expect(editor.padding).toMatchObject({
top: 100,
bottom: 100,
left: 100,
right: 100,
linked: true,
});
});
});
@@ -22,6 +22,7 @@ import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets";
import {
ADVANCED_VERTICAL_PADDING_MAX,
type AnnotationRegion,
type AudioRegion,
type AutoCaptionAnimation,
@@ -950,14 +951,17 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const p = editor.padding;
if (p && typeof p === "object") {
const linked = typeof p.linked === "boolean" ? p.linked : true;
const top = isFiniteNumber(p.top) ? clamp(p.top, 0, 100) : DEFAULT_PADDING.top;
const verticalMax = linked ? 100 : ADVANCED_VERTICAL_PADDING_MAX;
const top = isFiniteNumber(p.top)
? clamp(p.top, 0, verticalMax)
: DEFAULT_PADDING.top;
if (linked) {
return { top, bottom: top, left: top, right: top, linked: true };
}
return {
top,
bottom: isFiniteNumber(p.bottom)
? clamp(p.bottom, 0, 100)
? clamp(p.bottom, 0, verticalMax)
: DEFAULT_PADDING.bottom,
left: isFiniteNumber(p.left) ? clamp(p.left, 0, 100) : DEFAULT_PADDING.left,
right: isFiniteNumber(p.right) ? clamp(p.right, 0, 100) : DEFAULT_PADDING.right,
+2
View File
@@ -497,6 +497,8 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
export const ADVANCED_VERTICAL_PADDING_MAX = 250;
export interface Padding {
top: number;
bottom: number;
@@ -1,5 +1,59 @@
import { describe, expect, it } from "vitest";
import { scalePreviewBorderRadius } from "./layoutUtils";
import { ADVANCED_VERTICAL_PADDING_MAX } from "../types";
import { computePaddedLayout, scalePreviewBorderRadius } from "./layoutUtils";
const BASE_LAYOUT_PARAMS = {
width: 1000,
height: 1000,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
videoWidth: 1000,
videoHeight: 1000,
};
describe("computePaddedLayout", () => {
it("allows advanced bottom padding to pin the video to the top edge", () => {
const layout = computePaddedLayout({
...BASE_LAYOUT_PARAMS,
padding: {
top: 0,
bottom: ADVANCED_VERTICAL_PADDING_MAX,
left: 0,
right: 0,
linked: false,
},
});
expect(layout.centerOffsetY).toBeCloseTo(0);
});
it("allows advanced top padding to pin the video to the bottom edge", () => {
const layout = computePaddedLayout({
...BASE_LAYOUT_PARAMS,
padding: {
top: ADVANCED_VERTICAL_PADDING_MAX,
bottom: 0,
left: 0,
right: 0,
linked: false,
},
});
expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo(
BASE_LAYOUT_PARAMS.height,
);
});
it("preserves linked padding centering behavior", () => {
const layout = computePaddedLayout({
...BASE_LAYOUT_PARAMS,
padding: { top: 20, bottom: 20, left: 20, right: 20, linked: true },
});
expect(layout.centerOffsetY).toBeCloseTo(40);
expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo(960);
});
});
describe("scalePreviewBorderRadius", () => {
it("matches export scaling against the logical preview size", () => {
@@ -13,4 +67,4 @@ describe("scalePreviewBorderRadius", () => {
expect(scalePreviewBorderRadius(960, 0, 16)).toBe(0);
expect(scalePreviewBorderRadius(960, 540, -8)).toBe(0);
});
});
});
@@ -1,16 +1,12 @@
import { Application, Graphics, Sprite } from "pixi.js";
import { drawSquircleOnGraphics } from "@/lib/geometry/squircle";
import type { CropRegion, Padding } from "../types";
import { ADVANCED_VERTICAL_PADDING_MAX, type CropRegion, type Padding } from "../types";
export const PADDING_SCALE_FACTOR = 0.2;
export const BASE_PREVIEW_WIDTH = 1920;
export const BASE_PREVIEW_HEIGHT = 1080;
export function scalePreviewBorderRadius(
width: number,
height: number,
borderRadius = 0,
): number {
export function scalePreviewBorderRadius(width: number, height: number, borderRadius = 0): number {
if (width <= 0 || height <= 0) {
return 0;
}
@@ -23,12 +19,7 @@ export function isZeroPadding(padding: Padding | number): boolean {
if (typeof padding === "number") {
return padding === 0;
}
return (
padding.top === 0 &&
padding.bottom === 0 &&
padding.left === 0 &&
padding.right === 0
);
return padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0;
}
export interface PaddedLayoutResult {
@@ -64,13 +55,21 @@ export function computePaddedLayout(params: {
? { top: padding, bottom: padding, left: padding, right: padding }
: padding;
// Padding is a percentage (0-100)
// Clamp to ensure we don't have overlapping padding that exceeds 100% of a dimension
const clampPercent = (v: number) => Math.min(100, Math.max(0, v));
const leftPadFrac = (clampPercent(p.left) / 100) * PADDING_SCALE_FACTOR;
const rightPadFrac = (clampPercent(p.right) / 100) * PADDING_SCALE_FACTOR;
const topPadFrac = (clampPercent(p.top) / 100) * PADDING_SCALE_FACTOR;
const bottomPadFrac = (clampPercent(p.bottom) / 100) * PADDING_SCALE_FACTOR;
// Padding is a percentage. Linked padding keeps the original 0-100 scaling
// behavior; advanced vertical padding gets extra range for positioning.
const isAdvancedPadding = typeof padding !== "number" && padding.linked === false;
const clampPercent = (v: number, max = 100) => Math.min(max, Math.max(0, v));
const leftPercent = clampPercent(p.left);
const rightPercent = clampPercent(p.right);
const topPercent = clampPercent(p.top, isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100);
const bottomPercent = clampPercent(
p.bottom,
isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100,
);
const leftPadFrac = (leftPercent / 100) * PADDING_SCALE_FACTOR;
const rightPadFrac = (rightPercent / 100) * PADDING_SCALE_FACTOR;
const topPadFrac = (Math.min(topPercent, 100) / 100) * PADDING_SCALE_FACTOR;
const bottomPadFrac = (Math.min(bottomPercent, 100) / 100) * PADDING_SCALE_FACTOR;
const availableFracW = Math.max(0, 1.0 - leftPadFrac - rightPadFrac);
const availableFracH = Math.max(0, 1.0 - topPadFrac - bottomPadFrac);
@@ -103,17 +102,24 @@ export function computePaddedLayout(params: {
const fullFrameDisplayH = fullFrameVideoH * scale;
const availableCenterX = leftPadFrac * width + maxDisplayWidth / 2;
const availableCenterY = topPadFrac * height + maxDisplayHeight / 2;
const availableCenterY = isAdvancedPadding
? (() => {
const verticalTravel = Math.max(0, height - fullFrameDisplayH);
const centeredOffsetY = verticalTravel / 2;
const directionalOffsetY =
centeredOffsetY +
((topPercent - bottomPercent) / ADVANCED_VERTICAL_PADDING_MAX) *
centeredOffsetY;
const frameOffsetY = Math.min(verticalTravel, Math.max(0, directionalOffsetY));
return frameOffsetY + fullFrameDisplayH / 2;
})()
: topPadFrac * height + maxDisplayHeight / 2;
const frameCenterX = availableCenterX - fullFrameDisplayW / 2;
const frameCenterY = availableCenterY - fullFrameDisplayH / 2;
const centerOffsetX = insets
? frameCenterX + insets.left * fullFrameDisplayW
: frameCenterX;
const centerOffsetY = insets
? frameCenterY + insets.top * fullFrameDisplayH
: frameCenterY;
const centerOffsetX = insets ? frameCenterX + insets.left * fullFrameDisplayW : frameCenterX;
const centerOffsetY = insets ? frameCenterY + insets.top * fullFrameDisplayH : frameCenterY;
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
@@ -1,6 +1,6 @@
import macosClosedHandUrl from "../../../assets/cursors/macos/closedhand-1__50-50.svg";
import macosCrosshairUrl from "../../../assets/cursors/macos/crosshair-1__50-50.svg";
import macosTextUrl from "../../../assets/cursors/macos/ibeam-1__50-50.svg";
import macosTextUrl from "../../../assets/cursors/macos/ibeamstroke-1__50-50.svg";
import macosNotAllowedUrl from "../../../assets/cursors/macos/notallowed-1__23-0.svg";
import macosOpenHandUrl from "../../../assets/cursors/macos/openhand-1__50-50.svg";
import macosArrowUrl from "../../../assets/cursors/macos/pointer-1__34-24.svg";
+6 -2
View File
@@ -164,8 +164,12 @@ describe("shouldUseNativeWindowsCaptureForSource", () => {
expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true);
});
it("routes window sources through browser capture", () => {
expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(false);
it("keeps native Windows capture on window sources", () => {
expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(true);
});
it("keeps browser capture for non-desktop sources", () => {
expect(shouldUseNativeWindowsCaptureForSource({ id: "browser-tab:abc" })).toBe(false);
});
});
+4 -1
View File
@@ -213,7 +213,10 @@ export function resolveBrowserCaptureCursorPolicy({
export function shouldUseNativeWindowsCaptureForSource(
source: Pick<ProcessedDesktopSource, "id"> | null | undefined,
): boolean {
return source?.id?.startsWith("screen:") === true;
return (
source?.id?.startsWith("screen:") === true ||
source?.id?.startsWith("window:") === true
);
}
export function createProcessedMicrophoneConstraints(