Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/pr/screenshot-and-pin/editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr/screenshot-and-pin/native-pin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
90 changes: 90 additions & 0 deletions docs/screenshot-and-pin.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,26 @@ interface Window {
message?: string;
}>;
completeCapturePick: (pick: CapturePick | null) => Promise<void>;
startScreenshot: () => Promise<import("../src/lib/screenshot").ScreenshotResult>;
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<import("../src/lib/screenshot").ScreenshotResult>;
getScreenshotDocument: () => Promise<import("../src/lib/screenshot").ScreenshotDocument>;
screenshotAction: (
action: "copy" | "save" | "pin" | "close" | "opacity" | "zoom",
value?: string | number,
) => Promise<import("../src/lib/screenshot").ScreenshotResult>;
getCapturePickerContext: (displayId: number) => Promise<{
displayBounds: CaptureArea | null;
lastArea: CaptureArea | null;
Expand Down Expand Up @@ -947,6 +967,20 @@ interface Window {
enabled: boolean;
error?: string;
}>;
/** enabled: stored preference; active: mode this process started with (switches are startup-only). */
getLightweightMode: () => Promise<{ enabled: boolean; active: boolean }>;
setLightweightMode: (enabled: boolean) => Promise<{
success: boolean;
enabled: boolean;
error?: string;
}>;
/** Finishes a GIF-mode capture (convert + save) instead of opening the editor. */
completeGifCapture: (videoPath: string) => Promise<{ handled: boolean }>;
/** Whether the next recording opens the editor or is saved straight to GIF. */
getCaptureOutputMode: () => Promise<"editor" | "gif">;
/** HUD-only; other senders get the current mode back unchanged. */
setCaptureOutputMode: (mode: "editor" | "gif") => Promise<"editor" | "gif">;
onCaptureOutputModeChanged: (callback: (mode: "editor" | "gif") => void) => () => void;
previewUpdateToast: () => Promise<{ success: boolean }>;
checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>;
onUpdateToastStateChanged: (
Expand Down
28 changes: 28 additions & 0 deletions electron/gifCapture.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
45 changes: 45 additions & 0 deletions electron/gifCapture.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
23 changes: 22 additions & 1 deletion electron/gpuSwitches.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
31 changes: 31 additions & 0 deletions electron/gpuSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 24 additions & 1 deletion electron/ipc/ffmpeg/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions electron/ipc/register/capturePicker.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
list: vi.fn(),
begin: vi.fn(),
end: vi.fn(),
pickSourceList: vi.fn(),
validate: vi.fn(),

Expand All @@ -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),
Expand Down Expand Up @@ -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 });
Expand All @@ -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());
Expand Down
Loading
Loading