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/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/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/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", 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/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 ( { + 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..586e09d2a 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,17 @@ export function SettingsPanel({ }; useEffect(() => { + if ( + !isBackgroundPanel && + activeEffectSection !== "scene" && + activeEffectSection !== "frame" && + activeEffectSection !== "crop" && + activeEffectSection !== "extensions" && + !activeEffectSection.startsWith("ext:") + ) { + return; + } + let mounted = true; (async () => { try { @@ -1196,7 +1209,7 @@ export function SettingsPanel({ return () => { mounted = false; }; - }, []); + }, [activeEffectSection, isBackgroundPanel]); const colorPalette = [ "#FF0000", @@ -1231,8 +1244,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/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..0c7bc87f4 100644 --- a/src/components/video-editor/export/useExportDialogActions.ts +++ b/src/components/video-editor/export/useExportDialogActions.ts @@ -83,8 +83,11 @@ 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.cancelledExportRunIdRef.current = session.exportRunIdRef.current; + 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 95aef628f..37edbdba4 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"; @@ -71,6 +66,8 @@ export function useExportRunner(input: ExportRunnerInput) { pendingExportSaveRef, clearPendingExportSave, markExportAsSaving, + exportRunIdRef, + cancelledExportRunIdRef, } = exportSession; if (!videoPath) { toast.error("No video loaded"); @@ -83,6 +80,19 @@ export function useExportRunner(input: ExportRunnerInput) { return; } + 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); setExportError(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(); } @@ -116,6 +126,8 @@ 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, @@ -134,6 +146,7 @@ export function useExportRunner(input: ExportRunnerInput) { previewHeight, shadowIntensity: effectiveShadowIntensity, onProgress: (progress) => { + if (exportWasCancelled()) return; recordSmokeProgress(progress); setExportProgress(progress); }, @@ -143,8 +156,9 @@ export function useExportRunner(input: ExportRunnerInput) { maxPendingFrames: smokeExportConfig.maxPendingFrames, }); - exporterRef.current = gifExporter as unknown as VideoExporter; + exporterRef.current = gifExporter; const result = await gifExporter.export(); + if (exportWasCancelled()) return; if (result.success && result.blob) { const timestamp = Date.now(); @@ -156,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; @@ -228,6 +246,7 @@ export function useExportRunner(input: ExportRunnerInput) { }); const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); + if (exportWasCancelled()) return; const { width: exportWidth, height: exportHeight } = calculateMp4ExportDimensions( supportedSourceDimensions.width, @@ -273,6 +292,7 @@ export function useExportRunner(input: ExportRunnerInput) { previewHeight, shadowIntensity: effectiveShadowIntensity, onProgress: (progress) => { + if (exportWasCancelled()) return; recordSmokeProgress(progress); setExportProgress(progress); }, @@ -285,16 +305,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 ModernVideoExporter({ - ...exporterConfig, - backendPreference, - }) - : new 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) @@ -330,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, @@ -345,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 { @@ -466,6 +502,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 +524,17 @@ export function useExportRunner(input: ExportRunnerInput) { window.close(); } } finally { - setIsExporting(false); - exporterRef.current = null; - setShowExportDropdown(keepExportDialogOpen); - remountPreview(); + 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); + remountPreview(); + } } }, [showExportSuccessToast], diff --git a/src/components/video-editor/export/useExportSession.ts b/src/components/video-editor/export/useExportSession.ts index b9c92d4e8..a36ac6291 100644 --- a/src/components/video-editor/export/useExportSession.ts +++ b/src/components/video-editor/export/useExportSession.ts @@ -13,6 +13,8 @@ export function useExportSession() { const [exportedFilePath, setExportedFilePath] = useState(); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const exporterRef = useRef(null); + const exportRunIdRef = useRef(0); + const cancelledExportRunIdRef = useRef(null); const pendingExportSaveRef = useRef(null); const clearPendingExportSave = useCallback(() => { @@ -42,6 +44,8 @@ export function useExportSession() { hasPendingExportSave, setHasPendingExportSave, exporterRef, + exportRunIdRef, + cancelledExportRunIdRef, pendingExportSaveRef, clearPendingExportSave, markExportAsSaving, 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/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; 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"], },