diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d0b2d656..c5d28bcc 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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"; @@ -13,9 +13,14 @@ import { import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; +import { SCENE_FRAME_OPTIONS } from "@/lib/sceneFrames"; 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"; @@ -38,6 +43,7 @@ import type { CursorStyle, FigureData, PlaybackSpeed, + SceneFrameStyle, WebcamOverlaySettings, WebcamPositionPreset, ZoomDepth, @@ -53,6 +59,10 @@ import { DEFAULT_CURSOR_SMOOTHING, DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SWAY, + DEFAULT_SCENE_FRAME_OPACITY, + DEFAULT_SCENE_FRAME_STYLE, + DEFAULT_SCENE_FRAME_TEXT, + DEFAULT_SCENE_FRAME_THICKNESS, DEFAULT_WEBCAM_CORNER_RADIUS, DEFAULT_WEBCAM_MARGIN, DEFAULT_WEBCAM_POSITION_PRESET, @@ -105,7 +115,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" @@ -124,6 +134,10 @@ function getBackgroundTabForWallpaper(value: string): BackgroundTab { return "gradient"; } + if (isVideoWallpaperSource(value)) { + return "video"; + } + if (isHexWallpaper(value)) { return "color"; } @@ -190,6 +204,14 @@ interface SettingsPanelProps { onCursorClickBounceDurationChange?: (duration: number) => void; cursorSway?: number; onCursorSwayChange?: (amount: number) => void; + sceneFrameStyle?: SceneFrameStyle; + onSceneFrameStyleChange?: (style: SceneFrameStyle) => void; + sceneFrameText?: string; + onSceneFrameTextChange?: (text: string) => void; + sceneFrameOpacity?: number; + onSceneFrameOpacityChange?: (opacity: number) => void; + sceneFrameThickness?: number; + onSceneFrameThicknessChange?: (thickness: number) => void; borderRadius?: number; onBorderRadiusChange?: (radius: number) => void; webcam?: WebcamOverlaySettings; @@ -213,6 +235,7 @@ interface SettingsPanelProps { autoCaptionSettings?: AutoCaptionSettings; whisperExecutablePath?: string | null; whisperModelPath?: string | null; + hasDownloadedWhisperModel?: boolean; whisperModelDownloadStatus?: "idle" | "downloading" | "downloaded" | "error"; whisperModelDownloadProgress?: number; isGeneratingCaptions?: boolean; @@ -417,6 +440,64 @@ async function createInvertedPreview(url: string) { return canvas.toDataURL("image/png"); } +async function createVideoWallpaperPreview(url: string) { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + let settled = false; + + const cleanup = () => { + video.onloadeddata = null; + video.onerror = null; + video.pause(); + video.removeAttribute("src"); + video.load(); + }; + + const finish = (value: string) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + + const fail = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(`Failed to load video preview asset: ${url}`)); + }; + + const drawFrame = () => { + if (video.videoWidth <= 0 || video.videoHeight <= 0) { + fail(); + return; + } + + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + fail(); + return; + } + + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + finish(canvas.toDataURL("image/png")); + }; + + video.preload = "auto"; + video.muted = true; + video.playsInline = true; + video.crossOrigin = "anonymous"; + video.onloadeddata = () => drawFrame(); + video.onerror = () => fail(); + video.src = url; + video.load(); + }); +} + function CursorStylePreview({ style, previewUrls, @@ -527,6 +608,14 @@ export function SettingsPanel({ onCursorClickBounceDurationChange, cursorSway = DEFAULT_CURSOR_SWAY, onCursorSwayChange, + sceneFrameStyle = DEFAULT_SCENE_FRAME_STYLE, + onSceneFrameStyleChange, + sceneFrameText = DEFAULT_SCENE_FRAME_TEXT, + onSceneFrameTextChange, + sceneFrameOpacity = DEFAULT_SCENE_FRAME_OPACITY, + onSceneFrameOpacityChange, + sceneFrameThickness = DEFAULT_SCENE_FRAME_THICKNESS, + onSceneFrameThicknessChange, borderRadius = 12.5, onBorderRadiusChange, webcam, @@ -549,6 +638,7 @@ export function SettingsPanel({ autoCaptions = [], autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, whisperModelPath, + hasDownloadedWhisperModel = false, whisperModelDownloadStatus = "idle", whisperModelDownloadProgress = 0, isGeneratingCaptions = false, @@ -578,6 +668,7 @@ export function SettingsPanel({ padding: number; } | null>(null); const fileInputRef = useRef(null); + const videoFileInputRef = useRef(null); const builtInWallpaperPaths = useMemo( () => builtInWallpapers.map((wallpaper) => wallpaper.publicPath), [builtInWallpapers], @@ -592,6 +683,7 @@ export function SettingsPanel({ useEffect(() => { let mounted = true; + (async () => { try { const availableWallpapers = await getAvailableWallpapers(); @@ -644,6 +736,14 @@ export function SettingsPanel({ const [backgroundTab, setBackgroundTab] = useState(() => getBackgroundTabForWallpaper(selected), ); + const customImageWallpapers = useMemo( + () => customImages.filter((imageUrl) => !isVideoWallpaperSource(imageUrl)), + [customImages], + ); + const customVideoWallpapers = useMemo( + () => customImages.filter((imageUrl) => isVideoWallpaperSource(imageUrl)), + [customImages], + ); const customColorInputRef = useRef(null); const defaultWebcam = initialEditorPreferences.webcam; const [internalActiveEffectSection] = useState("scene"); @@ -651,6 +751,9 @@ export function SettingsPanel({ const [cursorPreviewUrls, setCursorPreviewUrls] = useState< Partial> >({}); + const [videoWallpaperPreviewUrls, setVideoWallpaperPreviewUrls] = useState< + Record + >({}); useEffect(() => { let cancelled = false; @@ -691,6 +794,41 @@ export function SettingsPanel({ }; }, []); + useEffect(() => { + let cancelled = false; + const videoSources = [ + ...wallpaperPreviewPaths.filter((path) => isVideoWallpaperSource(path)), + ...customVideoWallpapers, + ]; + + if (videoSources.length === 0) { + setVideoWallpaperPreviewUrls({}); + return; + } + + void (async () => { + const entries = await Promise.all( + videoSources.map(async (videoUrl) => { + try { + return [videoUrl, await createVideoWallpaperPreview(videoUrl)] as const; + } catch { + return null; + } + }), + ); + + if (!cancelled) { + setVideoWallpaperPreviewUrls( + Object.fromEntries(entries.filter(Boolean).map((entry) => entry!)), + ); + } + })(); + + return () => { + cancelled = true; + }; + }, [customVideoWallpapers, wallpaperPreviewPaths]); + useEffect(() => { setBackgroundTab(getBackgroundTabForWallpaper(selected)); @@ -702,10 +840,17 @@ export function SettingsPanel({ setGradient(selected); } - if (selected.startsWith("data:image") && !customImages.includes(selected)) { + const isBuiltInWallpaper = + builtInWallpaperPaths.includes(selected) || wallpaperPreviewPaths.includes(selected); + + if ( + !isBuiltInWallpaper && + (selected.startsWith("data:image") || isVideoWallpaperSource(selected)) && + !customImages.includes(selected) + ) { setCustomImages((prev) => [selected, ...prev]); } - }, [customImages, selected]); + }, [builtInWallpaperPaths, customImages, selected, wallpaperPreviewPaths]); useEffect(() => { saveEditorPreferences({ customWallpapers: customImages }); @@ -778,16 +923,38 @@ export function SettingsPanel({ role="button" >
- { + {isVideoWallpaperSource(imageUrl) ? ( + videoWallpaperPreviewUrls[imageUrl] ? ( + { + ) : ( +
+ + + {props?.title ?? props?.ariaLabel ?? tSettings("background.video", "Video background")} + +
+ ) + ) : ( + { + )}
{props?.children} @@ -871,6 +1038,10 @@ export function SettingsPanel({ const resetFrameSection = () => { onShadowChange?.(initialEditorPreferences.shadowIntensity); + onSceneFrameStyleChange?.(initialEditorPreferences.sceneFrameStyle); + onSceneFrameTextChange?.(initialEditorPreferences.sceneFrameText); + onSceneFrameOpacityChange?.(initialEditorPreferences.sceneFrameOpacity); + onSceneFrameThicknessChange?.(initialEditorPreferences.sceneFrameThickness); onBorderRadiusChange?.(initialEditorPreferences.borderRadius); onPaddingChange?.(initialEditorPreferences.padding); onAspectRatioChange?.(initialEditorPreferences.aspectRatio); @@ -946,6 +1117,25 @@ export function SettingsPanel({ event.target.value = ""; }; + const handleVideoUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0] as File & { path?: string }; + const isLikelyVideo = file.type.startsWith("video/") || isVideoWallpaperSource(file.name); + if (!isLikelyVideo) { + toast.error(tSettings("background.videoUploadError", "Choose a video file.")); + event.target.value = ""; + return; + } + + const source = file.path || URL.createObjectURL(file); + setCustomImages((prev) => [source, ...prev.filter((item) => item !== source)]); + onWallpaperChange(source); + toast.success(tSettings("background.videoUploadSuccess", "Video background added.")); + event.target.value = ""; + }; + const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { event.stopPropagation(); setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); @@ -988,10 +1178,11 @@ export function SettingsPanel({
-
+
{( [ { 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 @@ -1053,7 +1244,7 @@ export function SettingsPanel({
- {customImages.map((imageUrl, idx) => { + {customImageWallpapers.map((imageUrl, idx) => { const isSelected = getWallpaperTileState(imageUrl); return renderWallpaperImageTile(imageUrl, isSelected, { key: `custom-${idx}`, @@ -1086,6 +1277,52 @@ export function SettingsPanel({ })}
+ ) : backgroundTab === "video" ? ( +
+ + + + {customVideoWallpapers.length > 0 ? ( +
+ {customVideoWallpapers.map((videoUrl, idx) => { + const isSelected = getWallpaperTileState(videoUrl); + return renderWallpaperImageTile(videoUrl, isSelected, { + key: `video-${idx}`, + ariaLabel: + videoUrl.split(/[\\/]/).pop() ?? + tSettings("background.video", "Video background"), + title: videoUrl.split(/[\\/]/).pop(), + onClick: () => onWallpaperChange(videoUrl), + children: ( + + ), + }); + })} +
+ ) : ( +
+ {tSettings("background.videoEmpty", "Add a looping video background.")} +
+ )} +
) : backgroundTab === "color" ? (
+
+ + {tSettings("effects.frameStyle", "Frame Style")} + +
+ {SCENE_FRAME_OPTIONS.map((option) => { + const isSelected = sceneFrameStyle === option.value; + return ( + + ); + })} +
+
+ {sceneFrameStyle === "safari" ? ( +
+ + onSceneFrameTextChange?.(event.target.value)} + placeholder="recordly.app" + className="h-9 rounded-lg border border-white/10 bg-white/5 px-3 text-sm text-slate-100 outline-none transition-colors placeholder:text-slate-500 focus:border-[#2563EB]/60" + /> +
+ ) : null} + {sceneFrameStyle === "glass" ? ( + <> + onSceneFrameOpacityChange?.(value)} + formatValue={(value) => `${Math.round(value * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> + onSceneFrameThicknessChange?.(value)} + formatValue={(value) => `${Math.round(value)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + + ) : null} - ) : whisperModelPath ? ( + ) : hasDownloadedWhisperModel ? ( @@ -1660,8 +1963,6 @@ export function SettingsPanel({ 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; + } + + if (value.startsWith("blob:")) { + return true; + } + + const normalizedValue = value.split("?")[0]?.toLowerCase() ?? value.toLowerCase(); + return VIDEO_FILE_PATTERN.test(normalizedValue); +} + function toWallpaperId(fileName: string) { return fileName .replace(/\.[^.]+$/, "")