Spaces:
Running
Running
Download src/video/engine.ts from Ethscriptions/mx: direct link, hf CLI and curl.
- Browser
- Download file 6.79 kB
-
https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/engine.ts
- Command line
-
hf download hf://spaces/Ethscriptions/mx/src/video/engine.ts
-
curl -L -o engine.ts https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/engine.ts
6.79 kB
| import * as THREE from 'three' | |
| import { HandModel, type SceneState } from './model' | |
| export interface View { rect: [number, number, number, number]; state: SceneState; bg?: string } | |
| export interface OverlayApi { | |
| W: number; H: number; t: number | |
| /** project anchor/world point of view i → logical coords (1080 x 1920 space scaled by view rect) */ | |
| anchor: (name: string, view?: number) => { x: number; y: number; z: number } | |
| world: (p: THREE.Vector3, view?: number) => { x: number; y: number; z: number } | |
| model: HandModel | |
| } | |
| export interface Frame { | |
| views: View[] | |
| overlay?: (ctx: CanvasRenderingContext2D, api: OverlayApi) => void | |
| fade?: number // 0..1 black | |
| } | |
| export const LW = 1080, LH = 1920 | |
| const BG_CACHE: Record<string, THREE.Texture> = {} | |
| function bgTexture(kind: string) { | |
| if (BG_CACHE[kind]) return BG_CACHE[kind] | |
| const c = document.createElement('canvas'); c.width = 540; c.height = 960 | |
| const g = c.getContext('2d')! | |
| const pal: Record<string, [string, string, string]> = { | |
| lab: ['#123257', '#081a33', '#020711'], | |
| warm: ['#3a2a3f', '#1b1426', '#07050c'], | |
| red: ['#4a1626', '#1e0b19', '#06030a'], | |
| clean: ['#1d4a6e', '#0b2441', '#030b18'], | |
| } | |
| const [a, b, d] = pal[kind] ?? pal.lab | |
| const gr = g.createRadialGradient(270, 400, 20, 270, 480, 620) | |
| gr.addColorStop(0, a); gr.addColorStop(0.5, b); gr.addColorStop(1, d) | |
| g.fillStyle = gr; g.fillRect(0, 0, 540, 960) | |
| // subtle grid | |
| g.strokeStyle = 'rgba(120,190,255,0.05)'; g.lineWidth = 1 | |
| for (let x = 0; x <= 540; x += 30) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, 960); g.stroke() } | |
| for (let y = 0; y <= 960; y += 30) { g.beginPath(); g.moveTo(0, y); g.lineTo(540, y); g.stroke() } | |
| const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace | |
| BG_CACHE[kind] = t | |
| return t | |
| } | |
| export class Engine { | |
| gl: HTMLCanvasElement | |
| renderer: THREE.WebGLRenderer | |
| model: HandModel | |
| camera = new THREE.PerspectiveCamera(35, 9 / 16, 0.05, 100) | |
| cams: THREE.PerspectiveCamera[] = [] | |
| W = 1080; H = 1920 | |
| constructor() { | |
| this.gl = document.createElement('canvas') | |
| this.renderer = new THREE.WebGLRenderer({ canvas: this.gl, antialias: true, preserveDrawingBuffer: true, alpha: false, powerPreference: 'high-performance' }) | |
| this.renderer.outputColorSpace = THREE.SRGBColorSpace | |
| this.renderer.toneMapping = THREE.ACESFilmicToneMapping | |
| this.renderer.toneMappingExposure = 1.05 | |
| this.renderer.setPixelRatio(1) | |
| this.model = new HandModel(this.renderer) | |
| this.setSize(1080, 1920) | |
| } | |
| setSize(w: number, h: number) { | |
| this.W = w; this.H = h | |
| this.renderer.setSize(w, h, false) | |
| } | |
| private setupCam(cam: THREE.PerspectiveCamera, s: SceneState, aspect: number) { | |
| cam.aspect = aspect | |
| cam.fov = s.cam.fov | |
| cam.position.set(...s.cam.pos) | |
| const target = new THREE.Vector3(...s.cam.target) | |
| const follow = (s.cam as { follow?: number }).follow ?? 0 | |
| if (follow > 0) { | |
| const d = this.model.anchors.pain.clone().sub(target).multiplyScalar(follow) | |
| cam.position.add(d); target.add(d) | |
| } | |
| cam.up.set(0, 1, 0) | |
| if (s.cam.roll) cam.up.applyAxisAngle(new THREE.Vector3(0, 0, 1), s.cam.roll) | |
| cam.lookAt(target) | |
| cam.updateProjectionMatrix() | |
| const sh = (s.cam as { shift?: [number, number] }).shift | |
| if (sh) { | |
| // place the look-at point at screen fraction (fx, fy) | |
| cam.projectionMatrix.elements[8] -= 2 * sh[0] - 1 | |
| cam.projectionMatrix.elements[9] -= 1 - 2 * sh[1] | |
| cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert() | |
| } | |
| cam.updateMatrixWorld() | |
| } | |
| /** Render a frame onto a 2D context sized W x H (logical layout uses LW x LH scaled). */ | |
| render(frame: Frame, ctx: CanvasRenderingContext2D, t: number, logical: { w: number; h: number } = { w: LW, h: LH }) { | |
| const { W, H, renderer } = this | |
| renderer.setScissorTest(true) | |
| renderer.autoClear = false | |
| renderer.setClearColor('#02060d', 1) | |
| renderer.setViewport(0, 0, W, H); renderer.setScissor(0, 0, W, H); renderer.clear() | |
| while (this.cams.length < frame.views.length) this.cams.push(new THREE.PerspectiveCamera()) | |
| const anchorsPerView: Record<string, THREE.Vector3>[] = [] | |
| frame.views.forEach((v, i) => { | |
| const [rx, ry, rw, rh] = v.rect | |
| const x = Math.round(rx * W), y = Math.round(ry * H), w = Math.round(rw * W), h = Math.round(rh * H) | |
| this.model.apply(v.state) | |
| this.model.scene.background = bgTexture(v.bg ?? 'lab') | |
| const cam = this.cams[i] | |
| this.setupCam(cam, v.state, w / h) | |
| renderer.setViewport(x, H - y - h, w, h); renderer.setScissor(x, H - y - h, w, h) | |
| renderer.render(this.model.scene, cam) | |
| const snap: Record<string, THREE.Vector3> = {} | |
| for (const [k, p] of Object.entries(this.model.anchors)) snap[k] = p.clone() | |
| anchorsPerView.push(snap) | |
| }) | |
| renderer.setScissorTest(false) | |
| ctx.save() | |
| ctx.setTransform(1, 0, 0, 1, 0, 0) | |
| ctx.clearRect(0, 0, W, H) | |
| ctx.drawImage(this.gl, 0, 0, W, H) | |
| // logical transform | |
| const sx = W / logical.w, sy = H / logical.h | |
| ctx.setTransform(sx, 0, 0, sy, 0, 0) | |
| // particles + vignette | |
| drawAtmosphere(ctx, t, logical.w, logical.h) | |
| if (frame.overlay) { | |
| const api: OverlayApi = { | |
| W: logical.w, H: logical.h, t, model: this.model, | |
| world: (p, vi = 0) => { | |
| const v = frame.views[vi]; const cam = this.cams[vi] | |
| const q = p.clone().project(cam) | |
| const [rx, ry, rw, rh] = v.rect | |
| return { x: (rx + ((q.x + 1) / 2) * rw) * logical.w, y: (ry + ((1 - q.y) / 2) * rh) * logical.h, z: q.z } | |
| }, | |
| anchor: (name, vi = 0) => api.world(anchorsPerView[vi]?.[name] ?? new THREE.Vector3(), vi), | |
| } | |
| frame.overlay(ctx, api) | |
| } | |
| if (frame.fade && frame.fade > 0) { | |
| ctx.fillStyle = `rgba(0,0,0,${Math.min(1, frame.fade)})`; ctx.fillRect(0, 0, logical.w, logical.h) | |
| } | |
| ctx.restore() | |
| } | |
| } | |
| function drawAtmosphere(ctx: CanvasRenderingContext2D, t: number, w: number, h: number) { | |
| // floating micro particles (deterministic) | |
| ctx.save() | |
| ctx.globalCompositeOperation = 'lighter' | |
| for (let i = 0; i < 40; i++) { | |
| const s1 = Math.sin(i * 91.7) * 0.5 + 0.5, s2 = Math.sin(i * 37.3 + 1) * 0.5 + 0.5, s3 = Math.sin(i * 13.1 + 2) * 0.5 + 0.5 | |
| const x = (s1 * w + Math.sin(t * 0.2 + i) * 30) % w | |
| const y = ((s2 * h - t * (8 + s3 * 18)) % h + h) % h | |
| const r = 1.2 + s3 * 2.4 | |
| ctx.fillStyle = `rgba(120,200,255,${0.05 + s3 * 0.12})` | |
| ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill() | |
| } | |
| ctx.restore() | |
| const g = ctx.createRadialGradient(w / 2, h * 0.45, h * 0.3, w / 2, h * 0.5, h * 0.78) | |
| g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.55)') | |
| ctx.fillStyle = g; ctx.fillRect(0, 0, w, h) | |
| } | |