Merge pull request #496 from Enriquefft/fix/wallpaper-export-376

Fix wallpaper backgrounds exporting as black (#376)
This commit is contained in:
Sid
2026-04-24 21:34:59 -07:00
committed by GitHub
27 changed files with 725 additions and 387 deletions
+3 -1
View File
@@ -20,10 +20,12 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
// Asset layout contract: "wallpapers/" under resourcesPath must align with
// assetBaseDir in electron/preload.ts (packaged branch).
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
"to": "wallpapers"
}
],
+1 -1
View File
@@ -37,7 +37,7 @@ interface Window {
status: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
assetBaseUrl: string;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
+1 -16
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
@@ -801,21 +801,6 @@ export function registerIpcHandlers(
}
});
// Return base path for assets so renderer can resolve file:// paths in production
ipcMain.handle("get-asset-base-path", () => {
try {
if (app.isPackaged) {
const assetPath = path.join(process.resourcesPath, "assets");
return pathToFileURL(`${assetPath}${path.sep}`).toString();
}
const assetPath = path.join(app.getAppPath(), "public", "assets");
return pathToFileURL(`${assetPath}${path.sep}`).toString();
} catch (err) {
console.error("Failed to resolve asset base path:", err);
return null;
}
});
/**
* Handles saving an exported video file.
* Shows a save dialog, normalizes the file path for the current OS,
+14 -4
View File
@@ -1,17 +1,27 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { contextBridge, ipcRenderer } from "electron";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
// Asset base URL is a build-time constant per process; resolve once here so
// the renderer can consume it synchronously. Packaged: electron-builder
// extraResources copies public/wallpapers -> resources/wallpapers (see
// electron-builder.json5). Unpackaged: wallpapers live at <appRoot>/public/,
// and __dirname in dist-electron resolves to <appRoot>/dist-electron/.
const isPackagedProcess = !process.defaultApp;
const assetBaseDir = isPackagedProcess
? process.resourcesPath
: path.join(__dirname, "..", "public");
const assetBaseUrl = pathToFileURL(`${assetBaseDir}${path.sep}`).toString();
contextBridge.exposeInMainWorld("electronAPI", {
assetBaseUrl,
hudOverlayHide: () => {
ipcRenderer.send("hud-overlay-hide");
},
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke("get-asset-base-path");
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
+4 -4
View File
@@ -68,10 +68,10 @@ buildNpmPackage {
cp -r node_modules "$out/lib/openscreen/"
# Asset resolution: when app.isPackaged is false, the main process resolves
# assets at <appPath>/public/assets/. Mirror the electron-builder
# extraResources layout so wallpapers load correctly.
mkdir -p "$out/lib/openscreen/public/assets"
cp -r public/wallpapers "$out/lib/openscreen/public/assets/wallpapers"
# assets at <appPath>/public/. Place wallpapers at that root to match the
# packaged layout (electron-builder extraResources -> resources/wallpapers).
mkdir -p "$out/lib/openscreen/public"
cp -r public/wallpapers "$out/lib/openscreen/public/wallpapers"
# Wrap system electron with the app directory
mkdir -p "$out/bin"
+13 -44
View File
@@ -14,7 +14,7 @@ import {
Upload,
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
@@ -34,11 +34,11 @@ import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
@@ -123,11 +123,6 @@ function CustomSpeedInput({
);
}
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `wallpapers/wallpaper${i + 1}.jpg`,
);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -326,24 +321,12 @@ export function SettingsPanel({
onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
// on click — never the machine-specific file:// URL.
const wallpaperPreviewUrls = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
if (mounted) setWallpaperPaths(resolved);
} catch (_err) {
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
}
})();
return () => {
mounted = false;
};
}, []);
const colorPalette = [
"#FF0000",
"#FFD700",
@@ -526,7 +509,7 @@ export function SettingsPanel({
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
// If the removed image was selected, clear selection
if (selected === imageUrl) {
onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]);
onWallpaperChange(WALLPAPER_PATHS[0]);
}
};
@@ -1146,26 +1129,12 @@ export function SettingsPanel({
);
})}
{(wallpaperPaths.length > 0
? wallpaperPaths
: WALLPAPER_RELATIVE.map((p) => `/${p}`)
).map((path) => {
const isSelected = (() => {
if (!selected) return false;
if (selected === path) return true;
try {
const clean = (s: string) =>
s.replace(/^file:\/\//, "").replace(/^\//, "");
if (clean(selected).endsWith(clean(path))) return true;
if (clean(path).endsWith(clean(selected))) return true;
} catch {
// Best-effort comparison; fallback to strict match.
}
return false;
})();
{WALLPAPER_PATHS.map((canonicalPath, i) => {
const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath;
const isSelected = selected === canonicalPath;
return (
<div
key={path}
key={canonicalPath}
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
isSelected
@@ -1173,11 +1142,11 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{
backgroundImage: `url(${path})`,
backgroundImage: `url(${previewUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
onClick={() => onWallpaperChange(path)}
onClick={() => onWallpaperChange(canonicalPath)}
role="button"
/>
);
+11 -3
View File
@@ -32,6 +32,7 @@ import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import {
getAspectRatioValue,
getNativeAspectRatioValue,
@@ -1566,9 +1567,15 @@ export default function VideoEditor() {
}
} catch (error) {
console.error("Export error:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(`Export failed: ${errorMessage}`);
if (error instanceof BackgroundLoadError) {
const message = t("errors.exportBackgroundLoadFailed", { url: error.displayUrl });
setExportError(message);
toast.error(message);
} else {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(t("errors.exportFailedWithError", { error: errorMessage }));
}
} finally {
setIsExporting(false);
exporterRef.current = null;
@@ -1601,6 +1608,7 @@ export default function VideoEditor() {
exportQuality,
handleExportSaved,
cursorTelemetry,
t,
],
);
+12 -54
View File
@@ -18,7 +18,6 @@ import {
useRef,
useState,
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import {
getWebcamLayoutCssBoxShadow,
type Size,
@@ -26,6 +25,7 @@ import {
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
@@ -1108,7 +1108,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
const resolvedWallpaper = useMemo<string | null>(() => {
const source = wallpaper || DEFAULT_WALLPAPER;
const classified = classifyWallpaper(source);
if (classified.kind !== "image") return classified.value;
try {
return resolveImageWallpaperUrl(classified.path);
} catch (err) {
console.warn("[VideoPlayback] wallpaper resolve failed:", err);
return null;
}
}, [wallpaper]);
const webcamCssBoxShadow = useMemo(
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
[webcamLayoutPreset],
@@ -1176,58 +1186,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideo.currentTime = 0;
}, [webcamVideoPath]);
useEffect(() => {
let mounted = true;
(async () => {
try {
if (!wallpaper) {
const def = await getAssetPath("wallpapers/wallpaper1.jpg");
if (mounted) setResolvedWallpaper(def);
return;
}
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
// If it's a data URL (custom uploaded image), use as-is
if (wallpaper.startsWith("data:")) {
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
// If it's an absolute web/http or file path, use as-is
if (
wallpaper.startsWith("http") ||
wallpaper.startsWith("file://") ||
wallpaper.startsWith("/")
) {
// If it's an absolute server path (starts with '/'), resolve via getAssetPath as well
if (wallpaper.startsWith("/")) {
const rel = wallpaper.replace(/^\//, "");
const p = await getAssetPath(rel);
if (mounted) setResolvedWallpaper(p);
return;
}
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
const p = await getAssetPath(wallpaper.replace(/^\//, ""));
if (mounted) setResolvedWallpaper(p);
} catch (_err) {
if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg");
}
})();
return () => {
mounted = false;
};
}, [wallpaper]);
useEffect(() => {
return () => {
if (videoReadyRafRef.current) {
@@ -197,3 +197,62 @@ it("detects unsaved changes from differing snapshots", () => {
expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
});
describe("wallpaper legacy normalization", () => {
it("rewrites pre-fix packaged paths (resources/assets/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/assets/wallpapers/wallpaper5.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper5.jpg");
});
it("rewrites new packaged layout (resources/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper3.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper3.jpg");
});
it("rewrites unpackaged dev layout (public/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///home/user/project/public/wallpapers/wallpaper1.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
});
it("rewrites Windows-style file URLs with drive letter", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///C:/Users/me/openscreen/resources/wallpapers/wallpaper2.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
});
it("leaves canonical relative paths untouched", () => {
const normalized = normalizeProjectEditor({ wallpaper: "/wallpapers/wallpaper2.jpg" });
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
});
it("leaves data URIs untouched", () => {
const dataUri = "data:image/png;base64,AAA";
expect(normalizeProjectEditor({ wallpaper: dataUri }).wallpaper).toBe(dataUri);
});
it("leaves colors and gradients untouched", () => {
expect(normalizeProjectEditor({ wallpaper: "#1a1a2e" }).wallpaper).toBe("#1a1a2e");
expect(
normalizeProjectEditor({ wallpaper: "linear-gradient(90deg, red, blue)" }).wallpaper,
).toBe("linear-gradient(90deg, red, blue)");
});
it("does NOT rewrite user files outside the known install layout", () => {
const userPath = "file:///home/user/Pictures/wallpapers/wallpaper1.jpg";
expect(normalizeProjectEditor({ wallpaper: userPath }).wallpaper).toBe(userPath);
});
it("falls back to default for bundled paths outside WALLPAPER_PATHS", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper99.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
});
});
@@ -2,6 +2,7 @@ import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
@@ -37,13 +38,23 @@ import {
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 18;
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
// bundled wallpapers. Rewrite only paths that match a known install layout
// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so
// a legitimate user file that happens to live in a folder named "wallpapers"
// elsewhere is never silently replaced.
const LEGACY_FILE_WALLPAPER_RE =
/^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i;
const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS);
function normalizeWallpaperValue(value: string): string {
const match = LEGACY_FILE_WALLPAPER_RE.exec(value);
if (!match) return value;
const canonical = `/wallpapers/${match[1]}`;
return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER;
}
export const PROJECT_VERSION = 2;
@@ -425,7 +436,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
wallpaper:
typeof editor.wallpaper === "string"
? normalizeWallpaperValue(editor.wallpaper)
: DEFAULT_WALLPAPER,
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
+2 -1
View File
@@ -17,6 +17,7 @@ import {
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
} from "@/components/video-editor/types";
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
@@ -46,7 +47,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
speedRegions: [],
annotationRegions: [],
cropRegion: DEFAULT_CROP_REGION,
wallpaper: "/wallpapers/wallpaper1.jpg",
wallpaper: DEFAULT_WALLPAPER,
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
+1
View File
@@ -14,6 +14,7 @@
"failedToSaveVideo": "Failed to save video",
"exportFailed": "Export failed",
"exportFailedWithError": "Export failed: {{error}}",
"exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})",
"failedToSaveExport": "Failed to save export",
"failedToSaveExportedVideo": "Failed to save exported video",
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
+1
View File
@@ -8,6 +8,7 @@
"failedToSaveVideo": "Error al guardar el video",
"exportFailed": "La exportación falló",
"exportFailedWithError": "La exportación falló: {{error}}",
"exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})",
"failedToSaveExport": "Error al guardar la exportación",
"failedToSaveExportedVideo": "Error al guardar el video exportado",
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
+1
View File
@@ -14,6 +14,7 @@
"failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
"exportFailed": "L'export a échoué",
"exportFailedWithError": "L'export a échoué : {{error}}",
"exportBackgroundLoadFailed": "L'export a échoué : impossible de charger l'image d'arrière-plan ({{url}})",
"failedToSaveExport": "Échec de l'enregistrement de l'export",
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}"
+1
View File
@@ -14,6 +14,7 @@
"failedToSaveVideo": "비디오 저장에 실패했습니다",
"exportFailed": "내보내기에 실패했습니다",
"exportFailedWithError": "내보내기 실패: {{error}}",
"exportBackgroundLoadFailed": "내보내기 실패: 배경 이미지를 불러올 수 없습니다 ({{url}})",
"failedToSaveExport": "내보낸 파일 저장에 실패했습니다",
"failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
"failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}"
+1
View File
@@ -8,6 +8,7 @@
"failedToSaveVideo": "Video kaydedilemedi",
"exportFailed": "Dışa aktarım başarısız oldu",
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
"exportBackgroundLoadFailed": "Dışa aktarım başarısız: arka plan görüntüsü yüklenemedi ({{url}})",
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
+1
View File
@@ -14,6 +14,7 @@
"failedToSaveVideo": "保存视频失败",
"exportFailed": "导出失败",
"exportFailedWithError": "导出失败:{{error}}",
"exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})",
"failedToSaveExport": "保存导出文件失败",
"failedToSaveExportedVideo": "保存导出的视频失败",
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
+1
View File
@@ -14,6 +14,7 @@
"failedToSaveVideo": "儲存影片失敗",
"exportFailed": "匯出失敗",
"exportFailedWithError": "匯出失敗:{{error}}",
"exportBackgroundLoadFailed": "匯出失敗:無法載入背景圖片({{url}})",
"failedToSaveExport": "儲存匯出檔案失敗",
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
"failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}"
+34 -25
View File
@@ -1,9 +1,29 @@
export class UnsafeAssetPathError extends Error {
constructor(segment: string) {
super(`Unsafe asset path segment: ${segment}`);
this.name = "UnsafeAssetPathError";
}
}
export class AssetBaseUnavailableError extends Error {
constructor() {
super("electronAPI.assetBaseUrl is not available; preload did not load correctly");
this.name = "AssetBaseUnavailableError";
}
}
function encodeRelativeAssetPath(relativePath: string): string {
return relativePath
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.map((part) => {
const decoded = decodeURIComponent(part);
if (decoded === "." || decoded === "..") {
throw new UnsafeAssetPathError(decoded);
}
return encodeURIComponent(decoded);
})
.join("/");
}
@@ -11,33 +31,22 @@ function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
}
export async function getAssetPath(relativePath: string): Promise<string> {
const encodedRelativePath = encodeRelativeAssetPath(relativePath);
export function getAssetPath(relativePath: string): string {
const encoded = encodeRelativeAssetPath(relativePath);
try {
if (typeof window !== "undefined") {
// If running in a dev server (http/https), prefer the web-served path
if (
window.location &&
window.location.protocol &&
window.location.protocol.startsWith("http")
) {
return `/${encodedRelativePath}`;
}
if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") {
const base = await window.electronAPI.getAssetBasePath();
if (base) {
return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString();
}
}
}
} catch {
// ignore and use fallback
if (typeof window === "undefined") {
return `/${encoded}`;
}
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
return `/${encodedRelativePath}`;
if (window.location?.protocol?.startsWith("http")) {
return `/${encoded}`;
}
const base = window.electronAPI?.assetBaseUrl;
if (!base) {
throw new AssetBaseUnavailableError();
}
return new URL(encoded, ensureTrailingSlash(base)).toString();
}
export default getAssetPath;
+75 -104
View File
@@ -45,6 +45,7 @@ import {
type Size,
type StyledRenderRect,
} from "@/lib/compositeLayout";
import { BackgroundLoadError, classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
import { renderAnnotations } from "./annotationRenderer";
import {
@@ -231,123 +232,93 @@ export class FrameRenderer {
private async setupBackground(): Promise<void> {
const wallpaper = this.config.wallpaper;
// Create background canvas for separate rendering (not affected by zoom)
const bgCanvas = document.createElement("canvas");
bgCanvas.width = this.config.width;
bgCanvas.height = this.config.height;
const bgCtx = bgCanvas.getContext("2d")!;
try {
// Render background based on type
if (
wallpaper.startsWith("file://") ||
wallpaper.startsWith("data:") ||
wallpaper.startsWith("/") ||
wallpaper.startsWith("http")
) {
// Image background
const img = new Image();
// Don't set crossOrigin for same-origin images to avoid CORS taint
// Only set it for cross-origin URLs
let imageUrl: string;
if (wallpaper.startsWith("http")) {
imageUrl = wallpaper;
if (!imageUrl.startsWith(window.location.origin)) {
img.crossOrigin = "anonymous";
}
} else if (wallpaper.startsWith("file://") || wallpaper.startsWith("data:")) {
imageUrl = wallpaper;
} else {
imageUrl = window.location.origin + wallpaper;
}
const classified = classifyWallpaper(wallpaper);
if (classified.kind === "color") {
bgCtx.fillStyle = classified.value;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else if (classified.kind === "gradient") {
const parsedGradient = parseCssGradient(classified.value);
if (!parsedGradient) {
throw new BackgroundLoadError(classified.value);
}
const gradient =
parsedGradient.type === "linear"
? (() => {
const points = getLinearGradientPoints(
resolveLinearGradientAngle(parsedGradient.descriptor),
this.config.width,
this.config.height,
);
return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1);
})()
: (() => {
const shape = getRadialGradientShape(
parsedGradient.descriptor,
this.config.width,
this.config.height,
);
return bgCtx.createRadialGradient(
shape.cx,
shape.cy,
0,
shape.cx,
shape.cy,
shape.radius,
);
})();
parsedGradient.stops.forEach((stop) => {
gradient.addColorStop(stop.offset, stop.color);
});
bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else {
const imageUrl = resolveImageWallpaperUrl(classified.path);
const img = new Image();
if (imageUrl.startsWith("http") && !imageUrl.startsWith(window.location.origin)) {
img.crossOrigin = "anonymous";
}
try {
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (err) => {
console.error("[FrameRenderer] Failed to load background image:", imageUrl, err);
reject(new Error(`Failed to load background image: ${imageUrl}`));
};
img.onerror = (err) => reject(err);
img.src = imageUrl;
});
// Draw the image using cover and center positioning
const imgAspect = img.width / img.height;
const canvasAspect = this.config.width / this.config.height;
let drawWidth, drawHeight, drawX, drawY;
if (imgAspect > canvasAspect) {
drawHeight = this.config.height;
drawWidth = drawHeight * imgAspect;
drawX = (this.config.width - drawWidth) / 2;
drawY = 0;
} else {
drawWidth = this.config.width;
drawHeight = drawWidth / imgAspect;
drawX = 0;
drawY = (this.config.height - drawHeight) / 2;
}
bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
} else if (wallpaper.startsWith("#")) {
bgCtx.fillStyle = wallpaper;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else if (
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
const parsedGradient = parseCssGradient(wallpaper);
if (parsedGradient) {
const gradient =
parsedGradient.type === "linear"
? (() => {
const points = getLinearGradientPoints(
resolveLinearGradientAngle(parsedGradient.descriptor),
this.config.width,
this.config.height,
);
return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1);
})()
: (() => {
const shape = getRadialGradientShape(
parsedGradient.descriptor,
this.config.width,
this.config.height,
);
return bgCtx.createRadialGradient(
shape.cx,
shape.cy,
0,
shape.cx,
shape.cy,
shape.radius,
);
})();
parsedGradient.stops.forEach((stop) => {
gradient.addColorStop(stop.offset, stop.color);
});
bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else {
console.warn("[FrameRenderer] Could not parse gradient, using black fallback");
bgCtx.fillStyle = "#000000";
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
}
} else {
bgCtx.fillStyle = wallpaper;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} catch (err) {
throw new BackgroundLoadError(imageUrl, err);
}
} catch (error) {
console.error("[FrameRenderer] Error setting up background, using fallback:", error);
bgCtx.fillStyle = "#000000";
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
const imgAspect = img.width / img.height;
const canvasAspect = this.config.width / this.config.height;
let drawWidth: number;
let drawHeight: number;
let drawX: number;
let drawY: number;
if (imgAspect > canvasAspect) {
drawHeight = this.config.height;
drawWidth = drawHeight * imgAspect;
drawX = (this.config.width - drawWidth) / 2;
drawY = 0;
} else {
drawWidth = this.config.width;
drawHeight = drawWidth / imgAspect;
drawX = 0;
drawY = (this.config.height - drawHeight) / 2;
}
bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
}
// Store the background canvas for compositing
this.backgroundSprite = bgCanvas;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { BackgroundLoadError } from "../wallpaper";
import { GifExporter } from "./gifExporter";
import type { ExportProgress } from "./types";
@@ -40,4 +41,48 @@ describe("GifExporter (real browser)", () => {
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
it("exports successfully with an image wallpaper (served by Vite dev server)", async () => {
const exporter = new GifExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
loop: true,
sizePreset: "medium",
wallpaper: "/wallpapers/wallpaper1.jpg",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob!.size).toBeGreaterThan(1024);
});
it("throws BackgroundLoadError when wallpaper fails to load (no silent black fallback)", async () => {
const exporter = new GifExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
loop: true,
sizePreset: "medium",
wallpaper: "/wallpapers/does-not-exist.jpg",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const rejection = exporter.export();
await expect(rejection).rejects.toBeInstanceOf(BackgroundLoadError);
await expect(rejection).rejects.toMatchObject({
url: expect.stringContaining("does-not-exist"),
});
});
});
+4
View File
@@ -8,6 +8,7 @@ import type {
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { BackgroundLoadError } from "@/lib/wallpaper";
import { getPlatform } from "@/utils/platformUtils";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
import { FrameRenderer } from "./frameRenderer";
@@ -326,6 +327,9 @@ export class GifExporter {
return { success: true, blob };
} catch (error) {
if (error instanceof BackgroundLoadError) {
throw error;
}
console.error("GIF Export error:", error);
return {
success: false,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { BackgroundLoadError } from "../wallpaper";
import type { ExportProgress } from "./types";
import { VideoExporter } from "./videoExporter";
@@ -40,4 +41,46 @@ describe("VideoExporter (real browser)", () => {
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
it("exports successfully with an image wallpaper (served by Vite dev server)", async () => {
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "/wallpapers/wallpaper1.jpg",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob!.size).toBeGreaterThan(1024);
});
it("throws BackgroundLoadError when wallpaper fails to load (no silent black fallback)", async () => {
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "/wallpapers/does-not-exist.jpg",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const rejection = exporter.export();
await expect(rejection).rejects.toBeInstanceOf(BackgroundLoadError);
await expect(rejection).rejects.toMatchObject({
url: expect.stringContaining("does-not-exist"),
});
});
});
+5
View File
@@ -7,6 +7,7 @@ import type {
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { BackgroundLoadError } from "@/lib/wallpaper";
import { getPlatform } from "@/utils/platformUtils";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
import { AudioProcessor } from "./audioEncoder";
@@ -82,6 +83,10 @@ export class VideoExporter {
return { success: false, error: "Export cancelled" };
}
if (normalizedError instanceof BackgroundLoadError) {
throw normalizedError;
}
if (encoderPreferences.length > 1) {
console.warn(
`[VideoExporter] ${encoderPreference} export attempt failed:`,
+276
View File
@@ -0,0 +1,276 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AssetBaseUnavailableError, UnsafeAssetPathError } from "./assetPath";
import {
BackgroundLoadError,
classifyWallpaper,
DEFAULT_WALLPAPER,
resolveImageWallpaperUrl,
UnsafeImagePrefixError,
WALLPAPER_COUNT,
WALLPAPER_PATHS,
} from "./wallpaper";
describe("WALLPAPER_PATHS", () => {
it("contains WALLPAPER_COUNT entries", () => {
expect(WALLPAPER_PATHS).toHaveLength(WALLPAPER_COUNT);
});
it("DEFAULT_WALLPAPER is WALLPAPER_PATHS[0]", () => {
expect(DEFAULT_WALLPAPER).toBe(WALLPAPER_PATHS[0]);
});
});
describe("classifyWallpaper", () => {
it("hex color", () => {
expect(classifyWallpaper("#1a1a2e")).toEqual({ kind: "color", value: "#1a1a2e" });
});
it("rgb() color", () => {
expect(classifyWallpaper("rgb(1, 2, 3)")).toEqual({ kind: "color", value: "rgb(1, 2, 3)" });
});
it("rgba() color", () => {
expect(classifyWallpaper("rgba(1, 2, 3, 0.5)")).toEqual({
kind: "color",
value: "rgba(1, 2, 3, 0.5)",
});
});
it("hsl() color", () => {
expect(classifyWallpaper("hsl(180, 50%, 50%)")).toEqual({
kind: "color",
value: "hsl(180, 50%, 50%)",
});
});
it("oklch() color", () => {
expect(classifyWallpaper("oklch(50% 0.1 180)")).toEqual({
kind: "color",
value: "oklch(50% 0.1 180)",
});
});
it("linear gradient", () => {
const v = "linear-gradient(90deg, red, blue)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("radial gradient", () => {
const v = "radial-gradient(circle, red, blue)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("conic gradient", () => {
const v = "conic-gradient(red, blue)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("repeating-linear gradient", () => {
const v = "repeating-linear-gradient(45deg, red 0 10px, blue 10px 20px)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("repeating-radial gradient", () => {
const v = "repeating-radial-gradient(circle, red, blue 20px)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("leading-slash image path", () => {
expect(classifyWallpaper("/wallpapers/wallpaper1.jpg")).toEqual({
kind: "image",
path: "/wallpapers/wallpaper1.jpg",
});
});
it("http URL as image", () => {
expect(classifyWallpaper("https://example.com/bg.jpg")).toEqual({
kind: "image",
path: "https://example.com/bg.jpg",
});
});
it("file:// URL as image", () => {
expect(classifyWallpaper("file:///tmp/bg.jpg")).toEqual({
kind: "image",
path: "file:///tmp/bg.jpg",
});
});
it("data URI as image", () => {
expect(classifyWallpaper("data:image/png;base64,AAA")).toEqual({
kind: "image",
path: "data:image/png;base64,AAA",
});
});
it("named color falls back to color", () => {
expect(classifyWallpaper("red")).toEqual({ kind: "color", value: "red" });
});
it("empty string falls back to black", () => {
expect(classifyWallpaper("")).toEqual({ kind: "color", value: "#000000" });
});
it("trims whitespace", () => {
expect(classifyWallpaper(" #abcdef ")).toEqual({ kind: "color", value: "#abcdef" });
});
it("DEFAULT_WALLPAPER classifies as image", () => {
expect(classifyWallpaper(DEFAULT_WALLPAPER)).toEqual({
kind: "image",
path: DEFAULT_WALLPAPER,
});
});
});
describe("resolveImageWallpaperUrl", () => {
beforeEach(() => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "http:" },
electronAPI: undefined,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("passes through http URL", () => {
expect(resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe("http://example.com/bg.jpg");
});
it("passes through https URL", () => {
expect(resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe(
"https://example.com/bg.jpg",
);
});
it("passes through file:// URL", () => {
expect(resolveImageWallpaperUrl("file:///tmp/bg.jpg")).toBe("file:///tmp/bg.jpg");
});
it("passes through data URI", () => {
const uri = "data:image/png;base64,AAAA";
expect(resolveImageWallpaperUrl(uri)).toBe(uri);
});
it("resolves leading-slash wallpaper path via http fallback", () => {
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
it("resolves bare relative wallpaper path", () => {
expect(resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
it("encodes special characters in path segments", () => {
expect(resolveImageWallpaperUrl("/wallpapers/my image.jpg")).toBe("/wallpapers/my%20image.jpg");
});
it("rejects image paths outside /wallpapers/ with UnsafeImagePrefixError as cause", () => {
try {
resolveImageWallpaperUrl("/etc/passwd");
expect.fail("should have thrown");
} catch (err) {
if (!(err instanceof BackgroundLoadError)) throw err;
expect(err.cause).toBeInstanceOf(UnsafeImagePrefixError);
}
});
it("wraps traversal attempts in BackgroundLoadError (preserves UnsafeAssetPathError as cause)", () => {
try {
resolveImageWallpaperUrl("/wallpapers/../etc/passwd");
expect.fail("should have thrown");
} catch (err) {
if (!(err instanceof BackgroundLoadError)) throw err;
expect(err.cause).toBeInstanceOf(UnsafeAssetPathError);
}
});
it("wraps percent-encoded traversal in BackgroundLoadError", () => {
try {
resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar");
expect.fail("should have thrown");
} catch (err) {
if (!(err instanceof BackgroundLoadError)) throw err;
expect(err.cause).toBeInstanceOf(UnsafeAssetPathError);
}
});
it("resolves via electronAPI.assetBaseUrl when not http", () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: { assetBaseUrl: "file:///opt/app/public/" },
});
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"file:///opt/app/public/wallpapers/wallpaper1.jpg",
);
});
it("appends trailing slash to assetBaseUrl if missing", () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: { assetBaseUrl: "file:///opt/app/public" },
});
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"file:///opt/app/public/wallpapers/wallpaper1.jpg",
);
});
it("wraps AssetBaseUnavailableError in BackgroundLoadError when assetBaseUrl is empty", () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: { assetBaseUrl: "" },
});
try {
resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg");
expect.fail("should have thrown");
} catch (err) {
if (!(err instanceof BackgroundLoadError)) throw err;
expect(err.cause).toBeInstanceOf(AssetBaseUnavailableError);
}
});
});
describe("BackgroundLoadError", () => {
it("carries the failing URL and is instanceof Error", () => {
const err = new BackgroundLoadError("/home/user/secret/wallpaper.jpg");
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(BackgroundLoadError);
expect(err.url).toBe("/home/user/secret/wallpaper.jpg");
expect(err.name).toBe("BackgroundLoadError");
});
it("displayUrl hides parent directories to avoid leaking PII", () => {
const err = new BackgroundLoadError("file:///home/enrique/projects/openscreen/wallpaper1.jpg");
expect(err.displayUrl).toBe("wallpaper1.jpg");
});
it("displayUrl abbreviates data URIs", () => {
const err = new BackgroundLoadError("data:image/png;base64,AAA");
expect(err.displayUrl).toBe("data:…");
});
it("displayUrl returns sentinel for empty-basename URLs", () => {
const err = new BackgroundLoadError("file:///");
expect(err.displayUrl).toBe("(unknown)");
});
it("displayUrl returns sentinel for unparseable bare slash", () => {
const err = new BackgroundLoadError("/");
expect(err.displayUrl).toBe("(unknown)");
});
it("preserves cause when provided", () => {
const cause = new Error("inner");
const err = new BackgroundLoadError("file:///missing.jpg", cause);
expect(err.cause).toBe(cause);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { getAssetPath } from "@/lib/assetPath";
export const WALLPAPER_COUNT = 18;
export const WALLPAPER_PATHS: readonly string[] = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
export const DEFAULT_WALLPAPER = WALLPAPER_PATHS[0];
export type WallpaperClassification =
| { kind: "color"; value: string }
| { kind: "gradient"; value: string }
| { kind: "image"; path: string };
const GRADIENT_RE = /^(repeating-)?(linear|radial|conic)-gradient\(/;
const COLOR_FUNC_RE = /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/;
const IMAGE_URL_RE = /^(\/|https?:\/\/|file:\/\/|data:)/;
export function classifyWallpaper(value: string): WallpaperClassification {
const trimmed = value.trim();
if (trimmed === "") {
return { kind: "color", value: "#000000" };
}
if (trimmed.startsWith("#") || COLOR_FUNC_RE.test(trimmed)) {
return { kind: "color", value: trimmed };
}
if (GRADIENT_RE.test(trimmed)) {
return { kind: "gradient", value: trimmed };
}
if (IMAGE_URL_RE.test(trimmed)) {
return { kind: "image", path: trimmed };
}
return { kind: "color", value: trimmed };
}
const ALLOWED_IMAGE_PREFIX = "/wallpapers/";
export class UnsafeImagePrefixError extends Error {
constructor(prefix: string) {
super(`Image wallpaper path must live under ${prefix}`);
this.name = "UnsafeImagePrefixError";
}
}
export function resolveImageWallpaperUrl(imagePath: string): string {
if (
imagePath.startsWith("http://") ||
imagePath.startsWith("https://") ||
imagePath.startsWith("file://") ||
imagePath.startsWith("data:")
) {
return imagePath;
}
const withLeadingSlash = imagePath.startsWith("/") ? imagePath : `/${imagePath}`;
if (!withLeadingSlash.startsWith(ALLOWED_IMAGE_PREFIX)) {
throw new BackgroundLoadError(imagePath, new UnsafeImagePrefixError(ALLOWED_IMAGE_PREFIX));
}
try {
return getAssetPath(withLeadingSlash.slice(1));
} catch (cause) {
if (cause instanceof BackgroundLoadError) throw cause;
throw new BackgroundLoadError(imagePath, cause);
}
}
export class BackgroundLoadError extends Error {
readonly url: string;
readonly cause?: unknown;
constructor(url: string, cause?: unknown) {
super(`Failed to load background image: ${displayBasename(url)}`);
this.name = "BackgroundLoadError";
this.url = url;
this.cause = cause;
}
get displayUrl(): string {
return displayBasename(this.url);
}
}
function displayBasename(url: string): string {
if (url.startsWith("data:")) {
return "data:…";
}
try {
const parsed = new URL(url);
const last = parsed.pathname.split("/").filter(Boolean).pop();
return last ? decodeURIComponent(last) : "(unknown)";
} catch {
const last = url.split("/").filter(Boolean).pop();
return last ?? "(unknown)";
}
}
-124
View File
@@ -1,126 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />
interface ProcessedDesktopSource {
id: string;
name: string;
display_id: string;
thumbnail: string | null;
appIcon: string | null;
}
interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{
success: boolean;
path?: string;
session?: import("./lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
storeRecordedSession: (
payload: import("./lib/recordingSession").StoreRecordedSessionInput,
) => Promise<{
success: boolean;
path?: string;
session?: import("./lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
getRecordedVideoPath: () => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
session: import("./lib/recordingSession").RecordingSession | null,
) => Promise<{
success: boolean;
session?: import("./lib/recordingSession").RecordingSession;
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
getCurrentRecordingSession: () => Promise<{
success: boolean;
session?: import("./lib/recordingSession").RecordingSession;
}>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadCurrentProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
}