So bauen Sie einen 360°-Videoplayer mit Three.js & Transloadit
Wenn Sie Ihre Erinnerungen in 360°-Video festhalten, erleben Sie sie in einer völlig neuen Dimension noch einmal. VR ist nach wie vor eine Nischentechnologie, deshalb erstellen wir stattdessen einen Videoplayer, mit dem Nutzer ihre eigenen 360°-Videos hochladen und auf ihrem Laptop ansehen können, möglich gemacht durch Three.js, Uppy und natürlich Transloadit!

Das Video projizieren
Der Kern jedes Videoplayers ist naturgemäß die Wiedergabe eines Videos. Bei einem gewöhnlichen
2D-Video ist das einfach genug, doch für unsere Zwecke reicht das Element
<video> nicht aus. Hier kommt Three.js ins Spiel: Damit können wir ein
equirektangulares Video auf die Innenfläche einer Kugel
projizieren. Indem wir anschließend eine Kamera innerhalb der Kugel positionieren, können wir uns
umsehen, als wären wir wirklich Teil der Szene. Die Abbildung unten veranschaulicht das.

Um das in Three.js umzusetzen, müssen wir zunächst Objekte vom Typ
Scene, PerspectiveCamera, WebGLRenderer
und OrbitControls erstellen.
Beginnen Sie mit einem HTML-Dokument, das ein Viewport-Meta-Tag enthält. Fügen Sie diese Import Map im Head des Dokuments ein, noch vor allen Modul-Skripten. Three.js und sein Addon verwenden dieselbe fixierte Version.
<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>
Fassen Sie die folgenden JavaScript-Blöcke in einem einzigen <script type="module">
zusammen, das hinter den HTML-Bedienelementen aus dem Upload-Abschnitt steht. Bei
OrbitControls handelt es sich um ein eigenes Modul und nicht um eine Eigenschaft
des Objekts 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')
Nun können wir die Objekte in unserer Szene initialisieren und beginnen damit, ein Video mit verschiedenen angepassten Eigenschaften zu erstellen.
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)
Der anfängliche Clip ist das Beispiel-Panorama aus dem
Three.js-Videobeispiel. Ersetzen Sie ihn bei Bedarf durch Ihr
eigenes equirektangulares Video. Antworten entfernter Videos müssen ursprungsübergreifenden Zugriff
erlauben, damit WebGL sie als Texturen verwenden kann; das Setzen von
crossOrigin allein gewährt ihn nicht. Das gilt sowohl für den anfänglichen Clip
als auch für das nach S3 exportierte Ergebnis. Konfigurieren Sie die CORS-Richtlinie Ihres Buckets
so, dass Lesezugriffe vom Ursprung Ihrer Website erlaubt sind.
Ihnen wird auffallen, dass noch nichts sichtbar ist, da wir das Rendern unserer Szene erst noch
auslösen müssen. Erstellen Sie eine Funktion animate, die sowohl die
Funktion requestAnimationFrame als auch render enthält.
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
Et voilà! Wir sehen nun das abgespielte Video und können uns dank des Addons
OrbitControls umsehen. Möglicherweise fällt Ihnen auf, dass beim Ändern der
Fenstergröße seltsame Skalierungsprobleme auftreten. Beheben wir das schnell.
window.addEventListener('resize', onWindowResize)
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
}
Die obige Funktion ist recht einfach. Wir warten darauf, dass die Fenstergröße geändert wird. Ist das der Fall, aktualisieren wir sowohl das Seitenverhältnis unserer Kamera als auch die Größe des Renderers, damit sie zu den neuen Abmessungen des Bildschirms passen.
Zoom-Steuerung hinzufügen
Zoom hinzuzufügen ist recht einfach. Zwar bietet OrbitControls das bereits an,
doch zu unserem Videoplayer passt es nicht perfekt, deshalb implementieren wir es selbst. Erstellen
Sie einen Event-Listener und einen Handler für das Scrollrad, aktualisieren Sie dann das FoV der
Kamera entsprechend der gescrollten Distanz und erneuern Sie die Projektionsmatrix der Kamera.
renderer.domElement.addEventListener('wheel', handleZoom)
function handleZoom(e) {
camera.fov = THREE.MathUtils.clamp(camera.fov + e.deltaY / 10, 10, 100)
camera.updateProjectionMatrix()
}
So einfach ist das wirklich!
Videos mit Uppy hochladen
Erweitern wir nun den Bereich rund um den Player, indem wir Nutzern erlauben, ihre eigenen Videos hochzuladen. Zunächst skizzieren wir eine einfache Benutzeroberfläche.
<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);
}
}
Jetzt können wir Uppy ins Spiel bringen, indem wir es zuerst importieren und anschließend das Transloadit-Plugin initialisieren.
Fügen Sie diesen Import und den folgenden Upload-Code in dasselbe Modul-Skript ein wie den
Three.js-Code oben. In getrennten Modul-Skripten stehen die Variable
video und die Variablen des Statuselements nicht gemeinsam zur Verfügung.
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)
Füllen Sie unbedingt die Felder AUTH_KEY und
TEMPLATE_ID mit den zugehörigen Werten aus der
Transloadit-Konsole aus.
Verwenden Sie vor einer öffentlichen Bereitstellung serverseitig erzeugte Signaturen und autorisieren Sie Uploads auf Ihrem Server. Nehmen Sie niemals ein Auth Secret in den Browser-Code auf.
Anschließend müssen wir dem Absenden-Button einen Event-Listener hinzufügen, der beim Klick den Upload zu Transloadit auslöst. Erstellen Sie außerdem Variablen für den Button-Container und die Ladepunkte und blenden Sie die Ladepunkte zunächst aus.
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 = ''
}
}
Vielleicht ist Ihnen zuvor auch aufgefallen, dass wir die Funktion
setSrc als Teil des Ereignisses transloadit:complete
referenziert haben. Diese Funktion übernimmt einfach die Video-URL aus unserem Assembly
Status JSON und aktualisiert das Video-Element mit dieser neuen Quelle. Erstellen wir sie jetzt.
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()
}
Rufen Sie unbedingt video.load() und video.play() auf, da das
Element dadurch die Quelle neu lädt und so das Video in unserem Player aktualisiert wird.
Ein Template mit Transloadit erstellen
Nach alldem funktioniert unser Datei-Upload allerdings immer noch nicht. Das letzte Puzzleteil besteht darin, ein Template zu erstellen, das das hochgeladene Video verarbeitet und an unseren Client zurückgibt.
{
"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
}
}
}
Das Template oben transkodiert das hochgeladene Video in ein einheitliches Format und
exportiert es anschließend zur langfristigen Speicherung nach S3, da Transloadit Dateien nur
vorübergehend 24 Stunden lang speichert und sie danach löscht.
Ersetzen Sie YOUR_AWS_CREDENTIALS durch den Namen Ihrer gespeicherten
Template-Zugangsdaten; fügen Sie keine S3-Secrets in das Beispiel ein.
Zum Abschluss
Da nun alle Puzzleteile an ihrem Platz sind, sollte Ihr 360°-Videoplayer einwandfrei funktionieren. Zeigen Sie uns unbedingt auf X (Twitter), wie es Ihnen ergangen ist, oder erzählen Sie uns von etwas anderem, das Sie mit Transloadit erstellt haben!
