Last active
May 12, 2024 17:02
-
-
Save gfodor/2ebdba84a49ba790bebe39aba2bee6ea to your computer and use it in GitHub Desktop.
Hubs director bookmarklet
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
javascript:(async function() { | |
/* Hubs Director Mode bookmarklet - Lets you easily create a custom lerp/slerp | |
* of the camera, optionally tracking a user, for recording nice videos of hubs. | |
* | |
* Director state is stored in the URL, so you can pass URLs around to share shots. | |
* | |
* To use: | |
* | |
* Enter a hubs room, move to the desired start position, and run the bookmarklet. | |
* The page will refresh and drop you into the room again. | |
* | |
* Once refreshed, go to the desired end position and run the bookmarklet again. | |
* It will refresh once more. | |
* | |
* Then, optionally add any of the following query args to the current URL: | |
* | |
* director_lerp_duration: lerp/slerp duration in ms. (default: 3000) | |
* director_track_username: username, if any, whose avatar should be tracked. (default: none) | |
* director_track_distance: fixed distance to track username by. (default: follow lerp to track) | |
* director_fov: camera field of view (default: 80) | |
* director_loop: if the lerp should oscillate in a loop (default: true) | |
* director_inspect_media_frame: if the director should auto-inspect the objec tin the largest media frame in the current scene (default: false) | |
* director_inspect_src: if set, will inspect media with that src | |
* director_avatar_id: id of the avatar to use (default: blank avatar) | |
* director_lobby: if true, keep the camera view in the lobby | |
* | |
* Save this URL. You will need it to subsequently run director mode with those settings + camera positions. | |
* | |
* Then run the bookmarklet and the camera will move from start to end, optionally tracking the user specified. | |
**/ | |
const qs = new URLSearchParams(document.location.search); | |
if (!qs.get("director_start_xform")) { | |
const data = encodeURIComponent(btoa(JSON.stringify({ elements: document.querySelector("#avatar-pov-node").object3D.matrixWorld.elements }))); | |
document.location = document.location + `?director_start_xform=${data}&vr_entry_type=2d_now`; | |
return; | |
} | |
if (!qs.get("director_end_xform")) { | |
const data = encodeURIComponent(btoa(JSON.stringify({ elements: document.querySelector("#avatar-pov-node").object3D.matrixWorld.elements }))); | |
document.location = document.location + `&director_end_xform=${data}`; | |
return; | |
} | |
let scene = null; | |
const BezierEasing = (function() { | |
const NEWTON_ITERATIONS = 4; | |
const NEWTON_MIN_SLOPE = 0.001; | |
const SUBDIVISION_PRECISION = 0.0000001; | |
const SUBDIVISION_MAX_ITERATIONS = 10; | |
const kSplineTableSize = 11; | |
const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0); | |
const float32ArraySupported = typeof Float32Array === 'function'; | |
const A = (aA1, aA2) => { return 1.0 - 3.0 * aA2 + 3.0 * aA1; }; | |
const B = (aA1, aA2) => { return 3.0 * aA2 - 6.0 * aA1; }; | |
const C = (aA1) => { return 3.0 * aA1; }; | |
const calcBezier = (aT, aA1, aA2) => { return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT; }; | |
const getSlope = (aT, aA1, aA2) => { return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1); }; | |
const binarySubdivide = (aX, aA, aB, mX1, mX2) => { | |
let currentX, currentT, i = 0; | |
do { | |
currentT = aA + (aB - aA) / 2.0; | |
currentX = calcBezier(currentT, mX1, mX2) - aX; | |
if (currentX > 0.0) { | |
aB = currentT; | |
} else { | |
aA = currentT; | |
} | |
} while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS); | |
return currentT; | |
}; | |
const newtonRaphsonIterate = (aX, aGuessT, mX1, mX2) => { | |
for (let i = 0; i < NEWTON_ITERATIONS; ++i) { | |
const currentSlope = getSlope(aGuessT, mX1, mX2); | |
if (currentSlope === 0.0) { | |
return aGuessT; | |
} | |
const currentX = calcBezier(aGuessT, mX1, mX2) - aX; | |
aGuessT -= currentX / currentSlope; | |
} | |
return aGuessT; | |
}; | |
const LinearEasing = (x) => { | |
return x; | |
}; | |
return (mX1, mY1, mX2, mY2) => { | |
if (!(0 <= mX1 && mX1 <= 1 && 0 <= mX2 && mX2 <= 1)) { | |
throw new Error('bezier x values must be in [0, 1] range'); | |
} | |
if (mX1 === mY1 && mX2 === mY2) { | |
return LinearEasing; | |
} | |
const sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize); | |
for (let i = 0; i < kSplineTableSize; ++i) { | |
sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2); | |
} | |
const getTForX = (aX) => { | |
let intervalStart = 0.0; | |
let currentSample = 1; | |
let lastSample = kSplineTableSize - 1; | |
for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) { | |
intervalStart += kSampleStepSize; | |
} | |
--currentSample; | |
const dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample]); | |
const guessForT = intervalStart + dist * kSampleStepSize; | |
const initialSlope = getSlope(guessForT, mX1, mX2); | |
if (initialSlope >= NEWTON_MIN_SLOPE) { | |
return newtonRaphsonIterate(aX, guessForT, mX1, mX2); | |
} else if (initialSlope === 0.0) { | |
return guessForT; | |
} else { | |
return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2); | |
} | |
}; | |
return (x) => { | |
if (x === 0 || x === 1) { | |
return x; | |
} | |
return calcBezier(getTForX(x), mY1, mY2); | |
}; | |
}; | |
})(); | |
await new Promise(res => { | |
const interval = setInterval(() => { | |
scene = AFRAME.scenes[0]; | |
if (interval) { | |
res(); | |
clearInterval(interval); | |
} | |
}, 150); | |
}); | |
const easeInOut = BezierEasing(0.42, 0, 0.58, 1); | |
const v1 = new THREE.Vector3(); | |
const v2 = new THREE.Vector3(); | |
const up = new THREE.Vector3(0, 1, 0); | |
const lookAtMatrix = new THREE.Matrix4(); | |
const m1 = new THREE.Matrix4(); | |
const pos = new THREE.Vector3(); | |
const rot = new THREE.Quaternion(); | |
const scale = new THREE.Vector3(); | |
const getMatrixFromQsVar = (name) => { | |
const data = JSON.parse(atob(qs.get(name))).elements; | |
const mat = new THREE.Matrix4(); | |
for (let i = 0; i < data.length; i++) { | |
mat.elements[i] = data[i]; | |
} | |
return mat; | |
}; | |
const setMatrixWorld = (object3D, m) => { | |
if (!object3D.matrixIsModified) { | |
object3D.applyMatrix4(IDENTITY); // hack around our matrix optimizations | |
} | |
object3D.matrixWorld.copy(m); | |
if (object3D.parent) { | |
object3D.parent.updateMatrices(); | |
object3D.matrix = object3D.matrix | |
.copy(object3D.parent.matrixWorld) | |
.invert() | |
.multiply(object3D.matrixWorld); | |
} else { | |
object3D.matrix.copy(object3D.matrixWorld); | |
} | |
object3D.matrix.decompose(object3D.position, object3D.quaternion, object3D.scale); | |
object3D.childrenNeedMatrixWorldUpdate = true; | |
}; | |
class DirectorSystem { | |
constructor(mediaFrameElToInspect, srcUrlToInspect) { | |
this.mediaFrameElToInspect = mediaFrameElToInspect; | |
this.srcUrlToInspect = srcUrlToInspect; | |
this.avatarPov = document.querySelector("#avatar-pov-node"); | |
this.cameraSystem = AFRAME.scenes[0].systems["hubs-systems"].cameraSystem; | |
this.loop = qs.get("director_loop") === null || qs.get("director_loop") === "true"; | |
this.trackUsername = qs.get("director_track_username"); | |
this.trackDistance = qs.get("director_track_distance") ? parseFloat(qs.get("director_track_distance")) : null; | |
this.lastFindTrackedUserAt = 0; | |
const startMatrix = getMatrixFromQsVar("director_start_xform"); | |
const endMatrix = getMatrixFromQsVar("director_end_xform"); | |
const duration = parseInt(qs.get("director_lerp_duration") || "3000"); | |
this.startPos = new THREE.Vector3(); | |
this.startRot = new THREE.Quaternion(); | |
this.endPos = new THREE.Vector3(); | |
this.endRot = new THREE.Quaternion(); | |
startMatrix.decompose(this.startPos, this.startRot, new THREE.Vector3()); | |
endMatrix.decompose(this.endPos, this.endRot, new THREE.Vector3()); | |
this.lerpDuration = duration; | |
this.lerpT = 0.0; | |
this.isInspecting = false; | |
} | |
tick(t, dt) { | |
if (!this.isInspecting) { | |
this.lerpT += dt; | |
this.updateLerp(); | |
} | |
if (this.mediaFrameElToInspect || this.srcUrlToInspect) { | |
let targetId = null; | |
if (this.mediaFrameElToInspect) { | |
targetId = this.mediaFrameElToInspect.components["media-frame"].data.targetId; | |
} else { | |
const mediaEls = [...document.querySelectorAll("[media-loader]")]; | |
for (const el of mediaEls) { | |
if (!el.components["media-loader"] || el.components["media-loader"].data.src !== this.srcUrlToInspect) continue; | |
targetId = `naf-${el.components["networked"].data.networkId}`; | |
break; | |
} | |
} | |
if (targetId && targetId !== "empty" && !this.cameraSystem.inspectable) { | |
const targetEl = document.getElementById(targetId); | |
if (targetEl) { | |
this.isInspecting = true; | |
this.cameraSystem.lightsEnabled = false; | |
this.lerpT = 0.0; | |
/* Delay due to lerping */ | |
setTimeout(() => { | |
this.cameraSystem.inspect(targetEl); | |
}, 500); | |
} | |
} else if (this.cameraSystem.inspectable && (!this.cameraSystem.inspectable.el || !this.cameraSystem.inspectable.el.parentElement || (targetId !== "empty" && this.cameraSystem.inspectable.el.id !== targetId))) { | |
this.cameraSystem.lightsEnabled = true; | |
this.cameraSystem.uninspect(); | |
this.isInspecting = false; | |
this.lerpT = 0.0; | |
} | |
} | |
} | |
updateLerp() { | |
const { avatarPov } = this; | |
let t; | |
if (this.lerpT >= this.lerpDuration * 2.0) { | |
if (!this.loop) { | |
t = 1.0; | |
} else { | |
t = (this.lerpT - this.lerpDuration * 2.0) / this.lerpDuration; | |
this.lerpT = 0.0; | |
} | |
} else if (this.lerpT >= this.lerpDuration) { | |
if (!this.loop) { | |
t = 1.0; | |
} else { | |
t = 1.0 - (this.lerpT - this.lerpDuration) / this.lerpDuration; | |
} | |
} else { | |
t = this.lerpT / this.lerpDuration; | |
} | |
const y = easeInOut(t); | |
avatarPov.object3D.matrixWorld.decompose(pos, rot, scale); | |
pos.x = (1 - y) * this.startPos.x + y * this.endPos.x; | |
pos.y = (1 - y) * this.startPos.y + y * this.endPos.y; | |
pos.z = (1 - y) * this.startPos.z + y * this.endPos.z; | |
if (this.trackUsername) { | |
if (this.lastFindTrackedUserAt === 0 || performance.now() - this.lastFindTrackedUserAt > 10000) { | |
this.lastFindTrackedUserAt = performance.now(); | |
this.trackedEl = [...document.querySelectorAll("[player-info]")].find(el => el.components["player-info"].displayName === this.trackUsername && el.components["networked"].data.owner !== NAF.clientId); | |
} | |
} | |
const avatarObj = avatarPov.object3D; | |
if (this.trackedEl) { | |
this.trackedEl.object3D.getWorldScale(v1); | |
const trackedScale = v1.x; | |
const height = 1.5 * trackedScale; | |
if (this.trackDistance) { | |
this.trackedEl.object3D.getWorldPosition(pos); | |
v2.set(0, 0.15, 1); | |
v2.transformDirection(this.trackedEl.querySelector("[ik-controller]").components["ik-controller"].head.matrixWorld); | |
v2.multiplyScalar(this.trackDistance * trackedScale); | |
pos.add(v2); | |
pos.y += height; | |
this.trackedEl.object3D.getWorldPosition(v2); | |
v2.y += height; | |
lookAtMatrix.lookAt(pos, v2, up); | |
rot.setFromRotationMatrix(lookAtMatrix); | |
} else { | |
this.trackedEl.object3D.getWorldPosition(v1); | |
avatarObj.getWorldPosition(v2); | |
v2.y -= height; | |
lookAtMatrix.lookAt(v2, v1, up); | |
rot.setFromRotationMatrix(lookAtMatrix); | |
} | |
} else { | |
rot.slerpQuaternions(this.endRot, this.startRot, 1 - y); | |
} | |
m1.compose(pos, rot, scale); | |
setMatrixWorld(avatarObj, m1); | |
} | |
} | |
function addSystem(system) { | |
const hubsSystems = scene.systems["hubs-systems"]; | |
const oldTick = hubsSystems.tick.bind(hubsSystems); | |
hubsSystems.tick = (function (t, dt) { | |
oldTick(t, dt); | |
system.tick(t, dt); | |
}).bind(hubsSystems); | |
} | |
if (!qs.get("director_lobby")) { | |
if (!scene.is("entered")) { | |
await new Promise(res => { | |
scene.addEventListener("stateadded", ({ detail }) => { | |
if (detail === "entered") res(); | |
}, { once: true }); | |
}); | |
} | |
} | |
/* Hide UI */ | |
scene.emit("action_toggle_ui"); | |
/* Click notif dismiss */ | |
setTimeout(() => [...document.querySelectorAll("button")].find(b => b.getAttribute("class").indexOf("dismiss-button")).click(), 500); | |
/* Disable idle detection */ | |
AFRAME.scenes[0].systems["exit-on-blur"].tick = () => {}; | |
setInterval(() => { | |
/* Hide UI and fake activity */ | |
window.dispatchEvent(new CustomEvent("activity_detected")); | |
[...document.querySelectorAll(".ui")].forEach(el => el.object3D.visible = false); | |
}, 1000); | |
/* Prevent freeze during inspect */ | |
AFRAME.scenes[0].removeAttribute("freeze-controller"); | |
const fov = parseInt(qs.get("director_fov") || "80"); | |
const camera = document.querySelector("#viewing-camera").components["camera"].camera; | |
camera.fov = fov; | |
camera.updateProjectionMatrix(); | |
if (!qs.get("director_lobby")) { | |
/* Look up the biggest media frame */ | |
const mediaFrames = [...document.querySelectorAll("[media-frame]")]; | |
let mediaFrameElToInspect = null, srcUrlToInspect = null; | |
if (qs.get("director_inspect_media_frame") === "true" && mediaFrames.length > 0) { | |
mediaFrameElToInspect = mediaFrames.map(el => | |
[el.components["media-frame"].data.bounds.x * | |
el.components["media-frame"].data.bounds.y, el]) | |
.sort(([l1, el1], [l2, el2]) => l2 - l1)[0][1] | |
} | |
if (!!qs.get("director_inspect_src")) { | |
srcUrlToInspect = qs.get("director_inspect_src"); | |
} | |
/* Disable character controller and input */ | |
AFRAME.scenes[0].systems["hubs-systems"].characterController.tick = () => {}; | |
AFRAME.scenes[0].systems.userinput.tick2 = () => {}; | |
/* Empty username */ | |
const avatarId = qs.get("director_avatar_id") || "9ioqyYv"; | |
window.APP.store.update({ profile: { displayName: " ", avatarId } }); | |
scene.emit("avatar_updated"); | |
const directorSystem = new DirectorSystem(mediaFrameElToInspect, srcUrlToInspect); | |
window.directorSystem = directorSystem; | |
addSystem(directorSystem); | |
/* Hide cursor */ | |
[...document.querySelectorAll("[cursor-controller]")].forEach(el => el.components["cursor-controller"].data.cursor.object3DMap.mesh.visible = false); | |
} | |
})(); |
Thank you @sagefreeman for your help! I'm using vanilla hubs - for example https://hubs.mozilla.com/ZbPnoCW/test-space
I opened your link and clicked the bookmarklet and got the same error 😭
I'm just not sure what could be going wrong! Thank you for trying to help!
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@jywarren Are you using a private Hubs Cloud or is this on the vanilla Hubs server? I was able to use the bookmarklet to capture the start and endpoint for the camera move. I then added a couple variables to the end and run it in Hubs.
https://hubs.mozilla.com/Zfo6mvD/soulful-awesome-exploration?director_start_xform=eyJlbGVtZW50cyI6WzAuOTk5MDk1MjA4NTUzMzA3OCwtMy4yMDMxMjIxMDMyMDcyMTllLTE1LDAuMDQyNTI5NTY5MDc2NTQxNjUsMCwwLjAwMDg0MDI2MzQ0OTI2MDkxNCwwLjk5OTgwNDgwODI4ODQ5MTIsLTAuMDE5NzM5MjgyNjc1NjY0NTA3LDAsLTAuMDQyNTIxMjY3NjU3MTY0NzQsMC4wMTk3NTcxNTg3ODM5NDMzOCwwLjk5ODkwMDE5MzQ0OTU4NTcsMCwwLjUzNjExMTcyODc4OTMxMDUsLTAuMDIwMDAwMDAwMDAwMDAwMDE4LDIzLjU4ODU1NTA1NTI1OTQ0NSwxXX0%3D&vr_entry_type=2d_now&director_end_xform=eyJlbGVtZW50cyI6WzAuOTk5ODA3MjQwNDgyMTMzMSwtMi4wNDY3OTg2NDE0ODM5NzkyZS0xNCwtMC4wMTk2MzM2OTI0NjA2NDk0MSwwLC0wLjAwMDk2OTQzMzcwNTUzODI0NjUsMC45OTg3ODAyNjAxNTUzMzUzLC0wLjA0OTM2NjUwODEwNDc3MjcsMCwwLjAxOTYwOTc0NDQ2MzY1NjE0NywwLjA0OTM3NjAyNTgwMzcwNTc1LDAuOTk4NTg3NzM1NzUzODU5NCwwLC0wLjA5OTUxMzAwNDY2MDY0OTE1LDIuODQ5OTg3MjU5NjgzMTgsLTYuNzg1NDE1MTMxMjEzODIzLDEuMDAwMDAwMDAwMDAwMDAwMl19&director_lerp_duration=10000&director_loop=false
Hubs-Director-Bookmarklet.mp4