364 lines
11 KiB
JavaScript
364 lines
11 KiB
JavaScript
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}`);
|