Merge startup optimizations and prepare 1.4.0 beta 1

This commit is contained in:
webadderall
2026-09-05 19:19:18 +10:00
26 changed files with 339 additions and 178 deletions
+7 -2
View File
@@ -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/**",
+6 -2
View File
@@ -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();
+19 -27
View File
@@ -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,
+10 -1
View File
@@ -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),
});
+10 -1
View File
@@ -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;
+3
View File
@@ -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();
}
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "recordly",
"version": "1.3.5-beta.2",
"version": "1.4.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recordly",
"version": "1.3.5-beta.2",
"version": "1.4.0-beta.1",
"hasInstallScript": true,
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
+1 -1
View File
@@ -12,7 +12,7 @@
"url": "https://github.com/webadderallorg/Recordly/issues"
},
"private": true,
"version": "1.3.5-beta.2",
"version": "1.4.0-beta.1",
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts",
+44 -48
View File
@@ -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 (
<>
<LaunchWindow />
<Toaster className="pointer-events-auto" />
</>
);
content = <HudWindow />;
break;
case "source-selector":
return <SourceSelector />;
content = <SourceSelector />;
break;
case "countdown":
return <CountdownOverlay />;
content = <CountdownOverlay />;
break;
case "update-toast":
return <UpdateToastWindow />;
content = <UpdateToastWindow />;
break;
case "editor":
return (
<>
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
<AnnouncementDialog audience="editor" />
<LiveAnnouncementNotifications audience="editor" />
</>
);
content = <EditorWindow />;
break;
default:
return (
content = (
<div className="flex h-full w-full items-center justify-center bg-editor-bg text-foreground">
<div className="flex items-center gap-4 rounded-2xl border border-foreground/10 bg-foreground/5 px-6 py-5 shadow-2xl shadow-black/30 backdrop-blur-xl">
<img
@@ -103,4 +97,6 @@ export default function App() {
</div>
);
}
return <Suspense fallback={null}>{content}</Suspense>;
}
+11
View File
@@ -0,0 +1,11 @@
import { Toaster } from "../ui/sonner";
import { LaunchWindow } from "./LaunchWindow";
export default function HudWindow() {
return (
<>
<LaunchWindow />
<Toaster className="pointer-events-auto" />
</>
);
}
+1 -1
View File
@@ -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;
+37
View File
@@ -0,0 +1,37 @@
import { useLayoutEffect, useRef, useState } from "react";
export function MarqueeText({ text }: { text: string }) {
const staticRef = useRef<HTMLSpanElement>(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 (
<div
className="w-full source-selector-marquee"
data-overflowing={overflowing ? "true" : "false"}
>
<span ref={staticRef} className="source-selector-marquee-static">
{text}
</span>
<span className="source-selector-marquee-animated">
<span className="source-selector-marquee-track">
<span className="source-selector-marquee-segment">{text}</span>
<span className="source-selector-marquee-segment source-selector-marquee-duplicate">
{text}
</span>
</span>
</span>
</div>
);
}
+2 -37
View File
@@ -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<HTMLSpanElement>(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 (
<div
className="w-full source-selector-marquee"
data-overflowing={overflowing ? "true" : "false"}
>
<span ref={staticRef} className="source-selector-marquee-static">
{text}
</span>
<span className="source-selector-marquee-animated">
<span className="source-selector-marquee-track">
<span className="source-selector-marquee-segment">{text}</span>
<span className="source-selector-marquee-segment source-selector-marquee-duplicate">
{text}
</span>
</span>
</span>
</div>
);
}
/**
* SourceSelectorContent - The actual list of sources
*/
@@ -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"
>
<ProjectBrowserDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) requestClose(POPOVER_ID);
}}
entries={entries}
renderMode="inline"
onOpenProject={(path) => {
onOpenProject(path);
requestClose(POPOVER_ID);
}}
/>
<Suspense fallback={null}>
<ProjectBrowserDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) requestClose(POPOVER_ID);
}}
entries={entries}
renderMode="inline"
onOpenProject={(path) => {
onOpenProject(path);
requestClose(POPOVER_ID);
}}
/>
</Suspense>
</HudPopover>
);
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { Toaster as Sonner } from "sonner";
import { cn } from "@/lib/utils";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const Toaster = ({ className, ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
className={cn("toaster group", className)}
duration={3000}
toastOptions={{
classNames: {
@@ -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 (
<>
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
<AnnouncementDialog audience="editor" />
<LiveAnnouncementNotifications audience="editor" />
</>
);
}
+14 -3
View File
@@ -1110,6 +1110,8 @@ export function SettingsPanel({
);
const [experimentalUpdatesEnabled, setExperimentalUpdatesEnabled] = useState(false);
const [savingExperimentalUpdates, setSavingExperimentalUpdates] = useState(false);
const [internalActiveEffectSection] = useState<EditorEffectSection>("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<HTMLInputElement | null>(null);
const cursorClickEffectColorInputRef = useRef<HTMLInputElement | null>(null);
const defaultWebcam = initialEditorPreferences.webcam;
const [internalActiveEffectSection] = useState<EditorEffectSection>("scene");
const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection;
const [builtInCursorPreviewUrls, setBuiltInCursorPreviewUrls] = useState<
Partial<Record<string, string>>
>({});
+2 -1
View File
@@ -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;
@@ -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);
@@ -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],
@@ -13,6 +13,8 @@ export function useExportSession() {
const [exportedFilePath, setExportedFilePath] = useState<string>();
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const exporterRef = useRef<CancelableExporter | null>(null);
const exportRunIdRef = useRef(0);
const cancelledExportRunIdRef = useRef<number | null>(null);
const pendingExportSaveRef = useRef<PendingExportSave | null>(null);
const clearPendingExportSave = useCallback(() => {
@@ -42,6 +44,8 @@ export function useExportSession() {
hasPendingExportSave,
setHasPendingExportSave,
exporterRef,
exportRunIdRef,
cancelledExportRunIdRef,
pendingExportSaveRef,
clearPendingExportSave,
markExportAsSaving,
@@ -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";
@@ -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,
+40 -1
View File
@@ -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(() => {
+9 -10
View File
@@ -59,19 +59,13 @@ export async function getAssetPath(relativePath: string): Promise<string> {
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<string> {
}
}
// 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;
-1
View File
@@ -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"],
},