Skip to content

Instantly share code, notes, and snippets.

@gfodor
Last active May 12, 2024 17:02
Show Gist options
  • Save gfodor/2ebdba84a49ba790bebe39aba2bee6ea to your computer and use it in GitHub Desktop.
Save gfodor/2ebdba84a49ba790bebe39aba2bee6ea to your computer and use it in GitHub Desktop.
Hubs director bookmarklet
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);
}
})();
@jywarren
Copy link

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
Copy link

jywarren commented Aug 23, 2022

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