import { mkdir } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; const root = resolve(import.meta.dirname, '..'); const outputPath = resolve(root, 'media/hdr-galaxy-fireworks.mp4'); const webmOutputPath = resolve(root, 'media/hdr-galaxy-fireworks.webm'); const width = 960; const height = 540; const fps = 24; const duration = 6; const frames = fps * duration; const channels = 3; const bytesPerChannel = 2; const frameBytes = width * height * channels * bytesPerChannel; const palette = [ [0.08, 0.98, 1.0], [1.0, 0.12, 0.92], [0.72, 1.0, 0.10], [1.0, 0.82, 0.22], [0.22, 0.42, 1.0], [1.0, 1.0, 1.0] ]; function mulberry(seed) { return () => { seed += 0x6d2b79f5; let value = seed; value = Math.imul(value ^ (value >>> 15), value | 1); value ^= value + Math.imul(value ^ (value >>> 7), value | 61); return ((value ^ (value >>> 14)) >>> 0) / 4294967296; }; } const random = mulberry(0x485452); function lerp(a, b, t) { return a + (b - a) * t; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function easeOutCubic(t) { return 1 - (1 - t) ** 3; } function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); } function smoothstep(edge0, edge1, value) { const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); return t * t * (3 - 2 * t); } function colorMix(a, b, amount) { return [ lerp(a[0], b[0], amount), lerp(a[1], b[1], amount), lerp(a[2], b[2], amount) ]; } function makeParticles(count, type, baseColor, accentColor) { return Array.from({ length: count }, (_, index) => { const ringAngle = (Math.PI * 2 * index) / count; const jitter = (random() - 0.5) * (type === 'willow' ? 0.34 : 0.18); const angle = ringAngle + jitter; const verticalBias = type === 'willow' ? 0.22 + random() * 0.48 : random() * 0.24 - 0.12; const speed = type === 'willow' ? 92 + random() * 116 : 118 + random() * 174; return { angle, verticalBias, speed, radiusBias: type === 'willow' ? 0.58 + random() * 0.36 : 0.82 + random() * 0.24, size: type === 'willow' ? 1.0 + random() * 1.7 : 0.9 + random() * 2.2, color: random() > 0.72 ? colorMix(baseColor, accentColor, random() * 0.58) : baseColor, peak: type === 'willow' ? 0.38 + random() * 0.36 : 0.54 + random() * 0.42, tail: type === 'willow' ? 0.72 + random() * 0.28 : random() * 0.2, crackle: random() > 0.78 }; }); } const shells = [ { start: 0.12, launch: [width * 0.18, height + 32], apex: [width * 0.31, height * 0.25], type: 'chrysanthemum', color: palette[0], accent: palette[5], particles: makeParticles(168, 'chrysanthemum', palette[0], palette[5]) }, { start: 0.88, launch: [width * 0.78, height + 28], apex: [width * 0.67, height * 0.34], type: 'willow', color: palette[3], accent: palette[1], particles: makeParticles(132, 'willow', palette[3], palette[1]) }, { start: 1.74, launch: [width * 0.48, height + 34], apex: [width * 0.5, height * 0.18], type: 'chrysanthemum', color: palette[1], accent: palette[0], particles: makeParticles(190, 'chrysanthemum', palette[1], palette[0]) }, { start: 2.76, launch: [width * 0.28, height + 26], apex: [width * 0.2, height * 0.38], type: 'willow', color: palette[2], accent: palette[3], particles: makeParticles(124, 'willow', palette[2], palette[3]) }, { start: 3.56, launch: [width * 0.86, height + 38], apex: [width * 0.82, height * 0.22], type: 'chrysanthemum', color: palette[4], accent: palette[5], particles: makeParticles(158, 'chrysanthemum', palette[4], palette[5]) }, { start: 4.35, launch: [width * 0.43, height + 36], apex: [width * 0.43, height * 0.31], type: 'willow', color: palette[5], accent: palette[0], particles: makeParticles(142, 'willow', palette[5], palette[0]) }, { start: 5.08, launch: [width * 0.58, height + 30], apex: [width * 0.61, height * 0.2], type: 'chrysanthemum', color: palette[3], accent: palette[1], particles: makeParticles(210, 'chrysanthemum', palette[3], palette[1]) } ]; const stars = Array.from({ length: 54 }, () => ({ x: random() * width, y: height * (0.06 + random() * 0.52), r: 0.32 + random() * 0.72, phase: random() * Math.PI * 2, peak: 0.025 + random() * 0.08 })); function addPoint(frame, x, y, radius, color, strength, softness = 2.4) { const minX = Math.max(0, Math.floor(x - radius)); const maxX = Math.min(width - 1, Math.ceil(x + radius)); const minY = Math.max(0, Math.floor(y - radius)); const maxY = Math.min(height - 1, Math.ceil(y + radius)); const radiusSquared = radius * radius; for (let py = minY; py <= maxY; py += 1) { const dy = py - y; for (let px = minX; px <= maxX; px += 1) { const dx = px - x; const d2 = dx * dx + dy * dy; if (d2 > radiusSquared) continue; const falloff = Math.max(0, 1 - d2 / radiusSquared); const bloom = falloff ** softness * strength; const offset = (py * width + px) * 6; for (let channel = 0; channel < 3; channel += 1) { const current = frame.readUInt16LE(offset + channel * 2); const value = Math.min(65535, current + Math.round(65535 * color[channel] * bloom)); frame.writeUInt16LE(value, offset + channel * 2); } } } } function addLine(frame, x1, y1, x2, y2, radius, color, strength, samples = 42) { for (let i = 0; i < samples; i += 1) { const t = i / Math.max(1, samples - 1); const x = lerp(x1, x2, t); const y = lerp(y1, y2, t); addPoint(frame, x, y, radius, color, strength * (1 - t * 0.72), 2.0); } } function pathPoint(shell, progress) { const eased = easeOutQuad(progress); const curve = Math.sin(progress * Math.PI) * height * 0.1; return { x: lerp(shell.launch[0], shell.apex[0], eased) + Math.sin(progress * Math.PI * 1.4) * 16, y: lerp(shell.launch[1], shell.apex[1], eased) - curve }; } function drawLaunch(frame, shell, t) { const launchDuration = 0.78; const age = t - shell.start; if (age < 0 || age > launchDuration) return; const progress = clamp(age / launchDuration, 0, 1); const head = pathPoint(shell, progress); const previous = pathPoint(shell, Math.max(0, progress - 0.06)); const trailColor = colorMix(shell.color, [1, 1, 1], 0.32); for (let i = 0; i < 9; i += 1) { const tailProgress = Math.max(0, progress - i * 0.035); const point = pathPoint(shell, tailProgress); addPoint(frame, point.x, point.y, 1.7 + i * 0.18, trailColor, (0.52 - i * 0.045) * smoothstep(0, 0.08, progress), 1.8); } addLine(frame, previous.x, previous.y, head.x, head.y, 1.4, shell.color, 0.36, 18); addPoint(frame, head.x, head.y, 4.2, [1, 1, 1], 0.72, 2.1); } function drawBurst(frame, shell, t) { const launchDuration = 0.78; const burstAge = t - shell.start - launchDuration; const burstDuration = shell.type === 'willow' ? 1.72 : 1.32; if (burstAge < 0 || burstAge > burstDuration) return; const progress = clamp(burstAge / burstDuration, 0, 1); const expansion = easeOutCubic(progress); const fade = 1 - smoothstep(0.62, 1, progress); const centerStrength = Math.max(0, 1 - progress * 3.4); const center = shell.apex; addPoint(frame, center[0], center[1], 5.5 + progress * 3.5, [1, 1, 1], 0.95 * centerStrength, 2.5); addPoint(frame, center[0], center[1], 14 + progress * 9, shell.color, 0.18 * centerStrength, 2.8); for (const particle of shell.particles) { const sideDistance = particle.speed * particle.radiusBias * expansion; const drop = (shell.type === 'willow' ? 142 : 68) * burstAge * burstAge; const x = center[0] + Math.cos(particle.angle) * sideDistance; const y = center[1] + Math.sin(particle.angle) * sideDistance * (0.84 + particle.verticalBias) + drop; const strength = particle.peak * fade; const radius = particle.size * (shell.type === 'willow' ? 2.2 + progress * 1.2 : 2.4 + progress * 0.7); if (shell.type === 'willow' && progress > 0.16) { const tailAge = Math.max(0, burstAge - 0.12); const tailExpansion = easeOutCubic(clamp(tailAge / burstDuration, 0, 1)); const tailDistance = particle.speed * particle.radiusBias * tailExpansion * 0.86; const tailDrop = 142 * tailAge * tailAge; const tx = center[0] + Math.cos(particle.angle) * tailDistance; const ty = center[1] + Math.sin(particle.angle) * tailDistance * (0.84 + particle.verticalBias) + tailDrop; addLine(frame, tx, ty, x, y, 0.72, particle.color, strength * particle.tail * 0.34, 10); } addPoint(frame, x, y, radius, particle.color, strength, shell.type === 'willow' ? 2.0 : 2.6); if (particle.crackle && progress > 0.54 && progress < 0.86) { const cracklePhase = Math.sin((progress * 24 + particle.angle) * Math.PI); if (cracklePhase > 0.28) { addPoint(frame, x + Math.cos(particle.angle * 3.1) * 8, y + Math.sin(particle.angle * 2.3) * 8, 1.8, [1, 1, 1], strength * 0.68, 1.8); } } } } function drawSkyline(frame) { const y = height * 0.91; addLine(frame, 0, y, width, y, 0.7, [0.02, 0.06, 0.08], 0.035, 160); for (let i = 0; i < 7; i += 1) { const x = width * (0.08 + i * 0.13); addLine(frame, x, y, x + width * 0.04, y - height * (0.012 + (i % 3) * 0.008), 0.65, [0.02, 0.06, 0.08], 0.026, 16); } } function renderFrame(index) { const frame = Buffer.allocUnsafe(frameBytes); frame.fill(0); const t = index / fps; for (const star of stars) { const pulse = 0.62 + Math.sin(t * 2.2 + star.phase) * 0.28; addPoint(frame, star.x, star.y, star.r, [0.76, 0.94, 1], star.peak * pulse, 2.4); } drawSkyline(frame); for (const shell of shells) { drawLaunch(frame, shell, t); drawBurst(frame, shell, t); } return frame; } await mkdir(dirname(outputPath), { recursive: true }); const args = [ '-y', '-f', 'rawvideo', '-pix_fmt', 'rgb48le', '-s:v', `${width}x${height}`, '-r', String(fps), '-i', 'pipe:0', '-an', '-vf', 'format=yuv420p10le', '-c:v', 'libx265', '-profile:v', 'main10', '-preset', 'medium', '-crf', '10', '-tag:v', 'hvc1', '-movflags', '+faststart', '-color_primaries', 'bt2020', '-color_trc', 'smpte2084', '-colorspace', 'bt2020nc', '-x265-params', 'hdr10=1:repeat-headers=1:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1400,220', outputPath ]; const ffmpeg = spawn('ffmpeg', args, { stdio: ['pipe', 'inherit', 'inherit'] }); for (let index = 0; index < frames; index += 1) { const frame = renderFrame(index); if (!ffmpeg.stdin.write(frame)) { await once(ffmpeg.stdin, 'drain'); } } ffmpeg.stdin.end(); const [code] = await once(ffmpeg, 'close'); if (code !== 0) { throw new Error(`ffmpeg exited with code ${code}`); } console.log(`Wrote ${outputPath}`); const webmArgs = [ '-y', '-i', outputPath, '-an', '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p10le', '-row-mt', '1', '-b:v', '0', '-crf', '16', '-color_primaries', 'bt2020', '-color_trc', 'smpte2084', '-colorspace', 'bt2020nc', webmOutputPath ]; const webm = spawn('ffmpeg', webmArgs, { stdio: ['ignore', 'inherit', 'inherit'] }); const [webmCode] = await once(webm, 'close'); if (webmCode !== 0) { throw new Error(`ffmpeg webm exited with code ${webmCode}`); } console.log(`Wrote ${webmOutputPath}`);