From 7fd6e817520930b0f25215e9fa948d1e76dcaa92 Mon Sep 17 00:00:00 2001 From: justmaiko12 Date: Wed, 10 Jun 2026 23:30:59 -0400 Subject: [PATCH 01/45] Improve camera capture and export quality --- README.md | 26 ++++++++ .../launch/floatingWebcamPreview.test.ts | 21 ++++++ .../launch/floatingWebcamPreview.ts | 20 ++++++ .../launch/hooks/useWebcamPreviewOverlay.ts | 65 ++++++++----------- src/hooks/useScreenRecorder.test.ts | 32 +++++++++ src/hooks/useScreenRecorder.ts | 52 ++++++++------- src/lib/exporter/exportBitrate.test.ts | 14 ++-- src/lib/exporter/exportBitrate.ts | 2 +- 8 files changed, 162 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 121ef9343..48299bbc4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,32 @@ Language: EN | [简中](README.zh-CN.md) MP4 to GIF export (4) +--- + +## Why Use This AKA Build? + +This fork is tuned for creator recordings where the camera feed matters as much +as the screen. The upstream app is already strong for demos, but my version +raises the quality floor for phone-camera and webcam workflows so exports do not +fall apart when the face-cam is large or full-screen. + +- **Higher-quality phone camera capture:** webcam sidecar recording now requests + a 4K 16:9 stream when available, with 1080p as the minimum target instead of + silently accepting low-resolution camera input. +- **Higher webcam recording bitrate:** camera footage is recorded at a + 4K-friendly bitrate, so detailed face-cam footage has more room before + compression artifacts show up. +- **No low-res preview trap:** the floating webcam preview no longer opens the + camera as a tiny square stream that can cause some camera sources to negotiate + low-quality output. +- **Cleaner original-quality exports:** the highest MP4 quality path now keeps + the full source bitrate instead of reducing it below the source-quality + budget. + +Use this build if you record creator education, product walkthroughs, Loom-style +videos, or social clips where you want your phone camera to stay sharp in the +final export. + --- ### Backed by the community diff --git a/src/components/launch/floatingWebcamPreview.test.ts b/src/components/launch/floatingWebcamPreview.test.ts index 6e876f1d0..d93618912 100644 --- a/src/components/launch/floatingWebcamPreview.test.ts +++ b/src/components/launch/floatingWebcamPreview.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { canShowFloatingWebcamPreview, canToggleFloatingWebcamPreview, + createFloatingWebcamPreviewVideoConstraints, } from "./floatingWebcamPreview"; describe("canShowFloatingWebcamPreview", () => { @@ -24,3 +25,23 @@ describe("canToggleFloatingWebcamPreview", () => { expect(canToggleFloatingWebcamPreview(false)).toBe(false); }); }); + +describe("createFloatingWebcamPreviewVideoConstraints", () => { + it("keeps the phone camera preview on a high-definition 16:9 stream", () => { + expect(createFloatingWebcamPreviewVideoConstraints()).toEqual({ + aspectRatio: { ideal: 16 / 9 }, + resizeMode: "none", + width: { ideal: 1920, min: 1280 }, + height: { ideal: 1080, min: 720 }, + frameRate: { ideal: 30, max: 30 }, + }); + expect(createFloatingWebcamPreviewVideoConstraints("phone-camera")).toEqual({ + aspectRatio: { ideal: 16 / 9 }, + deviceId: { exact: "phone-camera" }, + resizeMode: "none", + width: { ideal: 1920, min: 1280 }, + height: { ideal: 1080, min: 720 }, + frameRate: { ideal: 30, max: 30 }, + }); + }); +}); diff --git a/src/components/launch/floatingWebcamPreview.ts b/src/components/launch/floatingWebcamPreview.ts index 5e421550e..4df0d56c1 100644 --- a/src/components/launch/floatingWebcamPreview.ts +++ b/src/components/launch/floatingWebcamPreview.ts @@ -1,3 +1,7 @@ +const FLOATING_WEBCAM_PREVIEW_WIDTH = 1920; +const FLOATING_WEBCAM_PREVIEW_HEIGHT = 1080; +const FLOATING_WEBCAM_PREVIEW_FRAME_RATE = 30; + export function canShowFloatingWebcamPreview( requested: boolean, hudOverlayMousePassthroughSupported: boolean | null, @@ -10,3 +14,19 @@ export function canToggleFloatingWebcamPreview( ): boolean { return hudOverlayMousePassthroughSupported !== false; } + +export function createFloatingWebcamPreviewVideoConstraints( + webcamDeviceId?: string, +): MediaTrackConstraints { + return { + ...(webcamDeviceId ? { deviceId: { exact: webcamDeviceId } } : {}), + aspectRatio: { ideal: 16 / 9 }, + resizeMode: "none", + width: { ideal: FLOATING_WEBCAM_PREVIEW_WIDTH, min: 1280 }, + height: { ideal: FLOATING_WEBCAM_PREVIEW_HEIGHT, min: 720 }, + frameRate: { + ideal: FLOATING_WEBCAM_PREVIEW_FRAME_RATE, + max: FLOATING_WEBCAM_PREVIEW_FRAME_RATE, + }, + } as MediaTrackConstraints; +} diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 7c93899a0..b320ce2ca 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -1,5 +1,8 @@ -import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; -import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview"; +import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; +import { + canShowFloatingWebcamPreview, + createFloatingWebcamPreviewVideoConstraints, +} from "../floatingWebcamPreview"; const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; @@ -61,32 +64,29 @@ export function useWebcamPreviewOverlay({ } }, [webcamEnabled]); - const handleWebcamPreviewPointerDown = useCallback( - (event: PointerEvent) => { - if (event.button !== 0) { - return; - } + const handleWebcamPreviewPointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) { + return; + } - const previewRect = event.currentTarget.getBoundingClientRect(); + const previewRect = event.currentTarget.getBoundingClientRect(); - event.preventDefault(); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - webcamPreviewDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: webcamPreviewOffsetRef.current.x, - originY: webcamPreviewOffsetRef.current.y, - initialLeft: previewRect.left, - initialTop: previewRect.top, - previewWidth: previewRect.width, - previewHeight: previewRect.height, - dragging: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }, - [], - ); + event.preventDefault(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + webcamPreviewDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: webcamPreviewOffsetRef.current.x, + originY: webcamPreviewOffsetRef.current.y, + initialLeft: previewRect.left, + initialTop: previewRect.top, + previewWidth: previewRect.width, + previewHeight: previewRect.height, + dragging: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent) => { const dragState = webcamPreviewDragStartRef.current; @@ -219,18 +219,7 @@ export function useWebcamPreviewOverlay({ try { const previewStream = await navigator.mediaDevices.getUserMedia({ - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - } - : { - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - }, + video: createFloatingWebcamPreviewVideoConstraints(webcamDeviceId), audio: false, }); diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index d74e884ce..91cfcd9e1 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createBrowserRecordingOptions, createProcessedMicrophoneConstraints, + createWebcamRecordingOptions, + createWebcamVideoConstraints, normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, shouldUseNativeWindowsCaptureForSource, @@ -139,6 +141,36 @@ describe("createBrowserRecordingOptions", () => { }); }); +describe("webcam recording quality", () => { + it("requests high-resolution camera input for phone cameras when available", () => { + expect(createWebcamVideoConstraints()).toEqual({ + aspectRatio: { ideal: 16 / 9 }, + resizeMode: "none", + width: { ideal: 3840, min: 1920 }, + height: { ideal: 2160, min: 1080 }, + frameRate: { ideal: 60, max: 60 }, + }); + expect(createWebcamVideoConstraints("phone-camera")).toEqual({ + aspectRatio: { ideal: 16 / 9 }, + deviceId: { exact: "phone-camera" }, + resizeMode: "none", + width: { ideal: 3840, min: 1920 }, + height: { ideal: 2160, min: 1080 }, + frameRate: { ideal: 60, max: 60 }, + }); + }); + + it("records the webcam sidecar at a 4K-friendly bitrate", () => { + expect(createWebcamRecordingOptions()).toEqual({ + videoBitsPerSecond: 45_000_000, + }); + expect(createWebcamRecordingOptions("video/mp4")).toEqual({ + videoBitsPerSecond: 45_000_000, + mimeType: "video/mp4", + }); + }); +}); + describe("resolveBrowserCaptureCursorPolicy", () => { it("preserves the existing hidden-cursor browser policy by default", () => { expect(resolveBrowserCaptureCursorPolicy()).toEqual({ diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd70056..de9983a99 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -32,10 +32,10 @@ const RECORDING_FILE_PREFIX = "recording-"; const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; const MIC_GAIN_BOOST = 1.4; -const WEBCAM_BITRATE = 8_000_000; -const WEBCAM_WIDTH = 1280; -const WEBCAM_HEIGHT = 720; -const WEBCAM_FRAME_RATE = 30; +const WEBCAM_BITRATE = 45_000_000; +const WEBCAM_WIDTH = 3840; +const WEBCAM_HEIGHT = 2160; +const WEBCAM_FRAME_RATE = 60; const WEBCAM_SUFFIX = "-webcam"; const MICROPHONE_FALLBACK_ERROR_TOAST_ID = "recording-microphone-fallback-error"; const MICROPHONE_SIDECAR_ERROR_TOAST_ID = "recording-microphone-sidecar-error"; @@ -213,10 +213,7 @@ export function resolveBrowserCaptureCursorPolicy({ export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { - return ( - source?.id?.startsWith("screen:") === true || - source?.id?.startsWith("window:") === true - ); + return source?.id?.startsWith("screen:") === true || source?.id?.startsWith("window:") === true; } export function createProcessedMicrophoneConstraints( @@ -265,6 +262,24 @@ export function createBrowserRecordingOptions({ return options; } +export function createWebcamVideoConstraints(webcamDeviceId?: string): MediaTrackConstraints { + return { + ...(webcamDeviceId ? { deviceId: { exact: webcamDeviceId } } : {}), + aspectRatio: { ideal: 16 / 9 }, + resizeMode: "none", + width: { ideal: WEBCAM_WIDTH, min: 1920 }, + height: { ideal: WEBCAM_HEIGHT, min: 1080 }, + frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, + } as MediaTrackConstraints; +} + +export function createWebcamRecordingOptions(mimeType?: string): MediaRecorderOptions { + return { + videoBitsPerSecond: WEBCAM_BITRATE, + ...(mimeType ? { mimeType } : {}), + }; +} + function createMicrophoneTrackSettingsSnapshot( stream: MediaStream, ): MicrophoneTrackSettingsSnapshot | null { @@ -959,18 +974,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { webcamStream.current = await navigator.mediaDevices.getUserMedia({ - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: WEBCAM_WIDTH }, - height: { ideal: WEBCAM_HEIGHT }, - frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, - } - : { - width: { ideal: WEBCAM_WIDTH }, - height: { ideal: WEBCAM_HEIGHT }, - frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, - }, + video: createWebcamVideoConstraints(webcamDeviceId), audio: false, }); @@ -982,10 +986,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); pendingWebcamPathPromise.current = webcamStopPromise.current; - const recorder = new MediaRecorder(webcamStream.current, { - videoBitsPerSecond: WEBCAM_BITRATE, - ...(mimeType ? { mimeType } : {}), - }); + const recorder = new MediaRecorder( + webcamStream.current, + createWebcamRecordingOptions(mimeType), + ); webcamRecorder.current = recorder; recorder.ondataavailable = (event) => { diff --git a/src/lib/exporter/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts index c8ef1715e..058cc4b25 100644 --- a/src/lib/exporter/exportBitrate.test.ts +++ b/src/lib/exporter/exportBitrate.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getMp4ExportBitrate, getSourceQualityBitrate } from "./exportBitrate"; describe("export bitrate policy", () => { - it("keeps the legacy source-quality bitrate unchanged", () => { + it("uses the full source bitrate for source-quality exports in quality mode", () => { expect(getSourceQualityBitrate(1920, 1080)).toBe(30_000_000); expect( getMp4ExportBitrate({ @@ -12,7 +12,7 @@ describe("export bitrate policy", () => { quality: "source", encodingMode: "quality", }), - ).toBe(27_000_000); + ).toBe(30_000_000); }); it("raises high-resolution 60fps source-quality exports above the 30fps budget", () => { @@ -32,9 +32,9 @@ describe("export bitrate policy", () => { frameRate: 60, }); - expect(thirtyFpsBitrate).toBe(45_000_000); + expect(thirtyFpsBitrate).toBe(50_000_000); expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); - expect(sixtyFpsBitrate).toBe(63_639_610); + expect(sixtyFpsBitrate).toBe(70_710_678); }); it("keeps modern native static-layout source exports high enough for screen text", () => { @@ -57,7 +57,7 @@ describe("export bitrate policy", () => { encodingMode: "quality", useModernNativeStaticLayout: true, }), - ).toBe(27_000_000); + ).toBe(30_000_000); }); it("scales modern native static-layout source exports at 60fps", () => { @@ -78,9 +78,9 @@ describe("export bitrate policy", () => { frameRate: 60, }); - expect(thirtyFpsBitrate).toBe(27_000_000); + expect(thirtyFpsBitrate).toBe(30_000_000); expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); - expect(sixtyFpsBitrate).toBe(38_183_766); + expect(sixtyFpsBitrate).toBe(42_426_407); }); it("does not raise fast exports when the requested bitrate is already lower than the cap", () => { diff --git a/src/lib/exporter/exportBitrate.ts b/src/lib/exporter/exportBitrate.ts index 84d871339..0c4c57ed2 100644 --- a/src/lib/exporter/exportBitrate.ts +++ b/src/lib/exporter/exportBitrate.ts @@ -9,7 +9,7 @@ export function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMod case "fast": return 0.1; case "quality": - return 0.9; + return 1; case "balanced": default: return 0.5; From 06174448ed607d9ab7d42c52408a6f5f120b23d6 Mon Sep 17 00:00:00 2001 From: justmaiko12 Date: Wed, 10 Jun 2026 01:49:05 -0400 Subject: [PATCH 02/45] Fix double-echo voice in editor preview for mac mic-only recordings On macOS, when recording mic-only (no system audio), the native helper writes mic audio both into the video's inline track and a .mic sidecar. The preview was playing both sources simultaneously, causing an audible echo. Mute the embedded preview whenever sidecar tracks exist but the video path was not listed as a distinct audio source (hasEmbeddedSourceAudio === false). Export behavior (includeEmbeddedInExport) is unchanged. Co-Authored-By: Claude Fable 5 --- src/lib/exporter/audioRoutingEngine.ts | 9 ++++++++- src/lib/exporter/sourceTrackRoutingPolicy.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/exporter/audioRoutingEngine.ts b/src/lib/exporter/audioRoutingEngine.ts index 3fc3285fc..e4464198e 100644 --- a/src/lib/exporter/audioRoutingEngine.ts +++ b/src/lib/exporter/audioRoutingEngine.ts @@ -121,7 +121,14 @@ export function buildResolvedAudioPlan(input: { hasEmbeddedSourceAudio, pathsByTrack, playbackPaths, - muteEmbeddedPreview: hasDedicatedTracks && !includeEmbeddedInExport, + // Mute embedded preview in two situations: + // 1. !includeEmbeddedInExport: a system or mixed sidecar supersedes the + // embedded audio, so the sidecars are the canonical playback source. + // 2. !hasEmbeddedSourceAudio: the video path was not listed as a distinct + // audio source; on macOS mic-only recordings the inline track duplicates + // the .mic sidecar, and playing both echoes the voice. + muteEmbeddedPreview: + hasDedicatedTracks && (!includeEmbeddedInExport || !hasEmbeddedSourceAudio), includeEmbeddedInExport, tracks, masterGain: clampGain(input.masterGain ?? 1, 1), diff --git a/src/lib/exporter/sourceTrackRoutingPolicy.test.ts b/src/lib/exporter/sourceTrackRoutingPolicy.test.ts index 58743acca..829b4c870 100644 --- a/src/lib/exporter/sourceTrackRoutingPolicy.test.ts +++ b/src/lib/exporter/sourceTrackRoutingPolicy.test.ts @@ -38,4 +38,16 @@ describe("resolveSourceTrackRoutingPolicy", () => { expect(policy.muteEmbeddedPreview).toBe(false); expect(policy.includeEmbeddedInExport).toBe(true); }); + + it("mutes embedded preview when only a mic sidecar exists without an embedded source entry", () => { + // macOS mic-only recordings duplicate the mic into the video's inline track + // and into the .mic sidecar; playing both echoes the voice in preview. + const policy = resolveSourceTrackRoutingPolicy("/tmp/recording.mp4", [ + "/tmp/recording.mic.m4a", + ]); + + expect(policy.playbackPaths).toEqual(["/tmp/recording.mic.m4a"]); + expect(policy.muteEmbeddedPreview).toBe(true); + expect(policy.includeEmbeddedInExport).toBe(true); + }); }); From 2920fb68ed6afcff1a3d5ed32c798b9904702eae Mon Sep 17 00:00:00 2001 From: justmaiko12 Date: Wed, 10 Jun 2026 01:53:14 -0400 Subject: [PATCH 03/45] Add teleprompter default bounds calculation --- electron/teleprompterBounds.test.ts | 33 +++++++++++++++++++++++++++++ electron/teleprompterBounds.ts | 33 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 electron/teleprompterBounds.test.ts create mode 100644 electron/teleprompterBounds.ts diff --git a/electron/teleprompterBounds.test.ts b/electron/teleprompterBounds.test.ts new file mode 100644 index 000000000..be580186e --- /dev/null +++ b/electron/teleprompterBounds.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + getTeleprompterDefaultBounds, + TELEPROMPTER_DEFAULT_HEIGHT, + TELEPROMPTER_DEFAULT_WIDTH, + TELEPROMPTER_TOP_MARGIN, +} from "./teleprompterBounds"; + +describe("getTeleprompterDefaultBounds", () => { + it("centers horizontally at the top of the work area", () => { + const bounds = getTeleprompterDefaultBounds({ x: 0, y: 25, width: 1440, height: 875 }); + + expect(bounds.width).toBe(TELEPROMPTER_DEFAULT_WIDTH); + expect(bounds.height).toBe(TELEPROMPTER_DEFAULT_HEIGHT); + expect(bounds.x).toBe(Math.round((1440 - TELEPROMPTER_DEFAULT_WIDTH) / 2)); + expect(bounds.y).toBe(25 + TELEPROMPTER_TOP_MARGIN); + }); + + it("respects work area offsets on secondary displays", () => { + const bounds = getTeleprompterDefaultBounds({ x: 1440, y: 100, width: 1920, height: 1080 }); + + expect(bounds.x).toBe(1440 + Math.round((1920 - TELEPROMPTER_DEFAULT_WIDTH) / 2)); + expect(bounds.y).toBe(100 + TELEPROMPTER_TOP_MARGIN); + }); + + it("clamps to small work areas", () => { + const bounds = getTeleprompterDefaultBounds({ x: 0, y: 0, width: 400, height: 300 }); + + expect(bounds.width).toBe(400); + expect(bounds.height).toBe(300); + expect(bounds.x).toBe(0); + }); +}); diff --git a/electron/teleprompterBounds.ts b/electron/teleprompterBounds.ts new file mode 100644 index 000000000..05487dcb5 --- /dev/null +++ b/electron/teleprompterBounds.ts @@ -0,0 +1,33 @@ +export interface TeleprompterBounds { + x: number; + y: number; + width: number; + height: number; +} + +export const TELEPROMPTER_DEFAULT_WIDTH = 520; +export const TELEPROMPTER_DEFAULT_HEIGHT = 360; +export const TELEPROMPTER_TOP_MARGIN = 12; + +interface WorkArea { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Default placement: horizontally centered at the very top of the primary + * display, so the window sits as close to the camera as possible. + */ +export function getTeleprompterDefaultBounds(workArea: WorkArea): TeleprompterBounds { + const width = Math.min(TELEPROMPTER_DEFAULT_WIDTH, workArea.width); + const height = Math.min(TELEPROMPTER_DEFAULT_HEIGHT, workArea.height); + + return { + x: workArea.x + Math.round((workArea.width - width) / 2), + y: workArea.y + Math.min(TELEPROMPTER_TOP_MARGIN, Math.max(0, workArea.height - height)), + width, + height, + }; +} From 03c413469cfe3d21b58c0130044f7c25c4ad4aa2 Mon Sep 17 00:00:00 2001 From: justmaiko12 Date: Wed, 10 Jun 2026 01:57:11 -0400 Subject: [PATCH 04/45] Add teleprompter window with capture protection, IPC, and global hotkeys Co-Authored-By: Claude Fable 5 --- electron/main.ts | 12 ++++ electron/teleprompterShortcuts.ts | 55 ++++++++++++++++++ electron/windows.ts | 93 +++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 electron/teleprompterShortcuts.ts diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb6..c3a09c58a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,6 +6,7 @@ import { BrowserWindow, desktopCapturer, dialog, + globalShortcut, ipcMain, Menu, Notification, @@ -27,6 +28,7 @@ import { } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; import { ensurePackagedRendererServer } from "./rendererServer"; +import { registerTeleprompterToggleShortcut } from "./teleprompterShortcuts"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -52,6 +54,7 @@ import { reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState, setHudOverlayRecordingActive, showUpdateToastWindow, + toggleTeleprompterWindow, } from "./windows"; const electronMainDir = path.dirname(fileURLToPath(import.meta.url)); @@ -857,6 +860,13 @@ app.on("before-quit", () => { void cleanupAllExportStreams(); }); +// will-quit (not before-quit): a quit can be canceled by the editor's +// unsaved-changes dialog, and shortcuts unregistered there would stay dead +// for the rest of the session. +app.on("will-quit", () => { + globalShortcut.unregisterAll(); +}); + app.on("window-all-closed", () => { if (IS_SMOKE_EXPORT || process.platform !== "darwin") { app.quit(); @@ -891,6 +901,8 @@ app.whenReady().then(async () => { session.defaultSession.setDevicePermissionHandler((_details) => true); + registerTeleprompterToggleShortcut(toggleTeleprompterWindow); + if (process.platform === "darwin") { const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); if (cameraStatus !== "granted") { diff --git a/electron/teleprompterShortcuts.ts b/electron/teleprompterShortcuts.ts new file mode 100644 index 000000000..9c5559e04 --- /dev/null +++ b/electron/teleprompterShortcuts.ts @@ -0,0 +1,55 @@ +import { globalShortcut } from "electron"; + +export type TeleprompterCommand = "toggle-play" | "speed-down" | "speed-up"; + +const SCROLL_SHORTCUTS: Array<[string, TeleprompterCommand]> = [ + ["Alt+F8", "toggle-play"], + ["Alt+F7", "speed-down"], + ["Alt+F9", "speed-up"], +]; + +const TOGGLE_SHORTCUT = "Alt+T"; + +/** Registered only while the teleprompter window exists. */ +export function registerTeleprompterScrollShortcuts( + send: (command: TeleprompterCommand) => void, +): void { + for (const [accelerator, command] of SCROLL_SHORTCUTS) { + try { + const registered = globalShortcut.register(accelerator, () => send(command)); + if (!registered) { + console.warn(`[teleprompter] Could not register global shortcut ${accelerator}`); + } + } catch (error) { + console.warn( + `[teleprompter] Could not register global shortcut ${accelerator}:`, + error, + ); + } + } +} + +export function unregisterTeleprompterScrollShortcuts(): void { + for (const [accelerator] of SCROLL_SHORTCUTS) { + try { + globalShortcut.unregister(accelerator); + } catch { + // Best effort - shortcut may not have been registered. + } + } +} + +/** Registered for the app lifetime so Alt+T can summon the window. */ +export function registerTeleprompterToggleShortcut(toggle: () => void): void { + try { + const registered = globalShortcut.register(TOGGLE_SHORTCUT, toggle); + if (!registered) { + console.warn(`[teleprompter] Could not register global shortcut ${TOGGLE_SHORTCUT}`); + } + } catch (error) { + console.warn( + `[teleprompter] Could not register global shortcut ${TOGGLE_SHORTCUT}:`, + error, + ); + } +} diff --git a/electron/windows.ts b/electron/windows.ts index 478584b55..41c8dcec1 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -7,6 +7,11 @@ import { app, BrowserWindow, ipcMain } from "electron"; import { USER_DATA_PATH } from "./appPaths"; import { getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds } from "./hudOverlayBounds"; import { getPackagedRendererBaseUrl } from "./rendererServer"; +import { getTeleprompterDefaultBounds } from "./teleprompterBounds"; +import { + registerTeleprompterScrollShortcuts, + unregisterTeleprompterScrollShortcuts, +} from "./teleprompterShortcuts"; const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url)); const nodeRequire = createRequire(import.meta.url); @@ -31,6 +36,7 @@ let hudOverlaySourceSelectionActive = false; let hudOverlayMouseReassertTimer: NodeJS.Timeout | null = null; let hudOverlayRecordingActive = false; let countdownWindow: BrowserWindow | null = null; +let teleprompterWindow: BrowserWindow | null = null; let updateToastWindow: BrowserWindow | null = null; const HUD_OVERLAY_SETTINGS_FILE = path.join(USER_DATA_PATH, "hud-overlay-settings.json"); @@ -1024,3 +1030,90 @@ export function closeCountdownWindow(): void { countdownWindow = null; } } + +export function createTeleprompterWindow(): BrowserWindow { + if (teleprompterWindow && !teleprompterWindow.isDestroyed()) { + teleprompterWindow.show(); + teleprompterWindow.moveTop(); + return teleprompterWindow; + } + + const bounds = getTeleprompterDefaultBounds(getScreen().getPrimaryDisplay().workArea); + + const win = new BrowserWindow({ + ...bounds, + minWidth: 280, + minHeight: 180, + frame: false, + backgroundColor: "#161616", + resizable: true, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + webPreferences: { + preload: path.join(electronWindowsDir, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); + + // Hide from screen capture so recordings never show the script. + if (isHudOverlayCaptureProtectionSupported()) { + win.setContentProtection(true); + } + + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + win.once("ready-to-show", () => { + if (!win.isDestroyed()) { + win.show(); + } + }); + + registerTeleprompterScrollShortcuts((command) => { + if (!win.isDestroyed()) { + win.webContents.send("teleprompter-command", command); + } + }); + + win.on("closed", () => { + unregisterTeleprompterScrollShortcuts(); + if (teleprompterWindow === win) { + teleprompterWindow = null; + } + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=teleprompter"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "teleprompter" }, + }); + } + + teleprompterWindow = win; + return win; +} + +export function getTeleprompterWindow(): BrowserWindow | null { + return teleprompterWindow && !teleprompterWindow.isDestroyed() ? teleprompterWindow : null; +} + +export function toggleTeleprompterWindow(): void { + const existing = getTeleprompterWindow(); + if (existing) { + existing.close(); + } else { + createTeleprompterWindow(); + } +} + +ipcMain.on("teleprompter-toggle", () => { + toggleTeleprompterWindow(); +}); + +ipcMain.on("teleprompter-close", () => { + getTeleprompterWindow()?.close(); +}); From b2eac8100725e8b0f18630e58fd6394b9a086e85 Mon Sep 17 00:00:00 2001 From: justmaiko12 Date: Wed, 10 Jun 2026 02:04:03 -0400 Subject: [PATCH 05/45] Expose teleprompter IPC through preload bridge --- electron/electron-env.d.ts | 3 +++ electron/preload.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6dc7d3966..48c0fe469 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -209,6 +209,9 @@ interface Window { hudOverlayHide: () => void; hudOverlayClose: () => void; hudOverlayRendererReady: () => void; + teleprompterToggle: () => void; + teleprompterClose: () => void; + onTeleprompterCommand: (callback: (command: string) => void) => () => void; getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; getHudOverlayMousePassthroughSupported: () => Promise<{ success: boolean; diff --git a/electron/preload.ts b/electron/preload.ts index 6f941e802..e678a0937 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -182,6 +182,21 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlayRendererReady: () => { ipcRenderer.send("hud-overlay-renderer-ready"); }, + teleprompterToggle: () => { + ipcRenderer.send("teleprompter-toggle"); + }, + teleprompterClose: () => { + ipcRenderer.send("teleprompter-close"); + }, + onTeleprompterCommand: (callback: (command: string) => void) => { + const listener = (_event: Electron.IpcRendererEvent, command: string) => { + callback(command); + }; + ipcRenderer.on("teleprompter-command", listener); + return () => { + ipcRenderer.removeListener("teleprompter-command", listener); + }; + }, getHudOverlayCaptureProtection: () => { return ipcRenderer.invoke("get-hud-overlay-capture-protection"); }, From 4dfb9727e176b77fa9c505bab54e73bcf1ce5993 Mon Sep 17 00:00:00 2001 From: justmaiko12 Date: Wed, 10 Jun 2026 02:05:11 -0400 Subject: [PATCH 06/45] Add teleprompter scroll engine primitives --- .../teleprompter/teleprompterScroll.test.ts | 49 +++++++++++++++++++ .../teleprompter/teleprompterScroll.ts | 27 ++++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/components/teleprompter/teleprompterScroll.test.ts create mode 100644 src/components/teleprompter/teleprompterScroll.ts diff --git a/src/components/teleprompter/teleprompterScroll.test.ts b/src/components/teleprompter/teleprompterScroll.test.ts new file mode 100644 index 000000000..6ff48e6a8 --- /dev/null +++ b/src/components/teleprompter/teleprompterScroll.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + advanceScrollTop, + DEFAULT_FONT_SIZE_INDEX, + DEFAULT_SPEED_INDEX, + FONT_SIZES, + SPEED_LEVELS, + stepIndex, +} from "./teleprompterScroll"; + +describe("advanceScrollTop", () => { + it("advances proportionally to elapsed time and speed", () => { + expect(advanceScrollTop(100, 60, 1000)).toBeCloseTo(160); + expect(advanceScrollTop(100, 60, 500)).toBeCloseTo(130); + expect(advanceScrollTop(0, 30, 16.7)).toBeCloseTo(0.501, 3); + }); + + it("ignores invalid elapsed time", () => { + expect(advanceScrollTop(100, 60, 0)).toBe(100); + expect(advanceScrollTop(100, 60, -5)).toBe(100); + expect(advanceScrollTop(100, 60, Number.NaN)).toBe(100); + }); +}); + +describe("stepIndex", () => { + it("steps within bounds", () => { + expect(stepIndex(3, 1, SPEED_LEVELS.length)).toBe(4); + expect(stepIndex(3, -1, SPEED_LEVELS.length)).toBe(2); + }); + + it("clamps at the ends", () => { + expect(stepIndex(0, -1, SPEED_LEVELS.length)).toBe(0); + expect(stepIndex(SPEED_LEVELS.length - 1, 1, SPEED_LEVELS.length)).toBe( + SPEED_LEVELS.length - 1, + ); + }); +}); + +describe("level tables", () => { + it("has strictly increasing speeds and sane defaults", () => { + for (let i = 1; i < SPEED_LEVELS.length; i++) { + expect(SPEED_LEVELS[i]).toBeGreaterThan(SPEED_LEVELS[i - 1]); + } + expect(DEFAULT_SPEED_INDEX).toBeGreaterThanOrEqual(0); + expect(DEFAULT_SPEED_INDEX).toBeLessThan(SPEED_LEVELS.length); + expect(DEFAULT_FONT_SIZE_INDEX).toBeGreaterThanOrEqual(0); + expect(DEFAULT_FONT_SIZE_INDEX).toBeLessThan(FONT_SIZES.length); + }); +}); diff --git a/src/components/teleprompter/teleprompterScroll.ts b/src/components/teleprompter/teleprompterScroll.ts new file mode 100644 index 000000000..fe0671efd --- /dev/null +++ b/src/components/teleprompter/teleprompterScroll.ts @@ -0,0 +1,27 @@ +/** Auto-scroll speeds in CSS pixels per second. */ +export const SPEED_LEVELS = [10, 20, 30, 45, 60, 80, 105, 135, 170, 210] as const; +export const DEFAULT_SPEED_INDEX = 3; + +/** Reading font sizes in CSS pixels. */ +export const FONT_SIZES = [20, 24, 28, 32, 40, 48, 56, 64] as const; +export const DEFAULT_FONT_SIZE_INDEX = 3; + +/** Clamp-stepped index into a level table. */ +export function stepIndex(current: number, delta: number, length: number): number { + return Math.max(0, Math.min(length - 1, current + delta)); +} + +/** + * Advance a fractional scroll position. Kept as a float by the caller so slow + * speeds (< 1px/frame) still accumulate instead of stalling on integer rounding. + */ +export function advanceScrollTop( + scrollTop: number, + speedPxPerSecond: number, + elapsedMs: number, +): number { + if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) { + return scrollTop; + } + return scrollTop + (speedPxPerSecond * elapsedMs) / 1000; +} From 0b051f96866ce19db4bfa47625701fe1d9049df8 Mon Sep 17 00:00:00 2001 From: justmaiko12 Date: Wed, 10 Jun 2026 02:11:29 -0400 Subject: [PATCH 07/45] Add teleprompter renderer window with auto-scroll and hotkey commands Co-Authored-By: Claude Fable 5 --- src/App.tsx | 3 + src/components/teleprompter/Teleprompter.tsx | 314 +++++++++++++++++++ src/i18n/locales/en/launch.json | 14 + src/i18n/locales/es/launch.json | 14 + src/i18n/locales/fr/launch.json | 14 + src/i18n/locales/it/launch.json | 14 + src/i18n/locales/ko/launch.json | 14 + src/i18n/locales/nl/launch.json | 14 + src/i18n/locales/pt-BR/launch.json | 14 + src/i18n/locales/ru/launch.json | 14 + src/i18n/locales/zh-CN/launch.json | 14 + src/i18n/locales/zh-TW/launch.json | 14 + 12 files changed, 457 insertions(+) create mode 100644 src/components/teleprompter/Teleprompter.tsx diff --git a/src/App.tsx b/src/App.tsx index 9e1f4e4c5..8c52e4fee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import { UpdateToastWindow } from "./components/launch/UpdateToastWindow"; +import { Teleprompter } from "./components/teleprompter/Teleprompter"; import { Toaster } from "./components/ui/sonner"; import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; import VideoEditor from "./components/video-editor/VideoEditor"; @@ -70,6 +71,8 @@ export default function App() { return ; case "update-toast": return ; + case "teleprompter": + return ; case "editor": return ( diff --git a/src/components/teleprompter/Teleprompter.tsx b/src/components/teleprompter/Teleprompter.tsx new file mode 100644 index 000000000..6722fb8ff --- /dev/null +++ b/src/components/teleprompter/Teleprompter.tsx @@ -0,0 +1,314 @@ +import { + CaretDownIcon, + CaretUpIcon, + MinusIcon, + PauseIcon, + PencilSimpleIcon, + PlayIcon, + PlusIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useScopedT } from "@/contexts/I18nContext"; +import { + advanceScrollTop, + DEFAULT_FONT_SIZE_INDEX, + DEFAULT_SPEED_INDEX, + FONT_SIZES, + SPEED_LEVELS, + stepIndex, +} from "./teleprompterScroll"; + +const SCRIPT_STORAGE_KEY = "recordly-teleprompter-script"; +const SPEED_STORAGE_KEY = "recordly-teleprompter-speed-index"; +const FONT_STORAGE_KEY = "recordly-teleprompter-font-index"; + +const dragRegion = { WebkitAppRegion: "drag" } as React.CSSProperties; +const noDragRegion = { WebkitAppRegion: "no-drag" } as React.CSSProperties; + +function loadStoredIndex(key: string, fallback: number, length: number): number { + const raw = Number.parseInt(window.localStorage.getItem(key) ?? "", 10); + if (!Number.isFinite(raw) || raw < 0 || raw >= length) { + return fallback; + } + return raw; +} + +export function Teleprompter() { + const t = useScopedT("launch"); + const [script, setScript] = useState( + () => window.localStorage.getItem(SCRIPT_STORAGE_KEY) ?? "", + ); + const [editing, setEditing] = useState( + () => (window.localStorage.getItem(SCRIPT_STORAGE_KEY) ?? "").trim().length === 0, + ); + const [playing, setPlaying] = useState(false); + const [speedIndex, setSpeedIndex] = useState(() => + loadStoredIndex(SPEED_STORAGE_KEY, DEFAULT_SPEED_INDEX, SPEED_LEVELS.length), + ); + const [fontIndex, setFontIndex] = useState(() => + loadStoredIndex(FONT_STORAGE_KEY, DEFAULT_FONT_SIZE_INDEX, FONT_SIZES.length), + ); + + const scrollContainerRef = useRef(null); + const scrollPositionRef = useRef(0); + const speedIndexRef = useRef(speedIndex); + const editingRef = useRef(editing); + const autoScrollingRef = useRef(false); + + useEffect(() => { + window.localStorage.setItem(SCRIPT_STORAGE_KEY, script); + }, [script]); + + useEffect(() => { + window.localStorage.setItem(SPEED_STORAGE_KEY, String(speedIndex)); + speedIndexRef.current = speedIndex; + }, [speedIndex]); + + useEffect(() => { + window.localStorage.setItem(FONT_STORAGE_KEY, String(fontIndex)); + }, [fontIndex]); + + useEffect(() => { + editingRef.current = editing; + }, [editing]); + + // Auto-scroll loop. Fractional position lives in scrollPositionRef so slow + // speeds accumulate sub-pixel movement instead of stalling. + useEffect(() => { + if (!playing || editing) { + return; + } + let frame = 0; + let lastTime: number | null = null; + const tick = (time: number) => { + const container = scrollContainerRef.current; + if (container && lastTime !== null) { + const next = advanceScrollTop( + scrollPositionRef.current, + SPEED_LEVELS[speedIndexRef.current], + time - lastTime, + ); + const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight); + scrollPositionRef.current = Math.min(next, maxScroll); + autoScrollingRef.current = true; + container.scrollTop = scrollPositionRef.current; + autoScrollingRef.current = false; + if (scrollPositionRef.current >= maxScroll) { + setPlaying(false); + return; + } + } + lastTime = time; + frame = window.requestAnimationFrame(tick); + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [playing, editing]); + + const togglePlay = useCallback(() => { + if (editingRef.current) { + // Entering read mode mounts a fresh container at scrollTop 0; reset the + // position ref to match so playback starts from the top, not a stale spot. + scrollPositionRef.current = 0; + setEditing(false); + setPlaying(true); + return; + } + setPlaying((was) => !was); + }, []); + + // Global hotkeys relayed from the main process. + useEffect(() => { + const unsubscribe = window.electronAPI?.onTeleprompterCommand?.((command) => { + if (command === "toggle-play") { + togglePlay(); + } else if (command === "speed-down") { + setSpeedIndex((index) => stepIndex(index, -1, SPEED_LEVELS.length)); + } else if (command === "speed-up") { + setSpeedIndex((index) => stepIndex(index, 1, SPEED_LEVELS.length)); + } + }); + return unsubscribe; + }, [togglePlay]); + + // Manual scrolling always works and pauses auto-scroll. + const handleWheel = useCallback(() => { + setPlaying(false); + }, []); + + // Note: scroll events fire async, so autoScrollingRef is a best-effort guard — + // programmatic scrolls can still reach this handler. That's harmless because it + // only syncs the position ref; pause logic must stay on onWheel, never onScroll. + const handleScroll = useCallback(() => { + const container = scrollContainerRef.current; + if (container && !autoScrollingRef.current) { + scrollPositionRef.current = container.scrollTop; + } + }, []); + + const startReading = useCallback(() => { + scrollPositionRef.current = 0; + setEditing(false); + }, []); + + const backToEdit = useCallback(() => { + setPlaying(false); + setEditing(true); + }, []); + + return ( +
+
+ + {t("teleprompter.menuLabel", "Teleprompter")} + + + {t("teleprompter.hotkeyHint", "⌥F8 play/pause · ⌥F7/⌥F9 speed · ⌥T show/hide")} + +
+ {!editing && ( + + )} + +
+
+ + {editing ? ( +
+