Setting the file. One moment. Cuboid Carousel · Cuboid Carousel · heygen-com/hyperframes · Skills Docs(opens in a new tab)
cuboid-carousel.html
HTML·876 lines·38 KB
1<!doctype html>
2<html
3 lang="en"
4 data-composition-variables='[{"id":"cardsJson","type":"string","label":"Content - Cards JSON (optional, see CARDS in source)","default":""},{"id":"heroCard","type":"number","label":"Content - Hero card (1-based)","default":4,"min":1,"max":12,"step":1},{"id":"count","type":"number","label":"Content - Repeating card count","default":8,"min":1,"max":12,"step":1},{"id":"gap","type":"number","label":"Layout - Extra gap between cuboids","default":0.01,"min":0,"max":1.5,"step":0.005},{"id":"cuboidWidth","type":"number","label":"Cuboid - Width","default":1.7,"min":0.3,"max":5,"step":0.01},{"id":"cuboidHeight","type":"number","label":"Cuboid - Height","default":2.4,"min":0.3,"max":6,"step":0.01},{"id":"cuboidDepth","type":"number","label":"Cuboid - Thickness (depth)","default":0.14,"min":0.02,"max":2,"step":0.01},{"id":"cornerRadius","type":"number","label":"Cuboid - Corner radius","default":0.105,"min":0,"max":0.6,"step":0.005},{"id":"bevelSize","type":"number","label":"Cuboid - Bevel size (edge chamfer)","default":0.03,"min":0,"max":0.3,"step":0.001},{"id":"bevelThickness","type":"number","label":"Cuboid - Bevel thickness","default":0.04,"min":0,"max":0.3,"step":0.001},{"id":"smoothness","type":"number","label":"Cuboid - Smoothness (segments)","default":32,"min":1,"max":32,"step":1},{"id":"bodyColor","type":"color","label":"Cuboid - Body colour","default":"#242424"},{"id":"roughness","type":"number","label":"Cuboid - Roughness","default":0.6,"min":0,"max":1,"step":0.01},{"id":"metalness","type":"number","label":"Cuboid - Metalness","default":0.4,"min":0,"max":1,"step":0.01},{"id":"heroScale","type":"number","label":"Hero - Isolated scale","default":1,"min":1,"max":1.6,"step":0.01},{"id":"heroFillIntensity","type":"number","label":"Hero - Face light intensity","default":0.55,"min":0,"max":2,"step":0.05,"description":"A soft fill on the detached face keeps its text readable without changing the original chain lighting."},{"id":"heroExitX","type":"number","label":"Hero - Exit X rotation degrees","default":288,"min":-720,"max":720,"step":1},{"id":"heroExitY","type":"number","label":"Hero - Exit Y rotation degrees","default":-234,"min":-720,"max":720,"step":1},{"id":"heroExitZ","type":"number","label":"Hero - Exit Z rotation degrees","default":198,"min":-720,"max":720,"step":1},{"id":"ambientIntensity","type":"number","label":"Lights - Ambient intensity","default":0.35,"min":0,"max":4,"step":0.01},{"id":"ambientColor","type":"color","label":"Lights - Ambient colour","default":"#ffffff"},{"id":"topIntensity","type":"number","label":"Lights - Top rig intensity","default":3.1,"min":0,"max":12,"step":0.05},{"id":"topColor","type":"color","label":"Lights - Top rig colour","default":"#ffffff"},{"id":"bottomIntensity","type":"number","label":"Lights - Bottom rig intensity","default":2.15,"min":0,"max":12,"step":0.05},{"id":"bottomColor","type":"color","label":"Lights - Bottom rig colour","default":"#ffcaad"},{"id":"keyIntensity","type":"number","label":"Lights - Key rig intensity","default":5,"min":0,"max":12,"step":0.05},{"id":"keyColor","type":"color","label":"Lights - Key rig colour","default":"#ffe9d6"},{"id":"shadowRadius","type":"number","label":"Shadows - Softness at map size 2048","default":44.5,"min":0,"max":50,"step":0.5},{"id":"shadowMapSize","type":"number","label":"Shadows - Map size","default":2048,"min":512,"max":4096,"step":256},{"id":"backdrop","type":"color","label":"Background - Colour","default":"#0b0d13"},{"id":"cameraFov","type":"number","label":"Camera - Initial Field of view","default":11,"min":10,"max":90,"step":0.5},{"id":"cameraX","type":"number","label":"Camera - Initial X","default":-1.65,"min":-20,"max":20,"step":0.05},{"id":"cameraY","type":"number","label":"Camera - Initial Y","default":8.5,"min":-20,"max":20,"step":0.05},{"id":"cameraZ","type":"number","label":"Camera - Initial Z (distance)","default":-8.508708,"min":-20,"max":50,"step":0.1},{"id":"cameraSettleFrame","type":"number","label":"Camera - Settle frame (30 fps)","default":70,"min":20,"max":90,"step":1,"description":"Fast-start ease-out along the saved route. Approved value: 70."},{"id":"handheldStrength","type":"number","label":"Camera - Handheld hold strength","default":1,"min":0,"max":1,"step":0.05,"description":"Camera-only motion during frames 90–150; 0 disables it, 1 matches Blender."},{"id":"cameraTargetFov","type":"number","label":"Camera target - Field of view","default":34,"min":10,"max":90,"step":0.5},{"id":"cameraTargetX","type":"number","label":"Camera target - X","default":-7.25,"min":-20,"max":20,"step":0.05},{"id":"cameraTargetY","type":"number","label":"Camera target - Y","default":1.15,"min":-20,"max":20,"step":0.05},{"id":"cameraTargetZ","type":"number","label":"Camera target - Z","default":6.2,"min":2,"max":50,"step":0.1}]'
5>
6 <head>
7 <meta charset="UTF-8" />
8 <meta name="viewport" content="width=1920, height=1080" />
9 <title>cuboid-carousel</title>
10 <script src="assets/gsap-3.14.2.min.js"></script>
11 <script type="importmap">
12 {
13 "imports": {
14 "three": "./assets/three.module.min.js",
15 "three/addons/": "./assets/addons/",
16 "cuboid-carousel/motion": "./assets/cuboid-motion.js"
17 }
18 }
19 </script>
20 <link rel="preconnect" href="https://fonts.googleapis.com" />
21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
22 <link
23 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap"
24 rel="stylesheet"
25 />
26 <style>
27 * {
28 margin: 0;
29 padding: 0;
30 box-sizing: border-box;
31 }
32 #cc-root {
33 position: relative;
34 width: 1920px;
35 height: 1080px;
36 background: #0b0d13;
37 overflow: hidden;
38 }
39 #cc-bg {
40 position: absolute;
41 inset: 0;
42 z-index: 0;
43 background: #0b0d13;
44 }
45 #cc-stage {
46 position: relative;
47 z-index: 1;
48 }
49 #cc-canvas {
50 display: block;
51 width: 1920px;
52 height: 1080px;
53 }
54 #cc-fontload {
55 position: absolute;
56 opacity: 0;
57 pointer-events: none;
58 font-family: "Inter", sans-serif;
59 }
60 </style>
61 </head>
62 <body>
63 <div
64 data-hf-id="hf-im3h"
65 id="cc-root"
66 data-composition-id="cuboid-carousel"
67 data-start="0"
68 data-duration="6.666666666666667"
69 data-width="1920"
70 data-height="1080"
71 >
72 <div data-hf-id="hf-y4po" id="cc-bg" aria-hidden="true"></div>
73 <section
74 data-hf-id="hf-xpi6"
75 id="cc-stage"
76 class="clip"
77 data-start="0"
78 data-duration="6.666666666666667"
79 data-track-index="1"
80 ></section>
81 <div data-hf-id="hf-0e46" id="cc-fontload" aria-hidden="true">
82 <span data-hf-id="hf-kqtg" style="font-weight: 400">Aa</span
83 ><span data-hf-id="hf-grhl" style="font-weight: 500">Aa</span>
84 </div>
85 </div>
86
87 <script>
88 /* Studio's variables parser fails on any composition with a <canvas> in
89 its MARKUP - created at runtime instead (identical for render). */
90 (function () {
91 var c = document.createElement("canvas");
92 c.id = "cc-canvas";
93 c.width = 1920;
94 c.height = 1080;
95 document.getElementById("cc-stage").appendChild(c);
96 })();
97 </script>
98 <script type="module">
99 /* Approved Blender choreography, 200 frames at 30 fps. Card geometry,
100 content textures and lighting remain the existing live block. Motion
101 uses saved native card curves and evaluated camera/handheld samples.
102 Frame 31: breakaway; 90–150: locked hero; 150–190: camera fly-through. */
103 import * as THREE from "three";
104 import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
105 import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js";
106
107 import { MOTION } from "cuboid-carousel/motion";
108
109 const DUR = MOTION.endFrame / MOTION.fps;
110 // Bare __hyperframes is this composition's own in a module.
111 const V =
112 typeof __hyperframes !== "undefined" && __hyperframes.getVariables
113 ? __hyperframes.getVariables()
114 : {};
115
116 // ---- variable readers (every one clamped to the DialKit range) ----
117 const num = (v, d, lo, hi) => {
118 const n = Number(v);
119 return isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d;
120 };
121 const col = (v, d) =>
122 typeof v === "string" && /^#[0-9a-fA-F]{3,8}$/.test(v.trim()) ? v.trim() : d;
123 const bool = (v, d) =>
124 typeof v === "boolean" ? v : v === "true" ? true : v === "false" ? false : d;
125 const pick = (v, opts, d) => (opts.indexOf(String(v)) >= 0 ? String(v) : d);
126
127 // cuboid
128 const WIDTH = num(V.cuboidWidth, 1.7, 0.3, 5);
129 const HEIGHT = num(V.cuboidHeight, 2.4, 0.3, 6);
130 const DEPTH = num(V.cuboidDepth, 0.16, 0.02, 2);
131 const CORNER_R = num(V.cornerRadius, 0.105, 0, 0.6);
132 const BEVEL_SIZE = num(V.bevelSize, 0.03, 0, 0.3);
133 const BEVEL_THICK = num(V.bevelThickness, 0.04, 0, 0.3);
134 const SMOOTH = Math.round(num(V.smoothness, 32, 1, 32));
135 const ROUGH = num(V.roughness, 0.6, 0, 1);
136 const METAL = num(V.metalness, 0.4, 0, 1);
137 const BODY = col(V.bodyColor, "#242424");
138 // layout
139 const COUNT = Math.round(num(V.count, 8, 1, 12));
140 const GAP = num(V.gap, 0.01, 0, 1.5);
141 // rotation
142 const AXIS = "x";
143 // hero
144 const HERO_SCALE = num(V.heroScale, 1, 1, 1.6);
145 // lights
146 const AMB_I = num(V.ambientIntensity, 0.35, 0, 4);
147 const AMB_C = col(V.ambientColor, "#ffffff");
148 const TOP_I = num(V.topIntensity, 3.1, 0, 12);
149 const TOP_C = col(V.topColor, "#ffffff");
150 const BOT_I = num(V.bottomIntensity, 2.15, 0, 12);
151 const BOT_C = col(V.bottomColor, "#ffcaad");
152 const KEY_I = num(V.keyIntensity, 5, 0, 12);
153 const KEY_C = col(V.keyColor, "#ffe9d6");
154 // shadows
155 const SHADOW_RADIUS = num(V.shadowRadius, 44.5, 0, 50);
156 const MAP_SIZE = Math.max(
157 512,
158 Math.min(4096, Math.round(num(V.shadowMapSize, 2048, 512, 4096) / 256) * 256),
159 );
160 // flat page background
161 const BACKDROP = col(V.backdrop, "#0b0d13");
162 // camera
163 const FOV = num(V.cameraFov, 65, 10, 90);
164 const CAM_X = num(V.cameraX, -0.010523080825805664, -20, 20);
165 const CAM_Y = num(V.cameraY, 0.5195326805114746, -20, 20);
166 const CAM_Z = num(V.cameraZ, -8.508708000183105, -20, 50);
167
168 const TARGET_FOV = num(V.cameraTargetFov, 34, 10, 90);
169 const TARGET_X = num(V.cameraTargetX, -7.25, -20, 20);
170 const TARGET_Y = num(V.cameraTargetY, 1.15, -20, 20);
171 const TARGET_Z = num(V.cameraTargetZ, 6.2, 2, 50);
172
173 /* ---- derived spacing: the no-overlap floor (see header) ---- */
174 const sweepHalf =
175 AXIS === "x"
176 ? WIDTH / 2
177 : AXIS === "y"
178 ? Math.hypot(WIDTH, DEPTH) / 2
179 : Math.hypot(WIDTH, HEIGHT) / 2;
180 const zSweepHalf =
181 AXIS === "x"
182 ? Math.hypot(HEIGHT, DEPTH) / 2
183 : AXIS === "y"
184 ? Math.hypot(WIDTH, DEPTH) / 2
185 : DEPTH / 2;
186 const SPACING = Math.max(WIDTH, 2 * sweepHalf + 0.02) + GAP;
187 const HALF_EXTENT = sweepHalf * HERO_SCALE;
188
189 /* ---- viewport maths (ported from viewportHalfWidth) ---- */
190 const ASPECT = 1920 / 1080;
191 const vFov = (FOV * Math.PI) / 180;
192 const hFov = 2 * Math.atan(Math.tan(vFov / 2) * ASPECT);
193 const VIEW_HALF = CAM_Z * Math.tan(hFov / 2);
194 /* Off-screen test. A cuboid reaches back to z = -zSweepHalf*heroScale,
195 where the frustum is WIDER than at z = 0, so culling on VIEW_HALF
196 alone would clip a corner that is still in shot. Measure at the
197 deepest point the geometry can reach. */
198 const EDGE =
199 (CAM_Z + zSweepHalf * HERO_SCALE) * Math.tan(hFov / 2) + Math.abs(CAM_X) + HALF_EXTENT; // |x| > EDGE = off-screen
200
201 /* ================= CONTENT - edit here =================
202 Each: { color, category, title, pills: [1-2 strings] }. The same array
203 can be supplied at render/preview time as JSON via the `cardsJson`
204 variable; anything invalid falls back to this list. */
205 const CARDS = [
206 {
207 color: "#fe552e",
208 category: "Creative requests",
209 title: "Identify key resources",
210 pills: ["In progress", "Jul 17, 2026"],
211 },
212 {
213 color: "#d98a1f",
214 category: "Creative requests",
215 title: "Develop concept art",
216 pills: ["In review", "Jul 20, 2026"],
217 },
218 {
219 color: "#4f6ef0",
220 category: "Product Development",
221 title: "Create Design Prototypes",
222 pills: ["In progress", "Aug 02, 2026"],
223 },
224 {
225 color: "#1f9d57",
226 category: "Project Management",
227 title: "Draft Project Timeline",
228 pills: ["Planned", "Aug 09, 2026"],
229 },
230 {
231 color: "#c026d3",
232 category: "Research",
233 title: "Run user interviews",
234 pills: ["Planned", "Aug 15, 2026"],
235 },
236 {
237 color: "#0891b2",
238 category: "Product Development",
239 title: "Ship the beta",
240 pills: ["Blocked", "Aug 22, 2026"],
241 },
242 {
243 color: "#e11d48",
244 category: "Creative requests",
245 title: "Cut the launch film",
246 pills: ["Planned", "Sep 01, 2026"],
247 },
248 {
249 color: "#7c3aed",
250 category: "Research",
251 title: "Synthesise findings",
252 pills: ["Planned", "Sep 08, 2026"],
253 },
254 ];
255 const FALLBACK_COLORS = [
256 "#fe552e",
257 "#d98a1f",
258 "#4f6ef0",
259 "#1f9d57",
260 "#c026d3",
261 "#0891b2",
262 "#e11d48",
263 "#7c3aed",
264 ];
265
266 /* cardsJson override: strict parse, silent fallback on any problem. */
267 let ITEMS = CARDS;
268 try {
269 const raw = String(V.cardsJson || "").trim();
270 if (raw) {
271 const parsed = JSON.parse(raw);
272 if (Array.isArray(parsed) && parsed.length >= 1 && parsed.length <= 24) {
273 const clean = parsed.map((e, i) => ({
274 color: typeof e.color === "string" ? e.color : FALLBACK_COLORS[i % 8],
275 category: typeof e.category === "string" ? e.category : "",
276 title: typeof e.title === "string" ? e.title : "",
277 pills: Array.isArray(e.pills) ? e.pills.slice(0, 2).map((x) => String(x)) : [],
278 }));
279 if (clean.every((e) => e.title)) ITEMS = clean;
280 }
281 }
282 } catch (e) {
283 /* keep CARDS */
284 }
285
286 const HERO = Math.max(0, Math.min(COUNT - 1, Math.round(num(V.heroCard, 4, 1, 12)) - 1));
287
288 const clamp01 = (x) => Math.max(0, Math.min(1, x));
289 const smooth01 = (x) => x * x * (3 - 2 * x);
290 const N = MOTION.tiles.length;
291 const CAMERA_SETTLE = num(V.cameraSettleFrame, 70, 20, 90);
292 const T_IN = CAMERA_SETTLE / 30,
293 T_BREAK = 31 / 30,
294 T_CENTER = 90 / 30;
295 const T_CLEAR = MOTION.tiles.find((tile) => !tile.hero).hideFrame / 30;
296 const T_OUT = 150 / 30,
297 T_EMPTY = 190 / 30;
298
299 const canvas = document.getElementById("cc-canvas");
300 const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
301 renderer.setPixelRatio(1);
302 renderer.setSize(1920, 1080, false);
303 renderer.shadowMap.enabled = true;
304 renderer.shadowMap.type = THREE.PCFSoftShadowMap;
305 renderer.outputColorSpace = THREE.SRGBColorSpace;
306
307 const scene = new THREE.Scene();
308 scene.background = new THREE.Color(BACKDROP);
309 document.getElementById("cc-bg").style.background = BACKDROP;
310
311 const camera = new THREE.PerspectiveCamera(FOV, ASPECT, 0.1, 200);
312 camera.position.set(CAM_X, CAM_Y, CAM_Z);
313 camera.lookAt(0, 0, 0);
314
315 // environment (stand-in for the `city` preset)
316 const pmrem = new THREE.PMREMGenerator(renderer);
317 const envRT = pmrem.fromScene(new RoomEnvironment(), 0.04);
318 const envMap = envRT.texture;
319
320 // ---- lights, exactly the experiment's rig ----
321 scene.add(new THREE.AmbientLight(new THREE.Color(AMB_C), AMB_I));
322
323 const top = new THREE.DirectionalLight(new THREE.Color(TOP_C), TOP_I);
324 top.position.set(20, 9, 3);
325 top.castShadow = true;
326 top.shadow.mapSize.set(MAP_SIZE, MAP_SIZE);
327 // "softness at the reference map size (2048)" - stable across mapSize
328 top.shadow.radius = SHADOW_RADIUS * (MAP_SIZE / 2048);
329 top.shadow.bias = -0.0003;
330 top.shadow.normalBias = 0.02;
331 {
332 const c = top.shadow.camera;
333 const halfW = Math.max(14, VIEW_HALF + SPACING * 3);
334 const halfH = Math.max(10, HEIGHT * 0.7 + 2);
335 c.left = -halfW;
336 c.right = halfW;
337 c.top = halfH;
338 c.bottom = -halfH;
339 c.near = 0.5;
340 c.far = 60;
341 c.updateProjectionMatrix();
342 }
343 scene.add(top);
344
345 const bottom = new THREE.DirectionalLight(new THREE.Color(BOT_C), BOT_I);
346 bottom.position.set(0, -8, 3);
347 scene.add(bottom);
348
349 const keyLight = new THREE.DirectionalLight(new THREE.Color(KEY_C), KEY_I);
350 keyLight.position.set(12, 9.2, 7);
351 scene.add(keyLight);
352
353 /* ---- rounded-rect shape + bevelled extrude, ported from
354 roundedRectShape() / buildCardGeometry() ---- */
355 function roundedRectShape(w, h, r) {
356 const s = new THREE.Shape();
357 const x = -w / 2,
358 y = -h / 2;
359 s.moveTo(x + r, y);
360 s.lineTo(x + w - r, y);
361 s.quadraticCurveTo(x + w, y, x + w, y + r);
362 s.lineTo(x + w, y + h - r);
363 s.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
364 s.lineTo(x + r, y + h);
365 s.quadraticCurveTo(x, y + h, x, y + h - r);
366 s.lineTo(x, y + r);
367 s.quadraticCurveTo(x, y, x + r, y);
368 s.closePath();
369 return s;
370 }
371
372 function buildCardGeometry() {
373 const outerR = Math.min(CORNER_R, Math.min(WIDTH, HEIGHT) / 2 - 0.001);
374 const r = Math.max(outerR, 0.002);
375 const bSize = Math.max(
376 0,
377 Math.min(BEVEL_SIZE, r * 0.98, WIDTH / 2 - 0.005, HEIGHT / 2 - 0.005),
378 );
379 const maxThick = Math.max(DEPTH * 0.45, 0.0005);
380 const bThick = Math.max(0, Math.min(BEVEL_THICK, maxThick));
381 const sizeOn = bSize > 0.0005,
382 thickOn = bThick > 0.0005;
383 const bevelOn = sizeOn || thickOn;
384 const innerW = sizeOn ? Math.max(WIDTH - 2 * bSize, 0.01) : WIDTH;
385 const innerH = sizeOn ? Math.max(HEIGHT - 2 * bSize, 0.01) : HEIGHT;
386 const innerR = sizeOn ? Math.max(r - bSize, 0.002) : r;
387 const extrudeDepth = thickOn ? Math.max(DEPTH - 2 * bThick, DEPTH * 0.15, 0.002) : DEPTH;
388
389 const shape = roundedRectShape(innerW, innerH, innerR);
390
391 // Cap UVs span the full face so the text texture lands square on it;
392 // side walls collapse to a single point, as in the source.
393 const uvGenerator = {
394 generateTopUV(_g, vertices, a, b, c) {
395 const p = (i) =>
396 new THREE.Vector2(
397 (vertices[i * 3] + WIDTH / 2) / WIDTH,
398 (vertices[i * 3 + 1] + HEIGHT / 2) / HEIGHT,
399 );
400 return [p(a), p(b), p(c)];
401 },
402 generateSideWallUV() {
403 return [
404 new THREE.Vector2(0, 0),
405 new THREE.Vector2(0, 0),
406 new THREE.Vector2(0, 0),
407 new THREE.Vector2(0, 0),
408 ];
409 },
410 };
411
412 const geo = new THREE.ExtrudeGeometry(shape, {
413 depth: extrudeDepth,
414 bevelEnabled: bevelOn,
415 bevelThickness: bThick,
416 bevelSize: bSize,
417 bevelSegments: Math.max(16, Math.round(SMOOTH * 2)),
418 curveSegments: Math.max(16, Math.round(SMOOTH)),
419 steps: 1,
420 UVGenerator: uvGenerator,
421 });
422 geo.center();
423 if (bevelOn) {
424 const merged = mergeVertices(geo, 1e-4);
425 geo.dispose();
426 merged.computeVertexNormals();
427 merged.computeBoundingSphere();
428 return merged;
429 }
430 geo.computeVertexNormals();
431 geo.computeBoundingSphere();
432 return geo;
433 }
434
435 /* ---- face texture, ported from face-renderer.ts (540x756).
436 The source clears to transparent and lets the body colour show
437 through a masking shader; painting the body colour in first gives
438 the same result without the extra pass. ---- */
439 const FACE_W = 540,
440 FACE_H = 756;
441 const FONT_STACK = '"Inter", -apple-system, system-ui, sans-serif';
442
443 function lightTint(hex, amount = 0.55) {
444 return new THREE.Color(hex).lerp(new THREE.Color("#ffffff"), amount).getStyle();
445 }
446 function roundedRectPath(ctx, x, y, w, h, r) {
447 const radius = Math.min(r, w / 2, h / 2);
448 ctx.beginPath();
449 ctx.moveTo(x + radius, y);
450 ctx.arcTo(x + w, y, x + w, y + h, radius);
451 ctx.arcTo(x + w, y + h, x, y + h, radius);
452 ctx.arcTo(x, y + h, x, y, radius);
453 ctx.arcTo(x, y, x + w, y, radius);
454 ctx.closePath();
455 }
456 function wrapLines(ctx, text, maxWidth) {
457 const words = String(text).split(" ");
458 const lines = [];
459 let current = "";
460 for (const word of words) {
461 const candidate = current ? current + " " + word : word;
462 if (ctx.measureText(candidate).width > maxWidth && current) {
463 lines.push(current);
464 current = word;
465 } else current = candidate;
466 }
467 if (current) lines.push(current);
468 return lines;
469 }
470
471 function faceTexture(item) {
472 const c = document.createElement("canvas");
473 c.width = FACE_W;
474 c.height = FACE_H;
475 const ctx = c.getContext("2d");
476 ctx.fillStyle = BODY;
477 ctx.fillRect(0, 0, FACE_W, FACE_H);
478
479 const tint = lightTint(item.color);
480 const pad = 45;
481 const contentW = FACE_W - pad * 2;
482 let y = pad + 8;
483
484 ctx.textBaseline = "alphabetic";
485 ctx.textAlign = "left";
486
487 ctx.fillStyle = tint;
488 ctx.font = "400 25px " + FONT_STACK;
489 ctx.fillText(item.category, pad, y + 22);
490 y += 22 + 34;
491
492 ctx.fillStyle = "#ffffff";
493 ctx.font = "500 38px " + FONT_STACK;
494 for (const line of wrapLines(ctx, item.title, contentW)) {
495 ctx.fillText(line, pad, y + 34);
496 y += 47;
497 }
498
499 ctx.font = "400 19px " + FONT_STACK;
500 const pillH = 50;
501 const pillY = FACE_H - pad - pillH;
502 let pillX = pad;
503 for (const label of item.pills) {
504 const pillW = ctx.measureText(label).width + 44;
505 roundedRectPath(ctx, pillX, pillY, pillW, pillH, pillH / 2);
506 ctx.strokeStyle = tint;
507 ctx.lineWidth = 1.4;
508 ctx.stroke();
509 ctx.fillStyle = tint;
510 ctx.textBaseline = "middle";
511 ctx.fillText(label, pillX + 22, pillY + pillH / 2 + 1);
512 ctx.textBaseline = "alphabetic";
513 pillX += pillW + 15;
514 }
515
516 const tex = new THREE.CanvasTexture(c);
517 tex.flipY = false;
518 tex.colorSpace = THREE.SRGBColorSpace;
519 tex.anisotropy = 8;
520 tex.minFilter = THREE.LinearMipmapLinearFilter;
521 tex.magFilter = THREE.LinearFilter;
522 return tex;
523 }
524
525 // ---- build the chain ----
526 const geometry = buildCardGeometry();
527 const sideMat = new THREE.MeshStandardMaterial({
528 color: new THREE.Color(BODY),
529 roughness: ROUGH,
530 metalness: METAL,
531 envMap,
532 envMapIntensity: 0.3,
533 });
534 const EULER_ORDER = AXIS === "x" ? "XYZ" : AXIS === "y" ? "YXZ" : "ZXY";
535
536 let capMats = [];
537 let heroMat = null;
538 let cuboids = [];
539 function buildChain() {
540 if (heroMat) heroMat.dispose();
541 capMats.forEach((m) => {
542 if (m.map) m.map.dispose();
543 m.dispose();
544 });
545 capMats = ITEMS.map((item) => {
546 const capMat = new THREE.MeshStandardMaterial({
547 map: faceTexture(item),
548 roughness: ROUGH,
549 metalness: METAL,
550 envMap,
551 envMapIntensity: 0,
552 });
553 /* The source masks content to the front cap with a smoothstep on
554 position.z (uHalfDepth / uCapBand). Same idea: the back cap drops
555 the map and shows plain body colour, so text never reads mirrored. */
556 capMat.onBeforeCompile = (shader) => {
557 shader.uniforms.uBody = { value: new THREE.Color(BODY) };
558 shader.vertexShader = shader.vertexShader
559 .replace("#include <common>", "#include <common>\nvarying float vLocalZ;")
560 .replace("#include <begin_vertex>", "#include <begin_vertex>\nvLocalZ = position.z;");
561 shader.fragmentShader = shader.fragmentShader
562 .replace(
563 "#include <common>",
564 "#include <common>\nvarying float vLocalZ;\nuniform vec3 uBody;",
565 )
566 .replace(
567 "#include <map_fragment>",
568 "#include <map_fragment>\nif (vLocalZ > 0.0) diffuseColor.rgb = uBody;",
569 )
570 .replace(
571 "#include <emissivemap_fragment>",
572 "#include <emissivemap_fragment>\nif (vLocalZ > 0.0) totalEmissiveRadiance = vec3(0.0);",
573 );
574 };
575 return capMat;
576 });
577 // A separate material avoids brightening repeated cards with the same
578 // content. Emissive fill is masked off the unprinted back cap as well.
579 const heroSource = capMats[HERO % ITEMS.length];
580 heroMat = heroSource.clone();
581 heroMat.onBeforeCompile = heroSource.onBeforeCompile;
582 heroMat.emissive.set(0xffffff);
583 heroMat.emissiveMap = heroSource.map;
584 heroMat.emissiveIntensity = 0;
585 if (cuboids.length === 0) {
586 for (let j = 0; j < N; j++) {
587 // ExtrudeGeometry group 0 = caps, group 1 = side walls
588 const mesh = new THREE.Mesh(geometry, [capMats[0], sideMat]);
589 mesh.rotation.order = EULER_ORDER;
590 mesh.castShadow = true;
591 mesh.receiveShadow = true;
592 scene.add(mesh);
593 cuboids.push(mesh);
594 }
595 }
596 }
597 buildChain();
598
599 // Blender frame 0 maps to web time 0. Every pose is reconstructed on seek.
600 const basis = new THREE.Quaternion().setFromAxisAngle(
601 new THREE.Vector3(1, 0, 0),
602 -Math.PI / 2,
603 );
604 const inverseBasis = basis.clone().invert();
605 const sourceRoot = new THREE.Matrix4().set(...MOTION.rootMatrix);
606 const convertedRoot = new THREE.Matrix4()
607 .makeRotationFromQuaternion(basis)
608 .multiply(sourceRoot)
609 .multiply(new THREE.Matrix4().makeRotationFromQuaternion(inverseBasis));
610 const motionRoot = new THREE.Group();
611 motionRoot.name = "Blender approved Cuboid chain";
612 motionRoot.matrixAutoUpdate = false;
613 motionRoot.matrix.copy(convertedRoot);
614 scene.add(motionRoot);
615 for (const mesh of cuboids) motionRoot.add(mesh);
616 const heroSlot = MOTION.tiles.findIndex((tile) => tile.hero);
617 const sourceStart = new THREE.Vector3(...MOTION.cameraStart).applyQuaternion(basis);
618 const sourceEnd = new THREE.Vector3(...MOTION.cameraEnd).applyQuaternion(basis);
619 const startOffset = new THREE.Vector3(CAM_X, CAM_Y, CAM_Z).sub(sourceStart);
620 const endOffset = new THREE.Vector3(TARGET_X, TARGET_Y, TARGET_Z).sub(sourceEnd);
621 const hasCameraOffset = startOffset.lengthSq() + endOffset.lengthSq() > 1e-14;
622 const targetCamera = new THREE.Vector3(TARGET_X, TARGET_Y, TARGET_Z);
623 const samplePosition = new THREE.Vector3(),
624 basePosition = new THREE.Vector3();
625 const sampleQuaternion = new THREE.Quaternion(),
626 baseQuaternion = new THREE.Quaternion();
627 const qA = new THREE.Quaternion(),
628 qB = new THREE.Quaternion(),
629 qDelta = new THREE.Quaternion();
630 const poseEuler = new THREE.Euler(0, 0, 0, "ZYX");
631 const lookCamera = new THREE.PerspectiveCamera();
632 const zero = new THREE.Vector3();
633 const S = { t: 0 };
634 const HERO_FILL = num(V.heroFillIntensity, 0.55, 0, 2);
635 const HANDHELD = num(V.handheldStrength, 1, 0, 1);
636 const EXIT_ANGLES = [
637 num(V.heroExitX, 288, -720, 720),
638 num(V.heroExitY, -234, -720, 720),
639 num(V.heroExitZ, 198, -720, 720),
640 ];
641 const sourceExitAngles = [288, -234, 198];
642
643 function channelAt(keys, frame) {
644 if (!keys || !keys.length) return 0;
645 if (frame <= keys[0][0]) return keys[0][1];
646 let lo = 0,
647 hi = keys.length - 1;
648 if (frame >= keys[hi][0]) return keys[hi][1];
649 while (hi - lo > 1) {
650 const mid = (lo + hi) >> 1;
651 if (keys[mid][0] <= frame) lo = mid;
652 else hi = mid;
653 }
654 const a = keys[lo],
655 b = keys[hi],
656 u = (frame - a[0]) / (b[0] - a[0]);
657 return a[1] + (b[1] - a[1]) * u;
658 }
659 function readCamera(frame, position, quaternion) {
660 const f = Math.max(0, Math.min(MOTION.endFrame, frame));
661 const i = Math.floor(f),
662 j = Math.min(MOTION.endFrame, i + 1),
663 u = f - i;
664 const a = MOTION.camera[i],
665 b = MOTION.camera[j];
666 position
667 .set(a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u)
668 .applyQuaternion(basis);
669 qA.set(a[3], a[4], a[5], a[6]);
670 qB.set(b[3], b[4], b[5], b[6]);
671 // Camera optical axes are already -Z forward / +Y up: convert world basis only.
672 quaternion.slerpQuaternions(qA, qB, u).premultiply(basis).normalize();
673 return a[7] + (b[7] - a[7]) * u;
674 }
675 function cameraAt(frame) {
676 const cameraFrame =
677 frame < CAMERA_SETTLE ? (frame * 70) / CAMERA_SETTLE : Math.max(70, frame);
678 const sourceFov = readCamera(cameraFrame, samplePosition, sampleQuaternion);
679 const inHandheld = frame > 90 && frame < 150;
680 if (inHandheld) {
681 readCamera(90, basePosition, baseQuaternion);
682 if (HANDHELD !== 1) {
683 samplePosition.lerpVectors(basePosition, samplePosition, HANDHELD);
684 sampleQuaternion.slerpQuaternions(baseQuaternion, sampleQuaternion, HANDHELD);
685 }
686 } else {
687 basePosition.copy(samplePosition);
688 baseQuaternion.copy(sampleQuaternion);
689 }
690 const u = clamp01(
691 (MOTION.cameraControls["Start FOV degrees"] - sourceFov) /
692 (MOTION.cameraControls["Start FOV degrees"] - MOTION.cameraControls["End FOV degrees"]),
693 );
694 // A changed start must not recompute the approved settled/handheld pose.
695 if (hasCameraOffset && (u < 1 || endOffset.lengthSq() > 1e-14)) {
696 qDelta.copy(baseQuaternion).invert().multiply(sampleQuaternion);
697 const offset = startOffset.clone().lerp(endOffset, u);
698 lookCamera.position.copy(basePosition).add(offset);
699 lookCamera.lookAt(zero);
700 sampleQuaternion.copy(lookCamera.quaternion).multiply(qDelta);
701 samplePosition.add(offset);
702 }
703 camera.position.copy(samplePosition);
704 camera.quaternion.copy(sampleQuaternion);
705 camera.fov = FOV === 65 && TARGET_FOV === 34 ? sourceFov : FOV + (TARGET_FOV - FOV) * u;
706 camera.updateProjectionMatrix();
707 camera.updateMatrixWorld();
708 }
709 const heroInitialX = MOTION.tiles[heroSlot].channels.location[0][0][1];
710 const slotOffsets = MOTION.tiles.map(
711 (tile) => (tile.channels.location[0][0][1] - heroInitialX) / 1.73,
712 );
713 // One mesh owns the hero slot before and after separation. Its arrival
714 // must use the same transport as the constant-rate chain, not the old
715 // slower Blender arrival curve. Keep that transport while it first lifts
716 // clear, then rejoin the approved absolute path without a position jump.
717 const heroXKeys = MOTION.tiles[heroSlot].channels.location[0];
718 const chainXKeys = MOTION.tiles.find((tile) => !tile.hero).channels.location[0];
719 const HERO_SLOT_RELEASE = 36,
720 HERO_PATH_REJOIN = 70;
721 const heroSlotXAt = (frame) => heroInitialX + channelAt(chainXKeys, frame) - chainXKeys[0][1];
722 const heroSlotDelta =
723 heroSlotXAt(HERO_SLOT_RELEASE) - channelAt(heroXKeys, HERO_SLOT_RELEASE);
724 const heroSlotVelocityDelta =
725 (heroSlotXAt(HERO_SLOT_RELEASE) -
726 heroSlotXAt(HERO_SLOT_RELEASE - 0.25) -
727 channelAt(heroXKeys, HERO_SLOT_RELEASE) +
728 channelAt(heroXKeys, HERO_SLOT_RELEASE - 0.25)) /
729 0.25;
730 function heroSlotCorrection(frame) {
731 if (frame <= HERO_SLOT_RELEASE) return heroSlotXAt(frame) - channelAt(heroXKeys, frame);
732 if (frame >= HERO_PATH_REJOIN) return 0;
733 const duration = HERO_PATH_REJOIN - HERO_SLOT_RELEASE;
734 const u = (frame - HERO_SLOT_RELEASE) / duration,
735 u2 = u * u,
736 u3 = u2 * u;
737 return (
738 (2 * u3 - 3 * u2 + 1) * heroSlotDelta +
739 (u3 - 2 * u2 + u) * duration * heroSlotVelocityDelta
740 );
741 }
742 const heroExitBase = MOTION.tiles[heroSlot].channels.rotation_euler.map((keys) =>
743 channelAt(keys, 150),
744 );
745 const actualPose = { frame: 0, camera: [], hero: [], heroRotation: [], visibleTiles: 0 };
746 function renderAt(time) {
747 S.t = Math.max(0, Math.min(DUR, time));
748 const frame = S.t * MOTION.fps;
749 cameraAt(frame);
750 const heroProgress = smooth01(clamp01((frame - 31) / 59));
751 heroMat.emissiveIntensity = HERO_FILL * heroProgress;
752 let visibleTiles = 0;
753 for (let j = 0; j < cuboids.length; j++) {
754 const mesh = cuboids[j],
755 tile = MOTION.tiles[j],
756 c = tile.channels;
757 const p = c.location.map((keys) => channelAt(keys, frame));
758 if (tile.hero) p[0] += heroSlotCorrection(frame);
759 const r = c.rotation_euler.map((keys) => channelAt(keys, frame));
760 if (tile.hero && frame > 150) {
761 for (let axis = 0; axis < 3; axis++)
762 r[axis] =
763 heroExitBase[axis] +
764 ((r[axis] - heroExitBase[axis]) * EXIT_ANGLES[axis]) / sourceExitAngles[axis];
765 }
766 mesh.position.set(p[0], p[2], -p[1]);
767 if (!tile.hero) mesh.position.x += slotOffsets[j] * (SPACING - 1.73);
768 // Blender XYZ is extrinsic X→Y→Z; Three's matching Euler order is ZYX.
769 poseEuler.set(r[0], r[1], r[2], "ZYX");
770 mesh.quaternion.setFromEuler(poseEuler).premultiply(basis).multiply(inverseBasis);
771 const scale = c.scale.map((keys) => channelAt(keys, frame));
772 mesh.scale.set(scale[0], scale[2], scale[1]);
773 if (tile.hero) mesh.scale.multiplyScalar(1 + (HERO_SCALE - 1) * heroProgress);
774 const item = ((((tile.itemIndex + HERO - 3) % COUNT) + COUNT) % COUNT) % ITEMS.length;
775 mesh.material[0] = tile.hero && frame >= 31 ? heroMat : capMats[item];
776 mesh.visible = frame < tile.hideFrame;
777 if (mesh.visible) visibleTiles++;
778 if (tile.hero) {
779 actualPose.hero = mesh.position.toArray();
780 actualPose.heroRotation = r;
781 }
782 }
783 actualPose.frame = frame;
784 actualPose.camera = camera.position.toArray();
785 actualPose.visibleTiles = visibleTiles;
786 renderer.toneMappingExposure = 1;
787 renderer.render(scene, camera);
788 }
789 window.__cuboidMotion = {
790 source: MOTION.sourceFile,
791 sha256: MOTION.sourceSha256,
792 fps: MOTION.fps,
793 endFrame: MOTION.endFrame,
794 phases: {
795 breakaway: 31,
796 directApproach: 40,
797 centered: 90,
798 exit: 150,
799 clear: 190,
800 end: 200,
801 },
802 get pose() {
803 return { ...actualPose };
804 },
805 };
806
807 /* ---- timeline: paused, seek-safe, no rAF ---- */
808 window.__timelines = window.__timelines || {};
809 const tl = gsap.timeline({ paused: true });
810 tl.addLabel("arrival", 0);
811 tl.addLabel("alternate-camera", T_IN);
812 tl.addLabel("hero-breakaway", T_BREAK);
813 tl.addLabel("hero-centered", T_CENTER);
814 tl.addLabel("solo-hold", T_CLEAR);
815 tl.addLabel("hero-exit", T_OUT);
816 tl.addLabel("empty", T_EMPTY);
817 // fromTo: the start is fixed, not whatever warmGpu last left in S.t when the tween first renders.
818 tl.fromTo(S, { t: 0 }, { t: DUR, duration: DUR, ease: "none" }, 0);
819 tl.eventCallback("onUpdate", () => renderAt(S.t));
820
821 /* Studio's player seeks with GSAP events suppressed and dispatches an
822 hf-seek CustomEvent instead - re-sync and repaint so the stage follows. */
823 window.addEventListener("hf-seek", function (event) {
824 const d = event && event.detail;
825 if (d && typeof d.time === "number")
826 tl.totalTime(Math.min(d.time, tl.totalDuration()), true);
827 const fn = tl.eventCallback("onUpdate");
828 if (fn) fn();
829 });
830 window.__timelines["cuboid-carousel"] = tl;
831
832 renderAt(0);
833 /* Draw across the timeline and flush the GPU before ready, so shader links
834 and texture uploads land in the readiness gate, not the first visible frames. */
835 async function warmGpu() {
836 for (const f of [0.25, 0.5, 0.75]) {
837 await new Promise((resolve) => setTimeout(resolve, 0));
838 renderAt(DUR * f);
839 }
840 renderAt(tl.time());
841 renderer.getContext().finish();
842 }
843 window.__hf = window.__hf || {};
844 window.__hf.buildReady = window.__hf.buildReady || {};
845 window.__hf.buildReady["cuboid-carousel"] = (
846 document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()
847 )
848 .then(() => {
849 buildChain();
850 renderAt(S.t);
851 return warmGpu();
852 })
853 .catch(() => {});
854 </script>
855 <script>
856 /* Standalone playback: open this file with ?play (add &loop to repeat).
857 Inert without the param — Studio and the renderer never add it, so
858 editing and deterministic renders are unaffected. */
859 (function () {
860 var q = new URLSearchParams(location.search);
861 if (!q.has("play")) return;
862 var tick = setInterval(function () {
863 var map = window.__timelines || {};
864 var ids = Object.keys(map);
865 if (!ids.length) return;
866 clearInterval(tick);
867 for (var i = 0; i < ids.length; i++) {
868 var tl = map[ids[i]];
869 if (q.has("loop")) tl.repeat(-1);
870 tl.play(0);
871 }
872 }, 100);
873 })();
874 </script>
875 </body>
876</html>