Cómo crear un reproductor de video 360° con Three.js y Transloadit
Capturar tus recuerdos en video 360° te permite revivirlos en una dimensión completamente nueva. La realidad virtual sigue siendo una tecnología de nicho, así que en su lugar crearemos un reproductor de video que permite a los usuarios subir sus propios videos 360° y verlos desde su laptop, ¡gracias a Three.js, Uppy y, por supuesto, Transloadit!

Proyectar el video
Naturalmente, el núcleo de cualquier reproductor de video es mostrar un video. Esto es bastante
sencillo con un video 2D normal, pero el elemento <video> no será suficiente para nuestros
propósitos. Aquí es donde entra Three.js, que nos permite proyectar un video
equirectangular sobre la superficie
interna de una esfera. Luego, al colocar una cámara dentro de la esfera, podemos mirar alrededor
como si realmente formáramos parte de la escena. Puedes ver esto ilustrado en el diagrama de abajo.

Para crear esto en Three.js, primero necesitamos crear los objetos Scene, PerspectiveCamera, WebGLRenderer
y OrbitControls.
Empieza con un documento HTML que contenga una metaetiqueta viewport. Agrega este import map en el head del documento, antes de cualquier script de módulo. Tanto Three.js como su addon usan la misma versión fijada.
<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>
Combina los bloques de JavaScript de abajo en un solo <script type="module">, colocado después de los controles
HTML de la sección de subida. OrbitControls es un módulo aparte, no una propiedad del objeto 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')
Ahora podemos inicializar los objetos de nuestra escena, empezando por crear un video con varias propiedades ajustadas.
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)
El clip inicial es el panorama de ejemplo del ejemplo de video de Three.js.
Reemplázalo por tu propio video equirectangular según lo necesites. Las respuestas de video remotas
deben permitir el acceso entre orígenes para que WebGL pueda usarlas como texturas; establecer
crossOrigin por sí solo no lo concede. Esto aplica tanto al clip inicial como al resultado exportado
a S3. Configura la política CORS de tu bucket para permitir lecturas desde el origen de tu sitio.
Notarás que todavía no se ve nada, ya que aún necesitamos activar un renderizado de nuestra escena.
Crea una función animate, que contendrá tanto la función requestAnimationFrame como la función render.
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
¡Et voilà! Ahora podemos ver el video reproduciéndose y podemos mirar alrededor gracias al addon
OrbitControls. Quizá notes que, si cambiamos el tamaño de la ventana, nos encontramos con
algunos problemas extraños de escalado. Vamos a arreglarlo rápidamente.
window.addEventListener('resize', onWindowResize)
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
}
La función anterior es bastante simple. Escuchamos el cambio de tamaño de la ventana. Cuando ocurre, actualizamos tanto la relación de aspecto de nuestra cámara como el tamaño del renderizador, para que coincidan con las nuevas dimensiones de la pantalla.
Agregar controles de zoom
Agregar el zoom es bastante simple. Aunque OrbitControls sí lo ofrece, no encaja a la perfección con
nuestro reproductor de video, así que vamos a implementarlo nosotros mismos. Crea un listener y un
manejador de eventos para la rueda de desplazamiento, luego actualiza el FoV de la cámara según la
distancia desplazada, además de refrescar la matriz de proyección de la cámara.
renderer.domElement.addEventListener('wheel', handleZoom)
function handleZoom(e) {
camera.fov = THREE.MathUtils.clamp(camera.fov + e.deltaY / 10, 10, 100)
camera.updateProjectionMatrix()
}
¡Así de fácil!
Subir videos con Uppy
Ahora ampliemos el entorno del reproductor, permitiendo que los usuarios suban sus propios videos. Primero, esbozaremos una interfaz básica.
<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);
}
}
Ahora podemos incorporar Uppy, primero importándolo y luego inicializando el plugin de Transloadit.
Agrega este import y el siguiente código de subida al mismo script de módulo que el código de
Three.js de arriba. Los scripts de módulo separados no comparten la variable video ni las del elemento de estado.
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)
Asegúrate de completar los campos AUTH_KEY y TEMPLATE_ID con sus valores de la
Transloadit Console.
Antes de desplegar públicamente, usa firmas generadas en el servidor y autoriza las subidas en tu servidor. Nunca incluyas un Auth Secret en el código del navegador.
Después, necesitaremos agregar un listener de eventos al botón de envío, que active la subida a Transloadit cuando se haga clic en él. Además, crea variables para el contenedor del botón y los puntos de carga, asegurándote de ocultar inicialmente los puntos de carga.
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 = ''
}
}
Quizá también hayas notado antes que hicimos referencia a la función setSrc como parte del
evento transloadit:complete. Esta función simplemente tomará la URL del video de nuestro Assembly
Status JSON y actualizará el elemento de video con esta nueva fuente. Vamos a crearla ahora.
function setSrc(assembly) {
const url = assembly.results.exported?.[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()
}
Asegúrate de llamar a video.load() y video.play(), ya que esto hace que el elemento recargue la
fuente, actualizando así el video dentro de nuestro reproductor.
Crear un Template con Transloadit
Sin embargo, después de todo eso, nuestra subida de archivos aún no funcionará. La pieza final del rompecabezas es crear un Template, que procesará el video subido y lo devolverá a nuestro cliente.
{
"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
}
}
}
El Template anterior transcodifica el video subido a un formato uniforme y luego lo
exporta a S3 para almacenamiento a largo plazo, ya que Transloadit solo almacena los archivos
temporalmente durante 24 horas antes de eliminarlos.
Reemplaza YOUR_AWS_CREDENTIALS por el nombre de tu
credencial de Template guardada; no pegues secretos de S3 en el ejemplo.
Para terminar
Ahora que todas las piezas de nuestro rompecabezas están en su lugar, tu reproductor de video 360° debería funcionar de maravilla. No dejes de contarnos cómo te fue en X (Twitter), ¡o cuéntanos sobre alguna otra cosa que hayas creado con Transloadit!
