diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 1223efee..ceb53dbf 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -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); diff --git a/electron/ipc/register/exportCaptionSidecars.test.ts b/electron/ipc/register/exportCaptionSidecars.test.ts new file mode 100644 index 00000000..6839e959 --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/electron/ipc/register/exportCaptionSidecars.ts b/electron/ipc/register/exportCaptionSidecars.ts new file mode 100644 index 00000000..6711d3bb --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.ts @@ -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 { + 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.`; +} \ No newline at end of file diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts new file mode 100644 index 00000000..d0b68e75 --- /dev/null +++ b/electron/ipc/register/sourceMapping.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts new file mode 100644 index 00000000..a61b4cf7 --- /dev/null +++ b/electron/ipc/register/sourceMapping.ts @@ -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}`; +} \ No newline at end of file diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 4ba6b35a..33c9ee74 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -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, diff --git a/package-lock.json b/package-lock.json index a96759f5..a255b232 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7d6c1285..6357c666 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index fce5f2b9..41167dce 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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}%`} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts new file mode 100644 index 00000000..575c3b67 --- /dev/null +++ b/src/components/video-editor/projectPersistence.test.ts @@ -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, + }); + }); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 704371a6..1d4be3d8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -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): 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, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9bc401d5..91d67624 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -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; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts index 39d21f03..a93db03f 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.test.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.test.ts @@ -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); }); -}); \ No newline at end of file +}); diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 3cce0cd3..b93ef19d 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -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; diff --git a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts index 38faffba..d017d2d7 100644 --- a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts +++ b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts @@ -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"; diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 85811467..d74e884c 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -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); }); }); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index a382ea3d..c5cd7005 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -213,7 +213,10 @@ export function resolveBrowserCaptureCursorPolicy({ export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { - return source?.id?.startsWith("screen:") === true; + return ( + source?.id?.startsWith("screen:") === true || + source?.id?.startsWith("window:") === true + ); } export function createProcessedMicrophoneConstraints(