From 417bdbfc4ca5db73d1e1e1b58cbd91b9d65b3a93 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:11:30 +0000 Subject: [PATCH 1/4] feat(marketing): holographic timeline engine Replace the tower-style engine, which shared its silhouette with the Spectrum Web Co engine, with ClippyOS's own design: an exploded editing timeline rendered as a glowing holographic wireframe. Audio, footage and caption tracks float over a projector deck with a jog wheel and media bin; a playhead light sheet sweeps the stack, a razor cuts the footage into clips that reframe to 9:16 cards, pass a review gate, ride four output lanes to network screens and circle the Hermes loop. The readout now names the track in focus and the zoom, the stage grid becomes scanlines, and the canvas gets a scanline and refresh overlay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AzZUDyzfUAfxJAXaPfiq8t --- README.md | 27 +- .../marketing/anime/landing-motion.ts | 10 +- src/components/marketing/clip-engine.tsx | 6 +- .../marketing/engine/clip-engine-scene.ts | 1526 ++++++++++------- src/lib/clip-engine.test.ts | 223 ++- src/lib/clip-engine.ts | 490 +++--- src/styles.css | 40 +- 7 files changed, 1386 insertions(+), 936 deletions(-) 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..48464a6 100644 --- a/src/components/marketing/engine/clip-engine-scene.ts +++ b/src/components/marketing/engine/clip-engine-scene.ts @@ -1,17 +1,32 @@ 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_Y, + SEGMENT_W, + TIMELINE_LENGTH, + TRACKS, + TRACK_DEPTH, + WAVE_BARS, + captionChips, clipPoseAt, engineStateAt, - isHookFrame, - transmitterAngle, + hookX, + laneDirection, + loopPoint, + segmentX, + waveAmplitude, } from "@/lib/clip-engine"; export type EngineTheme = "dark" | "light"; @@ -31,93 +46,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.35, z: 2.3 }; +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 +259,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 = k * 3; + 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(() => { + 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 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 gateLive = s.gate > 0.5; + const lanesLive = s.lanes > 0.5; + 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 = (gateLive && p.x > GATE_X) || lanesLive; + 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 +1049,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 +1067,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, @@ -820,12 +1092,10 @@ export function createClipEngine( 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..9628834 100644 --- a/src/lib/clip-engine.test.ts +++ b/src/lib/clip-engine.test.ts @@ -1,29 +1,41 @@ 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, + SEGMENT_W, + TIMELINE_LENGTH, + captionChips, 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 +63,177 @@ 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", () => { 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(0.5), "00:00:13:12"); +}); + +test("the readout names the track in focus and the zoom", () => { + assert.equal(readout(0), "all tracks · zoom 1.14x"); + 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..1a662d4 100644 --- a/src/lib/clip-engine.ts +++ b/src/lib/clip-engine.ts @@ -16,14 +16,31 @@ 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 NETWORKS = ["X", "YouTube", "Instagram", "TikTok"] as const; export const PIPELINE_STEPS = ["Ingest", "Detect", "Cut", "Caption + render"] as const; @@ -34,175 +51,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: 32, pitch: -20, dist: 18.5, x: 0.4, y: 2.2, z: 0.7 }, + 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, x: 0.4, y: 1.9, 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: 17.5, x: 0, y: 3.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, x: 0, y: 4.2, 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, x: 6.2, y: 5.6, 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: 392, pitch: -20, dist: 18.5, x: 0.4, y: 2.2, z: 0.7 }, + 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 +256,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 +280,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 +297,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, + -90, 0, - degrees(a), + 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, - degrees(a), - 0.85, + 0, + 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,18 +486,12 @@ 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 timecode(progress: number, fps = 24, secondsPerStage = 3): string { const clamped = Math.min(1, Math.max(0, progress)); const total = Math.round(clamped * LAST_STAGE * secondsPerStage * fps); 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%, From 4de5065ba362e3a0f1f34ce35e3d3a821fc0f7ea Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:17:48 +0000 Subject: [PATCH 2/4] fix(marketing): frame the timeline engine around the HUD Keep the hero clear of the headline, lift the jog wheel off the scrubber, and frame the render and publish stages below the stage panels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AzZUDyzfUAfxJAXaPfiq8t --- src/components/marketing/engine/clip-engine-scene.ts | 2 +- src/lib/clip-engine.test.ts | 2 +- src/lib/clip-engine.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/marketing/engine/clip-engine-scene.ts b/src/components/marketing/engine/clip-engine-scene.ts index 48464a6..87fdb3a 100644 --- a/src/components/marketing/engine/clip-engine-scene.ts +++ b/src/components/marketing/engine/clip-engine-scene.ts @@ -124,7 +124,7 @@ 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.35, z: 2.3 }; +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'; diff --git a/src/lib/clip-engine.test.ts b/src/lib/clip-engine.test.ts index 9628834..d0385f4 100644 --- a/src/lib/clip-engine.test.ts +++ b/src/lib/clip-engine.test.ts @@ -233,7 +233,7 @@ test("timecode counts 24fps frames across three seconds per stage", () => { }); test("the readout names the track in focus and the zoom", () => { - assert.equal(readout(0), "all tracks · zoom 1.14x"); + 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 1a662d4..6c0cc70 100644 --- a/src/lib/clip-engine.ts +++ b/src/lib/clip-engine.ts @@ -115,7 +115,7 @@ const BASE: Omit = { const STAGES: Record = { core: { ...BASE, - cam: { rot: 32, pitch: -20, dist: 18.5, x: 0.4, y: 2.2, z: 0.7 }, + cam: { rot: 42, pitch: -21, dist: 19.5, x: -0.2, y: 1.9, z: 0.6 }, formation: "track", focus: "all tracks", }, @@ -152,7 +152,7 @@ const STAGES: Record = { }, render: { ...BASE, - cam: { rot: 10, pitch: -12, dist: 17.5, x: 0, y: 3.4, z: 0 }, + cam: { rot: 10, pitch: -12, dist: 18, x: 0, y: 4.5, z: 0 }, sweep: 0.6, wave: 0.4, tether: 1, @@ -174,7 +174,7 @@ const STAGES: Record = { }, publish: { ...BASE, - cam: { rot: 14, pitch: -16, dist: 17, x: 6.2, y: 5.6, z: 0 }, + cam: { rot: 14, pitch: -16, dist: 17.5, x: 7.6, y: 6.7, z: 0 }, sweep: 0.5, wave: 0.3, lanes: 1, @@ -204,7 +204,7 @@ const STAGES: Record = { }, online: { ...BASE, - cam: { rot: 392, pitch: -20, dist: 18.5, x: 0.4, y: 2.2, z: 0.7 }, + 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, From 284c3e101dd2db0b8a276c8a0c8189b297806380 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:22:02 +0000 Subject: [PATCH 3/4] fix(marketing): keep cut, render and approve clear of the stage panels Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AzZUDyzfUAfxJAXaPfiq8t --- src/lib/clip-engine.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/clip-engine.ts b/src/lib/clip-engine.ts index 6c0cc70..aa19de7 100644 --- a/src/lib/clip-engine.ts +++ b/src/lib/clip-engine.ts @@ -140,7 +140,7 @@ const STAGES: Record = { }, cut: { ...BASE, - cam: { rot: -20, pitch: -28, dist: 13, x: 0.4, y: 1.9, z: 0 }, + cam: { rot: -20, pitch: -28, dist: 13.5, x: 1, y: 2.5, z: 0 }, sweep: 1.8, wave: 0.6, hooks: 0.6, @@ -152,7 +152,7 @@ const STAGES: Record = { }, render: { ...BASE, - cam: { rot: 10, pitch: -12, dist: 18, x: 0, y: 4.5, z: 0 }, + cam: { rot: 10, pitch: -12, dist: 18, x: 0.4, y: 5.4, z: 0 }, sweep: 0.6, wave: 0.4, tether: 1, @@ -163,7 +163,7 @@ const STAGES: Record = { }, approve: { ...BASE, - cam: { rot: -24, pitch: -10, dist: 15, x: 0, y: 4.2, z: 0 }, + 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, From 5ae64016a130f3b3f1a9c04fc91eeaf6ba3482f2 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:28:18 +0000 Subject: [PATCH 4/4] fix(marketing): sign off clips at the gate and align the ruler clock Sign-off checks now follow each clip's position on the review conveyor and stay on through the hand-off to the output lanes. The HUD timecode runs the same 24 seconds as the TC ruler, and the font redraw is skipped once the engine is disposed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AzZUDyzfUAfxJAXaPfiq8t --- .../marketing/engine/clip-engine-scene.ts | 11 +++++--- src/lib/clip-engine.test.ts | 25 ++++++++++++++++--- src/lib/clip-engine.ts | 19 +++++++++++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/components/marketing/engine/clip-engine-scene.ts b/src/components/marketing/engine/clip-engine-scene.ts index 87fdb3a..e12ab3b 100644 --- a/src/components/marketing/engine/clip-engine-scene.ts +++ b/src/components/marketing/engine/clip-engine-scene.ts @@ -13,6 +13,7 @@ import { LANE_LENGTH, LANE_ORIGIN, NETWORKS, + RULER_SECONDS, RULER_Y, SEGMENT_W, TIMELINE_LENGTH, @@ -20,6 +21,7 @@ import { TRACK_DEPTH, WAVE_BARS, captionChips, + clipApproved, clipPoseAt, engineStateAt, hookX, @@ -525,7 +527,7 @@ export function createClipEngine( } holo(ruler, L.frame, stack, 0.45, 0.7); for (let k = 0; k <= TIMELINE_LENGTH; k++) { - const seconds = k * 3; + 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], @@ -862,6 +864,7 @@ export function createClipEngine( if (typeof document !== "undefined" && document.fonts) { document.fonts.ready .then(() => { + if (disposed) return; labels.forEach(drawLabel); if (!running) update(0); }) @@ -869,6 +872,7 @@ export function createClipEngine( } let time = 0; + let disposed = false; let playU = 0.28; let jogAngle = 0; let revealed = false; @@ -996,8 +1000,6 @@ export function createClipEngine( laser.position.y = CARD_Y + Math.sin(time * 2.4) * 0.64; - const gateLive = s.gate > 0.5; - const lanesLive = s.lanes > 0.5; const tetherArray = tetherBuffer.array as Float32Array; cards.forEach((card, i) => { const p = clipPoseAt(view.progress, i, options.reduced ? 0 : time); @@ -1008,7 +1010,7 @@ export function createClipEngine( 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 = (gateLive && p.x > GATE_X) || lanesLive; + 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; @@ -1089,6 +1091,7 @@ export function createClipEngine( if (!running) update(0); }, dispose: () => { + disposed = true; renderer.setAnimationLoop(null); resizeObserver.disconnect(); visibility.disconnect(); diff --git a/src/lib/clip-engine.test.ts b/src/lib/clip-engine.test.ts index d0385f4..656ca51 100644 --- a/src/lib/clip-engine.test.ts +++ b/src/lib/clip-engine.test.ts @@ -14,9 +14,11 @@ import { LAST_STAGE, LOOP, NETWORKS, + RULER_SECONDS, SEGMENT_W, TIMELINE_LENGTH, captionChips, + clipApproved, clipPose, clipPoseAt, engineStateAt, @@ -226,10 +228,27 @@ 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(1), "00:00:27:00"); - assert.equal(timecode(0.5), "00:00:13:12"); + 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", () => { diff --git a/src/lib/clip-engine.ts b/src/lib/clip-engine.ts index aa19de7..523d7ff 100644 --- a/src/lib/clip-engine.ts +++ b/src/lib/clip-engine.ts @@ -41,6 +41,7 @@ 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; @@ -492,12 +493,22 @@ export function clipPoseAt(progress: number, index: number, time = 0): ClipPose ); } -export function timecode(progress: number, fps = 24, secondsPerStage = 3): string { +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, 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(":"); }