Files
HDR/scripts/generate-hdr-media.mjs

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}`);