From a6581c872ab8fc042dceb8a82e74cace22b11a5e Mon Sep 17 00:00:00 2001 From: bekkilove <921693422@qq.com> Date: Thu, 15 Oct 2026 22:22:30 +0800 Subject: [PATCH 1/5] feat(capture): add screenshots, annotations and desktop pins --- docs/pr/screenshot-and-pin/editor.png | Bin 0 -> 29066 bytes docs/pr/screenshot-and-pin/native-pin.png | Bin 0 -> 4495 bytes docs/screenshot-and-pin.md | 90 +++++ electron/electron-env.d.ts | 20 + electron/ipc/register/capturePicker.ts | 9 +- electron/main.ts | 19 +- electron/preload.ts | 11 + electron/recordingShortcuts.test.ts | 19 +- electron/recordingShortcuts.ts | 10 +- electron/screenshot.test.ts | 197 ++++++++++ electron/screenshot.ts | 248 ++++++++++++ electron/windows.ts | 7 +- src/App.tsx | 15 +- .../capture-picker/CapturePicker.tsx | 98 ++++- src/components/launch/LaunchWindow.tsx | 19 + .../screenshot/ScreenshotWindow.tsx | 366 ++++++++++++++++++ src/components/screenshot/drawing.ts | 69 ++++ src/components/screenshot/screenshot.css | 135 +++++++ src/lib/recordingShortcuts.ts | 2 + src/lib/screenshot.test.ts | 36 ++ src/lib/screenshot.ts | 35 ++ tests/ui/bridge.ts | 1 + tests/ui/screenshot.spec.ts | 155 ++++++++ 23 files changed, 1523 insertions(+), 38 deletions(-) create mode 100644 docs/pr/screenshot-and-pin/editor.png create mode 100644 docs/pr/screenshot-and-pin/native-pin.png create mode 100644 docs/screenshot-and-pin.md create mode 100644 electron/screenshot.test.ts create mode 100644 electron/screenshot.ts create mode 100644 src/components/screenshot/ScreenshotWindow.tsx create mode 100644 src/components/screenshot/drawing.ts create mode 100644 src/components/screenshot/screenshot.css create mode 100644 src/lib/screenshot.test.ts create mode 100644 src/lib/screenshot.ts create mode 100644 tests/ui/screenshot.spec.ts diff --git a/docs/pr/screenshot-and-pin/editor.png b/docs/pr/screenshot-and-pin/editor.png new file mode 100644 index 0000000000000000000000000000000000000000..da076357b4cf649db9554429bcfc456868f8b2c7 GIT binary patch literal 29066 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 F{2%Xjs+<4- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ced737e8c769d28d51c29fbdff67fbc09dc5a913 GIT binary patch literal 4495 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 X?sQ$16I+-4sRJ<3G1acrbPoR?mja^< literal 0 HcmV?d00001 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..3a96eb82e 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; diff --git a/electron/ipc/register/capturePicker.ts b/electron/ipc/register/capturePicker.ts index 5f471b03c..f92c7bf2f 100644 --- a/electron/ipc/register/capturePicker.ts +++ b/electron/ipc/register/capturePicker.ts @@ -1,15 +1,16 @@ -import { validateCaptureSource } from "../captureSelection"; -import { listCapturePickerWindows } from "../capturePickerSources"; import { ipcMain } from "electron"; +import { isScreenshotSelectionActive } from "../../screenshot"; import type { SourceListPickResult } from "../../sourceListPicker"; import { closeCapturePickerWindows, createCapturePickerWindows, 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"; @@ -120,6 +121,8 @@ export function registerCapturePickerHandlers({ return { success: true, source }; }; ipcMain.handle("pick-capture-target", () => { + if (isScreenshotSelectionActive()) + return { success: false, message: "Finish the screenshot selection first." }; if (isCursorCaptureActive) return { success: false, canceled: true }; if (!activePick) { activePick = pickTarget() diff --git a/electron/main.ts b/electron/main.ts index 2cbce01e3..288a43bb1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -35,6 +35,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, @@ -928,11 +929,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, [ @@ -1073,6 +1069,19 @@ app.whenReady().then(async () => { }, ); + const takeScreenshot = registerScreenshotHandlers(); + registerRecordingShortcuts((action) => { + if (action === "takeScreenshot") { + void takeScreenshot().catch((error) => + dialog.showErrorBox("Screenshot failed", String(error)), + ); + return; + } + void 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) { diff --git a/electron/preload.ts b/electron/preload.ts index b3125da17..371fd21fa 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); }, 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..99028ba4e --- /dev/null +++ b/electron/screenshot.test.ts @@ -0,0 +1,197 @@ +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(), + write: vi.fn(), + clipboard: vi.fn(), + save: vi.fn(), + create: vi.fn(), + close: vi.fn(), + pickers: [] as object[], + 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", () => ({ + getHudOverlayWindow: () => mocks.hud, + getCapturePickerWindows: () => mocks.pickers, + 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.pickers = []; + 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.pickers = [{}]; + 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..b03b31c5c --- /dev/null +++ b/electron/screenshot.ts @@ -0,0 +1,248 @@ +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 { + closeCapturePickerWindows, + createCapturePickerWindows, + getCapturePickerWindows, + 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 }; +let picking = false; +export function isScreenshotSelectionActive() { + return picking; +} + +export function registerScreenshotHandlers() { + const frames = new Map(); + const documents = new Map(); + let restoreHud = false; + let windows: Awaited> = []; + const finish = () => { + closeCapturePickerWindows(); + frames.clear(); + picking = false; + 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 (picking || getCapturePickerWindows().length) + throw new Error("Finish the current selection first."); + 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."); + 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..3149ea49e 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 { 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..1150eb5b2 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -305,6 +305,25 @@ 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/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"); +}); From 848210886588159d7bdd5530bac7107b1b547ffd Mon Sep 17 00:00:00 2001 From: bekkilove <921693422@qq.com> Date: Thu, 15 Oct 2026 22:33:27 +0800 Subject: [PATCH 2/5] fix(capture): reserve shared picker before asynchronous startup --- electron/ipc/register/capturePicker.test.ts | 17 +++++++++++++++++ electron/ipc/register/capturePicker.ts | 8 +++++--- electron/screenshot.test.ts | 10 ++++++---- electron/screenshot.ts | 12 +++++------- electron/windows.ts | 10 ++++++++++ 5 files changed, 43 insertions(+), 14 deletions(-) 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 f92c7bf2f..a49ece709 100644 --- a/electron/ipc/register/capturePicker.ts +++ b/electron/ipc/register/capturePicker.ts @@ -1,9 +1,10 @@ import { ipcMain } from "electron"; -import { isScreenshotSelectionActive } from "../../screenshot"; import type { SourceListPickResult } from "../../sourceListPicker"; import { + beginCaptureSelection, closeCapturePickerWindows, createCapturePickerWindows, + endCaptureSelection, isCapturePickerWebContents, } from "../../windows"; import { listCapturePickerWindows } from "../capturePickerSources"; @@ -121,10 +122,10 @@ export function registerCapturePickerHandlers({ return { success: true, source }; }; ipcMain.handle("pick-capture-target", () => { - if (isScreenshotSelectionActive()) - return { success: false, message: "Finish the screenshot selection first." }; 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); @@ -139,6 +140,7 @@ export function registerCapturePickerHandlers({ }) .finally(() => { activePick = null; + endCaptureSelection(); }); } return activePick; diff --git a/electron/screenshot.test.ts b/electron/screenshot.test.ts index 99028ba4e..43191a208 100644 --- a/electron/screenshot.test.ts +++ b/electron/screenshot.test.ts @@ -25,12 +25,13 @@ const mocks = vi.hoisted(() => { 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(), - pickers: [] as object[], overlay: {}, recording: false, hud: { @@ -75,8 +76,9 @@ vi.mock("./ipc/utils", () => ({ }), })); vi.mock("./windows", () => ({ + beginCaptureSelection: mocks.begin, + endCaptureSelection: mocks.end, getHudOverlayWindow: () => mocks.hud, - getCapturePickerWindows: () => mocks.pickers, createCapturePickerWindows: mocks.create, closeCapturePickerWindows: mocks.close, isCapturePickerWebContents: (sender: object) => sender === mocks.overlay, @@ -115,7 +117,7 @@ describe("screenshot native IPC and resource ownership", () => { mocks.handlers.clear(); mocks.instances.length = 0; mocks.recording = false; - mocks.pickers = []; + 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")); @@ -190,7 +192,7 @@ describe("screenshot native IPC and resource ownership", () => { mocks.recording = true; expect(await invoke("start-screenshot", {})).toMatchObject({ success: false }); mocks.recording = false; - mocks.pickers = [{}]; + 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 index b03b31c5c..386a12b80 100644 --- a/electron/screenshot.ts +++ b/electron/screenshot.ts @@ -15,9 +15,10 @@ import { listCapturePickerWindows } from "./ipc/capturePickerSources"; import { isCursorCaptureActive } from "./ipc/state"; import { getScreen } from "./ipc/utils"; import { + beginCaptureSelection, closeCapturePickerWindows, createCapturePickerWindows, - getCapturePickerWindows, + endCaptureSelection, getHudOverlayWindow, isCapturePickerWebContents, } from "./windows"; @@ -25,12 +26,9 @@ import { const mainDir = path.dirname(fileURLToPath(import.meta.url)); type Frame = { image: NativeImage; bounds: ScreenshotRect }; type Document = { image: NativeImage; window: BrowserWindow; pinned: boolean }; -let picking = false; -export function isScreenshotSelectionActive() { - return picking; -} export function registerScreenshotHandlers() { + let picking = false; const frames = new Map(); const documents = new Map(); let restoreHud = false; @@ -39,6 +37,7 @@ export function registerScreenshotHandlers() { closeCapturePickerWindows(); frames.clear(); picking = false; + endCaptureSelection(); const hud = getHudOverlayWindow(); if (restoreHud && hud && !hud.isDestroyed()) hud.showInactive(); restoreHud = false; @@ -88,12 +87,11 @@ export function registerScreenshotHandlers() { return win; }; const start = async () => { - if (picking || getCapturePickerWindows().length) - throw new Error("Finish the current selection first."); 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()); diff --git a/electron/windows.ts b/electron/windows.ts index 3149ea49e..03ec07fa8 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1313,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 = []; From a3b64fc788510617c466ea5e900937cbc927ca1b Mon Sep 17 00:00:00 2001 From: bekkilove <921693422@qq.com> Date: Fri, 16 Oct 2026 13:18:27 +0800 Subject: [PATCH 3/5] fix(ffmpeg): probe bundled binary before use and fall back to system FFmpeg existsSync alone accepted an unrunnable ffmpeg-static binary (EFTYPE), breaking every FFmpeg feature. Probe once with -version and cache the result. --- electron/ipc/ffmpeg/binary.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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(); From 1df1d36421147353f49fb6f1cd13b369f8fea0e0 Mon Sep 17 00:00:00 2001 From: bekkilove <921693422@qq.com> Date: Fri, 16 Oct 2026 13:18:28 +0800 Subject: [PATCH 4/5] feat(capture): lightweight tray-resident mode and direct region GIF capture - Lightweight mode (default on, settings toggle, restart to apply) skips aggressive global GPU switches and starts tray-only without the HUD renderer; idle Private memory ~145 MB vs ~1220 MB. - HUD is created on demand and destroyed after editor/GIF flows in lightweight mode. - Tray menu: Screenshot / Record GIF / Record Video / Open Recorder / Quit. - Region GIF capture converts the recording in the main process via FFmpeg (palettegen/paletteuse, 15 fps, max width 960) without opening the editor. --- electron/electron-env.d.ts | 9 + electron/gifCapture.test.ts | 28 +++ electron/gifCapture.ts | 45 +++++ electron/gpuSwitches.test.ts | 23 ++- electron/gpuSwitches.ts | 31 ++++ electron/main.ts | 167 ++++++++++++++++-- electron/preload.ts | 9 + src/components/video-editor/SettingsPanel.tsx | 70 ++++++++ src/hooks/useScreenRecorder.ts | 7 + src/i18n/locales/de/settings.json | 6 + src/i18n/locales/en/settings.json | 6 + src/i18n/locales/es/settings.json | 6 + src/i18n/locales/fr/settings.json | 6 + src/i18n/locales/it/settings.json | 6 + src/i18n/locales/ko/settings.json | 6 + src/i18n/locales/nl/settings.json | 6 + src/i18n/locales/pt-BR/settings.json | 6 + src/i18n/locales/ru/settings.json | 6 + src/i18n/locales/zh-CN/settings.json | 6 + src/i18n/locales/zh-TW/settings.json | 6 + 20 files changed, 440 insertions(+), 15 deletions(-) create mode 100644 electron/gifCapture.test.ts create mode 100644 electron/gifCapture.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 3a96eb82e..75c6769f1 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -967,6 +967,15 @@ 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 tray-started GIF capture (convert + save) instead of opening the editor. */ + completeGifCapture: (videoPath: string) => Promise<{ handled: boolean }>; 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/main.ts b/electron/main.ts index 288a43bb1..7bbdfa360 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, @@ -81,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())) { @@ -244,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); } @@ -594,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) { @@ -722,6 +741,63 @@ 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), + }; + } +}); + +// Set by the tray capture entries; consumed when the HUD finishes a recording. +let captureOutputMode: "editor" | "gif" = "editor"; + +ipcMain.handle("complete-gif-capture", async (event, videoPath: unknown) => { + const hud = getHudOverlayWindow(); + if (captureOutputMode !== "gif" || !hud || event.sender !== hud.webContents) { + return { handled: false }; + } + captureOutputMode = "editor"; + 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); @@ -736,6 +812,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(); @@ -761,8 +840,21 @@ 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: () => { + captureOutputMode = "editor"; if (!showHudOverlayFromTray()) { focusOrCreateMainWindow(); } @@ -909,7 +1001,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(); } }); @@ -1011,6 +1103,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. @@ -1070,6 +1165,39 @@ 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) => { + captureOutputMode = 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) => @@ -1077,9 +1205,18 @@ app.whenReady().then(async () => { ); return; } - void dispatchRecordingShortcut(action).catch((error) => - console.error("[recording-shortcuts]", error), - ); + 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) { @@ -1099,7 +1236,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 371fd21fa..b8ad3d265 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -954,6 +954,15 @@ 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); + }, previewUpdateToast: () => { return ipcRenderer.invoke("preview-update-toast"); }, 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": "無法變更輕量模式。" } } From 8989e187080f8524d2fe79e6c4c583a6c00653e1 Mon Sep 17 00:00:00 2001 From: bekkilove <921693422@qq.com> Date: Fri, 16 Oct 2026 14:52:22 +0800 Subject: [PATCH 5/5] feat(hud): capture mode toolbar synced with tray - Main process owns captureOutputMode (editor | gif); get/set-capture-output-mode IPC, set accepted only from the HUD, changes broadcast to the HUD. - HUD idle bar: Screenshot action plus GIF / Video radio group; tray Record GIF / Record Video update the same state. - Mode is sticky instead of resetting after one GIF or on Open Recorder; finalizing copy says the GIF is being saved. --- electron/electron-env.d.ts | 7 +++- electron/main.ts | 25 +++++++++--- electron/preload.ts | 11 +++++ src/components/launch/LaunchWindow.tsx | 56 +++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 75c6769f1..2996ad09e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -974,8 +974,13 @@ interface Window { enabled: boolean; error?: string; }>; - /** Finishes a tray-started GIF capture (convert + save) instead of opening the editor. */ + /** 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/main.ts b/electron/main.ts index 7bbdfa360..0dad4b445 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -767,15 +767,31 @@ ipcMain.handle("set-lightweight-mode", (_event, enabled: unknown) => { } }); -// Set by the tray capture entries; consumed when the HUD finishes a recording. -let captureOutputMode: "editor" | "gif" = "editor"; +// 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 }; } - captureOutputMode = "editor"; if (typeof videoPath !== "string" || !videoPath) { return { handled: false }; } @@ -854,7 +870,6 @@ function updateTrayMenu(recording: boolean = false) { { label: "Open Recorder", click: () => { - captureOutputMode = "editor"; if (!showHudOverlayFromTray()) { focusOrCreateMainWindow(); } @@ -1192,7 +1207,7 @@ app.whenReady().then(async () => { }); }; trayRecordAction = (mode) => { - captureOutputMode = mode; + setCaptureOutputMode(mode); void (async () => { if (!getHudOverlayWindow()) await openHudForShortcut(); await dispatchRecordingShortcut("openCapturePicker"); diff --git a/electron/preload.ts b/electron/preload.ts index b8ad3d265..49847f7fd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -963,6 +963,17 @@ contextBridge.exposeInMainWorld("electronAPI", { 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/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 1150eb5b2..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; @@ -324,6 +343,37 @@ function LaunchWindowContent() { > Screenshot +
+ + +
+