Video Upload Auto-Thumbnail Extractor
How it works & how to replicate it on another Base44 account
This page documents exactly how the auto-thumbnail extraction was built. Click Download Word Doc above to get a .doc file you can open in Microsoft Word or Google Docs and share with your other Base44 account.
1. The Core Idea
canvas.toBlob() to turn that frame into a JPEG file. That JPEG is then uploaded to Base44 storage with the Core.UploadFile integration — exactly the same way a user-uploaded image would be.2. Two Completely Different Paths
video_source. For YouTube videos, no extraction is needed — YouTube already hosts thumbnails at predictable URLs (maxresdefault.jpg, sddefault.jpg, hqdefault.jpg), so we just parse the video ID and build those three URLs. For uploaded videos (MP4, WebM, MOV…), we run the real frame-extraction pipeline. The failing account is almost certainly on the uploaded-video path.3. Step-by-Step: Uploaded Video Frame Extraction
Create an off-DOM video element
document.createElement('video') — never attached to the DOM. Set crossOrigin = 'anonymous' (critical — without it the canvas taints and toBlob returns null), muted = true, playsInline = true.
Load metadata & read the real duration
Point video.src at the uploaded file URL, wait for onloadedmetadata (30s timeout). Then read video.duration. If it's 0, Infinity, or NaN, abort — this is the #1 cause of silent failures.
Pick 3 random seek points
Three random percentages between 10%–90% of duration (avoiding black intro/outro frames), sorted, multiplied by duration → timestamps in seconds.
Seek, wait for 'seeked', draw the frame
Set video.currentTime = timestamp and listen for the seeked event (NOT a setTimeout — the seek is async). After seeked, wait 100ms, then ctx.drawImage(video, 0, 0, w, h) onto a canvas sized to video.videoWidth/videoHeight.
Export to JPEG and upload
canvas.toBlob(cb, 'image/jpeg', 0.85) → JPEG blob → wrap in new File(...) → base44.integrations.Core.UploadFile({ file }) → push returned file_url into the frames array.
Present the options
Store frames in thumbnailOptions, auto-select the first, write it into formData.thumbnail. Admin sees a 3-up grid, can click any, Retry, or upload a custom image.
4. The Auto-Extract Setting
PlatformSettings entity: setting_key = 'video_auto_extract_thumbnail'. After UploadFile returns the video URL, the code checks this setting; if it's not 'false', it calls handleGenerateThumbnails(file_url) after a 500ms delay. Defaults to ON.5. Why It Fails on Another Account (and How to Fix)
Missing crossOrigin = 'anonymous'
Without it (or without CORS headers on the storage host), the canvas taints and toBlob() silently returns null → zero thumbnails. Fix: always set video.crossOrigin = 'anonymous' before src, and ensure the file URL is served with Access-Control-Allow-Origin.
Not waiting for the 'seeked' event
Setting currentTime and immediately drawImage on a setTimeout captures a blank/stale frame. Fix: wait for seeked, then add a 100ms render delay.
duration is Infinity or NaN
Some WebM/MOV files report Infinity until fully buffered. Reading it too early → NaN timestamps → seek fails. Fix: wait for onloadedmetadata, guard with isFinite(duration) and duration > 0; fall back to a fixed 1-second timestamp for stubborn files.
Extracting before upload finishes
If the extractor runs before UploadFile resolves, video.src points at nothing. Fix: only call with the resolved file_url, plus a 500ms buffer.
File too large / decode limits
Huge videos exhaust browser memory. We cap at 50MB. Fix: enforce a max size and wrap each frame in its own try/catch so one failure doesn't abort the rest.
Tainted canvas from a redirected URL
If the storage URL redirects through a non-CORS domain, the canvas taints even with crossOrigin set. Fix: use the final direct URL with CORS headers.
6. The Exact Code
Pure client-side React + browser APIs + the Base44 UploadFile integration. No backend function, no npm package, no external service.
Complete handleGenerateThumbnails function
// Extract thumbnails from an uploaded video at random timestamps
const handleGenerateThumbnails = async (overrideUrl) => {
const videoUrl = overrideUrl || formData.video_file_url;
if (!videoUrl) {
toast.error('Please upload a video or enter YouTube URL first');
return;
}
// ---- YOUTUBE PATH: use YouTube's built-in thumbnail system ----
if (formData.video_source === 'youtube') {
const videoId = videoUrl.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\?\/]+)/
)?.[1];
if (videoId) {
const ytThumbnails = [
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
`https://img.youtube.com/vi/${videoId}/sddefault.jpg`,
`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
];
setThumbnailOptions(ytThumbnails);
setSelectedThumbnail(ytThumbnails[0]);
setFormData(prev => ({ ...prev, thumbnail: ytThumbnails[0] }));
}
return;
}
// ---- UPLOADED VIDEO PATH: extract real frames with <video> + <canvas> ----
setIsGeneratingThumbnails(true);
try {
// 1. Create an off-DOM HTML5 <video> element and load the uploaded file URL
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // REQUIRED — prevents tainted canvas
video.src = videoUrl;
video.muted = true; // required for autoplay policies
video.playsInline = true;
// 2. Wait for metadata so we know the real duration
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Video load timeout')), 30000);
video.onloadedmetadata = () => { clearTimeout(timeout); resolve(); };
video.onerror = (e) => { clearTimeout(timeout); reject(e); };
video.load();
});
const duration = video.duration;
if (!duration || duration === 0 || !isFinite(duration)) {
throw new Error('Invalid video duration');
}
// 3. Pick 3 random seek points between 10% and 90% of the duration
const randomPercentages = [
0.1 + Math.random() * 0.8,
0.1 + Math.random() * 0.8,
0.1 + Math.random() * 0.8
].sort();
const timestamps = randomPercentages.map(p => duration * p);
const extractedFrames = [];
// 4. For each timestamp: seek, wait for 'seeked', draw to canvas, export blob, upload
for (const timestamp of timestamps) {
try {
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 10000); // never reject, just move on
video.currentTime = timestamp;
const onSeeked = async () => {
clearTimeout(timeout);
video.removeEventListener('seeked', onSeeked);
await new Promise(r => setTimeout(r, 100)); // let the frame render
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 1280;
canvas.height = video.videoHeight || 720;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve(); return; }
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(async (blob) => {
if (!blob) { resolve(); return; }
try {
const file = new File(
[blob],
`thumbnail-${Date.now()}-${Math.floor(Math.random()*10000)}.jpg`,
{ type: 'image/jpeg' }
);
const { file_url } = await base44.integrations.Core.UploadFile({ file });
extractedFrames.push(file_url);
resolve();
} catch (err) {
resolve();
}
}, 'image/jpeg', 0.85);
};
video.addEventListener('seeked', onSeeked, { once: true });
});
} catch (err) {
// skip this frame, keep going
}
}
// 5. Show the extracted frames; auto-select the first one
if (extractedFrames.length > 0) {
setThumbnailOptions(extractedFrames);
setSelectedThumbnail(extractedFrames[0]);
setFormData(prev => ({ ...prev, thumbnail: extractedFrames[0] }));
toast.success(`Extracted ${extractedFrames.length} thumbnail(s)!`);
} else {
toast.error('Could not extract thumbnails. Upload a custom thumbnail instead.');
}
} catch (error) {
toast.error('Thumbnail extraction failed. Please upload a custom thumbnail instead.');
} finally {
setIsGeneratingThumbnails(false);
}
};Auto-trigger after upload
// Inside the video file upload onChange handler, AFTER UploadFile succeeds:
const autoExtract = settings.find(s => s.setting_key === 'video_auto_extract_thumbnail')?.setting_value !== 'false';
if (autoExtract) {
setTimeout(() => handleGenerateThumbnails(file_url), 500);
}PlatformSettings record
// PlatformSettings record that controls the global toggle:
{
setting_key: 'video_auto_extract_thumbnail',
setting_value: 'true', // or 'false'
description: 'Auto-extract thumbnails from uploaded videos (true/false)'
}7. Replication Checklist
- Add a
thumbnailstring field to your Video entity schema. - Add the
handleGenerateThumbnailsfunction to your video manager component. - After your video
UploadFilecall, check the auto-extract setting and call the function with the returnedfile_url. - Add a
PlatformSettingstoggle (video_auto_extract_thumbnail) so it can be turned off globally. - Always set
crossOrigin = 'anonymous', always wait forseeked, always guarddurationwithisFinite(). - Provide a custom-thumbnail upload fallback + Retry button so failures never block the admin.
8. Summary
toBlob, and upload each JPEG with Core.UploadFile. The three things that break it are (1) a tainted canvas from missing crossOrigin/CORS, (2) drawing before the seeked event fires, and (3) a non-finite duration. Fix those three and extraction will work reliably.