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

175 lines
5.7 KiB
JavaScript

import { mkdir, rm } 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-landing-prism.png');
const intermediatePath = resolve(root, 'media/.hdr-landing-prism-source.mp4');
const width = 1440;
const height = 810;
const frame = Buffer.alloc(width * height * 6);
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(0x0a0d06);
const colors = {
cyan: [0.08, 0.98, 1.0],
blue: [0.22, 0.42, 1.0],
magenta: [1.0, 0.12, 0.92],
amber: [1.0, 0.78, 0.18],
lime: [0.72, 1.0, 0.1],
white: [1.0, 1.0, 1.0]
};
function mix(a, b, t) {
return [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t
];
}
function addPoint(x, y, radius, color, strength) {
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 r2 = 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 > r2) continue;
const falloff = 1 - d2 / r2;
const bloom = falloff * falloff * 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(x1, y1, x2, y2, radius, color, strength, steps = 220) {
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const x = x1 + (x2 - x1) * t;
const y = y1 + (y2 - y1) * t;
const taper = Math.sin(Math.PI * t);
addPoint(x, y, radius * (0.28 + taper * 0.72), color, strength * (0.18 + taper * 0.82));
}
}
function addCurve(offsetY, radius, colorA, colorB, strength) {
const steps = 420;
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const x = width * (0.08 + t * 0.86);
const arc = Math.sin(Math.PI * t);
const ripple = Math.sin(t * Math.PI * 5.2 + offsetY * 0.018) * 0.035;
const y = height * (0.70 - arc * 0.34 + ripple) + offsetY;
const color = t < 0.5
? mix(colorA, colors.white, t * 0.34)
: mix(colors.white, colorB, (t - 0.5) * 0.68);
addPoint(x, y, radius * (0.22 + arc * 0.78), color, strength * (0.15 + arc * 0.85));
}
}
frame.fill(0);
for (let i = 0; i < 360; i += 1) {
const x = random() * width;
const y = random() * height;
const radius = 0.7 + random() * 1.8;
const color = [colors.cyan, colors.blue, colors.magenta, colors.amber, colors.white][Math.floor(random() * 5)];
const strength = 0.05 + random() * 0.16;
addPoint(x, y, radius * 2.2, color, strength);
}
for (let i = 0; i < 140; i += 1) {
const t = i / 139;
const x = width * (0.18 + t * 0.68);
const y = height * (0.51 + Math.sin(t * Math.PI * 2.5) * 0.09 + (random() - 0.5) * 0.05);
const color = mix(colors.blue, colors.magenta, t);
addPoint(x, y, 4 + random() * 9, color, 0.08 + random() * 0.18);
}
addCurve(-68, 34, colors.cyan, colors.magenta, 0.26);
addCurve(-22, 26, colors.blue, colors.amber, 0.20);
addCurve(38, 18, colors.lime, colors.cyan, 0.13);
addPoint(width * 0.56, height * 0.43, 108, colors.white, 0.42);
addPoint(width * 0.56, height * 0.43, 220, colors.cyan, 0.12);
addPoint(width * 0.56, height * 0.43, 320, colors.magenta, 0.07);
addLine(width * 0.16, height * 0.76, width * 0.78, height * 0.24, 9, colors.cyan, 0.22, 180);
addLine(width * 0.30, height * 0.84, width * 0.88, height * 0.33, 5, colors.amber, 0.14, 160);
for (let i = 0; i < 9; i += 1) {
const x = width * (0.12 + random() * 0.72);
const y = height * (0.20 + random() * 0.52);
const color = [colors.cyan, colors.magenta, colors.amber][Math.floor(random() * 3)];
addPoint(x, y, 18 + random() * 38, color, 0.10 + random() * 0.14);
}
await mkdir(dirname(outputPath), { recursive: true });
const encode = spawn('ffmpeg', [
'-y',
'-f', 'rawvideo',
'-pix_fmt', 'rgb48le',
'-s:v', `${width}x${height}`,
'-r', '1',
'-i', 'pipe:0',
'-an',
'-vf', 'format=yuv420p10le',
'-c:v', 'libx265',
'-profile:v', 'main10',
'-preset', 'medium',
'-crf', '8',
'-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,260',
intermediatePath
], { stdio: ['pipe', 'inherit', 'inherit'] });
encode.stdin.end(frame);
const [encodeCode] = await once(encode, 'close');
if (encodeCode !== 0) throw new Error(`ffmpeg intermediate exited with code ${encodeCode}`);
const extract = spawn('ffmpeg', [
'-y',
'-i', intermediatePath,
'-frames:v', '1',
'-update', '1',
'-c:v', 'png',
'-pred', 'mixed',
'-color_primaries', 'bt2020',
'-color_trc', 'smpte2084',
outputPath
], { stdio: ['ignore', 'inherit', 'inherit'] });
const [extractCode] = await once(extract, 'close');
await rm(intermediatePath, { force: true });
if (extractCode !== 0) throw new Error(`ffmpeg png exited with code ${extractCode}`);
console.log(`Wrote ${outputPath}`);