feat(wallpaper): video wallpaper system with wispysky default, upload, thumbnails, preview and export rendering

This commit is contained in:
webadderall
2026-04-02 21:09:16 +11:00
parent ab04f9143c
commit 7b1d16d969
5 changed files with 182 additions and 20 deletions
Binary file not shown.
+74 -14
View File
@@ -1,4 +1,4 @@
import { Film, Palette, Trash2, Upload, X } from "lucide-react";
import { Palette, Trash2, Upload, X } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -802,12 +802,16 @@ export function SettingsPanel({
>
<div className="absolute inset-[1px] overflow-hidden rounded-[8px] bg-[#0d0e11]">
{isVideoWallpaperSource(wallpaperUrl) ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 bg-[radial-gradient(circle_at_top,#1f3b68,transparent_58%),linear-gradient(135deg,#0f172a,#111827_48%,#1d4ed8)] px-1 text-center text-white">
<Film className="h-4 w-4 opacity-90" />
<span className="line-clamp-2 text-[8px] font-medium leading-tight text-white/80">
{props?.title ?? props?.ariaLabel ?? tSettings("background.video", "Video background")}
</span>
</div>
<video
src={wallpaperUrl}
muted
playsInline
preload="metadata"
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
onMouseEnter={(e) => e.currentTarget.play().catch(() => {})}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0; }}
/>
) : (
<img
src={wallpaperUrl}
@@ -982,6 +986,23 @@ export function SettingsPanel({
event.target.value = "";
};
const handleVideoUpload = async () => {
try {
const result = await (window as any).electronAPI.openVideoFilePicker();
if (!result?.success || !result.path) return;
const filePath = result.path as string;
if (!isVideoWallpaperSource(filePath)) {
toast.error("Unsupported format", { description: "Please select a video file (mp4, webm, mov, etc.)" });
return;
}
setCustomImages((prev) => [filePath, ...prev]);
onWallpaperChange(filePath);
toast.success("Video background added");
} catch {
toast.error("Failed to import video background");
}
};
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
event.stopPropagation();
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
@@ -1115,23 +1136,62 @@ export function SettingsPanel({
{(wallpaperPreviewPaths.length > 0
? wallpaperPreviewPaths
: builtInWallpaperPaths
).map((previewPath, index) => {
const wallpaper = builtInWallpapers[index] ?? BUILT_IN_WALLPAPERS[index];
).filter(p => !isVideoWallpaperSource(p)).map((previewPath, filteredIndex) => {
const imageWallpapers = builtInWallpapers.filter(w => !isVideoWallpaperSource(w.publicPath));
const wallpaper = imageWallpapers[filteredIndex];
const wallpaperValue =
wallpaper?.publicPath ?? builtInWallpaperPaths[index] ?? previewPath;
wallpaper?.publicPath ?? previewPath;
const isSelected = getWallpaperTileState(wallpaperValue, previewPath);
return renderWallpaperImageTile(previewPath, isSelected, {
key: wallpaperValue,
ariaLabel: wallpaper?.label ?? `Wallpaper ${index + 1}`,
title: wallpaper?.label ?? `Wallpaper ${index + 1}`,
ariaLabel: wallpaper?.label ?? `Wallpaper ${filteredIndex + 1}`,
title: wallpaper?.label ?? `Wallpaper ${filteredIndex + 1}`,
onClick: () => onWallpaperChange(wallpaperValue),
});
})}
</div>
</div>
) : backgroundTab === "video" ? (
<div className="mt-0 flex min-h-24 items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.03] text-[11px] text-slate-400">
Video backgrounds coming next.
<div className="mt-0 space-y-2">
<Button
onClick={handleVideoUpload}
variant="outline"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all h-7 text-[10px]"
>
<Upload className="w-3 h-3" />
{tSettings("background.uploadCustomVideo", "Upload Video")}
</Button>
<div className="grid grid-cols-8 gap-1.5">
{customImages.filter(isVideoWallpaperSource).map((videoUrl, idx) => {
const isSelected = getWallpaperTileState(videoUrl);
return renderWallpaperImageTile(videoUrl, isSelected, {
key: `custom-video-${idx}`,
ariaLabel: videoUrl.split(/[\\/]/).pop() ?? "Video background",
title: videoUrl.split(/[\\/]/).pop(),
onClick: () => onWallpaperChange(videoUrl),
children: (
<button
onClick={(e) => handleRemoveCustomImage(videoUrl, e)}
className="absolute top-0.5 right-0.5 w-3 h-3 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<X className="w-2 h-2 text-white" />
</button>
),
});
})}
{BUILT_IN_WALLPAPERS.filter(w => isVideoWallpaperSource(w.publicPath)).map((wallpaper) => {
const wallpaperValue = wallpaper.publicPath;
const isSelected = selected === wallpaperValue;
return renderWallpaperImageTile(wallpaperValue, isSelected, {
key: wallpaperValue,
ariaLabel: wallpaper.label,
title: wallpaper.label,
onClick: () => onWallpaperChange(wallpaperValue),
});
})}
</div>
</div>
) : backgroundTab === "color" ? (
<div className="mt-0 space-y-2">
+24 -5
View File
@@ -15,7 +15,6 @@ import {
DEFAULT_WALLPAPER_RELATIVE_PATH,
isVideoWallpaperSource,
} from "@/lib/wallpapers";
import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource";
import {
Application,
Container,
@@ -329,6 +328,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
const lastWebcamSyncTimeRef = useRef<number | null>(null);
const bgVideoRef = useRef<HTMLVideoElement | null>(null);
const zoomMotionBlurRef = useRef(zoomMotionBlur);
const connectZoomsRef = useRef(connectZooms);
const zoomInDurationMsRef = useRef(zoomInDurationMs);
@@ -753,8 +753,25 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
isPlayingRef.current = isPlaying;
const bgVideo = bgVideoRef.current;
if (bgVideo) {
if (isPlaying) {
bgVideo.play().catch(() => {});
} else {
bgVideo.pause();
}
}
}, [isPlaying]);
// Sync background video wallpaper with timeline scrubbing
useEffect(() => {
const bgVideo = bgVideoRef.current;
if (!bgVideo) return;
if (!isPlaying && bgVideo.duration && Number.isFinite(bgVideo.duration)) {
bgVideo.currentTime = currentTime % bgVideo.duration;
}
}, [currentTime, isPlaying]);
useEffect(() => {
trimRegionsRef.current = trimRegions;
}, [trimRegions]);
@@ -1506,10 +1523,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
if (isVideoWallpaperSource(wallpaper)) {
const resolved = await resolveMediaElementSource(wallpaper);
revokeResolvedWallpaper = resolved.revoke;
let videoSrc = wallpaper;
if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) {
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
}
if (mounted) {
setResolvedWallpaper(resolved.src);
setResolvedWallpaper(videoSrc);
setResolvedWallpaperKind("video");
}
return;
@@ -1610,11 +1629,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
{resolvedWallpaperKind === "video" && resolvedWallpaper ? (
<video
key={resolvedWallpaper}
ref={bgVideoRef}
className="absolute inset-0 h-full w-full object-cover"
src={resolvedWallpaper}
muted
loop
autoPlay
playsInline
style={{
filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none",
+82
View File
@@ -47,6 +47,7 @@ import { getWebcamOverlayPosition, getWebcamOverlaySizePx } from "@/components/v
import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle";
import { ForwardFrameSource } from "./forwardFrameSource";
import { resolveMediaElementSource } from "./localMediaSource";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
interface FrameRenderConfig {
width: number;
@@ -143,6 +144,7 @@ export class FrameRenderer {
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
private backgroundVideoElement: HTMLVideoElement | null = null;
private backgroundCtx: CanvasRenderingContext2D | null = null;
private cleanupBackgroundSource: (() => void) | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
@@ -301,7 +303,34 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for background canvas");
}
this.backgroundCtx = bgCtx;
try {
// Check for video wallpaper first
if (isVideoWallpaperSource(wallpaper)) {
let videoSrc = wallpaper;
if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) {
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
}
const video = document.createElement("video");
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "auto";
video.src = videoSrc;
await new Promise<void>((resolve, reject) => {
video.onloadeddata = () => resolve();
video.onerror = () => reject(new Error(`Failed to load video wallpaper: ${wallpaper}`));
});
this.backgroundVideoElement = video;
this.drawVideoFrameToBackground();
this.backgroundSprite = bgCanvas as any;
return;
}
// Render background based on type
if (
wallpaper.startsWith("file://") ||
@@ -427,6 +456,53 @@ export class FrameRenderer {
this.backgroundSprite = bgCanvas as any;
}
private drawVideoFrameToBackground(): void {
const video = this.backgroundVideoElement;
const ctx = this.backgroundCtx;
if (!video || !ctx) return;
const w = this.config.width;
const h = this.config.height;
const videoAspect = video.videoWidth / video.videoHeight;
const canvasAspect = w / h;
let drawWidth: number, drawHeight: number, drawX: number, drawY: number;
if (videoAspect > canvasAspect) {
drawHeight = h;
drawWidth = drawHeight * videoAspect;
drawX = (w - drawWidth) / 2;
drawY = 0;
} else {
drawWidth = w;
drawHeight = drawWidth / videoAspect;
drawX = 0;
drawY = (h - drawHeight) / 2;
}
ctx.clearRect(0, 0, w, h);
ctx.drawImage(video, drawX, drawY, drawWidth, drawHeight);
}
private async syncBackgroundVideo(timeSeconds: number): Promise<void> {
const video = this.backgroundVideoElement;
if (!video) return;
if (video.duration && Number.isFinite(video.duration)) {
const targetTime = timeSeconds % video.duration;
if (Math.abs(video.currentTime - targetTime) > 0.01) {
video.currentTime = targetTime;
await new Promise<void>((resolve) => {
const onSeeked = () => {
video.removeEventListener("seeked", onSeeked);
resolve();
};
video.addEventListener("seeked", onSeeked);
});
}
}
this.drawVideoFrameToBackground();
}
private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> {
if (
wallpaper.startsWith("file://") ||
@@ -731,6 +807,11 @@ export class FrameRenderer {
await this.syncWebcamFrame(targetTime);
}
// Sync video wallpaper frame
if (this.backgroundVideoElement) {
await this.syncBackgroundVideo(this.currentVideoTime);
}
// Create or update video sprite from VideoFrame
if (!this.videoSprite) {
const texture = Texture.from(videoFrame as any);
@@ -1306,6 +1387,7 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
this.backgroundCtx = null;
if (this.backgroundVideoElement) {
this.backgroundVideoElement.pause();
this.backgroundVideoElement.src = "";
+2 -1
View File
@@ -32,6 +32,7 @@ export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
{ id: 'levels', label: 'Levels', relativePath: 'wallpapers/levels.jpg', publicPath: '/wallpapers/levels.jpg' },
{ id: 'mountaintrees', label: 'Mountain Trees', relativePath: 'wallpapers/mountaintrees.jpg', publicPath: '/wallpapers/mountaintrees.jpg' },
{ id: 'luisdelrio', label: 'Luis Del Rio', relativePath: 'wallpapers/luisdelrio.jpg', publicPath: '/wallpapers/luisdelrio.jpg' },
{ id: 'wispysky', label: 'Wispy Sky', relativePath: 'wallpapers/wispysky.mp4', publicPath: '/wallpapers/wispysky.mp4' },
];
export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath);
@@ -99,7 +100,7 @@ export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
}
const discoveredFiles = sortWallpaperFiles(
result.files.filter((fileName) => IMAGE_FILE_PATTERN.test(fileName)),
result.files.filter((fileName) => IMAGE_FILE_PATTERN.test(fileName) || VIDEO_FILE_PATTERN.test(fileName)),
);
if (discoveredFiles.length === 0) {