From 6465b88f305aa84c3a26eb601a1eaf027b4cda72 Mon Sep 17 00:00:00 2001 From: David Ralph Date: Wed, 28 Jan 2026 16:27:24 +0000 Subject: [PATCH] fix: custom video background --- .../Elements/ShareModal/ShareModal.jsx | 2 +- src/features/background/api/videoCheck.js | 22 +++-- src/features/background/options/Custom.jsx | 82 +++++++++++++++---- src/utils/imageMetadata.js | 53 ++++++++++++ 4 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/components/Elements/ShareModal/ShareModal.jsx b/src/components/Elements/ShareModal/ShareModal.jsx index 2491e82b..8a673585 100644 --- a/src/components/Elements/ShareModal/ShareModal.jsx +++ b/src/components/Elements/ShareModal/ShareModal.jsx @@ -127,4 +127,4 @@ function ShareModal({ modalClose, data }) { const MemoizedSharemodal = memo(ShareModal); -export { MemoizedSharemodal as default, MemoizedSharemodal as ShareModal }; +export { MemoizedSharemodal as default, MemoizedSharemodal as ShareModal }; \ No newline at end of file diff --git a/src/features/background/api/videoCheck.js b/src/features/background/api/videoCheck.js index 8b6d44fa..8510b419 100644 --- a/src/features/background/api/videoCheck.js +++ b/src/features/background/api/videoCheck.js @@ -1,13 +1,19 @@ /** - * If the URL starts with `data:video/` or ends with `.mp4`, `.webm`, or `.ogg`, then it's a video. - * @param url - The URL of the file to be checked. - * @returns A function that takes a url and returns a boolean. + * Checks if the given URL or MIME type represents a video file. + * Supports both URLs (data:video/, .mp4, .webm, .ogg) and MIME types (video/mp4, video/webm, video/ogg). + * @param urlOrMimeType - The URL or MIME type to check. + * @returns true if it's a video, false otherwise. */ -export default function videoCheck(url) { +export default function videoCheck(urlOrMimeType) { + if (!urlOrMimeType) { + return false; + } + return ( - url.startsWith('data:video/') || - url.endsWith('.mp4') || - url.endsWith('.webm') || - url.endsWith('.ogg') + urlOrMimeType.startsWith('data:video/') || + urlOrMimeType.startsWith('video/') || + urlOrMimeType.endsWith('.mp4') || + urlOrMimeType.endsWith('.webm') || + urlOrMimeType.endsWith('.ogg') ); } diff --git a/src/features/background/options/Custom.jsx b/src/features/background/options/Custom.jsx index 200f99dc..f5f9201a 100644 --- a/src/features/background/options/Custom.jsx +++ b/src/features/background/options/Custom.jsx @@ -33,6 +33,7 @@ import { calculateStorageSize, calculateTotalStorageSize, formatBytes, + extractVideoThumbnail, } from 'utils/imageMetadata'; import { generateBlurHashDataUrl } from '../api/blurHash'; @@ -132,6 +133,7 @@ const CustomSettings = memo(() => { fileSize: metadata.fileSize, folder: metadata.folder || '', blurHash: metadata.blurHash, + thumbnail: metadata.thumbnail || null, }; await addBackground(backgroundData); @@ -189,17 +191,37 @@ const CustomSettings = memo(() => { const reader = new FileReader(); return new Promise((resolve, reject) => { - reader.onloadend = () => { - resolve({ - dataUrl: reader.result, - metadata: { - name: getFileName(file, customBackground.length), - dimensions: null, - fileSize: file.size, - folder: folderName, - blurHash: null, - }, - }); + reader.onloadend = async () => { + try { + // Extract thumbnail and dimensions from video + const { thumbnail, dimensions } = await extractVideoThumbnail(reader.result); + + resolve({ + dataUrl: reader.result, + metadata: { + name: getFileName(file, customBackground.length), + dimensions, + fileSize: file.size, + folder: folderName, + blurHash: null, + thumbnail, + }, + }); + } catch (error) { + console.warn('Could not extract video thumbnail:', error); + // Fallback to no thumbnail if extraction fails + resolve({ + dataUrl: reader.result, + metadata: { + name: getFileName(file, customBackground.length), + dimensions: null, + fileSize: file.size, + folder: folderName, + blurHash: null, + thumbnail: null, + }, + }); + } }; reader.onerror = reject; reader.readAsDataURL(file); @@ -625,7 +647,7 @@ const CustomSettings = memo(() => { )} - · + {customBackground.length > 0 && ·} {selectedImages.size > 0 ? ( <> {selectedImages.size} selected @@ -757,9 +779,39 @@ const CustomSettings = memo(() => { ) : null; })()} {isVideo ? ( -
- -
+ bg.thumbnail ? ( + <> + {bg.name +
+ +
+ + ) : ( +
+ +
+ ) ) : ( {bg.name} + */ +export async function extractVideoThumbnail(videoDataUrl, maxWidth = 320) { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.muted = true; + video.playsInline = true; + + video.onloadedmetadata = () => { + // Seek to 0.1 seconds to get a better frame than the first black frame + video.currentTime = 0.1; + }; + + video.onseeked = () => { + try { + // Calculate thumbnail dimensions maintaining aspect ratio + const scale = maxWidth / video.videoWidth; + const thumbnailWidth = Math.floor(video.videoWidth * scale); + const thumbnailHeight = Math.floor(video.videoHeight * scale); + + // Create canvas and draw the video frame + const canvas = document.createElement('canvas'); + canvas.width = thumbnailWidth; + canvas.height = thumbnailHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0, thumbnailWidth, thumbnailHeight); + + // Convert to data URL (JPEG for better compression) + const thumbnail = canvas.toDataURL('image/jpeg', 0.8); + + resolve({ + thumbnail, + dimensions: { + width: video.videoWidth, + height: video.videoHeight, + }, + }); + } catch (error) { + reject(new Error('Failed to extract video thumbnail: ' + error.message)); + } + }; + + video.onerror = () => reject(new Error('Failed to load video')); + + video.src = videoDataUrl; + }); +}