mx / src /video /engine.ts
Ethscriptions's picture
Step 6-7: 播放器 + MP4 导出 + 文案 + 封面
d61897b verified
Raw History Blame Contribute Delete
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)
}