diff --git a/docs/pr/screenshot-and-pin/editor.png b/docs/pr/screenshot-and-pin/editor.png new file mode 100644 index 000000000..da076357b Binary files /dev/null and b/docs/pr/screenshot-and-pin/editor.png differ diff --git a/docs/pr/screenshot-and-pin/native-pin.png b/docs/pr/screenshot-and-pin/native-pin.png new file mode 100644 index 000000000..ced737e8c Binary files /dev/null and b/docs/pr/screenshot-and-pin/native-pin.png differ diff --git a/docs/screenshot-and-pin.md b/docs/screenshot-and-pin.md new file mode 100644 index 000000000..b2f031142 --- /dev/null +++ b/docs/screenshot-and-pin.md @@ -0,0 +1,90 @@ +# Screenshots, annotations, and desktop pins + +Implemented on the fork's `feat/screenshot-and-pin` branch, building on the global +controls branch used by [upstream PR #1137](https://github.com/webadderallorg/Recordly/pull/1137). +This is a screenshot workflow inside Recordly, with its own implementation. It does +not embed Snipaste or PixPin code, and it is not a complete replacement for either. + +## Use + +Run the changed application and click **Screenshot** on the idle recording HUD, +or press **Ctrl+Alt+S** on Windows / **Cmd+Option+S** on macOS. Customize or disable +the binding in **Home → Settings → Recording → Global recording shortcuts**. +Saved recording bindings are retained when the new action is introduced. + +The app freezes each display before showing the shared capture picker. Drag an +area, adjust its handles, and press **Enter** or **Capture screenshot**. Clicking +a window selects its visible screen rectangle; this does not reveal obscured or +off-screen window contents. **Escape** or right-click cancels. The recording +source remains unchanged, and no video recording starts. + +In the screenshot window: + +- Draw rectangles, arrows, mosaic regions, or numbered markers. +- Enter a HEX or RGB color, e.g. `#ff4545` or `rgb(255,69,69)`. +- Choose **Text**, enter a label, and click to place it. The Text field can edit + the most recently placed text until another tool is selected or history changes. +- Use **Undo / Redo**, or **Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z**. +- **Copy image**, **Save PNG**, and **Pin to desktop** all use the same flattened, + annotated PNG at the captured pixel dimensions. Copy/save also have + **Ctrl/Cmd+C / Ctrl/Cmd+S** shortcuts outside text inputs. + +Pins remain above ordinary application windows. Move a pin by its title strip, +scroll over the image to resize it, adjust the opacity slider, or close it with +**× / Escape**. Each pin owns its image independently, so closing the annotation +window does not close its pins. Pin copy/save preserves the image pixels; window +size and opacity do not change the exported image. + +## Capture and privacy boundaries + +Screenshot capture currently supports Windows and macOS. Linux is explicitly +unsupported by this screenshot path; its existing recording picker is unchanged. +The screenshot picker supports rectangles on Windows even though the existing +Windows *video recording* picker still limits selection to screens/windows. + +Each selection is restricted to one display. The image is captured at that display's +requested physical resolution; a capturer returning another resolution produces +an error instead of silently upscaling a thumbnail. Crop coordinates convert from +that display's DIP bounds to its PNG pixel bounds, including negative origins and +fractional scale factors. Display layouts changing during selection are not yet +covered by native acceptance. + +The HUD is hidden before freezing the screen. Selection handles and annotation +controls are drawn afterward and are not part of the captured pixels. Protected +content can remain absent according to the OS capture policy. Screenshots and pins +remain in memory until explicitly saved; there is no screenshot history, disk +recovery, upload, OCR, or restart restoration. Closing an unsaved window or quitting +Recordly discards its in-memory image. + +This change does **not** implement scrolling screenshots, clipboard-to-pin import, +arbitrary editing/moving of every existing annotation, a color eyedropper, or the +unified screenshot/GIF/MP4 action toolbar. Those remain separate product work. + +## Validation + +- Browser coverage: frozen Windows rectangle selection, negative display origin, + annotation editing, arrow/number/mosaic, invalid colors, pixel-preserving undo and + redo, matching PNGs across copy/save/pin, and pin opacity/zoom/close callbacks. +- Native IPC coverage: physical crop bounds, caller ownership, cancellation, + resolution errors and retry, annotated copy/save/pin, independent pin lifetime, + save cancellation, and refusal during recording/another selection. +- **Windows native run:** the real Electron desktop capturer and real picker UI + cropped a controlled test window into a **300 × 150 PNG**, at display scale 1. + The center BGRA pixel matched the test surface `[71,71,211,255]`. The save-dialog + choice was automated; the PNG file write and image decode were real. +- The same native run copied an image to the system clipboard and read back its + dimensions, then restored the previous supported clipboard formats. It also + verified a real pin's always-on-top property, native opacity, wheel-driven size + change, and window destruction through the close control. +- Native tests used an isolated user-data directory and a synthetic test surface; + no installed application, user recordings, or user settings were replaced. + +The screenshot editor and pin images below use synthetic content. + +![Screenshot annotations](pr/screenshot-and-pin/editor.png) + +![Windows native pinned screenshot](pr/screenshot-and-pin/native-pin.png) + +Real macOS capture/permission behavior, mixed-DPI native multi-monitor layouts, +native drag movement, and UU Remote delivery remain manual acceptance items. +Unit coordinate tests and browser bridge tests do not establish those outcomes. diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fe6c4b27e..2996ad09e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -251,6 +251,26 @@ interface Window { message?: string; }>; completeCapturePick: (pick: CapturePick | null) => Promise; + startScreenshot: () => Promise; + getScreenshotContext: (displayId: number) => Promise<{ + displayBounds: import("../src/lib/screenshot").ScreenshotRect; + allowArea: boolean; + lastArea: null; + windows: CapturePickerWindow[]; + cursor: { x: number; y: number }; + background: string; + }>; + completeScreenshotPick: ( + pick: { + displayId: number; + rect: import("../src/lib/screenshot").ScreenshotRect; + } | null, + ) => Promise; + getScreenshotDocument: () => Promise; + screenshotAction: ( + action: "copy" | "save" | "pin" | "close" | "opacity" | "zoom", + value?: string | number, + ) => Promise; getCapturePickerContext: (displayId: number) => Promise<{ displayBounds: CaptureArea | null; lastArea: CaptureArea | null; @@ -947,6 +967,20 @@ interface Window { enabled: boolean; error?: string; }>; + /** enabled: stored preference; active: mode this process started with (switches are startup-only). */ + getLightweightMode: () => Promise<{ enabled: boolean; active: boolean }>; + setLightweightMode: (enabled: boolean) => Promise<{ + success: boolean; + enabled: boolean; + error?: string; + }>; + /** Finishes a GIF-mode capture (convert + save) instead of opening the editor. */ + completeGifCapture: (videoPath: string) => Promise<{ handled: boolean }>; + /** Whether the next recording opens the editor or is saved straight to GIF. */ + getCaptureOutputMode: () => Promise<"editor" | "gif">; + /** HUD-only; other senders get the current mode back unchanged. */ + setCaptureOutputMode: (mode: "editor" | "gif") => Promise<"editor" | "gif">; + onCaptureOutputModeChanged: (callback: (mode: "editor" | "gif") => void) => () => void; previewUpdateToast: () => Promise<{ success: boolean }>; checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>; onUpdateToastStateChanged: ( diff --git a/electron/gifCapture.test.ts b/electron/gifCapture.test.ts new file mode 100644 index 000000000..4af434d06 --- /dev/null +++ b/electron/gifCapture.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./ipc/ffmpeg/binary", () => ({ getFfmpegBinaryPath: () => "ffmpeg" })); + +import { buildGifConversionArgs, GIF_FRAME_RATE, GIF_MAX_WIDTH } from "./gifCapture"; + +describe("buildGifConversionArgs", () => { + it("converts the whole clip to a looping, silent, palette-optimized GIF", () => { + const args = buildGifConversionArgs("/rec/in.mp4", "/out/clip.gif"); + expect(args.slice(args.indexOf("-i"), args.indexOf("-i") + 2)).toEqual([ + "-i", + "/rec/in.mp4", + ]); + expect(args).toContain("-an"); + expect(args.slice(-3)).toEqual(["-loop", "0", "/out/clip.gif"]); + const filter = args[args.indexOf("-filter_complex") + 1]; + expect(filter).toContain("fps=" + GIF_FRAME_RATE); + expect(filter).toContain("scale='min(" + GIF_MAX_WIDTH + ",iw)':-1"); + expect(filter).toContain("palettegen"); + expect(filter).toContain("paletteuse"); + }); + + it("passes paths as discrete arguments so spaces need no quoting", () => { + const args = buildGifConversionArgs("C:/My Videos/a b.mp4", "C:/Out Dir/x y.gif"); + expect(args).toContain("C:/My Videos/a b.mp4"); + expect(args.at(-1)).toBe("C:/Out Dir/x y.gif"); + }); +}); diff --git a/electron/gifCapture.ts b/electron/gifCapture.ts new file mode 100644 index 000000000..67a19de37 --- /dev/null +++ b/electron/gifCapture.ts @@ -0,0 +1,45 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getFfmpegBinaryPath } from "./ipc/ffmpeg/binary"; + +const run = promisify(execFile); + +// Direct GIF capture skips the editor (and its WebGL/WebCodecs renderer) entirely: +// the finished recording is converted in the main process with the bundled ffmpeg, +// using a per-clip palette so screen content stays crisp at a small file size. +export const GIF_FRAME_RATE = 15; +export const GIF_MAX_WIDTH = 960; +const GIF_CONVERT_TIMEOUT_MS = 600_000; +const GIF_CONVERT_MAX_BUFFER_BYTES = 10_485_760; + +export function buildGifConversionArgs(inputPath: string, outputPath: string): string[] { + const filter = + "fps=" + + GIF_FRAME_RATE + + ",scale='min(" + + GIF_MAX_WIDTH + + ",iw)':-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"; + return [ + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-y", + "-i", + inputPath, + "-an", + "-filter_complex", + filter, + "-loop", + "0", + outputPath, + ]; +} + +export async function convertVideoToGif(inputPath: string, outputPath: string) { + await run(getFfmpegBinaryPath(), buildGifConversionArgs(inputPath, outputPath), { + timeout: GIF_CONVERT_TIMEOUT_MS, + maxBuffer: GIF_CONVERT_MAX_BUFFER_BYTES, + windowsHide: true, + }); +} diff --git a/electron/gpuSwitches.test.ts b/electron/gpuSwitches.test.ts index 4cbb1cdf4..1a93bf520 100644 --- a/electron/gpuSwitches.test.ts +++ b/electron/gpuSwitches.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getGpuSwitches, shouldForceLinuxEgl } from "./gpuSwitches"; +import { getGpuSwitches, resolveLightweightMode, shouldForceLinuxEgl } from "./gpuSwitches"; describe("shouldForceLinuxEgl", () => { it("does not force EGL in a Wayland session", () => { @@ -65,3 +65,24 @@ describe("getGpuSwitches", () => { }); }); }); + +describe("resolveLightweightMode", () => { + it("defaults to lightweight when the setting is unset", () => { + expect(resolveLightweightMode(null, {})).toBe(true); + }); + + it("honors an explicit opt-out in app settings", () => { + expect(resolveLightweightMode(false, {})).toBe(false); + }); + + it("lets RECORDLY_LIGHTWEIGHT override the stored setting", () => { + expect(resolveLightweightMode(true, { RECORDLY_LIGHTWEIGHT: "0" })).toBe(false); + expect(resolveLightweightMode(false, { RECORDLY_LIGHTWEIGHT: "1" })).toBe(true); + }); + + it("always uses full GPU acceleration for smoke exports", () => { + expect( + resolveLightweightMode(true, { RECORDLY_SMOKE_EXPORT: "1", RECORDLY_LIGHTWEIGHT: "1" }), + ).toBe(false); + }); +}); diff --git a/electron/gpuSwitches.ts b/electron/gpuSwitches.ts index 7b7c81ee8..9be863a6f 100644 --- a/electron/gpuSwitches.ts +++ b/electron/gpuSwitches.ts @@ -64,3 +64,34 @@ export function getGpuSwitches( return {}; } + +/** + * Lightweight mode (default ON): skip the aggressive global GPU switches that + * measurably add ~300 MB of idle private memory (browser + GPU process). + * The editor still gets WebGPU where Chromium enables it by default and falls + * back to WebGL otherwise. Chromium switches are startup-only, so a change + * takes effect on the next launch. + */ +export const LIGHTWEIGHT_MODE_SETTING_KEY = "lightweightMode"; + +export const PERFORMANCE_GPU_SWITCHES: readonly string[] = [ + "ignore-gpu-blocklist", + "enable-unsafe-webgpu", + "enable-gpu-rasterization", +]; + +export function resolveLightweightMode( + storedValue: unknown, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.RECORDLY_SMOKE_EXPORT === "1") { + return false; + } + if (env.RECORDLY_LIGHTWEIGHT === "0") { + return false; + } + if (env.RECORDLY_LIGHTWEIGHT === "1") { + return true; + } + return storedValue !== false; +} diff --git a/electron/ipc/ffmpeg/binary.ts b/electron/ipc/ffmpeg/binary.ts index a0cf8468c..d43ef7834 100644 --- a/electron/ipc/ffmpeg/binary.ts +++ b/electron/ipc/ffmpeg/binary.ts @@ -116,16 +116,39 @@ export function resolveSystemFfprobeBinaryPath(): string | null { return null; } +let cachedFfmpegBinaryPath: string | null = null; + +// existsSync alone is not enough: a bundled binary can be present yet unrunnable +// (EFTYPE / corrupted download / blocked by OS policy). Probe it once and cache. +function isRunnableBinary(binaryPath: string): boolean { + if (!existsSync(binaryPath)) return false; + const probe = spawnSync(binaryPath, ["-version"], { + stdio: "ignore", + timeout: 10_000, + windowsHide: true, + }); + return !probe.error && probe.status === 0; +} + export function getFfmpegBinaryPath(): string { + if (cachedFfmpegBinaryPath) return cachedFfmpegBinaryPath; + const ffmpegStatic = loadFfmpegStatic(); if (ffmpegStatic && typeof ffmpegStatic === "string") { const bundledPath = app.isPackaged ? ffmpegStatic.replace(/\.asar([/\\])/, ".asar.unpacked$1") : ffmpegStatic; - if (existsSync(bundledPath)) { + if (isRunnableBinary(bundledPath)) { + cachedFfmpegBinaryPath = bundledPath; return bundledPath; } + if (existsSync(bundledPath)) { + console.warn( + "[ffmpeg] Bundled binary is not runnable; falling back to system FFmpeg:", + bundledPath, + ); + } } const systemFfmpeg = resolveSystemFfmpegBinaryPath(); diff --git a/electron/ipc/register/capturePicker.test.ts b/electron/ipc/register/capturePicker.test.ts index 6a12558a0..1ab674416 100644 --- a/electron/ipc/register/capturePicker.test.ts +++ b/electron/ipc/register/capturePicker.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + const mocks = vi.hoisted(() => ({ handlers: new Map unknown>(), list: vi.fn(), + begin: vi.fn(), + end: vi.fn(), pickSourceList: vi.fn(), validate: vi.fn(), @@ -21,6 +24,8 @@ vi.mock("electron", () => ({ }, })); vi.mock("../../windows", () => ({ + beginCaptureSelection: mocks.begin, + endCaptureSelection: mocks.end, createCapturePickerWindows: mocks.create, closeCapturePickerWindows: mocks.close, isCapturePickerWebContents: (sender: object) => mocks.windows.includes(sender), @@ -51,7 +56,9 @@ vi.mock("./sources", () => ({ broadcastSelectedSourceChange: vi.fn(), getDesktopSources: vi.fn().mockResolvedValue([{ id: "screen:1:0", display_id: "1" }]), })); + import { registerCapturePickerHandlers } from "./capturePicker"; + const platform = process.platform; afterEach(() => { Object.defineProperty(process, "platform", { value: platform }); @@ -65,12 +72,22 @@ beforeEach(() => { mocks.windows = [mocks.overlay]; Object.defineProperty(process, "platform", { value: "darwin" }); mocks.list.mockResolvedValue([]); + mocks.begin.mockReturnValue(true); mocks.validate.mockImplementation(async (source) => source); registerCapturePickerHandlers({ pickSourceList: mocks.pickSourceList }); }); const call = (name: string, sender: object, input?: unknown) => mocks.handlers.get(name)!({ sender }, input); describe("selection-only picker IPC", () => { + it("does not replace a screenshot selection, including its capture startup", async () => { + mocks.begin.mockReturnValue(false); + expect(await call("pick-capture-target", mocks.outsider)).toMatchObject({ + success: false, + message: expect.stringContaining("Finish"), + }); + expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.create).not.toHaveBeenCalled(); + }); it("ignores completion from other windows and forces selection-only completion", async () => { const pending = call("pick-capture-target", mocks.outsider); await vi.waitFor(() => expect(mocks.create).toHaveBeenCalledOnce()); diff --git a/electron/ipc/register/capturePicker.ts b/electron/ipc/register/capturePicker.ts index 5f471b03c..a49ece709 100644 --- a/electron/ipc/register/capturePicker.ts +++ b/electron/ipc/register/capturePicker.ts @@ -1,15 +1,17 @@ -import { validateCaptureSource } from "../captureSelection"; -import { listCapturePickerWindows } from "../capturePickerSources"; import { ipcMain } from "electron"; import type { SourceListPickResult } from "../../sourceListPicker"; import { + beginCaptureSelection, closeCapturePickerWindows, createCapturePickerWindows, + endCaptureSelection, isCapturePickerWebContents, } from "../../windows"; +import { listCapturePickerWindows } from "../capturePickerSources"; +import { validateCaptureSource } from "../captureSelection"; import { stopWindowBoundsCapture } from "../cursor/bounds"; -import { isCursorCaptureActive, setSelectedSource } from "../state"; import { createAreaSource, createWindowSource, normalizeCapturePick } from "../sourceArea"; +import { isCursorCaptureActive, setSelectedSource } from "../state"; import type { CapturePick, CapturePickerWindow, SelectedSource } from "../types"; import { getScreen } from "../utils"; import { getScreenSourceIdForDisplay, isLikelyLinuxWaylandSession } from "./sourceMapping"; @@ -122,6 +124,8 @@ export function registerCapturePickerHandlers({ ipcMain.handle("pick-capture-target", () => { if (isCursorCaptureActive) return { success: false, canceled: true }; if (!activePick) { + if (!beginCaptureSelection()) + return { success: false, message: "Finish the current selection first." }; activePick = pickTarget() .catch((error) => { finishPick(null); @@ -136,6 +140,7 @@ export function registerCapturePickerHandlers({ }) .finally(() => { activePick = null; + endCaptureSelection(); }); } return activePick; diff --git a/electron/main.ts b/electron/main.ts index 2cbce01e3..0dad4b445 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,9 +16,16 @@ import { Tray, } from "electron"; import { RECORDINGS_DIR } from "./appPaths"; +import { readAppSetting, writeAppSetting } from "./appSettingsStore"; import { createAuthCallbackController } from "./authCallback"; import { showCursor } from "./cursorHider"; -import { getGpuSwitches } from "./gpuSwitches"; +import { convertVideoToGif } from "./gifCapture"; +import { + getGpuSwitches, + LIGHTWEIGHT_MODE_SETTING_KEY, + PERFORMANCE_GPU_SWITCHES, + resolveLightweightMode, +} from "./gpuSwitches"; import { cleanupAllExportStreams, cleanupNativeVideoExportSessions, @@ -35,6 +42,7 @@ import { createRecordingShortcutDispatcher } from "./recordingShortcutDispatch"; import { registerRecordingShortcuts } from "./recordingShortcuts"; import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; import { createSaveBeforeCloseController } from "./saveBeforeClose"; +import { registerScreenshotHandlers } from "./screenshot"; import { checkForAppUpdates, deferUpdateReminder, @@ -80,9 +88,15 @@ function ignoreBrokenConsolePipe(stream: NodeJS.WritableStream | undefined) { ignoreBrokenConsolePipe(process.stdout); ignoreBrokenConsolePipe(process.stderr); -app.commandLine.appendSwitch("ignore-gpu-blocklist"); -app.commandLine.appendSwitch("enable-unsafe-webgpu"); -app.commandLine.appendSwitch("enable-gpu-rasterization"); +const LIGHTWEIGHT_MODE = resolveLightweightMode( + readAppSetting(LIGHTWEIGHT_MODE_SETTING_KEY), + process.env, +); +if (!LIGHTWEIGHT_MODE) { + for (const gpuSwitch of PERFORMANCE_GPU_SWITCHES) { + app.commandLine.appendSwitch(gpuSwitch); + } +} app.on("web-contents-created", (_event, contents) => { if (!shouldHardenWebContentsType(contents.getType())) { @@ -243,7 +257,12 @@ function closeEditorWindowToHud(window: BrowserWindow | null) { if (mainWindow === window) { mainWindow = null; } - createWindow(); + if (LIGHTWEIGHT_MODE) { + // Return to tray-only idle: release the hidden HUD renderer too. + getHudOverlayWindow()?.close(); + } else { + createWindow(); + } closeEditorWindowBypassingUnsavedPrompt(window); } @@ -593,9 +612,10 @@ function createTray() { } function shouldUseTray() { - // macOS and Windows expose Recordly through their Dock/taskbar. Keep the - // tray entry only on Linux, where it remains the primary app entry point. - return process.platform === "linux"; + // Lightweight mode keeps only the tray resident on every platform; the + // recorder HUD and editor are created on demand. Otherwise the tray is + // Linux-only, where it remains the primary app entry point. + return process.platform === "linux" || LIGHTWEIGHT_MODE; } function getPublicAssetPath(filename: string) { @@ -721,6 +741,79 @@ ipcMain.handle("set-experimental-updates-enabled", async (_event, enabled: unkno } }); +// Lightweight mode only takes effect at startup (Chromium switches, tray-only idle), +// so report both the stored preference and the mode this process is running in. +ipcMain.handle("get-lightweight-mode", () => { + return { + enabled: readAppSetting(LIGHTWEIGHT_MODE_SETTING_KEY) !== false, + active: LIGHTWEIGHT_MODE, + }; +}); + +ipcMain.handle("set-lightweight-mode", (_event, enabled: unknown) => { + if (typeof enabled !== "boolean") { + return { success: false, enabled: readAppSetting(LIGHTWEIGHT_MODE_SETTING_KEY) !== false }; + } + try { + writeAppSetting(LIGHTWEIGHT_MODE_SETTING_KEY, enabled); + return { success: true, enabled }; + } catch (error) { + console.error("Failed to save lightweight mode preference:", error); + return { + success: false, + enabled: readAppSetting(LIGHTWEIGHT_MODE_SETTING_KEY) !== false, + error: String(error), + }; + } +}); + +// Owned by main so the tray and the HUD toolbar agree; read when the HUD finishes a recording. +type CaptureOutputMode = "editor" | "gif"; +let captureOutputMode: CaptureOutputMode = "editor"; + +function setCaptureOutputMode(mode: CaptureOutputMode) { + if (captureOutputMode === mode) return; + captureOutputMode = mode; + const hud = getHudOverlayWindow(); + if (hud && !hud.isDestroyed()) hud.webContents.send("capture-output-mode-changed", mode); +} + +ipcMain.handle("get-capture-output-mode", () => captureOutputMode); + +ipcMain.handle("set-capture-output-mode", (event, mode: unknown) => { + const hud = getHudOverlayWindow(); + if (!hud || event.sender !== hud.webContents) return captureOutputMode; + if (mode === "editor" || mode === "gif") setCaptureOutputMode(mode); + return captureOutputMode; +}); + +ipcMain.handle("complete-gif-capture", async (event, videoPath: unknown) => { + const hud = getHudOverlayWindow(); + if (captureOutputMode !== "gif" || !hud || event.sender !== hud.webContents) { + return { handled: false }; + } + if (typeof videoPath !== "string" || !videoPath) { + return { handled: false }; + } + const { canceled, filePath } = await dialog.showSaveDialog({ + defaultPath: path.join(app.getPath("pictures"), "Recordly-" + Date.now() + ".gif"), + filters: [{ name: "GIF image", extensions: ["gif"] }], + }); + if (!canceled && filePath) { + try { + await convertVideoToGif(videoPath, filePath); + shell.showItemInFolder(filePath); + } catch (error) { + dialog.showErrorBox("GIF export failed", String(error)); + } + } + // The recording stays in the library; the GIF flow never opens the editor. + if (LIGHTWEIGHT_MODE && !hud.isDestroyed()) { + hud.close(); + } + return { handled: true }; +}); + ipcMain.handle("preview-update-toast", async () => { if (process.platform !== "darwin") { await previewNativeUpdateDialog(getUpdateDialogWindow); @@ -735,6 +828,9 @@ ipcMain.handle("check-for-app-updates", async () => { return { success: true, logPath: getUpdaterLogPath() }; }); +let trayScreenshotAction: (() => void) | null = null; +let trayRecordAction: ((mode: "editor" | "gif") => void) | null = null; + function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon(); @@ -760,7 +856,19 @@ function updateTrayMenu(recording: boolean = false) { ] : [ { - label: "Open", + label: "Screenshot", + click: () => trayScreenshotAction?.(), + }, + { + label: "Record GIF", + click: () => trayRecordAction?.("gif"), + }, + { + label: "Record Video", + click: () => trayRecordAction?.("editor"), + }, + { + label: "Open Recorder", click: () => { if (!showHudOverlayFromTray()) { focusOrCreateMainWindow(); @@ -908,7 +1016,7 @@ app.on("before-quit", () => { }); app.on("window-all-closed", () => { - if (IS_SMOKE_EXPORT || process.platform !== "darwin") { + if (IS_SMOKE_EXPORT || (process.platform !== "darwin" && !LIGHTWEIGHT_MODE)) { app.quit(); } }); @@ -928,11 +1036,6 @@ app.on("second-instance", (_event, commandLine) => { // Register all IPC handlers when app is ready app.whenReady().then(async () => { - registerRecordingShortcuts((action) => { - void dispatchRecordingShortcut(action).catch((error) => - console.error("[recording-shortcuts]", error), - ); - }); authCallbacks.startDevServer(); if (process.defaultApp && process.argv[1]) { app.setAsDefaultProtocolClient(authCallbacks.protocol, process.execPath, [ @@ -1015,6 +1118,9 @@ app.whenReady().then(async () => { console.log("[main] Closing HUD window via hud-overlay-close"); hud.close(); } + if (LIGHTWEIGHT_MODE && tray) { + return; + } // If this was the last window (or we are in a state where we should quit), do it. // We use a small delay to allow window.close() to propagate. @@ -1073,6 +1179,61 @@ app.whenReady().then(async () => { }, ); + const takeScreenshot = registerScreenshotHandlers(); + trayScreenshotAction = () => { + void takeScreenshot().catch((error) => + dialog.showErrorBox("Screenshot failed", String(error)), + ); + }; + const openHudForShortcut = async () => { + createWindow(); + const hud = getHudOverlayWindow(); + if (!hud || hud.isDestroyed()) return; + // Lightweight mode idles without a HUD: wait until its renderer has mounted and + // subscribed to shortcuts, otherwise the dispatched action is dropped. + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + ipcMain.removeListener("hud-overlay-renderer-ready", onReady); + hud.removeListener("closed", finish); + resolve(); + }; + const onReady = (event: Electron.IpcMainEvent) => { + if (!hud.isDestroyed() && event.sender === hud.webContents) finish(); + }; + const timer = setTimeout(finish, 10_000); + ipcMain.on("hud-overlay-renderer-ready", onReady); + hud.once("closed", finish); + }); + }; + trayRecordAction = (mode) => { + setCaptureOutputMode(mode); + void (async () => { + if (!getHudOverlayWindow()) await openHudForShortcut(); + await dispatchRecordingShortcut("openCapturePicker"); + })().catch((error) => console.error("[tray-capture]", error)); + }; + registerRecordingShortcuts((action) => { + if (action === "takeScreenshot") { + void takeScreenshot().catch((error) => + dialog.showErrorBox("Screenshot failed", String(error)), + ); + return; + } + const needsHud = + action === "toggleRecording" || + action === "openCapturePicker" || + action === "toggleHud"; + void (async () => { + if (needsHud && !getHudOverlayWindow()) { + await openHudForShortcut(); + // Showing the controls is the whole intent of toggleHud. + if (action === "toggleHud") return; + } + await dispatchRecordingShortcut(action); + })().catch((error) => console.error("[recording-shortcuts]", error)); + }); + if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) { await logSmokeExportGpuDiagnostics(); if (IS_SMOKE_EXPORT) { @@ -1090,7 +1251,9 @@ app.whenReady().then(async () => { return; } - createWindow(); + if (!LIGHTWEIGHT_MODE) { + createWindow(); + } setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows); if (IS_DEV && process.env.RECORDLY_DEV_PREVIEW_UPDATE === "1") { setTimeout(() => { diff --git a/electron/preload.ts b/electron/preload.ts index b3125da17..49847f7fd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -544,6 +544,17 @@ contextBridge.exposeInMainWorld("electronAPI", { completeCapturePick: (pick: CapturePick | null) => { return ipcRenderer.invoke("complete-capture-pick", pick); }, + startScreenshot: () => ipcRenderer.invoke("start-screenshot"), + getScreenshotContext: (displayId: number) => + ipcRenderer.invoke("get-screenshot-context", displayId), + completeScreenshotPick: ( + pick: { displayId: number; rect: import("../src/lib/screenshot").ScreenshotRect } | null, + ) => ipcRenderer.invoke("complete-screenshot-pick", pick), + getScreenshotDocument: () => ipcRenderer.invoke("get-screenshot-document"), + screenshotAction: ( + action: "copy" | "save" | "pin" | "close" | "opacity" | "zoom", + value?: string | number, + ) => ipcRenderer.invoke("screenshot-action", action, value), getCapturePickerContext: (displayId: number) => { return ipcRenderer.invoke("get-capture-picker-context", displayId); }, @@ -943,6 +954,26 @@ contextBridge.exposeInMainWorld("electronAPI", { setExperimentalUpdatesEnabled: (enabled: boolean) => { return ipcRenderer.invoke("set-experimental-updates-enabled", enabled); }, + getLightweightMode: () => { + return ipcRenderer.invoke("get-lightweight-mode"); + }, + setLightweightMode: (enabled: boolean) => { + return ipcRenderer.invoke("set-lightweight-mode", enabled); + }, + completeGifCapture: (videoPath: string) => { + return ipcRenderer.invoke("complete-gif-capture", videoPath); + }, + getCaptureOutputMode: () => ipcRenderer.invoke("get-capture-output-mode"), + setCaptureOutputMode: (mode: "editor" | "gif") => + ipcRenderer.invoke("set-capture-output-mode", mode), + onCaptureOutputModeChanged: (callback: (mode: "editor" | "gif") => void) => { + const listener = (_event: Electron.IpcRendererEvent, mode: "editor" | "gif") => + callback(mode); + ipcRenderer.on("capture-output-mode-changed", listener); + return () => { + ipcRenderer.removeListener("capture-output-mode-changed", listener); + }; + }, previewUpdateToast: () => { return ipcRenderer.invoke("preview-update-toast"); }, diff --git a/electron/recordingShortcuts.test.ts b/electron/recordingShortcuts.test.ts index 422177baa..1acfb20be 100644 --- a/electron/recordingShortcuts.test.ts +++ b/electron/recordingShortcuts.test.ts @@ -32,6 +32,19 @@ vi.mock("./appPaths", () => ({ USER_DATA_PATH: "test-data" })); import { registerRecordingShortcuts } from "./recordingShortcuts"; describe("recording shortcut lifecycle", () => { + it("keeps saved recording bindings when a new screenshot action is introduced", () => { + mocks.exists.mockReturnValue(true); + const { takeScreenshot: _newAction, ...previous } = DEFAULT_RECORDING_SHORTCUTS; + mocks.read.mockReturnValue(JSON.stringify({ ...previous, toggleRecording: "F9" })); + const status = registerRecordingShortcuts(vi.fn()); + expect(status()).toMatchObject({ + settings: { + toggleRecording: "F9", + takeScreenshot: DEFAULT_RECORDING_SHORTCUTS.takeScreenshot, + }, + errors: [], + }); + }); beforeEach(() => { vi.resetAllMocks(); mocks.handlers.clear(); @@ -44,7 +57,7 @@ describe("recording shortcut lifecycle", () => { mocks.register.mock.calls[1][1](); expect(dispatch).toHaveBeenCalledWith("togglePause"); mocks.quit.mock.calls[0][1](); - expect(mocks.unregister).toHaveBeenCalledTimes(5); + expect(mocks.unregister).toHaveBeenCalledTimes(6); }); it("reports OS conflicts without claiming success for unavailable keys", () => { mocks.register.mockReturnValueOnce(false); @@ -61,7 +74,7 @@ describe("recording shortcut lifecycle", () => { }), ); registerRecordingShortcuts(vi.fn()); - expect(mocks.register).toHaveBeenCalledTimes(4); + expect(mocks.register).toHaveBeenCalledTimes(5); expect(mocks.register).toHaveBeenCalledWith("F9", expect.any(Function)); }); it("leaves active bindings intact on invalid input or failed persistence", () => { @@ -88,7 +101,7 @@ describe("recording shortcut lifecycle", () => { const result = mocks.handlers.get("set-recording-shortcuts")!({}, settings); expect(result).toMatchObject({ success: true, errors: [], settings }); expect(mocks.rename).toHaveBeenCalled(); - expect(mocks.unregister).toHaveBeenCalledTimes(5); + expect(mocks.unregister).toHaveBeenCalledTimes(6); }); it("does not silently enable defaults over a corrupt configuration", () => { mocks.exists.mockReturnValue(true); diff --git a/electron/recordingShortcuts.ts b/electron/recordingShortcuts.ts index 6e5a68f44..a4d6eb968 100644 --- a/electron/recordingShortcuts.ts +++ b/electron/recordingShortcuts.ts @@ -41,10 +41,12 @@ export function registerRecordingShortcuts(dispatch: (action: RecordingShortcutA for (const error of errors) console.warn("[recording-shortcuts]", error); }; try { - if (fs.existsSync(settingsPath)) - settings = validateRecordingShortcuts( - JSON.parse(fs.readFileSync(settingsPath, "utf8")), - ); + if (fs.existsSync(settingsPath)) { + const saved: unknown = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + if (!saved || typeof saved !== "object" || Array.isArray(saved)) + throw new Error("Expected a shortcut settings object."); + settings = validateRecordingShortcuts({ ...DEFAULT_RECORDING_SHORTCUTS, ...saved }); + } bind(settings); } catch (error) { // Do not replace a corrupt user configuration with silently active defaults. diff --git a/electron/screenshot.test.ts b/electron/screenshot.test.ts new file mode 100644 index 000000000..43191a208 --- /dev/null +++ b/electron/screenshot.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + let nextId = 10; + class Window { + webContents = { id: nextId++ }; + events = new Map void>(); + loadFile = vi.fn(); + loadURL = vi.fn(); + show = vi.fn(); + setOpacity = vi.fn(); + setSize = vi.fn(); + getBounds = () => ({ x: 0, y: 0, width: 600, height: 300 }); + once = (event: string, callback: () => void) => { + this.events.set(event, callback); + }; + close = vi.fn(() => this.events.get("closed")?.()); + constructor(public options: Record) { + instances.push(this); + } + } + const instances: Window[] = []; + return { + handlers: new Map unknown>(), + Window, + instances, + getSources: vi.fn(), + begin: vi.fn(), + end: vi.fn(), + write: vi.fn(), + clipboard: vi.fn(), + save: vi.fn(), + create: vi.fn(), + close: vi.fn(), + overlay: {}, + recording: false, + hud: { + isVisible: () => true, + isDestroyed: () => false, + hide: vi.fn(), + showInactive: vi.fn(), + }, + decode: vi.fn(), + }; +}); +vi.mock("electron", () => ({ + BrowserWindow: mocks.Window, + clipboard: { writeImage: mocks.clipboard }, + desktopCapturer: { getSources: mocks.getSources }, + dialog: { showSaveDialog: mocks.save }, + nativeImage: { createFromDataURL: mocks.decode }, + ipcMain: { + handle: (key: string, callback: (...args: unknown[]) => unknown) => + mocks.handlers.set(key, callback), + }, +})); +vi.mock("node:fs/promises", () => ({ default: { writeFile: mocks.write } })); +vi.mock("./ipc/capturePickerSources", () => ({ listCapturePickerWindows: async () => [] })); +vi.mock("./ipc/state", () => ({ + get isCursorCaptureActive() { + return mocks.recording; + }, +})); +vi.mock("./ipc/utils", () => ({ + getScreen: () => ({ + getAllDisplays: () => [ + { + id: 1, + bounds: { x: -1000, y: 0, width: 1000, height: 500 }, + size: { width: 1000, height: 500 }, + scaleFactor: 2, + }, + ], + getCursorScreenPoint: () => ({ x: 0, y: 0 }), + getDisplayNearestPoint: () => ({ workAreaSize: { width: 1920, height: 1080 } }), + }), +})); +vi.mock("./windows", () => ({ + beginCaptureSelection: mocks.begin, + endCaptureSelection: mocks.end, + getHudOverlayWindow: () => mocks.hud, + createCapturePickerWindows: mocks.create, + closeCapturePickerWindows: mocks.close, + isCapturePickerWebContents: (sender: object) => sender === mocks.overlay, +})); +function image(width: number, height: number, name = "original") { + return { + getSize: () => ({ width, height }), + isEmpty: () => false, + toDataURL: () => `data:image/png;base64,${name}`, + toPNG: () => Buffer.from(name), + crop: vi.fn((rect: { width: number; height: number }) => + image(rect.width, rect.height, "crop"), + ), + }; +} +const invoke = (name: string, sender: object, ...args: unknown[]) => + mocks.handlers.get(name)!({ sender }, ...args); +async function selectedDocument() { + await invoke("start-screenshot", {}); + await invoke("complete-screenshot-pick", mocks.overlay, { + displayId: 1, + rect: { x: -950, y: 50, width: 300, height: 150 }, + }); + return mocks.instances[0]; +} + +describe("screenshot native IPC and resource ownership", () => { + const platform = process.platform; + afterEach(() => { + Object.defineProperty(process, "platform", { value: platform }); + }); + beforeEach(async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + vi.resetModules(); + vi.clearAllMocks(); + mocks.handlers.clear(); + mocks.instances.length = 0; + mocks.recording = false; + mocks.begin.mockReturnValue(true); + mocks.getSources.mockResolvedValue([{ display_id: "1", thumbnail: image(2000, 1000) }]); + mocks.save.mockResolvedValue({ canceled: false, filePath: "/screenshot.png" }); + mocks.decode.mockReturnValue(image(600, 300, "edited")); + const { registerScreenshotHandlers } = await import("./screenshot"); + registerScreenshotHandlers(); + }); + it("captures at native DPI, crops negative-origin rectangles and restores the HUD", async () => { + const doc = await selectedDocument(); + expect(mocks.getSources).toHaveBeenCalledWith({ + types: ["screen"], + thumbnailSize: { width: 2000, height: 1000 }, + }); + expect(mocks.create).toHaveBeenCalledWith(expect.any(Function), "screenshot"); + expect(mocks.hud.hide).toHaveBeenCalledOnce(); + expect(mocks.hud.showInactive).toHaveBeenCalledOnce(); + expect(invoke("get-screenshot-document", doc.webContents)).toMatchObject({ + width: 600, + height: 300, + pinned: false, + }); + }); + it("cancels selection and prevents a foreign window from reading screenshot pixels", async () => { + await invoke("start-screenshot", {}); + expect(() => invoke("get-screenshot-context", {}, 1)).toThrow(); + await invoke("complete-screenshot-pick", mocks.overlay, null); + expect(() => invoke("get-screenshot-context", mocks.overlay, 1)).toThrow(); + expect(mocks.instances).toHaveLength(0); + }); + it("reports capture resolution failures and permits a subsequent retry", async () => { + mocks.getSources.mockResolvedValueOnce([{ display_id: "1", thumbnail: image(1000, 500) }]); + expect(await invoke("start-screenshot", {})).toMatchObject({ + success: false, + error: expect.stringContaining("received 1000"), + }); + expect(mocks.hud.showInactive).toHaveBeenCalledOnce(); + expect(await invoke("start-screenshot", {})).toEqual({ success: true }); + }); + it("copies and saves the composited PNG and pins an independently owned image", async () => { + const doc = await selectedDocument(); + await invoke("screenshot-action", doc.webContents, "copy", "data:image/png;base64,edited"); + expect(mocks.clipboard).toHaveBeenCalledWith( + expect.objectContaining({ toPNG: expect.any(Function) }), + ); + await invoke("screenshot-action", doc.webContents, "save", "data:image/png;base64,edited"); + expect(mocks.write).toHaveBeenCalledWith("/screenshot.png", Buffer.from("edited")); + await invoke("screenshot-action", doc.webContents, "pin", "data:image/png;base64,edited"); + const pin = mocks.instances[1]; + expect(pin.options).toMatchObject({ alwaysOnTop: true, frame: false }); + doc.close(); + expect(() => invoke("get-screenshot-document", doc.webContents)).toThrow(); + expect(invoke("get-screenshot-document", pin.webContents)).toMatchObject({ pinned: true }); + }); + it("save cancellation preserves the document and does not write a file", async () => { + const doc = await selectedDocument(); + mocks.save.mockResolvedValueOnce({ canceled: true }); + expect(await invoke("screenshot-action", doc.webContents, "save")).toMatchObject({ + canceled: true, + }); + expect(mocks.write).not.toHaveBeenCalled(); + expect(invoke("get-screenshot-document", doc.webContents)).toMatchObject({ width: 600 }); + }); + it("rejects mismatched exports and actions from unowned windows", async () => { + const doc = await selectedDocument(); + mocks.decode.mockReturnValue(image(120, 30)); + await expect( + invoke("screenshot-action", doc.webContents, "copy", "data:image/png;base64,bad"), + ).rejects.toThrow("dimensions"); + await expect(invoke("screenshot-action", {}, "copy")).rejects.toThrow("belongs"); + expect(mocks.clipboard).not.toHaveBeenCalled(); + }); + it("does not start during recording or an existing source selection", async () => { + mocks.recording = true; + expect(await invoke("start-screenshot", {})).toMatchObject({ success: false }); + mocks.recording = false; + mocks.begin.mockReturnValue(false); + expect(await invoke("start-screenshot", {})).toMatchObject({ success: false }); + expect(mocks.getSources).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/screenshot.ts b/electron/screenshot.ts new file mode 100644 index 000000000..386a12b80 --- /dev/null +++ b/electron/screenshot.ts @@ -0,0 +1,246 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + BrowserWindow, + clipboard, + desktopCapturer, + dialog, + ipcMain, + type NativeImage, + nativeImage, +} from "electron"; +import { type ScreenshotRect, screenshotPixelRect } from "../src/lib/screenshot"; +import { listCapturePickerWindows } from "./ipc/capturePickerSources"; +import { isCursorCaptureActive } from "./ipc/state"; +import { getScreen } from "./ipc/utils"; +import { + beginCaptureSelection, + closeCapturePickerWindows, + createCapturePickerWindows, + endCaptureSelection, + getHudOverlayWindow, + isCapturePickerWebContents, +} from "./windows"; + +const mainDir = path.dirname(fileURLToPath(import.meta.url)); +type Frame = { image: NativeImage; bounds: ScreenshotRect }; +type Document = { image: NativeImage; window: BrowserWindow; pinned: boolean }; + +export function registerScreenshotHandlers() { + let picking = false; + const frames = new Map(); + const documents = new Map(); + let restoreHud = false; + let windows: Awaited> = []; + const finish = () => { + closeCapturePickerWindows(); + frames.clear(); + picking = false; + endCaptureSelection(); + const hud = getHudOverlayWindow(); + if (restoreHud && hud && !hud.isDestroyed()) hud.showInactive(); + restoreHud = false; + }; + const openDocument = (image: NativeImage, pinned: boolean, bounds?: ScreenshotRect) => { + const display = getScreen().getDisplayNearestPoint(getScreen().getCursorScreenPoint()); + const size = image.getSize(); + const scale = Math.min( + 1, + (display.workAreaSize.width - 80) / size.width, + (display.workAreaSize.height - 140) / size.height, + ); + const win = new BrowserWindow({ + width: pinned + ? Math.max(240, Math.round(size.width * scale)) + : Math.min( + display.workAreaSize.width - 40, + Math.max(720, Math.round(size.width * scale)), + ), + height: pinned + ? Math.max(180, Math.round(size.height * scale) + 57) + : Math.min( + display.workAreaSize.height - 40, + Math.max(520, Math.round(size.height * scale) + 180), + ), + ...(pinned && bounds ? { x: Math.round(bounds.x), y: Math.round(bounds.y) } : {}), + title: pinned ? "Recordly Pin" : "Recordly Screenshot", + frame: !pinned, + alwaysOnTop: pinned, + skipTaskbar: pinned, + show: false, + backgroundColor: "#18181b", + webPreferences: { + preload: path.join(mainDir, "preload.mjs"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + documents.set(win.webContents.id, { image, window: win, pinned }); + const documentId = win.webContents.id; + win.once("closed", () => documents.delete(documentId)); + win.once("ready-to-show", () => win.show()); + const query = { windowType: "screenshot", pinned: String(pinned) }; + const devUrl = process.env.VITE_DEV_SERVER_URL; + if (devUrl) void win.loadURL(`${devUrl}?${new URLSearchParams(query)}`); + else void win.loadFile(path.join(mainDir, "../dist/index.html"), { query }); + return win; + }; + const start = async () => { + if (isCursorCaptureActive) + throw new Error("Stop the recording before taking a screenshot."); + if (process.platform === "linux") + throw new Error("Screenshot selection currently supports Windows and macOS."); + if (!beginCaptureSelection()) throw new Error("Finish the current selection first."); + picking = true; + const hud = getHudOverlayWindow(); + restoreHud = Boolean(hud?.isVisible()); + if (restoreHud) hud?.hide(); + try { + windows = await listCapturePickerWindows(); + for (const display of getScreen().getAllDisplays()) { + const width = Math.round(display.size.width * display.scaleFactor); + const height = Math.round(display.size.height * display.scaleFactor); + const sources = await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width, height }, + }); + const image = sources.find( + (source) => source.display_id === String(display.id), + )?.thumbnail; + if (!image || image.isEmpty()) + throw new Error( + "Could not capture the display. Check screen recording permission.", + ); + const actual = image.getSize(); + if (actual.width !== width || actual.height !== height) + throw new Error( + `Expected ${width}×${height} pixels, received ${actual.width}×${actual.height}.`, + ); + frames.set(display.id, { image, bounds: display.bounds }); + } + createCapturePickerWindows(finish, "screenshot"); + } catch (error) { + finish(); + throw error; + } + }; + ipcMain.handle("start-screenshot", async () => { + try { + await start(); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("get-screenshot-context", (event, displayId: number) => { + if (!isCapturePickerWebContents(event.sender) || !picking) + throw new Error("No screenshot selection is active."); + const frame = frames.get(displayId); + if (!frame) throw new Error("The display is no longer available."); + return { + displayBounds: frame.bounds, + allowArea: true, + lastArea: null, + windows, + cursor: getScreen().getCursorScreenPoint(), + background: frame.image.toDataURL(), + }; + }); + ipcMain.handle( + "complete-screenshot-pick", + (event, input: { displayId: number; rect: ScreenshotRect } | null) => { + if (!isCapturePickerWebContents(event.sender) || !picking) + throw new Error("No screenshot selection is active."); + if (!input) { + finish(); + return { success: true, canceled: true }; + } + try { + const frame = frames.get(input.displayId); + if (!frame) throw new Error("The display is no longer available."); + const image = frame.image.crop( + screenshotPixelRect(input.rect, frame.bounds, frame.image.getSize()), + ); + openDocument(image, false); + finish(); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + ipcMain.handle("get-screenshot-document", (event) => { + const doc = documents.get(event.sender.id); + if (!doc) throw new Error("No screenshot belongs to this window."); + return { dataUrl: doc.image.toDataURL(), ...doc.image.getSize(), pinned: doc.pinned }; + }); + ipcMain.handle( + "screenshot-action", + async ( + event, + action: "copy" | "save" | "pin" | "close" | "opacity" | "zoom", + value?: string | number, + ) => { + const doc = documents.get(event.sender.id); + if (!doc) throw new Error("No screenshot belongs to this window."); + if (action === "close") { + doc.window.close(); + return { success: true }; + } + if (action === "opacity") { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0.2 || + value > 1 + ) + throw new Error("Invalid opacity."); + doc.window.setOpacity(value); + return { success: true }; + } + if (action === "zoom") { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0.1 || + value > 3 + ) + throw new Error("Invalid zoom."); + const size = doc.image.getSize(); + doc.window.setSize( + Math.max(100, Math.round(size.width * value)), + Math.max(80, Math.round(size.height * value) + 57), + ); + return { success: true }; + } + if ( + value !== undefined && + (typeof value !== "string" || !value.startsWith("data:image/png;base64,")) + ) + throw new Error("Expected a PNG image."); + const image = + typeof value === "string" ? nativeImage.createFromDataURL(value) : doc.image; + const originalSize = doc.image.getSize(); + const size = image.getSize(); + if ( + image.isEmpty() || + size.width !== originalSize.width || + size.height !== originalSize.height + ) + throw new Error("Screenshot pixels must match the captured dimensions."); + if (action === "copy") clipboard.writeImage(image); + else if (action === "save") { + const result = await dialog.showSaveDialog(doc.window, { + defaultPath: `Screenshot-${Date.now()}.png`, + filters: [{ name: "PNG image", extensions: ["png"] }], + }); + if (result.canceled || !result.filePath) return { success: true, canceled: true }; + await fs.writeFile(result.filePath, image.toPNG()); + } else if (action === "pin") openDocument(image, true, doc.window.getBounds()); + else throw new Error("Unknown screenshot action."); + return { success: true }; + }, + ); + return start; +} diff --git a/electron/windows.ts b/electron/windows.ts index 4dfbab27d..03ec07fa8 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1234,7 +1234,10 @@ export function closeCountdownWindow(): void { * Covers every display with a transparent overlay for picking what to record. * Each overlay renders the `capture-picker` window and reports back over IPC. */ -export function createCapturePickerWindows(onClosed: () => void): BrowserWindow[] { +export function createCapturePickerWindows( + onClosed: () => void, + purpose: "recording" | "screenshot" = "recording", +): BrowserWindow[] { closeCapturePickerWindows(); const cursorDisplayId = getScreen().getDisplayNearestPoint( getScreen().getCursorScreenPoint(), @@ -1295,7 +1298,7 @@ export function createCapturePickerWindows(onClosed: () => void): BrowserWindow[ if (wasOpen) onClosed(); }); - const query = { windowType: "capture-picker", displayId: String(display.id) }; + const query = { windowType: "capture-picker", displayId: String(display.id), purpose }; if (VITE_DEV_SERVER_URL) { win.loadURL(`${VITE_DEV_SERVER_URL}?${new URLSearchParams(query).toString()}`); } else { @@ -1310,6 +1313,16 @@ export function getCapturePickerWindows(): BrowserWindow[] { return [...capturePickerWindows]; } +let captureSelectionBusy = false; +export function beginCaptureSelection(): boolean { + if (captureSelectionBusy) return false; + captureSelectionBusy = true; + return true; +} +export function endCaptureSelection(): void { + captureSelectionBusy = false; +} + export function closeCapturePickerWindows(): void { const windows = capturePickerWindows; capturePickerWindows = []; diff --git a/src/App.tsx b/src/App.tsx index c37826613..714c5c816 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,9 @@ import { Card } from "@heroui/react"; import { lazy, Suspense, useEffect, useState } from "react"; -import { useI18n } from "./contexts/I18nContext"; -import { getStartupView } from "./lib/startupView"; import { HomeLoadingSkeleton } from "./components/video-editor/dashboard/HomeLoadingSkeleton"; import { EditorLoadingSkeleton } from "./components/video-editor/layout/EditorLoadingSkeleton"; +import { useI18n } from "./contexts/I18nContext"; +import { getStartupView } from "./lib/startupView"; const HudWindow = lazy(() => import("./components/launch/HudWindow")); const SourceSelector = lazy(() => @@ -23,6 +23,7 @@ const UpdateToastWindow = lazy(() => ); const EditorWindow = lazy(() => import("./components/video-editor/EditorWindow")); const CapturePicker = lazy(() => import("./components/capture-picker/CapturePicker")); +const ScreenshotWindow = lazy(() => import("./components/screenshot/ScreenshotWindow")); export default function App() { const [windowType] = useState( @@ -59,6 +60,13 @@ export default function App() { }, [windowType]); useEffect(() => { + if (windowType === "screenshot") { + document.title = + new URLSearchParams(window.location.search).get("pinned") === "true" + ? "Recordly Pin" + : "Recordly Screenshot"; + return; + } document.title = windowType === "editor" ? t("app.editorTitle", "Recordly Editor") @@ -67,6 +75,9 @@ export default function App() { let content; switch (windowType) { + case "screenshot": + content = ; + break; case "hud-overlay": content = ; break; diff --git a/src/components/capture-picker/CapturePicker.tsx b/src/components/capture-picker/CapturePicker.tsx index 3b5f779c1..237f65f27 100644 --- a/src/components/capture-picker/CapturePicker.tsx +++ b/src/components/capture-picker/CapturePicker.tsx @@ -1,16 +1,16 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; -import type { PointerEvent as ReactPointerEvent } from "react"; import { + AREA_HANDLES, + type AreaHandle, + type AreaRect, frontmostAt, MIN_AREA_SIZE, - normalizeAreaRect, moveAreaRect, - resizeAreaRect, + normalizeAreaRect, rectContains, - AREA_HANDLES, - type AreaHandle, - type AreaRect, + resizeAreaRect, } from "./areaGeometry"; type Target = @@ -25,6 +25,9 @@ function screenRect() { /** Chooses a source only. Recording remains controlled by the existing HUD. */ export function CapturePicker() { const displayId = Number(new URLSearchParams(window.location.search).get("displayId")); + const screenshot = new URLSearchParams(window.location.search).get("purpose") === "screenshot"; + const [background, setBackground] = useState(""); + const [error, setError] = useState(""); const [target, setTarget] = useState(null); const [allowArea, setAllowArea] = useState(false); const windows = useRef<{ window: CapturePickerWindow; rect: AreaRect }[]>([]); @@ -69,6 +72,32 @@ export function CapturePicker() { ) return; completed.current = true; + if (screenshot) { + void window.electronAPI + .completeScreenshotPick( + pick + ? { + displayId, + rect: { + ...pick.rect, + x: origin.current.x + pick.rect.x, + y: origin.current.y + pick.rect.y, + }, + } + : null, + ) + .then((result) => { + if (!result.success) { + completed.current = false; + setError(result.error ?? "Screenshot failed."); + } + }) + .catch((error) => { + completed.current = false; + setError(String(error)); + }); + return; + } const common = { displayId, record: false }; void window.electronAPI.completeCapturePick( !pick @@ -86,13 +115,22 @@ export function CapturePicker() { }, ); }, - [displayId], + [displayId, screenshot], ); useEffect(() => { let disposed = false; - void window.electronAPI - .getCapturePickerContext(displayId) - .then((context) => { + void ( + screenshot + ? window.electronAPI.getScreenshotContext(displayId) + : window.electronAPI.getCapturePickerContext(displayId) + ) + .then(async (context) => { + if ("background" in context && typeof context.background === "string") { + const image = new Image(); + image.src = context.background; + await image.decode(); + if (!disposed) setBackground(context.background); + } if (disposed) return; if (context.displayBounds) origin.current = context.displayBounds; setAllowArea(context.allowArea === true); @@ -130,7 +168,7 @@ export function CapturePicker() { return () => { disposed = true; }; - }, [displayId, targetAt, finish]); + }, [displayId, targetAt, finish, screenshot]); useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -217,7 +255,11 @@ export function CapturePicker() { if (editingArea.current) { editingArea.current = false; } - finish(targetAt(event.clientX, event.clientY)); + const selected = targetAt(event.clientX, event.clientY); + if (screenshot) { + editingArea.current = true; + update({ kind: "area", rect: selected.rect }); + } else finish(selected); }} onPointerCancel={() => { press.current = null; @@ -227,6 +269,14 @@ export function CapturePicker() { finish(null); }} > + {background && ( + Captured display + )} {target && (
+ {screenshot && ( + + )}
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f6ce0841d..374e14fb3 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -178,6 +178,25 @@ function LaunchWindowContent() { const pickerPending = useRef(false); const [isPickerPending, setIsPickerPending] = useState(false); const [sourceReady, setSourceReady] = useState(false); + // Main owns the mode so tray entries and this toolbar stay in sync. + const [captureMode, setCaptureMode] = useState<"editor" | "gif">("editor"); + useEffect(() => { + let mounted = true; + void window.electronAPI?.getCaptureOutputMode?.().then((mode) => { + if (mounted) setCaptureMode(mode); + }); + const cleanup = window.electronAPI?.onCaptureOutputModeChanged?.((mode) => { + if (mounted) setCaptureMode(mode); + }); + return () => { + mounted = false; + cleanup?.(); + }; + }, []); + const selectCaptureMode = useCallback((mode: "editor" | "gif") => { + setCaptureMode(mode); + void window.electronAPI?.setCaptureOutputMode?.(mode).then(setCaptureMode); + }, []); const handlePickOnScreen = useCallback(async () => { if (pickerPending.current || recording || finalizing) return; pickerPending.current = true; @@ -305,6 +324,56 @@ function LaunchWindowContent() { const idleControls = ( <> + +
+ + +
+ + + + + + Pinned screenshot { + event.preventDefault(); + void adjustPin( + "zoom", + Math.max(0.1, Math.min(3, zoom * (event.deltaY < 0 ? 1.1 : 1 / 1.1))), + ); + }} + /> + + {error &&

{error}

} + + ); + return ( +
+
+ + Screenshot{" "} + + {doc.width} × {doc.height} + + +
+ {(["rectangle", "arrow", "text", "mosaic", "number"] as const).map((kind) => ( + + ))} + + + +
+
+ + + + + + + +
+
+
+ { + if (event.button !== 0 || !colorValid) return; + const start = point(event); + const item: Annotation = { + kind: tool, + start, + end: start, + color, + text: + tool === "number" + ? String( + annotations.filter((item) => item.kind === "number") + .length + 1, + ) + : text, + }; + if (tool === "text" || tool === "number") { + commit(item); + return; + } + gesture.current = item; + setDraft(item); + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + if (gesture.current) { + gesture.current = { ...gesture.current, end: point(event) }; + setDraft(gesture.current); + } + }} + onPointerUp={(event) => { + if (gesture.current) commit({ ...gesture.current, end: point(event) }); + if (event.currentTarget.hasPointerCapture(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={() => { + gesture.current = null; + setDraft(null); + }} + /> +
+
+ {error || + (!colorValid + ? "Enter a valid HEX or RGB color." + : notice || + "Draw on the image · Ctrl/Cmd+Z undo · Ctrl/Cmd+C copy · Ctrl/Cmd+S save")} +
+
+ ); +} diff --git a/src/components/screenshot/drawing.ts b/src/components/screenshot/drawing.ts new file mode 100644 index 000000000..4dcb51467 --- /dev/null +++ b/src/components/screenshot/drawing.ts @@ -0,0 +1,69 @@ +export type Point = { x: number; y: number }; +export type Annotation = { + kind: "rectangle" | "arrow" | "text" | "mosaic" | "number"; + start: Point; + end: Point; + color: string; + text: string; +}; + +export function drawScreenshot( + ctx: CanvasRenderingContext2D, + image: CanvasImageSource, + annotations: Annotation[], +) { + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.drawImage(image, 0, 0, ctx.canvas.width, ctx.canvas.height); + for (const item of annotations) { + ctx.save(); + ctx.strokeStyle = item.color; + ctx.fillStyle = item.color; + ctx.lineWidth = 3; + ctx.lineCap = "round"; + ctx.font = "22px sans-serif"; + const x = Math.min(item.start.x, item.end.x), + y = Math.min(item.start.y, item.end.y); + const width = Math.abs(item.end.x - item.start.x), + height = Math.abs(item.end.y - item.start.y); + if (item.kind === "rectangle") ctx.strokeRect(x, y, width, height); + else if (item.kind === "arrow") { + const angle = Math.atan2(item.end.y - item.start.y, item.end.x - item.start.x); + ctx.beginPath(); + ctx.moveTo(item.start.x, item.start.y); + ctx.lineTo(item.end.x, item.end.y); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(item.end.x, item.end.y); + ctx.lineTo( + item.end.x - 18 * Math.cos(angle - Math.PI / 6), + item.end.y - 18 * Math.sin(angle - Math.PI / 6), + ); + ctx.lineTo( + item.end.x - 18 * Math.cos(angle + Math.PI / 6), + item.end.y - 18 * Math.sin(angle + Math.PI / 6), + ); + ctx.closePath(); + ctx.fill(); + } else if (item.kind === "text") ctx.fillText(item.text, item.start.x, item.start.y + 22); + else if (item.kind === "number") { + ctx.beginPath(); + ctx.arc(item.start.x, item.start.y, 15, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 19px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(item.text, item.start.x, item.start.y); + } else if (item.kind === "mosaic" && width >= 1 && height >= 1) { + const small = document.createElement("canvas"); + small.width = Math.max(1, Math.ceil(width / 14)); + small.height = Math.max(1, Math.ceil(height / 14)); + const smallCtx = small.getContext("2d"); + if (!smallCtx) throw new Error("Could not create the mosaic canvas."); + smallCtx.drawImage(ctx.canvas, x, y, width, height, 0, 0, small.width, small.height); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(small, 0, 0, small.width, small.height, x, y, width, height); + } + ctx.restore(); + } +} diff --git a/src/components/screenshot/screenshot.css b/src/components/screenshot/screenshot.css new file mode 100644 index 000000000..3735a024c --- /dev/null +++ b/src/components/screenshot/screenshot.css @@ -0,0 +1,135 @@ +.screenshot-app { + height: 100dvh; + display: flex; + flex-direction: column; + background: #18181b; + color: #fafafa; + font: + 13px system-ui, + sans-serif; +} +.screenshot-toolbar { + padding: 14px 16px 10px; + border-bottom: 1px solid #3f3f46; + display: flex; + flex-direction: column; + gap: 10px; +} +.screenshot-toolbar strong { + font-size: 15px; +} +.screenshot-toolbar small { + margin-left: 12px; + color: #a1a1aa; + font-size: 12px; + font-weight: normal; +} +.screenshot-tools { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} +.screenshot-app button { + border: 1px solid #52525b; + border-radius: 6px; + padding: 5px 10px; + background: #27272a; + cursor: pointer; + color: inherit; + white-space: nowrap; +} +.screenshot-app button:disabled { + opacity: 0.4; + cursor: default; +} +.screenshot-app button[aria-pressed="true"] { + background: #2563eb; + border-color: #60a5fa; +} +.screenshot-app button:focus-visible, +.screenshot-app input:focus-visible { + outline: 2px solid #60a5fa; + outline-offset: 2px; +} +.screenshot-tools label { + display: flex; + align-items: center; + gap: 5px; + color: #d4d4d8; +} +.screenshot-tools input { + width: 145px; + border: 1px solid #52525b; + border-radius: 4px; + background: #09090b; + color: white; + padding: 4px 6px; +} +.color-preview { + width: 18px; + height: 18px; + border: 1px solid #a1a1aa; + border-radius: 50%; +} +.toolbar-spacer { + flex: 1; +} +.screenshot-canvas-area { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 18px; + background: #09090b; +} +.screenshot-canvas-area canvas { + max-width: 100%; + max-height: 100%; + object-fit: contain; + width: auto; + height: auto; + touch-action: none; + cursor: crosshair; + box-shadow: 0 8px 30px #0008; +} +.screenshot-app footer { + padding: 7px 16px; + min-height: 30px; + color: #a1a1aa; +} +.pin-bar { + height: 32px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 6px; + background: #27272a; + font-size: 11px; + color: #a1a1aa; +} +.pin-bar button { + border: 0; + padding: 2px 6px; +} +.pinned-image { + flex: 1; + min-height: 0; + width: 100%; + object-fit: contain; +} +.pin-opacity { + display: flex; + gap: 8px; + align-items: center; + padding: 3px 8px; + height: 25px; + font-size: 10px; +} +.pin-opacity input { + flex: 1; + min-width: 30px; +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 89974d144..8ce6f4035 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1020,6 +1020,11 @@ export function SettingsPanel({ ); const [experimentalUpdatesEnabled, setExperimentalUpdatesEnabled] = useState(false); const [savingExperimentalUpdates, setSavingExperimentalUpdates] = useState(false); + const [lightweightMode, setLightweightMode] = useState<{ + enabled: boolean; + active: boolean; + } | null>(null); + const [savingLightweightMode, setSavingLightweightMode] = useState(false); const { openConfig: openShortcutsConfig } = useShortcuts(); const [internalActiveEffectSection] = useState("scene"); const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; @@ -1078,6 +1083,47 @@ export function SettingsPanel({ } }; + useEffect(() => { + let cancelled = false; + void window.electronAPI + .getLightweightMode() + .then((mode) => { + if (!cancelled) setLightweightMode(mode); + }) + .catch((error) => { + console.error("Failed to load lightweight mode preference:", error); + }); + return () => { + cancelled = true; + }; + }, []); + + const updateLightweightModePreference = async (enabled: boolean) => { + if (!lightweightMode) return; + const previous = lightweightMode; + setLightweightMode({ ...previous, enabled }); + setSavingLightweightMode(true); + try { + const result = await window.electronAPI.setLightweightMode(enabled); + setLightweightMode({ ...previous, enabled: result.enabled }); + if (!result.success) { + toast.error( + result.error || + tSettings("performance.saveFailed", "Failed to change lightweight mode."), + ); + } + } catch (error) { + setLightweightMode(previous); + toast.error( + tSettings("performance.saveFailed", "Failed to change lightweight mode.") + + " " + + String(error), + ); + } finally { + setSavingLightweightMode(false); + } + }; + useEffect(() => { if ( !isBackgroundPanel && @@ -2317,6 +2363,30 @@ export function SettingsPanel({ + + + + void updateLightweightModePreference(enabled) + } + aria-label={tSettings("performance.lightweight", "Lightweight mode")} + /> + {advanced && ( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 4b30813a7..32b6a5f6d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -748,6 +748,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } setFinalizing(false); + let handledAsGif = false; + try { + handledAsGif = (await window.electronAPI.completeGifCapture(videoPath)).handled; + } catch (error) { + console.error("GIF capture failed; opening the editor instead:", error); + } + if (handledAsGif) return; await window.electronAPI.switchToEditor(); console.log( `[PERF:RENDERER] Finalize Session & Switch to Editor: COMPLETED in ${(performance.now() - start).toFixed(2)}ms`, diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 3dae35b70..391963cf1 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -258,5 +258,11 @@ "title": "Updates", "experimental": "Experimentelle Updates", "saveFailed": "Update-Kanal konnte nicht geändert werden." + }, + "performance": { + "lightweight": "Leichtgewichtsmodus", + "lightweightDescription": "Im Leerlauf nur im Tray mit minimalem Speicher; Recorder und Editor öffnen bei Bedarf. Ausschalten für schnellere GPU-beschleunigte Exporte.", + "lightweightRestart": "Starte Recordly neu, um die Änderung anzuwenden.", + "saveFailed": "Leichtgewichtsmodus konnte nicht geändert werden." } } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9db0e74eb..6dc1d5758 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -258,5 +258,11 @@ "micLabel": "Source Mic", "mixedLabel": "Source", "deleteRegion": "Delete Audio" + }, + "performance": { + "lightweight": "Lightweight mode", + "lightweightDescription": "Idle in the tray with minimal memory; the recorder and editor open on demand. Turn off for faster GPU-accelerated exports.", + "lightweightRestart": "Restart Recordly to apply this change.", + "saveFailed": "Failed to change lightweight mode." } } diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 5eafdfdde..c376b3acc 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -258,5 +258,11 @@ "title": "Actualizaciones", "experimental": "Actualizaciones experimentales", "saveFailed": "No se pudo cambiar el canal de actualización." + }, + "performance": { + "lightweight": "Modo ligero", + "lightweightDescription": "En reposo solo queda en la bandeja con memoria mínima; la grabadora y el editor se abren al usarlos. Desactívalo para exportar más rápido con GPU.", + "lightweightRestart": "Reinicia Recordly para aplicar el cambio.", + "saveFailed": "No se pudo cambiar el modo ligero." } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 26283e789..f3a6a46d7 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -258,5 +258,11 @@ "title": "Mises à jour", "experimental": "Mises à jour expérimentales", "saveFailed": "Impossible de changer le canal de mise à jour." + }, + "performance": { + "lightweight": "Mode léger", + "lightweightDescription": "Au repos, seule l’icône de la barre système reste, avec un minimum de mémoire ; l’enregistreur et l’éditeur s’ouvrent à la demande. Désactivez-le pour des exports accélérés par GPU.", + "lightweightRestart": "Redémarrez Recordly pour appliquer ce changement.", + "saveFailed": "Impossible de changer le mode léger." } } diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index b43fdba9a..eaef3ee5f 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -258,5 +258,11 @@ "title": "Aggiornamenti", "experimental": "Aggiornamenti sperimentali", "saveFailed": "Impossibile cambiare il canale di aggiornamento." + }, + "performance": { + "lightweight": "Modalità leggera", + "lightweightDescription": "A riposo resta solo nella barra di sistema con memoria minima; registratore ed editor si aprono quando servono. Disattivala per esportazioni più veloci con GPU.", + "lightweightRestart": "Riavvia Recordly per applicare la modifica.", + "saveFailed": "Impossibile cambiare la modalità leggera." } } diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 33488fc0b..c0cc00e05 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -258,5 +258,11 @@ "title": "업데이트", "experimental": "실험적 업데이트", "saveFailed": "업데이트 채널을 변경하지 못했습니다." + }, + "performance": { + "lightweight": "경량 모드", + "lightweightDescription": "대기 중에는 트레이에만 상주하여 메모리를 최소화하고, 녹화기와 편집기는 필요할 때 열립니다. 끄면 GPU 가속 내보내기가 빨라집니다.", + "lightweightRestart": "변경 사항을 적용하려면 Recordly를 다시 시작하세요.", + "saveFailed": "경량 모드를 변경하지 못했습니다." } } diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index 9d863cb53..189a498e8 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -258,5 +258,11 @@ "title": "Updates", "experimental": "Experimentele updates", "saveFailed": "Kan het updatekanaal niet wijzigen." + }, + "performance": { + "lightweight": "Lichte modus", + "lightweightDescription": "In rust alleen in het systeemvak met minimaal geheugen; recorder en editor openen wanneer nodig. Zet uit voor snellere GPU-versnelde exports.", + "lightweightRestart": "Start Recordly opnieuw om deze wijziging toe te passen.", + "saveFailed": "Kan de lichte modus niet wijzigen." } } diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 7541913a7..4dd80d9f8 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -258,5 +258,11 @@ "title": "Atualizações", "experimental": "Atualizações experimentais", "saveFailed": "Falha ao alterar o canal de atualização." + }, + "performance": { + "lightweight": "Modo leve", + "lightweightDescription": "Em repouso fica só na bandeja com memória mínima; o gravador e o editor abrem quando necessário. Desative para exportações mais rápidas com GPU.", + "lightweightRestart": "Reinicie o Recordly para aplicar a alteração.", + "saveFailed": "Falha ao alterar o modo leve." } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index aadb70e02..1252d389a 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -258,5 +258,11 @@ "experimental": "Экспериментальные обновления", "saveFailed": "Не удалось изменить канал обновлений.", "experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска." + }, + "performance": { + "lightweight": "Лёгкий режим", + "lightweightDescription": "В простое остаётся только в трее с минимумом памяти; рекордер и редактор открываются по запросу. Отключите для более быстрого экспорта с GPU.", + "lightweightRestart": "Перезапустите Recordly, чтобы применить изменение.", + "saveFailed": "Не удалось изменить лёгкий режим." } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ba4a68c6a..462a0ad6b 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -258,5 +258,11 @@ "title": "更新", "experimental": "实验性更新", "saveFailed": "无法更改更新频道。" + }, + "performance": { + "lightweight": "轻量模式", + "lightweightDescription": "空闲时仅驻留托盘,内存占用最低;录制器和编辑器按需打开。关闭后导出可使用 GPU 加速。", + "lightweightRestart": "重启 Recordly 后生效。", + "saveFailed": "无法更改轻量模式。" } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index b7b944030..28dfbb216 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -258,5 +258,11 @@ "title": "更新", "experimental": "實驗性更新", "saveFailed": "無法變更更新頻道。" + }, + "performance": { + "lightweight": "輕量模式", + "lightweightDescription": "閒置時僅常駐系統匣,記憶體占用最低;錄製器與編輯器按需開啟。關閉後匯出可使用 GPU 加速。", + "lightweightRestart": "重新啟動 Recordly 後生效。", + "saveFailed": "無法變更輕量模式。" } } diff --git a/src/lib/recordingShortcuts.ts b/src/lib/recordingShortcuts.ts index 23fc5a86c..877336d22 100644 --- a/src/lib/recordingShortcuts.ts +++ b/src/lib/recordingShortcuts.ts @@ -4,6 +4,7 @@ export const DEFAULT_RECORDING_SHORTCUTS = { toggleHud: "CommandOrControl+Alt+H", cancelRecording: "CommandOrControl+Alt+Shift+X", openCapturePicker: "CommandOrControl+Shift+2", + takeScreenshot: "CommandOrControl+Alt+S", }; export type RecordingShortcutAction = keyof typeof DEFAULT_RECORDING_SHORTCUTS; @@ -19,6 +20,7 @@ export const RECORDING_SHORTCUT_LABELS: Record toggleHud: "Show / hide controls", cancelRecording: "Cancel recording (with confirmation)", openCapturePicker: "Choose recording source", + takeScreenshot: "Take screenshot", }; // A deliberately small accelerator grammar makes configuration errors explicit. diff --git a/src/lib/screenshot.test.ts b/src/lib/screenshot.test.ts new file mode 100644 index 000000000..6e60cfe26 --- /dev/null +++ b/src/lib/screenshot.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { screenshotPixelRect } from "./screenshot"; + +describe("screenshot DIP to physical pixel crop", () => { + it("handles a negative-origin Retina display without global scaling", () => { + expect( + screenshotPixelRect( + { x: -1550, y: 70, width: 301, height: 199 }, + { x: -1600, y: 0, width: 1600, height: 900 }, + { width: 3200, height: 1800 }, + ), + ).toEqual({ x: 100, y: 140, width: 602, height: 398 }); + }); + it("uses rounded endpoints at fractional DPI and clips to one display", () => { + expect( + screenshotPixelRect( + { x: 99, y: -10, width: 104, height: 111 }, + { x: 100, y: 0, width: 800, height: 600 }, + { width: 1000, height: 750 }, + ), + ).toEqual({ x: 0, y: 0, width: 129, height: 126 }); + }); + it.each([ + { x: NaN, y: 1, width: 10, height: 10 }, + { x: 1, y: 1, width: -1, height: 10 }, + { x: 5000, y: 0, width: 10, height: 10 }, + ])("rejects invalid and disjoint selections", (rect) => { + expect(() => + screenshotPixelRect( + rect, + { x: 0, y: 0, width: 800, height: 600 }, + { width: 800, height: 600 }, + ), + ).toThrow(); + }); +}); diff --git a/src/lib/screenshot.ts b/src/lib/screenshot.ts new file mode 100644 index 000000000..1c0e7dd87 --- /dev/null +++ b/src/lib/screenshot.ts @@ -0,0 +1,35 @@ +export type ScreenshotRect = { x: number; y: number; width: number; height: number }; +export type ScreenshotDocument = { + dataUrl: string; + width: number; + height: number; + pinned: boolean; +}; +export type ScreenshotResult = { success: boolean; canceled?: boolean; error?: string }; + +/** Display coordinates are DIP; PNG crop coordinates are physical pixels. */ +export function screenshotPixelRect( + rect: ScreenshotRect, + display: ScreenshotRect, + pixels: { width: number; height: number }, +): ScreenshotRect { + if ( + ![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) || + rect.width <= 0 || + rect.height <= 0 + ) + throw new Error("Choose a non-empty screenshot area."); + const left = Math.max(display.x, rect.x); + const top = Math.max(display.y, rect.y); + const right = Math.min(display.x + display.width, rect.x + rect.width); + const bottom = Math.min(display.y + display.height, rect.y + rect.height); + if (right <= left || bottom <= top) throw new Error("The area is outside this display."); + const x = Math.round(((left - display.x) * pixels.width) / display.width); + const y = Math.round(((top - display.y) * pixels.height) / display.height); + return { + x, + y, + width: Math.round(((right - display.x) * pixels.width) / display.width) - x, + height: Math.round(((bottom - display.y) * pixels.height) / display.height) - y, + }; +} diff --git a/tests/ui/bridge.ts b/tests/ui/bridge.ts index a491d9d2d..5279d6664 100644 --- a/tests/ui/bridge.ts +++ b/tests/ui/bridge.ts @@ -32,6 +32,7 @@ export async function installDesktopBridge( toggleHud: "CommandOrControl+Alt+H", cancelRecording: "CommandOrControl+Alt+Shift+X", openCapturePicker: "CommandOrControl+Shift+2", + takeScreenshot: "CommandOrControl+Alt+S", }), ), errors: [], diff --git a/tests/ui/screenshot.spec.ts b/tests/ui/screenshot.spec.ts new file mode 100644 index 000000000..97a7bdcb1 --- /dev/null +++ b/tests/ui/screenshot.spec.ts @@ -0,0 +1,155 @@ +import { expect, test } from "@playwright/test"; +import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge"; + +async function installScreenshot(page: import("@playwright/test").Page, pinned = false) { + await installDesktopBridge(page); + await installDesktopBridgeOverrides( + page, + (pinned) => { + const image = () => { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 360; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, 640, 360); + ctx.fillStyle = "#111111"; + for (let y = 0; y < 360; y += 8) + for (let x = 0; x < 640; x += 8) + if ((x + y) % 16 === 0) ctx.fillRect(x, y, 8, 8); + return canvas.toDataURL(); + }; + Object.assign(window.electronAPI, { + getScreenshotDocument: async () => ({ + dataUrl: image(), + width: 640, + height: 360, + pinned, + }), + getScreenshotContext: async () => ({ + displayBounds: { x: -1440, y: 0, width: 1440, height: 1000 }, + allowArea: true, + windows: [], + cursor: null, + background: image(), + }), + completeScreenshotPick: async (pick: unknown) => { + document.documentElement.dataset.screenshotPick = JSON.stringify(pick); + return { success: true }; + }, + screenshotAction: async (action: string, value?: string | number) => { + document.documentElement.dataset.screenshotAction = action; + if (typeof value === "string") + document.documentElement.dataset.exportedPng = value; + if (typeof value === "number") + document.documentElement.dataset.pinValue = String(value); + return { success: true }; + }, + }); + }, + pinned, + ); +} + +test("Windows screenshot mode draws areas on the frozen display without changing recording selection", async ({ + page, +}) => { + await installScreenshot(page); + await page.goto("/?windowType=capture-picker&purpose=screenshot&displayId=1"); + await expect(page.getByAltText("Captured display")).toBeVisible(); + await page.mouse.move(200, 200); + await page.mouse.down(); + await page.mouse.move(600, 450, { steps: 8 }); + await page.mouse.up(); + await page.getByRole("button", { name: "Capture screenshot", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute( + "data-screenshot-pick", + JSON.stringify({ displayId: 1, rect: { x: -1240, y: 200, width: 400, height: 250 } }), + ); + await expect(page.locator("html")).not.toHaveAttribute("data-pick"); +}); + +test("annotations are flattened into identical copy/save/pin PNGs; undo and redo restore pixels", async ({ + page, +}) => { + await installScreenshot(page); + await page.goto("/?windowType=screenshot"); + const canvas = page.getByLabel("Screenshot canvas"); + await expect(canvas).toBeVisible(); + const original = await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL()); + const box = (await canvas.boundingBox())!; + await page.getByLabel("Annotation color").fill("rgb(255,0,0)"); + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.7, { steps: 8 }); + await page.mouse.up(); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect + .poll(() => canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())) + .toBe(original); + await page.getByRole("button", { name: "Redo", exact: true }).click(); + await expect + .poll(() => canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())) + .not.toBe(original); + await page.getByRole("button", { name: "Text", exact: true }).click(); + await page.getByLabel("Annotation text").fill("Review this"); + await canvas.click({ position: { x: box.width * 0.3, y: box.height * 0.3 } }); + const textBefore = await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL()); + await page.getByLabel("Annotation text").fill("Updated label"); + await expect + .poll(() => canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())) + .not.toBe(textBefore); + await page.getByRole("button", { name: "Arrow", exact: true }).click(); + await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.3); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.2, { steps: 4 }); + await page.mouse.up(); + await page.getByRole("button", { name: "Number", exact: true }).click(); + await canvas.click({ position: { x: box.width * 0.8, y: box.height * 0.5 } }); + await page.getByRole("button", { name: "Copy image", exact: true }).click(); + const png = await page.locator("html").getAttribute("data-exported-png"); + expect(png).toMatch(/^data:image\/png;base64,/); + expect(png).not.toBe(original); + await page.getByRole("button", { name: "Save PNG", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-exported-png", png!); + await page.getByRole("button", { name: "Pin to desktop", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-exported-png", png!); + await page.screenshot({ path: "test-results/screenshot-editor.png" }); +}); + +test("mosaic changes captured pixels and invalid colors do not draw", async ({ page }) => { + await installScreenshot(page); + await page.goto("/?windowType=screenshot"); + const canvas = page.getByLabel("Screenshot canvas"); + await expect(canvas).toBeVisible(); + const original = await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL()); + await page.getByLabel("Annotation color").fill("definitely invalid"); + await canvas.click(); + await expect(page.getByLabel("Annotation color")).toHaveAttribute("aria-invalid", "true"); + expect(await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())).toBe(original); + await page.getByLabel("Annotation color").fill("#ff0000"); + await page.getByRole("button", { name: "Mosaic", exact: true }).click(); + const box = (await canvas.boundingBox())!; + await page.mouse.move(box.x + 50, box.y + 50); + await page.mouse.down(); + await page.mouse.move(box.x + 250, box.y + 180, { steps: 5 }); + await page.mouse.up(); + await expect + .poll(() => canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())) + .not.toBe(original); +}); + +test("a pinned image exposes copy, opacity, wheel zoom and close", async ({ page }) => { + await installScreenshot(page, true); + await page.goto("/?windowType=screenshot&pinned=true"); + await expect(page.getByAltText("Pinned screenshot")).toBeVisible(); + await page.getByLabel("Pin opacity").fill("0.5"); + await expect(page.locator("html")).toHaveAttribute("data-pin-value", "0.5"); + await page.getByAltText("Pinned screenshot").hover(); + await page.mouse.wheel(0, -100); + await expect(page.locator("html")).toHaveAttribute("data-screenshot-action", "zoom"); + await page.getByRole("button", { name: "Copy", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-screenshot-action", "copy"); + await page.getByRole("button", { name: "Close pin", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-screenshot-action", "close"); +});