diff --git a/README.md b/README.md
index 75015f0..5384e16 100644
--- a/README.md
+++ b/README.md
@@ -67,22 +67,25 @@ Plus two audiences beyond the operator:
### Landing clip engine
The landing page is a scroll-driven story built on [anime.js](https://animejs.com)
-v4 and [three.js](https://threejs.org), in the same style as the Spectrum Web Co
-engine. A pinned WebGL stage (`src/components/marketing/engine/clip-engine-scene.ts`)
-renders the ClippyOS clipping engine as a machine with moving parts: an octagonal
-chassis, pumping pistons, a glass core with a spinning crystal, three render
-rotors, two film reels feeding a film strip, a hook scanner, a cutter that slams
-the strip into clips, an approval gate, a crown of four transmitters that
-broadcast ripples, and a library vault. Twelve clips move between formations
-(halo, reel, cut row, render spiral, gate arc, broadcast, vault stack, Hermes
-orbit) while the camera orbits the engine.
+v4 and [three.js](https://threejs.org). A pinned WebGL stage
+(`src/components/marketing/engine/clip-engine-scene.ts`) renders the ClippyOS
+engine as an exploded editing timeline drawn as a glowing holographic wireframe:
+a projector deck with a spinning jog wheel and a media bin, audio (A1), footage
+(V1) and caption (C1) tracks floating under a timecode ruler, a playhead light
+sheet that sweeps the stack, a source port that streams footage in, hook markers
+on the waveform, a razor that cuts V1 into twelve clips, a review gate, four
+output lanes that end in X, YouTube, Instagram and TikTok screens, and the Hermes
+loop around the machine. The clips move between formations (segments on the
+footage track, cut apart, vertical 9:16 cards, the review conveyor, the output
+lanes, the media bin and the Hermes loop) while the camera dollies along the
+timeline.
Scrolling the chapters in `src/components/marketing/landing-page.tsx` scrubs one
anime.js timeline in `src/components/marketing/anime/landing-motion.ts` with
`onScroll`; it drives the engine's stage progress, the readout, panels and HUD.
-anime.js also runs the engine assembly intro, stage pulses, pointer parallax,
-transmitter ripples, split-word reveals and magnetic buttons. three.js is loaded
-with a dynamic import so it only ships to the landing page.
+anime.js also runs the engine boot (the wireframe draws itself in), stage
+pulses, pointer parallax, split-word reveals and magnetic buttons. three.js is
+loaded with a dynamic import so it only ships to the landing page.
The engine is a labelled demo run. Stage configs and clip formations live in
`src/lib/clip-engine.ts` and are covered by `src/lib/clip-engine.test.ts`.
diff --git a/src/components/marketing/anime/landing-motion.ts b/src/components/marketing/anime/landing-motion.ts
index 6776b5a..46b2bed 100644
--- a/src/components/marketing/anime/landing-motion.ts
+++ b/src/components/marketing/anime/landing-motion.ts
@@ -9,7 +9,7 @@ import {
utils,
type AnimatableObject,
} from "animejs";
-import { ENGINE_STAGES, LAST_STAGE, engineStateAt, stageIndex, timecode } from "@/lib/clip-engine";
+import { ENGINE_STAGES, LAST_STAGE, readout, stageIndex, timecode } from "@/lib/clip-engine";
import type {
EngineHandle,
EngineTheme,
@@ -41,12 +41,6 @@ function currentTheme(): EngineTheme {
return document.documentElement.dataset.theme === "light" ? "light" : "dark";
}
-function cameraText(progress: number) {
- const { cam } = engineStateAt(progress);
- const orbit = ((Math.round(cam.rot) % 360) + 360) % 360;
- return `orbit ${pad(orbit, 3)}° · pitch ${Math.round(cam.pitch)}° · dist ${cam.dist.toFixed(1)}`;
-}
-
function heroIntro(root: HTMLElement, reduced: boolean): Cleanup {
const title = one(root, "[data-hero-title]");
const items = all(root, "[data-hero-item]");
@@ -164,7 +158,7 @@ function clipEngine(root: HTMLElement, reduced: boolean): Cleanup {
onUpdate: (timeline) => {
setStage(Math.round(timeline.progress * LAST_STAGE));
if (clock) clock.textContent = timecode(timeline.progress);
- const nextCam = cameraText(timeline.progress * LAST_STAGE);
+ const nextCam = readout(timeline.progress * LAST_STAGE);
if (camLabel && nextCam !== camText) {
camText = nextCam;
camLabel.textContent = nextCam;
diff --git a/src/components/marketing/clip-engine.tsx b/src/components/marketing/clip-engine.tsx
index 4d74ca9..b01c7d2 100644
--- a/src/components/marketing/clip-engine.tsx
+++ b/src/components/marketing/clip-engine.tsx
@@ -8,12 +8,11 @@ import {
NETWORKS,
PIPELINE_STEPS,
STORAGE_LAYERS,
- stageState,
+ readout,
} from "@/lib/clip-engine";
const LEDS = 5;
const CLIPS_PER_NETWORK = CLIP_COUNT / NETWORKS.length;
-const start = stageState(0).cam;
function pad(value: number) {
return String(value).padStart(2, "0");
@@ -54,8 +53,7 @@ export function ClipEngine() {
00:00:00:00
- orbit {String(Math.round(start.rot)).padStart(3, "0")}° · pitch {start.pitch}° · dist{" "}
- {start.dist.toFixed(1)}
+ {readout(0)}
diff --git a/src/components/marketing/engine/clip-engine-scene.ts b/src/components/marketing/engine/clip-engine-scene.ts
index cfa526c..e12ab3b 100644
--- a/src/components/marketing/engine/clip-engine-scene.ts
+++ b/src/components/marketing/engine/clip-engine-scene.ts
@@ -1,17 +1,34 @@
import * as THREE from "three";
-import { animate, type JSAnimation } from "animejs";
+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
+import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import {
+ BIN_COLUMNS,
+ BIN_Z,
+ CAPTION_WORDS,
+ CARD_Y,
CLIP_COUNT,
- FILM_FRAMES,
+ FOOTAGE_Y,
GATE_X,
- GATE_Z,
- PISTON_COUNT,
- ROTOR_HEIGHTS,
- TRANSMITTER_COUNT,
+ LANE_LENGTH,
+ LANE_ORIGIN,
+ NETWORKS,
+ RULER_SECONDS,
+ RULER_Y,
+ SEGMENT_W,
+ TIMELINE_LENGTH,
+ TRACKS,
+ TRACK_DEPTH,
+ WAVE_BARS,
+ captionChips,
+ clipApproved,
clipPoseAt,
engineStateAt,
- isHookFrame,
- transmitterAngle,
+ hookX,
+ laneDirection,
+ loopPoint,
+ segmentX,
+ waveAmplitude,
} from "@/lib/clip-engine";
export type EngineTheme = "dark" | "light";
@@ -31,93 +48,203 @@ export type EngineHandle = {
dispose: () => void;
};
-type Palette = {
- body: string;
- panel: string;
- deep: string;
- metal: string;
- accent: string;
- crystal: string;
- edge: string;
- glass: string;
+type Tone = "line" | "dim" | "hot";
+
+type Palette = Record & {
+ face: string;
fog: string;
- grid: string;
- shadow: number;
- hemi: number;
- sky: string;
- ground: string;
- clips: string[];
- film: string;
+ additive: boolean;
+ lineAlpha: number;
+ haloAlpha: number;
+ faceAlpha: number;
+ labelAlpha: number;
};
const PALETTES: Record = {
dark: {
- body: "#1c2b24",
- panel: "#253a30",
- deep: "#0b3d2c",
- metal: "#7d948a",
- accent: "#10b981",
- crystal: "#6ee7b7",
- edge: "#34d399",
- glass: "#10b981",
+ line: "#34d399",
+ dim: "#0f6b52",
+ hot: "#b4f5d9",
+ face: "#10b981",
fog: "#050a08",
- grid: "#14532d",
- shadow: 0.55,
- hemi: 1.8,
- sky: "#d1fae5",
- ground: "#052e1f",
- clips: ["#1f6f55", "#7a4a1f", "#1f4f6f", "#5b2f7a"],
- film: "#0a1410",
+ additive: true,
+ lineAlpha: 0.95,
+ haloAlpha: 0.18,
+ faceAlpha: 0.2,
+ labelAlpha: 0.9,
},
light: {
- body: "#eef8f3",
- panel: "#d5efe2",
- deep: "#047857",
- metal: "#9fb8ad",
- accent: "#059669",
- crystal: "#34d399",
- edge: "#059669",
- glass: "#34d399",
+ line: "#0f766e",
+ dim: "#8fcdb5",
+ hot: "#059669",
+ face: "#10b981",
fog: "#f3faf6",
- grid: "#a7dcc4",
- shadow: 0.16,
- hemi: 2.4,
- sky: "#ffffff",
- ground: "#a7f3d0",
- clips: ["#2f9e78", "#c07a3a", "#3a78a8", "#8a55b5"],
- film: "#10231b",
+ additive: false,
+ lineAlpha: 0.9,
+ haloAlpha: 0.1,
+ faceAlpha: 0.1,
+ labelAlpha: 0.95,
},
};
+type Layer = {
+ tone: Tone;
+ core: LineMaterial;
+ halo: LineMaterial;
+ glow: number;
+ vis: number;
+};
+
+type Holo = {
+ geometry: LineSegmentsGeometry;
+ count: number;
+ from: number;
+ to: number;
+};
+
+type Label = {
+ sprite: THREE.Sprite;
+ material: THREE.SpriteMaterial;
+ texture: THREE.CanvasTexture;
+ canvas: HTMLCanvasElement;
+ text: string;
+ size: number;
+ layer: Layer;
+};
+
+type Segs = number[];
+
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
-const EDGE_ANGLE = 28;
-const SPARK_COUNT = 24;
+const HALF = TIMELINE_LENGTH / 2;
+const PLATE_X0 = -HALF - 0.2;
+const PLATE_X1 = HALF + 0.2;
+const HEADER_X0 = -HALF - 1;
+const HEADER_X1 = -HALF - 0.35;
+const HALF_DEPTH = TRACK_DEPTH / 2;
+const FRONT = HALF_DEPTH + 0.05;
+const DECK = { x0: -6.4, x1: 6.2, z0: -1.5, z1: 3.55 };
+const SRC = { x: -5.9, size: 0.62 };
+const INGEST_PARTICLES = 18;
+const JOG = { x: 4.1, z: 1.9 };
+const LOOP_PULSES = 3;
+const FONT = '600 44px "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace';
+
+const vertexShader = /* glsl */ `
+ varying vec2 vUv;
+ varying vec3 vWorld;
+ void main() {
+ vUv = uv;
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vWorld = world.xyz;
+ gl_Position = projectionMatrix * viewMatrix * world;
+ }
+`;
+
+const fragmentShader = /* glsl */ `
+ uniform vec3 uColor;
+ uniform float uOpacity;
+ uniform float uSweep;
+ uniform float uTime;
+ uniform float uEdge;
+ uniform float uFade;
+ varying vec2 vUv;
+ varying vec3 vWorld;
+ void main() {
+ vec2 d = abs(vUv - 0.5) * 2.0;
+ float edge = smoothstep(0.78, 1.0, max(d.x, d.y));
+ float scan = 0.55 + 0.45 * step(1.5, mod(gl_FragCoord.y, 3.0));
+ float band = exp(-pow((vWorld.x - uSweep) * 2.4, 2.0));
+ float drift = 0.5 + 0.5 * sin(vWorld.x * 1.4 + vWorld.z * 2.1 - uTime * 1.3);
+ float fade = mix(1.0, 1.0 - vUv.y, uFade);
+ float alpha = uOpacity * (0.16 + 0.12 * drift + edge * uEdge + band * 0.85) * scan * fade;
+ gl_FragColor = vec4(uColor, alpha);
+ #include
+ }
+`;
function seg(value: number, start: number, end: number): number {
return Math.min(1, Math.max(0, (value - start) / (end - start)));
}
-function outBack(t: number): number {
- const c = 1.4;
- const x = t - 1;
- return t <= 0 ? 0 : 1 + (c + 1) * x * x * x + c * x * x;
+function wrap(value: number): number {
+ return ((value % 1) + 1) % 1;
+}
+
+function line(s: Segs, ax: number, ay: number, az: number, bx: number, by: number, bz: number) {
+ s.push(ax, ay, az, bx, by, bz);
+}
+
+function rectXZ(s: Segs, x0: number, x1: number, y: number, z0: number, z1: number) {
+ line(s, x0, y, z0, x1, y, z0);
+ line(s, x1, y, z0, x1, y, z1);
+ line(s, x1, y, z1, x0, y, z1);
+ line(s, x0, y, z1, x0, y, z0);
}
-function shadowTexture(): THREE.CanvasTexture {
+function rectXY(s: Segs, x0: number, x1: number, y0: number, y1: number, z: number) {
+ line(s, x0, y0, z, x1, y0, z);
+ line(s, x1, y0, z, x1, y1, z);
+ line(s, x1, y1, z, x0, y1, z);
+ line(s, x0, y1, z, x0, y0, z);
+}
+
+function rectYZ(s: Segs, x: number, y0: number, y1: number, z0: number, z1: number) {
+ line(s, x, y0, z0, x, y1, z0);
+ line(s, x, y1, z0, x, y1, z1);
+ line(s, x, y1, z1, x, y0, z1);
+ line(s, x, y0, z1, x, y0, z0);
+}
+
+function bracketsXZ(
+ s: Segs,
+ x0: number,
+ x1: number,
+ y: number,
+ z0: number,
+ z1: number,
+ size: number,
+) {
+ const corners: [number, number, number, number][] = [
+ [x0, z0, 1, 1],
+ [x1, z0, -1, 1],
+ [x1, z1, -1, -1],
+ [x0, z1, 1, -1],
+ ];
+ corners.forEach(([x, z, dx, dz]) => {
+ line(s, x, y, z, x + dx * size, y, z);
+ line(s, x, y, z, x, y, z + dz * size);
+ });
+}
+
+function box(s: Segs, cx: number, cy: number, cz: number, size: number) {
+ const h = size / 2;
+ rectXZ(s, cx - h, cx + h, cy - h, cz - h, cz + h);
+ rectXZ(s, cx - h, cx + h, cy + h, cz - h, cz + h);
+ [
+ [-h, -h],
+ [h, -h],
+ [h, h],
+ [-h, h],
+ ].forEach(([dx, dz]) => line(s, cx + dx, cy - h, cz + dz, cx + dx, cy + h, cz + dz));
+}
+
+function glowTexture(): THREE.CanvasTexture {
const canvas = document.createElement("canvas");
- canvas.width = 128;
- canvas.height = 128;
+ canvas.width = 64;
+ canvas.height = 64;
const ctx = canvas.getContext("2d");
if (ctx) {
- const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
- gradient.addColorStop(0, "rgba(0,0,0,1)");
- gradient.addColorStop(0.55, "rgba(0,0,0,0.45)");
- gradient.addColorStop(1, "rgba(0,0,0,0)");
+ const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
+ gradient.addColorStop(0, "rgba(255,255,255,1)");
+ gradient.addColorStop(0.25, "rgba(255,255,255,0.55)");
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, 128, 128);
+ ctx.fillRect(0, 0, 64, 64);
}
- return new THREE.CanvasTexture(canvas);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ return texture;
}
export function createClipEngine(
@@ -134,639 +261,787 @@ export function createClipEngine(
} catch {
return null;
}
- renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
+ const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.75);
+ renderer.setPixelRatio(pixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
- renderer.toneMappingExposure = 1.05;
+ renderer.toneMapping = THREE.NoToneMapping;
renderer.setClearColor(0x000000, 0);
host.appendChild(renderer.domElement);
const view = options.view;
const scene = new THREE.Scene();
- scene.fog = new THREE.Fog(PALETTES[options.theme].fog, 22, 42);
- const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 120);
+ const fog = new THREE.Fog(PALETTES[options.theme].fog, 16, 40);
+ scene.fog = fog;
+ const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 140);
+ const target = new THREE.Vector3();
const geometries: THREE.BufferGeometry[] = [];
- const track = (geometry: T): T => {
+ const materials: THREE.Material[] = [];
+ const textures: THREE.Texture[] = [];
+ const keep = (geometry: T): T => {
geometries.push(geometry);
return geometry;
};
- const mat = {
- body: new THREE.MeshStandardMaterial({ roughness: 0.62, metalness: 0.18, flatShading: true }),
- panel: new THREE.MeshStandardMaterial({ roughness: 0.5, metalness: 0.2, flatShading: true }),
- deep: new THREE.MeshStandardMaterial({ roughness: 0.4, metalness: 0.3, flatShading: true }),
- metal: new THREE.MeshStandardMaterial({ roughness: 0.32, metalness: 0.75, flatShading: true }),
- accent: new THREE.MeshStandardMaterial({ roughness: 0.35, metalness: 0.2, flatShading: true }),
- crystal: new THREE.MeshStandardMaterial({ roughness: 0.2, metalness: 0.1, flatShading: true }),
- glass: new THREE.MeshPhysicalMaterial({
- roughness: 0.08,
- metalness: 0,
+ const layers: Layer[] = [];
+ const layer = (tone: Tone, width = 1.35, glow = 5): Layer => {
+ const core = new LineMaterial({
+ linewidth: width,
transparent: true,
- opacity: 0.16,
depthWrite: false,
- side: THREE.DoubleSide,
- }),
- film: new THREE.MeshStandardMaterial({ roughness: 0.6, metalness: 0.1 }),
- frame: new THREE.MeshStandardMaterial({ roughness: 0.5, metalness: 0.05 }),
- clip: new THREE.MeshStandardMaterial({ roughness: 0.45, metalness: 0.1 }),
- rim: new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.9 }),
- glow: new THREE.MeshBasicMaterial({
+ fog: true,
+ });
+ const halo = new LineMaterial({
+ linewidth: glow,
transparent: true,
- opacity: 0.5,
- blending: THREE.AdditiveBlending,
depthWrite: false,
- side: THREE.DoubleSide,
- }),
- ripple: new THREE.MeshBasicMaterial({
+ fog: true,
+ });
+ materials.push(core, halo);
+ const entry = { tone, core, halo, glow, vis: 1 };
+ layers.push(entry);
+ return entry;
+ };
+
+ const L = {
+ frame: layer("line"),
+ grid: layer("dim", 1, 0),
+ wave: layer("line", 1.6, 4),
+ waveHot: layer("line", 2, 7),
+ hooks: layer("hot", 1.5, 7),
+ razor: layer("hot", 1.6, 8),
+ chips: layer("line", 1.2, 4),
+ caret: layer("hot", 2, 8),
+ tether: layer("line", 1, 3),
+ jog: layer("line", 1.3, 5),
+ jogHot: layer("hot", 2, 8),
+ ingest: layer("hot", 1.6, 7),
+ gate: layer("line", 1.5, 6),
+ laser: layer("hot", 2.2, 10),
+ lanes: layer("line", 1.4, 5),
+ bin: layer("line", 1.2, 4),
+ loop: layer("line", 1.5, 6),
+ playhead: layer("hot", 2, 10),
+ clip: layer("line", 1.5, 6),
+ clipHot: layer("hot", 1.8, 9),
+ clipGhost: layer("dim", 1, 0),
+ check: layer("hot", 2, 8),
+ };
+
+ const sweep = { value: 0 };
+ const clock = { value: 0 };
+ const faces: { material: THREE.ShaderMaterial; tone: "face" | "hot"; alpha: number }[] = [];
+ const faceMaterial = (tone: "face" | "hot", alpha: number, edge = 0.7, fade = 0) => {
+ const material = new THREE.ShaderMaterial({
+ uniforms: {
+ uColor: { value: new THREE.Color() },
+ uOpacity: { value: 0 },
+ uSweep: sweep,
+ uTime: clock,
+ uEdge: { value: edge },
+ uFade: { value: fade },
+ },
+ vertexShader,
+ fragmentShader,
transparent: true,
- opacity: 0,
depthWrite: false,
side: THREE.DoubleSide,
- }),
- spark: new THREE.MeshBasicMaterial({
+ });
+ materials.push(material);
+ faces.push({ material, tone, alpha });
+ return material;
+ };
+
+ const faceMat = {
+ plate: faceMaterial("face", 1),
+ deck: faceMaterial("face", 0.35, 0.3),
+ sheet: faceMaterial("hot", 0.9, 0.2, 1),
+ clip: faceMaterial("face", 1.3),
+ clipHot: faceMaterial("hot", 1.2),
+ gate: faceMaterial("face", 0.7),
+ lanes: faceMaterial("face", 0.6),
+ };
+
+ const holos: Holo[] = [];
+ const holo = (
+ segs: Segs,
+ target: Layer,
+ parent: THREE.Object3D,
+ from = 0,
+ to = 1,
+ ): { geometry: LineSegmentsGeometry; core: LineSegments2; halo: LineSegments2 } => {
+ const geometry = keep(new LineSegmentsGeometry());
+ geometry.setPositions(segs);
+ const core = new LineSegments2(geometry, target.core);
+ const halo = new LineSegments2(geometry, target.halo);
+ core.frustumCulled = false;
+ halo.frustumCulled = false;
+ parent.add(halo, core);
+ holos.push({ geometry, count: segs.length / 6, from, to });
+ return { geometry, core, halo };
+ };
+
+ const glowMap = glowTexture();
+ textures.push(glowMap);
+ const sprites: { material: THREE.SpriteMaterial; tone: Tone }[] = [];
+ const glowSprite = (tone: Tone, size: number, parent: THREE.Object3D) => {
+ const material = new THREE.SpriteMaterial({
+ map: glowMap,
transparent: true,
- opacity: 0.9,
- blending: THREE.AdditiveBlending,
depthWrite: false,
- }),
- edge: new THREE.LineBasicMaterial({ transparent: true, opacity: 0.55 }),
- shadow: new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, depthWrite: false }),
+ fog: true,
+ });
+ materials.push(material);
+ sprites.push({ material, tone });
+ const sprite = new THREE.Sprite(material);
+ sprite.scale.setScalar(size);
+ parent.add(sprite);
+ return sprite;
};
- const shadowMap = shadowTexture();
- mat.shadow.map = shadowMap;
-
- const mesh = (
- geometry: THREE.BufferGeometry,
- material: THREE.Material,
- position: [number, number, number] = [0, 0, 0],
- edges = true,
- ) => {
- const m = new THREE.Mesh(track(geometry), material);
- m.position.set(position[0], position[1], position[2]);
- if (edges)
- m.add(new THREE.LineSegments(track(new THREE.EdgesGeometry(geometry, EDGE_ANGLE)), mat.edge));
- return m;
+
+ const labels: Label[] = [];
+ const drawLabel = (label: Label) => {
+ const ctx = label.canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.font = FONT;
+ const width = Math.ceil(ctx.measureText(label.text).width) + 24;
+ label.canvas.width = width;
+ label.canvas.height = 64;
+ ctx.font = FONT;
+ ctx.fillStyle = "#ffffff";
+ ctx.textBaseline = "middle";
+ ctx.fillText(label.text, 12, 34);
+ label.texture.needsUpdate = true;
+ label.sprite.scale.set((label.size * width) / 64, label.size, 1);
};
- const cyl = (
- top: number,
- bottom: number,
- height: number,
- sides: number,
- material: THREE.Material,
+ const addLabel = (
+ text: string,
position: [number, number, number],
- edges = true,
- ) => mesh(new THREE.CylinderGeometry(top, bottom, height, sides), material, position, edges);
- const box = (
- w: number,
- h: number,
- d: number,
- material: THREE.Material,
- position: [number, number, number],
- edges = true,
- ) => mesh(new THREE.BoxGeometry(w, h, d), material, position, edges);
+ parent: THREE.Object3D,
+ target: Layer,
+ size = 0.26,
+ ) => {
+ const canvas = document.createElement("canvas");
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.minFilter = THREE.LinearFilter;
+ textures.push(texture);
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false,
+ fog: true,
+ });
+ materials.push(material);
+ const sprite = new THREE.Sprite(material);
+ sprite.position.set(position[0], position[1], position[2]);
+ parent.add(sprite);
+ const label = { sprite, material, texture, canvas, text, size, layer: target };
+ labels.push(label);
+ drawLabel(label);
+ return label;
+ };
const root = new THREE.Group();
scene.add(root);
- const hemi = new THREE.HemisphereLight(0xffffff, 0x000000, 1.5);
- const key = new THREE.DirectionalLight(0xffffff, 3);
- key.position.set(5, 9, 7);
- const rim = new THREE.DirectionalLight(0xffffff, 1.2);
- rim.position.set(-6, 5, -5);
- const coreLight = new THREE.PointLight(0xffffff, 10, 9, 2);
- coreLight.position.set(0, 2.2, 0);
- const flash = new THREE.PointLight(0xffffff, 0, 4, 2);
- flash.position.set(0, 1.2, 2.6);
- scene.add(hemi, key, rim, coreLight, flash);
-
- const grid = new THREE.GridHelper(22, 44);
+ const grid = new THREE.GridHelper(48, 96);
const gridMaterial = grid.material as THREE.LineBasicMaterial;
gridMaterial.transparent = true;
- gridMaterial.opacity = 0.35;
gridMaterial.depthWrite = false;
- grid.position.y = -0.002;
- track(grid.geometry);
- const shadow = new THREE.Mesh(track(new THREE.CircleGeometry(4.6, 48)), mat.shadow);
- shadow.rotation.x = -Math.PI / 2;
- shadow.position.y = 0.004;
- root.add(grid, shadow);
-
- const chassis = new THREE.Group();
- const plinth = cyl(2.62, 2.84, 0.4, 8, mat.body, [0, 0.2, 0]);
- const deck = cyl(2.3, 2.44, 0.22, 8, mat.panel, [0, 0.51, 0]);
- const trim = cyl(2.68, 2.68, 0.07, 8, mat.accent, [0, 0.41, 0], false);
- [plinth, deck, trim].forEach((part) => (part.rotation.y = Math.PI / 8));
- chassis.add(plinth, deck, trim);
- for (let i = 0; i < 4; i++) {
- const a = (i / 4) * TAU;
- const vent = box(
- 1.05,
- 0.18,
- 0.08,
- mat.deep,
- [Math.sin(a) * 2.66, 0.2, Math.cos(a) * 2.66],
- false,
- );
- vent.rotation.y = a;
- chassis.add(vent);
+ gridMaterial.vertexColors = false;
+ grid.position.y = -0.01;
+ keep(grid.geometry);
+ root.add(grid);
+
+ const deck = new THREE.Group();
+ const deckSegs: Segs = [];
+ rectXZ(deckSegs, DECK.x0, DECK.x1, 0, DECK.z0, DECK.z1);
+ const deckGrid: Segs = [];
+ for (let x = Math.ceil(DECK.x0 * 2) / 2; x <= DECK.x1; x += 0.5) {
+ line(deckGrid, x, 0, DECK.z0, x, 0, DECK.z1);
}
- for (let i = 0; i < 8; i++) {
- const a = (i / 8) * TAU + Math.PI / 8;
- chassis.add(
- cyl(0.07, 0.07, 0.06, 6, mat.metal, [Math.sin(a) * 2.05, 0.65, Math.cos(a) * 2.05], false),
- );
+ for (let z = Math.ceil(DECK.z0 * 2) / 2; z <= DECK.z1; z += 0.5) {
+ line(deckGrid, DECK.x0, 0, z, DECK.x1, 0, z);
+ }
+ for (let x = DECK.x0 + 0.25; x < DECK.x1; x += 0.25) {
+ const major = Math.abs((x * 4) % 4) < 0.01;
+ line(deckSegs, x, 0, DECK.z1, x, 0, DECK.z1 - (major ? 0.22 : 0.1));
}
- root.add(chassis);
+ bracketsXZ(deckSegs, DECK.x0 - 0.18, DECK.x1 + 0.18, 0, DECK.z0 - 0.18, DECK.z1 + 0.18, 0.5);
+ holo(deckGrid, L.grid, deck, 0, 0.3);
+ holo(deckSegs, L.frame, deck, 0, 0.25);
+ const deckFace = new THREE.Mesh(
+ keep(new THREE.PlaneGeometry(DECK.x1 - DECK.x0, DECK.z1 - DECK.z0)),
+ faceMat.deck,
+ );
+ deckFace.rotation.x = -Math.PI / 2;
+ deckFace.position.set((DECK.x0 + DECK.x1) / 2, 0, (DECK.z0 + DECK.z1) / 2);
+ deck.add(deckFace);
+ root.add(deck);
- const core = new THREE.Group();
- core.add(cyl(1.02, 1.14, 0.3, 6, mat.panel, [0, 0.76, 0]));
- core.add(cyl(1.02, 1.02, 0.2, 6, mat.panel, [0, 3.62, 0]));
- core.add(
- mesh(new THREE.CylinderGeometry(0.93, 0.93, 2.66, 6, 1, true), mat.glass, [0, 2.2, 0], false),
+ const stack = new THREE.Group();
+ const pillars: Segs = [];
+ [PLATE_X0, PLATE_X1].forEach((x) =>
+ [-HALF_DEPTH, HALF_DEPTH].forEach((z) => line(pillars, x, 0, z, x, RULER_Y + 0.2, z)),
);
- for (let i = 0; i < 6; i++) {
- const a = (i / 6) * TAU;
- core.add(
- box(0.09, 2.66, 0.09, mat.metal, [Math.sin(a) * 0.96, 2.2, Math.cos(a) * 0.96], false),
- );
- }
- const crystal = mesh(new THREE.OctahedronGeometry(0.52, 0), mat.crystal, [0, 2.2, 0]);
- crystal.scale.set(1, 2.05, 1);
- const clipRing = mesh(new THREE.TorusGeometry(0.62, 0.05, 6, 6), mat.accent, [0, 2.2, 0], false);
- core.add(crystal, clipRing);
- const sparks = new THREE.InstancedMesh(
- track(new THREE.BoxGeometry(0.07, 0.07, 0.07)),
- mat.spark,
- SPARK_COUNT,
+ [HEADER_X0].forEach((x) =>
+ [-HALF_DEPTH, HALF_DEPTH].forEach((z) => line(pillars, x, 0, z, x, RULER_Y, z)),
);
- sparks.frustumCulled = false;
- core.add(sparks);
- root.add(core);
-
- const pistons = Array.from({ length: PISTON_COUNT }, (_, i) => {
- const arm = new THREE.Group();
- arm.rotation.y = (i / PISTON_COUNT) * TAU + Math.PI / 6;
- const slide = new THREE.Group();
- slide.position.z = 1.9;
- slide.add(cyl(0.2, 0.22, 1.05, 12, mat.metal, [0, 1.13, 0]));
- slide.add(cyl(0.25, 0.25, 0.1, 12, mat.panel, [0, 0.66, 0]));
- const head = new THREE.Group();
- head.position.y = 1.66;
- head.add(cyl(0.075, 0.075, 0.5, 10, mat.body, [0, 0.1, 0], false));
- head.add(cyl(0.27, 0.27, 0.16, 12, mat.accent, [0, 0.36, 0]));
- slide.add(head);
- arm.add(slide);
- root.add(arm);
- return { arm, head };
- });
+ holo(pillars, L.grid, stack, 0.05, 0.35);
- const rotors = ROTOR_HEIGHTS.map((height, k) => {
- const mount = new THREE.Group();
- mount.position.y = height;
- const spin = new THREE.Group();
- const ring = mesh(new THREE.TorusGeometry(1.1, 0.07, 8, 40), mat.metal, [0, 0, 0], false);
- ring.rotation.x = Math.PI / 2;
- const outer = mesh(new THREE.TorusGeometry(1.56, 0.035, 6, 48), mat.metal, [0, 0, 0], false);
- outer.rotation.x = Math.PI / 2;
- spin.add(ring, outer);
- const finMaterial = k === 0 ? mat.accent : k === 1 ? mat.deep : mat.panel;
- for (let i = 0; i < 14; i++) {
- const holder = new THREE.Group();
- holder.rotation.y = (i / 14) * TAU;
- const fin = box(0.44, 0.05, 0.22, finMaterial, [1.33, 0, 0], false);
- fin.rotation.x = 0.55;
- holder.add(fin);
- spin.add(holder);
- }
- mount.add(spin);
- root.add(mount);
- return { mount, spin };
+ const levels = [
+ ...TRACKS.map((track) => ({ key: track.key, y: track.y })),
+ { key: "TC", y: RULER_Y },
+ ];
+ levels.forEach((level, index) => {
+ const segs: Segs = [];
+ rectXZ(segs, PLATE_X0, PLATE_X1, level.y, -HALF_DEPTH, HALF_DEPTH);
+ rectXZ(segs, HEADER_X0, HEADER_X1, level.y, -HALF_DEPTH, HALF_DEPTH);
+ bracketsXZ(
+ segs,
+ PLATE_X0 - 0.12,
+ PLATE_X1 + 0.12,
+ level.y,
+ -HALF_DEPTH - 0.12,
+ HALF_DEPTH + 0.12,
+ 0.28,
+ );
+ const start = 0.12 + index * 0.1;
+ holo(segs, L.frame, stack, start, start + 0.3);
+ const face = new THREE.Mesh(
+ keep(new THREE.PlaneGeometry(PLATE_X1 - PLATE_X0, TRACK_DEPTH)),
+ faceMat.plate,
+ );
+ face.rotation.x = -Math.PI / 2;
+ face.position.set(0, level.y, 0);
+ stack.add(face);
+ addLabel(level.key, [(HEADER_X0 + HEADER_X1) / 2, level.y + 0.16, 0], stack, L.frame, 0.3);
});
- const crown = new THREE.Group();
- const crownBase = cyl(1.32, 1.18, 0.2, 8, mat.body, [0, 3.86, 0]);
- crownBase.rotation.y = Math.PI / 8;
- crown.add(crownBase);
- for (let i = 0; i < 16; i++) {
- const a = (i / 16) * TAU;
- const fin = box(
- 0.06,
- 0.44,
- 0.5,
- mat.metal,
- [Math.sin(a) * 0.92, 4.18, Math.cos(a) * 0.92],
- false,
+ const ruler: Segs = [];
+ for (let k = 0; k <= TIMELINE_LENGTH * 4; k++) {
+ const x = -HALF + k * 0.25;
+ const major = k % 4 === 0;
+ line(ruler, x, RULER_Y, HALF_DEPTH, x, RULER_Y, HALF_DEPTH - (major ? 0.32 : 0.14));
+ }
+ holo(ruler, L.frame, stack, 0.45, 0.7);
+ for (let k = 0; k <= TIMELINE_LENGTH; k++) {
+ const seconds = Math.round((k * RULER_SECONDS) / TIMELINE_LENGTH);
+ addLabel(
+ `00:${String(seconds).padStart(2, "0")}`,
+ [-HALF + k, RULER_Y + 0.17, HALF_DEPTH - 0.18],
+ stack,
+ L.grid,
+ 0.17,
);
- fin.rotation.y = a;
- crown.add(fin);
}
- crown.add(cyl(0.56, 0.72, 0.26, 8, mat.panel, [0, 4.52, 0]));
- crown.add(cyl(0.17, 0.27, 0.5, 12, mat.accent, [0, 4.88, 0]));
- const beacon = mesh(new THREE.SphereGeometry(0.12, 16, 12), mat.crystal, [0, 5.2, 0], false);
- crown.add(beacon);
- const ripples: THREE.Mesh[] = [];
- const transmitters = Array.from({ length: TRANSMITTER_COUNT }, (_, k) => {
- const holder = new THREE.Group();
- holder.rotation.y = transmitterAngle(k) * DEG;
- const rod = box(0.07, 0.07, 0.95, mat.metal, [0, 4.5, 1.05], false);
- rod.rotation.x = -0.45;
- const dish = cyl(0.34, 0.07, 0.12, 16, mat.accent, [0, 4.72, 1.52]);
- dish.rotation.x = 1.05;
- holder.add(rod, dish);
- for (let r = 0; r < 3; r++) {
- const ring = new THREE.Mesh(
- track(new THREE.TorusGeometry(0.4, 0.018, 6, 40)),
- mat.ripple.clone(),
- );
- ring.position.set(0, 4.8, 1.62);
- ring.rotation.x = 1.05 - Math.PI / 2;
- holder.add(ring);
- ripples.push(ring);
- }
- crown.add(holder);
- return holder;
+ root.add(stack);
+
+ const waveXs = Array.from(
+ { length: WAVE_BARS },
+ (_, i) => -HALF + ((i + 0.5) / WAVE_BARS) * TIMELINE_LENGTH,
+ );
+ const nearHook = (x: number) =>
+ Array.from({ length: CLIP_COUNT }, (_, i) => Math.abs(x - hookX(i))).some((d) => d < 0.13);
+ const waveGroups = [false, true].map((hot) => {
+ const xs = waveXs.filter((x) => nearHook(x) === hot);
+ const segs: Segs = [];
+ xs.forEach((x) => line(segs, x, TRACKS[0].y, 0, x, TRACKS[0].y, 0));
+ const lines = holo(segs, hot ? L.waveHot : L.wave, stack, 0.5, 0.8);
+ return { xs, geometry: lines.geometry };
});
- root.add(crown);
- const reels = [-1, 1].map((side) => {
- const group = new THREE.Group();
- group.position.set(side * 2.75, 0, -0.3);
- group.add(box(0.16, 2.62, 0.16, mat.metal, [0, 1.72, -0.05], false));
- const reel = new THREE.Group();
- reel.position.y = 3.08;
- const rimOuter = mesh(
- new THREE.TorusGeometry(1.02, 0.07, 8, 48),
- mat.accent,
- [0, 0, 0.1],
- false,
- );
- const rimBack = mesh(
- new THREE.TorusGeometry(1.02, 0.07, 8, 48),
- mat.metal,
- [0, 0, -0.1],
- false,
- );
- const pack = mesh(new THREE.CylinderGeometry(0.78, 0.78, 0.16, 40), mat.film, [0, 0, 0], false);
- pack.rotation.x = Math.PI / 2;
- const hub = cyl(0.22, 0.22, 0.34, 12, mat.panel, [0, 0, 0]);
- hub.rotation.x = Math.PI / 2;
- reel.add(rimOuter, rimBack, pack, hub);
- for (let i = 0; i < 5; i++) {
- const spoke = box(0.12, 0.84, 0.05, mat.body, [0, 0, 0.12], false);
- spoke.position.set(Math.sin((i / 5) * TAU) * 0.46, Math.cos((i / 5) * TAU) * 0.46, 0.12);
- spoke.rotation.z = -(i / 5) * TAU;
- reel.add(spoke);
+ const hookSegs: Segs = [];
+ for (let i = 0; i < CLIP_COUNT; i++) {
+ const x = hookX(i);
+ const y = TRACKS[0].y + 0.78;
+ line(hookSegs, x, y + 0.1, 0, x + 0.07, y, 0);
+ line(hookSegs, x + 0.07, y, 0, x, y - 0.1, 0);
+ line(hookSegs, x, y - 0.1, 0, x - 0.07, y, 0);
+ line(hookSegs, x - 0.07, y, 0, x, y + 0.1, 0);
+ for (let d = 0; d < 3; d++) {
+ const y0 = y + 0.16 + d * 0.16;
+ line(hookSegs, x, y0, 0, x, Math.min(FOOTAGE_Y - 0.03, y0 + 0.08), 0);
}
- group.add(reel);
- root.add(group);
- return { group, reel, side };
- });
+ }
+ const hookMarks = new THREE.Group();
+ holo(hookSegs, L.hooks, hookMarks);
+ stack.add(hookMarks);
- const filmPoints = [
- [-2.75, 2.2, -0.1],
- [-2.35, 1.3, 1.1],
- [-1.25, 0.96, 2.25],
- [0, 0.92, 2.55],
- [1.25, 0.96, 2.25],
- [2.35, 1.3, 1.1],
- [2.75, 2.2, -0.1],
- ].map(([x, y, z]) => new THREE.Vector3(x, y, z));
- const filmCurve = new THREE.CatmullRomCurve3(filmPoints);
- const railCurve = (offset: number) =>
- new THREE.CatmullRomCurve3(filmPoints.map((p) => new THREE.Vector3(p.x, p.y + offset, p.z)));
- const film = new THREE.Group();
- [-0.14, 0.14].forEach((offset) => {
- film.add(
- new THREE.Mesh(track(new THREE.TubeGeometry(railCurve(offset), 120, 0.016, 5)), mat.metal),
- );
- });
- const frames = new THREE.InstancedMesh(
- track(new THREE.BoxGeometry(0.34, 0.22, 0.025)),
- mat.frame,
- FILM_FRAMES,
+ const razorSegs: Segs = [];
+ for (let b = 0; b <= CLIP_COUNT; b++) {
+ const x = -HALF + b * SEGMENT_W;
+ line(razorSegs, x, FOOTAGE_Y - 0.28, FRONT, x, FOOTAGE_Y + 0.62, FRONT);
+ line(razorSegs, x, FOOTAGE_Y + 0.005, -HALF_DEPTH, x, FOOTAGE_Y + 0.005, HALF_DEPTH);
+ line(razorSegs, x - 0.07, FOOTAGE_Y + 0.62, FRONT, x + 0.07, FOOTAGE_Y + 0.72, FRONT);
+ line(razorSegs, x + 0.07, FOOTAGE_Y + 0.62, FRONT, x - 0.07, FOOTAGE_Y + 0.72, FRONT);
+ }
+ holo(razorSegs, L.razor, stack);
+
+ const chips = captionChips();
+ const captionY = TRACKS[2].y + 0.01;
+ const chipSegs: Segs = [];
+ chips.forEach((chip) =>
+ rectXZ(chipSegs, chip.x - chip.w / 2, chip.x + chip.w / 2, captionY, -0.17, 0.17),
);
- const hookGlow = new THREE.InstancedMesh(
- track(new THREE.BoxGeometry(0.4, 0.28, 0.01)),
- mat.glow,
- FILM_FRAMES,
+ holo(chipSegs, L.chips, stack, 0.55, 0.85);
+ const caret = new THREE.Group();
+ const caretSegs: Segs = [];
+ rectXZ(caretSegs, -0.5, 0.5, 0, -0.5, 0.5);
+ line(caretSegs, -0.5, 0, 0.72, 0.5, 0, 0.72);
+ holo(caretSegs, L.caret, caret);
+ caret.position.y = captionY + 0.01;
+ stack.add(caret);
+
+ const src = new THREE.Group();
+ src.position.set(SRC.x, FOOTAGE_Y, 0);
+ const srcSegs: Segs = [];
+ box(srcSegs, 0, 0, 0, SRC.size);
+ rectXZ(srcSegs, -0.2, 0.2, -SRC.size / 2 - 0.12, -0.2, 0.2);
+ holo(srcSegs, L.frame, src, 0.3, 0.55);
+ const chevron: Segs = [];
+ [-0.12, 0.04].forEach((dx) => {
+ line(chevron, dx, 0.14, SRC.size / 2 + 0.01, dx + 0.12, 0, SRC.size / 2 + 0.01);
+ line(chevron, dx + 0.12, 0, SRC.size / 2 + 0.01, dx, -0.14, SRC.size / 2 + 0.01);
+ });
+ holo(chevron, L.ingest, src);
+ addLabel("SRC", [0, SRC.size / 2 + 0.24, 0], src, L.frame, 0.24);
+ root.add(src);
+ const ingestParticles = Array.from({ length: INGEST_PARTICLES }, () =>
+ glowSprite("hot", 0.2, root),
);
- frames.frustumCulled = false;
- hookGlow.frustumCulled = false;
- film.add(frames, hookGlow);
- root.add(film);
-
- const scanner = new THREE.Group();
- const scanRing = mesh(new THREE.TorusGeometry(1.3, 0.035, 8, 64), mat.accent, [0, 0, 0], false);
- scanRing.rotation.x = Math.PI / 2;
- const scanDisc = new THREE.Mesh(track(new THREE.RingGeometry(0.98, 1.28, 48)), mat.glow);
- scanDisc.rotation.x = -Math.PI / 2;
- scanner.add(scanRing, scanDisc);
- root.add(scanner);
-
- const cutter = new THREE.Group();
- cutter.position.set(0, 0, 2.55);
- cutter.add(box(0.1, 1.72, 0.1, mat.metal, [-0.52, 1.46, 0], false));
- cutter.add(box(0.1, 1.72, 0.1, mat.metal, [0.52, 1.46, 0], false));
- cutter.add(box(1.18, 0.16, 0.24, mat.body, [0, 2.36, 0]));
- const ram = new THREE.Group();
- ram.add(cyl(0.08, 0.08, 0.6, 10, mat.metal, [0, 0.3, 0], false));
- ram.add(box(0.86, 0.07, 0.3, mat.accent, [0, 0, 0]));
- cutter.add(ram);
- root.add(cutter);
+
+ const playhead = new THREE.Group();
+ const phSegs: Segs = [];
+ line(phSegs, 0, 0, FRONT, 0, RULER_Y + 0.12, FRONT);
+ const headY = RULER_Y + 0.12;
+ line(phSegs, -0.2, headY + 0.36, FRONT, 0.2, headY + 0.36, FRONT);
+ line(phSegs, 0.2, headY + 0.36, FRONT, 0, headY, FRONT);
+ line(phSegs, 0, headY, FRONT, -0.2, headY + 0.36, FRONT);
+ rectXY(phSegs, -0.2, 0.2, headY + 0.36, headY + 0.52, FRONT);
+ line(phSegs, 0, RULER_Y, -HALF_DEPTH, 0, RULER_Y, HALF_DEPTH);
+ holo(phSegs, L.playhead, playhead, 0.6, 0.85);
+ const sheet = new THREE.Mesh(keep(new THREE.PlaneGeometry(TRACK_DEPTH, RULER_Y)), faceMat.sheet);
+ sheet.rotation.y = Math.PI / 2;
+ sheet.position.set(0, RULER_Y / 2, 0);
+ playhead.add(sheet);
+ const headGlow = glowSprite("hot", 0.9, playhead);
+ headGlow.position.set(0, headY + 0.2, FRONT);
+ const spark = glowSprite("hot", 1.1, playhead);
+ spark.position.set(0, FOOTAGE_Y + 0.1, FRONT);
+ stack.add(playhead);
+
+ const tetherSegs: Segs = [];
+ for (let i = 0; i < CLIP_COUNT; i++) line(tetherSegs, 0, 0, 0, 0, 0, 0);
+ const tethers = holo(tetherSegs, L.tether, root);
const gate = new THREE.Group();
- gate.position.set(GATE_X, 0, GATE_Z);
- gate.rotation.y = Math.PI / 2;
- const gateRing = mesh(new THREE.TorusGeometry(1.05, 0.08, 8, 48), mat.metal, [0, 1.9, 0], false);
- const gateInner = mesh(new THREE.TorusGeometry(0.86, 0.03, 6, 6), mat.accent, [0, 1.9, 0], false);
- gate.add(gateRing, gateInner);
- gate.add(box(0.12, 0.9, 0.12, mat.metal, [-0.75, 0.45, 0], false));
- gate.add(box(0.12, 0.9, 0.12, mat.metal, [0.75, 0.45, 0], false));
+ gate.position.set(GATE_X, 0, 0);
+ const gateSegs: Segs = [];
+ const gy0 = CARD_Y - 0.72;
+ const gy1 = CARD_Y + 0.72;
+ rectYZ(gateSegs, 0, gy0, gy1, -0.52, 0.52);
+ rectYZ(gateSegs, 0, gy0 - 0.08, gy1 + 0.08, -0.6, 0.6);
+ [-0.52, 0.52].forEach((z) => line(gateSegs, 0, RULER_Y, z, 0, gy0 - 0.08, z));
+ [
+ [gy1 + 0.2, 1],
+ [gy0 - 0.2, -1],
+ ].forEach(([y, dir]) => {
+ line(gateSegs, 0, y, -0.72, 0, y, -0.4);
+ line(gateSegs, 0, y, 0.4, 0, y, 0.72);
+ line(gateSegs, 0, y, -0.72, 0, y - dir * 0.18, -0.72);
+ line(gateSegs, 0, y, 0.72, 0, y - dir * 0.18, 0.72);
+ });
+ holo(gateSegs, L.gate, gate);
+ const gateFace = new THREE.Mesh(keep(new THREE.PlaneGeometry(1.04, 1.44)), faceMat.gate);
+ gateFace.rotation.y = Math.PI / 2;
+ gateFace.position.y = CARD_Y;
+ gate.add(gateFace);
+ const laser = new THREE.Group();
+ const laserSegs: Segs = [];
+ line(laserSegs, 0, 0, -0.62, 0, 0, 0.62);
+ holo(laserSegs, L.laser, laser);
+ gate.add(laser);
+ addLabel("REVIEW", [0, gy1 + 0.42, 0], gate, L.gate, 0.24);
root.add(gate);
- const vault = new THREE.Group();
- vault.position.set(0, 0, -3.3);
- const vaultBase = cyl(1.25, 1.36, 0.3, 8, mat.body, [0, 0.15, 0]);
- vaultBase.rotation.y = Math.PI / 8;
- vault.add(vaultBase, cyl(1.3, 1.3, 0.05, 8, mat.accent, [0, 0.31, 0], false));
- const platters = Array.from({ length: 6 }, () => {
- const platter = cyl(1.02, 1.02, 0.06, 32, mat.panel, [0, 0.36, 0], false);
- vault.add(platter);
- return platter;
+ const lanes = new THREE.Group();
+ const laneSegs: Segs = [];
+ NETWORKS.forEach((network, k) => {
+ const dir = laneDirection(k);
+ const at = (d: number) => ({
+ x: LANE_ORIGIN.x + dir.x * d,
+ y: LANE_ORIGIN.y + dir.y * d,
+ });
+ const a = at(0.35);
+ const b = at(LANE_LENGTH);
+ [-0.3, 0.3].forEach((z) => line(laneSegs, a.x, a.y, z, b.x, b.y, z));
+ for (let d = 0.5; d < LANE_LENGTH; d += 0.5) {
+ const p = at(d);
+ line(laneSegs, p.x, p.y, -0.3, p.x, p.y, 0.3);
+ }
+ const e = at(LANE_LENGTH + 0.42);
+ const py = e.y + 0.32;
+ rectXY(laneSegs, e.x - 0.27, e.x + 0.27, py - 0.48, py + 0.48, 0);
+ rectXY(laneSegs, e.x - 0.33, e.x + 0.33, py - 0.54, py + 0.54, 0);
+ line(laneSegs, e.x - 0.08, py + 0.42, 0, e.x + 0.08, py + 0.42, 0);
+ line(laneSegs, e.x - 0.1, py - 0.44, 0, e.x + 0.1, py - 0.44, 0);
+ addLabel(network, [e.x, py + 0.78, 0], lanes, L.lanes, 0.24);
+ const face = new THREE.Mesh(
+ keep(new THREE.PlaneGeometry(LANE_LENGTH - 0.35, 0.6)),
+ faceMat.lanes,
+ );
+ const mid = at((LANE_LENGTH + 0.35) / 2);
+ face.matrixAutoUpdate = false;
+ face.matrix.makeBasis(
+ new THREE.Vector3(dir.x, dir.y, 0),
+ new THREE.Vector3(0, 0, 1),
+ new THREE.Vector3(dir.y, -dir.x, 0),
+ );
+ face.matrix.setPosition(mid.x, mid.y, 0);
+ lanes.add(face);
});
- root.add(vault);
-
- const clips = new THREE.InstancedMesh(
- track(new THREE.BoxGeometry(0.42, 0.74, 0.04)),
- mat.clip,
- CLIP_COUNT,
+ rectXY(
+ laneSegs,
+ LANE_ORIGIN.x - 0.18,
+ LANE_ORIGIN.x + 0.18,
+ LANE_ORIGIN.y - 0.18,
+ LANE_ORIGIN.y + 0.18,
+ 0,
);
- const clipRims = new THREE.InstancedMesh(
- track(new THREE.BoxGeometry(0.47, 0.79, 0.02)),
- mat.rim,
- CLIP_COUNT,
- );
- const captions = new THREE.InstancedMesh(
- track(new THREE.BoxGeometry(0.3, 0.05, 0.045)),
- mat.rim,
- CLIP_COUNT,
- );
- [clips, clipRims, captions].forEach((m) => {
- m.frustumCulled = false;
- root.add(m);
+ holo(laneSegs, L.lanes, lanes);
+ root.add(lanes);
+
+ const bin = new THREE.Group();
+ const binSegs: Segs = [];
+ const binX = ((BIN_COLUMNS - 1) / 2) * 0.72 + 0.45;
+ rectXZ(binSegs, -binX, binX, 0.02, BIN_Z - 0.62, BIN_Z + 1.05 + 0.62);
+ bracketsXZ(binSegs, -binX - 0.12, binX + 0.12, 0.02, BIN_Z - 0.74, BIN_Z + 1.79, 0.3);
+ for (let i = 0; i < CLIP_COUNT; i++) {
+ const col = i % BIN_COLUMNS;
+ const row = Math.floor(i / BIN_COLUMNS);
+ const x = (col - (BIN_COLUMNS - 1) / 2) * 0.72;
+ const z = BIN_Z + row * 1.05;
+ rectXZ(binSegs, x - 0.3, x + 0.3, 0.02, z - 0.49, z + 0.49);
+ }
+ holo(binSegs, L.bin, bin);
+ addLabel("BIN", [-binX - 0.45, 0.22, BIN_Z - 0.4], bin, L.bin, 0.26);
+ root.add(bin);
+
+ const loop = new THREE.Group();
+ const loopSegs: Segs = [];
+ const LOOP_STEPS = 128;
+ for (let k = 0; k < LOOP_STEPS; k++) {
+ const a = loopPoint((k / LOOP_STEPS) * TAU);
+ const b = loopPoint(((k + 1) / LOOP_STEPS) * TAU);
+ line(loopSegs, a.x, a.y, a.z, b.x, b.y, b.z);
+ }
+ holo(loopSegs, L.loop, loop);
+ const pulses = Array.from({ length: LOOP_PULSES }, () => glowSprite("hot", 0.7, loop));
+ const hermes = loopPoint(-Math.PI / 2);
+ addLabel("HERMES", [hermes.x, hermes.y + 0.4, hermes.z], loop, L.loop, 0.26);
+ root.add(loop);
+
+ const jog = new THREE.Group();
+ jog.position.set(JOG.x, 0.03, JOG.z);
+ const ring = (segs: Segs, radius: number, steps: number) => {
+ for (let k = 0; k < steps; k++) {
+ const a0 = (k / steps) * TAU;
+ const a1 = ((k + 1) / steps) * TAU;
+ line(
+ segs,
+ Math.cos(a0) * radius,
+ 0,
+ Math.sin(a0) * radius,
+ Math.cos(a1) * radius,
+ 0,
+ Math.sin(a1) * radius,
+ );
+ }
+ };
+ const jogBase: Segs = [];
+ ring(jogBase, 1.08, 72);
+ ring(jogBase, 0.24, 24);
+ holo(jogBase, L.jog, jog, 0.3, 0.55);
+ const jogOuter = new THREE.Group();
+ const outerSegs: Segs = [];
+ ring(outerSegs, 0.95, 64);
+ for (let k = 0; k < 36; k++) {
+ const a = (k / 36) * TAU;
+ const r0 = k % 3 === 0 ? 0.78 : 0.86;
+ line(
+ outerSegs,
+ Math.cos(a) * r0,
+ 0,
+ Math.sin(a) * r0,
+ Math.cos(a) * 0.95,
+ 0,
+ Math.sin(a) * 0.95,
+ );
+ }
+ holo(outerSegs, L.jog, jogOuter, 0.35, 0.6);
+ const dimple: Segs = [];
+ rectXZ(dimple, 0.58, 0.72, 0.01, -0.07, 0.07);
+ holo(dimple, L.jogHot, jogOuter);
+ const jogInner = new THREE.Group();
+ const innerSegs: Segs = [];
+ ring(innerSegs, 0.62, 48);
+ for (let k = 0; k < 6; k++) {
+ const a = (k / 6) * TAU;
+ line(
+ innerSegs,
+ Math.cos(a) * 0.3,
+ 0,
+ Math.sin(a) * 0.3,
+ Math.cos(a) * 0.6,
+ 0,
+ Math.sin(a) * 0.6,
+ );
+ }
+ holo(innerSegs, L.jog, jogInner, 0.4, 0.65);
+ jog.add(jogOuter, jogInner);
+ addLabel("JOG", [0, 0.2, 1.35], jog, L.jog, 0.22);
+ root.add(jog);
+
+ const cardSegs: Segs = [];
+ rectXY(cardSegs, -0.5, 0.5, -0.5, 0.5, 0);
+ line(cardSegs, -0.32, -0.3, 0.001, 0.32, -0.3, 0.001);
+ line(cardSegs, -0.2, -0.38, 0.001, 0.2, -0.38, 0.001);
+ line(cardSegs, -0.1, 0.16, 0.001, -0.1, -0.08, 0.001);
+ line(cardSegs, -0.1, -0.08, 0.001, 0.12, 0.04, 0.001);
+ line(cardSegs, 0.12, 0.04, 0.001, -0.1, 0.16, 0.001);
+ const cardGeometry = keep(new LineSegmentsGeometry());
+ cardGeometry.setPositions(cardSegs);
+ holos.push({ geometry: cardGeometry, count: cardSegs.length / 6, from: 0.7, to: 1 });
+ const checkSegs: Segs = [];
+ line(checkSegs, 0.12, 0.34, 0.002, 0.2, 0.26, 0.002);
+ line(checkSegs, 0.2, 0.26, 0.002, 0.36, 0.44, 0.002);
+ const checkGeometry = keep(new LineSegmentsGeometry());
+ checkGeometry.setPositions(checkSegs);
+ const cardFace = keep(new THREE.PlaneGeometry(1, 1));
+ const cards = Array.from({ length: CLIP_COUNT }, () => {
+ const group = new THREE.Group();
+ group.rotation.order = "YXZ";
+ const face = new THREE.Mesh(cardFace, faceMat.clip);
+ const halo = new LineSegments2(cardGeometry, L.clip.halo);
+ const core = new LineSegments2(cardGeometry, L.clip.core);
+ const check = new LineSegments2(checkGeometry, L.check.core);
+ [halo, core, check].forEach((part) => (part.frustumCulled = false));
+ group.add(face, halo, core, check);
+ root.add(group);
+ return { group, face, halo, core, check };
});
- const matrix = new THREE.Matrix4();
- const local = new THREE.Matrix4();
- const quaternion = new THREE.Quaternion();
- const euler = new THREE.Euler();
- const position = new THREE.Vector3();
- const scale = new THREE.Vector3();
- const tangent = new THREE.Vector3();
- const up = new THREE.Vector3(0, 1, 0);
- const xAxis = new THREE.Vector3();
- const yAxis = new THREE.Vector3();
- const zAxis = new THREE.Vector3();
const color = new THREE.Color();
- const accentColor = new THREE.Color();
- const frameColors: THREE.Color[] = [];
- const clipColors: THREE.Color[] = [];
-
let palette = PALETTES[options.theme];
+ const toneColor = (tone: Tone | "face") => (tone === "face" ? palette.face : palette[tone]);
const applyTheme = (theme: EngineTheme) => {
palette = PALETTES[theme];
- mat.body.color.set(palette.body);
- mat.panel.color.set(palette.panel);
- mat.deep.color.set(palette.deep);
- mat.metal.color.set(palette.metal);
- mat.accent.color.set(palette.accent);
- mat.accent.emissive.set(palette.accent);
- mat.accent.emissiveIntensity = 0.35;
- mat.crystal.color.set(palette.crystal);
- mat.crystal.emissive.set(palette.accent);
- mat.glass.color.set(palette.glass);
- mat.film.color.set(palette.film);
- mat.frame.color.set("#ffffff");
- mat.clip.color.set("#ffffff");
- mat.rim.color.set(palette.accent);
- mat.glow.color.set(palette.accent);
- mat.spark.color.set(palette.crystal);
- mat.edge.color.set(palette.edge);
- mat.shadow.opacity = palette.shadow;
- ripples.forEach((ring) => (ring.material as THREE.MeshBasicMaterial).color.set(palette.accent));
- hemi.color.set(palette.sky);
- hemi.groundColor.set(palette.ground);
- hemi.intensity = palette.hemi;
- rim.color.set(palette.accent);
- coreLight.color.set(palette.accent);
- (scene.fog as THREE.Fog).color.set(palette.fog);
- gridMaterial.color.set("#ffffff");
- const gridColors = grid.geometry.getAttribute("color");
- color.set(palette.grid);
- for (let i = 0; i < gridColors.count; i++) gridColors.setXYZ(i, color.r, color.g, color.b);
- gridColors.needsUpdate = true;
- accentColor.set(palette.accent);
- frameColors.length = 0;
- for (let i = 0; i < FILM_FRAMES; i++) frameColors.push(new THREE.Color(palette.clips[i % 4]));
- clipColors.length = 0;
- for (let i = 0; i < CLIP_COUNT; i++) {
- clipColors.push(new THREE.Color(palette.clips[i % 4]));
- clips.setColorAt(i, clipColors[i]);
- }
- if (clips.instanceColor) clips.instanceColor.needsUpdate = true;
+ const blending = palette.additive ? THREE.AdditiveBlending : THREE.NormalBlending;
+ layers.forEach((entry) => {
+ entry.core.color.set(toneColor(entry.tone));
+ entry.halo.color.set(toneColor(entry.tone));
+ entry.core.blending = blending;
+ entry.halo.blending = blending;
+ });
+ faces.forEach((face) => {
+ face.material.uniforms.uColor.value.set(face.tone === "hot" ? palette.hot : palette.face);
+ face.material.blending = blending;
+ });
+ sprites.forEach((sprite) => {
+ sprite.material.color.set(toneColor(sprite.tone));
+ sprite.material.blending = blending;
+ });
+ labels.forEach((label) => {
+ label.material.color.set(toneColor(label.layer.tone === "dim" ? "line" : label.layer.tone));
+ });
+ gridMaterial.color.set(palette.dim);
+ fog.color.set(palette.fog);
};
applyTheme(options.theme);
- const rippleAnimations: JSAnimation[] = options.reduced
- ? []
- : ripples.flatMap((ring, index) => {
- const delay = (index % 3) * 800;
- return [
- animate(ring.scale, {
- x: [0.3, 2.6],
- y: [0.3, 2.6],
- z: [0.3, 2.6],
- duration: 2400,
- delay,
- ease: "out(2)",
- loop: true,
- }),
- animate(ring.material, {
- opacity: [0.8, 0],
- duration: 2400,
- delay,
- ease: "out(2)",
- loop: true,
- }),
- ];
- });
+ if (typeof document !== "undefined" && document.fonts) {
+ document.fonts.ready
+ .then(() => {
+ if (disposed) return;
+ labels.forEach(drawLabel);
+ if (!running) update(0);
+ })
+ .catch(() => {});
+ }
let time = 0;
- let reelAngle = 0;
- let filmOffset = 0;
- const rotorAngles = ROTOR_HEIGHTS.map(() => 0);
- let crystalAngle = 0;
- let sparkOffset = 0;
- let gateAngle = 0;
+ let disposed = false;
+ let playU = 0.28;
+ let jogAngle = 0;
+ let revealed = false;
+ const waveBuffers = waveGroups.map(
+ (group) =>
+ (group.geometry.attributes.instanceStart as THREE.InterleavedBufferAttribute)
+ .data as THREE.InstancedInterleavedBuffer,
+ );
+ const tetherBuffer = (
+ tethers.geometry.attributes.instanceStart as THREE.InterleavedBufferAttribute
+ ).data as THREE.InstancedInterleavedBuffer;
const update = (dt: number) => {
time += dt;
+ clock.value = time;
const s = engineStateAt(view.progress);
const a = view.assembly;
const pulse = view.pulse;
+ const motion = options.reduced ? 0 : 1;
- const rot =
- (s.cam.rot + view.px * 8 + (options.reduced ? 0 : Math.sin(time * 0.15) * 2.5)) * DEG;
+ const rot = (s.cam.rot + view.px * 8 + Math.sin(time * 0.15) * 2.5 * motion) * DEG;
const pitch = (s.cam.pitch + view.py * 4) * DEG;
- const dist = s.cam.dist + (1 - a) * 4;
+ const dist = s.cam.dist + (1 - a) * 5;
+ target.set(s.cam.x, s.cam.y, s.cam.z);
camera.position.set(
- Math.sin(rot) * Math.cos(pitch) * dist,
- s.cam.y - Math.sin(pitch) * dist,
- Math.cos(rot) * Math.cos(pitch) * dist,
+ target.x + Math.sin(rot) * Math.cos(pitch) * dist,
+ target.y - Math.sin(pitch) * dist,
+ target.z + Math.cos(rot) * Math.cos(pitch) * dist,
);
- camera.lookAt(0, s.cam.y, 0);
-
- const aChassis = outBack(seg(a, 0, 0.35));
- const aCore = outBack(seg(a, 0.15, 0.5));
- const aPistons = outBack(seg(a, 0.3, 0.65));
- const aCrown = outBack(seg(a, 0.55, 0.9));
- const aReels = outBack(seg(a, 0.45, 0.85));
- const aFilm = seg(a, 0.7, 1);
- const aClips = seg(a, 0.75, 1);
-
- chassis.position.y = (1 - aChassis) * -1.2;
- chassis.scale.setScalar(Math.max(0.001, aChassis));
- core.position.y = (1 - aCore) * 3;
- core.scale.setScalar(Math.max(0.001, aCore));
- crown.position.y = (1 - aCrown) * 2.4;
- crown.scale.setScalar(Math.max(0.001, aCrown));
-
- crystalAngle += dt * (0.6 + s.rotor * 0.2);
- crystal.rotation.y = crystalAngle;
- clipRing.rotation.set(Math.PI / 2, 0, -crystalAngle * 1.5);
- mat.crystal.emissiveIntensity = 0.55 + Math.sin(time * 2.2) * 0.2 + pulse * 0.8;
- coreLight.intensity = (6 + s.glow * 6 + pulse * 14) * aCore;
-
- sparkOffset = (sparkOffset + dt * (0.12 + s.rotor * 0.06)) % 1;
- for (let j = 0; j < SPARK_COUNT; j++) {
- const phase = (sparkOffset + j / SPARK_COUNT) % 1;
- const angle = j * 2.4 + phase * 6;
- const radius = 0.5 + Math.sin(j * 1.7) * 0.22;
- position.set(Math.sin(angle) * radius, 0.95 + phase * 2.5, Math.cos(angle) * radius);
- const size = Math.max(0.001, Math.sin(phase * Math.PI) * (0.7 + pulse * 0.6));
- scale.set(size, size, size);
- euler.set(phase * 6, angle, 0);
- quaternion.setFromEuler(euler);
- matrix.compose(position, quaternion, scale);
- sparks.setMatrixAt(j, matrix);
+ camera.lookAt(target);
+ fog.near = dist * 0.72;
+ fog.far = dist * 1.95;
+
+ playU = wrap(playU + dt * s.sweep * 0.07 * motion);
+ const phX = -HALF + playU * TIMELINE_LENGTH;
+ playhead.position.x = phX;
+ sweep.value = phX;
+
+ if (!revealed || a < 1) {
+ holos.forEach((h) => {
+ const r = seg(a, h.from, h.to);
+ h.geometry.instanceCount = r >= 1 ? h.count : Math.ceil(h.count * r);
+ });
+ revealed = a >= 1;
}
- sparks.instanceMatrix.needsUpdate = true;
+ const boot = a < 1 ? 0.55 + 0.45 * Math.abs(Math.sin(time * 37)) : 1;
- pistons.forEach(({ arm, head }, i) => {
- const ai = outBack(seg(a, 0.3 + i * 0.04, 0.6 + i * 0.04));
- arm.scale.set(1, Math.max(0.001, ai), 1);
- head.position.y = 1.66 + s.pistons * 0.28 * Math.sin(time * 3 + i * 1.05) * aPistons;
+ L.hooks.vis = s.hooks;
+ L.razor.vis = s.razor;
+ L.caret.vis = s.captions;
+ L.chips.vis = 0.45 + s.captions * 0.55;
+ L.tether.vis = s.tether;
+ L.ingest.vis = 0.25 + s.ingest * 0.75;
+ L.gate.vis = s.gate;
+ L.laser.vis = s.gate;
+ L.check.vis = Math.max(s.gate, s.lanes);
+ L.lanes.vis = s.lanes;
+ L.bin.vis = s.bin;
+ L.loop.vis = s.loop;
+ L.wave.vis = 0.55 + s.wave * 0.45;
+ L.waveHot.vis = 0.55 + s.wave * 0.45;
+ const glow = s.glow * (1 + pulse * 0.8);
+ layers.forEach((entry) => {
+ entry.core.opacity = palette.lineAlpha * entry.vis * boot;
+ entry.halo.opacity = palette.haloAlpha * entry.vis * glow * boot;
+ entry.core.visible = entry.vis > 0.01;
+ entry.halo.visible = entry.vis > 0.01 && entry.halo.linewidth > 0;
});
+ L.waveHot.core.color.set(palette.line).lerp(color.set(palette.hot), s.hooks);
+ L.waveHot.halo.color.copy(L.waveHot.core.color);
- rotors.forEach(({ mount, spin }, k) => {
- const ak = outBack(seg(a, 0.4 + k * 0.08, 0.72 + k * 0.08));
- rotorAngles[k] += (k % 2 ? -1 : 1) * (0.35 + k * 0.25) * s.rotor * dt;
- spin.rotation.y = rotorAngles[k];
- mount.position.y = ROTOR_HEIGHTS[k] + (1 - ak) * (2.5 + k);
- mount.scale.setScalar(Math.max(0.001, ak));
+ const faceFade = seg(a, 0.2, 0.9);
+ faces.forEach((face) => {
+ face.material.uniforms.uOpacity.value = palette.faceAlpha * face.alpha * faceFade;
});
+ faceMat.gate.uniforms.uOpacity.value *= s.gate;
+ faceMat.lanes.uniforms.uOpacity.value *= s.lanes;
+ faceMat.sheet.uniforms.uOpacity.value *= 0.6 + s.razor * 0.6 + pulse * 0.5;
- const crownLive = s.crown;
- transmitters.forEach((holder, k) => {
- holder.scale.setScalar(Math.max(0.001, 0.6 + crownLive * 0.4));
- holder.rotation.y = transmitterAngle(k) * DEG + Math.sin(time * 0.8 + k) * 0.06 * crownLive;
- });
- ripples.forEach((ring) => (ring.visible = crownLive > 0.05));
- beacon.scale.setScalar(1 + crownLive * 0.4 + Math.sin(time * 4) * 0.12 * crownLive);
-
- reelAngle += s.reel * TAU * dt;
- reels.forEach(({ group, reel, side }) => {
- group.position.x = side * (2.75 + (1 - aReels) * 2);
- group.scale.setScalar(Math.max(0.001, aReels));
- reel.rotation.z = -reelAngle;
+ labels.forEach((label) => {
+ label.material.opacity = palette.labelAlpha * label.layer.vis * faceFade;
+ label.sprite.visible = label.layer.vis > 0.01;
});
- filmOffset = (filmOffset + (s.strip / FILM_FRAMES) * dt) % 1;
- const filmScale = s.stripShow * aFilm;
- film.children.forEach((child) => {
- if (child !== frames && child !== hookGlow) child.scale.setScalar(Math.max(0.001, aFilm));
+ const waveMotion = options.reduced ? 0.8 : time;
+ waveGroups.forEach((group, g) => {
+ const array = waveBuffers[g].array as Float32Array;
+ group.xs.forEach((x, i) => {
+ const h = waveAmplitude(x, waveMotion, s.hooks) * (0.45 + s.wave * 0.75);
+ array[i * 6 + 1] = TRACKS[0].y - h;
+ array[i * 6 + 4] = TRACKS[0].y + h;
+ });
+ waveBuffers[g].needsUpdate = true;
});
- for (let i = 0; i < FILM_FRAMES; i++) {
- const u = (filmOffset + i / FILM_FRAMES) % 1;
- const edgeFade = Math.min(1, u / 0.06, (1 - u) / 0.06);
- filmCurve.getPointAt(u, position);
- filmCurve.getTangentAt(u, tangent);
- xAxis.copy(tangent).normalize();
- zAxis.crossVectors(xAxis, up).normalize();
- yAxis.crossVectors(zAxis, xAxis).normalize();
- matrix.makeBasis(xAxis, yAxis, zAxis);
- const sc = Math.max(0.001, filmScale * edgeFade);
- scale.set(sc, sc, sc);
- matrix.scale(scale);
- matrix.setPosition(position);
- frames.setMatrixAt(i, matrix);
- const hook = isHookFrame(i);
- color.copy(frameColors[i]).lerp(accentColor, hook ? s.hooks * 0.7 : 0);
- frames.setColorAt(i, color);
- const glowScale = Math.max(0.001, hook ? s.hooks * sc : 0.001);
- scale.set(glowScale, glowScale, glowScale);
- matrix.makeBasis(xAxis, yAxis, zAxis);
- matrix.scale(scale);
- matrix.setPosition(position.addScaledVector(zAxis, 0.02));
- hookGlow.setMatrixAt(i, matrix);
- }
- frames.instanceMatrix.needsUpdate = true;
- hookGlow.instanceMatrix.needsUpdate = true;
- if (frames.instanceColor) frames.instanceColor.needsUpdate = true;
- mat.glow.opacity = 0.35 + Math.sin(time * 6) * 0.15;
-
- scanner.position.y = 2.2 + Math.sin(time * 1.6) * 1.15;
- scanner.scale.setScalar(Math.max(0.001, s.scanner));
- scanner.rotation.y = time * 0.8;
-
- const cycle = (time % 1.1) / 1.1;
- const slam = cycle < 0.18 ? (cycle / 0.18) ** 2 : cycle < 0.3 ? 1 : 1 - (cycle - 0.3) / 0.7;
- cutter.scale.setScalar(Math.max(0.001, s.cutter));
- ram.position.y = 1.78 - slam * 0.7 * s.cutter;
- flash.intensity = s.cutter * (cycle >= 0.18 && cycle < 0.3 ? 26 : 0);
- flash.color.copy(accentColor);
-
- gateAngle += dt * 1.6 * s.gate;
- gateInner.rotation.z = gateAngle;
- gate.scale.setScalar(Math.max(0.001, s.gate));
-
- vault.scale.setScalar(Math.max(0.001, 0.55 + s.vault * 0.45));
- platters.forEach((platter, k) => {
- platter.position.y = 0.36 + k * 0.1 * s.vault;
- platter.visible = s.vault > 0.05 || k === 0;
+ hookMarks.position.y = Math.sin(time * 3) * 0.03 * s.hooks;
+
+ const chipIndex = Math.floor(time * 2.6) % CAPTION_WORDS;
+ const chip = chips[chipIndex];
+ caret.position.x = chip.x;
+ caret.scale.set(chip.w + 0.1, 1, 0.46);
+
+ src.scale.setScalar(Math.max(0.001, seg(a, 0.2, 0.6)));
+ ingestParticles.forEach((particle, k) => {
+ const u = wrap(k / INGEST_PARTICLES + time * 0.45);
+ const x0 = SRC.x + SRC.size / 2;
+ particle.position.set(
+ x0 + (phX - x0) * u,
+ FOOTAGE_Y + 0.08 + Math.sin(k * 2.1) * 0.05,
+ Math.sin(k * 1.7) * 0.5,
+ );
+ particle.material.opacity = s.ingest * Math.sin(u * Math.PI) * 0.9;
+ particle.visible = s.ingest > 0.01;
});
- for (let i = 0; i < CLIP_COUNT; i++) {
+ const boundary = Math.min(
+ ...Array.from({ length: CLIP_COUNT + 1 }, (_, b) =>
+ Math.abs(phX - (-HALF + b * SEGMENT_W) * 1.06),
+ ),
+ );
+ const flash = s.razor * Math.max(0, 1 - boundary / 0.18);
+ spark.material.opacity = flash;
+ spark.visible = flash > 0.01;
+ spark.scale.setScalar(0.6 + flash * 1.2);
+ headGlow.material.opacity = 0.35 + pulse * 0.5;
+
+ laser.position.y = CARD_Y + Math.sin(time * 2.4) * 0.64;
+
+ const tetherArray = tetherBuffer.array as Float32Array;
+ cards.forEach((card, i) => {
const p = clipPoseAt(view.progress, i, options.reduced ? 0 : time);
- euler.set(p.rx * DEG, p.ry * DEG, 0, "YXZ");
- quaternion.setFromEuler(euler);
- const sc = Math.max(0.001, p.scale * aClips);
- position.set(p.x, p.y, p.z);
- scale.set(sc, sc, sc);
- matrix.compose(position, quaternion, scale);
- clips.setMatrixAt(i, matrix);
- color.copy(clipColors[i]).lerp(accentColor, p.x > GATE_X ? s.gate * 0.5 : 0);
- clips.setColorAt(i, color);
- local.makeTranslation(0, 0, -0.018);
- clipRims.setMatrixAt(i, local.premultiply(matrix));
- local.makeTranslation(0, -0.24, 0.004);
- captions.setMatrixAt(i, local.premultiply(matrix));
- }
- clips.instanceMatrix.needsUpdate = true;
- if (clips.instanceColor) clips.instanceColor.needsUpdate = true;
- clipRims.instanceMatrix.needsUpdate = true;
- captions.instanceMatrix.needsUpdate = true;
+ const sc = p.scale * seg(a, 0.7, 1);
+ card.group.visible = sc > 0.18;
+ card.group.position.set(p.x, p.y, p.z);
+ card.group.rotation.set(p.rx * DEG, p.ry * DEG, 0);
+ card.group.scale.set(p.w * sc, p.h * sc, 1);
+ const ghost = s.ingest > 0.5 && segmentX(i) > phX;
+ const cutting = s.razor > 0.5 && Math.abs(p.x - phX) < SEGMENT_W * 0.55;
+ const approved = clipApproved(s, i, options.reduced ? 0 : time);
+ const hot = cutting || approved;
+ const target = ghost ? L.clipGhost : hot ? L.clipHot : L.clip;
+ card.core.material = target.core;
+ card.halo.material = target.halo;
+ card.halo.visible = !ghost;
+ card.face.material = hot ? faceMat.clipHot : faceMat.clip;
+ card.face.visible = !ghost;
+ card.check.visible = approved && !ghost;
+ const o = i * 6;
+ tetherArray[o] = p.x;
+ tetherArray[o + 1] = p.y - (p.h * sc) / 2;
+ tetherArray[o + 2] = p.z;
+ tetherArray[o + 3] = segmentX(i);
+ tetherArray[o + 4] = FOOTAGE_Y;
+ tetherArray[o + 5] = 0;
+ });
+ tetherBuffer.needsUpdate = true;
+
+ jogAngle += dt * s.sweep * 1.4 * motion;
+ jogOuter.rotation.y = -jogAngle;
+ jogInner.rotation.y = jogAngle * 0.6;
+
+ const loopLive = s.loop;
+ pulses.forEach((pulseSprite, k) => {
+ const q = loopPoint(time * 0.9 + (k / LOOP_PULSES) * TAU);
+ pulseSprite.position.set(q.x, q.y, q.z);
+ pulseSprite.material.opacity = loopLive;
+ pulseSprite.visible = loopLive > 0.01;
+ });
+
+ gridMaterial.opacity = (palette.additive ? 0.22 : 0.4) * faceFade;
renderer.render(scene, camera);
};
@@ -776,15 +1051,16 @@ export function createClipEngine(
const height = Math.max(1, host.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
- camera.fov = width / height < 0.8 ? 40 : 32;
+ camera.fov = width / height < 0.8 ? 42 : 32;
+ const glowScale = Math.min(1, Math.max(0.5, width / 820));
+ layers.forEach((entry) => (entry.halo.linewidth = entry.glow * glowScale));
camera.updateProjectionMatrix();
if (!running) update(0);
};
let running = false;
- let visible = true;
let last = 0;
- const loop = (now: number) => {
+ const loopFrame = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
update(dt);
@@ -793,19 +1069,17 @@ export function createClipEngine(
if (options.reduced || next === running) return;
running = next;
last = 0;
- renderer.setAnimationLoop(next ? loop : null);
- rippleAnimations.forEach((animation) => (next ? animation.play() : animation.pause()));
+ renderer.setAnimationLoop(next ? loopFrame : null);
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(host);
const visibility = new IntersectionObserver((entries) => {
- visible = entries.some((entry) => entry.isIntersecting);
- setRunning(visible);
+ setRunning(entries.some((entry) => entry.isIntersecting));
});
visibility.observe(host);
resize();
- setRunning(visible);
+ setRunning(true);
return {
view,
@@ -817,15 +1091,14 @@ export function createClipEngine(
if (!running) update(0);
},
dispose: () => {
+ disposed = true;
renderer.setAnimationLoop(null);
resizeObserver.disconnect();
visibility.disconnect();
- rippleAnimations.forEach((animation) => animation.pause());
geometries.forEach((geometry) => geometry.dispose());
- Object.values(mat).forEach((material) => material.dispose());
+ materials.forEach((material) => material.dispose());
+ textures.forEach((texture) => texture.dispose());
gridMaterial.dispose();
- ripples.forEach((ring) => (ring.material as THREE.Material).dispose());
- shadowMap.dispose();
renderer.dispose();
renderer.domElement.remove();
},
diff --git a/src/lib/clip-engine.test.ts b/src/lib/clip-engine.test.ts
index 07b4c87..656ca51 100644
--- a/src/lib/clip-engine.test.ts
+++ b/src/lib/clip-engine.test.ts
@@ -1,29 +1,43 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
+ BIN_COLUMNS,
+ CARD_H,
+ CARD_W,
+ CARD_Y,
CLIP_COUNT,
ENGINE_STAGES,
+ FOOTAGE_Y,
GATE_SPAN,
GATE_X,
- GATE_Z,
+ LANE_ORIGIN,
LAST_STAGE,
- TRANSMITTER_COUNT,
+ LOOP,
+ NETWORKS,
+ RULER_SECONDS,
+ SEGMENT_W,
+ TIMELINE_LENGTH,
+ captionChips,
+ clipApproved,
clipPose,
clipPoseAt,
engineStateAt,
- hookNetwork,
- isHookFrame,
+ hookX,
+ laneDirection,
+ readout,
+ segmentX,
smoothstep,
stageFormation,
stageIndex,
stageState,
timecode,
- transmitterAngle,
+ waveAmplitude,
} from "./clip-engine.ts";
const clips = Array.from({ length: CLIP_COUNT }, (_, index) => index);
+const on = (key: Parameters[0]) => stageState(stageIndex(key));
-test("the engine runs ten stages from the assembled core to the online lap", () => {
+test("the engine runs ten stages from the assembled timeline to the online lap", () => {
assert.deepEqual(
ENGINE_STAGES.map((stage) => stage.key),
[
@@ -51,140 +65,194 @@ test("only the clip pipeline stages are marked as rolling out", () => {
test("each stage arranges the clips in its own formation", () => {
assert.deepEqual(
- ENGINE_STAGES.map((_, stage) => stageFormation(stage)),
- ["halo", "reel", "reel", "cut", "spiral", "gate", "broadcast", "vault", "orbit", "halo"],
+ ENGINE_STAGES.map((_, index) => stageFormation(index)),
+ ["track", "track", "track", "split", "reframe", "gate", "lanes", "bin", "loop", "track"],
);
});
-test("each stage switches on the moving parts that do its work", () => {
- const on = (key: (typeof ENGINE_STAGES)[number]["key"]) => stageState(stageIndex(key));
- assert.ok(on("ingest").reel > on("core").reel);
- assert.ok(on("ingest").strip > on("core").strip);
- assert.equal(on("detect").scanner, 1);
+test("each stage switches on the parts that do its work", () => {
+ assert.equal(on("ingest").ingest, 1);
assert.equal(on("detect").hooks, 1);
- assert.equal(on("cut").cutter, 1);
- assert.ok(on("render").rotor > 2);
+ assert.equal(on("cut").razor, 1);
+ assert.equal(on("render").captions, 1);
+ assert.equal(on("render").tether, 1);
assert.equal(on("approve").gate, 1);
- assert.equal(on("publish").crown, 1);
- assert.equal(on("library").vault, 1);
- assert.deepEqual(
- ENGINE_STAGES.map((_, stage) => stageState(stage).cutter),
- [0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
- );
+ assert.equal(on("publish").lanes, 1);
+ assert.equal(on("library").bin, 1);
+ assert.equal(on("agent").loop, 1);
+ const core = on("core");
+ assert.equal(core.gate + core.lanes + core.loop + core.razor + core.ingest, 0);
});
test("the online stage finishes a full camera lap back to the assembled view", () => {
- const core = stageState(stageIndex("core"));
- const online = stageState(stageIndex("online"));
- assert.equal(online.cam.rot - core.cam.rot, 360);
- assert.equal(online.cam.pitch, core.cam.pitch);
- assert.equal(online.cam.dist, core.cam.dist);
+ const core = on("core").cam;
+ const online = on("online").cam;
+ assert.equal(online.rot - core.rot, 360);
+ assert.deepEqual({ ...online, rot: 0 }, { ...core, rot: 0 });
});
test("engine state lands exactly on each stage and eases between them", () => {
- ENGINE_STAGES.forEach((_, stage) => assert.deepEqual(engineStateAt(stage), stageState(stage)));
- const halfway = engineStateAt(0.5);
- assert.equal(halfway.cam.rot, 15);
- assert.equal(halfway.reel, (stageState(0).reel + stageState(1).reel) / 2);
- assert.ok(engineStateAt(0.1).cam.rot > 27);
+ const approve = stageIndex("approve");
+ assert.deepEqual(engineStateAt(approve), stageState(approve));
+ const halfway = engineStateAt(approve + 0.5);
+ assert.equal(halfway.gate, 0.5);
+ assert.equal(halfway.lanes, 0.5);
+ assert.equal(halfway.cam.x, (on("approve").cam.x + on("publish").cam.x) / 2);
});
test("progress outside the run clamps to the first and last stage", () => {
- assert.deepEqual(engineStateAt(-2), stageState(0));
- assert.deepEqual(engineStateAt(99), stageState(LAST_STAGE));
+ assert.deepEqual(engineStateAt(-3), stageState(0));
+ assert.deepEqual(engineStateAt(42), stageState(LAST_STAGE));
});
test("smoothstep eases in and out and clamps its input", () => {
- assert.deepEqual([-1, 0, 0.5, 1, 2].map(smoothstep), [0, 0, 0.5, 1, 1]);
- assert.ok(smoothstep(0.1) < 0.1);
+ assert.deepEqual(
+ [smoothstep(-1), smoothstep(0), smoothstep(0.5), smoothstep(1), smoothstep(2)],
+ [0, 0, 0.5, 1, 1],
+ );
});
-test("the halo keeps every clip on its ring, facing outward", () => {
- clips.forEach((index) => {
- const clip = clipPose("halo", index);
- assert.ok(Math.abs(Math.hypot(clip.x, clip.z) - 3.4) < 0.01);
- assert.equal(clip.ry, Math.round((index / CLIP_COUNT) * 360 * 1000) / 1000);
+test("the footage track tiles the timeline with flat clip segments", () => {
+ const row = clips.map((index) => clipPose("track", index));
+ assert.ok(Math.abs(row[0].x - (-TIMELINE_LENGTH / 2 + SEGMENT_W / 2)) < 0.001);
+ row.slice(1).forEach((clip, index) => {
+ assert.ok(Math.abs(clip.x - row[index].x - SEGMENT_W) < 0.002);
});
+ assert.ok(row.every((clip) => clip.rx === -90 && clip.w < SEGMENT_W));
+ assert.ok(row.every((clip) => clip.y > FOOTAGE_Y && clip.y < FOOTAGE_Y + 0.1));
});
-test("clips wait inside the supply reel until the cut", () => {
- assert.deepEqual(
- clips.map((index) => clipPose("reel", index).scale),
- clips.map(() => 0),
- );
- assert.ok(clips.every((index) => clipPose("cut", index).scale > 0));
+test("the cut lifts every segment off the track and pulls them apart", () => {
+ const track = clips.map((index) => clipPose("track", index));
+ const cut = clips.map((index) => clipPose("split", index, 0));
+ cut.forEach((clip, index) => assert.ok(clip.y > track[index].y + 0.2));
+ cut.slice(1).forEach((clip, index) => assert.ok(clip.x - cut[index].x > SEGMENT_W));
});
-test("the cut lays the clips out in one row in front of the cutter", () => {
- const row = clips.map((index) => clipPose("cut", index, 0));
- row.slice(1).forEach((clip, index) => assert.ok(clip.x > row[index].x));
- assert.ok(row.every((clip) => clip.z === 3.7));
- assert.equal(row[0].x, -row[CLIP_COUNT - 1].x);
+test("render stands every clip up as a vertical 9:16 card above the stack", () => {
+ const cards = clips.map((index) => clipPose("reframe", index, 0));
+ assert.ok(cards.every((card) => card.rx === 0 && card.w === CARD_W && card.h === CARD_H));
+ assert.ok(Math.abs(CARD_H / CARD_W - 16 / 9) < 0.01);
+ assert.ok(cards.every((card) => Math.abs(card.y - CARD_Y) <= 0.051));
});
test("the approval gate carries every clip through the gate on a conveyor", () => {
for (const time of [0, 1.7, 9.3, 42]) {
for (const index of clips) {
const clip = clipPose("gate", index, time);
- assert.equal(clip.z, GATE_Z);
+ assert.equal(clip.z, 0);
assert.ok(Math.abs(clip.x - GATE_X) <= GATE_SPAN / 2);
- assert.ok(clip.scale >= 0 && clip.scale <= 0.9);
+ assert.ok(clip.scale >= 0 && clip.scale <= 1);
}
}
const entering = clipPose("gate", 0, 0);
assert.equal(entering.x, GATE_X - GATE_SPAN / 2);
assert.equal(entering.scale, 0);
- const crossing = clipPose("gate", 0, 0.5 / 0.06);
+ const crossing = clipPose("gate", 0, 0.5 / 0.05);
assert.ok(Math.abs(crossing.x - GATE_X) < 0.01);
- assert.equal(crossing.scale, 0.9);
- assert.ok(clipPose("gate", 0, 1).x > entering.x);
+ assert.equal(crossing.scale, 1);
});
-test("broadcast sends three clips to each of the four transmitters", () => {
- const groups = new Map();
- clips.forEach((index) => {
- const clip = clipPose("broadcast", index, 0);
- groups.set(clip.ry, (groups.get(clip.ry) ?? 0) + 1);
- });
- assert.deepEqual(
- [...groups.entries()].sort((a, b) => a[0] - b[0]),
- Array.from({ length: TRANSMITTER_COUNT }, (_, k) => [transmitterAngle(k), 3]),
+test("publish sends three clips down each of the four network lanes", () => {
+ const perLane = NETWORKS.map((_, lane) =>
+ clips.filter((index) => index % NETWORKS.length === lane),
);
+ assert.ok(perLane.every((lane) => lane.length === 3));
+ for (const time of [0, 2.5, 11]) {
+ clips.forEach((index) => {
+ const clip = clipPose("lanes", index, time);
+ const dir = laneDirection(index % NETWORKS.length);
+ const dx = clip.x - LANE_ORIGIN.x;
+ const dy = clip.y - CARD_H * 0.36 - LANE_ORIGIN.y;
+ assert.ok(Math.abs(dx * dir.y - dy * dir.x) < 0.01);
+ assert.ok(dx * dir.x + dy * dir.y > 0);
+ });
+ }
});
-test("the vault stacks every clip flat, one above the other", () => {
- const stack = clips.map((index) => clipPose("vault", index));
- assert.ok(stack.every((clip) => clip.rx === -90 && clip.x === 0 && clip.z === -3.3));
- stack.slice(1).forEach((clip, index) => assert.ok(clip.y > stack[index].y));
+test("the library files every clip flat into a six by two media bin", () => {
+ const bin = clips.map((index) => clipPose("bin", index));
+ assert.ok(bin.every((clip) => clip.rx === -90));
+ assert.equal(new Set(bin.map((clip) => clip.x)).size, BIN_COLUMNS);
+ assert.equal(new Set(bin.map((clip) => clip.z)).size, CLIP_COUNT / BIN_COLUMNS);
+ assert.equal(new Set(bin.map((clip) => `${clip.x}:${clip.z}`)).size, CLIP_COUNT);
+});
+
+test("the Hermes loop keeps every clip on the loop around the machine", () => {
+ for (const time of [0, 3.3]) {
+ clips.forEach((index) => {
+ const clip = clipPose("loop", index, time);
+ const r = (clip.x / LOOP.x) ** 2 + ((clip.z - LOOP.center) / LOOP.z) ** 2;
+ assert.ok(Math.abs(r - 1) < 0.01);
+ });
+ }
});
test("clip poses blend between the formations of neighbouring stages", () => {
const cut = stageIndex("cut");
- const halo = clipPoseAt(0, 3, 1.5);
- assert.deepEqual(halo, clipPose("halo", 3, 1.5));
- assert.deepEqual(clipPoseAt(cut, 3, 1.5), clipPose("cut", 3, 1.5));
- const between = clipPoseAt(cut - 0.5, 3, 1.5);
- assert.equal(between.scale, Math.round(0.62 * 0.5 * 1000) / 1000);
+ const from = clipPose("split", 4, 0);
+ const to = clipPose("reframe", 4, 0);
+ const between = clipPoseAt(cut + 0.5, 4, 0);
+ assert.ok(Math.abs(between.x - (from.x + to.x) / 2) < 0.002);
+ assert.ok(Math.abs(between.rx - (from.rx + to.rx) / 2) < 0.002);
+ assert.deepEqual(clipPoseAt(cut, 4, 0), from);
});
-test("every fourth film frame is a hook", () => {
- assert.deepEqual(
- Array.from({ length: 9 }, (_, index) => isHookFrame(index)),
- [false, true, false, false, false, true, false, false, false],
- );
+test("every clip carries one hook inside its own segment", () => {
+ clips.forEach((index) => {
+ const x = hookX(index);
+ assert.ok(x > segmentX(index) - SEGMENT_W / 2 && x < segmentX(index) + SEGMENT_W / 2);
+ });
});
-test("hook networks cycle through X, YouTube, Instagram and TikTok", () => {
- assert.deepEqual(
- [0, 1, 2, 3, 4].map((clip) => hookNetwork(clip)),
- ["X", "YouTube", "Instagram", "TikTok", "X"],
- );
+test("the waveform spikes at the hooks only while hook detection runs", () => {
+ const at = hookX(3);
+ assert.ok(waveAmplitude(at, 1.2, 1) > waveAmplitude(at, 1.2, 0) + 0.3);
+ assert.ok(waveAmplitude(at, 1.2, 1) <= 0.62);
+ assert.ok(waveAmplitude(segmentX(3) + SEGMENT_W * 0.3, 1.2, 1) < 0.3);
+});
+
+test("caption chips run left to right across the caption track without overlapping", () => {
+ const chips = captionChips();
+ chips.slice(1).forEach((chip, index) => {
+ const prev = chips[index];
+ assert.ok(chip.x - chip.w / 2 > prev.x + prev.w / 2);
+ });
+ const first = chips[0];
+ const last = chips[chips.length - 1];
+ assert.ok(first.x - first.w / 2 >= -TIMELINE_LENGTH / 2);
+ assert.ok(last.x + last.w / 2 <= TIMELINE_LENGTH / 2);
+});
+
+test("networks cover X, YouTube, Instagram and TikTok", () => {
+ assert.deepEqual([...NETWORKS], ["X", "YouTube", "Instagram", "TikTok"]);
});
-test("timecode counts 24fps frames across three seconds per stage", () => {
+test("timecode counts 24fps frames and ends where the ruler ends", () => {
assert.equal(timecode(0), "00:00:00:00");
- assert.equal(timecode(0.5), "00:00:13:12");
- assert.equal(timecode(1), "00:00:27:00");
- assert.equal(timecode(4), "00:00:27:00");
- assert.equal(timecode(-1), "00:00:00:00");
+ assert.equal(timecode(1), `00:00:${RULER_SECONDS}:00`);
+ assert.equal(timecode(0.5), "00:00:12:00");
+ assert.equal(timecode(0.25), "00:00:06:00");
+});
+
+test("clips are signed off only once they pass the review gate", () => {
+ const approve = stageIndex("approve");
+ const passed = clips.filter((index) => clipPose("gate", index, 0).x > GATE_X);
+ const waiting = clips.filter((index) => clipPose("gate", index, 0).x <= GATE_X);
+ assert.ok(passed.length > 0 && waiting.length > 0);
+ const onGate = engineStateAt(approve);
+ passed.forEach((index) => assert.equal(clipApproved(onGate, index, 0), true));
+ waiting.forEach((index) => assert.equal(clipApproved(onGate, index, 0), false));
+ const entering = engineStateAt(approve - 0.3);
+ clips.forEach((index) => assert.equal(clipApproved(entering, index, 0), false));
+ const leaving = engineStateAt(approve + 0.35);
+ passed.forEach((index) => assert.equal(clipApproved(leaving, index, 0), true));
+ const midway = engineStateAt(approve + 0.5);
+ clips.forEach((index) => assert.equal(clipApproved(midway, index, 0), true));
+});
+
+test("the readout names the track in focus and the zoom", () => {
+ assert.equal(readout(0), "all tracks · zoom 1.08x");
+ assert.ok(readout(stageIndex("cut")).startsWith("V1 razor · zoom "));
+ assert.ok(readout(stageIndex("publish")).startsWith("out 4 lanes"));
});
diff --git a/src/lib/clip-engine.ts b/src/lib/clip-engine.ts
index 1913ef0..523d7ff 100644
--- a/src/lib/clip-engine.ts
+++ b/src/lib/clip-engine.ts
@@ -16,14 +16,32 @@ export type EngineStageKey = (typeof ENGINE_STAGES)[number]["key"];
export const STAGE_COUNT = ENGINE_STAGES.length;
export const LAST_STAGE = STAGE_COUNT - 1;
export const CLIP_COUNT = 12;
-export const FILM_FRAMES = 26;
-export const HOOK_EVERY = 4;
-export const PISTON_COUNT = 6;
-export const ROTOR_HEIGHTS = [1.45, 2.25, 3.05] as const;
-export const TRANSMITTER_COUNT = 4;
-export const GATE_X = -1.5;
-export const GATE_Z = 4.1;
-export const GATE_SPAN = 7;
+
+export const TIMELINE_LENGTH = 8;
+export const TRACK_DEPTH = 1.5;
+export const TRACKS = [
+ { key: "A1", label: "Audio", y: 0.9 },
+ { key: "V1", label: "Footage", y: 1.85 },
+ { key: "C1", label: "Captions", y: 2.8 },
+] as const;
+export const FOOTAGE_Y = 1.85;
+export const RULER_Y = 3.75;
+export const CARD_Y = 4.9;
+export const SEGMENT_W = TIMELINE_LENGTH / CLIP_COUNT;
+export const CARD_W = 0.5;
+export const CARD_H = 0.89;
+export const GATE_X = 0;
+export const GATE_SPAN = TIMELINE_LENGTH;
+export const LANE_ORIGIN = { x: 4.7, y: RULER_Y, z: 0 } as const;
+export const LANE_ANGLES = [0, 22, 44, 66] as const;
+export const LANE_LENGTH = 4;
+export const BIN_COLUMNS = 6;
+export const BIN_ROWS = 2;
+export const BIN_Z = 1.6;
+export const LOOP = { x: 5.9, z: 3.2, y: 1.85, center: 0.5 } as const;
+export const WAVE_BARS = 112;
+export const CAPTION_WORDS = 14;
+export const RULER_SECONDS = 24;
export const NETWORKS = ["X", "YouTube", "Instagram", "TikTok"] as const;
export const PIPELINE_STEPS = ["Ingest", "Detect", "Cut", "Caption + render"] as const;
@@ -34,175 +52,192 @@ export const STORAGE_LAYERS = [
] as const;
export const AGENT_LINES = ["MCP tools", "Playbooks", "Isolated skills", "Linear sync"] as const;
export const ENGINE_PARTS = [
- { count: 2, label: "film reels" },
- { count: ROTOR_HEIGHTS.length, label: "render rotors" },
- { count: TRANSMITTER_COUNT, label: "transmitters" },
+ { count: 1, label: "playhead" },
+ { count: TRACKS.length, label: "tracks" },
+ { count: NETWORKS.length, label: "output lanes" },
{ count: CLIP_COUNT, label: "clips" },
] as const;
-export type EngineCamera = { rot: number; pitch: number; dist: number; y: number };
+export type EngineCamera = {
+ rot: number;
+ pitch: number;
+ dist: number;
+ x: number;
+ y: number;
+ z: number;
+};
export type EngineState = {
cam: EngineCamera;
- reel: number;
- strip: number;
- stripShow: number;
+ sweep: number;
+ ingest: number;
+ wave: number;
hooks: number;
- scanner: number;
- cutter: number;
- rotor: number;
- pistons: number;
+ razor: number;
+ tether: number;
+ captions: number;
gate: number;
- crown: number;
- vault: number;
+ lanes: number;
+ bin: number;
+ loop: number;
glow: number;
};
-export type FormationKey =
- "halo" | "reel" | "cut" | "spiral" | "gate" | "broadcast" | "vault" | "orbit";
+export type FormationKey = "track" | "split" | "reframe" | "gate" | "lanes" | "bin" | "loop";
-export type ClipPose = { x: number; y: number; z: number; rx: number; ry: number; scale: number };
+export type ClipPose = {
+ x: number;
+ y: number;
+ z: number;
+ rx: number;
+ ry: number;
+ w: number;
+ h: number;
+ scale: number;
+};
-type StageConfig = EngineState & { formation: FormationKey };
+type StageConfig = EngineState & { formation: FormationKey; focus: string };
-const BASE: Omit = {
- reel: 0.25,
- strip: 1.2,
- stripShow: 1,
+const BASE: Omit = {
+ sweep: 1,
+ ingest: 0,
+ wave: 0.5,
hooks: 0,
- scanner: 0,
- cutter: 0,
- rotor: 1,
- pistons: 0.4,
+ razor: 0,
+ tether: 0,
+ captions: 0.2,
gate: 0,
- crown: 0.4,
- vault: 0,
- glow: 1.2,
+ lanes: 0,
+ bin: 0.25,
+ loop: 0,
+ glow: 1,
};
const STAGES: Record = {
- core: { ...BASE, cam: { rot: 30, pitch: -16, dist: 20.5, y: 2.5 }, formation: "halo" },
+ core: {
+ ...BASE,
+ cam: { rot: 42, pitch: -21, dist: 19.5, x: -0.2, y: 1.9, z: 0.6 },
+ formation: "track",
+ focus: "all tracks",
+ },
ingest: {
...BASE,
- cam: { rot: 0, pitch: -8, dist: 16.5, y: 2.3 },
- reel: 1.4,
- strip: 6,
- rotor: 0.6,
- pistons: 0.3,
- crown: 0,
- glow: 1,
- formation: "reel",
+ cam: { rot: 50, pitch: -14, dist: 13, x: -3.4, y: 1.9, z: 0 },
+ sweep: 1.4,
+ ingest: 1,
+ wave: 0.3,
+ formation: "track",
+ focus: "V1 source in",
},
detect: {
...BASE,
- cam: { rot: -28, pitch: -12, dist: 15.5, y: 2.2 },
- reel: 0.8,
- strip: 3,
+ cam: { rot: 12, pitch: -22, dist: 12.5, x: -0.8, y: 1.4, z: 0 },
+ sweep: 0.8,
+ wave: 1,
hooks: 1,
- scanner: 1,
- rotor: 0.6,
- pistons: 0.3,
- crown: 0,
- glow: 1.4,
- formation: "reel",
+ glow: 1.2,
+ formation: "track",
+ focus: "A1 hook scan",
},
cut: {
...BASE,
- cam: { rot: 18, pitch: -13, dist: 17, y: 1.9 },
- reel: 0.6,
- strip: 2.4,
- hooks: 1,
- cutter: 1,
- rotor: 0.8,
- pistons: 0.6,
- crown: 0,
- glow: 1.6,
- formation: "cut",
+ cam: { rot: -20, pitch: -28, dist: 13.5, x: 1, y: 2.5, z: 0 },
+ sweep: 1.8,
+ wave: 0.6,
+ hooks: 0.6,
+ razor: 1,
+ tether: 0.3,
+ glow: 1.3,
+ formation: "split",
+ focus: "V1 razor",
},
render: {
...BASE,
- cam: { rot: 70, pitch: -20, dist: 18.5, y: 3 },
- reel: 0.3,
- strip: 1,
- stripShow: 0.6,
- rotor: 4,
- pistons: 1,
- crown: 0.2,
- glow: 2.2,
- formation: "spiral",
+ cam: { rot: 10, pitch: -12, dist: 18, x: 0.4, y: 5.4, z: 0 },
+ sweep: 0.6,
+ wave: 0.4,
+ tether: 1,
+ captions: 1,
+ glow: 1.3,
+ formation: "reframe",
+ focus: "C1 captions 9:16",
},
approve: {
...BASE,
- cam: { rot: -22, pitch: -11, dist: 19, y: 2.2 },
- reel: 0.2,
- strip: 0.8,
- stripShow: 0.5,
+ cam: { rot: -24, pitch: -10, dist: 15.5, x: 0.6, y: 5.5, z: 0 },
+ sweep: 0.4,
+ wave: 0.3,
+ captions: 0.5,
gate: 1,
- crown: 0.2,
- glow: 1.4,
+ glow: 1.1,
formation: "gate",
+ focus: "review gate",
},
publish: {
...BASE,
- cam: { rot: 40, pitch: -22, dist: 19, y: 3.8 },
- reel: 0.2,
- strip: 0.6,
- stripShow: 0.4,
- rotor: 1.5,
- pistons: 0.5,
- crown: 1,
- glow: 2,
- formation: "broadcast",
+ cam: { rot: 14, pitch: -16, dist: 17.5, x: 7.6, y: 6.7, z: 0 },
+ sweep: 0.5,
+ wave: 0.3,
+ lanes: 1,
+ glow: 1.4,
+ formation: "lanes",
+ focus: "out 4 lanes",
},
library: {
...BASE,
- cam: { rot: 150, pitch: -28, dist: 18.5, y: 1.8 },
- reel: 0.2,
- strip: 0.5,
- stripShow: 0.4,
- rotor: 0.6,
- pistons: 0.2,
- crown: 0.3,
- vault: 1,
- formation: "vault",
+ cam: { rot: 6, pitch: -46, dist: 14, x: 0.6, y: 0.6, z: 1.8 },
+ sweep: 0.3,
+ wave: 0.2,
+ bin: 1,
+ formation: "bin",
+ focus: "media bin",
},
agent: {
...BASE,
- cam: { rot: 215, pitch: -30, dist: 19.5, y: 2.8 },
- reel: 0.5,
- strip: 1.5,
- stripShow: 0.8,
- rotor: 2,
- pistons: 0.8,
- crown: 0.6,
- vault: 0.4,
- glow: 1.8,
- formation: "orbit",
+ cam: { rot: 150, pitch: -28, dist: 20, x: 0, y: 2, z: 0.4 },
+ sweep: 1.2,
+ wave: 0.6,
+ captions: 0.5,
+ loop: 1,
+ glow: 1.3,
+ formation: "loop",
+ focus: "hermes loop",
},
online: {
...BASE,
- cam: { rot: 390, pitch: -16, dist: 20.5, y: 2.5 },
- crown: 0.6,
- glow: 1.6,
- formation: "halo",
+ cam: { rot: 402, pitch: -21, dist: 19.5, x: -0.2, y: 1.9, z: 0.6 },
+ wave: 0.6,
+ captions: 0.4,
+ glow: 1.2,
+ formation: "track",
+ focus: "all tracks",
},
};
const NUMERIC_KEYS = [
- "reel",
- "strip",
- "stripShow",
+ "sweep",
+ "ingest",
+ "wave",
"hooks",
- "scanner",
- "cutter",
- "rotor",
- "pistons",
+ "razor",
+ "tether",
+ "captions",
"gate",
- "crown",
- "vault",
+ "lanes",
+ "bin",
+ "loop",
"glow",
] as const satisfies readonly (keyof EngineState)[];
+const CAMERA_KEYS = [
+ "rot",
+ "pitch",
+ "dist",
+ "x",
+ "y",
+ "z",
+] as const satisfies readonly (keyof EngineCamera)[];
+
const TAU = Math.PI * 2;
function clampStage(stage: number): number {
@@ -222,6 +257,10 @@ function round(value: number): number {
return rounded === 0 ? 0 : rounded;
}
+function wrap(value: number): number {
+ return ((value % 1) + 1) % 1;
+}
+
export function smoothstep(t: number): number {
const x = Math.min(1, Math.max(0, t));
return x * x * (3 - 2 * x);
@@ -242,8 +281,12 @@ export function stageFormation(stage: number): FormationKey {
return STAGES[stageKey(stage)].formation;
}
+export function stageFocus(stage: number): string {
+ return STAGES[stageKey(stage)].focus;
+}
+
export function stageState(stage: number): EngineState {
- const { formation: _formation, ...state } = STAGES[stageKey(stage)];
+ const { formation: _formation, focus: _focus, ...state } = STAGES[stageKey(stage)];
return { ...state, cam: { ...state.cam } };
}
@@ -255,114 +298,182 @@ export function engineStateAt(progress: number): EngineState {
NUMERIC_KEYS.forEach((key) => {
state[key] = round(lerp(a[key], b[key], t));
});
- state.cam = {
- rot: round(lerp(a.cam.rot, b.cam.rot, t)),
- pitch: round(lerp(a.cam.pitch, b.cam.pitch, t)),
- dist: round(lerp(a.cam.dist, b.cam.dist, t)),
- y: round(lerp(a.cam.y, b.cam.y, t)),
- };
+ CAMERA_KEYS.forEach((key) => {
+ state.cam[key] = round(lerp(a.cam[key], b.cam[key], t));
+ });
return state;
}
+export function readout(progress: number): string {
+ const { cam } = engineStateAt(progress);
+ const zoom = (21 / cam.dist).toFixed(2);
+ return `${stageFocus(progress)} · zoom ${zoom}x`;
+}
+
function degrees(radians: number): number {
return (radians * 180) / Math.PI;
}
-function pose(x: number, y: number, z: number, rx: number, ry: number, scale: number): ClipPose {
+function pose(
+ x: number,
+ y: number,
+ z: number,
+ rx: number,
+ ry: number,
+ w: number,
+ h: number,
+ scale: number,
+): ClipPose {
return {
x: round(x),
y: round(y),
z: round(z),
rx: round(rx),
ry: round(ry),
+ w: round(w),
+ h: round(h),
scale: round(scale),
};
}
-export function transmitterAngle(index: number): number {
- return 45 + (index % TRANSMITTER_COUNT) * 90;
+export function segmentX(index: number): number {
+ return -TIMELINE_LENGTH / 2 + (index + 0.5) * SEGMENT_W;
+}
+
+export function hookX(index: number): number {
+ return segmentX(index) - SEGMENT_W / 2 + SEGMENT_W * 0.3;
+}
+
+export function waveAmplitude(x: number, time: number, hooks: number): number {
+ const body =
+ 0.08 +
+ 0.16 * Math.abs(Math.sin(x * 3.1 + time * 2.3)) * Math.abs(Math.sin(x * 7.7 - time * 1.1));
+ let peak = 0;
+ for (let i = 0; i < CLIP_COUNT; i++) {
+ const d = (x - hookX(i)) / 0.11;
+ peak = Math.max(peak, Math.exp(-d * d));
+ }
+ return round(Math.min(0.62, body + peak * 0.36 * hooks));
+}
+
+export function captionChips(): { x: number; w: number }[] {
+ const widths = Array.from({ length: CAPTION_WORDS }, (_, i) => 0.3 + ((i * 7) % 5) * 0.08);
+ const gap = 0.1;
+ const total = widths.reduce((sum, w) => sum + w, 0) + gap * (CAPTION_WORDS - 1);
+ let cursor = -total / 2;
+ return widths.map((w) => {
+ const chip = { x: round(cursor + w / 2), w: round(w) };
+ cursor += w + gap;
+ return chip;
+ });
+}
+
+export function laneDirection(lane: number): { x: number; y: number } {
+ const a = (LANE_ANGLES[lane % LANE_ANGLES.length] * Math.PI) / 180;
+ return { x: Math.cos(a), y: Math.sin(a) };
+}
+
+export function lanePoint(lane: number, u: number): { x: number; y: number; z: number } {
+ const dir = laneDirection(lane);
+ const d = 0.35 + u * (LANE_LENGTH + 0.07);
+ return {
+ x: round(LANE_ORIGIN.x + dir.x * d),
+ y: round(LANE_ORIGIN.y + dir.y * d),
+ z: LANE_ORIGIN.z,
+ };
+}
+
+export function loopPoint(a: number): { x: number; y: number; z: number } {
+ return {
+ x: round(Math.cos(a) * LOOP.x),
+ y: round(LOOP.y + Math.sin(a * 2) * 0.25),
+ z: round(LOOP.center + Math.sin(a) * LOOP.z),
+ };
+}
+
+function edgeFade(u: number, width = 0.08): number {
+ return Math.min(1, Math.max(0, (0.5 - Math.abs(u - 0.5)) / width));
}
export function clipPose(formation: FormationKey, index: number, time = 0): ClipPose {
const i = ((index % CLIP_COUNT) + CLIP_COUNT) % CLIP_COUNT;
switch (formation) {
- case "halo": {
- const a = (i / CLIP_COUNT) * TAU + time * 0.25;
+ case "track":
return pose(
- Math.sin(a) * 3.4,
- 2.4 + Math.sin(3 * a) * 0.18,
- Math.cos(a) * 3.4,
+ segmentX(i),
+ FOOTAGE_Y + 0.02,
0,
- degrees(a),
+ -90,
+ 0,
+ SEGMENT_W * 0.92,
+ TRACK_DEPTH * 0.72,
1,
);
- }
- case "reel":
- return pose(-2.7, 3.05, -0.2, 0, 0, 0);
- case "cut":
+ case "split": {
+ const lift = 0.3 + (i % 2) * 0.12 + Math.sin(time * 2 + i) * 0.03;
return pose(
- (i - (CLIP_COUNT - 1) / 2) * 0.4,
- 0.42 + Math.sin(time * 2 + i) * 0.04,
- 3.7,
- -18,
+ segmentX(i) * 1.12,
+ FOOTAGE_Y + lift,
0,
- 0.62,
+ -72,
+ (i % 2 ? 1 : -1) * 6,
+ SEGMENT_W * 0.86,
+ TRACK_DEPTH * 0.66,
+ 1,
);
- case "spiral": {
- const a = (i / CLIP_COUNT) * TAU * 1.5 + time * 0.9;
- const r = 2.05;
+ }
+ case "reframe":
return pose(
- Math.sin(a) * r,
- 1.1 + (i / (CLIP_COUNT - 1)) * 3.1,
- Math.cos(a) * r,
+ segmentX(i),
+ CARD_Y + Math.sin(time * 1.5 + i * 0.6) * 0.05,
+ 0,
0,
- degrees(a),
- 0.85,
+ 0,
+ CARD_W,
+ CARD_H,
+ 1,
);
- }
case "gate": {
- const u = (i / CLIP_COUNT + time * 0.06) % 1;
- const fade = Math.min(1, (0.5 - Math.abs(u - 0.5)) / 0.08);
+ const u = wrap(i / CLIP_COUNT + time * 0.05);
return pose(
GATE_X + (u - 0.5) * GATE_SPAN,
- 1.9 + Math.sin(time * 2 + i) * 0.03,
- GATE_Z,
+ CARD_Y + Math.sin(time * 2 + i) * 0.03,
0,
0,
- 0.9 * fade,
- );
- }
- case "broadcast": {
- const k = i % TRANSMITTER_COUNT;
- const j = Math.floor(i / TRANSMITTER_COUNT);
- const a = (transmitterAngle(k) * Math.PI) / 180;
- const spin = time * 0.8 + (j * TAU) / 3;
- const r = 2.5 + Math.cos(spin) * 0.35;
- return pose(
- Math.sin(a) * r,
- 4.7 + j * 0.42 + Math.sin(spin) * 0.12,
- Math.cos(a) * r,
0,
- transmitterAngle(k),
- 0.72,
+ CARD_W,
+ CARD_H,
+ edgeFade(u),
);
}
- case "vault":
- return pose(0, 0.42 + i * 0.09, -3.3, -90, i * 7, 1.05);
- case "orbit": {
- const a = (i / CLIP_COUNT) * TAU + time * 0.6;
- const tilt = (28 * Math.PI) / 180;
- const r = 3.1;
- const flat = Math.cos(a) * r;
+ case "lanes": {
+ const lane = i % NETWORKS.length;
+ const slot = Math.floor(i / NETWORKS.length);
+ const u = wrap(slot / 3 + time * 0.09 + lane * 0.07);
+ const p = lanePoint(lane, u);
+ return pose(p.x, p.y + CARD_H * 0.36, p.z, 0, 0, CARD_W, CARD_H, 0.62 * edgeFade(u, 0.12));
+ }
+ case "bin": {
+ const col = i % BIN_COLUMNS;
+ const row = Math.floor(i / BIN_COLUMNS);
return pose(
- Math.sin(a) * r,
- 2.7 + flat * Math.sin(tilt),
- flat * Math.cos(tilt),
+ (col - (BIN_COLUMNS - 1) / 2) * 0.72,
+ 0.06,
+ BIN_Z + row * 1.05,
+ -90,
0,
- degrees(a),
- 0.8,
+ CARD_W,
+ CARD_H,
+ 1,
);
}
+ case "loop": {
+ const a = (i / CLIP_COUNT) * TAU + time * 0.3;
+ const p = loopPoint(a);
+ const nx = Math.cos(a) / LOOP.x;
+ const nz = Math.sin(a) / LOOP.z;
+ return pose(p.x, p.y, p.z, 0, degrees(Math.atan2(nx, nz)), CARD_W, CARD_H, 0.8);
+ }
}
}
@@ -376,24 +487,28 @@ export function clipPoseAt(progress: number, index: number, time = 0): ClipPose
lerp(a.z, b.z, t),
lerp(a.rx, b.rx, t),
lerp(a.ry, b.ry, t),
+ lerp(a.w, b.w, t),
+ lerp(a.h, b.h, t),
lerp(a.scale, b.scale, t),
);
}
-export function isHookFrame(index: number): boolean {
- return index % HOOK_EVERY === 1;
-}
-
-export function hookNetwork(clip: number): (typeof NETWORKS)[number] {
- return NETWORKS[clip % NETWORKS.length];
+export function clipApproved(
+ state: Pick,
+ index: number,
+ time = 0,
+): boolean {
+ if (state.lanes >= 0.5) return true;
+ const passed = clipPose("gate", index, time).x > GATE_X;
+ return passed && (state.gate > 0.9 || (state.gate >= 0.5 && state.lanes > 0));
}
-export function timecode(progress: number, fps = 24, secondsPerStage = 3): string {
+export function timecode(progress: number, fps = 24, seconds = RULER_SECONDS): string {
const clamped = Math.min(1, Math.max(0, progress));
- const total = Math.round(clamped * LAST_STAGE * secondsPerStage * fps);
+ const total = Math.round(clamped * seconds * fps);
const frames = total % fps;
- const seconds = Math.floor(total / fps) % 60;
+ const secs = Math.floor(total / fps) % 60;
const minutes = Math.floor(total / (fps * 60)) % 60;
const hours = Math.floor(total / (fps * 3600));
- return [hours, minutes, seconds, frames].map((part) => String(part).padStart(2, "0")).join(":");
+ return [hours, minutes, secs, frames].map((part) => String(part).padStart(2, "0")).join(":");
}
diff --git a/src/styles.css b/src/styles.css
index c5958d1..5b59aec 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -987,10 +987,7 @@
.ce-stage__grid {
position: absolute;
inset: 0;
- background-image:
- linear-gradient(var(--ce-line) 1px, transparent 1px),
- linear-gradient(90deg, var(--ce-line) 1px, transparent 1px);
- background-size: 56px 56px;
+ background-image: repeating-linear-gradient(0deg, var(--ce-line) 0 1px, transparent 1px 4px);
mask-image: radial-gradient(ellipse at 68% 55%, #000 10%, transparent 68%);
}
@@ -1019,6 +1016,41 @@
height: 100%;
}
+.ce-engine::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background:
+ linear-gradient(
+ 180deg,
+ transparent,
+ color-mix(in srgb, var(--accent) 8%, transparent),
+ transparent
+ )
+ 0 -45% / 100% 30% no-repeat,
+ repeating-linear-gradient(
+ 0deg,
+ color-mix(in srgb, var(--bg) 38%, transparent) 0 1px,
+ transparent 1px 3px
+ );
+ animation: ce-refresh 7s linear infinite;
+}
+
+@keyframes ce-refresh {
+ to {
+ background-position:
+ 0 145%,
+ 0 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ce-engine::after {
+ animation: none;
+ }
+}
+
.ce-engine[data-fallback="true"] {
background: radial-gradient(
circle at 50% 55%,