fix(registry): six blocks load their own files and variables when mounted (#4452)

* feat(core): a composition's script can load its own files with __hyperframes.assetUrl

* test(core): a template composition inside a mounted file resolves assets from that file

* docs: assetUrl is for paths beside the composition, not project-root paths

* docs: say assetUrl misses a project-root path

* fix(core): a mounted composition's import map and module scripts run as such

* refactor(core): scope a mounted module script under the same condition as a classic one

* fix(core): a mounted module script loaded by src stays a module

* fix(core): a mounted import map keeps the trailing slash of a prefix address

* fix(registry): six blocks load their own files and variables when mounted

* fix(registry): keep frost's variable declarations on its html element

* fix(registry): orbit-card uses its own variables; frost font and catalog maps follow

* chore(core): keep the module-script helpers' comment share at the base

* fix(catalog): frost's payload runs its driving script, not the asset-base one

* test(catalog): check import-map keys by membership, not a built regex
This commit is contained in:
Miguel Ángel
2026-09-25 04:43:43 +00:00
committed by GitHub
parent f873734cc4
commit 53e2e20a15
9 changed files with 364 additions and 285 deletions
+18 -23
View File
@@ -106,16 +106,11 @@
document.getElementById("cpt-scene").appendChild(c);
})();
var V = Object.fromEntries(
JSON.parse(
document
.querySelector("[data-composition-variables]")
.getAttribute("data-composition-variables"),
).map((e) => [e.id, e.default]),
);
// getVariables carries the declared defaults, standalone and mounted.
var V = {};
try {
if (window.__hyperframes && window.__hyperframes.getVariables)
V = Object.assign(V, window.__hyperframes.getVariables() || {});
V = window.__hyperframes.getVariables() || {};
} catch (e) {
/* corrupt declarations must not blank the composition */
}
@@ -347,31 +342,26 @@
const W = 1920,
H = 1080,
DUR = 12;
let V = Object.fromEntries(
JSON.parse(
document
.querySelector("[data-composition-variables]")
.getAttribute("data-composition-variables"),
).map((e) => [e.id, e.default]),
);
// Bare __hyperframes is this composition's own in a module; getVariables carries the defaults.
let V = {};
try {
if (window.__hyperframes && window.__hyperframes.getVariables)
V = Object.assign(V, window.__hyperframes.getVariables() || {});
if (typeof __hyperframes !== "undefined" && __hyperframes.getVariables)
V = __hyperframes.getVariables() || {};
} catch (e) {
/* corrupt declarations must not blank the composition */
}
const HEADLINE1 = String(V.headline1 ?? "Understory");
const HEADLINE2 = String(V.headline2 ?? "MOVE SLOWLY");
const HEADLINE2 = String(V.headline2 ?? "Move slowly");
/* Headline typography is a PARAMETER — never a Design-panel edit. Any
Google Fonts family name works; falls back through Gelasio/serif
while (or if never) loading. */
const FONT = (String(V.font || "Gelasio").trim() || "Gelasio").replace(/["']/g, "");
const FONT = (String(V.font || "Helvetica").trim() || "Helvetica").replace(/["']/g, "");
const FONT_WEIGHT = Math.max(
100,
Math.min(
900,
Math.round(
isFinite(Number(V.fontWeight)) && Number(V.fontWeight) ? Number(V.fontWeight) : 500,
isFinite(Number(V.fontWeight)) && Number(V.fontWeight) ? Number(V.fontWeight) : 900,
),
),
);
@@ -386,7 +376,7 @@
);
const LETTER_SP = Math.max(
-0.2,
Math.min(1, isFinite(Number(V.letterSpacing)) ? Number(V.letterSpacing) : 0.1),
Math.min(1, isFinite(Number(V.letterSpacing)) ? Number(V.letterSpacing) : 0.01),
);
const FONT_STACK = '"' + FONT + '", Gelasio, Georgia, serif';
if (!/^(Helvetica|Arial|Gelasio|Georgia|serif|sans-serif|monospace)$/i.test(FONT)) {
@@ -650,10 +640,15 @@
return { map, bump };
}
// Beside this file when mounted; a runtime without assetUrl keeps the plain path.
const assetUrl = (path) =>
typeof __hyperframes !== "undefined" && __hyperframes.assetUrl
? __hyperframes.assetUrl(path)
: path;
const textureLoader = new THREE.TextureLoader();
const [photoSurface, photoNormal] = await Promise.all([
textureLoader.loadAsync("assets/leaf-surface-color.webp"),
textureLoader.loadAsync("assets/leaf-surface-normal.webp"),
textureLoader.loadAsync(assetUrl("assets/leaf-surface-color.webp")),
textureLoader.loadAsync(assetUrl("assets/leaf-surface-normal.webp")),
]);
photoSurface.colorSpace = THREE.SRGBColorSpace;
photoSurface.wrapS = photoSurface.wrapT = THREE.RepeatWrapping;
+6 -4
View File
@@ -12,7 +12,8 @@
{
"imports": {
"three": "./assets/three.module.min.js",
"three/addons/": "./assets/addons/"
"three/addons/": "./assets/addons/",
"cuboid-carousel/motion": "./assets/cuboid-motion.js"
}
}
</script>
@@ -103,12 +104,13 @@
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js";
import { MOTION } from "./assets/cuboid-motion.js";
import { MOTION } from "cuboid-carousel/motion";
const DUR = MOTION.endFrame / MOTION.fps;
// Bare __hyperframes is this composition's own in a module.
const V =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
typeof __hyperframes !== "undefined" && __hyperframes.getVariables
? __hyperframes.getVariables()
: {};
// ---- variable readers (every one clamped to the DialKit range) ----
@@ -8,6 +8,11 @@
<meta name="viewport" content="width=1920, height=1080" />
<title>Frost Sequence Rig</title>
<script src="assets/gsap-3.14.2.min.js"></script>
<script>
// frost.js reads its asset base when it loads; beside this file when mounted.
if (typeof __hyperframes !== "undefined" && __hyperframes.assetUrl)
window.__frostAssetBase = __hyperframes.assetUrl("assets/");
</script>
<script src="assets/frost.js"></script>
<style>
* {
@@ -2523,6 +2528,9 @@
? window.__hyperframes.getVariables()
: {}),
};
// The default logo ships beside this file; a logo the user sets is their project's path.
if (values.logoUrl === defaults.logoUrl && window.__frostAssetBase)
values.logoUrl = window.__frostAssetBase + "logo.svg";
window.Frost.setRendererProfile(values.rendererProfile);
console.info("[Frost renderer]", JSON.stringify(window.__rewrite));
const matcapKeys = [
@@ -3160,7 +3168,7 @@
...opts,
previewPixelRatio,
rig: { duration: DURATION, mode: values.assemblyMode, sequence: true, sample: sampleRig },
fontUrl: "assets/fonts/Geist-Bold.ttf",
fontUrl: (window.__frostAssetBase || "assets/") + "fonts/Geist-Bold.ttf",
onProgress: (msg) => {
stage.dataset.state = msg;
buildStatus.textContent = "Building Frost · " + msg;
@@ -100,6 +100,11 @@
if (vars.title) document.getElementById("lcw-title").textContent = vars.title;
if (vars.body) document.getElementById("lcw-body").textContent = vars.body;
// Beside this file when mounted; a runtime without assetUrl keeps the plain path.
const assetUrl = (path) =>
typeof __hyperframes !== "undefined" && __hyperframes.assetUrl
? __hyperframes.assetUrl(path)
: path;
// The character's body acting (walk, stop, point, idle) lives in the Lottie file.
// The runtime seeks every player on window.__hfLottie to composition time.
var mascot = lottie.loadAnimation({
@@ -107,7 +112,7 @@
renderer: "svg",
loop: false,
autoplay: false,
path: "assets/mascot.json",
path: assetUrl("assets/mascot.json"),
});
window.__hfLottie = window.__hfLottie || [];
window.__hfLottie.push(mascot);
+255 -249
View File
@@ -1,257 +1,263 @@
import * as THREE from "./three.module.min.js";
import { MOTION } from "./orbit-motion.js";
// Blender X/Y/Z -> Three X/Z/-Y. 100 original CSS pixels = one rig unit.
const V = window.__hyperframes?.getVariables?.() || {};
const number = (v, fallback) => (Number.isFinite(Number(v)) ? Number(v) : fallback);
const degrees = Math.max(-36000, Math.min(36000, number(V.cardOrbitDegrees, 360)));
const title = String(V.feature1Title || "Always in sync");
const description = String(V.feature1Desc || "Changes reach every screen the moment they happen.");
const accent = /^#[0-9a-f]{6}$/i.test(V.feature1Accent) ? V.feature1Accent : "#4cc9ff";
const stage = document.getElementById("oc-stage");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(1);
renderer.setSize(1920, 1080);
renderer.setClearColor("#161718");
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.domElement.setAttribute(
"aria-label",
"Orbit Card: approved Blender camera and card animation",
);
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
// Saved rig: 30 mm lens, 36 mm sensor, 1920 x 1080.
const camera = new THREE.PerspectiveCamera(
(2 * Math.atan(36 / (1920 / 1080) / 60) * 180) / Math.PI,
1920 / 1080,
0.1,
200,
);
scene.add(new THREE.HemisphereLight(0xffffff, 0x414753, 2.1));
const keyLight = new THREE.DirectionalLight(0xf0f5ff, 3.0);
keyLight.position.set(-5, 8, 8);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x8fbad2, 2.0);
rimLight.position.set(5, 3, -5);
scene.add(rimLight);
const sourceRig = new THREE.Group();
sourceRig.name = "Card_Rig_EDIT_ME";
scene.add(sourceRig);
const orbitPivot = new THREE.Group();
orbitPivot.name = "Card_Orbit_EDIT_ME";
orbitPivot.position.set(0, 0.94, 0);
sourceRig.add(orbitPivot);
const card = new THREE.Group();
card.position.copy(orbitPivot.position).multiplyScalar(-1);
orbitPivot.add(card);
function roundedRect(w, h, r) {
const s = new THREE.Shape(),
x = -w / 2,
y = -h / 2;
s.moveTo(x + r, y);
s.lineTo(x + w - r, y);
s.quadraticCurveTo(x + w, y, x + w, y + r);
s.lineTo(x + w, y + h - r);
s.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
s.lineTo(x + r, y + h);
s.quadraticCurveTo(x, y + h, x, y + h - r);
s.lineTo(x, y + r);
s.quadraticCurveTo(x, y, x + r, y);
return s;
}
const shape = roundedRect(4.4, 5.4, 0.24);
const hole = new THREE.Path();
hole.absarc(0, 0.94, 1.08, 0, Math.PI * 2, true);
shape.holes.push(hole);
const plateGeometry = new THREE.ExtrudeGeometry(shape, {
depth: 0.16,
bevelEnabled: true,
bevelSize: 0.025,
bevelThickness: 0.02,
bevelSegments: 3,
steps: 1,
curveSegments: 64,
});
plateGeometry.translate(0, 0, -0.08);
const plate = new THREE.Mesh(
plateGeometry,
new THREE.MeshStandardMaterial({ color: "#16191d", metalness: 0.32, roughness: 0.38 }),
);
plate.name = "Orbit_Card";
card.add(plate);
const outlineMaterial = new THREE.LineBasicMaterial({
color: "#58616b",
transparent: true,
opacity: 0.45,
});
const outline = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
shape.getPoints(96).map((p) => new THREE.Vector3(p.x, p.y, 0.103)),
),
outlineMaterial,
);
card.add(outline);
const apertureRim = new THREE.Mesh(
new THREE.TorusGeometry(1.08, 0.009, 6, 128),
new THREE.MeshBasicMaterial({ color: accent, transparent: true, opacity: 0.3 }),
);
apertureRim.position.set(0, 0.94, 0.103);
card.add(apertureRim);
// The aperture is open through the plate: no separate circular backplate.
const copyCanvas = document.createElement("canvas");
copyCanvas.width = 1440;
copyCanvas.height = 500;
const copyContext = copyCanvas.getContext("2d");
const copyTexture = new THREE.CanvasTexture(copyCanvas);
copyTexture.colorSpace = THREE.SRGBColorSpace;
copyTexture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
const copy = new THREE.Mesh(
new THREE.PlaneGeometry(3.6, 1.25),
new THREE.MeshBasicMaterial({
map: copyTexture,
transparent: true,
side: THREE.FrontSide,
depthWrite: false,
}),
);
copy.position.set(0, -1.525, 0.106);
card.add(copy);
function paintCopy() {
const g = copyContext,
k = 4;
g.clearRect(0, 0, 1440, 500);
g.fillStyle = accent;
g.fillRect(0, 10, 30 * k, 3 * k);
g.fillStyle = "#f0f3f5";
g.font = "600 108px Archivo";
g.fillText(title, 0, 165, 1440);
g.fillStyle = "#a4a8ad";
g.font = "400 66px Archivo";
const words = description.split(/\s+/);
let line = "",
y = 305;
for (const word of words) {
if (g.measureText(line + word).width > 1420 && line) {
g.fillText(line.trim(), 0, y);
y += 99;
line = "";
}
line += word + " ";
}
g.fillText(line.trim(), 0, y);
copyTexture.needsUpdate = true;
}
paintCopy();
// A sibling branch of the orbit control: the sphere retains its own spin.
const sphereTilt = new THREE.Group();
sphereTilt.name = "Sphere_Tilt_FROM_SOURCE";
sphereTilt.position.set(0, 0.94, 0);
sphereTilt.rotation.x = -0.32;
sourceRig.add(sphereTilt);
const sphere = new THREE.Group();
sphere.name = "Dot_Sphere_Proxy";
sphereTilt.add(sphere);
const dotGeometry = new THREE.IcosahedronGeometry(0.012, 0);
const dotMaterial = new THREE.MeshStandardMaterial({
color: "#ecf0f4",
roughness: 0.45,
emissive: "#c4d5df",
emissiveIntensity: 0.18,
});
const dots = new THREE.InstancedMesh(dotGeometry, dotMaterial, 640);
const dummy = new THREE.Object3D();
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < 640; i++) {
const y = 1 - (i / 639) * 2,
radius = Math.sqrt(Math.max(0, 1 - y * y)),
a = golden * i;
dummy.position.set(Math.cos(a) * radius, -y, Math.sin(a) * radius);
dummy.updateMatrix();
dots.setMatrixAt(i, dummy.matrix);
}
sphere.add(dots);
const clamp = (u) => Math.max(0, Math.min(1, u));
const smooth = (u) => u * u * (3 - 2 * u);
// Saved Blender F-curves: linear time handles, exact cubic value handles.
function channelAt(keys, frame) {
if (frame <= keys[0][0][0]) return keys[0][0][1];
for (let i = 1; i < keys.length; i++) {
const a = keys[i - 1],
b = keys[i];
if (frame <= b[0][0]) {
const u = (frame - a[0][0]) / (b[0][0] - a[0][0]),
v = 1 - u;
return a[3] === "LINEAR"
? a[0][1] * v + b[0][1] * u
: v ** 3 * a[0][1] + 3 * v * v * u * a[2][1] + 3 * v * u * u * b[1][1] + u ** 3 * b[0][1];
}
}
return keys[keys.length - 1][0][1];
}
// Camera positions are the saved rig's evaluated 30 fps export, not a new orbit.
function cameraAt(time) {
const f = Math.min(300, Math.max(0, time * 30)),
i = Math.min(299, Math.floor(f)),
u = f - i;
const a = MOTION.cameraPositions[i],
b = MOTION.cameraPositions[i + 1];
camera.position.set(
a[0] + (b[0] - a[0]) * u,
a[2] + (b[2] - a[2]) * u,
-a[1] - (b[1] - a[1]) * u,
// Built per call from the variables the mounting module passes in: an imported
// module only sees the page's __hyperframes, not its composition's.
export function createOrbitScene(V) {
// Blender X/Y/Z -> Three X/Z/-Y. 100 original CSS pixels = one rig unit.
const number = (v, fallback) => (Number.isFinite(Number(v)) ? Number(v) : fallback);
const degrees = Math.max(-36000, Math.min(36000, number(V.cardOrbitDegrees, 360)));
const title = String(V.feature1Title || "Always in sync");
const description = String(
V.feature1Desc || "Changes reach every screen the moment they happen.",
);
const frame = 1 + time * 30;
let aim = MOTION.aimKeys.at(-1)[1];
for (let j = 1; j < MOTION.aimKeys.length; j++) {
const a = MOTION.aimKeys[j - 1],
b = MOTION.aimKeys[j];
if (frame <= b[0]) {
aim = THREE.MathUtils.lerp(a[1], b[1], smooth(clamp((frame - a[0]) / (b[0] - a[0]))));
break;
}
const accent = /^#[0-9a-f]{6}$/i.test(V.feature1Accent) ? V.feature1Accent : "#4cc9ff";
const stage = document.getElementById("oc-stage");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(1);
renderer.setSize(1920, 1080);
renderer.setClearColor("#161718");
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.domElement.setAttribute(
"aria-label",
"Orbit Card: approved Blender camera and card animation",
);
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
// Saved rig: 30 mm lens, 36 mm sensor, 1920 x 1080.
const camera = new THREE.PerspectiveCamera(
(2 * Math.atan(36 / (1920 / 1080) / 60) * 180) / Math.PI,
1920 / 1080,
0.1,
200,
);
scene.add(new THREE.HemisphereLight(0xffffff, 0x414753, 2.1));
const keyLight = new THREE.DirectionalLight(0xf0f5ff, 3.0);
keyLight.position.set(-5, 8, 8);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x8fbad2, 2.0);
rimLight.position.set(5, 3, -5);
scene.add(rimLight);
const sourceRig = new THREE.Group();
sourceRig.name = "Card_Rig_EDIT_ME";
scene.add(sourceRig);
const orbitPivot = new THREE.Group();
orbitPivot.name = "Card_Orbit_EDIT_ME";
orbitPivot.position.set(0, 0.94, 0);
sourceRig.add(orbitPivot);
const card = new THREE.Group();
card.position.copy(orbitPivot.position).multiplyScalar(-1);
orbitPivot.add(card);
function roundedRect(w, h, r) {
const s = new THREE.Shape(),
x = -w / 2,
y = -h / 2;
s.moveTo(x + r, y);
s.lineTo(x + w - r, y);
s.quadraticCurveTo(x + w, y, x + w, y + r);
s.lineTo(x + w, y + h - r);
s.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
s.lineTo(x + r, y + h);
s.quadraticCurveTo(x, y + h, x, y + h - r);
s.lineTo(x, y + r);
s.quadraticCurveTo(x, y, x + r, y);
return s;
}
camera.lookAt(0, aim, 0);
}
const channels = MOTION.cardChannels;
let currentTime = 0;
export function renderAt(time) {
currentTime = Math.max(0, Math.min(10, time));
const frame = 1 + currentTime * 30;
const values = channels.map((c) => channelAt(c.keys, frame));
sourceRig.position.set(values[0], values[2], -values[1]);
sourceRig.rotation.y = values[3];
orbitPivot.rotation.y = THREE.MathUtils.degToRad(degrees) * smooth(clamp((frame - 150) / 30));
sphere.rotation.y = currentTime * 0.5;
sphere.scale.setScalar(channelAt(MOTION.sphereScale, frame));
cameraAt(currentTime);
renderer.render(scene, camera);
}
const shape = roundedRect(4.4, 5.4, 0.24);
const hole = new THREE.Path();
hole.absarc(0, 0.94, 1.08, 0, Math.PI * 2, true);
shape.holes.push(hole);
const plateGeometry = new THREE.ExtrudeGeometry(shape, {
depth: 0.16,
bevelEnabled: true,
bevelSize: 0.025,
bevelThickness: 0.02,
bevelSegments: 3,
steps: 1,
curveSegments: 64,
});
plateGeometry.translate(0, 0, -0.08);
const plate = new THREE.Mesh(
plateGeometry,
new THREE.MeshStandardMaterial({ color: "#16191d", metalness: 0.32, roughness: 0.38 }),
);
plate.name = "Orbit_Card";
card.add(plate);
const outlineMaterial = new THREE.LineBasicMaterial({
color: "#58616b",
transparent: true,
opacity: 0.45,
});
const outline = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
shape.getPoints(96).map((p) => new THREE.Vector3(p.x, p.y, 0.103)),
),
outlineMaterial,
);
card.add(outline);
const apertureRim = new THREE.Mesh(
new THREE.TorusGeometry(1.08, 0.009, 6, 128),
new THREE.MeshBasicMaterial({ color: accent, transparent: true, opacity: 0.3 }),
);
apertureRim.position.set(0, 0.94, 0.103);
card.add(apertureRim);
// The aperture is open through the plate: no separate circular backplate.
renderAt(0);
document.fonts.ready.then(() => {
const copyCanvas = document.createElement("canvas");
copyCanvas.width = 1440;
copyCanvas.height = 500;
const copyContext = copyCanvas.getContext("2d");
const copyTexture = new THREE.CanvasTexture(copyCanvas);
copyTexture.colorSpace = THREE.SRGBColorSpace;
copyTexture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
const copy = new THREE.Mesh(
new THREE.PlaneGeometry(3.6, 1.25),
new THREE.MeshBasicMaterial({
map: copyTexture,
transparent: true,
side: THREE.FrontSide,
depthWrite: false,
}),
);
copy.position.set(0, -1.525, 0.106);
card.add(copy);
function paintCopy() {
const g = copyContext,
k = 4;
g.clearRect(0, 0, 1440, 500);
g.fillStyle = accent;
g.fillRect(0, 10, 30 * k, 3 * k);
g.fillStyle = "#f0f3f5";
g.font = "600 108px Archivo";
g.fillText(title, 0, 165, 1440);
g.fillStyle = "#a4a8ad";
g.font = "400 66px Archivo";
const words = description.split(/\s+/);
let line = "",
y = 305;
for (const word of words) {
if (g.measureText(line + word).width > 1420 && line) {
g.fillText(line.trim(), 0, y);
y += 99;
line = "";
}
line += word + " ";
}
g.fillText(line.trim(), 0, y);
copyTexture.needsUpdate = true;
}
paintCopy();
renderAt(currentTime);
});
// Read-only inspection aid for the live workbench; no extra animation clock.
window.__orbitCardMotion = {
fps: 30,
duration: 10,
orbitFrames: [150, 180],
degrees,
get pose() {
const center = sphereTilt.getWorldPosition(new THREE.Vector3());
return {
frame: 1 + currentTime * 30,
camera: camera.position.toArray(),
cardOrbitDegrees: THREE.MathUtils.radToDeg(orbitPivot.rotation.y),
sphereCenter: center.toArray(),
sphereSpin: sphere.rotation.y,
};
},
};
// A sibling branch of the orbit control: the sphere retains its own spin.
const sphereTilt = new THREE.Group();
sphereTilt.name = "Sphere_Tilt_FROM_SOURCE";
sphereTilt.position.set(0, 0.94, 0);
sphereTilt.rotation.x = -0.32;
sourceRig.add(sphereTilt);
const sphere = new THREE.Group();
sphere.name = "Dot_Sphere_Proxy";
sphereTilt.add(sphere);
const dotGeometry = new THREE.IcosahedronGeometry(0.012, 0);
const dotMaterial = new THREE.MeshStandardMaterial({
color: "#ecf0f4",
roughness: 0.45,
emissive: "#c4d5df",
emissiveIntensity: 0.18,
});
const dots = new THREE.InstancedMesh(dotGeometry, dotMaterial, 640);
const dummy = new THREE.Object3D();
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < 640; i++) {
const y = 1 - (i / 639) * 2,
radius = Math.sqrt(Math.max(0, 1 - y * y)),
a = golden * i;
dummy.position.set(Math.cos(a) * radius, -y, Math.sin(a) * radius);
dummy.updateMatrix();
dots.setMatrixAt(i, dummy.matrix);
}
sphere.add(dots);
const clamp = (u) => Math.max(0, Math.min(1, u));
const smooth = (u) => u * u * (3 - 2 * u);
// Saved Blender F-curves: linear time handles, exact cubic value handles.
function channelAt(keys, frame) {
if (frame <= keys[0][0][0]) return keys[0][0][1];
for (let i = 1; i < keys.length; i++) {
const a = keys[i - 1],
b = keys[i];
if (frame <= b[0][0]) {
const u = (frame - a[0][0]) / (b[0][0] - a[0][0]),
v = 1 - u;
return a[3] === "LINEAR"
? a[0][1] * v + b[0][1] * u
: v ** 3 * a[0][1] + 3 * v * v * u * a[2][1] + 3 * v * u * u * b[1][1] + u ** 3 * b[0][1];
}
}
return keys[keys.length - 1][0][1];
}
// Camera positions are the saved rig's evaluated 30 fps export, not a new orbit.
function cameraAt(time) {
const f = Math.min(300, Math.max(0, time * 30)),
i = Math.min(299, Math.floor(f)),
u = f - i;
const a = MOTION.cameraPositions[i],
b = MOTION.cameraPositions[i + 1];
camera.position.set(
a[0] + (b[0] - a[0]) * u,
a[2] + (b[2] - a[2]) * u,
-a[1] - (b[1] - a[1]) * u,
);
const frame = 1 + time * 30;
let aim = MOTION.aimKeys.at(-1)[1];
for (let j = 1; j < MOTION.aimKeys.length; j++) {
const a = MOTION.aimKeys[j - 1],
b = MOTION.aimKeys[j];
if (frame <= b[0]) {
aim = THREE.MathUtils.lerp(a[1], b[1], smooth(clamp((frame - a[0]) / (b[0] - a[0]))));
break;
}
}
camera.lookAt(0, aim, 0);
}
const channels = MOTION.cardChannels;
let currentTime = 0;
function renderAt(time) {
currentTime = Math.max(0, Math.min(10, time));
const frame = 1 + currentTime * 30;
const values = channels.map((c) => channelAt(c.keys, frame));
sourceRig.position.set(values[0], values[2], -values[1]);
sourceRig.rotation.y = values[3];
orbitPivot.rotation.y = THREE.MathUtils.degToRad(degrees) * smooth(clamp((frame - 150) / 30));
sphere.rotation.y = currentTime * 0.5;
sphere.scale.setScalar(channelAt(MOTION.sphereScale, frame));
cameraAt(currentTime);
renderer.render(scene, camera);
}
renderAt(0);
document.fonts.ready.then(() => {
paintCopy();
renderAt(currentTime);
});
// Read-only inspection aid for the live workbench; no extra animation clock.
window.__orbitCardMotion = {
fps: 30,
duration: 10,
orbitFrames: [150, 180],
degrees,
get pose() {
const center = sphereTilt.getWorldPosition(new THREE.Vector3());
return {
frame: 1 + currentTime * 30,
camera: camera.position.toArray(),
cardOrbitDegrees: THREE.MathUtils.radToDeg(orbitPivot.rotation.y),
sphereCenter: center.toArray(),
sphereSpin: sphere.rotation.y,
};
},
};
return renderAt;
}
+8 -1
View File
@@ -74,8 +74,15 @@
<span style="font-weight: 400">Aa</span><span style="font-weight: 600">Aa</span>
</div>
</div>
<script type="importmap">
{ "imports": { "orbit-card/scene": "./assets/orbit-scene.js" } }
</script>
<script type="module">
import { renderAt } from "./assets/orbit-scene.js";
import { createOrbitScene } from "orbit-card/scene";
// Bare __hyperframes is this composition's own in a module.
const renderAt = createOrbitScene(
(typeof __hyperframes !== "undefined" && __hyperframes.getVariables?.()) || {},
);
const state = { time: 0 };
const timeline = gsap.timeline({ paused: true });
timeline.to(state, {
@@ -800,7 +800,14 @@
};
// ---------- font: local Geist Bold TTF -> three Font (blocks the load event) ----------
const ttfBuffer = await fetch("assets/fonts/Geist-Bold.ttf").then((r) => r.arrayBuffer());
// Beside this file when mounted; a runtime without assetUrl keeps the plain path.
const assetUrl = (path) =>
typeof __hyperframes !== "undefined" && __hyperframes.assetUrl
? __hyperframes.assetUrl(path)
: path;
const ttfBuffer = await fetch(assetUrl("assets/fonts/Geist-Bold.ttf")).then((r) =>
r.arrayBuffer(),
);
const font = new FontLoader().parse(new TTFLoader().parse(ttfBuffer));
try {
await document.fonts.ready; // DOM letter rects must be measured with the real font
+44 -1
View File
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
@@ -89,3 +89,46 @@ describe("glass-shard-title payload", () => {
});
});
});
describe("module blocks' catalog import map", () => {
const vendorUrls = Object.fromEntries(
[
"gsap-3.14.2.min",
"three.core.min",
"three.module.min",
"RoomEnvironment",
"BufferGeometryUtils",
].map((k) => [k, `https://cdn.example/${k}.js`]),
);
it("frost: runs the script that drives frost after frost.js, not an earlier inline script", () => {
const name = "frost-sequence-camera-orbit";
const dir = join("registry/blocks", name);
const out = inlineCatalogScripts(
name,
readFileSync(join(dir, `${name}.html`), "utf-8"),
dir,
vendorUrls,
);
const start = out.indexOf("<script>(function(){");
const bootstrap = out.slice(start, out.indexOf("})();</script>", start));
assert.match(bootstrap, /setRendererProfile/);
assert.doesNotMatch(out.replace(bootstrap, ""), /setRendererProfile/);
});
for (const name of ["cuboid-carousel", "orbit-card"]) {
it(`${name}: maps every specifier its entry module imports, with no import map of its own left`, () => {
const dir = join("registry/blocks", name);
const html = readFileSync(join(dir, `${name}.html`), "utf-8");
const entry = html.slice(html.indexOf('<script type="module">'));
const imported = [...entry.matchAll(/^\s*import [^;]*? from "([^"]+)"/gm)].flatMap(
(m) => m[1] ?? [],
);
assert.ok(imported.length > 0);
const out = inlineCatalogScripts(name, html, dir, vendorUrls);
const mapped = /imports:\{(.*?)\}\}\);/.exec(out)?.[1] ?? "";
const keys = [...mapped.matchAll(/(?:^|,)"?([^",:]+)"?:/g)].map((m) => m[1]);
for (const spec of imported) assert.ok(keys.includes(spec), spec);
assert.doesNotMatch(out, /<script type="importmap">/);
});
}
});
+10 -4
View File
@@ -206,7 +206,7 @@ function inlineFrostScripts(
// gsap/frost.js load: left in document order it would run before either is ready.
const { text: compositionScriptText, html: withoutComposition } = extractAndRemoveScript(
html.replace(`<script src="assets/gsap-3.14.2.min.js"></script>`, "__CATALOG_BOOTSTRAP__"),
/<script>(?=[\s\S]*?window\.__frostInstance)/,
/<script>(?=(?:(?!<\/script>)[\s\S])*?window\.__frostInstance)/,
"frost's composition script",
);
@@ -276,7 +276,7 @@ const bufferGeoUrl=await ${vendorFetchExpr(vendorUrl(vendorUrls, "BufferGeometry
const cuboidMotionUrl=${blobUrlExpr(jsStringLiteral(cuboidMotionText))};
const im=document.createElement("script");
im.type="importmap";
im.textContent=JSON.stringify({imports:{three:threeModuleUrl,"three/addons/environments/RoomEnvironment.js":roomEnvUrl,"three/addons/utils/BufferGeometryUtils.js":bufferGeoUrl,"./assets/cuboid-motion.js":cuboidMotionUrl}});
im.textContent=JSON.stringify({imports:{three:threeModuleUrl,"three/addons/environments/RoomEnvironment.js":roomEnvUrl,"three/addons/utils/BufferGeometryUtils.js":bufferGeoUrl,"cuboid-carousel/motion":cuboidMotionUrl}});
selfScript.after(im);
${precedingScriptEvals}
const entry=document.createElement("script");
@@ -299,8 +299,14 @@ function inlineOrbitScripts(
if (!gsapRe.test(html)) {
throw new Error("catalog-script-inlining: orbit-card's gsap script tag not found.");
}
// The bootstrap's import map replaces the block's own, which points at unhosted files.
const { html: withoutImportmap } = extractAndRemoveScript(
html,
/<script type="importmap">/,
"orbit-card's import map",
);
const { text: entryModuleText, html: withoutEntry } = extractAndRemoveScript(
html.replace(gsapRe, "__CATALOG_BOOTSTRAP__"),
withoutImportmap.replace(gsapRe, "__CATALOG_BOOTSTRAP__"),
/<script type="module">/,
"orbit-card's entry module script",
);
@@ -316,7 +322,7 @@ const orbitSceneText=${jsStringLiteral(orbitSceneText)}.split('"./three.module.m
const orbitSceneUrl=${blobUrlExpr("orbitSceneText")};
const im=document.createElement("script");
im.type="importmap";
im.textContent=JSON.stringify({imports:{"./assets/orbit-scene.js":orbitSceneUrl}});
im.textContent=JSON.stringify({imports:{"orbit-card/scene":orbitSceneUrl}});
selfScript.after(im);
const entry=document.createElement("script");
entry.type="module";