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%,