Como criar um player de vídeo 360° com Three.js e Transloadit
Registrar suas memórias em vídeo 360° permite revivê-las em uma dimensão totalmente nova. A RV ainda é uma tecnologia de nicho, então vamos criar um player de vídeo que permite aos usuários fazer upload dos próprios vídeos 360° e assisti-los no notebook, com Three.js, Uppy e, claro, a Transloadit!

Projetando o vídeo
Naturalmente, o núcleo de qualquer player de vídeo é exibir um vídeo. Isso é bem fácil com um vídeo
2D comum, mas o elemento <video> não vai dar conta do que precisamos. É aqui que o Three.js
entra, permitindo projetar um vídeo
equirretangular na superfície interna
de uma esfera. Depois, posicionando uma câmera dentro da esfera, podemos olhar ao redor como se
realmente fizéssemos parte da cena. Você pode ver isso ilustrado no diagrama abaixo.

Para montar isso no Three.js, primeiro precisamos criar os objetos Scene, PerspectiveCamera,
WebGLRenderer e OrbitControls.
Comece com um documento HTML que contenha uma meta tag de viewport. Adicione este import map no head do documento, antes de qualquer script de módulo. Tanto o Three.js quanto o addon usam a mesma versão fixada.
<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>
Combine os blocos de JavaScript abaixo em um único <script type="module">, colocado depois dos controles HTML
da seção de upload. OrbitControls é um módulo separado, não uma propriedade do 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')
Agora podemos inicializar os objetos da nossa cena, começando pela criação de um vídeo com várias propriedades 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)
O clipe inicial é o panorama de amostra do exemplo de vídeo do Three.js.
Substitua-o pelo seu próprio vídeo equirretangular, se necessário. As respostas de vídeos remotos
precisam permitir acesso cross-origin para que o WebGL possa usá-las como texturas; definir
crossOrigin sozinho não concede esse acesso.
Isso vale tanto para o clipe inicial quanto para o resultado exportado para o S3. Configure a
política de CORS do seu bucket para permitir leituras a partir da origem do seu site.
Você vai notar que ainda não aparece nada, pois ainda precisamos disparar a renderização da cena.
Crie uma função animate, que vai conter as funções requestAnimationFrame e render.
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
E voilà! Agora podemos ver o vídeo sendo reproduzido e olhar ao redor graças ao addon
OrbitControls. Você pode perceber que, se redimensionarmos a janela, surgem alguns problemas
estranhos de escala. Vamos corrigir isso rapidinho.
window.addEventListener('resize', onWindowResize)
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
}
A função acima é bem simples. Ficamos escutando o redimensionamento da janela. Quando ele acontece, atualizamos tanto a proporção da câmera quanto o tamanho do renderizador, para que correspondam às novas dimensões da tela.
Adicionando controles de zoom
Adicionar zoom é bem simples. Embora o OrbitControls ofereça esse recurso, ele não se encaixa
perfeitamente no nosso player de vídeo, então vamos implementá-lo nós mesmos. Crie um event listener
e um handler para a roda de rolagem e, em seguida, atualize o FoV da câmera de acordo com a distância
rolada, além de atualizar a matriz de projeção da câmera.
renderer.domElement.addEventListener('wheel', handleZoom)
function handleZoom(e) {
camera.fov = THREE.MathUtils.clamp(camera.fov + e.deltaY / 10, 10, 100)
camera.updateProjectionMatrix()
}
É realmente simples assim!
Fazendo upload de vídeos com o Uppy
Agora vamos expandir o que há ao redor do player, permitindo que os usuários façam upload dos próprios vídeos. Primeiro, vamos esboçar uma interface 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);
}
}
Agora podemos incluir o Uppy na jogada, primeiro importando-o e depois inicializando o plugin Transloadit.
Adicione este import e o código de upload a seguir ao mesmo script de módulo do código Three.js
acima. Scripts de módulo separados não compartilham a variável video nem a variável do
elemento de status.
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)
Não se esqueça de preencher os campos AUTH_KEY e TEMPLATE_ID com os valores do
console da Transloadit.
Antes de publicar, use assinaturas geradas no servidor e autorize os uploads no seu servidor. Nunca inclua um Auth Secret no código do navegador.
Em seguida, precisamos adicionar um event listener ao botão de envio, que dispara o upload para a Transloadit quando ele é clicado. Crie também variáveis para o contêiner do botão e para os pontos de carregamento, garantindo que os pontos de carregamento fiquem ocultos inicialmente.
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 = ''
}
}
Você também deve ter notado que, antes, fizemos referência à função setSrc como parte do
evento transloadit:complete. Essa função simplesmente pega a URL do vídeo do nosso
Assembly Status JSON e atualiza o elemento de vídeo
com essa nova fonte. Vamos criá-la agora.
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()
}
Não deixe de chamar video.load() e video.play(), pois isso faz o elemento recarregar a
fonte e, com isso, atualizar o vídeo dentro do nosso player.
Criando um Template com a Transloadit
Depois de tudo isso, porém, nosso upload de arquivos ainda não vai funcionar. A peça final do quebra-cabeça é criar um Template, que vai processar o vídeo enviado e devolvê-lo ao nosso 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
}
}
}
O Template acima transcodifica o vídeo enviado para um formato uniforme e depois o
exporta para o S3 para armazenamento de longo prazo, já que a Transloadit armazena os arquivos apenas
temporariamente, por 24 horas, antes de excluí-los.
Substitua YOUR_AWS_CREDENTIALS pelo nome das suas
Credenciais de Template salvas; não cole segredos do S3 no exemplo.
Chegando ao fim
Agora que todas as peças do nosso quebra-cabeça estão no lugar, seu player de vídeo 360° deve estar funcionando lindamente. Não deixe de mostrar como você se saiu no X (Twitter) ou de contar para a gente sobre outra coisa que você criou usando a Transloadit!
