Comment créer un lecteur vidéo 360° avec Three.js et Transloadit
Capturer vos souvenirs en vidéo 360° vous permet de les revivre dans une toute nouvelle dimension. La réalité virtuelle reste une technologie de niche : nous allons donc plutôt créer un lecteur vidéo qui permet aux utilisateurs de téléverser leurs propres vidéos 360° et de les regarder depuis leur ordinateur portable, grâce à Three.js, Uppy et, bien sûr, Transloadit !

Projeter la vidéo
Naturellement, le cœur de tout lecteur vidéo consiste à afficher une vidéo. C’est assez simple avec
une vidéo 2D classique, mais l’élément <video> ne suffira pas pour nos besoins. C’est là
qu’intervient Three.js, qui nous permet de projeter une vidéo
équirectangulaire sur la surface interne d’une sphère. Ensuite, en
plaçant une caméra à l’intérieur de la sphère, nous pouvons regarder autour de nous comme si nous
faisions réellement partie de la scène. Le schéma ci-dessous illustre ce principe.

Pour réaliser cela dans Three.js, nous devons d’abord créer les objets Scene,
PerspectiveCamera, WebGLRenderer et OrbitControls.
Commencez par un document HTML contenant une balise meta viewport. Ajoutez cette import map dans l’élément head du document, avant tout script de module. Three.js et son module complémentaire utilisent tous deux la même version épinglée.
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.177.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.177.0/examples/jsm/"
}
}
</script>
Regroupez les blocs JavaScript ci-dessous dans un seul <script type="module">, placé après les
contrôles HTML de la section sur le téléversement. OrbitControls est un module distinct, et
non une propriété de l’objet THREE.
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 100)
const renderer = new THREE.WebGLRenderer()
const controls = new OrbitControls(camera, renderer.domElement)
const radius = 30
const status = document.getElementById('status')
Nous pouvons maintenant initialiser les objets de notre scène, en commençant par créer une vidéo dont plusieurs propriétés sont ajustées.
controls.enablePan = false
controls.enableZoom = false
// Points the camera at the horizon by default
// Also moves the camera away for the OrbitControls target slightly so it works
let spherical = new THREE.Spherical(1, Math.PI / 2, 0)
spherical.makeSafe()
camera.position.setFromSpherical(spherical)
const video = document.createElement('video')
video.loop = true
video.muted = true
video.playsInline = true
video.crossOrigin = 'anonymous'
function showVideoError() {
status.textContent = 'The video could not be loaded. Check its format and cross-origin access headers.'
}
function playVideo() {
return video.play().then(() => {
status.textContent = 'Your video is ready.'
}).catch((error) => {
if (error.name === 'NotAllowedError') {
status.textContent = 'Select Play to start the video.'
} else if (error.name !== 'AbortError') {
showVideoError()
}
})
}
video.addEventListener('error', showVideoError)
video.src = 'https://threejs.org/examples/textures/pano.mp4'
playVideo()
document.getElementById('play-button').addEventListener('click', () => {
if (!video.paused) {
video.pause()
return
}
playVideo()
})
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
const texture = new THREE.VideoTexture(video)
texture.colorSpace = THREE.SRGBColorSpace
const geometry = new THREE.SphereGeometry(radius, 48, 32)
geometry.scale(-1, 1, 1)
const material = new THREE.MeshBasicMaterial({ map: texture })
const sphere = new THREE.Mesh(geometry, material)
scene.add(sphere)
Le clip initial est le panorama fourni avec l’exemple vidéo de Three.js.
Remplacez-le par votre propre vidéo équirectangulaire si nécessaire. Les réponses vidéo distantes
doivent autoriser l’accès cross-origin pour que WebGL puisse les utiliser comme textures ; définir
crossOrigin seul ne suffit pas à l’accorder. Cela s’applique aussi bien au clip initial
qu’au résultat exporté vers S3. Configurez la politique CORS de votre bucket pour autoriser les
lectures depuis l’origine de votre site.
Vous remarquerez que rien n’est encore visible, car nous devons encore déclencher le rendu de notre
scène. Créez une fonction animate, qui contiendra à la fois les fonctions
requestAnimationFrame et render.
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
Et voilà ! Nous voyons maintenant la vidéo en cours de lecture, et nous pouvons regarder autour de
nous grâce au module complémentaire OrbitControls. Vous remarquerez peut-être que si nous
redimensionnons la fenêtre, nous rencontrons d’étranges problèmes de mise à l’échelle. Corrigeons
cela rapidement.
window.addEventListener('resize', onWindowResize)
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
}
La fonction ci-dessus est plutôt simple. Nous écoutons le redimensionnement de la fenêtre. Lorsqu’il se produit, nous mettons à jour à la fois le rapport d’aspect de notre caméra et la taille du moteur de rendu, pour correspondre aux nouvelles dimensions de l’écran.
Ajouter des commandes de zoom
Ajouter un zoom est plutôt simple. Bien que OrbitControls le propose, il ne convient pas
parfaitement à notre lecteur vidéo ; nous allons donc l’implémenter nous-mêmes. Créez un écouteur et
un gestionnaire d’événements pour la molette de défilement, puis mettez à jour le FoV (champ de
vision) de la caméra en fonction de la distance parcourue, tout en actualisant la matrice de
projection de la caméra.
renderer.domElement.addEventListener('wheel', handleZoom)
function handleZoom(e) {
camera.fov = THREE.MathUtils.clamp(camera.fov + e.deltaY / 10, 10, 100)
camera.updateProjectionMatrix()
}
C’est vraiment aussi simple que ça !
Téléverser des vidéos avec Uppy
Étoffons maintenant l’environnement du lecteur en permettant aux utilisateurs de téléverser leurs propres vidéos. Commençons par esquisser une interface de base.
<div class="floating-box">
<h2 class="title">Upload a 360° file to view</h2>
<button id="play-button" type="button">Play / pause</button>
<p id="status" role="status"></p>
<!--- from https://loading.io/css/ -->
<div class="lds-ellipsis" id="loading-dots">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div id="container">
<label for="file-input">Equirectangular video</label>
<input type="file" id="file-input" accept="video/*" required />
<button id="submit-button">Submit</button>
</div>
</div>
body {
margin: 0;
}
canvas {
display: block;
}
.floating-box {
position: absolute;
z-index: 10;
top: 16px;
left: 16px;
box-sizing: border-box;
width: min(320px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
background: #fff;
display: flex;
flex-direction: column;
padding: 20px;
align-items: center;
padding-top: 0px;
justify-items: center;
font-family: Helvetica;
}
#container {
display: flex;
flex-direction: column;
gap: 8px;
}
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #000;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
Nous pouvons maintenant intégrer Uppy, en l’important d’abord, puis en initialisant le plugin Transloadit.
Ajoutez cet import et le code de téléversement suivant dans le même script de module que le code
Three.js ci-dessus. Des scripts de module distincts ne partagent pas la variable
video ni les variables des éléments de statut.
import { Uppy, Transloadit } from 'https://releases.transloadit.com/uppy/v3.13.0/uppy.min.mjs'
const uppy = new Uppy({
restrictions: {
allowedFileTypes: ['video/*'],
maxNumberOfFiles: 1,
},
autoProceed: false,
})
uppy.use(Transloadit, {
waitForEncoding: true,
assemblyOptions: {
params: {
auth: { key: 'AUTH_KEY' },
template_id: 'TEMPLATE_ID',
},
},
})
uppy.on('transloadit:complete', setSrc)
Veillez à renseigner les champs AUTH_KEY et TEMPLATE_ID avec leurs valeurs
issues de la Transloadit Console.
Avant tout déploiement public, utilisez des signatures générées côté serveur et autorisez les téléversements sur votre serveur. N’incluez jamais un Auth Secret dans du code exécuté par le navigateur.
Ensuite, nous devons ajouter un écouteur d’événements au bouton d’envoi, qui déclenche le téléversement vers Transloadit lorsqu’on clique dessus. Créez également des variables pour le conteneur du bouton et les points de chargement, en veillant à masquer initialement les points de chargement.
const submit = document.getElementById('submit-button')
submit.addEventListener('click', uploadVideo)
const container = document.getElementById('container')
const loadingDots = document.getElementById('loading-dots')
loadingDots.style.display = 'none'
async function uploadVideo() {
const input = document.getElementById('file-input')
const file = input.files[0] // should only ever be one
if (file == null) return
uppy.cancelAll()
container.style.display = 'none'
loadingDots.style.display = 'block'
submit.disabled = true
status.textContent = 'Processing video.'
try {
uppy.addFile({ name: file.name, type: file.type, data: file })
const result = await uppy.upload()
if (result.failed.length > 0) throw new Error('Video upload failed.')
} catch {
status.textContent = 'Video processing failed. Please try again.'
} finally {
container.style.display = 'flex'
loadingDots.style.display = 'none'
submit.disabled = false
input.value = ''
}
}
Vous avez peut-être aussi remarqué plus tôt que nous faisions référence à la fonction
setSrc dans le cadre de l’événement transloadit:complete. Cette fonction récupère
simplement l’URL de la vidéo depuis notre Assembly Status JSON,
puis met à jour l’élément vidéo avec cette nouvelle source. Créons-la maintenant.
function setSrc(assembly) {
const url = assembly.results.encode_360?.[0]?.ssl_url
if (typeof url !== 'string') {
status.textContent = 'No video was returned. Please try again.'
return
}
video.src = url
video.load()
status.textContent = 'Loading video.'
playVideo()
}
Veillez à appeler video.load() et video.play(), car cela force l’élément à
recharger la source, ce qui met à jour la vidéo dans notre lecteur.
Créer un Template avec Transloadit
Malgré tout cela, notre téléversement de fichier ne fonctionnera toujours pas. La dernière pièce du puzzle consiste à créer un Template, qui traitera la vidéo téléversée et la renverra à notre client.
{
"steps": {
":original": {
"robot": "/upload/handle"
},
"encode_360": {
"use": ":original",
"robot": "/video/encode",
"ffmpeg_stack": "v7",
"preset": "webm"
},
"exported": {
"use": ["encode_360"],
"robot": "/s3/store",
"credentials": "YOUR_AWS_CREDENTIALS",
"result": true
}
}
}
Le Template ci-dessus transcode la vidéo téléversée dans un
format uniforme, puis l’exporte vers S3 pour un stockage à long terme, car Transloadit ne stocke les
fichiers que temporairement, pendant 24 heures, avant de les supprimer.
Remplacez YOUR_AWS_CREDENTIALS par le nom de votre
ensemble d’informations d’identification de Template enregistré ; ne
collez pas de secrets S3 dans l’exemple.
Pour conclure
Maintenant que toutes les pièces de notre puzzle sont en place, votre lecteur vidéo 360° devrait fonctionner à merveille. N’hésitez pas à nous montrer votre résultat sur X (Twitter), ou à nous parler d’un autre projet que vous avez réalisé avec Transloadit !
