fix: custom video background

This commit is contained in:
David Ralph
2026-01-28 16:27:24 +00:00
parent a4e575c5f6
commit 6465b88f30
4 changed files with 135 additions and 24 deletions

View File

@@ -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 };

View File

@@ -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')
);
}

View File

@@ -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(() => {
)}
</span>
</span>
<span className="selection-separator">·</span>
{customBackground.length > 0 && <span className="selection-separator">·</span>}
{selectedImages.size > 0 ? (
<>
<span className="selected-count">{selectedImages.size} selected</span>
@@ -757,9 +779,39 @@ const CustomSettings = memo(() => {
) : null;
})()}
{isVideo ? (
<div className="video-icon-wrapper">
<MdPersonalVideo className="customvideoicon" />
</div>
bg.thumbnail ? (
<>
<img
alt={bg.name || 'Custom video'}
src={bg.thumbnail}
loading="lazy"
style={{ position: 'relative', zIndex: 1 }}
/>
<div
className="video-overlay-icon"
style={{
position: 'absolute',
top: '8px',
right: '8px',
zIndex: 2,
background: 'rgba(0, 0, 0, 0.6)',
borderRadius: '4px',
padding: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<MdPersonalVideo
style={{ fontSize: '20px', color: 'white' }}
/>
</div>
</>
) : (
<div className="video-icon-wrapper">
<MdPersonalVideo className="customvideoicon" />
</div>
)
) : (
<img
alt={bg.name || 'Custom background'}

View File

@@ -158,3 +158,56 @@ export function formatBytes(bytes) {
return Math.round((bytes / Math.pow(k, i)) * 10) / 10 + ' ' + sizes[i];
}
/**
* Extract the first frame of a video as a thumbnail
* @param {string} videoDataUrl - Video data URL
* @param {number} maxWidth - Maximum width for thumbnail (default: 320)
* @returns {Promise<{thumbnail: string, dimensions: {width: number, height: number}}>}
*/
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;
});
}