feat(editor): add video wallpaper background support

Adds video file detection (mp4/webm/mov/etc) to wallpaper system,
renders video backgrounds with looping playback in editor preview,
and adds Video tab to background picker in settings panel.
This commit is contained in:
webadderall
2026-04-02 16:49:56 +11:00
parent b08f4efb37
commit 8d262fbc10
3 changed files with 129 additions and 31 deletions
+51 -16
View File
@@ -1,4 +1,4 @@
import { Palette, Trash2, Upload, X } from "lucide-react";
import { Film, 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";
@@ -15,7 +15,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { cn } from "@/lib/utils";
import type { BuiltInWallpaper } from "@/lib/wallpapers";
import { BUILT_IN_WALLPAPERS, getAvailableWallpapers } from "@/lib/wallpapers";
import { BUILT_IN_WALLPAPERS, getAvailableWallpapers, isVideoWallpaperSource } from "@/lib/wallpapers";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import minimalCursorUrl from "../../../Minimal Cursor.svg";
import amongusCursorUrl from "../../assets/cursors/amongus/default.png";
@@ -105,7 +105,7 @@ const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: str
{ value: "pop", label: "Pop" },
];
type BackgroundTab = "image" | "color" | "gradient";
type BackgroundTab = "image" | "video" | "color" | "gradient";
export type EditorEffectSection =
| "scene"
| "cursor"
@@ -128,6 +128,10 @@ function getBackgroundTabForWallpaper(value: string): BackgroundTab {
return "color";
}
if (isVideoWallpaperSource(value)) {
return "video";
}
return "image";
}
@@ -713,7 +717,18 @@ export function SettingsPanel({
if (selected.startsWith("data:image") && !customImages.includes(selected)) {
setCustomImages((prev) => [selected, ...prev]);
}
}, [customImages, selected]);
const isBuiltInWallpaper =
builtInWallpaperPaths.includes(selected) || wallpaperPreviewPaths.includes(selected);
if (
!isBuiltInWallpaper &&
isVideoWallpaperSource(selected) &&
!customImages.includes(selected)
) {
setCustomImages((prev) => [selected, ...prev]);
}
}, [builtInWallpaperPaths, customImages, selected, wallpaperPreviewPaths]);
useEffect(() => {
saveEditorPreferences({ customWallpapers: customImages });
@@ -767,7 +782,7 @@ export function SettingsPanel({
);
const renderWallpaperImageTile = (
imageUrl: string,
wallpaperUrl: string,
isSelected: boolean,
props?: {
key?: string;
@@ -786,16 +801,25 @@ export function SettingsPanel({
role="button"
>
<div className="absolute inset-[1px] overflow-hidden rounded-[8px] bg-[#0d0e11]">
<img
src={imageUrl}
alt={
props?.title ??
props?.ariaLabel ??
tSettings("background.wallpaperPreview", "Wallpaper preview")
}
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
/>
{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>
) : (
<img
src={wallpaperUrl}
alt={
props?.title ??
props?.ariaLabel ??
tSettings("background.wallpaperPreview", "Wallpaper preview")
}
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
/>
)}
</div>
{props?.children}
</div>
@@ -1000,10 +1024,11 @@ export function SettingsPanel({
<div className="w-full">
<LayoutGroup id="background-picker-switcher">
<div className="grid h-8 w-full grid-cols-3 rounded-xl border border-white/10 bg-white/[0.04] p-1">
<div className="grid h-8 w-full grid-cols-4 rounded-xl border border-white/10 bg-white/[0.04] p-1">
{(
[
{ value: "image", label: tSettings("background.image") },
{ value: "video", label: tSettings("background.video", "Video") },
{ value: "color", label: tSettings("background.color") },
{ value: "gradient", label: tSettings("background.gradient") },
] as const
@@ -1069,6 +1094,12 @@ export function SettingsPanel({
const isSelected = getWallpaperTileState(imageUrl);
return renderWallpaperImageTile(imageUrl, isSelected, {
key: `custom-${idx}`,
ariaLabel: isVideoWallpaperSource(imageUrl)
? imageUrl.split(/[\\/]/).pop() ?? tSettings("background.video", "Video background")
: undefined,
title: isVideoWallpaperSource(imageUrl)
? imageUrl.split(/[\\/]/).pop()
: undefined,
onClick: () => onWallpaperChange(imageUrl),
children: (
<button
@@ -1098,6 +1129,10 @@ export function SettingsPanel({
})}
</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>
) : backgroundTab === "color" ? (
<div className="mt-0 space-y-2">
<input
+68 -15
View File
@@ -13,7 +13,9 @@ import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import {
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
isVideoWallpaperSource,
} from "@/lib/wallpapers";
import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource";
import {
Application,
Container,
@@ -1473,14 +1475,21 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(
null,
);
const [resolvedWallpaperKind, setResolvedWallpaperKind] = useState<
"image" | "video" | "style"
>("image");
useEffect(() => {
let mounted = true;
let revokeResolvedWallpaper = () => {};
(async () => {
try {
if (!wallpaper) {
const def = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH);
if (mounted) setResolvedWallpaper(def);
if (mounted) {
setResolvedWallpaper(def);
setResolvedWallpaperKind("image");
}
return;
}
@@ -1489,13 +1498,29 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
if (mounted) setResolvedWallpaper(wallpaper);
if (mounted) {
setResolvedWallpaper(wallpaper);
setResolvedWallpaperKind("style");
}
return;
}
if (isVideoWallpaperSource(wallpaper)) {
const resolved = await resolveMediaElementSource(wallpaper);
revokeResolvedWallpaper = resolved.revoke;
if (mounted) {
setResolvedWallpaper(resolved.src);
setResolvedWallpaperKind("video");
}
return;
}
// If it's a data URL (custom uploaded image), use as-is
if (wallpaper.startsWith("data:")) {
if (mounted) setResolvedWallpaper(wallpaper);
if (mounted) {
setResolvedWallpaper(wallpaper);
setResolvedWallpaperKind("image");
}
return;
}
@@ -1505,20 +1530,31 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
wallpaper.startsWith("/")
) {
const renderable = await getRenderableAssetUrl(wallpaper);
if (mounted) setResolvedWallpaper(renderable);
if (mounted) {
setResolvedWallpaper(renderable);
setResolvedWallpaperKind("image");
}
return;
}
const p = await getRenderableAssetUrl(
await getAssetPath(wallpaper.replace(/^\//, "")),
);
if (mounted) setResolvedWallpaper(p);
if (mounted) {
setResolvedWallpaper(p);
setResolvedWallpaperKind("image");
}
} catch (err) {
if (mounted)
if (mounted) {
setResolvedWallpaper(wallpaper || DEFAULT_WALLPAPER_PATH);
setResolvedWallpaperKind(
isVideoWallpaperSource(wallpaper || "") ? "video" : "image",
);
}
}
})();
return () => {
mounted = false;
revokeResolvedWallpaper();
};
}, [wallpaper]);
@@ -1531,7 +1567,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
}, []);
const isImageUrl = Boolean(
const isImageUrl = resolvedWallpaperKind === "image" && Boolean(
resolvedWallpaper &&
(resolvedWallpaper.startsWith("file://") ||
resolvedWallpaper.startsWith("http") ||
@@ -1540,7 +1576,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
);
const backgroundStyle = isImageUrl
? { backgroundImage: `url(${resolvedWallpaper || ""})` }
: { background: resolvedWallpaper || "" };
: resolvedWallpaperKind === "video"
? {}
: { background: resolvedWallpaper || "" };
const nativeAspectRatio = (() => {
const locked = lockedVideoDimensionsRef.current;
@@ -1569,13 +1607,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}}
>
{/* Background layer */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
...backgroundStyle,
filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none",
}}
/>
{resolvedWallpaperKind === "video" && resolvedWallpaper ? (
<video
key={resolvedWallpaper}
className="absolute inset-0 h-full w-full object-cover"
src={resolvedWallpaper}
muted
loop
autoPlay
playsInline
style={{
filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none",
}}
/>
) : (
<div
className="absolute inset-0 bg-cover bg-center"
style={{
...backgroundStyle,
filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none",
}}
/>
)}
<div
ref={containerRef}
className="absolute inset-0"
+10
View File
@@ -6,6 +6,7 @@ export interface BuiltInWallpaper {
}
const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/i;
const VIDEO_FILE_PATTERN = /\.(avi|m4v|mkv|mov|mp4|webm)$/i;
export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
{ id: 'wallpaper-1', label: 'Wallpaper 1', relativePath: 'wallpapers/wallpaper1.jpg', publicPath: '/wallpapers/wallpaper1.jpg' },
@@ -38,6 +39,15 @@ export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => w
export const DEFAULT_WALLPAPER_PATH = '/wallpapers/wallpaper2.jpg';
export const DEFAULT_WALLPAPER_RELATIVE_PATH = 'wallpapers/wallpaper2.jpg';
export function isVideoWallpaperSource(value: string): boolean {
if (!value) {
return false;
}
const normalizedValue = value.split("?")[0]?.toLowerCase() ?? value.toLowerCase();
return VIDEO_FILE_PATTERN.test(normalizedValue);
}
function toWallpaperId(fileName: string) {
return fileName
.replace(/\.[^.]+$/, "")