Extraire des miniatures vidéo dans le navigateur avec ffmpeg.wasm
Sélectionnez une vidéo locale, saisissez des horodatages en secondes et affichez des aperçus PNG sans téléverser la vidéo. Ce tutoriel construit une petite application de navigateur avec ffmpeg.wasm et Vite, y compris le sélecteur de fichier, les ressources Wasm correspondantes, les retours d’erreur et le nettoyage entre les exécutions.
Pourquoi extraire des miniatures dans le navigateur ?
Un aperçu local permet de choisir une image pertinente de la vidéo avant de décider de la téléverser ou non. Dans cet exemple, le navigateur télécharge le code de l’application et le cœur FFmpeg depuis votre serveur local ; la vidéo sélectionnée et les images générées restent dans la mémoire du navigateur. Cela décrit le comportement de cette application, et non une garantie de confidentialité pour toute application qui utilise ffmpeg.wasm.
Présentation de ffmpeg.wasm
La surcouche ffmpeg.wasm exécute FFmpeg dans son propre web worker. Vous copiez un fichier dans son système de fichiers virtuel, exécutez une commande FFmpeg familière et relisez le résultat. Le cœur monothread utilisé ici s’exécute tout de même en dehors du thread principal du navigateur.
Commencez avec un MP4 H.264 court et non chiffré. Cet exemple limite les entrées à 25 MiB et chaque demande à six horodatages. Ce sont des limites de démonstration, et non une garantie que chaque appareil peut traiter un tel fichier. L’environnement vérifié est Linux, Node.js 24.15.0, Yarn 4.12.0 et Chromium 145.0.7632.6. Node compile et sert l’application ; le navigateur effectue le traitement multimédia. Les autres navigateurs et les appareils mobiles nécessitent leurs propres tests.
Installer et initialiser
Avec Node.js 24.15.0 et Corepack disponibles, collez ceci dans un shell compatible Bash, depuis un
répertoire de travail temporaire. Cela crée un nouveau projet wasm-thumbnails et refuse d’écraser un
répertoire existant. Le sous-shell laisse inchangés le répertoire courant et les options de votre
shell. Le fichier de verrouillage local et la configuration TypeScript isolent le projet de toute
configuration d’espace de travail englobante.
(
set -eu
mkdir wasm-thumbnails
cd wasm-thumbnails
cat > package.json <<'JSON'
{
"name": "wasm-thumbnails",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@ffmpeg/core": "0.12.10",
"@ffmpeg/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2",
"vite": "8.3.1"
}
}
JSON
printf '{"compilerOptions":{"target":"ES2022"}}\n' > tsconfig.json
touch yarn.lock
YARN_NODE_LINKER=node-modules corepack yarn install
)
Conservez le yarn.lock généré pour une résolution reproductible des dépendances. Enregistrez les
trois fichiers suivants dans wasm-thumbnails. Tout d’abord, copy-core.ts copie le cœur ESM depuis le
paquet installé afin que les fichiers JavaScript et Wasm correspondent. Le
guide de chargement officiel spécifie des ressources ESM pour Vite ; les
ressources de même origine peuvent être chargées directement, sans encapsulation dans des URL blob.
import { copyFile, mkdir } from 'node:fs/promises'
await mkdir('public/core', { recursive: true })
for (const name of ['ffmpeg-core.js', 'ffmpeg-core.wasm']) {
await copyFile(`node_modules/@ffmpeg/core/dist/esm/${name}`, `public/core/${name}`)
}
Ensuite, enregistrez index.html :
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Local video thumbnails</title>
<link rel="icon" href="data:," />
<style>
body { font-family: system-ui, sans-serif; max-width: 48rem; margin: 2rem auto; padding: 1rem; }
label { display: block; margin-block: 1rem; }
input { max-width: 100%; }
figure { margin-inline: 0; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
<h1>Local video thumbnails</h1>
<form id="picker">
<fieldset id="controls">
<legend>Choose a video and timestamps</legend>
<label>Video <input id="video" type="file" accept="video/*" required /></label>
<label>Seconds, separated by commas
<input id="times" type="text" value="0.5, 1.5" required />
</label>
<button type="submit">Extract thumbnails</button>
</fieldset>
</form>
<p id="status" role="status">Choose a video to begin.</p>
<section id="results" aria-label="Thumbnails"></section>
<script type="module" src="/main.ts"></script>
</body>
</html>
Respecter les exigences du navigateur
Servez l’application via localhost comme indiqué ci-dessous, plutôt que d’ouvrir index.html en
tant que fichier. Ce @ffmpeg/core monothread fonctionne sans SharedArrayBuffer ni en-têtes COOP/COEP. La
surcouche nécessite toujours des workers de module et WebAssembly. Le téléchargement du cœur pèse
environ 31 MiB avant compression HTTP, de sorte que la première extraction peut prendre
sensiblement plus de temps que les suivantes.
Passer à @ffmpeg/core-mt constitue une intégration distincte :
l’exemple de chargement multithread
nécessite une ressource worker supplémentaire et les conditions de sécurité de SharedArrayBuffer,
notamment l’isolation cross-origin. N’ajoutez pas ces exigences par défaut à cet exemple monothread.
Le HTTPS en production, la politique de sécurité du contenu et l’hébergement sous un sous-chemin
nécessitent une configuration distincte ; ce tutoriel sert l’application depuis la racine de
l’origine.
Extraire une seule miniature
Enregistrez ce programme complet sous main.ts. extractThumbnail() sélectionne le premier flux
vidéo, se positionne à l’instant demandé et écrit un PNG. Chaque appel utilise des noms de fichiers
virtuels uniques, vérifie le code de sortie et les octets produits, puis supprime ses fichiers, même
en cas d’échec.
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
let extracting = false
async function extractThumbnail(videoFile: File, seconds: number): Promise<Blob> {
if (!Number.isFinite(seconds) || seconds < 0) throw new Error('Invalid timestamp')
if (extracting) throw new Error('A thumbnail operation is already running')
extracting = true
const id = crypto.randomUUID()
const input = `input-${id}.mp4`
const output = `thumb-${id}.png`
try {
if (!ffmpeg.loaded) {
await ffmpeg.load({
coreURL: new URL('/core/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/core/ffmpeg-core.wasm', location.href).href,
})
}
await ffmpeg.writeFile(input, await fetchFile(videoFile))
const code = await ffmpeg.exec([
'-xerror', '-ss', String(seconds), '-i', input,
'-map', '0:v:0', '-frames:v', '1', '-vf', 'scale=320:-1', output,
])
if (code !== 0) throw new Error('FFmpeg could not extract this frame')
// A seek past the end can succeed without producing a usable image.
const data = await ffmpeg.readFile(output)
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('No thumbnail was produced')
}
return new Blob([new Uint8Array(data)], { type: 'image/png' })
} finally {
// A failed command may never have created one or both files.
await Promise.allSettled([ffmpeg.deleteFile(input), ffmpeg.deleteFile(output)])
extracting = false
}
}
function parseTimes(value: string): number[] {
const parts = value.split(',').map((part) => part.trim())
if (parts.length > 6 || parts.some((part) => !/^\d+(\.\d+)?$/.test(part))) {
throw new Error('Enter one to six nonnegative timestamps in seconds.')
}
const times = parts.map(Number)
if (times.some((time) => !Number.isFinite(time))) throw new Error('Invalid timestamp')
return times
}
const form = document.getElementById('picker')
const controls = document.getElementById('controls')
const video = document.getElementById('video')
const times = document.getElementById('times')
const status = document.getElementById('status')
const results = document.getElementById('results')
if (!(form instanceof HTMLFormElement) || !(controls instanceof HTMLFieldSetElement) ||
!(video instanceof HTMLInputElement) || !(times instanceof HTMLInputElement) ||
!status || !results) {
throw new Error('Missing page controls')
}
let running = false
const previewUrls: string[] = []
function clearPreviews(container: HTMLElement): void {
container.replaceChildren()
for (const url of previewUrls) URL.revokeObjectURL(url)
previewUrls.length = 0
}
form.addEventListener('change', () => {
if (running) return
clearPreviews(results)
status.textContent = 'Ready to extract.'
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (running) return
const file = video.files?.[0]
if (!file) return
running = true
controls.disabled = true
clearPreviews(results)
status.textContent = 'Loading FFmpeg and extracting…'
const deadline = setTimeout(() => ffmpeg.terminate(), 60_000)
try {
if (file.size === 0 || file.size > 25 * 1024 * 1024) {
throw new Error('Choose a nonempty video of at most 25 MiB.')
}
const marks = parseTimes(times.value)
const figures = []
for (const mark of marks) {
const blob = await extractThumbnail(file, mark)
const url = URL.createObjectURL(blob)
previewUrls.push(url)
const image = new Image()
image.alt = `Video frame at ${mark} seconds`
image.src = url
await image.decode()
const caption = document.createElement('figcaption')
caption.textContent = `${mark} s`
const figure = document.createElement('figure')
figure.append(image, caption)
figures.push(figure)
}
results.replaceChildren(...figures)
status.textContent = `${marks.length} thumbnail(s) ready.`
} catch {
clearPreviews(results)
ffmpeg.terminate()
status.textContent = 'Could not extract thumbnails. Use a small valid video and times within its duration, then retry.'
} finally {
clearTimeout(deadline)
controls.disabled = false
running = false
}
})
L’option -ss effectue le positionnement avant le décodage, et le
positionnement précis par défaut de FFmpeg ignore les images situées avant la position demandée lors
du transcodage. Les images vidéo existent à des instants discrets : un horodatage situé entre deux
images ne crée pas d’image interpolée. La sortie fait 320 pixels de large. Il s’agit d’un
extracteur d’aperçus, et non d’une vérification d’intégrité du fichier entier ; une miniature
extraite avec succès au début ne prouve pas que les paquets ultérieurs de la vidéo sont intacts.
Récupérer plusieurs miniatures de manière séquentielle
La boucle du formulaire attend chaque horodatage sur une seule instance FFmpeg. Elle copie à nouveau l’entrée pour chaque image, privilégiant une fonction d’extraction petite et autonome plutôt qu’une API de traitement par lot plus élaborée. Les résultats n’apparaissent ensemble qu’une fois que chaque image demandée a été décodée. Une nouvelle soumission efface l’ensemble précédent ; un lot en échec ne laisse aucun aperçu partiel.
Exécutez ce qui suit depuis le répertoire contenant wasm-thumbnails. La copie du cœur n’écrase que
les deux ressources du projet, et Vite remplace le build dist du projet. Chaque étape ne
s’exécute que si la précédente réussit :
(
cd wasm-thumbnails &&
node copy-core.ts &&
corepack yarn vite build &&
corepack yarn vite preview --host 127.0.0.1 --port 4173 --strictPort
)
Ouvrez http://127.0.0.1:4173, choisissez une vidéo de plus de 2 secondes, laissez les horodatages à
0.5, 1.5 et appuyez sur Extract thumbnails. Deux images devraient
apparaître, avec leurs instants demandés en dessous et
2 thumbnail(s) ready. au-dessus. Utilisez une valeur unique telle que
0.5 pour une seule miniature. Arrêtez le serveur avec Ctrl+C ; si le port 4173 est
occupé, choisissez un autre port dans la commande et dans l’URL. Le
serveur de prévisualisation de Vite sert à vérifier un build local.
Garder l’interface réactive avec un web worker
La surcouche gère déjà le worker. Le formulaire désactive son sélecteur de fichier, son champ d’horodatages et son bouton d’envoi pendant le traitement, et ignore les soumissions en double. Il ne propose pas d’annulation et ne permet pas à une nouvelle sélection de supplanter un lot en cours. Le délai de 60 secondes met fin au worker si le chargement ou l’extraction se bloque ; la soumission suivante charge une nouvelle instance du cœur.
Conseils de performance
Le cœur se charge à la première soumission et est réutilisé après les lots réussis. Les fichiers virtuels temporaires sont supprimés après chaque extraction. Les URL d’aperçu restent valides tant que leurs images sont affichées, puis sont révoquées lorsque les entrées changent ou qu’une autre exécution démarre. Recharger ou fermer la page supprime la session ; l’application n’enregistre pas les miniatures sur le disque.
Supprimer les fichiers virtuels ne garantit pas que le runtime Wasm restitue immédiatement toute la mémoire allouée au système d’exploitation. Un worker peut conserver sa capacité mémoire pour la réutiliser. Des images de sortie plus petites n’éliminent pas non plus le coût du décodage d’une entrée haute résolution. Testez des fichiers représentatifs sur les appareils que vous comptez prendre en charge avant d’augmenter les limites de démonstration.
Traitement dans le navigateur ou sur le serveur
L’extraction dans le navigateur est utile pour un aperçu avant téléversement, mais son temps de téléchargement, son utilisation de la mémoire et sa vitesse de traitement dépendent de l’appareil de l’utilisateur. L’extraction côté serveur nécessite d’envoyer la vidéo et de prévoir la capacité de traitement ainsi que la conservation des données. Elle peut convenir aux flux de travail qui nécessitent des résultats persistants ou des ressources de traitement constantes. Aucune de ces approches n’assure à elle seule une prise en charge universelle des codecs ou une capacité illimitée.
Résoudre les problèmes courants
- Aucun aperçu : vérifiez que la vidéo n’est pas vide, qu’elle respecte la limite de taille et
qu’elle contient un flux vidéo décodable. Saisissez des secondes décimales telles que
1.25, et non00:00:01.25. Un horodatage situé à la fin ou au-delà peut ne produire aucune image ; réessayez avec un instant antérieur. - Échec du chargement du cœur : vérifiez que
/core/ffmpeg-core.jset/core/ffmpeg-core.wasmsont tous deux servis par le build. Relancez la commande de copie et de build après avoir changé la version du cœur. La surcouche JavaScript et le cœur ont des numéros de version distincts ; ils n’ont pas besoin de partager la même chaîne de version. - L’extraction échoue ou atteint le délai : essayez une vidéo plus courte, de plus faible résolution. Le gestionnaire d’échec supprime les aperçus et met fin au worker afin que la soumission suivante puisse repartir proprement.
- Erreurs
SharedArrayBuffer: vérifiez si vous n’avez pas remplacé par erreur le cœur par sa version multithread. L’ajout d’en-têtes d’isolation ne corrige pas des ressources manquantes dans cette configuration monothread.
Aller au-delà des aperçus locaux
Si les appareils cibles ne peuvent pas traiter vos vidéos, envisagez un flux de travail fondé sur le téléversement, avec le consentement explicite de l’utilisateur. La documentation sur les miniatures vidéo (English) décrit l’option côté serveur de Transloadit. Gardez l’aperçu local utile en soi : une extraction ratée devrait permettre à l’utilisateur de choisir un autre fichier ou un autre horodatage sans perdre le reste de son travail.
