mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
+18
-23
@@ -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
@@ -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) ----
|
||||
|
||||
+9
-1
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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">/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user