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);
}
})();
@sagefreeman
Copy link

Hi Greg, thank you for sharing this bookmarklet. I appreciate your shared knowledge and support for a free and open internet.

@jywarren
Copy link

Hi, I tried adding this as a bookmarklet and running it, but got Uncaught SyntaxError: Unexpected end of input when i ran it, and the page did not refresh. I had simply pasted it into the URL field, did I do something wrong?

image

Thank you, very excited to use this!!

@sagefreeman
Copy link

Hi Jeffrey, I just tested this and it seems to be working without errors. I am using Chrome and adding the bookmarklet to the bookmark bar. If you are getting syntax errors make sure you copy/paste the entire code.

Screen Shot 2022-08-20 at 4 29 00 PM

@jywarren
Copy link

Thank you very much for responding so fast! Trying again now.

OK, so what I think has happened is that some part of the UI maybe can't handle so much text? See how I get an ellipse at the end? When I copy that and paste it again somewhere else, the ellipse stays. I'll research this, it must affect all long bookmarklets in ? some version of mac os?

image

@jywarren
Copy link

Hmm, sorry, maybe that was a false lead - when I edit in the Bookmark Manager, i see the full string.

image

@jywarren
Copy link

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!

@jywarren
Copy link

jywarren commented Aug 23, 2022

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...

@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