Merge pull request #496 from Enriquefft/fix/wallpaper-export-376
Fix wallpaper backgrounds exporting as black (#376)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -37,7 +37,7 @@ interface Window {
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getAssetBasePath: () => Promise<string | null>;
|
||||
assetBaseUrl: string;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"failedToSaveVideo": "비디오 저장에 실패했습니다",
|
||||
"exportFailed": "내보내기에 실패했습니다",
|
||||
"exportFailedWithError": "내보내기 실패: {{error}}",
|
||||
"exportBackgroundLoadFailed": "내보내기 실패: 배경 이미지를 불러올 수 없습니다 ({{url}})",
|
||||
"failedToSaveExport": "내보낸 파일 저장에 실패했습니다",
|
||||
"failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
|
||||
"failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"failedToSaveVideo": "保存视频失败",
|
||||
"exportFailed": "导出失败",
|
||||
"exportFailedWithError": "导出失败:{{error}}",
|
||||
"exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})",
|
||||
"failedToSaveExport": "保存导出文件失败",
|
||||
"failedToSaveExportedVideo": "保存导出的视频失败",
|
||||
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"failedToSaveVideo": "儲存影片失敗",
|
||||
"exportFailed": "匯出失敗",
|
||||
"exportFailedWithError": "匯出失敗:{{error}}",
|
||||
"exportBackgroundLoadFailed": "匯出失敗:無法載入背景圖片({{url}})",
|
||||
"failedToSaveExport": "儲存匯出檔案失敗",
|
||||
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
|
||||
"failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}"
|
||||
|
||||
+34
-25
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
Vendored
-124
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user