-
-
Save gfodor/2ebdba84a49ba790bebe39aba2bee6ea to your computer and use it in GitHub Desktop.
| 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); | |
| } | |
| })(); |
Unfortunately it did the same on the Chromebook. I'll try doing it in incognito mode to eliminate extensions.
...no, same thing. Can you think of anything else I could do to debug how I'm using it? It's probably something really obvious, hmmm...
I also tested in Safari, and got the same thing. I counted equal #s of { and }, ( and ) so I don't think i'm missing any brackets or anything...
I found I was able to use it partially from the JS console, so that's getting me partway there, also confirming that the script itself seems OK... that is, I could run it to set the start and end points, and they seemed to check out from inspecting their values directly with
// before:
$ document.querySelector("#avatar-pov-node").object3D.matrixWorld.elements
(16) [0.7071067811865476, 0, -0.7071067811865476, 0, 0, 1, 0, 0, 0.7071067811865476, 0, 0.7071067811865476, 0, 1.8939339828220199, 1.76, 1.8939339828220234, 1]
$ encodeURIComponent(btoa(JSON.stringify({ elements: document.querySelector("#avatar-pov-node").object3D.matrixWorld.elements })));
'eyJlbGVtZW50cyI6WzAuNzA3MTA2NzgxMTg2NTQ3NiwwLC0wLjcwNzEwNjc4MTE4NjU0NzYsMCwwLDEsMCwwLDAuNzA3MTA2NzgxMTg2NTQ3NiwwLDAuNzA3MTA2NzgxMTg2NTQ3NiwwLDEuODkzOTMzOTgyODIyMDE5OSwxLjc2LDEuODkzOTMzOTgyODIyMDIzNCwxXX0%3D'
// after:
$ document.querySelector("#avatar-pov-node").object3D.matrixWorld.elements
(16) [0.7071067811865476, 0, -0.7071067811865476, 0, 0, 1, 0, 0, 0.7071067811865476, 0, 0.7071067811865476, 0, -3.2446529863192133, 1.76, -3.244652986319501, 1]
$ encodeURIComponent(btoa(JSON.stringify({ elements: document.querySelector("#avatar-pov-node").object3D.matrixWorld.elements })));
'eyJlbGVtZW50cyI6WzAuNzA3MTA2NzgxMTg2NTQ3NiwwLC0wLjcwNzEwNjc4MTE4NjU0NzYsMCwwLDEsMCwwLDAuNzA3MTA2NzgxMTg2NTQ3NiwwLDAuNzA3MTA2NzgxMTg2NTQ3NiwwLC0zLjI0NDY1Mjk4NjMxOTIxMzMsMS43NiwtMy4yNDQ2NTI5ODYzMTk1MDEsMV19'
But the third time I run it, i see Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'camera') on this line:
const camera = document.querySelector("#viewing-camera").components["camera"].camera;Of course it's not designed to be run from the console, but I couldn't think of any reason why it shouldn't work that way.
Sorry for all the comments, if I can figure it out I'm happy to delete them all. Just trying to be descriptive as I debug.
@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.
Hubs-Director-Bookmarklet.mp4
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!
Looking deeper, I tried eliminating comments due to this suggestion but it didn't help, i also tried reencoding in URL character codes with https://mrcoles.com/bookmarklet/, also didn't work. I still get the same
Uncaught SyntaxError: Unexpected end of input.I'm on Chrome on MacOS. I'll try it on a Chromebook (linux) next, just to see... thanks for helping me out!