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