From 0315155e4f33f04b74597f0065107393f06cfd4d Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:15:37 +1000 Subject: [PATCH 1/9] perf: optimize application startup --- electron-builder.json5 | 10 +- electron/main.ts | 46 ++++------ electron/rendererServer.ts | 11 ++- electron/updater.ts | 11 ++- electron/windows.ts | 3 + src/App.tsx | 92 +++++++++---------- src/components/launch/HudWindow.tsx | 11 +++ src/components/launch/LaunchWindow.tsx | 2 +- src/components/launch/MarqueeText.tsx | 37 ++++++++ src/components/launch/SourceSelector.tsx | 39 +------- .../launch/popovers/ProjectPopover.tsx | 33 ++++--- src/components/video-editor/EditorWindow.tsx | 26 ++++++ src/components/video-editor/SettingsPanel.tsx | 8 +- .../video-editor/export/useExportRunner.ts | 20 ++-- .../project/useProjectLibraryController.ts | 2 +- .../video-editor/projectPersistence.ts | 2 +- vite.config.ts | 1 - 17 files changed, 205 insertions(+), 149 deletions(-) create mode 100644 src/components/launch/HudWindow.tsx create mode 100644 src/components/launch/MarqueeText.tsx create mode 100644 src/components/video-editor/EditorWindow.tsx diff --git a/electron-builder.json5 b/electron-builder.json5 index a333a8723..197dcc179 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -8,7 +8,7 @@ "node_modules/ffmpeg-static/**", "node_modules/ffprobe-static/**", "node_modules/uiohook-napi/**", - "electron/native/**" + "electron/native/bin/**" ], "productName": "Recordly", "npmRebuild": true, @@ -19,8 +19,13 @@ }, "files": [ "dist", + "!dist/wallpapers/**", "dist-electron", - "electron/native", + "electron/native/bin", + "!electron/native/bin/**/whisper-bench*", + "!electron/native/bin/**/whisper-quantize*", + "!electron/native/bin/**/whisper-server*", + "!electron/native/bin/**/whisper-vad-speech-segments*", "!node_modules/ffprobe-static/bin/darwin/**", "!node_modules/ffprobe-static/bin/linux/**", "!node_modules/ffprobe-static/bin/win32/ia32/**", @@ -111,4 +116,3 @@ "artifactName": "${productName}-windows-${arch}.${ext}" } } - diff --git a/electron/main.ts b/electron/main.ts index 1ea7dce62..890726670 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -935,17 +935,11 @@ app.whenReady().then(async () => { // Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default. session.defaultSession.setDevicePermissionHandler(() => false); - if (process.platform === "darwin") { - const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); - if (cameraStatus !== "granted") { - await systemPreferences.askForMediaAccess("camera"); - } - - const micStatus = systemPreferences.getMediaAccessStatus("microphone"); - if (micStatus !== "granted") { - await systemPreferences.askForMediaAccess("microphone"); - } - } else if (process.platform === "win32") { + // macOS prompts for camera and microphone access at the point of use. Asking + // here blocks the first window behind two modal OS permission flows and makes + // a fresh install look hung. Windows has no equivalent request API, so retain + // its diagnostic warnings. + if (process.platform === "win32") { const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); const micStatus = systemPreferences.getMediaAccessStatus("microphone"); if (cameraStatus !== "granted") { @@ -986,22 +980,20 @@ app.whenReady().then(async () => { updateTrayMenu(); } setupApplicationMenu(); - // Ensure recordings directory exists - await ensureRecordingsDir(); - - if (!VITE_DEV_SERVER_URL) { - try { - await ensurePackagedRendererServer(RENDERER_DIST); - } catch (error) { - console.warn("[renderer-server] Failed to start packaged renderer server:", error); - } - } - - try { - await ensureMediaServer(); - } catch (error) { - console.warn("[media-server] Failed to start media server:", error); - } + await Promise.all([ + ensureRecordingsDir(), + !VITE_DEV_SERVER_URL + ? ensurePackagedRendererServer(RENDERER_DIST).catch((error) => { + console.warn( + "[renderer-server] Failed to start packaged renderer server:", + error, + ); + }) + : Promise.resolve(), + ensureMediaServer().catch((error) => { + console.warn("[media-server] Failed to start media server:", error); + }), + ]); registerIpcHandlers( createEditorWindowWrapper, diff --git a/electron/rendererServer.ts b/electron/rendererServer.ts index 8f0d56627..f3453a06f 100644 --- a/electron/rendererServer.ts +++ b/electron/rendererServer.ts @@ -28,6 +28,15 @@ function getContentType(filePath: string): string { return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; } +function getCacheControl(filePath: string): string { + // Vite fingerprints production assets, so they can be reused across the HUD, + // picker, toast, and editor windows without revalidation. Keep index.html + // uncached because it points at the current fingerprinted files. + return /-[A-Za-z0-9_-]{8,}\.[^.]+$/.test(path.basename(filePath)) + ? "public, max-age=31536000, immutable" + : "no-cache"; +} + function resolveRequestedFilePath(rootDir: string, requestPathname: string): string | null { const trimmedPathname = requestPathname === "/" ? "/index.html" : requestPathname; @@ -72,7 +81,7 @@ async function servePackagedRendererRequest( const fileContents = await fs.readFile(filePath); response.writeHead(200, { - "Cache-Control": "no-cache", + "Cache-Control": getCacheControl(filePath), "Content-Type": getContentType(filePath), }); diff --git a/electron/updater.ts b/electron/updater.ts index 86c2d76d2..b9c4e5dca 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -8,6 +8,7 @@ import { readAppSetting, writeAppSetting } from "./appSettingsStore"; import { EXPERIMENTAL_UPDATE_DESCRIPTION, getUpdateChannelConfiguration } from "./updateChannel"; const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const INITIAL_UPDATE_CHECK_DELAY_MS = 15 * 1000; export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; const DISMISSED_READY_REMINDER_DELAY_MS = 5 * 60 * 1000; const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1"; @@ -71,6 +72,7 @@ let updaterInitialized = false; let updateCheckInProgress = false; let manualCheckRequested = false; let periodicCheckTimer: NodeJS.Timeout | null = null; +let initialCheckTimer: NodeJS.Timeout | null = null; let deferredReminderTimer: NodeJS.Timeout | null = null; let devPreviewProgressTimer: NodeJS.Timeout | null = null; let currentToastPayload: UpdateToastPayload | null = null; @@ -905,7 +907,10 @@ export function setupAutoUpdates( void showDownloadedUpdateDialog(getMainWindow, info.version); }); - void checkForAppUpdates(getMainWindow); + initialCheckTimer = setTimeout(() => { + initialCheckTimer = null; + void checkForAppUpdates(getMainWindow); + }, INITIAL_UPDATE_CHECK_DELAY_MS); periodicCheckTimer = setInterval(() => { void checkForAppUpdates(getMainWindow); }, UPDATE_CHECK_INTERVAL_MS); @@ -913,6 +918,10 @@ export function setupAutoUpdates( app.on("before-quit", () => { clearDeferredReminderTimer(); clearDevPreviewProgressTimer(); + if (initialCheckTimer) { + clearTimeout(initialCheckTimer); + initialCheckTimer = null; + } if (periodicCheckTimer) { clearInterval(periodicCheckTimer); periodicCheckTimer = null; diff --git a/electron/windows.ts b/electron/windows.ts index eb17b2e29..23e874fa1 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -447,6 +447,7 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) }); export function createHudOverlayWindow(): BrowserWindow { + const perfStart = Date.now(); loadHudOverlayCaptureProtectionSetting(); hudOverlayFallbackExpanded = false; hudOverlayWebcamPreviewVisible = false; @@ -557,6 +558,7 @@ export function createHudOverlayWindow(): BrowserWindow { } win.webContents.on("did-finish-load", () => { + console.log(`[PERF:MAIN] HUD Window: did-finish-load in ${Date.now() - perfStart}ms`); win?.webContents.send("main-process-message", new Date().toLocaleString()); // Safety fallback if renderer-ready signal never arrives. setTimeout(() => { @@ -577,6 +579,7 @@ export function createHudOverlayWindow(): BrowserWindow { const handleHudRendererReady = () => { if (!win.isDestroyed()) { + console.log(`[PERF:MAIN] HUD Window: renderer-ready in ${Date.now() - perfStart}ms`); showHudWindow(); } }; diff --git a/src/App.tsx b/src/App.tsx index 6970efb16..25401793d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,54 +1,56 @@ -import { useEffect, useState } from "react"; -import { AnnouncementDialog } from "./components/announcements/AnnouncementDialog"; -import { LiveAnnouncementNotifications } from "./components/announcements/LiveAnnouncementNotifications"; -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 { Toaster } from "./components/ui/sonner"; -import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; -import VideoEditor from "./components/video-editor/VideoEditor"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useI18n } from "./contexts/I18nContext"; -import { ShortcutsProvider } from "./contexts/ShortcutsContext"; -import { loadAllCustomFonts } from "./lib/customFonts"; + +const HudWindow = lazy(() => import("./components/launch/HudWindow")); +const SourceSelector = lazy(() => + import("./components/launch/SourceSelector").then((module) => ({ + default: module.SourceSelector, + })), +); +const CountdownOverlay = lazy(() => + import("./components/countdown/CountdownOverlay").then((module) => ({ + default: module.CountdownOverlay, + })), +); +const UpdateToastWindow = lazy(() => + import("./components/launch/UpdateToastWindow").then((module) => ({ + default: module.UpdateToastWindow, + })), +); +const EditorWindow = lazy(() => import("./components/video-editor/EditorWindow")); export default function App() { - const [windowType, setWindowType] = useState(""); + const [windowType] = useState( + () => new URLSearchParams(window.location.search).get("windowType") || "", + ); const { t } = useI18n(); const appIconSrc = "/app-icons/recordly-128.png"; useEffect(() => { - const params = new URLSearchParams(window.location.search); - const type = params.get("windowType") || ""; - setWindowType(type); - document.documentElement.dataset.windowType = type; + document.documentElement.dataset.windowType = windowType; if ( - type === "hud-overlay" || - type === "source-selector" || - type === "countdown" || - type === "update-toast" + windowType === "hud-overlay" || + windowType === "source-selector" || + windowType === "countdown" || + windowType === "update-toast" ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); } - if (type === "hud-overlay") { + if (windowType === "hud-overlay") { document.documentElement.classList.add("hud-overlay-window"); document.body.classList.add("hud-overlay-window"); document.getElementById("root")?.classList.add("hud-overlay-window"); window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } else if (type === "update-toast") { + } else if (windowType === "update-toast") { document.documentElement.style.overflow = "visible"; document.body.style.overflow = "visible"; document.getElementById("root")?.style.setProperty("overflow", "visible"); } - - loadAllCustomFonts().catch((error) => { - console.error("Failed to load custom fonts:", error); - }); - }, []); + }, [windowType]); useEffect(() => { document.title = @@ -57,33 +59,25 @@ export default function App() { : t("app.name", "Recordly"); }, [windowType, t]); + let content; switch (windowType) { case "hud-overlay": - return ( - <> - - - - ); + content = ; + break; case "source-selector": - return ; + content = ; + break; case "countdown": - return ; + content = ; + break; case "update-toast": - return ; + content = ; + break; case "editor": - return ( - <> - - - - - - - - ); + content = ; + break; default: - return ( + content = (
); } + + return {content}; } diff --git a/src/components/launch/HudWindow.tsx b/src/components/launch/HudWindow.tsx new file mode 100644 index 000000000..a62dc3ade --- /dev/null +++ b/src/components/launch/HudWindow.tsx @@ -0,0 +1,11 @@ +import { Toaster } from "../ui/sonner"; +import { LaunchWindow } from "./LaunchWindow"; + +export default function HudWindow() { + return ( + <> + + + + ); +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f7583b28e..66cbe608b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -30,6 +30,7 @@ import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; import { useRecordingTimer } from "./hooks/useRecordingTimer"; import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; import styles from "./LaunchWindow.module.css"; +import { MarqueeText } from "./MarqueeText"; import { CountdownPopover } from "./popovers/CountdownPopover"; import { LaunchPopoverCoordinatorProvider, @@ -41,7 +42,6 @@ import { ProjectPopover } from "./popovers/ProjectPopover"; import { SourcePopover } from "./popovers/SourcePopover"; import { WebcamPopover } from "./popovers/WebcamPopover"; import { RecordingControls } from "./RecordingControls"; -import { MarqueeText } from "./SourceSelector"; const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; diff --git a/src/components/launch/MarqueeText.tsx b/src/components/launch/MarqueeText.tsx new file mode 100644 index 000000000..8788e7c1e --- /dev/null +++ b/src/components/launch/MarqueeText.tsx @@ -0,0 +1,37 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +export function MarqueeText({ text }: { text: string }) { + const staticRef = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const node = staticRef.current; + if (!node || node.textContent !== text) return; + const checkOverflow = () => { + setOverflowing(node.scrollWidth > node.clientWidth + 1); + }; + checkOverflow(); + const observer = new ResizeObserver(checkOverflow); + observer.observe(node); + return () => observer.disconnect(); + }, [text]); + + return ( +
+ + {text} + + + + {text} + + {text} + + + +
+ ); +} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 8ce540006..d05f855e7 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,6 +1,6 @@ import { AppWindowIcon, CaretUpIcon, MonitorIcon } from "@phosphor-icons/react"; import * as React from "react"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useScopedT } from "@/contexts/I18nContext"; @@ -14,6 +14,7 @@ import { import "./launchTheme.css"; import "./SourceSelector.css"; import { useHudInteraction } from "./contexts/HudInteractionContext"; +import { MarqueeText } from "./MarqueeText"; interface SourceSelectorProps { /** List of available screen sources */ @@ -36,42 +37,6 @@ interface SourceSelectorProps { children?: React.ReactNode; } -export function MarqueeText({ text }: { text: string }) { - const staticRef = useRef(null); - const [overflowing, setOverflowing] = useState(false); - - useLayoutEffect(() => { - const node = staticRef.current; - if (!node || node.textContent !== text) return; - const checkOverflow = () => { - setOverflowing(node.scrollWidth > node.clientWidth + 1); - }; - checkOverflow(); - const observer = new ResizeObserver(checkOverflow); - observer.observe(node); - return () => observer.disconnect(); - }, [text]); - - return ( -
- - {text} - - - - {text} - - {text} - - - -
- ); -} - /** * SourceSelectorContent - The actual list of sources */ diff --git a/src/components/launch/popovers/ProjectPopover.tsx b/src/components/launch/popovers/ProjectPopover.tsx index 22df11d05..4219fdfa7 100644 --- a/src/components/launch/popovers/ProjectPopover.tsx +++ b/src/components/launch/popovers/ProjectPopover.tsx @@ -1,8 +1,9 @@ -import type { ReactElement } from "react"; +import { lazy, type ReactElement, Suspense } from "react"; +import type { ProjectLibraryEntry } from "../../video-editor/ProjectBrowserDialog"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import { HudPopover } from "./PopoverScaffold"; -import ProjectBrowserDialog from "../../video-editor/ProjectBrowserDialog"; -import type { ProjectLibraryEntry } from "../../video-editor/ProjectBrowserDialog"; + +const ProjectBrowserDialog = lazy(() => import("../../video-editor/ProjectBrowserDialog")); const POPOVER_ID = "projects"; @@ -31,18 +32,20 @@ export function ProjectPopover({ trigger={trigger} align="center" > - { - if (!nextOpen) requestClose(POPOVER_ID); - }} - entries={entries} - renderMode="inline" - onOpenProject={(path) => { - onOpenProject(path); - requestClose(POPOVER_ID); - }} - /> + + { + if (!nextOpen) requestClose(POPOVER_ID); + }} + entries={entries} + renderMode="inline" + onOpenProject={(path) => { + onOpenProject(path); + requestClose(POPOVER_ID); + }} + /> + ); } diff --git a/src/components/video-editor/EditorWindow.tsx b/src/components/video-editor/EditorWindow.tsx new file mode 100644 index 000000000..f45d9cb6e --- /dev/null +++ b/src/components/video-editor/EditorWindow.tsx @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { ShortcutsProvider } from "../../contexts/ShortcutsContext"; +import { loadAllCustomFonts } from "../../lib/customFonts"; +import { AnnouncementDialog } from "../announcements/AnnouncementDialog"; +import { LiveAnnouncementNotifications } from "../announcements/LiveAnnouncementNotifications"; +import { ShortcutsConfigDialog } from "./ShortcutsConfigDialog"; +import VideoEditor from "./VideoEditor"; + +export default function EditorWindow() { + useEffect(() => { + loadAllCustomFonts().catch((error) => { + console.error("Failed to load custom fonts:", error); + }); + }, []); + + return ( + <> + + + + + + + + ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 8be410188..7cb58576e 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1110,6 +1110,8 @@ export function SettingsPanel({ ); const [experimentalUpdatesEnabled, setExperimentalUpdatesEnabled] = useState(false); const [savingExperimentalUpdates, setSavingExperimentalUpdates] = useState(false); + const [internalActiveEffectSection] = useState("scene"); + const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; const removeBackgroundStateRef = useRef<{ aspectRatio: AspectRatio; padding: Padding; @@ -1166,6 +1168,8 @@ export function SettingsPanel({ }; useEffect(() => { + if (!isBackgroundPanel && activeEffectSection !== "scene") return; + let mounted = true; (async () => { try { @@ -1196,7 +1200,7 @@ export function SettingsPanel({ return () => { mounted = false; }; - }, []); + }, [activeEffectSection, isBackgroundPanel]); const colorPalette = [ "#FF0000", @@ -1231,8 +1235,6 @@ export function SettingsPanel({ const customColorInputRef = useRef(null); const cursorClickEffectColorInputRef = useRef(null); const defaultWebcam = initialEditorPreferences.webcam; - const [internalActiveEffectSection] = useState("scene"); - const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; const [builtInCursorPreviewUrls, setBuiltInCursorPreviewUrls] = useState< Partial> >({}); diff --git a/src/components/video-editor/export/useExportRunner.ts b/src/components/video-editor/export/useExportRunner.ts index 95aef628f..0472f9215 100644 --- a/src/components/video-editor/export/useExportRunner.ts +++ b/src/components/video-editor/export/useExportRunner.ts @@ -1,13 +1,8 @@ import { useCallback, useRef } from "react"; import { toast } from "sonner"; -import { - DEFAULT_MP4_CODEC, - type ExportSettings, - GifExporter, - ModernVideoExporter, - VideoExporter, -} from "@/lib/exporter"; import { getMp4ExportBitrate } from "@/lib/exporter/exportBitrate"; +import { DEFAULT_MP4_CODEC } from "@/lib/exporter/mp4Support"; +import type { ExportSettings } from "@/lib/exporter/types"; import { calculateMp4ExportDimensions } from "../exportDimensions"; import { resolveMp4ExportRouting } from "../mp4ExportRouting"; import { resolveMp4ExportSettings } from "../mp4ExportSettings"; @@ -116,6 +111,7 @@ export function useExportRunner(input: ExportRunnerInput) { if (settings.format === "gif" && settings.gifConfig) { // GIF Export + const { GifExporter } = await import("@/lib/exporter/gifExporter"); const gifExporter = new GifExporter({ videoUrl: videoPath, width: settings.gifConfig.width, @@ -143,7 +139,7 @@ export function useExportRunner(input: ExportRunnerInput) { maxPendingFrames: smokeExportConfig.maxPendingFrames, }); - exporterRef.current = gifExporter as unknown as VideoExporter; + exporterRef.current = gifExporter; const result = await gifExporter.export(); if (result.success && result.blob) { @@ -287,11 +283,15 @@ export function useExportRunner(input: ExportRunnerInput) { const exporter = pipelineModel === "modern" - ? new ModernVideoExporter({ + ? new ( + await import("@/lib/exporter/modernVideoExporter") + ).ModernVideoExporter({ ...exporterConfig, backendPreference, }) - : new VideoExporter(exporterConfig); + : new (await import("@/lib/exporter/videoExporter")).VideoExporter( + exporterConfig, + ); exporterRef.current = exporter; const result = await exporter.export(); diff --git a/src/components/video-editor/project/useProjectLibraryController.ts b/src/components/video-editor/project/useProjectLibraryController.ts index a2cdf5a53..3ecb33e4f 100644 --- a/src/components/video-editor/project/useProjectLibraryController.ts +++ b/src/components/video-editor/project/useProjectLibraryController.ts @@ -1,6 +1,6 @@ /* biome-ignore-all lint/correctness/useExhaustiveDependencies: grouped editor domain objects contain the thumbnail renderer dependencies. */ import { type RefObject, useCallback, useEffect, useRef } from "react"; -import { FrameRenderer } from "@/lib/exporter"; +import { FrameRenderer } from "@/lib/exporter/frameRenderer"; import { toFileUrl } from "../projectPersistence"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useProjectState } from "../state/useProjectState"; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 563199c05..c50546d3e 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -9,7 +9,7 @@ import type { GifFrameRate, GifSizePreset, } from "@/lib/exporter"; -import { isValidMp4FrameRate } from "@/lib/exporter"; +import { isValidMp4FrameRate } from "@/lib/exporter/types"; import { TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, diff --git a/vite.config.ts b/vite.config.ts index 3dd36633d..b9b676043 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -126,7 +126,6 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: { - pixi: ["pixi.js"], "react-vendor": ["react", "react-dom"], "video-processing": ["mediabunny", "mp4box", "@fix-webm-duration/fix"], }, From 158b141c4f2e874a194d0a3a7746996b06ca608b Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:28:36 +1000 Subject: [PATCH 2/9] fix: stop looping startup permission prompts --- src/components/launch/LaunchWindow.tsx | 3 +-- .../launch/hooks/useLaunchWindowSystemState.ts | 8 +------- src/hooks/useScreenRecorder.ts | 12 ++++-------- src/i18n/locales/en/launch.json | 8 ++++---- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 66cbe608b..7321b2b2f 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -78,7 +78,6 @@ function LaunchWindowContent() { setWebcamDeviceId, countdownDelay, setCountdownDelay, - preparePermissions, } = useScreenRecorder(); const { elapsed, formatTime } = useRecordingTimer(recording, paused); @@ -114,7 +113,7 @@ function LaunchWindowContent() { hideHudFromCapture, chooseRecordingsDirectory, toggleHudCaptureProtection, - } = useLaunchWindowSystemState(preparePermissions); + } = useLaunchWindowSystemState(); const hudCaptureProtectionSupported = supportsHudCaptureProtection(platform ?? ""); diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts index 56793fa36..58dc42dd7 100644 --- a/src/components/launch/hooks/useLaunchWindowSystemState.ts +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -1,8 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -export function useLaunchWindowSystemState( - preparePermissions: (args: { startup?: boolean }) => Promise, -) { +export function useLaunchWindowSystemState() { const [recordingsDirectory, setRecordingsDirectory] = useState(null); const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< boolean | null @@ -65,10 +63,6 @@ export function useLaunchWindowSystemState( }; }, []); - useEffect(() => { - void preparePermissions({ startup: true }); - }, [preparePermissions]); - useEffect(() => { let cancelled = false; const loadVersion = async () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 459652415..829845396 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -135,7 +135,7 @@ type UseScreenRecorderReturn = { pauseRecording: () => void; resumeRecording: () => void; cancelRecording: () => void; - preparePermissions: (options?: { startup?: boolean }) => Promise; + preparePermissions: () => Promise; isMacOS: boolean; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; @@ -546,7 +546,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { micFallbackPauseIntervals.current = []; }, []); - const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => { + const preparePermissions = useCallback(async () => { const platform = await window.electronAPI.getPlatform(); if (platform !== "darwin") { return true; @@ -556,9 +556,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!screenPermission.success || screenPermission.status !== "granted") { await window.electronAPI.openScreenRecordingPreferences(); alert( - options.startup - ? "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly." - : "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + "Recordly needs Screen Recording permission before you can record. Enable it in System Settings, then quit and reopen Recordly.", ); return false; } @@ -579,9 +577,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await window.electronAPI.openAccessibilityPreferences(); alert( - options.startup - ? "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly." - : "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + "Recordly also needs Accessibility permission for cursor tracking. Enable it in System Settings, then quit and reopen Recordly.", ); return false; diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index d4f7aba94..eee1c3f07 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -67,10 +67,10 @@ "share": "Share" }, "permissions": { - "screenRecordingNeeded": "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly.", - "screenRecordingMissing": "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", - "accessibilityNeeded": "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly.", - "accessibilityMissing": "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + "screenRecordingNeeded": "Recordly needs Screen Recording permission before you can record. Enable it in System Settings, then quit and reopen Recordly.", + "screenRecordingMissing": "Recordly still needs Screen Recording permission. Enable it in System Settings, then quit and reopen Recordly.", + "accessibilityNeeded": "Recordly needs Accessibility permission for cursor tracking. Enable it in System Settings, then quit and reopen Recordly.", + "accessibilityMissing": "Recordly still needs Accessibility permission for cursor tracking. Enable it in System Settings, then quit and reopen Recordly.", "selectSource": "Please select a source to record", "systemAudioUnavailable": "System audio is not available for this source. Recording will continue without system audio.", "microphoneDenied": "Microphone access was denied. Recording will continue without microphone audio.", From 490eb546f19cee442e56d2a7586f7eb2a0871920 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:31:26 +1000 Subject: [PATCH 3/9] Revert "fix: stop looping startup permission prompts" This reverts commit 158b141c4f2e874a194d0a3a7746996b06ca608b. --- src/components/launch/LaunchWindow.tsx | 3 ++- .../launch/hooks/useLaunchWindowSystemState.ts | 8 +++++++- src/hooks/useScreenRecorder.ts | 12 ++++++++---- src/i18n/locales/en/launch.json | 8 ++++---- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 7321b2b2f..66cbe608b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -78,6 +78,7 @@ function LaunchWindowContent() { setWebcamDeviceId, countdownDelay, setCountdownDelay, + preparePermissions, } = useScreenRecorder(); const { elapsed, formatTime } = useRecordingTimer(recording, paused); @@ -113,7 +114,7 @@ function LaunchWindowContent() { hideHudFromCapture, chooseRecordingsDirectory, toggleHudCaptureProtection, - } = useLaunchWindowSystemState(); + } = useLaunchWindowSystemState(preparePermissions); const hudCaptureProtectionSupported = supportsHudCaptureProtection(platform ?? ""); diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts index 58dc42dd7..56793fa36 100644 --- a/src/components/launch/hooks/useLaunchWindowSystemState.ts +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useState } from "react"; -export function useLaunchWindowSystemState() { +export function useLaunchWindowSystemState( + preparePermissions: (args: { startup?: boolean }) => Promise, +) { const [recordingsDirectory, setRecordingsDirectory] = useState(null); const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< boolean | null @@ -63,6 +65,10 @@ export function useLaunchWindowSystemState() { }; }, []); + useEffect(() => { + void preparePermissions({ startup: true }); + }, [preparePermissions]); + useEffect(() => { let cancelled = false; const loadVersion = async () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 829845396..459652415 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -135,7 +135,7 @@ type UseScreenRecorderReturn = { pauseRecording: () => void; resumeRecording: () => void; cancelRecording: () => void; - preparePermissions: () => Promise; + preparePermissions: (options?: { startup?: boolean }) => Promise; isMacOS: boolean; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; @@ -546,7 +546,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { micFallbackPauseIntervals.current = []; }, []); - const preparePermissions = useCallback(async () => { + const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => { const platform = await window.electronAPI.getPlatform(); if (platform !== "darwin") { return true; @@ -556,7 +556,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!screenPermission.success || screenPermission.status !== "granted") { await window.electronAPI.openScreenRecordingPreferences(); alert( - "Recordly needs Screen Recording permission before you can record. Enable it in System Settings, then quit and reopen Recordly.", + options.startup + ? "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly." + : "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", ); return false; } @@ -577,7 +579,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await window.electronAPI.openAccessibilityPreferences(); alert( - "Recordly also needs Accessibility permission for cursor tracking. Enable it in System Settings, then quit and reopen Recordly.", + options.startup + ? "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly." + : "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", ); return false; diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index eee1c3f07..d4f7aba94 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -67,10 +67,10 @@ "share": "Share" }, "permissions": { - "screenRecordingNeeded": "Recordly needs Screen Recording permission before you can record. Enable it in System Settings, then quit and reopen Recordly.", - "screenRecordingMissing": "Recordly still needs Screen Recording permission. Enable it in System Settings, then quit and reopen Recordly.", - "accessibilityNeeded": "Recordly needs Accessibility permission for cursor tracking. Enable it in System Settings, then quit and reopen Recordly.", - "accessibilityMissing": "Recordly still needs Accessibility permission for cursor tracking. Enable it in System Settings, then quit and reopen Recordly.", + "screenRecordingNeeded": "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly.", + "screenRecordingMissing": "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + "accessibilityNeeded": "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly.", + "accessibilityMissing": "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", "selectSource": "Please select a source to record", "systemAudioUnavailable": "System audio is not available for this source. Recording will continue without system audio.", "microphoneDenied": "Microphone access was denied. Recording will continue without microphone audio.", From 6217ea4098e127da8f6487257b5d530f75a847e4 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:34:40 +1000 Subject: [PATCH 4/9] fix: resolve packaged wallpaper assets --- electron/ipc/register/assets.ts | 8 +++++-- src/lib/assetPath.test.ts | 41 ++++++++++++++++++++++++++++++++- src/lib/assetPath.ts | 19 ++++++++------- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index fae8d708d..a9e972788 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -2,10 +2,10 @@ import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { ipcMain } from "electron"; +import { app, ipcMain } from "electron"; import { USER_DATA_PATH } from "../../appPaths"; -import { normalizePath } from "../utils"; import { getAssetRootPath } from "../project/manager"; +import { normalizePath } from "../utils"; export function registerAssetHandlers() { async function resolveReadableLocalFilePath(filePath: string) { @@ -73,6 +73,10 @@ export function registerAssetHandlers() { // Return base path for assets so renderer can resolve file:// paths in production ipcMain.handle("get-asset-base-path", () => { + if (!app.isPackaged) { + return null; + } + try { const assetPath = getAssetRootPath(); return pathToFileURL(`${assetPath}${path.sep}`).toString(); diff --git a/src/lib/assetPath.test.ts b/src/lib/assetPath.test.ts index 0cf6cd065..8956f2919 100644 --- a/src/lib/assetPath.test.ts +++ b/src/lib/assetPath.test.ts @@ -1,5 +1,44 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getExportableVideoUrl, getRenderableAssetUrl, getRenderableVideoUrl } from "./assetPath"; +import { + getAssetPath, + getExportableVideoUrl, + getRenderableAssetUrl, + getRenderableVideoUrl, +} from "./assetPath"; + +describe("getAssetPath", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses the packaged asset directory from an HTTP renderer", async () => { + vi.stubGlobal("window", { + location: { protocol: "http:" }, + electronAPI: { + getAssetBasePath: vi.fn( + async () => "file:///Applications/Recordly.app/Contents/Resources/assets/", + ), + }, + }); + + await expect(getAssetPath("wallpapers/tahoe-light.jpg")).resolves.toBe( + "file:///Applications/Recordly.app/Contents/Resources/assets/wallpapers/tahoe-light.jpg", + ); + }); + + it("uses root-relative assets when the dev server has no packaged asset base", async () => { + vi.stubGlobal("window", { + location: { protocol: "http:" }, + electronAPI: { + getAssetBasePath: vi.fn(async () => null), + }, + }); + + await expect(getAssetPath("wallpapers/tahoe-light.jpg")).resolves.toBe( + "/wallpapers/tahoe-light.jpg", + ); + }); +}); describe("getRenderableAssetUrl", () => { beforeEach(() => { diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index d6acbfbc6..f810c1537 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -59,19 +59,13 @@ export async function getAssetPath(relativePath: string): Promise { const isWebContext = typeof window !== "undefined" && Boolean(window.location?.protocol?.startsWith("http")); - if (isWebContext) { - return `/${encodedRelativePath}`; - } - try { if (typeof window !== "undefined") { if (typeof window.electronAPI?.getAssetBasePath === "function") { const base = await window.electronAPI.getAssetBasePath(); - if (!base) { - throw new Error(`Failed to resolve asset base path for ${relativePath}`); + if (base) { + return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString(); } - - return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString(); } } } catch (error) { @@ -80,8 +74,13 @@ export async function getAssetPath(relativePath: string): Promise { } } - // Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...' - return `/${encodedRelativePath}`; + if (isWebContext) { + // Dev and browser contexts serve public assets from the site root. Packaged + // Electron windows resolve above to the single extraResources asset copy. + return `/${encodedRelativePath}`; + } + + throw new Error(`Failed to resolve asset base path for ${relativePath}`); } const BASE64_CHUNK_SIZE = 0x8000; From 26008edd51b8b22595f891b88c64228f38cd46b9 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:47:12 +1000 Subject: [PATCH 5/9] fix: address startup review regressions --- src/components/ui/sonner.tsx | 5 ++- src/components/video-editor/SettingsPanel.tsx | 9 ++++- src/components/video-editor/VideoEditor.tsx | 3 +- .../export/useExportDialogActions.ts | 6 ++- .../video-editor/export/useExportRunner.ts | 37 ++++++++++++------- .../video-editor/export/useExportSession.ts | 2 + 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index fe1e39054..42ee8ac4d 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,11 +1,12 @@ import { Toaster as Sonner } from "sonner"; +import { cn } from "@/lib/utils"; type ToasterProps = React.ComponentProps; -const Toaster = ({ ...props }: ToasterProps) => { +const Toaster = ({ className, ...props }: ToasterProps) => { return ( { - if (!isBackgroundPanel && activeEffectSection !== "scene") return; + if ( + !isBackgroundPanel && + activeEffectSection !== "scene" && + activeEffectSection !== "frame" && + activeEffectSection !== "crop" + ) { + return; + } let mounted = true; (async () => { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 84c973b4c..883e8fc9b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -103,7 +103,7 @@ export default function VideoEditor() { setExportPipelineModel, } = exportSettings; const exportSession = useExportSession(); - const { exporterRef, pendingExportSaveRef } = exportSession; + const { exporterRef, exportRunIdRef, pendingExportSaveRef } = exportSession; const enableModernExportPipeline = useCallback(() => { setExportPipelineModel("modern"); }, []); @@ -165,6 +165,7 @@ export default function VideoEditor() { useEffect(() => { return () => { + exportRunIdRef.current += 1; exporterRef.current?.cancel(); exporterRef.current = null; const pending = pendingExportSaveRef.current; diff --git a/src/components/video-editor/export/useExportDialogActions.ts b/src/components/video-editor/export/useExportDialogActions.ts index 87de829a3..02cc8c25d 100644 --- a/src/components/video-editor/export/useExportDialogActions.ts +++ b/src/components/video-editor/export/useExportDialogActions.ts @@ -83,8 +83,10 @@ export function useExportDialogActions({ }, [videoPath, videoPlaybackRef, hasCaptionsForSidecar, settings, session, handleExport]); const handleCancelExport = useCallback(() => { - if (!session.exporterRef.current) return; - session.exporterRef.current.cancel(); + if (!session.isExporting) return; + session.exportRunIdRef.current += 1; + session.exporterRef.current?.cancel(); + session.exporterRef.current = null; toast.info("Export canceled"); session.clearPendingExportSave(); session.setShowExportDropdown(false); diff --git a/src/components/video-editor/export/useExportRunner.ts b/src/components/video-editor/export/useExportRunner.ts index 0472f9215..f36e70b5a 100644 --- a/src/components/video-editor/export/useExportRunner.ts +++ b/src/components/video-editor/export/useExportRunner.ts @@ -66,6 +66,7 @@ export function useExportRunner(input: ExportRunnerInput) { pendingExportSaveRef, clearPendingExportSave, markExportAsSaving, + exportRunIdRef, } = exportSession; if (!videoPath) { toast.error("No video loaded"); @@ -78,6 +79,10 @@ export function useExportRunner(input: ExportRunnerInput) { return; } + const exportRunId = exportRunIdRef.current + 1; + exportRunIdRef.current = exportRunId; + const exportWasCancelled = () => exportRunIdRef.current !== exportRunId; + setIsExporting(true); setExportProgress(null); setExportError(null); @@ -112,6 +117,7 @@ export function useExportRunner(input: ExportRunnerInput) { if (settings.format === "gif" && settings.gifConfig) { // GIF Export const { GifExporter } = await import("@/lib/exporter/gifExporter"); + if (exportWasCancelled()) return; const gifExporter = new GifExporter({ videoUrl: videoPath, width: settings.gifConfig.width, @@ -141,6 +147,7 @@ export function useExportRunner(input: ExportRunnerInput) { exporterRef.current = gifExporter; const result = await gifExporter.export(); + if (exportWasCancelled()) return; if (result.success && result.blob) { const timestamp = Date.now(); @@ -224,6 +231,7 @@ export function useExportRunner(input: ExportRunnerInput) { }); const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); + if (exportWasCancelled()) return; const { width: exportWidth, height: exportHeight } = calculateMp4ExportDimensions( supportedSourceDimensions.width, @@ -281,20 +289,20 @@ export function useExportRunner(input: ExportRunnerInput) { sourceAudioTrackSettings: sourceAudioTrackSettingsForExport, }; + const Exporter = + pipelineModel === "modern" + ? (await import("@/lib/exporter/modernVideoExporter")) + .ModernVideoExporter + : (await import("@/lib/exporter/videoExporter")).VideoExporter; + if (exportWasCancelled()) return; const exporter = pipelineModel === "modern" - ? new ( - await import("@/lib/exporter/modernVideoExporter") - ).ModernVideoExporter({ - ...exporterConfig, - backendPreference, - }) - : new (await import("@/lib/exporter/videoExporter")).VideoExporter( - exporterConfig, - ); + ? new Exporter({ ...exporterConfig, backendPreference }) + : new Exporter(exporterConfig); exporterRef.current = exporter; const result = await exporter.export(); + if (exportWasCancelled()) return; const smokeExportElapsedMs = smokeExportStartedAt !== null ? Math.round(performance.now() - smokeExportStartedAt) @@ -466,6 +474,7 @@ export function useExportRunner(input: ExportRunnerInput) { video.currentTime = restoreTime; } } catch (error) { + if (exportWasCancelled()) return; console.error("Export error:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; if (smokeExportConfig.enabled) { @@ -487,10 +496,12 @@ export function useExportRunner(input: ExportRunnerInput) { window.close(); } } finally { - setIsExporting(false); - exporterRef.current = null; - setShowExportDropdown(keepExportDialogOpen); - remountPreview(); + if (!exportWasCancelled()) { + setIsExporting(false); + exporterRef.current = null; + setShowExportDropdown(keepExportDialogOpen); + remountPreview(); + } } }, [showExportSuccessToast], diff --git a/src/components/video-editor/export/useExportSession.ts b/src/components/video-editor/export/useExportSession.ts index b9c92d4e8..a922c0490 100644 --- a/src/components/video-editor/export/useExportSession.ts +++ b/src/components/video-editor/export/useExportSession.ts @@ -13,6 +13,7 @@ export function useExportSession() { const [exportedFilePath, setExportedFilePath] = useState(); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const exporterRef = useRef(null); + const exportRunIdRef = useRef(0); const pendingExportSaveRef = useRef(null); const clearPendingExportSave = useCallback(() => { @@ -42,6 +43,7 @@ export function useExportSession() { hasPendingExportSave, setHasPendingExportSave, exporterRef, + exportRunIdRef, pendingExportSaveRef, clearPendingExportSave, markExportAsSaving, From 1e496608e9a94f69682a68c698456bfc3ace6446 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:47:46 +1000 Subject: [PATCH 6/9] chore: prepare 1.3.5 beta 3 test release --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40b49f261..dfbc3d2da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recordly", - "version": "1.3.5-beta.2", + "version": "1.3.5-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recordly", - "version": "1.3.5-beta.2", + "version": "1.3.5-beta.3", "hasInstallScript": true, "dependencies": { "@phosphor-icons/react": "^2.1.10", diff --git a/package.json b/package.json index f1ef508f5..5ba90fae2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/webadderallorg/Recordly/issues" }, "private": true, - "version": "1.3.5-beta.2", + "version": "1.3.5-beta.3", "type": "module", "scripts": { "dev": "vite --config vite.config.ts", From adc7df9f0b79412b76500b4fbdefa8b4ed206119 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:58:55 +1000 Subject: [PATCH 7/9] fix: harden export cancellation cleanup --- src/components/video-editor/SettingsPanel.tsx | 4 +- .../export/useExportDialogActions.ts | 1 + .../video-editor/export/useExportRunner.ts | 39 +++++++++++++++++-- .../video-editor/export/useExportSession.ts | 2 + 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 514ef0dc9..586e09d2a 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1172,7 +1172,9 @@ export function SettingsPanel({ !isBackgroundPanel && activeEffectSection !== "scene" && activeEffectSection !== "frame" && - activeEffectSection !== "crop" + activeEffectSection !== "crop" && + activeEffectSection !== "extensions" && + !activeEffectSection.startsWith("ext:") ) { return; } diff --git a/src/components/video-editor/export/useExportDialogActions.ts b/src/components/video-editor/export/useExportDialogActions.ts index 02cc8c25d..0c7bc87f4 100644 --- a/src/components/video-editor/export/useExportDialogActions.ts +++ b/src/components/video-editor/export/useExportDialogActions.ts @@ -84,6 +84,7 @@ export function useExportDialogActions({ const handleCancelExport = useCallback(() => { if (!session.isExporting) return; + session.cancelledExportRunIdRef.current = session.exportRunIdRef.current; session.exportRunIdRef.current += 1; session.exporterRef.current?.cancel(); session.exporterRef.current = null; diff --git a/src/components/video-editor/export/useExportRunner.ts b/src/components/video-editor/export/useExportRunner.ts index f36e70b5a..37edbdba4 100644 --- a/src/components/video-editor/export/useExportRunner.ts +++ b/src/components/video-editor/export/useExportRunner.ts @@ -67,6 +67,7 @@ export function useExportRunner(input: ExportRunnerInput) { clearPendingExportSave, markExportAsSaving, exportRunIdRef, + cancelledExportRunIdRef, } = exportSession; if (!videoPath) { toast.error("No video loaded"); @@ -81,7 +82,16 @@ export function useExportRunner(input: ExportRunnerInput) { const exportRunId = exportRunIdRef.current + 1; exportRunIdRef.current = exportRunId; + cancelledExportRunIdRef.current = null; const exportWasCancelled = () => exportRunIdRef.current !== exportRunId; + const exportWasExplicitlyCancelled = () => + cancelledExportRunIdRef.current === exportRunId; + const discardCancelledTemp = async (pending: PendingExportSave) => { + if (!pending.tempFilePath) return; + await window.electronAPI + .discardExportedTemp?.(pending.tempFilePath) + .catch(() => undefined); + }; setIsExporting(true); setExportProgress(null); @@ -90,10 +100,10 @@ export function useExportRunner(input: ExportRunnerInput) { const smokeExportStartedAt = smokeExportConfig.enabled ? performance.now() : null; let keepExportDialogOpen = false; + const wasPlaying = isPlaying; + const restoreTime = video.currentTime; try { - const wasPlaying = isPlaying; - const restoreTime = video.currentTime; if (wasPlaying) { videoPlaybackRef.current?.pause(); } @@ -136,6 +146,7 @@ export function useExportRunner(input: ExportRunnerInput) { previewHeight, shadowIntensity: effectiveShadowIntensity, onProgress: (progress) => { + if (exportWasCancelled()) return; recordSmokeProgress(progress); setExportProgress(progress); }, @@ -159,6 +170,10 @@ export function useExportRunner(input: ExportRunnerInput) { fileName, smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, ); + if (exportWasCancelled()) { + await discardCancelledTemp(pendingSave); + return; + } if (saveResult.canceled) { pendingExportSaveRef.current = pendingSave; @@ -277,6 +292,7 @@ export function useExportRunner(input: ExportRunnerInput) { previewHeight, shadowIntensity: effectiveShadowIntensity, onProgress: (progress) => { + if (exportWasCancelled()) return; recordSmokeProgress(progress); setExportProgress(progress); }, @@ -338,6 +354,14 @@ export function useExportRunner(input: ExportRunnerInput) { : null, captionSidecar: sidecarForThisExport, }); + if (exportWasCancelled()) { + await discardCancelledTemp({ + fileName, + tempFilePath: result.tempFilePath, + captionSidecar: sidecarForThisExport, + }); + return; + } pendingOnCancel = { fileName, tempFilePath: result.tempFilePath, @@ -353,6 +377,10 @@ export function useExportRunner(input: ExportRunnerInput) { smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, sidecarForThisExport, ); + if (exportWasCancelled()) { + await discardCancelledTemp(blobSave.pendingSave); + return; + } saveResult = blobSave.saveResult; pendingOnCancel = blobSave.pendingSave; } else { @@ -496,7 +524,12 @@ export function useExportRunner(input: ExportRunnerInput) { window.close(); } } finally { - if (!exportWasCancelled()) { + if (exportWasExplicitlyCancelled() && exportRunIdRef.current === exportRunId + 1) { + video.currentTime = restoreTime; + if (wasPlaying) { + await videoPlaybackRef.current?.play().catch(() => undefined); + } + } else if (!exportWasCancelled()) { setIsExporting(false); exporterRef.current = null; setShowExportDropdown(keepExportDialogOpen); diff --git a/src/components/video-editor/export/useExportSession.ts b/src/components/video-editor/export/useExportSession.ts index a922c0490..a36ac6291 100644 --- a/src/components/video-editor/export/useExportSession.ts +++ b/src/components/video-editor/export/useExportSession.ts @@ -14,6 +14,7 @@ export function useExportSession() { const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const exporterRef = useRef(null); const exportRunIdRef = useRef(0); + const cancelledExportRunIdRef = useRef(null); const pendingExportSaveRef = useRef(null); const clearPendingExportSave = useCallback(() => { @@ -44,6 +45,7 @@ export function useExportSession() { setHasPendingExportSave, exporterRef, exportRunIdRef, + cancelledExportRunIdRef, pendingExportSaveRef, clearPendingExportSave, markExportAsSaving, From a35aa2326263033cc78b5ee94e8144d3c35b7dad Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:30:26 +1000 Subject: [PATCH 8/9] ci: support public beta feed without a release --- .github/workflows/release.yml | 89 ++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dea29ea6d..8e37edddc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,9 +6,21 @@ on: workflow_dispatch: inputs: tag_name: - description: Existing release tag to rebuild and publish + description: Version tag name (must match package.json) required: true type: string + source_sha: + description: Exact source commit (required for a Pages beta feed) + required: false + type: string + publish_target: + description: Where to publish update assets + required: true + default: release + type: choice + options: + - release + - pages release_scope: description: Platforms to publish required: true @@ -24,6 +36,7 @@ permissions: id-token: write attestations: write artifact-metadata: write + pages: write concurrency: group: release-${{ github.event.release.tag_name || github.event.inputs.tag_name || github.run_id }} @@ -39,11 +52,12 @@ jobs: source_sha: ${{ steps.prepare.outputs.source_sha }} prerelease: ${{ steps.prepare.outputs.prerelease }} release_scope: ${{ steps.prepare.outputs.release_scope }} + publish_target: ${{ steps.prepare.outputs.publish_target }} steps: - name: Checkout release source uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.tag_name }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.publish_target == 'pages' && github.event.inputs.source_sha || github.event.inputs.tag_name }} fetch-depth: 0 - name: Validate release tag @@ -55,19 +69,28 @@ jobs: REQUESTED_SCOPE: ${{ github.event.inputs.release_scope }} RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} DISPATCH_TAG_NAME: ${{ github.event.inputs.tag_name }} + REQUESTED_SOURCE_SHA: ${{ github.event.inputs.source_sha }} + REQUESTED_PUBLISH_TARGET: ${{ github.event.inputs.publish_target }} run: | set -euo pipefail if [ "${{ github.event_name }}" = "release" ]; then TAG_NAME="$RELEASE_TAG_NAME" IS_PRERELEASE="${{ github.event.release.prerelease }}" RELEASE_SCOPE="all" + PUBLISH_TARGET="release" if [ "$IS_PRERELEASE" = "true" ] && grep -Fq '' <<< "$RELEASE_BODY"; then RELEASE_SCOPE="windows-linux" fi else TAG_NAME="$DISPATCH_TAG_NAME" - IS_PRERELEASE="$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)" - RELEASE_SCOPE="${REQUESTED_SCOPE:-all}" + PUBLISH_TARGET="${REQUESTED_PUBLISH_TARGET:-release}" + if [ "$PUBLISH_TARGET" = "pages" ]; then + IS_PRERELEASE="true" + RELEASE_SCOPE="all" + else + IS_PRERELEASE="$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)" + RELEASE_SCOPE="${REQUESTED_SCOPE:-all}" + fi fi if [ -z "$TAG_NAME" ]; then @@ -91,12 +114,32 @@ jobs: ;; esac + case "$PUBLISH_TARGET" in + release|pages) ;; + *) + echo "Unsupported publish target: $PUBLISH_TARGET" + exit 1 + ;; + esac + + if [ "$PUBLISH_TARGET" = "pages" ]; then + requested_source_sha="${REQUESTED_SOURCE_SHA,,}" + if [[ ! "$requested_source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "A Pages beta feed requires an exact 40-character source_sha." + exit 1 + fi + if [ "$(git rev-parse HEAD)" != "$requested_source_sha" ]; then + echo "Checked-out source does not match source_sha." + exit 1 + fi + fi + if [ "$IS_PRERELEASE" != "true" ] && [ "$RELEASE_SCOPE" != "all" ]; then echo "Partial platform scope is allowed only for prereleases." exit 1 fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "$PUBLISH_TARGET" = "release" ]; then gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1 || \ gh release create "$TAG_NAME" --repo "${{ github.repository }}" --verify-tag --draft --title "$TAG_NAME" fi @@ -106,6 +149,7 @@ jobs: echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" echo "release_scope=$RELEASE_SCOPE" >> "$GITHUB_OUTPUT" + echo "publish_target=$PUBLISH_TARGET" >> "$GITHUB_OUTPUT" build-macos-x64: name: Build macOS x64 needs: prepare-release @@ -707,6 +751,7 @@ jobs: subject-checksums: release-assets/upload/SHA256SUMS.txt - name: Upload release assets to GitHub + if: needs.prepare-release.outputs.publish_target == 'release' shell: bash run: | set -euo pipefail @@ -728,13 +773,45 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Add beta feed landing page + if: needs.prepare-release.outputs.publish_target == 'pages' + shell: bash + run: | + touch release-assets/upload/.nojekyll + printf '%s\n' \ + '' \ + 'Recordly Experimental Updates' \ + '

Recordly Experimental Updates

Public updater assets for opt-in experimental builds. These builds may be unstable.

' \ + > release-assets/upload/index.html + + - name: Upload public beta feed artifact + if: needs.prepare-release.outputs.publish_target == 'pages' + uses: actions/upload-pages-artifact@v4 + with: + path: release-assets/upload + + deploy-experimental-update-feed: + name: Deploy experimental update feed + needs: + - prepare-release + - publish-release-assets + if: needs.prepare-release.outputs.publish_target == 'pages' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy public beta feed without a GitHub Release + id: deployment + uses: actions/deploy-pages@v4 + trigger-homebrew-update: name: Trigger Homebrew tap update needs: - prepare-release - publish-release-assets runs-on: ubuntu-latest - if: needs.prepare-release.outputs.prerelease != 'true' && needs.prepare-release.outputs.release_scope == 'all' + if: needs.prepare-release.outputs.publish_target == 'release' && needs.prepare-release.outputs.prerelease != 'true' && needs.prepare-release.outputs.release_scope == 'all' steps: - name: Dispatch Homebrew tap workflow shell: bash From f945ca50fe7ce34b73e7e823c6829f04466452a9 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:37:41 +1000 Subject: [PATCH 9/9] Revert "ci: support public beta feed without a release" This reverts commit a35aa2326263033cc78b5ee94e8144d3c35b7dad. --- .github/workflows/release.yml | 89 +++-------------------------------- 1 file changed, 6 insertions(+), 83 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e37edddc..dea29ea6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,21 +6,9 @@ on: workflow_dispatch: inputs: tag_name: - description: Version tag name (must match package.json) + description: Existing release tag to rebuild and publish required: true type: string - source_sha: - description: Exact source commit (required for a Pages beta feed) - required: false - type: string - publish_target: - description: Where to publish update assets - required: true - default: release - type: choice - options: - - release - - pages release_scope: description: Platforms to publish required: true @@ -36,7 +24,6 @@ permissions: id-token: write attestations: write artifact-metadata: write - pages: write concurrency: group: release-${{ github.event.release.tag_name || github.event.inputs.tag_name || github.run_id }} @@ -52,12 +39,11 @@ jobs: source_sha: ${{ steps.prepare.outputs.source_sha }} prerelease: ${{ steps.prepare.outputs.prerelease }} release_scope: ${{ steps.prepare.outputs.release_scope }} - publish_target: ${{ steps.prepare.outputs.publish_target }} steps: - name: Checkout release source uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.publish_target == 'pages' && github.event.inputs.source_sha || github.event.inputs.tag_name }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.tag_name }} fetch-depth: 0 - name: Validate release tag @@ -69,28 +55,19 @@ jobs: REQUESTED_SCOPE: ${{ github.event.inputs.release_scope }} RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} DISPATCH_TAG_NAME: ${{ github.event.inputs.tag_name }} - REQUESTED_SOURCE_SHA: ${{ github.event.inputs.source_sha }} - REQUESTED_PUBLISH_TARGET: ${{ github.event.inputs.publish_target }} run: | set -euo pipefail if [ "${{ github.event_name }}" = "release" ]; then TAG_NAME="$RELEASE_TAG_NAME" IS_PRERELEASE="${{ github.event.release.prerelease }}" RELEASE_SCOPE="all" - PUBLISH_TARGET="release" if [ "$IS_PRERELEASE" = "true" ] && grep -Fq '' <<< "$RELEASE_BODY"; then RELEASE_SCOPE="windows-linux" fi else TAG_NAME="$DISPATCH_TAG_NAME" - PUBLISH_TARGET="${REQUESTED_PUBLISH_TARGET:-release}" - if [ "$PUBLISH_TARGET" = "pages" ]; then - IS_PRERELEASE="true" - RELEASE_SCOPE="all" - else - IS_PRERELEASE="$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)" - RELEASE_SCOPE="${REQUESTED_SCOPE:-all}" - fi + IS_PRERELEASE="$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)" + RELEASE_SCOPE="${REQUESTED_SCOPE:-all}" fi if [ -z "$TAG_NAME" ]; then @@ -114,32 +91,12 @@ jobs: ;; esac - case "$PUBLISH_TARGET" in - release|pages) ;; - *) - echo "Unsupported publish target: $PUBLISH_TARGET" - exit 1 - ;; - esac - - if [ "$PUBLISH_TARGET" = "pages" ]; then - requested_source_sha="${REQUESTED_SOURCE_SHA,,}" - if [[ ! "$requested_source_sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "A Pages beta feed requires an exact 40-character source_sha." - exit 1 - fi - if [ "$(git rev-parse HEAD)" != "$requested_source_sha" ]; then - echo "Checked-out source does not match source_sha." - exit 1 - fi - fi - if [ "$IS_PRERELEASE" != "true" ] && [ "$RELEASE_SCOPE" != "all" ]; then echo "Partial platform scope is allowed only for prereleases." exit 1 fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "$PUBLISH_TARGET" = "release" ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1 || \ gh release create "$TAG_NAME" --repo "${{ github.repository }}" --verify-tag --draft --title "$TAG_NAME" fi @@ -149,7 +106,6 @@ jobs: echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" echo "release_scope=$RELEASE_SCOPE" >> "$GITHUB_OUTPUT" - echo "publish_target=$PUBLISH_TARGET" >> "$GITHUB_OUTPUT" build-macos-x64: name: Build macOS x64 needs: prepare-release @@ -751,7 +707,6 @@ jobs: subject-checksums: release-assets/upload/SHA256SUMS.txt - name: Upload release assets to GitHub - if: needs.prepare-release.outputs.publish_target == 'release' shell: bash run: | set -euo pipefail @@ -773,45 +728,13 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Add beta feed landing page - if: needs.prepare-release.outputs.publish_target == 'pages' - shell: bash - run: | - touch release-assets/upload/.nojekyll - printf '%s\n' \ - '' \ - 'Recordly Experimental Updates' \ - '

Recordly Experimental Updates

Public updater assets for opt-in experimental builds. These builds may be unstable.

' \ - > release-assets/upload/index.html - - - name: Upload public beta feed artifact - if: needs.prepare-release.outputs.publish_target == 'pages' - uses: actions/upload-pages-artifact@v4 - with: - path: release-assets/upload - - deploy-experimental-update-feed: - name: Deploy experimental update feed - needs: - - prepare-release - - publish-release-assets - if: needs.prepare-release.outputs.publish_target == 'pages' - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy public beta feed without a GitHub Release - id: deployment - uses: actions/deploy-pages@v4 - trigger-homebrew-update: name: Trigger Homebrew tap update needs: - prepare-release - publish-release-assets runs-on: ubuntu-latest - if: needs.prepare-release.outputs.publish_target == 'release' && needs.prepare-release.outputs.prerelease != 'true' && needs.prepare-release.outputs.release_scope == 'all' + if: needs.prepare-release.outputs.prerelease != 'true' && needs.prepare-release.outputs.release_scope == 'all' steps: - name: Dispatch Homebrew tap workflow shell: bash