diff --git a/README.md b/README.md index a0c67ca..caff9dc 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,7 @@ pnpm start ### Roadmap - [x] GitHub stats card - [x] CRT-style stats card +- [x] Animated GIF output (crt-flicker, glow-pulse, scanline-scroll) ## Author diff --git a/bench.mjs b/bench.mjs new file mode 100644 index 0000000..9ee736a --- /dev/null +++ b/bench.mjs @@ -0,0 +1,91 @@ +import { crt, curve, pixelate } from './packages/pixel-profile/src/shaders/index.ts' +import { glow } from './packages/pixel-profile/src/shaders/glow.ts' +import { scanline } from './packages/pixel-profile/src/shaders/scanline.ts' +import { orderedBayer } from './packages/pixel-profile/src/shaders/dithering.ts' +import { executePipelineSmart, buildStatsPipeline, buildCrtPipeline } from './packages/pixel-profile/src/pipeline.ts' +import { dispatchCrt, dispatchGlow, dispatchCurve, shutdownPool } from './packages/pixel-profile/src/workers/pool.ts' + +const W = 1226, H = 430 +const size = W * H * 4 +const pixels = Buffer.alloc(size) + +// Simulate realistic pixel data (dark background + bright text areas) +for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const idx = (y * W + x) * 4 + const isBright = (x > 100 && x < 400 && y > 50 && y < 200) || + (x > 500 && x < 900 && y > 100 && y < 300) + if (isBright) { + pixels[idx] = 200 + Math.random() * 55 | 0 + pixels[idx+1] = 200 + Math.random() * 55 | 0 + pixels[idx+2] = 200 + Math.random() * 55 | 0 + } else { + pixels[idx] = Math.random() * 30 | 0 + pixels[idx+1] = Math.random() * 30 | 0 + pixels[idx+2] = Math.random() * 30 | 0 + } + pixels[idx+3] = 255 + } +} + +function bench(name, fn, iterations = 5) { + // Warmup + fn() + const times = [] + for (let i = 0; i < iterations; i++) { + const start = performance.now() + fn() + times.push(performance.now() - start) + } + times.sort((a, b) => a - b) + const median = times[Math.floor(times.length / 2)] + const min = times[0] + const max = times[times.length - 1] + console.log(`${name.padEnd(35)} median=${median.toFixed(1)}ms min=${min.toFixed(1)}ms max=${max.toFixed(1)}ms`) +} + +async function benchAsync(name, fn, iterations = 5) { + await fn() + const times = [] + for (let i = 0; i < iterations; i++) { + const start = performance.now() + await fn() + times.push(performance.now() - start) + } + times.sort((a, b) => a - b) + const median = times[Math.floor(times.length / 2)] + const min = times[0] + const max = times[times.length - 1] + console.log(`${name.padEnd(35)} median=${median.toFixed(1)}ms min=${min.toFixed(1)}ms max=${max.toFixed(1)}ms`) +} + +console.log(`\nBenchmark: ${W}x${H} (${(size/1024/1024).toFixed(1)}MB)\n`) +console.log('=== Individual Shaders (sync) ===') + +bench('crt (sync)', () => crt(pixels, W, H)) +bench('glow r3 l2 (sync)', () => glow(pixels, W, H, { radius: 3, intensity: 0.3, color: [1,1,1], layers: 2, falloff: 'exponential' })) +bench('glow r5 l5 (sync)', () => glow(pixels, W, H, { radius: 5, intensity: 0.17, color: [1,1,1], layers: 5, falloff: 'exponential' })) +bench('curve (sync)', () => curve(pixels, W, H)) +bench('pixelate center', () => pixelate(pixels, W, H, { blockSize: 4, samplingMode: 'center' })) +bench('pixelate dominant', () => pixelate(pixels, W, H, { blockSize: 4, samplingMode: 'dominant' })) +bench('scanline', () => scanline(pixels, W, H)) +bench('orderedBayer', () => orderedBayer(pixels, W, H)) + +console.log('\n=== Worker Parallel Shaders ===') +await benchAsync('crt (parallel)', () => dispatchCrt(pixels, W, H, {})) +await benchAsync('glow r3 l2 (parallel)', () => dispatchGlow(pixels, W, H, { radius: 3, intensity: 0.3, color: [1,1,1], layers: 2, falloff: 'exponential' })) +await benchAsync('glow r5 l5 (parallel)', () => dispatchGlow(pixels, W, H, { radius: 5, intensity: 0.17, color: [1,1,1], layers: 5, falloff: 'exponential' })) +await benchAsync('curve (parallel)', () => dispatchCurve(pixels, W, H)) + +console.log('\n=== Full Pipelines (async/smart) ===') +const screenPipeline = buildStatsPipeline({ theme: 'default', screenEffect: true, isFastMode: false, dithering: false }) +await benchAsync('screen effect pipeline', () => executePipelineSmart(pixels, W, H, screenPipeline)) + +const crtPipeline = buildCrtPipeline() +await benchAsync('CRT pipeline (l1)', () => executePipelineSmart(pixels, W, H, crtPipeline)) + +const crtThemePipeline = buildStatsPipeline({ theme: 'crt', screenEffect: false, isFastMode: false, dithering: false }) +await benchAsync('CRT theme pipeline (l5)', () => executePipelineSmart(pixels, W, H, crtThemePipeline)) + +shutdownPool() +console.log('\nDone.') diff --git a/packages/pixel-profile-server/src/github-stats.ts b/packages/pixel-profile-server/src/github-stats.ts index b6159cb..793f9cf 100644 --- a/packages/pixel-profile-server/src/github-stats.ts +++ b/packages/pixel-profile-server/src/github-stats.ts @@ -1,12 +1,14 @@ import { CONSTANTS, parseArray, parseBoolean, parseString } from './utils' import { Hono } from 'hono' -import { clamp, fetchStats, renderStats } from 'pixel-profile' +import { type AnimationOptions, clamp, fetchStats, renderStats } from 'pixel-profile' const githubStats = new Hono() githubStats.get('/', async (c) => { const { req, res, body } = c const { + animation, + animation_effect, background, cache_seconds = `${CONSTANTS.CARD_CACHE_SECONDS}`, color, @@ -19,10 +21,12 @@ githubStats.get('/', async (c) => { username, theme, avatar_border, - dithering + dithering, + dithering_palette } = req.query() - res.headers.set('Content-Type', 'image/png') + const isAnimated = parseBoolean(animation) + res.headers.set('Content-Type', isAnimated ? 'image/gif' : 'image/png') try { const showStats = parseArray(show) @@ -46,6 +50,16 @@ githubStats.get('/', async (c) => { `max-age=${cacheSeconds / 2}, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}` ) + let animationOpts: AnimationOptions | boolean | undefined + if (isAnimated) { + const effect = parseString(animation_effect) + if (effect === 'crt-flicker' || effect === 'glow-pulse' || effect === 'scanline-scroll') { + animationOpts = { effect } + } else { + animationOpts = true + } + } + const options = { background: parseString(background), color: parseString(color), @@ -55,7 +69,9 @@ githubStats.get('/', async (c) => { theme: parseString(theme), screenEffect: parseBoolean(screen_effect), avatarBorder: parseBoolean(avatar_border), - dithering: parseBoolean(dithering) + dithering: parseBoolean(dithering), + ditheringPalette: parseString(dithering_palette), + animation: animationOpts } const result = await renderStats(stats, options) diff --git a/packages/pixel-profile/README.md b/packages/pixel-profile/README.md index 9aecd26..b2822a6 100644 --- a/packages/pixel-profile/README.md +++ b/packages/pixel-profile/README.md @@ -63,8 +63,8 @@ The layout in this project is entirely done with JSX, so developing it is almost ### TODO - [x] Github stats card. -- [ ] Github repo card. -- [ ] Leetcode stats card. +- [x] CRT-style stats card. +- [x] Animated GIF output. ## Author diff --git a/packages/pixel-profile/src/animation/gif-encoder.ts b/packages/pixel-profile/src/animation/gif-encoder.ts new file mode 100644 index 0000000..f45f759 --- /dev/null +++ b/packages/pixel-profile/src/animation/gif-encoder.ts @@ -0,0 +1,328 @@ +/** + * Minimal GIF89a encoder — no external dependencies. + * Supports animated GIF with global color table and per-frame delay. + */ + +export type GifFrame = { + pixels: Buffer + delay: number +} + +/** + * Quantize RGB to 16-bit key: 5 bits R, 6 bits G, 5 bits B. + */ +function rgbToKey16(r: number, g: number, b: number): number { + return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) +} + +/** + * Color quantization using typed arrays only (no object allocation). + * Finds top 256 colors by count, then precomputes a full 65536-entry + * LUT so every indexFrame call is pure O(1) per pixel. + */ +function buildPaletteAndLUT(frames: Buffer[], width: number, height: number): { palette: Uint8Array; lut: Uint8Array } { + const counts = new Uint32Array(65536) + const pixelCount = width * height + + for (const pixels of frames) { + for (let i = 0; i < pixelCount; i++) { + const pi = i * 4 + counts[rgbToKey16(pixels[pi], pixels[pi + 1], pixels[pi + 2])]++ + } + } + + let numColors = 0 + const keys = new Uint16Array(65536) + const cnts = new Uint32Array(65536) + for (let k = 0; k < 65536; k++) { + if (counts[k] > 0) { + keys[numColors] = k + cnts[numColors] = counts[k] + numColors++ + } + } + + const limit = Math.min(256, numColors) + if (numColors > limit) { + quickSelect(keys, cnts, 0, numColors - 1, limit) + } + + const palette = new Uint8Array(768) + const palR = new Uint8Array(256) + const palG = new Uint8Array(256) + const palB = new Uint8Array(256) + for (let i = 0; i < limit; i++) { + const k = keys[i] + const r = ((k >> 11) & 0x1f) << 3 + const g = ((k >> 5) & 0x3f) << 2 + const b = (k & 0x1f) << 3 + palette[i * 3] = r + palette[i * 3 + 1] = g + palette[i * 3 + 2] = b + palR[i] = r + palG[i] = g + palB[i] = b + } + + const lut = new Uint8Array(65536) + for (let i = 0; i < numColors; i++) { + const k = keys[i] + if (i < limit) { + lut[k] = i + } else { + const r = ((k >> 11) & 0x1f) << 3 + const g = ((k >> 5) & 0x3f) << 2 + const b = (k & 0x1f) << 3 + let bestDist = Infinity + let bestIdx = 0 + for (let c = 0; c < limit; c++) { + const dr = palR[c] - r + const dg = palG[c] - g + const db = palB[c] - b + const dist = dr * dr + dg * dg + db * db + if (dist < bestDist) { + bestDist = dist + bestIdx = c + if (dist === 0) break + } + } + lut[k] = bestIdx + } + } + + return { palette, lut } +} + +function quickSelect(keys: Uint16Array, cnts: Uint32Array, lo: number, hi: number, k: number): void { + while (lo < hi) { + const pivotIdx = lo + ((hi - lo) >> 1) + const pivotVal = cnts[pivotIdx] + let tk = keys[pivotIdx] + keys[pivotIdx] = keys[hi] + keys[hi] = tk + let tc = cnts[pivotIdx] + cnts[pivotIdx] = cnts[hi] + cnts[hi] = tc + let store = lo + for (let i = lo; i < hi; i++) { + if (cnts[i] > pivotVal) { + tk = keys[i] + keys[i] = keys[store] + keys[store] = tk + tc = cnts[i] + cnts[i] = cnts[store] + cnts[store] = tc + store++ + } + } + tk = keys[store] + keys[store] = keys[hi] + keys[hi] = tk + tc = cnts[store] + cnts[store] = cnts[hi] + cnts[hi] = tc + if (store === k) return + if (store < k) lo = store + 1 + else hi = store - 1 + } +} + +/** + * Map RGBA pixels to palette indices using precomputed Uint8Array LUT. + * Every possible 16-bit color key already maps to its nearest palette index. + */ +function indexFrame(pixels: Buffer, pixelCount: number, lut: Uint8Array): Uint8Array { + const indexed = new Uint8Array(pixelCount) + + for (let i = 0; i < pixelCount; i++) { + const pi = i * 4 + indexed[i] = lut[rgbToKey16(pixels[pi], pixels[pi + 1], pixels[pi + 2])] + } + + return indexed +} + +/** + * LZW compression for GIF using power-of-2 hash table. + */ +function lzwEncode(indexed: Uint8Array, minCodeSize: number): Buffer { + const clearCode = 1 << minCodeSize + const eoiCode = clearCode + 1 + const MAX_CODE = 4096 + + const HASH_BITS = 13 + const HASH_SIZE = 1 << HASH_BITS + const HASH_MASK = HASH_SIZE - 1 + const hashKeys = new Int32Array(HASH_SIZE) + const hashVals = new Uint16Array(HASH_SIZE) + + let codeSize = minCodeSize + 1 + let nextCode = eoiCode + 1 + + hashKeys.fill(-1) + + const outBytes = Buffer.allocUnsafe(indexed.length + 1024) + let outPos = 0 + let bitBuffer = 0 + let bitCount = 0 + + bitBuffer |= clearCode << bitCount + bitCount += codeSize + while (bitCount >= 8) { + outBytes[outPos++] = bitBuffer & 0xff + bitBuffer >>= 8 + bitCount -= 8 + } + + let current = indexed[0] + + for (let i = 1; i < indexed.length; i++) { + const next = indexed[i] + const key = (current << 8) | next + let idx = ((key >> 4) ^ key) & HASH_MASK + + let found = -1 + while (hashKeys[idx] !== -1) { + if (hashKeys[idx] === key) { + found = hashVals[idx] + break + } + idx = (idx + 1) & HASH_MASK + } + + if (found !== -1) { + current = found + } else { + bitBuffer |= current << bitCount + bitCount += codeSize + while (bitCount >= 8) { + outBytes[outPos++] = bitBuffer & 0xff + bitBuffer >>= 8 + bitCount -= 8 + } + + if (nextCode < MAX_CODE) { + hashKeys[idx] = key + hashVals[idx] = nextCode++ + if (nextCode > 1 << codeSize && codeSize < 12) { + codeSize++ + } + } else { + bitBuffer |= clearCode << bitCount + bitCount += codeSize + while (bitCount >= 8) { + outBytes[outPos++] = bitBuffer & 0xff + bitBuffer >>= 8 + bitCount -= 8 + } + hashKeys.fill(-1) + codeSize = minCodeSize + 1 + nextCode = eoiCode + 1 + } + current = next + } + } + + bitBuffer |= current << bitCount + bitCount += codeSize + while (bitCount >= 8) { + outBytes[outPos++] = bitBuffer & 0xff + bitBuffer >>= 8 + bitCount -= 8 + } + + bitBuffer |= eoiCode << bitCount + bitCount += codeSize + while (bitCount >= 8) { + outBytes[outPos++] = bitBuffer & 0xff + bitBuffer >>= 8 + bitCount -= 8 + } + + if (bitCount > 0) { + outBytes[outPos++] = bitBuffer & 0xff + } + + const rawBytes = outPos + const subBlockCount = Math.ceil(rawBytes / 255) + const result = Buffer.allocUnsafe(1 + rawBytes + subBlockCount + 1) + result[0] = minCodeSize + let rPos = 1 + + for (let off = 0; off < rawBytes; off += 255) { + const chunkLen = Math.min(255, rawBytes - off) + result[rPos++] = chunkLen + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outBytes.copy(result as any, rPos, off, off + chunkLen) + rPos += chunkLen + } + + result[rPos++] = 0 + + return result.subarray(0, rPos) +} + +const NETSCAPE_EXT = Buffer.from([ + 0x21, 0xff, 0x0b, 0x4e, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2e, 0x30, 0x03, 0x01, 0x00, 0x00, 0x00 +]) + +export function encodeGif(frames: GifFrame[], width: number, height: number): Buffer { + const pixelCount = width * height + const frameBuffers = frames.map((f) => f.pixels) + + const { palette, lut } = buildPaletteAndLUT(frameBuffers, width, height) + + const chunks: Buffer[] = [] + + const header = Buffer.allocUnsafe(13) + header[0] = 0x47 + header[1] = 0x49 + header[2] = 0x46 + header[3] = 0x38 + header[4] = 0x39 + header[5] = 0x61 + header.writeUInt16LE(width, 6) + header.writeUInt16LE(height, 8) + header[10] = 0xf7 + header[11] = 0x00 + header[12] = 0x00 + chunks.push(header) + + const gct = Buffer.allocUnsafe(768) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gct.set(palette as any) + chunks.push(gct) + + chunks.push(NETSCAPE_EXT) + + for (const frame of frames) { + const indexed = indexFrame(frame.pixels, pixelCount, lut) + + const delayCs = Math.round(frame.delay / 10) + const gce = Buffer.allocUnsafe(8) + gce[0] = 0x21 + gce[1] = 0xf9 + gce[2] = 0x04 + gce[3] = 0x00 + gce.writeUInt16LE(delayCs, 4) + gce[6] = 0x00 + gce[7] = 0x00 + chunks.push(gce) + + const imgDesc = Buffer.allocUnsafe(10) + imgDesc[0] = 0x2c + imgDesc.writeUInt16LE(0, 1) + imgDesc.writeUInt16LE(0, 3) + imgDesc.writeUInt16LE(width, 5) + imgDesc.writeUInt16LE(height, 7) + imgDesc[9] = 0x00 + chunks.push(imgDesc) + + chunks.push(lzwEncode(indexed, 8)) + } + + chunks.push(Buffer.from([0x3b])) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Buffer.concat(chunks as any) +} diff --git a/packages/pixel-profile/src/animation/index.ts b/packages/pixel-profile/src/animation/index.ts new file mode 100644 index 0000000..cc56ab3 --- /dev/null +++ b/packages/pixel-profile/src/animation/index.ts @@ -0,0 +1,303 @@ +import { executePipelineAsync, executePipelineSmart, type Pipeline } from '../pipeline' +import { compositeGlowFromPrecomputed, precomputeGlowLayers } from '../workers/pool' +import { encodeGif, type GifFrame } from './gif-encoder' + +export type AnimationOptions = { + frameCount?: number + frameDelay?: number + effect?: 'crt-flicker' | 'glow-pulse' | 'scanline-scroll' +} + +const defaultAnimationOptions: Required = { + frameCount: 8, + frameDelay: 100, + effect: 'glow-pulse' +} + +/** + * Build a set of pipelines that vary per frame to create animation. + */ +function buildAnimatedPipelines(effect: string, frameCount: number, basePipeline: Pipeline): Pipeline[] { + const pipelines: Pipeline[] = [] + + for (let i = 0; i < frameCount; i++) { + const t = i / frameCount + + switch (effect) { + case 'crt-flicker': { + const noiseIntensity = 0.03 + 0.04 * Math.sin(t * Math.PI * 2) + const scanLineStrength = 0.1 + 0.1 * Math.sin(t * Math.PI * 2 + 1) + const pipeline: Pipeline = basePipeline.map((pass) => { + if (pass.name === 'crt') { + return { + ...pass, + options: { + ...pass.options, + noiseIntensity, + scanLineStrength + } + } + } + + return pass + }) + pipelines.push(pipeline) + break + } + + case 'glow-pulse': { + const intensity = 0.1 + 0.15 * Math.sin(t * Math.PI * 2) + const pipeline: Pipeline = basePipeline.map((pass) => { + if (pass.name === 'glow') { + return { + ...pass, + options: { + ...pass.options, + intensity + } + } + } + + return pass + }) + pipelines.push(pipeline) + break + } + + case 'scanline-scroll': { + const pipeline: Pipeline = [] + for (const pass of basePipeline) { + pipeline.push(pass) + } + const scrollOffset = Math.floor(t * 6) + pipeline.push({ + name: 'scanline-animated', + shader: (source: Buffer, width: number, height: number) => { + return scanlineWithOffset(source, width, height, scrollOffset) + } + }) + pipelines.push(pipeline) + break + } + + default: + pipelines.push(basePipeline) + } + } + + return pipelines +} + +function scanlineWithOffset(source: Buffer, width: number, height: number, offset: number): Buffer { + const target = Buffer.allocUnsafe(width * height * 4) + const thickness = 3 + const brightness = 0.85 + const rowBytes = width * 4 + + for (let y = 0; y < height; y++) { + const rowOffset = y * rowBytes + if ((y + offset) % thickness === 0) { + for (let x = 0; x < width; x++) { + const idx = rowOffset + x * 4 + target[idx] = source[idx] * brightness + target[idx + 1] = source[idx + 1] * brightness + target[idx + 2] = source[idx + 2] * brightness + target[idx + 3] = source[idx + 3] + } + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + source.copy(target as any, rowOffset, rowOffset, rowOffset + rowBytes) + } + } + + return target +} + +/** + * Render an animated GIF from base pixels and a shader pipeline. + */ +export async function renderAnimatedGif( + basePixels: Buffer, + width: number, + height: number, + basePipeline: Pipeline, + userOptions: AnimationOptions = {} +): Promise { + const options = { ...defaultAnimationOptions, ...userOptions } + const { frameCount, frameDelay, effect } = options + + if (effect === 'glow-pulse') { + return renderGlowPulseOptimized(basePixels, width, height, basePipeline, frameCount, frameDelay) + } + if (effect === 'scanline-scroll') { + return renderScanlineScrollOptimized(basePixels, width, height, basePipeline, frameCount, frameDelay) + } + if (effect === 'crt-flicker') { + return renderCrtFlickerOptimized(basePixels, width, height, basePipeline, frameCount, frameDelay) + } + + const animatedPipelines = buildAnimatedPipelines(effect, frameCount, basePipeline) + + const frames: GifFrame[] = [] + for (const pipeline of animatedPipelines) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pixels = await executePipelineSmart(Buffer.from(basePixels as any), width, height, pipeline) + frames.push({ pixels, delay: frameDelay }) + } + + return encodeGif(frames, width, height) +} + +async function renderGlowPulseOptimized( + basePixels: Buffer, + width: number, + height: number, + basePipeline: Pipeline, + frameCount: number, + frameDelay: number +): Promise { + const preGlowPipeline = basePipeline.filter((p) => p.name !== 'glow') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const preGlowPixels = await executePipelineAsync(Buffer.from(basePixels as any), width, height, preGlowPipeline) + + const glowPass = basePipeline.find((p) => p.name === 'glow') + if (!glowPass) { + const frames: GifFrame[] = [] + for (let i = 0; i < frameCount; i++) { + frames.push({ pixels: preGlowPixels, delay: frameDelay }) + } + + return encodeGif(frames, width, height) + } + + const precomputed = await precomputeGlowLayers(preGlowPixels, width, height, glowPass.options || {}) + if (!precomputed) { + const animatedPipelines = buildAnimatedPipelines('glow-pulse', frameCount, basePipeline) + const frames: GifFrame[] = [] + for (const pipeline of animatedPipelines) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pixels = await executePipelineSmart(Buffer.from(basePixels as any), width, height, pipeline) + frames.push({ pixels, delay: frameDelay }) + } + + return encodeGif(frames, width, height) + } + + const color: [number, number, number] = glowPass.options?.color ?? [1, 1, 1] + const frames: GifFrame[] = [] + for (let i = 0; i < frameCount; i++) { + const t = i / frameCount + const intensity = 0.1 + 0.15 * Math.sin(t * Math.PI * 2) + const pixels = await compositeGlowFromPrecomputed(precomputed, intensity, color) + frames.push({ pixels, delay: frameDelay }) + } + + return encodeGif(frames, width, height) +} + +async function renderScanlineScrollOptimized( + basePixels: Buffer, + width: number, + height: number, + basePipeline: Pipeline, + frameCount: number, + frameDelay: number +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const processed = await executePipelineSmart(Buffer.from(basePixels as any), width, height, basePipeline) + + const frames: GifFrame[] = [] + for (let i = 0; i < frameCount; i++) { + const t = i / frameCount + const scrollOffset = Math.floor(t * 6) + frames.push({ pixels: scanlineWithOffset(processed, width, height, scrollOffset), delay: frameDelay }) + } + + return encodeGif(frames, width, height) +} + +async function renderCrtFlickerOptimized( + basePixels: Buffer, + width: number, + height: number, + basePipeline: Pipeline, + frameCount: number, + frameDelay: number +): Promise { + const crtPass = basePipeline.find((p) => p.name === 'crt') + const glowPass = basePipeline.find((p) => p.name === 'glow') + const nonGlowPipeline = basePipeline.filter((p) => p.name !== 'glow') + + if (!crtPass || !glowPass) { + const animatedPipelines = buildAnimatedPipelines('crt-flicker', frameCount, basePipeline) + const frames: GifFrame[] = [] + for (const pipeline of animatedPipelines) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pixels = await executePipelineSmart(Buffer.from(basePixels as any), width, height, pipeline) + frames.push({ pixels, delay: frameDelay }) + } + + return encodeGif(frames, width, height) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const baseCrt = await executePipelineSmart(Buffer.from(basePixels as any), width, height, nonGlowPipeline) + const precomputed = await precomputeGlowLayers(baseCrt, width, height, glowPass.options || {}) + + if (!precomputed) { + const animatedPipelines = buildAnimatedPipelines('crt-flicker', frameCount, basePipeline) + const frames: GifFrame[] = [] + for (const pipeline of animatedPipelines) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pixels = await executePipelineSmart(Buffer.from(basePixels as any), width, height, pipeline) + frames.push({ pixels, delay: frameDelay }) + } + + return encodeGif(frames, width, height) + } + + const color: [number, number, number] = glowPass.options?.color ?? [1, 1, 1] + const intensity = glowPass.options?.intensity ?? 0.17 + const glowOnBase = await compositeGlowFromPrecomputed(precomputed, intensity, color) + const byteLen = width * height * 4 + + const frames: GifFrame[] = [] + for (let i = 0; i < frameCount; i++) { + const t = i / frameCount + const noiseIntensity = 0.03 + 0.04 * Math.sin(t * Math.PI * 2) + const scanLineStrength = 0.1 + 0.1 * Math.sin(t * Math.PI * 2 + 1) + + const frameCrtPipeline: Pipeline = nonGlowPipeline.map((pass) => { + if (pass.name === 'crt') { + return { ...pass, options: { ...pass.options, noiseIntensity, scanLineStrength } } + } + + return pass + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const crtPixels = await executePipelineSmart(Buffer.from(basePixels as any), width, height, frameCrtPipeline) + + const merged = Buffer.allocUnsafe(byteLen) + for (let j = 0; j < byteLen; j += 4) { + let r = glowOnBase[j] + crtPixels[j] - baseCrt[j] + let g = glowOnBase[j + 1] + crtPixels[j + 1] - baseCrt[j + 1] + let b = glowOnBase[j + 2] + crtPixels[j + 2] - baseCrt[j + 2] + if (r < 0) r = 0 + else if (r > 255) r = 255 + if (g < 0) g = 0 + else if (g > 255) g = 255 + if (b < 0) b = 0 + else if (b > 255) b = 255 + merged[j] = r + merged[j + 1] = g + merged[j + 2] = b + merged[j + 3] = 255 + } + frames.push({ pixels: merged, delay: frameDelay }) + } + + return encodeGif(frames, width, height) +} + +export { encodeGif, type GifFrame } from './gif-encoder' diff --git a/packages/pixel-profile/src/index.ts b/packages/pixel-profile/src/index.ts index b75cfeb..50da87b 100644 --- a/packages/pixel-profile/src/index.ts +++ b/packages/pixel-profile/src/index.ts @@ -1,4 +1,17 @@ +export type { AnimationOptions, GifFrame } from './animation' +export { encodeGif, renderAnimatedGif } from './animation' export { fetchStats } from './fetchers/stats-fetcher' +export type { Pipeline, ShaderFn, ShaderPass } from './pipeline' +export { + buildCrtPipeline, + buildStatsPipeline, + executePipeline, + executePipelineAsync, + executePipelineSmart, + SHADERS +} from './pipeline' export { renderCrtStats } from './renderers/crt-renderer' export { renderStats } from './renderers/stats-renderer' +export type { PaletteId } from './shaders/dithering' +export { BUILTIN_PALETTES, paletteDither } from './shaders/dithering' export { clamp, request, RETRIES, retryer } from './utils' diff --git a/packages/pixel-profile/src/pipeline.ts b/packages/pixel-profile/src/pipeline.ts new file mode 100644 index 0000000..a9f1ff2 --- /dev/null +++ b/packages/pixel-profile/src/pipeline.ts @@ -0,0 +1,207 @@ +import { addBorder, crt, curve, pixelate } from './shaders' +import { BUILTIN_PALETTES, orderedBayer, paletteDither, type PaletteId } from './shaders/dithering' +import { glow } from './shaders/glow' +import { scanline } from './shaders/scanline' +import { dispatchCrt, dispatchCurve, dispatchGlow } from './workers/pool' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ShaderFn = (pixels: Buffer, width: number, height: number, opts?: any) => Buffer + +export type ShaderPass = { + name: string + shader: ShaderFn + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options?: Record + enabled?: boolean + parallel?: boolean +} + +export type Pipeline = ShaderPass[] + +export const SHADERS = { + pixelate, + addBorder, + orderedBayer, + scanline, + glow, + curve, + crt +} as const + +export function executePipeline(pixels: Buffer, width: number, height: number, pipeline: Pipeline): Buffer { + let result = pixels + + for (const pass of pipeline) { + if (pass.enabled === false) continue + result = pass.shader(result, width, height, pass.options) + } + + return result +} + +export async function executePipelineAsync( + pixels: Buffer, + width: number, + height: number, + pipeline: Pipeline +): Promise { + let result = pixels + + for (const pass of pipeline) { + if (pass.enabled === false) continue + if (pass.parallel) { + if (pass.name === 'crt') { + const parallel = await dispatchCrt(result, width, height, pass.options || {}) + if (parallel) { + result = parallel + continue + } + } else if (pass.name === 'glow') { + const parallel = await dispatchGlow(result, width, height, pass.options || {}) + if (parallel) { + result = parallel + continue + } + } else if (pass.name === 'curve') { + const parallel = await dispatchCurve(result, width, height) + if (parallel) { + result = parallel + continue + } + } + } + result = pass.shader(result, width, height, pass.options) + } + + return result +} + +function hasParallelPass(pipeline: Pipeline): boolean { + return pipeline.some((p) => p.parallel) +} + +export async function executePipelineSmart( + pixels: Buffer, + width: number, + height: number, + pipeline: Pipeline +): Promise { + if (hasParallelPass(pipeline)) { + return executePipelineAsync(pixels, width, height, pipeline) + } + + return executePipeline(pixels, width, height, pipeline) +} + +export function buildStatsPipeline(options: { + theme: string + screenEffect: boolean + isFastMode: boolean + dithering: boolean + ditheringPalette?: string +}): Pipeline { + const { theme, screenEffect, isFastMode, dithering, ditheringPalette } = options + + if (theme === 'crt') { + return [ + { name: 'crt', shader: crt, parallel: true }, + { + name: 'glow', + shader: glow, + parallel: true, + options: { + radius: 5, + intensity: 0.17, + color: [1, 1, 1], + layers: 5, + falloff: 'exponential' + } + } + ] + } + + const pipeline: Pipeline = [] + + if (ditheringPalette) { + const palette = resolvePalette(ditheringPalette) + if (palette) { + pipeline.push({ + name: 'paletteDither', + shader: paletteDither, + options: palette + }) + } + } else if (dithering) { + pipeline.push({ name: 'orderedBayer', shader: orderedBayer }) + } + + if (screenEffect) { + if (!dithering && !ditheringPalette) { + pipeline.push({ name: 'scanline', shader: scanline }) + } + if (!isFastMode) { + pipeline.push({ + name: 'glow', + shader: glow, + parallel: true, + options: { + radius: 3, + intensity: 0.3, + color: [1, 1, 1], + layers: 2, + falloff: 'exponential' + } + }) + } + pipeline.push({ name: 'curve', shader: curve, parallel: true }) + } + + return pipeline +} + +function resolvePalette(spec: string): number[][] | null { + if (spec in BUILTIN_PALETTES) { + return BUILTIN_PALETTES[spec as PaletteId] + } + + const hexColors = spec.split(',').map((s) => s.trim().replace(/^#/, '')) + if (hexColors.length < 2 || hexColors.some((h) => !/^[0-9a-fA-F]{6}$/.test(h))) { + return null + } + + return hexColors.map((h) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]) +} + +export function buildCrtPipeline(): Pipeline { + return [ + { + name: 'crt', + shader: crt, + parallel: true, + options: { + curvatureX: 0.045, + curvatureY: 0.045, + cornerSize: 0.05, + vignetteDarkness: 0.05, + scanLineStrength: 0.15, + scanLineCount: 240, + rgbShift: 0.5, + bloomAmount: 0.25, + noiseIntensity: 0.05, + borderSize: 0 + } + }, + { + name: 'glow', + shader: glow, + parallel: true, + options: { + radius: 5, + intensity: 0.17, + color: [1, 1, 1], + layers: 1, + falloff: 'exponential' + } + } + ] +} diff --git a/packages/pixel-profile/src/renderer/common.ts b/packages/pixel-profile/src/renderer/common.ts index 3eef36c..b2903b3 100644 --- a/packages/pixel-profile/src/renderer/common.ts +++ b/packages/pixel-profile/src/renderer/common.ts @@ -1,14 +1 @@ -export type PixelCoords = [number, number] export type RGBA = [number, number, number, number] -export type Texture = (coords: PixelCoords) => RGBA -export type FragShader = (coords: PixelCoords, texture: Texture) => RGBA - -export function coordsToIndex(x: number, y: number, width: number): number { - return y * width + x -} - -export function coordsToPixel(source: Buffer, x: number, y: number, width: number): RGBA { - const index = coordsToIndex(x, y, width) * 4 - - return [source[index], source[index + 1], source[index + 2], source[index + 3]] -} diff --git a/packages/pixel-profile/src/renderer/index.ts b/packages/pixel-profile/src/renderer/index.ts index 7c51305..fe998ea 100644 --- a/packages/pixel-profile/src/renderer/index.ts +++ b/packages/pixel-profile/src/renderer/index.ts @@ -1,4 +1 @@ -export type { FragShader, PixelCoords, RGBA, Texture } from './common' -export { coordsToIndex } from './common' -export { render } from './render' -export { TEXTURE_FILTER } from './texture-filter' +export type { RGBA } from './common' diff --git a/packages/pixel-profile/src/renderer/render.ts b/packages/pixel-profile/src/renderer/render.ts deleted file mode 100644 index aab7fe9..0000000 --- a/packages/pixel-profile/src/renderer/render.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { clamp } from '../utils' -import { coordsToIndex, FragShader, PixelCoords, RGBA } from './common' -import { TEXTURE_FILTER, textureFilterGeneratorByName, TextureFilterName } from './texture-filter' - -type Options = { - textureFilter?: TextureFilterName -} - -export function render( - pixels: Buffer, - width: number, - height: number, - fragShader: FragShader, - options: Options = {} -): Buffer { - const { textureFilter = TEXTURE_FILTER.LINEAR } = options - - const target = Buffer.alloc(width * height * 4) - const maxX = width - 1 - const maxY = height - 1 - - const textureFilterFn = textureFilterGeneratorByName[textureFilter](pixels, width, height) - - function texture(coords: PixelCoords): RGBA { - coords[0] = clamp(coords[0], 0, maxX) - coords[1] = clamp(coords[1], 0, maxY) - - return textureFilterFn(coords) - } - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const rgba = fragShader([x, y], texture) - const index = coordsToIndex(x, y, width) * 4 - target[index] = rgba[0] - target[index + 1] = rgba[1] - target[index + 2] = rgba[2] - target[index + 3] = rgba[3] - } - } - - return target -} diff --git a/packages/pixel-profile/src/renderer/texture-filter/index.ts b/packages/pixel-profile/src/renderer/texture-filter/index.ts deleted file mode 100644 index c4fecc0..0000000 --- a/packages/pixel-profile/src/renderer/texture-filter/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { genBiLinearFilter } from './linear' -import { genNearestNeighborFilter } from './nearest' - -export const TEXTURE_FILTER = { - NEAREST: 'NEAREST', - LINEAR: 'LINEAR' -} as const - -export type TextureFilterName = (typeof TEXTURE_FILTER)[keyof typeof TEXTURE_FILTER] - -export const textureFilterGeneratorByName = { - [TEXTURE_FILTER.NEAREST]: genNearestNeighborFilter, - [TEXTURE_FILTER.LINEAR]: genBiLinearFilter -} diff --git a/packages/pixel-profile/src/renderer/texture-filter/linear.ts b/packages/pixel-profile/src/renderer/texture-filter/linear.ts deleted file mode 100644 index fbefcf0..0000000 --- a/packages/pixel-profile/src/renderer/texture-filter/linear.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { clamp } from '../../utils' -import { coordsToPixel, PixelCoords, RGBA, Texture } from '../common' - -export function genBiLinearFilter(pixels: Buffer, width: number, height: number): Texture { - const maxX = width - 1 - const maxY = height - 1 - - function biLinearInterpolate(v1: number, v2: number, v3: number, v4: number, sx: number, sy: number): number { - const tmp1 = v1 * (1 - sx) + v2 * sx - const tmp2 = v3 * (1 - sx) + v4 * sx - - return tmp1 * (1 - sy) + tmp2 * sy - } - function biLinearFilter(coords: PixelCoords): RGBA { - const x = coords[0] - const y = coords[1] - const flooredX = Math.floor(x) - const flooredY = Math.floor(y) - const x0 = clamp(flooredX, 0, maxX) - const x1 = clamp(flooredX + 1, 0, maxX) - const y0 = clamp(flooredY, 0, maxY) - const y1 = clamp(flooredY + 1, 0, maxY) - - const sx = x - x0 - const sy = y - y0 - - const p00 = coordsToPixel(pixels, x0, y0, width) - const p01 = coordsToPixel(pixels, x0, y1, width) - const p10 = coordsToPixel(pixels, x1, y0, width) - const p11 = coordsToPixel(pixels, x1, y1, width) - - const r = biLinearInterpolate(p00[0], p10[0], p01[0], p11[0], sx, sy) - const g = biLinearInterpolate(p00[1], p10[1], p01[1], p11[1], sx, sy) - const b = biLinearInterpolate(p00[2], p10[2], p01[2], p11[2], sx, sy) - const a = biLinearInterpolate(p00[3], p10[3], p01[3], p11[3], sx, sy) - - return [r, g, b, a] - } - - return biLinearFilter -} diff --git a/packages/pixel-profile/src/renderer/texture-filter/nearest.ts b/packages/pixel-profile/src/renderer/texture-filter/nearest.ts deleted file mode 100644 index 46798b2..0000000 --- a/packages/pixel-profile/src/renderer/texture-filter/nearest.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { coordsToPixel, PixelCoords, RGBA, Texture } from '../common' - -export function genNearestNeighborFilter(pixels: Buffer, width: number): Texture { - function nearestNeighborFilter(coords: PixelCoords): RGBA { - const nearestX = Math.round(coords[0]) - const nearestY = Math.round(coords[1]) - - return coordsToPixel(pixels, nearestX, nearestY, width) - } - - return nearestNeighborFilter -} diff --git a/packages/pixel-profile/src/renderers/crt-renderer.ts b/packages/pixel-profile/src/renderers/crt-renderer.ts index afc003d..b1ff4fb 100644 --- a/packages/pixel-profile/src/renderers/crt-renderer.ts +++ b/packages/pixel-profile/src/renderers/crt-renderer.ts @@ -1,5 +1,4 @@ -import { crt } from '../shaders' -import { glow } from '../shaders/glow' +import { buildCrtPipeline, executePipelineSmart } from '../pipeline' import { defaultTemplateOptions, makeGithubStats, TemplateOptions } from '../templates/crt-template' import { getThemeOptions } from '../theme' import { GithubStats } from '../types' @@ -44,27 +43,8 @@ export async function renderCrtStats(stats: GithubStats, options: CrtOptions = { () => makeGithubStats({ ...templateStats, name: username }, templateOptions) ) - let pixels = renderedPixels - - pixels = crt(pixels, width, height, { - curvatureX: 0.045, - curvatureY: 0.045, - cornerSize: 0.05, - vignetteDarkness: 0.05, - scanLineStrength: 0.15, - scanLineCount: 240, - rgbShift: 0.5, - bloomAmount: 0.25, - noiseIntensity: 0.05, - borderSize: 0 - }) - pixels = glow(pixels, width, height, { - radius: 5, - intensity: 0.17, - color: [1, 1, 1], - layers: 1, - falloff: 'exponential' - }) + const pipeline = buildCrtPipeline() + let pixels = await executePipelineSmart(renderedPixels, width, height, pipeline) pixels = await blendBorder(pixels, width, height, { targetWidth: CRT_CARD_SIZE.width, diff --git a/packages/pixel-profile/src/renderers/render-utils.ts b/packages/pixel-profile/src/renderers/render-utils.ts index 3ef8ed6..e09f703 100644 --- a/packages/pixel-profile/src/renderers/render-utils.ts +++ b/packages/pixel-profile/src/renderers/render-utils.ts @@ -1,6 +1,6 @@ import { fontBuffer } from '../assets/fonts/press-start-2p' import { GithubStats, TemplateStats } from '../types' -import { getPixelsFromPngBuffer, kFormatter } from '../utils' +import { kFormatter } from '../utils' import { Resvg } from '@resvg/resvg-js' import satori from 'satori' @@ -28,9 +28,17 @@ export function formatStatsData(stats: GithubStats, avatar: string): TemplateSta } } -/** - * Render JSX element to pixels with font fallback support - */ +const SATORI_FONTS = [{ name: 'PressStart2P', data: fontBuffer, weight: 400 as const, style: 'normal' as const }] +const RESVG_OPTS = { fitTo: { mode: 'width' as const, value: 0 }, font: { loadSystemFonts: false } } + +function renderSvgToPixels(svg: string, width: number): Buffer { + const opts = { ...RESVG_OPTS, fitTo: { ...RESVG_OPTS.fitTo, value: width } } + const pngData = new Resvg(svg, opts).render() + const px = pngData.pixels + + return Buffer.from(px.buffer, px.byteOffset, px.byteLength) +} + export async function renderToPixels( element: JSX.Element, width: number, @@ -42,14 +50,7 @@ export async function renderToPixels( let svg = await satori(element, { width, height, - fonts: [ - { - name: 'PressStart2P', - data: fontBuffer, - weight: 400, - style: 'normal' - } - ], + fonts: SATORI_FONTS, loadAdditionalAsset: async () => { isMissingFont = true @@ -58,34 +59,10 @@ export async function renderToPixels( }) if (isMissingFont && fallbackRender) { - svg = await satori(fallbackRender(), { - width, - height, - fonts: [ - { - name: 'PressStart2P', - data: fontBuffer, - weight: 400, - style: 'normal' - } - ] - }) + svg = await satori(fallbackRender(), { width, height, fonts: SATORI_FONTS }) } - const opts = { - fitTo: { - mode: 'width', - value: width - }, - font: { - loadSystemFonts: false - } - } as const - - const pngData = new Resvg(svg, opts).render() - const pngBuffer = pngData.asPng() - - const { pixels } = await getPixelsFromPngBuffer(pngBuffer) + const pixels = renderSvgToPixels(svg, width) return { pixels, width, height } } diff --git a/packages/pixel-profile/src/renderers/stats-renderer.ts b/packages/pixel-profile/src/renderers/stats-renderer.ts index 3cf2e91..39e9e1d 100644 --- a/packages/pixel-profile/src/renderers/stats-renderer.ts +++ b/packages/pixel-profile/src/renderers/stats-renderer.ts @@ -1,7 +1,7 @@ -import { addBorder, crt, curve, pixelate } from '../shaders' -import { orderedBayer } from '../shaders/dithering' -import { glow } from '../shaders/glow' -import { scanline } from '../shaders/scanline' +import { type AnimationOptions, renderAnimatedGif } from '../animation' +import { buildStatsPipeline, executePipelineSmart } from '../pipeline' +import { pixelate } from '../shaders' +import { addBorder } from '../shaders/border' import { AVATAR_SIZE, defaultTemplateOptions, makeGithubStats, TemplateOptions } from '../templates/stats-template' import { getThemeOptions } from '../theme' import { GithubStats } from '../types' @@ -24,6 +24,8 @@ type Options = { pixelateAvatar?: boolean avatarBorder?: boolean dithering?: boolean + ditheringPalette?: string + animation?: AnimationOptions | boolean } const CARD_SIZE = { @@ -51,7 +53,8 @@ export async function renderStats(stats: GithubStats, options: Options = {}): Pr isFastMode = true, avatarBorder, theme = '', - dithering = false + dithering = false, + ditheringPalette } = options const applyAvatarBorder = avatarBorder !== undefined ? avatarBorder : theme !== '' @@ -86,39 +89,16 @@ export async function renderStats(stats: GithubStats, options: Options = {}): Pr () => makeGithubStats({ ...templateStats, name: username }, templateOptions) ) - let pixels = renderedPixels + const pipeline = buildStatsPipeline({ theme, screenEffect, isFastMode, dithering, ditheringPalette }) - if (theme === 'crt') { - pixels = crt(pixels, width, height) - pixels = glow(pixels, width, height, { - radius: 5, - intensity: 0.17, - color: [1, 1, 1], - layers: 5, - falloff: 'exponential' - }) - } else { - if (dithering) { - pixels = orderedBayer(pixels, width, height) - } + if (options.animation) { + const animOpts: AnimationOptions = typeof options.animation === 'boolean' ? {} : options.animation - if (screenEffect) { - if (!dithering) { - pixels = scanline(pixels, width, height) - } - if (!isFastMode) { - pixels = glow(pixels, width, height, { - radius: 3, - intensity: 0.3, - color: [1, 1, 1], - layers: 2, - falloff: 'exponential' - }) - } - pixels = curve(pixels, width, height) - } + return await renderAnimatedGif(renderedPixels, width, height, pipeline, animOpts) } + const pixels = await executePipelineSmart(renderedPixels, width, height, pipeline) + return await getPngBufferFromPixels(pixels, width, height) } diff --git a/packages/pixel-profile/src/shaders/border.ts b/packages/pixel-profile/src/shaders/border.ts index 1535613..d1142aa 100644 --- a/packages/pixel-profile/src/shaders/border.ts +++ b/packages/pixel-profile/src/shaders/border.ts @@ -1,5 +1,3 @@ -import { render, type RGBA, TEXTURE_FILTER } from '../renderer' - export function addBorder( source: Buffer, width: number, @@ -11,40 +9,31 @@ export function addBorder( } ) { const { enabledTransparentBorder = true, enabledCornerRemoval = true, frameWidthRatio } = options + const target = Buffer.allocUnsafe(width * height * 4) + target.set(source) - return render( - source, - width, - height, - (coords, texture) => { - const maxX = width - 1 - const maxY = height - 1 - const x = coords[0] - const y = coords[1] - - const frameWidth = frameWidthRatio * width - - const samplerColor: RGBA = texture(coords) + const frameWidth = frameWidthRatio * width + const maxX = width - 1 + const maxY = height - 1 + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { const count = Number(x < frameWidth) + Number(y < frameWidth) + Number(x > maxX - frameWidth) + Number(y > maxY - frameWidth) if (count !== 0) { + const idx = (y * width + x) * 4 + if (enabledTransparentBorder) { - samplerColor[3] = 128 + target[idx + 3] = 128 } if (count === 2 && enabledCornerRemoval) { - samplerColor[3] = 0 + target[idx + 3] = 0 } - - return samplerColor } - - return samplerColor - }, - { - textureFilter: TEXTURE_FILTER.NEAREST } - ) + } + + return target } diff --git a/packages/pixel-profile/src/shaders/crt.ts b/packages/pixel-profile/src/shaders/crt.ts index 8e382de..01d9551 100644 --- a/packages/pixel-profile/src/shaders/crt.ts +++ b/packages/pixel-profile/src/shaders/crt.ts @@ -1,30 +1,17 @@ -import { render } from '../renderer' -import type { Vec2 } from '../utils' -import { add2, clamp, dot2, length2, subtract2 } from '../utils' - interface CRTOptions { - // Curvature (0.0 to 0.5, higher = more curved) curvatureX: number curvatureY: number - // Corner size (0.0 to 0.3) cornerSize: number - // Vignette darkness (0.0 to 1.0) vignetteDarkness: number - // Scan line strength (0.0 to 1.0) scanLineStrength: number - // Scan line count (integer) scanLineCount: number - // RGB separation amount (0.0 to 5.0) rgbShift: number - // Bloom amount (0.0 to 0.5) bloomAmount: number - // Noise intensity (0.0 to 0.3) noiseIntensity: number - // Border size as fraction of screen (0.0 to 0.1) borderSize: number } -const defaultCRTOptions: CRTOptions = { +export const defaultCRTOptions: CRTOptions = { curvatureX: 0.03, curvatureY: 0.03, cornerSize: 0.05, @@ -37,126 +24,220 @@ const defaultCRTOptions: CRTOptions = { borderSize: 0.03 } -function randomNoise(coords: Vec2): number { - const x = Math.sin(dot2(coords, [12.9898, 78.233])) * 43758.5453 - - return (x - Math.floor(x)) * 2.0 - 1.0 -} - -export function crt(source: Buffer, width: number, height: number, options: Partial = {}): Buffer { - const opts: CRTOptions = { ...defaultCRTOptions, ...options } - - return render(source, width, height, (coords, texture) => { - const maxX = width - 1 - const maxY = height - 1 - - const uv: Vec2 = [coords[0] / width, coords[1] / height] - - function distortCoordinates(coords: Vec2): Vec2 { - const centered: Vec2 = subtract2(coords, [0.5, 0.5]) - - const distSquared = dot2(centered, centered) - - const curveFactor: Vec2 = [ - 1.0 + distSquared * (opts.curvatureX * 5.0), - 1.0 + distSquared * (opts.curvatureY * 5.0) - ] - - const distorted: Vec2 = [centered[0] * curveFactor[0], centered[1] * curveFactor[1]] - - return add2(distorted, [0.5, 0.5]) - } - - const distortedCoords = distortCoordinates(uv) - - const borderMargin = opts.borderSize - const inBounds = - distortedCoords[0] >= borderMargin && - distortedCoords[0] <= 1.0 - borderMargin && - distortedCoords[1] >= borderMargin && - distortedCoords[1] <= 1.0 - borderMargin - - if (!inBounds) { - return [0, 0, 0, 255] - } +/** + * Core CRT computation for a row range. Closure-free so it can be + * serialized via Function.toString() for Worker threads. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function crtCore( + source: any, + target: any, + width: number, + height: number, + startRow: number, + endRow: number, + opts: any +): void { + const BLOOM_OFFSETS_X = [-1, 0, 1, -1, 1, -1, 0, 1] + const BLOOM_OFFSETS_Y = [-1, -1, -1, 0, 0, 1, 1, 1] + + const maxX = width - 1 + const maxY = height - 1 + + const curvX5 = opts.curvatureX * 5 + const curvY5 = opts.curvatureY * 5 + const borderMargin = opts.borderSize + const borderDenom = 1 - 2 * borderMargin + const rgbShiftAmount = opts.rgbShift * 0.01 + const hasBloom = opts.bloomAmount > 0 + const bloomAmount = opts.bloomAmount + const scanLineCount = opts.scanLineCount + const scanLineStrength = opts.scanLineStrength + const noiseIntensity = opts.noiseIntensity + const vignetteDarkness = opts.vignetteDarkness + const cornerSize = opts.cornerSize + const w4 = width * 4 + const oneBorderMargin = 1 - borderMargin + + for (let py = startRow; py < endRow; py++) { + const uvY = py / height + const cy = uvY - 0.5 + const cy2 = cy * cy + const rowBase = py * w4 + + for (let px = 0; px < width; px++) { + const uvX = px / width + const idx = rowBase + px * 4 + + const cx = uvX - 0.5 + const distSq = cx * cx + cy2 + const distX = cx * (1 + distSq * curvX5) + 0.5 + const distY = cy * (1 + distSq * curvY5) + 0.5 + + if (distX < borderMargin || distX > oneBorderMargin || distY < borderMargin || distY > oneBorderMargin) { + target[idx] = 0 + target[idx + 1] = 0 + target[idx + 2] = 0 + target[idx + 3] = 255 + continue + } - const rescaledCoords: Vec2 = [ - (distortedCoords[0] - borderMargin) / (1.0 - 2.0 * borderMargin), - (distortedCoords[1] - borderMargin) / (1.0 - 2.0 * borderMargin) - ] - - const pixelCoords: Vec2 = [rescaledCoords[0] * maxX, rescaledCoords[1] * maxY] - - const vignetteCoords: Vec2 = subtract2(rescaledCoords, [0.5, 0.5]) - const distFromCenter = length2(vignetteCoords) - const cornerDistance = Math.min(Math.abs(vignetteCoords[0]) + Math.abs(vignetteCoords[1]) * opts.cornerSize, 1.0) - - const vignette = clamp( - (1.0 - distFromCenter * 1.5) * (1.0 - cornerDistance * 0.5), - 1.0 - opts.vignetteDarkness, - 1.0 - ) - - const rgbShiftAmount = opts.rgbShift * 0.01 - const shiftDir = vignetteCoords - - let r = 0 - let g = 0 - let b = 0 - - const redCoords: Vec2 = [ - pixelCoords[0] + shiftDir[0] * rgbShiftAmount * maxX, - pixelCoords[1] + shiftDir[1] * rgbShiftAmount * maxY - ] - const redSample = texture(redCoords) - r = redSample[0] - - const greenSample = texture(pixelCoords) - g = greenSample[1] - - const blueCoords: Vec2 = [ - pixelCoords[0] - shiftDir[0] * rgbShiftAmount * maxX, - pixelCoords[1] - shiftDir[1] * rgbShiftAmount * maxY - ] - const blueSample = texture(blueCoords) - b = blueSample[2] - - const scanLineY = Math.floor(rescaledCoords[1] * opts.scanLineCount) % 2 - const scanLine = 1.0 - scanLineY * opts.scanLineStrength - - const noise = 1.0 + randomNoise(uv) * opts.noiseIntensity - - let bloom = 0 - if (opts.bloomAmount > 0) { - const sampleOffsets = [ - [-1, -1], - [0, -1], - [1, -1], - [-1, 0], - [1, 0], - [-1, 1], - [0, 1], - [1, 1] - ] - let bloomSum = 0 - - for (const offset of sampleOffsets) { - const sampleCoord: Vec2 = [pixelCoords[0] + offset[0], pixelCoords[1] + offset[1]] - - if (sampleCoord[0] >= 0 && sampleCoord[0] <= maxX && sampleCoord[1] >= 0 && sampleCoord[1] <= maxY) { - const sample = texture(sampleCoord) - bloomSum += (sample[0] + sample[1] + sample[2]) / 3 + const rsX = (distX - borderMargin) / borderDenom + const rsY = (distY - borderMargin) / borderDenom + let pcX = rsX * maxX + let pcY = rsY * maxY + + const vcX = rsX - 0.5 + const vcY = rsY - 0.5 + const distFromCenter = Math.sqrt(vcX * vcX + vcY * vcY) + const cornerDist = Math.min(Math.abs(vcX) + Math.abs(vcY) * cornerSize, 1) + let vignette = (1 - distFromCenter * 1.5) * (1 - cornerDist * 0.5) + vignette = Math.min(1, Math.max(1 - vignetteDarkness, vignette)) + + let bx: number, by: number, bx0: number, bx1: number, by0: number, by1: number + let bsx: number, bsy: number, bosx: number, bosy: number + let bi00: number, bi10: number, bi01: number, bi11: number + let ry0: number, ry1: number, cx0: number, cx1: number + + bx = Math.min(maxX, Math.max(0, pcX + vcX * rgbShiftAmount * maxX)) + by = Math.min(maxY, Math.max(0, pcY + vcY * rgbShiftAmount * maxY)) + bx0 = bx | 0 + bx1 = Math.min(bx0 + 1, maxX) + by0 = by | 0 + by1 = Math.min(by0 + 1, maxY) + bsx = bx - bx0 + bsy = by - by0 + bosx = 1 - bsx + bosy = 1 - bsy + ry0 = by0 * w4 + ry1 = by1 * w4 + cx0 = bx0 << 2 + cx1 = bx1 << 2 + bi00 = ry0 + cx0 + bi10 = ry0 + cx1 + bi01 = ry1 + cx0 + bi11 = ry1 + cx1 + const r = (source[bi00] * bosx + source[bi10] * bsx) * bosy + (source[bi01] * bosx + source[bi11] * bsx) * bsy + + pcX = Math.min(maxX, Math.max(0, pcX)) + pcY = Math.min(maxY, Math.max(0, pcY)) + bx0 = pcX | 0 + bx1 = Math.min(bx0 + 1, maxX) + by0 = pcY | 0 + by1 = Math.min(by0 + 1, maxY) + bsx = pcX - bx0 + bsy = pcY - by0 + bosx = 1 - bsx + bosy = 1 - bsy + ry0 = by0 * w4 + ry1 = by1 * w4 + cx0 = bx0 << 2 + cx1 = bx1 << 2 + bi00 = ry0 + cx0 + bi10 = ry0 + cx1 + bi01 = ry1 + cx0 + bi11 = ry1 + cx1 + const g = + (source[bi00 + 1] * bosx + source[bi10 + 1] * bsx) * bosy + + (source[bi01 + 1] * bosx + source[bi11 + 1] * bsx) * bsy + + bx = Math.min(maxX, Math.max(0, pcX - vcX * rgbShiftAmount * maxX)) + by = Math.min(maxY, Math.max(0, pcY - vcY * rgbShiftAmount * maxY)) + bx0 = bx | 0 + bx1 = Math.min(bx0 + 1, maxX) + by0 = by | 0 + by1 = Math.min(by0 + 1, maxY) + bsx = bx - bx0 + bsy = by - by0 + bosx = 1 - bsx + bosy = 1 - bsy + ry0 = by0 * w4 + ry1 = by1 * w4 + cx0 = bx0 << 2 + cx1 = bx1 << 2 + bi00 = ry0 + cx0 + bi10 = ry0 + cx1 + bi01 = ry1 + cx0 + bi11 = ry1 + cx1 + const b = + (source[bi00 + 2] * bosx + source[bi10 + 2] * bsx) * bosy + + (source[bi01 + 2] * bosx + source[bi11 + 2] * bsx) * bsy + + const scanLineY = Math.floor(rsY * scanLineCount) % 2 + const scanLine = 1 - scanLineY * scanLineStrength + + const noiseSeed = Math.sin(uvX * 12.9898 + uvY * 78.233) * 43758.5453 + const noise = 1 + ((noiseSeed - Math.floor(noiseSeed)) * 2 - 1) * noiseIntensity + + let bloom = 0 + if (hasBloom) { + let bloomSum = 0 + const gBx0 = pcX | 0 + const gBy0 = pcY | 0 + const gBsx = pcX - gBx0 + const gBsy = pcY - gBy0 + const gBosx = 1 - gBsx + const gBosy = 1 - gBsy + + if (gBx0 >= 1 && gBx0 <= maxX - 2 && gBy0 >= 1 && gBy0 <= maxY - 2) { + for (let bi = 0; bi < 8; bi++) { + const base = ((gBy0 + BLOOM_OFFSETS_Y[bi]) * width + gBx0 + BLOOM_OFFSETS_X[bi]) * 4 + bloomSum += + ((source[base] * gBosx + source[base + 4] * gBsx) * gBosy + + (source[base + w4] * gBosx + source[base + w4 + 4] * gBsx) * gBsy + + (source[base + 1] * gBosx + source[base + 5] * gBsx) * gBosy + + (source[base + w4 + 1] * gBosx + source[base + w4 + 5] * gBsx) * gBsy + + (source[base + 2] * gBosx + source[base + 6] * gBsx) * gBosy + + (source[base + w4 + 2] * gBosx + source[base + w4 + 6] * gBsx) * gBsy) / + 3 + } + } else { + for (let bi = 0; bi < 8; bi++) { + const sx = pcX + BLOOM_OFFSETS_X[bi] + const sy = pcY + BLOOM_OFFSETS_Y[bi] + if (sx >= 0 && sx <= maxX && sy >= 0 && sy <= maxY) { + bx0 = sx | 0 + bx1 = Math.min(bx0 + 1, maxX) + by0 = sy | 0 + by1 = Math.min(by0 + 1, maxY) + bsx = sx - bx0 + bsy = sy - by0 + bosx = 1 - bsx + bosy = 1 - bsy + ry0 = by0 * w4 + ry1 = by1 * w4 + cx0 = bx0 << 2 + cx1 = bx1 << 2 + bi00 = ry0 + cx0 + bi10 = ry0 + cx1 + bi01 = ry1 + cx0 + bi11 = ry1 + cx1 + bloomSum += + ((source[bi00] * bosx + source[bi10] * bsx) * bosy + + (source[bi01] * bosx + source[bi11] * bsx) * bsy + + (source[bi00 + 1] * bosx + source[bi10 + 1] * bsx) * bosy + + (source[bi01 + 1] * bosx + source[bi11 + 1] * bsx) * bsy + + (source[bi00 + 2] * bosx + source[bi10 + 2] * bsx) * bosy + + (source[bi01 + 2] * bosx + source[bi11 + 2] * bsx) * bsy) / + 3 + } + } } + bloom = (bloomSum / 8) * bloomAmount } - bloom = (bloomSum / sampleOffsets.length) * opts.bloomAmount + target[idx] = Math.min(255, Math.max(0, r * vignette * scanLine * noise + bloom)) + target[idx + 1] = Math.min(255, Math.max(0, g * vignette * scanLine * noise + bloom)) + target[idx + 2] = Math.min(255, Math.max(0, b * vignette * scanLine * noise + bloom)) + target[idx + 3] = 255 } + } +} + +export function crt(source: Buffer, width: number, height: number, options: Partial = {}): Buffer { + const opts: CRTOptions = { ...defaultCRTOptions, ...options } + const target = Buffer.allocUnsafe(width * height * 4) + crtCore(source, target, width, height, 0, height, opts) - return [ - clamp(r * vignette * scanLine * noise + bloom, 0, 255), - clamp(g * vignette * scanLine * noise + bloom, 0, 255), - clamp(b * vignette * scanLine * noise + bloom, 0, 255), - 255 - ] - }) + return target } diff --git a/packages/pixel-profile/src/shaders/curve.ts b/packages/pixel-profile/src/shaders/curve.ts index d39e15b..4e43a9c 100644 --- a/packages/pixel-profile/src/shaders/curve.ts +++ b/packages/pixel-profile/src/shaders/curve.ts @@ -1,37 +1,87 @@ -import { type PixelCoords, render } from '../renderer' -import type { Vec2 } from '../utils' -import { add2, dot2, prod2, subtract2 } from '../utils' +/** + * Core curve computation for a row range. Closure-free so it can be + * serialized via Function.toString() for Worker threads. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function curveCore( + source: any, + target: any, + width: number, + height: number, + startRow: number, + endRow: number +): void { + const curvature = 0.1 + const root15 = 1.9679896712654867 + const maxX = width - 1 + const maxY = height - 1 + const w4 = width * 4 + const invW = 1 / width + const invH = 1 / height -const margin = [0, 0] -const screenCurvature = 0.1 + const colVig = new Float64Array(width) + for (let c = 0; c < width; c++) { + const u = c * invW + colVig[c] = Math.pow(u * (1 - u), 0.25) + } -export function curve(source: Buffer, width: number, height: number): Buffer { - return render(source, width, height, (coords, texture) => { - const uv = [coords[0] / width, coords[1] / height] + for (let py = startRow; py < endRow; py++) { + const uvY = py * invH + const ccY = uvY - 0.5 + const ccY2 = ccY * ccY + const rowBase = py * w4 + const rowVig = Math.pow(uvY * (1 - uvY), 0.25) * root15 - const maxX = width - 1 - const maxY = height - 1 + for (let px = 0; px < width; px++) { + const uvX = px * invW + const idx = rowBase + px * 4 - function distortCoordinates(_coords: PixelCoords): Vec2 { - const cc = subtract2(_coords, [0.5, 0.5]) - const dist = dot2(cc, cc) * screenCurvature + const ccX = uvX - 0.5 + const dist = (ccX * ccX + ccY2) * curvature const temp = (1 + dist) * dist - cc[0] = cc[0] * temp - cc[1] = cc[1] * temp - - return add2(_coords, cc) - } + const tx = (uvX + ccX * temp) * maxX + const ty = (uvY + ccY * temp) * maxY - const targetCoords = distortCoordinates([uv[0], uv[1]]) + const vignette = colVig[px] * rowVig - targetCoords[0] = targetCoords[0] * (margin[0] * 2 + 1) - margin[0] - targetCoords[1] = targetCoords[1] * (margin[1] * 2 + 1) - margin[1] + const bx = tx < 0 ? 0 : tx > maxX ? maxX : tx + const by = ty < 0 ? 0 : ty > maxY ? maxY : ty + const bx0 = bx | 0 + const bx1 = bx0 + 1 > maxX ? maxX : bx0 + 1 + const by0 = by | 0 + const by1 = by0 + 1 > maxY ? maxY : by0 + 1 + const bsx = bx - bx0 + const bsy = by - by0 + const bosx = 1 - bsx + const bosy = 1 - bsy + const ry0 = by0 * w4 + const ry1 = by1 * w4 + const cx0 = bx0 << 2 + const cx1 = bx1 << 2 + const bi00 = ry0 + cx0 + const bi10 = ry0 + cx1 + const bi01 = ry1 + cx0 + const bi11 = ry1 + cx1 - const vignetteCoords: Vec2 = [uv[0] * (1 - uv[1]), uv[1] * (1 - uv[0])] - const vignette = Math.pow(prod2(vignetteCoords) * 15, 0.25) + target[idx] = + ((source[bi00] * bosx + source[bi10] * bsx) * bosy + (source[bi01] * bosx + source[bi11] * bsx) * bsy) * + vignette + target[idx + 1] = + ((source[bi00 + 1] * bosx + source[bi10 + 1] * bsx) * bosy + + (source[bi01 + 1] * bosx + source[bi11 + 1] * bsx) * bsy) * + vignette + target[idx + 2] = + ((source[bi00 + 2] * bosx + source[bi10 + 2] * bsx) * bosy + + (source[bi01 + 2] * bosx + source[bi11 + 2] * bsx) * bsy) * + vignette + target[idx + 3] = 255 + } + } +} - const samplerColor = texture([targetCoords[0] * maxX, targetCoords[1] * maxY]) +export function curve(source: Buffer, width: number, height: number): Buffer { + const target = Buffer.allocUnsafe(width * height * 4) + curveCore(source, target, width, height, 0, height) - return [samplerColor[0] * vignette, samplerColor[1] * vignette, samplerColor[2] * vignette, 255] - }) + return target } diff --git a/packages/pixel-profile/src/shaders/dithering.ts b/packages/pixel-profile/src/shaders/dithering.ts index 37fc304..6fd90b0 100644 --- a/packages/pixel-profile/src/shaders/dithering.ts +++ b/packages/pixel-profile/src/shaders/dithering.ts @@ -1,28 +1,5 @@ -import { render, TEXTURE_FILTER } from '../renderer' -import { hslToRgb, rgbToHsl } from '../utils' import { Vec3 } from '../utils/math' -// const PALETTE_16: Vec3[] = [ -// [0.1498, 0.7753, 0.8255], -// [0.1024, 0.9387, 0.6804], -// [0.0699, 0.6976, 0.598], -// [0.9567, 0.2212, 0.4431], -// [0.8718, 0.2321, 0.2196], -// [0.3833, 0.3623, 0.2706], -// [0.2965, 0.3277, 0.4608], -// [0.175, 0.5405, 0.5647], -// [0.5355, 0.5341, 0.6549], -// [0.5996, 0.368, 0.5098], -// [0.6795, 0.134, 0.3804], -// [0.5333, 0.1643, 0.5824], -// [0.0764, 0.2791, 0.8314], -// [0, 0.8324, 0.649], -// [0.9194, 0.4819, 0.3784], -// [0.9423, 0.7605, 0.6725] -// ] -// const BIAS_16 = 0.11 - -// WINDOWS 95 - 256 COLOURS PALETTE const PALETTE_256: Vec3[] = [ [0, 0, 0], [0, 1, 0.251], @@ -285,6 +262,8 @@ const PALETTE_256: Vec3[] = [ const BIAS_256 = 0 const lightnessSteps = 4 const saturationSteps = 4 +const invLightnessSteps = 1 / lightnessSteps +const invSaturationSteps = 1 / saturationSteps /* eslint-disable prettier/prettier */ const ditherTable = new Uint8Array([ @@ -299,14 +278,19 @@ const ditherTable = new Uint8Array([ ]) /* eslint-enable prettier/prettier */ +const ditherLimits = new Float64Array(64) +for (let i = 0; i < 64; i++) { + ditherLimits[i] = (ditherTable[i] + 1) / 64 + BIAS_256 +} + function hueDistance(h1: number, h2: number): number { const diff = Math.abs(h1 - h2) return diff < 0.5 ? diff : 1 - diff } -const lightnessStep = (l: number) => Math.round(l * lightnessSteps) / lightnessSteps -const saturationStep = (s: number) => Math.round(s * saturationSteps) / saturationSteps +const lightnessStep = (l: number) => Math.round(l * lightnessSteps) * invLightnessSteps +const saturationStep = (s: number) => Math.round(s * saturationSteps) * invSaturationSteps const closestColorsCache = new Map() @@ -338,40 +322,278 @@ function closestColors(hue: number): [Vec3, Vec3] { return result } -function dither(pos: [number, number], color: Vec3): Vec3 { - const index = (pos[0] & 7) + ((pos[1] & 7) << 3) - const limit = (ditherTable[index] + 1) / 64 + BIAS_256 +function hue2rgb(p: number, q: number, t: number): number { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + + return p +} + +interface DitherEntry { + hueDiff: number + lightDiff: number + satDiff: number + combos: Uint8Array +} + +const ditherRgbCache = new Map() + +function buildDitherEntry(rRaw: number, gRaw: number, bRaw: number): DitherEntry { + const r = rRaw / 255 + const g = gRaw / 255 + const b = bRaw / 255 + + const cmax = Math.max(r, g, b) + const cmin = Math.min(r, g, b) + + let h = 0 + let s = 0 + const l = (cmax + cmin) / 2 + + if (cmax !== cmin) { + const d = cmax - cmin + s = l > 0.5 ? d / (2 - cmax - cmin) : d / (cmax + cmin) + switch (cmax) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + + const [closest, secondClosest] = closestColors(h) + const hueDiff = hueDistance(h, closest[0]) / hueDistance(secondClosest[0], closest[0]) + + const l1 = lightnessStep(Math.max(l - 0.125, 0)) + const l2 = lightnessStep(Math.min(l + 0.124, 1)) + const lightDiff = (l - l1) / (l2 - l1) + + const s1 = saturationStep(Math.max(s - 0.125, 0)) + const s2 = saturationStep(Math.min(s + 0.124, 1)) + const satDiff = (s - s1) / (s2 - s1) + + const hueOpts = [closest[0], secondClosest[0]] + const lightOpts = [l1, l2] + const satOpts = [s1, s2] + + const combos = new Uint8Array(24) + for (let dh = 0; dh < 2; dh++) { + const rH = hueOpts[dh] + for (let dl = 0; dl < 2; dl++) { + const rL = lightOpts[dl] + for (let ds = 0; ds < 2; ds++) { + const rS = satOpts[ds] + const ci = (dh * 4 + dl * 2 + ds) * 3 + if (rS === 0) { + const v = rL * 255 + combos[ci] = v + combos[ci + 1] = v + combos[ci + 2] = v + } else { + const q2 = rL < 0.5 ? rL * (1 + rS) : rL + rS - rL * rS + const p2 = 2 * rL - q2 + combos[ci] = hue2rgb(p2, q2, rH + 1 / 3) * 255 + combos[ci + 1] = hue2rgb(p2, q2, rH) * 255 + combos[ci + 2] = hue2rgb(p2, q2, rH - 1 / 3) * 255 + } + } + } + } + + return { hueDiff, lightDiff, satDiff, combos } +} + +export type PaletteId = 'gameboy' | 'nokia' | 'cga' | 'grayscale' | 'sepia' | 'neon' + +export const BUILTIN_PALETTES: Record = { + gameboy: [ + [15, 56, 15], + [48, 98, 48], + [139, 172, 15], + [155, 188, 15] + ], + nokia: [ + [67, 82, 61], + [199, 240, 216] + ], + grayscale: [ + [0, 0, 0], + [85, 85, 85], + [170, 170, 170], + [255, 255, 255] + ], + sepia: [ + [43, 23, 0], + [110, 76, 30], + [176, 128, 80], + [232, 208, 160] + ], + neon: [ + [0, 0, 0], + [255, 0, 102], + [0, 255, 204], + [255, 255, 0], + [102, 0, 255] + ], + cga: [ + [0, 0, 0], + [0, 0, 170], + [0, 170, 0], + [0, 170, 170], + [170, 0, 0], + [170, 0, 170], + [170, 85, 0], + [170, 170, 170], + [85, 85, 85], + [85, 85, 255], + [85, 255, 85], + [85, 255, 255], + [255, 85, 85], + [255, 85, 255], + [255, 255, 85], + [255, 255, 255] + ] +} + +export function paletteDither(source: Buffer, width: number, height: number, paletteColors: number[][]): Buffer { + const palLen = paletteColors.length + + if (palLen < 2) { + return source + } + + const palR = new Uint8Array(palLen) + const palG = new Uint8Array(palLen) + const palB = new Uint8Array(palLen) + for (let i = 0; i < palLen; i++) { + palR[i] = paletteColors[i][0] + palG[i] = paletteColors[i][1] + palB[i] = paletteColors[i][2] + } + + const cache = new Map() + + function findTwoClosest(r: number, g: number, b: number): number { + const key = (r << 16) | (g << 8) | b + const cached = cache.get(key) + if (cached !== undefined) return cached - const [closest, secondClosest] = closestColors(color[0]) - const hueDiff = hueDistance(color[0], closest[0]) / hueDistance(secondClosest[0], closest[0]) + let minDist = Infinity + let secondDist = Infinity + let closest = 0 + let second = 0 + + for (let i = 0; i < palLen; i++) { + const dr = r - palR[i] + const dg = g - palG[i] + const db = b - palB[i] + const dist = dr * dr + dg * dg + db * db + if (dist < minDist) { + secondDist = minDist + second = closest + minDist = dist + closest = i + } else if (dist < secondDist) { + secondDist = dist + second = i + } + } + + const totalDist = minDist + secondDist + const factor = totalDist > 0 ? minDist / totalDist : 0 + const factorQ = (factor * 63 + 0.5) | 0 + + const packed = (closest & 0xff) | ((second & 0xff) << 8) | ((factorQ & 0xff) << 16) + cache.set(key, packed) + + return packed + } - const l1 = lightnessStep(Math.max(color[2] - 0.125, 0)) - const l2 = lightnessStep(Math.min(color[2] + 0.124, 1)) - const lightnessDiff = (color[2] - l1) / (l2 - l1) + for (let y = 0; y < height; y++) { + let prevR = -1 + let prevG = -1 + let prevB = -1 + let packed = 0 - const resultColor: Vec3 = hueDiff < limit ? [...closest] : [...secondClosest] - resultColor[2] = lightnessDiff < limit ? l1 : l2 + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4 + const r = source[idx] + const g = source[idx + 1] + const b = source[idx + 2] - const s1 = saturationStep(Math.max(color[1] - 0.125, 0)) - const s2 = saturationStep(Math.min(color[1] + 0.124, 1)) - const saturationDiff = (color[1] - s1) / (s2 - s1) + if (r !== prevR || g !== prevG || b !== prevB) { + prevR = r + prevG = g + prevB = b + packed = findTwoClosest(r, g, b) + } - resultColor[1] = saturationDiff < limit ? s1 : s2 + const closest = packed & 0xff + const second = (packed >> 8) & 0xff + const factorQ = (packed >> 16) & 0xff - return hslToRgb(resultColor) + const threshold = ditherTable[(x & 7) + ((y & 7) << 3)] + const pick = factorQ > threshold ? second : closest + + source[idx] = palR[pick] + source[idx + 1] = palG[pick] + source[idx + 2] = palB[pick] + } + } + + return source } export function orderedBayer(source: Buffer, width: number, height: number): Buffer { - return render( - source, - width, - height, - (pixelCoords, texture) => { - const color = texture(pixelCoords) - const ditheredColor = dither(pixelCoords, rgbToHsl(color)) - - return [...ditheredColor, color[3]] - }, - { textureFilter: TEXTURE_FILTER.NEAREST } - ) + for (let y = 0; y < height; y++) { + let prevR = -1 + let prevG = -1 + let prevB = -1 + let hueDiff = 0 + let lightDiff = 0 + let satDiff = 0 + let combos: Uint8Array = null! + + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4 + + const rRaw = source[idx] + const gRaw = source[idx + 1] + const bRaw = source[idx + 2] + + if (rRaw !== prevR || gRaw !== prevG || bRaw !== prevB) { + prevR = rRaw + prevG = gRaw + prevB = bRaw + + const key = (rRaw << 16) | (gRaw << 8) | bRaw + let entry = ditherRgbCache.get(key) + if (!entry) { + entry = buildDitherEntry(rRaw, gRaw, bRaw) + ditherRgbCache.set(key, entry) + } + hueDiff = entry.hueDiff + lightDiff = entry.lightDiff + satDiff = entry.satDiff + combos = entry.combos + } + + const limit = ditherLimits[(x & 7) + ((y & 7) << 3)] + const ci = ((hueDiff < limit ? 0 : 4) + (lightDiff < limit ? 0 : 2) + (satDiff < limit ? 0 : 1)) * 3 + source[idx] = combos[ci] + source[idx + 1] = combos[ci + 1] + source[idx + 2] = combos[ci + 2] + } + } + + return source } diff --git a/packages/pixel-profile/src/shaders/glow.ts b/packages/pixel-profile/src/shaders/glow.ts index 92c7f5d..d1ba738 100644 --- a/packages/pixel-profile/src/shaders/glow.ts +++ b/packages/pixel-profile/src/shaders/glow.ts @@ -1,5 +1,4 @@ -import { coordsToIndex, render } from '../renderer' -import { mix3, multiply3, type Vec3 } from '../utils/math' +import type { Vec3 } from '../utils/math' interface GlowOptions { radius: number @@ -21,23 +20,21 @@ const defaultOptions: GlowOptions = { adaptiveThreshold: true } -function calculateAdaptiveThreshold(source: Buffer, width: number, height: number): number { - let totalLuminance = 0 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function calculateAdaptiveThreshold(source: any, width: number, height: number): number { + let totalWeightedSum = 0 const size = width * height for (let i = 0; i < size * 4; i += 4) { - const r = source[i] - const g = source[i + 1] - const b = source[i + 2] - totalLuminance += (r * 0.2126 + g * 0.7152 + b * 0.0722) / 255 + totalWeightedSum += source[i] * 0.2126 + source[i + 1] * 0.7152 + source[i + 2] * 0.0722 } - const avgLuminance = totalLuminance / size + const avgLuminance = totalWeightedSum / (size * 255) return Math.max(0.6, Math.min(0.9, avgLuminance + 0.3)) } -function getFalloffFunction(type: string): (dist: number, radiusSquared: number) => number { +export function getFalloffFunction(type: string): (dist: number, radiusSquared: number) => number { switch (type) { case 'linear': return (dist, radiusSquared) => Math.max(0, 1 - dist / Math.sqrt(radiusSquared)) @@ -50,85 +47,239 @@ function getFalloffFunction(type: string): (dist: number, radiusSquared: number) } } -export function glow(source: Buffer, width: number, height: number, userOptions: Partial = {}): Buffer { - const options = { ...defaultOptions, ...userOptions } - const { radius, intensity, color, layers, falloff, adaptiveThreshold, threshold: _threshold } = options - const threshold = adaptiveThreshold ? calculateAdaptiveThreshold(source, width, height) : _threshold - const falloffFn = getFalloffFunction(falloff) +export function buildWeightTable( + currentRadius: number, + falloffFn: (dist: number, radiusSquared: number) => number +): Float64Array { + const radiusSquared = currentRadius * currentRadius * 2 + const weights = new Float64Array(currentRadius * 2 + 1) - function horizontalPass(input: Buffer | Float32Array, output: Float32Array, currentRadius: number) { - const radiusSquared = currentRadius * currentRadius * 2 + for (let i = -currentRadius; i <= currentRadius; i++) { + weights[i + currentRadius] = falloffFn(i * i, radiusSquared) + } - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const centerIdx = (y * width + x) * 4 + return weights +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildLuminanceMap(input: any, size: number, output?: Float64Array): Float64Array { + const lum = output || new Float64Array(size) + + for (let i = 0; i < size; i++) { + const idx = i * 4 + lum[i] = (input[idx] * 0.2126 + input[idx + 1] * 0.7152 + input[idx + 2] * 0.0722) / 255 + } + + return lum +} + +/** + * Horizontal blur pass. All parameters explicit (closure-free for Worker serialization). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function horizontalPassCore( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any, + output: Float32Array, + luminance: Float64Array, + outLuminance: Float64Array, + width: number, + height: number, + threshold: number, + currentRadius: number, + weights: Float64Array, + startRow: number = 0, + endRow: number = height +): void { + const maxX = width - 1 + const diameter = currentRadius * 2 + 1 + let totalWeight = 0 + for (let w = 0; w < diameter; w++) totalWeight += weights[w] + + const rowPrefix = new Int32Array(width + 1) + + for (let y = startRow; y < endRow; y++) { + const rowOffset = y * width + + rowPrefix[0] = 0 + for (let x = 0; x < width; x++) { + rowPrefix[x + 1] = rowPrefix[x] + (luminance[rowOffset + x] > threshold ? 1 : 0) + } + + for (let x = 0; x < width; x++) { + const centerIdx = (rowOffset + x) * 4 + + const lo = Math.max(0, x - currentRadius) + const hi = Math.min(maxX, x + currentRadius) + const brightCount = rowPrefix[hi + 1] - rowPrefix[lo] + + if (brightCount === 0) { + output[centerIdx] = input[centerIdx] + output[centerIdx + 1] = input[centerIdx + 1] + output[centerIdx + 2] = input[centerIdx + 2] + output[centerIdx + 3] = input[centerIdx + 3] + outLuminance[rowOffset + x] = luminance[rowOffset + x] + continue + } + + if (x >= currentRadius && x <= maxX - currentRadius && brightCount === diameter) { let sumR = 0 let sumG = 0 let sumB = 0 - let weightSum = 0 + let si = (rowOffset + x - currentRadius) * 4 + for (let i = 0; i < diameter; i++) { + const wt = weights[i] + sumR += input[si] * wt + sumG += input[si + 1] * wt + sumB += input[si + 2] * wt + si += 4 + } + output[centerIdx] = sumR / totalWeight + output[centerIdx + 1] = sumG / totalWeight + output[centerIdx + 2] = sumB / totalWeight + output[centerIdx + 3] = 255 + outLuminance[rowOffset + x] = + (output[centerIdx] * 0.2126 + output[centerIdx + 1] * 0.7152 + output[centerIdx + 2] * 0.0722) / 255 + continue + } - for (let i = -currentRadius; i <= currentRadius; i++) { - const sampleX = Math.min(Math.max(x + i, 0), width - 1) - const sampleIdx = (y * width + sampleX) * 4 + let sumR = 0 + let sumG = 0 + let sumB = 0 + let weightSum = 0 - const r = input[sampleIdx] - const g = input[sampleIdx + 1] - const b = input[sampleIdx + 2] - const luminance = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 255 + for (let i = -currentRadius; i <= currentRadius; i++) { + const sampleX = Math.min(Math.max(x + i, 0), maxX) + const sampleIdx = (rowOffset + sampleX) * 4 - if (luminance > threshold) { - const dist = i * i - const weight = falloffFn(dist, radiusSquared) + if (luminance[rowOffset + sampleX] > threshold) { + const weight = weights[i + currentRadius] - sumR += r * weight - sumG += g * weight - sumB += b * weight - weightSum += weight - } + sumR += input[sampleIdx] * weight + sumG += input[sampleIdx + 1] * weight + sumB += input[sampleIdx + 2] * weight + weightSum += weight } + } - if (weightSum > 0) { - output[centerIdx] = sumR / weightSum - output[centerIdx + 1] = sumG / weightSum - output[centerIdx + 2] = sumB / weightSum - output[centerIdx + 3] = 255 - } else { + if (weightSum > 0) { + output[centerIdx] = sumR / weightSum + output[centerIdx + 1] = sumG / weightSum + output[centerIdx + 2] = sumB / weightSum + output[centerIdx + 3] = 255 + outLuminance[rowOffset + x] = + (output[centerIdx] * 0.2126 + output[centerIdx + 1] * 0.7152 + output[centerIdx + 2] * 0.0722) / 255 + } else { + output[centerIdx] = input[centerIdx] + output[centerIdx + 1] = input[centerIdx + 1] + output[centerIdx + 2] = input[centerIdx + 2] + output[centerIdx + 3] = input[centerIdx + 3] + outLuminance[rowOffset + x] = luminance[rowOffset + x] + } + } + } +} + +/** + * Fused multi-layer horizontal pass. Processes one row at a time across all layers, + * sharing the row prefix scan and keeping source data hot in L1 cache. + * Closure-free for Worker serialization. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function horizontalPassFusedCore( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any, + luminance: Float64Array, + width: number, + height: number, + threshold: number, + numLayers: number, + outputs: Float32Array[], + outLuminances: Float64Array[], + radii: number[], + weightTables: Float64Array[], + startRow: number, + endRow: number +): void { + const maxX = width - 1 + const rowPrefix = new Int32Array(width + 1) + + const diameters = new Array(numLayers) + const totalWeights = new Array(numLayers) + for (let li = 0; li < numLayers; li++) { + diameters[li] = radii[li] * 2 + 1 + let tw = 0 + const d = diameters[li] + for (let w = 0; w < d; w++) tw += weightTables[li][w] + totalWeights[li] = tw + } + + for (let y = startRow; y < endRow; y++) { + const rowOffset = y * width + + rowPrefix[0] = 0 + for (let x = 0; x < width; x++) { + rowPrefix[x + 1] = rowPrefix[x] + (luminance[rowOffset + x] > threshold ? 1 : 0) + } + + for (let li = 0; li < numLayers; li++) { + const output = outputs[li] + const outLuminance = outLuminances[li] + const currentRadius = radii[li] + const weights = weightTables[li] + const diameter = diameters[li] + const totalWeight = totalWeights[li] + + for (let x = 0; x < width; x++) { + const centerIdx = (rowOffset + x) * 4 + const lo = Math.max(0, x - currentRadius) + const hi = Math.min(maxX, x + currentRadius) + const brightCount = rowPrefix[hi + 1] - rowPrefix[lo] + + if (brightCount === 0) { output[centerIdx] = input[centerIdx] output[centerIdx + 1] = input[centerIdx + 1] output[centerIdx + 2] = input[centerIdx + 2] output[centerIdx + 3] = input[centerIdx + 3] + outLuminance[rowOffset + x] = luminance[rowOffset + x] + continue } - } - } - } - function verticalPass(input: Float32Array, output: Float32Array, currentRadius: number) { - const radiusSquared = currentRadius * currentRadius * 2 + if (x >= currentRadius && x <= maxX - currentRadius && brightCount === diameter) { + let sumR = 0 + let sumG = 0 + let sumB = 0 + let si = (rowOffset + x - currentRadius) * 4 + for (let i = 0; i < diameter; i++) { + const wt = weights[i] + sumR += input[si] * wt + sumG += input[si + 1] * wt + sumB += input[si + 2] * wt + si += 4 + } + output[centerIdx] = sumR / totalWeight + output[centerIdx + 1] = sumG / totalWeight + output[centerIdx + 2] = sumB / totalWeight + output[centerIdx + 3] = 255 + outLuminance[rowOffset + x] = + (output[centerIdx] * 0.2126 + output[centerIdx + 1] * 0.7152 + output[centerIdx + 2] * 0.0722) / 255 + continue + } - for (let x = 0; x < width; x++) { - for (let y = 0; y < height; y++) { - const centerIdx = (y * width + x) * 4 let sumR = 0 let sumG = 0 let sumB = 0 let weightSum = 0 for (let i = -currentRadius; i <= currentRadius; i++) { - const sampleY = Math.min(Math.max(y + i, 0), height - 1) - const sampleIdx = (sampleY * width + x) * 4 - - const r = input[sampleIdx] - const g = input[sampleIdx + 1] - const b = input[sampleIdx + 2] - const luminance = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 255 - - if (luminance > threshold) { - const dist = i * i - const weight = falloffFn(dist, radiusSquared) - - sumR += r * weight - sumG += g * weight - sumB += b * weight + const sampleX = Math.min(Math.max(x + i, 0), maxX) + const sampleIdx = (rowOffset + sampleX) * 4 + + if (luminance[rowOffset + sampleX] > threshold) { + const weight = weights[i + currentRadius] + sumR += input[sampleIdx] * weight + sumG += input[sampleIdx + 1] * weight + sumB += input[sampleIdx + 2] * weight weightSum += weight } } @@ -138,56 +289,260 @@ export function glow(source: Buffer, width: number, height: number, userOptions: output[centerIdx + 1] = sumG / weightSum output[centerIdx + 2] = sumB / weightSum output[centerIdx + 3] = 255 + outLuminance[rowOffset + x] = + (output[centerIdx] * 0.2126 + output[centerIdx + 1] * 0.7152 + output[centerIdx + 2] * 0.0722) / 255 } else { output[centerIdx] = input[centerIdx] output[centerIdx + 1] = input[centerIdx + 1] output[centerIdx + 2] = input[centerIdx + 2] output[centerIdx + 3] = input[centerIdx + 3] + outLuminance[rowOffset + x] = luminance[rowOffset + x] } } } } +} + +export function buildColumnPrefixMap( + luminance: Float64Array, + width: number, + height: number, + threshold: number, + output?: Int32Array +): Int32Array { + const colPrefix = output || new Int32Array((height + 1) * width) + for (let y = 0; y < height; y++) { + const currRow = (y + 1) * width + const prevRow = y * width + for (let x = 0; x < width; x++) { + colPrefix[currRow + x] = colPrefix[prevRow + x] + (luminance[prevRow + x] > threshold ? 1 : 0) + } + } + + return colPrefix +} + +/** + * Vertical blur pass. All parameters explicit (closure-free for Worker serialization). + * When precomputedColPrefix is provided, skips the O(height*width) prefix scan. + */ +export function verticalPassCore( + input: Float32Array, + output: Float32Array, + luminance: Float64Array, + width: number, + height: number, + threshold: number, + currentRadius: number, + weights: Float64Array, + startRow: number = 0, + endRow: number = height, + precomputedColPrefix?: Int32Array +): void { + const maxY = height - 1 + const diameter = currentRadius * 2 + 1 + let totalWeight = 0 + for (let w = 0; w < diameter; w++) totalWeight += weights[w] + const w4 = width * 4 + + const colPrefix = precomputedColPrefix || buildColumnPrefixMap(luminance, width, height, threshold) + + for (let y = startRow; y < endRow; y++) { + const lo = Math.max(0, y - currentRadius) + const hi = Math.min(maxY, y + currentRadius) + const loRow = lo * width + const hiRow = (hi + 1) * width + + for (let x = 0; x < width; x++) { + const centerIdx = (y * width + x) * 4 + const brightCount = colPrefix[hiRow + x] - colPrefix[loRow + x] + + if (brightCount === 0) { + output[centerIdx] = input[centerIdx] + output[centerIdx + 1] = input[centerIdx + 1] + output[centerIdx + 2] = input[centerIdx + 2] + output[centerIdx + 3] = input[centerIdx + 3] + continue + } + + if (y >= currentRadius && y <= maxY - currentRadius && brightCount === diameter) { + let sumR = 0 + let sumG = 0 + let sumB = 0 + let si = ((y - currentRadius) * width + x) * 4 + for (let i = 0; i < diameter; i++) { + const wt = weights[i] + sumR += input[si] * wt + sumG += input[si + 1] * wt + sumB += input[si + 2] * wt + si += w4 + } + output[centerIdx] = sumR / totalWeight + output[centerIdx + 1] = sumG / totalWeight + output[centerIdx + 2] = sumB / totalWeight + output[centerIdx + 3] = 255 + continue + } + + let sumR = 0 + let sumG = 0 + let sumB = 0 + let weightSum = 0 + + for (let i = -currentRadius; i <= currentRadius; i++) { + const sampleY = Math.min(Math.max(y + i, 0), maxY) + const sampleIdx = (sampleY * width + x) * 4 + + if (luminance[sampleY * width + x] > threshold) { + const weight = weights[i + currentRadius] + + sumR += input[sampleIdx] * weight + sumG += input[sampleIdx + 1] * weight + sumB += input[sampleIdx + 2] * weight + weightSum += weight + } + } + + if (weightSum > 0) { + output[centerIdx] = sumR / weightSum + output[centerIdx + 1] = sumG / weightSum + output[centerIdx + 2] = sumB / weightSum + output[centerIdx + 3] = 255 + } else { + output[centerIdx] = input[centerIdx] + output[centerIdx + 1] = input[centerIdx + 1] + output[centerIdx + 2] = input[centerIdx + 2] + output[centerIdx + 3] = input[centerIdx + 3] + } + } + } +} +/** + * Process a single glow layer. Closure-free for Worker serialization. + * Returns a Float32Array of the blurred layer. + */ +export function glowLayerCore( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + source: any, + width: number, + height: number, + threshold: number, + layerRadius: number, + falloffType: string +): Float32Array { const size = width * height + const sourceLuminance = buildLuminanceMap(source, size) + const falloffFn = getFalloffFunction(falloffType) + const weights = buildWeightTable(layerRadius, falloffFn) + const horizontalBlur = new Float32Array(size * 4) + const hBlurLuminance = new Float64Array(size) + const layerOutput = new Float32Array(size * 4) - const glowLayers: Float32Array[] = [] + horizontalPassCore( + source, + horizontalBlur, + sourceLuminance, + hBlurLuminance, + width, + height, + threshold, + layerRadius, + weights + ) + verticalPassCore(horizontalBlur, layerOutput, hBlurLuminance, width, height, threshold, layerRadius, weights) - for (let i = 0; i < layers; i++) { - const currentRadius = Math.floor(radius * (i + 1)) - const currentLayer = new Float32Array(size * 4) + return layerOutput +} - horizontalPass(source, horizontalBlur, currentRadius) - verticalPass(horizontalBlur, currentLayer, currentRadius) +export function glow(source: Buffer, width: number, height: number, userOptions: Partial = {}): Buffer { + const options = { ...defaultOptions, ...userOptions } + const { radius, intensity, color, layers, falloff, adaptiveThreshold, threshold: _threshold } = options + const threshold = adaptiveThreshold ? calculateAdaptiveThreshold(source, width, height) : _threshold + + const size = width * height + const sourceLuminance = buildLuminanceMap(source, size) - glowLayers.push(currentLayer) + const hBlurs: Float32Array[] = [] + const hLums: Float64Array[] = [] + const radii: number[] = [] + const weightTables: Float64Array[] = [] + for (let i = 0; i < layers; i++) { + radii.push(Math.floor(radius * (i + 1))) + weightTables.push(buildWeightTable(radii[i], getFalloffFunction(falloff))) + hBlurs.push(new Float32Array(size * 4)) + hLums.push(new Float64Array(size)) } - return render( + horizontalPassFusedCore( source, + sourceLuminance, width, height, - (uv, texture2D) => { - const originalColor = texture2D(uv) - let finalColor: Vec3 = [originalColor[0], originalColor[1], originalColor[2]] + threshold, + layers, + hBlurs, + hLums, + radii, + weightTables, + 0, + height + ) - for (let i = 0; i < layers; i++) { - const currentIntensity = intensity / (i + 1) - const layerBuffer = glowLayers[i] + const glowLayers: Float32Array[] = [] + for (let i = 0; i < layers; i++) { + const layerOutput = new Float32Array(size * 4) + verticalPassCore(hBlurs[i], layerOutput, hLums[i], width, height, threshold, radii[i], weightTables[i]) + glowLayers.push(layerOutput) + } + + const result = Buffer.allocUnsafe(size * 4) + const [colorR, colorG, colorB] = color + const isWhite = colorR === 1 && colorG === 1 && colorB === 1 - const glowColor = [ - layerBuffer[coordsToIndex(uv[0], uv[1], width) * 4], - layerBuffer[coordsToIndex(uv[0], uv[1], width) * 4 + 1], - layerBuffer[coordsToIndex(uv[0], uv[1], width) * 4 + 2] - ] as Vec3 + const layerIntensities = new Float64Array(layers) + const layerOneMinusT = new Float64Array(layers) + for (let li = 0; li < layers; li++) { + layerIntensities[li] = intensity / (li + 1) + layerOneMinusT[li] = 1 - layerIntensities[li] + } - const tintedGlow = multiply3(glowColor, color) + for (let y = 0; y < height; y++) { + const rowOffset = y * width - finalColor = mix3(finalColor, tintedGlow, currentIntensity) + for (let x = 0; x < width; x++) { + const idx = (rowOffset + x) * 4 + let finalR = source[idx] + let finalG = source[idx + 1] + let finalB = source[idx + 2] + + if (isWhite) { + for (let i = 0; i < layers; i++) { + const ci = layerIntensities[i] + const oi = layerOneMinusT[i] + const lb = glowLayers[i] + finalR = finalR * oi + lb[idx] * ci + finalG = finalG * oi + lb[idx + 1] * ci + finalB = finalB * oi + lb[idx + 2] * ci + } + } else { + for (let i = 0; i < layers; i++) { + const ci = layerIntensities[i] + const oi = layerOneMinusT[i] + const lb = glowLayers[i] + finalR = finalR * oi + lb[idx] * colorR * ci + finalG = finalG * oi + lb[idx + 1] * colorG * ci + finalB = finalB * oi + lb[idx + 2] * colorB * ci + } } - return [finalColor[0], finalColor[1], finalColor[2], 255] - }, - { textureFilter: 'NEAREST' } - ) + result[idx] = finalR + result[idx + 1] = finalG + result[idx + 2] = finalB + result[idx + 3] = 255 + } + } + + return result } diff --git a/packages/pixel-profile/src/shaders/halftone.ts b/packages/pixel-profile/src/shaders/halftone.ts index f211948..bb15f8e 100644 --- a/packages/pixel-profile/src/shaders/halftone.ts +++ b/packages/pixel-profile/src/shaders/halftone.ts @@ -1,33 +1,29 @@ -import { render, RGBA } from '../renderer' - -const dotSize = 4 -const dotDensity = 1 -const mat = [ - [0.1, 0.9, 0.3, 0.9], - [0.9, 0.3, 0.9, 0.6], - [0.3, 0.9, 0.1, 0.9], - [0.9, 0.6, 0.9, 0.6] -] -const ditherRange = 0 +/* eslint-disable prettier/prettier */ +const flatMat = new Float64Array([ + 0.1, 0.9, 0.3, 0.9, + 0.9, 0.3, 0.9, 0.6, + 0.3, 0.9, 0.1, 0.9, + 0.9, 0.6, 0.9, 0.6 +]) +/* eslint-enable prettier/prettier */ + +const DARK_U32 = (7 | (85 << 8) | (59 << 16) | (255 << 24)) >>> 0 +const LIGHT_U32 = (206 | (212 << 8) | (106 << 16) | (255 << 24)) >>> 0 export function halftone(source: Buffer, width: number, height: number): Buffer { - return render(source, width, height, (pixelCoords, texture) => { - const gridCoords = [Math.floor(pixelCoords[0] / dotSize), Math.floor(pixelCoords[1] / dotSize)] - - const samplerColor = texture(pixelCoords) - const grayValue = (samplerColor[0] + samplerColor[1] + samplerColor[2]) / (3 * 255) - const ditherValue = (Math.random() - 0.5) * ditherRange - const adjustedGrayValue = Math.min(Math.max(grayValue + ditherValue, 0), 1) - - const relativeCoords = [pixelCoords[0] - gridCoords[0] * dotSize, pixelCoords[1] - gridCoords[1] * dotSize] - - const intensity = mat[relativeCoords[0]][relativeCoords[1]] + const target = Buffer.allocUnsafe(width * height * 4) + const u32 = new Uint32Array(target.buffer, target.byteOffset, width * height) - const dotRadius = dotDensity * (1 - adjustedGrayValue) - const isInDot = intensity < dotRadius + for (let y = 0; y < height; y++) { + const rowOffset = y * width + for (let x = 0; x < width; x++) { + const idx = (rowOffset + x) * 4 + const grayValue = (source[idx] + source[idx + 1] + source[idx + 2]) / (3 * 255) + const dotRadius = 1 - grayValue - const finalColor: RGBA = isInDot ? [7, 85, 59, 255] : [206, 212, 106, 255] + u32[rowOffset + x] = flatMat[(x & 3) * 4 + (y & 3)] < dotRadius ? DARK_U32 : LIGHT_U32 + } + } - return finalColor - }) + return target } diff --git a/packages/pixel-profile/src/shaders/pixelate.ts b/packages/pixel-profile/src/shaders/pixelate.ts index 633e653..97140b7 100644 --- a/packages/pixel-profile/src/shaders/pixelate.ts +++ b/packages/pixel-profile/src/shaders/pixelate.ts @@ -1,6 +1,3 @@ -import type { RGBA } from '../renderer' -import { render } from '../renderer' - export type PixelateOptions = { blockSize: number samplingMode?: 'center' | 'average' | 'dominant' @@ -11,64 +8,279 @@ export function pixelate(source: Buffer, width: number, height: number, options: const opts: PixelateOptions = typeof options === 'number' ? { blockSize: options } : options const { blockSize, samplingMode = 'center', antiAlias = true } = opts - const halfBlockSize = blockSize / 2 + if (samplingMode === 'center') { + return pixelateCenter(source, width, height, blockSize) + } + + if (samplingMode === 'dominant') { + return pixelateDominant(source, width, height, blockSize, antiAlias) + } + + return pixelateAverage(source, width, height, blockSize, antiAlias) +} + +function buildSampleOffsets(blockSize: number, antiAlias: boolean): [number, number][] { const samplePoints = antiAlias ? 4 : 1 - const sampleOffsets: [number, number][] = [] + const offsets: [number, number][] = [] for (let i = 0; i < samplePoints; i++) { for (let j = 0; j < samplePoints; j++) { - sampleOffsets.push([(i + 0.5) * (blockSize / samplePoints), (j + 0.5) * (blockSize / samplePoints)]) + offsets.push([(i + 0.5) * (blockSize / samplePoints), (j + 0.5) * (blockSize / samplePoints)]) } } - return render(source, width, height, (coords, texture) => { - const x = Math.floor(coords[0] / blockSize) - const y = Math.floor(coords[1] / blockSize) - const blockX = x * blockSize - const blockY = y * blockSize + return offsets +} - if (samplingMode === 'center') { - return texture([blockX + halfBlockSize, blockY + halfBlockSize]) - } +function fillBlockBands( + target: Buffer, + blockColors: Float64Array, + width: number, + height: number, + blockSize: number, + blocksX: number +) { + const rowLen = width * 4 + let prevBy = -1 + let bandRowStart = 0 - const samples: RGBA[] = [] - samples.length = sampleOffsets.length - for (let i = 0; i < sampleOffsets.length; i++) { - const [offsetX, offsetY] = sampleOffsets[i] - samples[i] = texture([blockX + offsetX, blockY + offsetY]) - } + for (let py = 0; py < height; py++) { + const by = Math.floor(py / blockSize) - if (samplingMode === 'average') { - const sum: RGBA = [0, 0, 0, 0] - for (const color of samples) { - sum[0] += color[0] - sum[1] += color[1] - sum[2] += color[2] - sum[3] += color[3] - } - const count = samples.length + if (by !== prevBy) { + prevBy = by + bandRowStart = py * rowLen - return [sum[0] / count, sum[1] / count, sum[2] / count, sum[3] / count] as RGBA + for (let px = 0; px < width; px++) { + const bx = Math.floor(px / blockSize) + const bi = (by * blocksX + bx) * 4 + const idx = bandRowStart + px * 4 + target[idx] = blockColors[bi] + target[idx + 1] = blockColors[bi + 1] + target[idx + 2] = blockColors[bi + 2] + target[idx + 3] = blockColors[bi + 3] + } } else { - const colorCount = new Map() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target.copy(target as any, py * rowLen, bandRowStart, bandRowStart + rowLen) + } + } +} + +function pixelateDominant( + source: Buffer, + width: number, + height: number, + blockSize: number, + antiAlias: boolean +): Buffer { + const maxX = width - 1 + const maxY = height - 1 + const target = Buffer.allocUnsafe(width * height * 4) + const offsets = buildSampleOffsets(blockSize, antiAlias) + const numSamples = offsets.length + + const blocksX = Math.ceil(width / blockSize) + const blocksY = Math.ceil(height / blockSize) + + const blockColors = new Float64Array(blocksX * blocksY * 4) + + const MAX_DISTINCT = 16 + const sampleR = new Float64Array(MAX_DISTINCT) + const sampleG = new Float64Array(MAX_DISTINCT) + const sampleB = new Float64Array(MAX_DISTINCT) + const sampleA = new Float64Array(MAX_DISTINCT) + const sampleCount = new Uint8Array(MAX_DISTINCT) + + for (let by = 0; by < blocksY; by++) { + const blockY = by * blockSize + for (let bx = 0; bx < blocksX; bx++) { + const blockX = bx * blockSize + + let numDistinct = 0 let maxCount = 0 - let dominantColor = samples[0] - - for (const color of samples) { - const key = color.join(',') - const entry = colorCount.get(key) - if (entry) { - entry.count++ - if (entry.count > maxCount) { - maxCount = entry.count - dominantColor = entry.color + let domR = 0 + let domG = 0 + let domB = 0 + let domA = 0 + + for (let s = 0; s < numSamples; s++) { + const fx = blockX + offsets[s][0] + const fy = blockY + offsets[s][1] + + const cx = Math.min(maxX, Math.max(0, fx)) + const cy = Math.min(maxY, Math.max(0, fy)) + const x0 = Math.min(Math.max(Math.floor(cx), 0), maxX) + const x1 = Math.min(x0 + 1, maxX) + const y0 = Math.min(Math.max(Math.floor(cy), 0), maxY) + const y1 = Math.min(y0 + 1, maxY) + const sx = cx - x0 + const sy = cy - y0 + const osx = 1 - sx + const osy = 1 - sy + const i00 = (y0 * width + x0) * 4 + const i10 = (y0 * width + x1) * 4 + const i01 = (y1 * width + x0) * 4 + const i11 = (y1 * width + x1) * 4 + const r = (source[i00] * osx + source[i10] * sx) * osy + (source[i01] * osx + source[i11] * sx) * sy + const g = + (source[i00 + 1] * osx + source[i10 + 1] * sx) * osy + (source[i01 + 1] * osx + source[i11 + 1] * sx) * sy + const b = + (source[i00 + 2] * osx + source[i10 + 2] * sx) * osy + (source[i01 + 2] * osx + source[i11 + 2] * sx) * sy + const a = + (source[i00 + 3] * osx + source[i10 + 3] * sx) * osy + (source[i01 + 3] * osx + source[i11 + 3] * sx) * sy + + if (s === 0) { + domR = r + domG = g + domB = b + domA = a + } + + let found = false + for (let c = 0; c < numDistinct; c++) { + if (sampleR[c] === r && sampleG[c] === g && sampleB[c] === b && sampleA[c] === a) { + sampleCount[c]++ + if (sampleCount[c] > maxCount) { + maxCount = sampleCount[c] + domR = r + domG = g + domB = b + domA = a + } + found = true + break } - } else { - colorCount.set(key, { color, count: 1 }) + } + if (!found) { + sampleR[numDistinct] = r + sampleG[numDistinct] = g + sampleB[numDistinct] = b + sampleA[numDistinct] = a + sampleCount[numDistinct] = 1 + numDistinct++ } } - return dominantColor + const bi = (by * blocksX + bx) * 4 + blockColors[bi] = domR + blockColors[bi + 1] = domG + blockColors[bi + 2] = domB + blockColors[bi + 3] = domA + + for (let c = 0; c < numDistinct; c++) sampleCount[c] = 0 } - }) + } + + fillBlockBands(target, blockColors, width, height, blockSize, blocksX) + + return target +} + +function pixelateAverage(source: Buffer, width: number, height: number, blockSize: number, antiAlias: boolean): Buffer { + const maxX = width - 1 + const maxY = height - 1 + const target = Buffer.allocUnsafe(width * height * 4) + const offsets = buildSampleOffsets(blockSize, antiAlias) + const numSamples = offsets.length + + const blocksX = Math.ceil(width / blockSize) + const blocksY = Math.ceil(height / blockSize) + + const blockColors = new Float64Array(blocksX * blocksY * 4) + + for (let by = 0; by < blocksY; by++) { + const blockY = by * blockSize + for (let bx = 0; bx < blocksX; bx++) { + const blockX = bx * blockSize + let sumR = 0 + let sumG = 0 + let sumB = 0 + let sumA = 0 + + for (let s = 0; s < numSamples; s++) { + const fx = blockX + offsets[s][0] + const fy = blockY + offsets[s][1] + + const cx = Math.min(maxX, Math.max(0, fx)) + const cy = Math.min(maxY, Math.max(0, fy)) + const x0 = Math.min(Math.max(Math.floor(cx), 0), maxX) + const x1 = Math.min(x0 + 1, maxX) + const y0 = Math.min(Math.max(Math.floor(cy), 0), maxY) + const y1 = Math.min(y0 + 1, maxY) + const sx = cx - x0 + const sy = cy - y0 + const osx = 1 - sx + const osy = 1 - sy + const i00 = (y0 * width + x0) * 4 + const i10 = (y0 * width + x1) * 4 + const i01 = (y1 * width + x0) * 4 + const i11 = (y1 * width + x1) * 4 + + sumR += (source[i00] * osx + source[i10] * sx) * osy + (source[i01] * osx + source[i11] * sx) * sy + sumG += + (source[i00 + 1] * osx + source[i10 + 1] * sx) * osy + (source[i01 + 1] * osx + source[i11 + 1] * sx) * sy + sumB += + (source[i00 + 2] * osx + source[i10 + 2] * sx) * osy + (source[i01 + 2] * osx + source[i11 + 2] * sx) * sy + sumA += + (source[i00 + 3] * osx + source[i10 + 3] * sx) * osy + (source[i01 + 3] * osx + source[i11 + 3] * sx) * sy + } + + const bi = (by * blocksX + bx) * 4 + blockColors[bi] = sumR / numSamples + blockColors[bi + 1] = sumG / numSamples + blockColors[bi + 2] = sumB / numSamples + blockColors[bi + 3] = sumA / numSamples + } + } + + fillBlockBands(target, blockColors, width, height, blockSize, blocksX) + + return target +} + +function pixelateCenter(source: Buffer, width: number, height: number, blockSize: number): Buffer { + const maxX = width - 1 + const maxY = height - 1 + const target = Buffer.allocUnsafe(width * height * 4) + const halfBlock = blockSize / 2 + + const blocksX = Math.ceil(width / blockSize) + const blocksY = Math.ceil(height / blockSize) + + const blockColors = new Float64Array(blocksX * blocksY * 4) + for (let by = 0; by < blocksY; by++) { + const fy = by * blockSize + halfBlock + for (let bx = 0; bx < blocksX; bx++) { + const fx = bx * blockSize + halfBlock + + const cx = Math.min(maxX, Math.max(0, fx)) + const cy = Math.min(maxY, Math.max(0, fy)) + const x0 = Math.min(Math.max(Math.floor(cx), 0), maxX) + const x1 = Math.min(x0 + 1, maxX) + const y0 = Math.min(Math.max(Math.floor(cy), 0), maxY) + const y1 = Math.min(y0 + 1, maxY) + const sx = cx - x0 + const sy = cy - y0 + const osx = 1 - sx + const osy = 1 - sy + const i00 = (y0 * width + x0) * 4 + const i10 = (y0 * width + x1) * 4 + const i01 = (y1 * width + x0) * 4 + const i11 = (y1 * width + x1) * 4 + + const bi = (by * blocksX + bx) * 4 + blockColors[bi] = (source[i00] * osx + source[i10] * sx) * osy + (source[i01] * osx + source[i11] * sx) * sy + blockColors[bi + 1] = + (source[i00 + 1] * osx + source[i10 + 1] * sx) * osy + (source[i01 + 1] * osx + source[i11 + 1] * sx) * sy + blockColors[bi + 2] = + (source[i00 + 2] * osx + source[i10 + 2] * sx) * osy + (source[i01 + 2] * osx + source[i11 + 2] * sx) * sy + blockColors[bi + 3] = + (source[i00 + 3] * osx + source[i10 + 3] * sx) * osy + (source[i01 + 3] * osx + source[i11 + 3] * sx) * sy + } + } + + fillBlockBands(target, blockColors, width, height, blockSize, blocksX) + + return target } diff --git a/packages/pixel-profile/src/shaders/scanline.ts b/packages/pixel-profile/src/shaders/scanline.ts index 2a5b113..2e558f9 100644 --- a/packages/pixel-profile/src/shaders/scanline.ts +++ b/packages/pixel-profile/src/shaders/scanline.ts @@ -1,22 +1,20 @@ -import { render } from '../renderer' - const scanlineIntensity = 0.15 const scanlineThickness = 3 const scanlineBrightness = 1 - scanlineIntensity export function scanline(source: Buffer, width: number, height: number): Buffer { - return render(source, width, height, (coords, texture) => { - const samplerColor = texture(coords) + const rowBytes = width * 4 - if (coords[1] % scanlineThickness === 0) { - return [ - samplerColor[0] * scanlineBrightness, - samplerColor[1] * scanlineBrightness, - samplerColor[2] * scanlineBrightness, - samplerColor[3] - ] + for (let y = 0; y < height; y++) { + if (y % scanlineThickness !== 0) continue + const rowOffset = y * rowBytes + const rowEnd = rowOffset + rowBytes + for (let idx = rowOffset; idx < rowEnd; idx += 4) { + source[idx] = source[idx] * scanlineBrightness + source[idx + 1] = source[idx + 1] * scanlineBrightness + source[idx + 2] = source[idx + 2] * scanlineBrightness } + } - return samplerColor - }) + return source } diff --git a/packages/pixel-profile/src/utils/bilinear.ts b/packages/pixel-profile/src/utils/bilinear.ts new file mode 100644 index 0000000..d89c24d --- /dev/null +++ b/packages/pixel-profile/src/utils/bilinear.ts @@ -0,0 +1,31 @@ +export function sampleBilinear( + source: Buffer, + width: number, + maxX: number, + maxY: number, + fx: number, + fy: number +): [number, number, number, number] { + const x = Math.min(maxX, Math.max(0, fx)) + const y = Math.min(maxY, Math.max(0, fy)) + const x0 = Math.min(Math.max(Math.floor(x), 0), maxX) + const x1 = Math.min(x0 + 1, maxX) + const y0 = Math.min(Math.max(Math.floor(y), 0), maxY) + const y1 = Math.min(y0 + 1, maxY) + const sx = x - x0 + const sy = y - y0 + const osx = 1 - sx + const osy = 1 - sy + + const i00 = (y0 * width + x0) * 4 + const i10 = (y0 * width + x1) * 4 + const i01 = (y1 * width + x0) * 4 + const i11 = (y1 * width + x1) * 4 + + return [ + (source[i00] * osx + source[i10] * sx) * osy + (source[i01] * osx + source[i11] * sx) * sy, + (source[i00 + 1] * osx + source[i10 + 1] * sx) * osy + (source[i01 + 1] * osx + source[i11 + 1] * sx) * sy, + (source[i00 + 2] * osx + source[i10 + 2] * sx) * osy + (source[i01 + 2] * osx + source[i11 + 2] * sx) * sy, + (source[i00 + 3] * osx + source[i10 + 3] * sx) * osy + (source[i01 + 3] * osx + source[i11 + 3] * sx) * sy + ] +} diff --git a/packages/pixel-profile/src/utils/border-blend.ts b/packages/pixel-profile/src/utils/border-blend.ts index d5d7149..c22675a 100644 --- a/packages/pixel-profile/src/utils/border-blend.ts +++ b/packages/pixel-profile/src/utils/border-blend.ts @@ -1,5 +1,4 @@ import { IMG_BORDER } from '../theme/images/border-frame' -import { getPixelsFromPngBuffer } from './converter' import Jimp from 'jimp' export interface BorderOptions { @@ -7,14 +6,39 @@ export interface BorderOptions { targetHeight?: number } -/** - * Blend border image with target pixels - * @param pixels - Target image pixels buffer - * @param width - Target image width - * @param height - Target image height - * @param options - Border blending options - * @returns Blended pixels buffer - */ +let cachedBorderRGBA: Buffer | null = null +let cachedAlpha: Float64Array | null = null +let cachedOneMinusAlpha: Float64Array | null = null +let cachedKey = '' + +async function getBorderData(targetWidth: number, targetHeight: number) { + const key = `${targetWidth}x${targetHeight}` + if (cachedKey === key && cachedBorderRGBA && cachedAlpha && cachedOneMinusAlpha) { + return { borderPixels: cachedBorderRGBA, alpha: cachedAlpha, oneMinusAlpha: cachedOneMinusAlpha } + } + + const borderPng = await Jimp.read(Buffer.from(IMG_BORDER.split(',')[1], 'base64')) + borderPng.resize(targetWidth, targetHeight) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const borderPixels = Buffer.from(borderPng.bitmap.data as any) + + const pixelCount = targetWidth * targetHeight + const alpha = new Float64Array(pixelCount) + const oneMinusAlpha = new Float64Array(pixelCount) + for (let i = 0; i < pixelCount; i++) { + const a = borderPixels[i * 4 + 3] / 255 + alpha[i] = a + oneMinusAlpha[i] = 1 - a + } + + cachedBorderRGBA = borderPixels + cachedAlpha = alpha + cachedOneMinusAlpha = oneMinusAlpha + cachedKey = key + + return { borderPixels, alpha, oneMinusAlpha } +} + export async function blendBorder( pixels: Buffer, width: number, @@ -22,20 +46,17 @@ export async function blendBorder( options: BorderOptions = {} ): Promise { const { targetWidth = width, targetHeight = height } = options + const { borderPixels, alpha, oneMinusAlpha } = await getBorderData(targetWidth, targetHeight) - const borderPng = await Jimp.read(Buffer.from(IMG_BORDER.split(',')[1], 'base64')) - borderPng.resize(targetWidth, targetHeight) - const borderBuffer = await borderPng.getBufferAsync(Jimp.MIME_PNG) - const { pixels: borderPixels } = await getPixelsFromPngBuffer(borderBuffer) - - const blendedPixels = Buffer.alloc(pixels.length) + const blendedPixels = Buffer.allocUnsafe(pixels.length) - for (let i = 0; i < blendedPixels.length - 1; i += 4) { - const alpha = borderPixels[i + 3] / 255 + for (let i = 0, p = 0; i < blendedPixels.length - 1; i += 4, p++) { + const a = alpha[p] + const oma = oneMinusAlpha[p] - blendedPixels[i] = pixels[i] * (1 - alpha) + borderPixels[i] * alpha - blendedPixels[i + 1] = pixels[i + 1] * (1 - alpha) + borderPixels[i + 1] * alpha - blendedPixels[i + 2] = pixels[i + 2] * (1 - alpha) + borderPixels[i + 2] * alpha + blendedPixels[i] = pixels[i] * oma + borderPixels[i] * a + blendedPixels[i + 1] = pixels[i + 1] * oma + borderPixels[i + 1] * a + blendedPixels[i + 2] = pixels[i + 2] * oma + borderPixels[i + 2] * a blendedPixels[i + 3] = 255 } diff --git a/packages/pixel-profile/src/utils/converter.ts b/packages/pixel-profile/src/utils/converter.ts index 3a45ad1..366e965 100644 --- a/packages/pixel-profile/src/utils/converter.ts +++ b/packages/pixel-profile/src/utils/converter.ts @@ -1,6 +1,7 @@ import { RGBA } from '../renderer' import { isBase64PNG } from './is' import { Vec3 } from './math' +import { encodePng, encodePngBase64 } from './png-encoder' import axios from 'axios' import Jimp from 'jimp' @@ -13,14 +14,7 @@ export async function getPixelsFromPngBuffer(png: Buffer): Promise<{ const width = image.getWidth() const height = image.getHeight() - const pixels = Buffer.alloc(width * height * 4) - - image.scan(0, 0, width, height, (_x, _y, idx) => { - pixels[idx] = image.bitmap.data[idx] - pixels[idx + 1] = image.bitmap.data[idx + 1] - pixels[idx + 2] = image.bitmap.data[idx + 2] - pixels[idx + 3] = image.bitmap.data[idx + 3] - }) + const pixels = image.bitmap.data as Buffer return { pixels, @@ -29,28 +23,12 @@ export async function getPixelsFromPngBuffer(png: Buffer): Promise<{ } } -export async function getBase64FromPixels(pixels: Buffer, width: number, height: number): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line no-new - new Jimp(width, height, (_, image) => { - image.bitmap.data = pixels - image.getBase64('image/png', (_, str) => { - resolve(str) - }) - }) - }) +export function getBase64FromPixels(pixels: Buffer, width: number, height: number): string { + return encodePngBase64(pixels, width, height) } -export function getPngBufferFromPixels(pixelsBuffer: Buffer, width: number, height: number): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line no-new - new Jimp(width, height, function (_, image) { - image.bitmap.data = pixelsBuffer - image.getBuffer('image/png', function (_, buffer) { - resolve(buffer) - }) - }) - }) +export function getPngBufferFromPixels(pixelsBuffer: Buffer, width: number, height: number): Buffer { + return encodePng(pixelsBuffer, width, height) } export function getPngBufferFromBase64(base64: string): Buffer { diff --git a/packages/pixel-profile/src/utils/png-encoder.ts b/packages/pixel-profile/src/utils/png-encoder.ts new file mode 100644 index 0000000..a10b6de --- /dev/null +++ b/packages/pixel-profile/src/utils/png-encoder.ts @@ -0,0 +1,87 @@ +import { deflateSync } from 'zlib' + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) +const IHDR_TYPE = Buffer.from('IHDR', 'ascii') +const IDAT_TYPE = Buffer.from('IDAT', 'ascii') + +const crcTable = new Uint32Array(256) +for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + crcTable[n] = c +} + +function crc32(buf: Buffer, start: number, end: number): number { + let crc = 0xffffffff + for (let i = start; i < end; i++) { + crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8) + } + + return (crc ^ 0xffffffff) >>> 0 +} + +const IEND_CHUNK = Buffer.alloc(12) +IEND_CHUNK.writeUInt32BE(0, 0) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +IDAT_TYPE.copy(IEND_CHUNK as any, 4) +IEND_CHUNK[4] = 73 // I +IEND_CHUNK[5] = 69 // E +IEND_CHUNK[6] = 78 // N +IEND_CHUNK[7] = 68 // D +IEND_CHUNK.writeUInt32BE(crc32(IEND_CHUNK, 4, 8), 8) + +export function encodePng(pixels: Buffer, width: number, height: number, level: number = 1): Buffer { + const rowBytes = width * 4 + const rawSize = (rowBytes + 1) * height + const raw = Buffer.allocUnsafe(rawSize) + + for (let y = 0; y < height; y++) { + const srcOff = y * rowBytes + const dstOff = y * (rowBytes + 1) + raw[dstOff] = 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pixels.copy(raw as any, dstOff + 1, srcOff, srcOff + rowBytes) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const compressed = deflateSync(raw as any, { level }) + const compLen = compressed.length + + const out = Buffer.allocUnsafe(57 + compLen) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + PNG_SIGNATURE.copy(out as any, 0) + + out.writeUInt32BE(13, 8) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + IHDR_TYPE.copy(out as any, 12) + out.writeUInt32BE(width, 16) + out.writeUInt32BE(height, 20) + out[24] = 8 + out[25] = 6 + out[26] = 0 + out[27] = 0 + out[28] = 0 + out.writeUInt32BE(crc32(out, 12, 29), 29) + + const idatStart = 33 + out.writeUInt32BE(compLen, idatStart) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + IDAT_TYPE.copy(out as any, idatStart + 4) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compressed.copy(out as any, idatStart + 8) + out.writeUInt32BE(crc32(out, idatStart + 4, idatStart + 8 + compLen), idatStart + 8 + compLen) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + IEND_CHUNK.copy(out as any, 45 + compLen) + + return out +} + +export function encodePngBase64(pixels: Buffer, width: number, height: number): string { + const png = encodePng(pixels, width, height) + + return `data:image/png;base64,${png.toString('base64')}` +} diff --git a/packages/pixel-profile/src/workers/pool.ts b/packages/pixel-profile/src/workers/pool.ts new file mode 100644 index 0000000..d94ba83 --- /dev/null +++ b/packages/pixel-profile/src/workers/pool.ts @@ -0,0 +1,689 @@ +import { crtCore, defaultCRTOptions } from '../shaders/crt' +import { curveCore } from '../shaders/curve' +import { + buildColumnPrefixMap, + buildLuminanceMap, + buildWeightTable, + calculateAdaptiveThreshold, + getFalloffFunction, + horizontalPassCore, + horizontalPassFusedCore, + verticalPassCore +} from '../shaders/glow' +import os from 'node:os' +import { Worker } from 'node:worker_threads' + +const POOL_SIZE = Math.max(2, Math.min(os.cpus().length - 1, 6)) + +interface PoolWorker { + worker: Worker + pending: { resolve: () => void; reject: (err: Error) => void } | null +} + +const POOL_INIT_TIMEOUT_MS = 3000 + +let pool: PoolWorker[] | null = null +let poolFailed = false +let poolReadyPromise: Promise | null = null + +function buildWorkerCode(): string { + const fns = [ + crtCore, + curveCore, + buildColumnPrefixMap, + buildWeightTable, + getFalloffFunction, + horizontalPassCore, + horizontalPassFusedCore, + verticalPassCore + ] + const fnDefs = fns.map((fn) => `var ${fn.name} = ${fn.toString()};`).join('\n') + + return ` +'use strict'; +var { parentPort } = require('worker_threads'); + +${fnDefs} + +parentPort.on('message', function(msg) { + if (msg.type === 'crt') { + var src = new Uint8Array(msg.sourceSab); + var tgt = new Uint8Array(msg.targetSab); + crtCore(src, tgt, msg.width, msg.height, msg.startRow, msg.endRow, msg.opts); + } else if (msg.type === 'curve') { + var src = new Uint8Array(msg.sourceSab); + var tgt = new Uint8Array(msg.targetSab); + curveCore(src, tgt, msg.width, msg.height, msg.startRow, msg.endRow); + } else if (msg.type === 'glow-h-batch') { + var src = new Uint8Array(msg.sourceSab); + var lum = new Float64Array(msg.lumSab); + var w = msg.width, h = msg.height; + var sR = msg.startRow, eR = msg.endRow; + var th = msg.threshold, ft = msg.falloffType; + var ls = msg.layers; + var nL = ls.length; + var outs = new Array(nL); + var outLums = new Array(nL); + var rads = new Array(nL); + var wts = new Array(nL); + for (var li = 0; li < nL; li++) { + var la = ls[li]; + outs[li] = new Float32Array(la.hBlurSab); + outLums[li] = new Float64Array(la.hLumSab); + rads[li] = la.radius; + wts[li] = buildWeightTable(la.radius, getFalloffFunction(ft)); + } + horizontalPassFusedCore(src, lum, w, h, th, nL, outs, outLums, rads, wts, sR, eR); + } else if (msg.type === 'glow-v-only') { + var w = msg.width, h = msg.height; + var sR = msg.startRow, eR = msg.endRow; + var th = msg.threshold, ft = msg.falloffType; + var ls = msg.layers; + for (var li = 0; li < ls.length; li++) { + var la = ls[li]; + var hB = new Float32Array(la.hBlurSab); + var hL = new Float64Array(la.hLumSab); + var oS = new Float32Array(la.layerOutSab); + var ffn = getFalloffFunction(ft); + var wt = buildWeightTable(la.radius, ffn); + var cp = la.colPrefixSab ? new Int32Array(la.colPrefixSab) : undefined; + verticalPassCore(hB, oS, hL, w, h, th, la.radius, wt, sR, eR, cp); + } + } else if (msg.type === 'glow-composite') { + var src = new Uint8Array(msg.sourceSab); + var res = new Uint8Array(msg.resultSab); + var w = msg.width, h = msg.height; + var sR = msg.startRow, eR = msg.endRow; + var ls = msg.layers, nL = ls.length; + var layerOuts = new Array(nL); + for (var li = 0; li < nL; li++) { + layerOuts[li] = new Float32Array(ls[li].layerOutSab); + } + var cI = msg.colorIsWhite; + var cR = msg.colorR, cG = msg.colorG, cB = msg.colorB; + var lI = msg.layerIntensities, lO = msg.layerOneMinusT; + for (var y = sR; y < eR; y++) { + var rOff = y * w; + for (var x = 0; x < w; x++) { + var idx = (rOff + x) * 4; + var fR = src[idx], fG = src[idx+1], fB = src[idx+2]; + if (cI) { + for (var j = 0; j < nL; j++) { + var ci = lI[j], oi = lO[j], lb = layerOuts[j]; + fR = fR*oi + lb[idx]*ci; + fG = fG*oi + lb[idx+1]*ci; + fB = fB*oi + lb[idx+2]*ci; + } + } else { + for (var j = 0; j < nL; j++) { + var ci = lI[j], oi = lO[j], lb = layerOuts[j]; + fR = fR*oi + lb[idx]*cR*ci; + fG = fG*oi + lb[idx+1]*cG*ci; + fB = fB*oi + lb[idx+2]*cB*ci; + } + } + res[idx] = fR; res[idx+1] = fG; + res[idx+2] = fB; res[idx+3] = 255; + } + } + } else if (msg.type === 'glow-vc-batch') { + var w = msg.width, h = msg.height; + var sR = msg.startRow, eR = msg.endRow; + var th = msg.threshold, ft = msg.falloffType; + var ls = msg.layers, nL = ls.length; + var layerOuts = new Array(nL); + for (var li = 0; li < nL; li++) { + var la = ls[li]; + var hB = new Float32Array(la.hBlurSab); + var hL = new Float64Array(la.hLumSab); + var oS = new Float32Array(la.layerOutSab); + layerOuts[li] = oS; + var ffn = getFalloffFunction(ft); + var wt = buildWeightTable(la.radius, ffn); + var cp = la.colPrefixSab ? new Int32Array(la.colPrefixSab) : undefined; + verticalPassCore(hB, oS, hL, w, h, th, la.radius, wt, sR, eR, cp); + } + var src = new Uint8Array(msg.sourceSab); + var res = new Uint8Array(msg.resultSab); + var cI = msg.colorIsWhite; + var cR = msg.colorR, cG = msg.colorG, cB = msg.colorB; + var lI = msg.layerIntensities; + var lO = msg.layerOneMinusT; + for (var y = sR; y < eR; y++) { + var rOff = y * w; + for (var x = 0; x < w; x++) { + var idx = (rOff + x) * 4; + var fR = src[idx], fG = src[idx+1], fB = src[idx+2]; + if (cI) { + for (var j = 0; j < nL; j++) { + var ci = lI[j], oi = lO[j], lb = layerOuts[j]; + fR = fR*oi + lb[idx]*ci; + fG = fG*oi + lb[idx+1]*ci; + fB = fB*oi + lb[idx+2]*ci; + } + } else { + for (var j = 0; j < nL; j++) { + var ci = lI[j], oi = lO[j], lb = layerOuts[j]; + fR = fR*oi + lb[idx]*cR*ci; + fG = fG*oi + lb[idx+1]*cG*ci; + fB = fB*oi + lb[idx+2]*cB*ci; + } + } + res[idx] = fR; res[idx+1] = fG; + res[idx+2] = fB; res[idx+3] = 255; + } + } + } + parentPort.postMessage(0); +}); +parentPort.postMessage(0); +` +} + +function initPool(): { workers: PoolWorker[]; ready: Promise } | null { + try { + const code = buildWorkerCode() + const readyPromises: Array> = [] + + const workers = Array.from({ length: POOL_SIZE }, () => { + const w = new Worker(code, { eval: true }) + const pw: PoolWorker = { worker: w, pending: null } + + let isReady = false + let readyResolve: () => void + readyPromises.push( + new Promise((resolve) => { + readyResolve = resolve + }) + ) + + w.on('message', () => { + if (!isReady) { + isReady = true + readyResolve() + + return + } + const p = pw.pending + pw.pending = null + p?.resolve() + }) + w.on('error', (err) => { + if (!isReady) { + isReady = true + readyResolve() + + return + } + const p = pw.pending + pw.pending = null + p?.reject(err) + }) + + return pw + }) + + const ready = Promise.race([ + Promise.all(readyPromises).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), POOL_INIT_TIMEOUT_MS)) + ]) + + return { workers, ready } + } catch { + return null + } +} + +function ensurePool(): void { + if (poolFailed || pool || poolReadyPromise) return + + const result = initPool() + if (!result) { + poolFailed = true + + return + } + + pool = result.workers + poolReadyPromise = result.ready.then((ok) => { + if (!ok) { + poolFailed = true + for (const w of pool!) { + try { + w.worker.terminate() + } catch { + /* ignore */ + } + } + pool = null + } + + return ok + }) +} + +async function getPool(): Promise { + ensurePool() + if (poolFailed) return null + + if (poolReadyPromise) { + const ok = await poolReadyPromise + if (!ok) return null + } + + return pool +} + +export function getPoolSize(): number { + return POOL_SIZE +} + +function toSharedSource(source: Buffer, byteLen: number): SharedArrayBuffer { + if (source.buffer instanceof SharedArrayBuffer && source.byteOffset === 0 && source.byteLength >= byteLen) { + return source.buffer + } + const sab = new SharedArrayBuffer(byteLen) + new Uint8Array(sab).set(new Uint8Array(source.buffer, source.byteOffset, byteLen)) + + return sab +} + +export async function dispatchCrt( + source: Buffer, + width: number, + height: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + userOpts: any +): Promise { + const workers = await getPool() + if (!workers) return null + + const opts = { ...defaultCRTOptions, ...userOpts } + const byteLen = width * height * 4 + const sourceSab = toSharedSource(source, byteLen) + + const targetSab = new SharedArrayBuffer(byteLen) + + const rowsPerWorker = Math.ceil(height / workers.length) + + const promises = workers.map((pw, i) => { + const startRow = i * rowsPerWorker + const endRow = Math.min(startRow + rowsPerWorker, height) + if (startRow >= height) return Promise.resolve() + + return new Promise((resolve, reject) => { + pw.pending = { resolve, reject } + pw.worker.postMessage({ type: 'crt', sourceSab, targetSab, width, height, startRow, endRow, opts }) + }) + }) + + await Promise.all(promises) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Buffer.from(targetSab as any) as Buffer +} + +export async function dispatchCurve(source: Buffer, width: number, height: number): Promise { + const workers = await getPool() + if (!workers) return null + + const byteLen = width * height * 4 + const sourceSab = toSharedSource(source, byteLen) + + const targetSab = new SharedArrayBuffer(byteLen) + + const rowsPerWorker = Math.ceil(height / workers.length) + + const promises = workers.map((pw, i) => { + const startRow = i * rowsPerWorker + const endRow = Math.min(startRow + rowsPerWorker, height) + if (startRow >= height) return Promise.resolve() + + return new Promise((resolve, reject) => { + pw.pending = { resolve, reject } + pw.worker.postMessage({ + type: 'curve', + sourceSab, + targetSab, + width, + height, + startRow, + endRow + }) + }) + }) + + await Promise.all(promises) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Buffer.from(targetSab as any) as Buffer +} + +const DEFAULT_GLOW = { + radius: 1, + intensity: 0.7, + threshold: 0.8, + color: [1, 1, 1] as [number, number, number], + layers: 2, + falloff: 'gaussian', + adaptiveThreshold: true +} + +export interface PrecomputedGlowLayers { + layerMsgs: { + hBlurSab: SharedArrayBuffer + hLumSab: SharedArrayBuffer + layerOutSab: SharedArrayBuffer + radius: number + }[] + sourceSab: SharedArrayBuffer + threshold: number + falloff: string + numLayers: number + width: number + height: number +} + +export async function precomputeGlowLayers( + source: Buffer, + width: number, + height: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + userOpts: any +): Promise { + const workers = await getPool() + if (!workers) return null + + const opts = { ...DEFAULT_GLOW, ...userOpts } + const { radius, layers, falloff, adaptiveThreshold, threshold: _threshold } = opts + const threshold = adaptiveThreshold ? calculateAdaptiveThreshold(source, width, height) : _threshold + + const byteLen = width * height * 4 + const size = width * height + const sourceSab = toSharedSource(source, byteLen) + + const lumSab = new SharedArrayBuffer(size * 8) + buildLuminanceMap(source, size, new Float64Array(lumSab)) + + const colPrefixLen = (height + 1) * width + const layerMsgs: PrecomputedGlowLayers['layerMsgs'] = [] + for (let i = 0; i < layers; i++) { + layerMsgs.push({ + hBlurSab: new SharedArrayBuffer(size * 4 * 4), + hLumSab: new SharedArrayBuffer(size * 8), + layerOutSab: new SharedArrayBuffer(size * 4 * 4), + radius: Math.floor(radius * (i + 1)) + }) + } + + const rowsPerWorker = Math.ceil(height / workers.length) + + await Promise.all( + workers.map((pw, i) => { + const startRow = i * rowsPerWorker + const endRow = Math.min(startRow + rowsPerWorker, height) + if (startRow >= height) return Promise.resolve() + + return new Promise((resolve, reject) => { + pw.pending = { resolve, reject } + pw.worker.postMessage({ + type: 'glow-h-batch', + sourceSab, + lumSab, + layers: layerMsgs, + width, + height, + threshold, + falloffType: falloff, + startRow, + endRow + }) + }) + }) + ) + + for (const lm of layerMsgs) { + const hLum = new Float64Array(lm.hLumSab) + const cpSab = new SharedArrayBuffer(colPrefixLen * 4) + buildColumnPrefixMap(hLum, width, height, threshold, new Int32Array(cpSab)) + ;(lm as { colPrefixSab?: SharedArrayBuffer }).colPrefixSab = cpSab + } + + await Promise.all( + workers.map((pw, i) => { + const startRow = i * rowsPerWorker + const endRow = Math.min(startRow + rowsPerWorker, height) + if (startRow >= height) return Promise.resolve() + + return new Promise((resolve, reject) => { + pw.pending = { resolve, reject } + pw.worker.postMessage({ + type: 'glow-v-only', + layers: layerMsgs, + width, + height, + threshold, + falloffType: falloff, + startRow, + endRow + }) + }) + }) + ) + + return { layerMsgs, sourceSab, threshold, falloff, numLayers: layers, width, height } +} + +export async function compositeGlowFromPrecomputed( + precomputed: PrecomputedGlowLayers, + intensity: number, + color: [number, number, number] +): Promise { + const workers = await getPool() + const { layerMsgs, sourceSab, numLayers, width, height } = precomputed + const byteLen = width * height * 4 + + const [colorR, colorG, colorB] = color + const isWhite = colorR === 1 && colorG === 1 && colorB === 1 + const layerIntensities: number[] = [] + const layerOneMinusT: number[] = [] + for (let li = 0; li < numLayers; li++) { + layerIntensities.push(intensity / (li + 1)) + layerOneMinusT.push(1 - intensity / (li + 1)) + } + + if (workers) { + const resultSab = new SharedArrayBuffer(byteLen) + const rowsPerWorker = Math.ceil(height / workers.length) + + await Promise.all( + workers.map((pw, i) => { + const startRow = i * rowsPerWorker + const endRow = Math.min(startRow + rowsPerWorker, height) + if (startRow >= height) return Promise.resolve() + + return new Promise((resolve, reject) => { + pw.pending = { resolve, reject } + pw.worker.postMessage({ + type: 'glow-composite', + layers: layerMsgs, + sourceSab, + resultSab, + width, + height, + startRow, + endRow, + colorIsWhite: isWhite, + colorR, + colorG, + colorB, + layerIntensities, + layerOneMinusT + }) + }) + }) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Buffer.from(resultSab as any) as Buffer + } + + const source = Buffer.from(sourceSab) + const result = Buffer.allocUnsafe(byteLen) + const glowLayers: Float32Array[] = layerMsgs.map((lm) => new Float32Array(lm.layerOutSab)) + + for (let y = 0; y < height; y++) { + const rowOffset = y * width + for (let x = 0; x < width; x++) { + const idx = (rowOffset + x) * 4 + let fR = source[idx] + let fG = source[idx + 1] + let fB = source[idx + 2] + for (let li = 0; li < numLayers; li++) { + const ci = layerIntensities[li] + const oi = layerOneMinusT[li] + const lb = glowLayers[li] + if (isWhite) { + fR = fR * oi + lb[idx] * ci + fG = fG * oi + lb[idx + 1] * ci + fB = fB * oi + lb[idx + 2] * ci + } else { + fR = fR * oi + lb[idx] * colorR * ci + fG = fG * oi + lb[idx + 1] * colorG * ci + fB = fB * oi + lb[idx + 2] * colorB * ci + } + } + result[idx] = fR + result[idx + 1] = fG + result[idx + 2] = fB + result[idx + 3] = 255 + } + } + + return result +} + +export async function dispatchGlow( + source: Buffer, + width: number, + height: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + userOpts: any +): Promise { + const workers = await getPool() + if (!workers) return null + + const opts = { ...DEFAULT_GLOW, ...userOpts } + const { radius, intensity, layers, falloff, adaptiveThreshold, threshold: _threshold, color } = opts + const threshold = adaptiveThreshold ? calculateAdaptiveThreshold(source, width, height) : _threshold + + const byteLen = width * height * 4 + const size = width * height + const sourceSab = toSharedSource(source, byteLen) + const colPrefixLen = (height + 1) * width + + const lumSab = new SharedArrayBuffer(size * 8) + buildLuminanceMap(source, size, new Float64Array(lumSab)) + + const layerMsgs: { + hBlurSab: SharedArrayBuffer + hLumSab: SharedArrayBuffer + layerOutSab: SharedArrayBuffer + radius: number + colPrefixSab?: SharedArrayBuffer + }[] = [] + for (let i = 0; i < layers; i++) { + layerMsgs.push({ + hBlurSab: new SharedArrayBuffer(size * 4 * 4), + hLumSab: new SharedArrayBuffer(size * 8), + layerOutSab: new SharedArrayBuffer(size * 4 * 4), + radius: Math.floor(radius * (i + 1)) + }) + } + + const rowsPerWorker = Math.ceil(height / workers.length) + + await Promise.all( + workers.map((pw, i) => { + const startRow = i * rowsPerWorker + const endRow = Math.min(startRow + rowsPerWorker, height) + if (startRow >= height) return Promise.resolve() + + return new Promise((resolve, reject) => { + pw.pending = { resolve, reject } + pw.worker.postMessage({ + type: 'glow-h-batch', + sourceSab, + lumSab, + layers: layerMsgs, + width, + height, + threshold, + falloffType: falloff, + startRow, + endRow + }) + }) + }) + ) + + for (const lm of layerMsgs) { + const hLum = new Float64Array(lm.hLumSab) + const cpSab = new SharedArrayBuffer(colPrefixLen * 4) + buildColumnPrefixMap(hLum, width, height, threshold, new Int32Array(cpSab)) + lm.colPrefixSab = cpSab + } + + const [colorR, colorG, colorB] = color + const isWhite = colorR === 1 && colorG === 1 && colorB === 1 + const layerIntensities: number[] = [] + const layerOneMinusT: number[] = [] + for (let li = 0; li < layers; li++) { + layerIntensities.push(intensity / (li + 1)) + layerOneMinusT.push(1 - intensity / (li + 1)) + } + + const resultSab = new SharedArrayBuffer(byteLen) + + await Promise.all( + workers.map((pw, i) => { + const startRow = i * rowsPerWorker + const endRow = Math.min(startRow + rowsPerWorker, height) + if (startRow >= height) return Promise.resolve() + + return new Promise((resolve, reject) => { + pw.pending = { resolve, reject } + pw.worker.postMessage({ + type: 'glow-vc-batch', + layers: layerMsgs, + sourceSab, + resultSab, + width, + height, + threshold, + falloffType: falloff, + startRow, + endRow, + colorIsWhite: isWhite, + colorR, + colorG, + colorB, + layerIntensities, + layerOneMinusT + }) + }) + }) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Buffer.from(resultSab as any) as Buffer +} + +export function shutdownPool(): void { + if (pool) { + for (const pw of pool) { + pw.worker.terminate() + } + pool = null + } +} diff --git a/packages/pixel-profile/test/animation.test.ts b/packages/pixel-profile/test/animation.test.ts new file mode 100644 index 0000000..9011ea2 --- /dev/null +++ b/packages/pixel-profile/test/animation.test.ts @@ -0,0 +1,130 @@ +import './utils/data' +import { encodeGif, type GifFrame, renderAnimatedGif } from '../src/animation' +import type { Pipeline } from '../src/pipeline' +import { describe, expect, it } from 'vitest' + +function makeSolidFrame(width: number, height: number, r: number, g: number, b: number): Buffer { + const buf = Buffer.alloc(width * height * 4) + for (let i = 0; i < width * height; i++) { + buf[i * 4] = r + buf[i * 4 + 1] = g + buf[i * 4 + 2] = b + buf[i * 4 + 3] = 255 + } + + return buf +} + +describe('GIF encoder', () => { + it('produces a valid GIF89a header', () => { + const frames: GifFrame[] = [{ pixels: makeSolidFrame(4, 4, 255, 0, 0), delay: 100 }] + const gif = encodeGif(frames, 4, 4) + const sig = gif.subarray(0, 6).toString('ascii') + expect(sig).toBe('GIF89a') + }) + + it('encodes width and height correctly', () => { + const w = 16 + const h = 8 + const frames: GifFrame[] = [{ pixels: makeSolidFrame(w, h, 0, 128, 255), delay: 50 }] + const gif = encodeGif(frames, w, h) + expect(gif.readUInt16LE(6)).toBe(w) + expect(gif.readUInt16LE(8)).toBe(h) + }) + + it('ends with GIF trailer byte 0x3B', () => { + const frames: GifFrame[] = [{ pixels: makeSolidFrame(2, 2, 0, 0, 0), delay: 100 }] + const gif = encodeGif(frames, 2, 2) + expect(gif[gif.length - 1]).toBe(0x3b) + }) + + it('encodes multiple frames without error', () => { + const frames: GifFrame[] = [ + { pixels: makeSolidFrame(8, 8, 255, 0, 0), delay: 100 }, + { pixels: makeSolidFrame(8, 8, 0, 255, 0), delay: 100 }, + { pixels: makeSolidFrame(8, 8, 0, 0, 255), delay: 100 } + ] + const gif = encodeGif(frames, 8, 8) + expect(gif.subarray(0, 6).toString('ascii')).toBe('GIF89a') + expect(gif.length).toBeGreaterThan(13 + 768 + 19) + }) + + it('contains NETSCAPE2.0 extension for looping', () => { + const frames: GifFrame[] = [ + { pixels: makeSolidFrame(4, 4, 255, 0, 0), delay: 100 }, + { pixels: makeSolidFrame(4, 4, 0, 255, 0), delay: 100 } + ] + const gif = encodeGif(frames, 4, 4) + const str = gif.toString('binary') + expect(str).toContain('NETSCAPE2.0') + }) + + it('handles gradient pixel data with many colors', () => { + const w = 32 + const h = 32 + const buf = Buffer.alloc(w * h * 4) + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = (y * w + x) * 4 + buf[i] = Math.floor((x / w) * 255) + buf[i + 1] = Math.floor((y / h) * 255) + buf[i + 2] = 128 + buf[i + 3] = 255 + } + } + const frames: GifFrame[] = [{ pixels: buf, delay: 100 }] + const gif = encodeGif(frames, w, h) + expect(gif.subarray(0, 6).toString('ascii')).toBe('GIF89a') + expect(gif[gif.length - 1]).toBe(0x3b) + }) +}) + +describe('renderAnimatedGif', () => { + const W = 16 + const H = 16 + const basePixels = makeSolidFrame(W, H, 100, 150, 200) + + it('produces a GIF with empty pipeline', async () => { + const pipeline: Pipeline = [] + const gif = await renderAnimatedGif(basePixels, W, H, pipeline, { + effect: 'scanline-scroll', + frameCount: 3, + frameDelay: 80 + }) + expect(gif.subarray(0, 6).toString('ascii')).toBe('GIF89a') + expect(gif[gif.length - 1]).toBe(0x3b) + }) + + it('respects frameCount option', async () => { + const pipeline: Pipeline = [] + const gif3 = await renderAnimatedGif(basePixels, W, H, pipeline, { + effect: 'scanline-scroll', + frameCount: 3, + frameDelay: 100 + }) + const gif6 = await renderAnimatedGif(basePixels, W, H, pipeline, { + effect: 'scanline-scroll', + frameCount: 6, + frameDelay: 100 + }) + expect(gif6.length).toBeGreaterThan(gif3.length) + }) + + it('scanline-scroll produces valid output', async () => { + const pipeline: Pipeline = [] + const gif = await renderAnimatedGif(basePixels, W, H, pipeline, { + effect: 'scanline-scroll', + frameCount: 4, + frameDelay: 100 + }) + expect(gif.subarray(0, 6).toString('ascii')).toBe('GIF89a') + expect(gif.readUInt16LE(6)).toBe(W) + expect(gif.readUInt16LE(8)).toBe(H) + }) + + it('defaults to glow-pulse effect and 8 frames', async () => { + const pipeline: Pipeline = [] + const gif = await renderAnimatedGif(basePixels, W, H, pipeline) + expect(gif.subarray(0, 6).toString('ascii')).toBe('GIF89a') + }) +}) diff --git a/packages/pixel-profile/test/renderer.test.ts b/packages/pixel-profile/test/renderer.test.ts deleted file mode 100644 index 61efe16..0000000 --- a/packages/pixel-profile/test/renderer.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { render } from '../src/renderer' -import { genBiLinearFilter } from '../src/renderer/texture-filter/linear' -import { compare } from '../src/utils' -import { getPixelsFromPngBuffer, getPngBufferFromBase64 } from '../src/utils/converter' -import { LUCI_AVATAR } from './utils/avatar/luci' -import { describe, expect, it } from 'vitest' - -describe('Renderer', () => { - it('Render', async () => { - const png = getPngBufferFromBase64(LUCI_AVATAR) - const { pixels: source, width, height } = await getPixelsFromPngBuffer(png) - const target = render(source, width, height, (uv, texture2D) => texture2D([uv[0], uv[1]]), { - textureFilter: 'NEAREST' - }) - - expect(compare(source, target)).toBeTruthy() - }) -}) - -describe('genBiLinearFilter', () => { - const width = 4 - const height = 4 - /* eslint-disable prettier/prettier */ - const pixels = Buffer.from([ - 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, - 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 255, 0, 0, 0, 255, - 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, - 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 255, 0, 0, 0, 255 - ]) - /* eslint-enable prettier/prettier */ - - const biLinearFilter = genBiLinearFilter(pixels, width, height) - - it('should return the correct pixel value at integer coordinates', () => { - expect(biLinearFilter([0, 0])).toEqual([255, 0, 0, 255]) - expect(biLinearFilter([1, 0])).toEqual([0, 255, 0, 255]) - expect(biLinearFilter([0, 1])).toEqual([0, 255, 255, 255]) - expect(biLinearFilter([3, 3])).toEqual([0, 0, 0, 255]) - }) - - it('should correctly interpolate at non-integer coordinates', () => { - const result = biLinearFilter([0.5, 0.5]) - expect(result[0]).toBeCloseTo(127.5) - expect(result[1]).toBeCloseTo(127.5) - expect(result[2]).toBeCloseTo(127.5) - expect(result[3]).toBe(255) - }) - - it('should correctly handle image edges', () => { - const result = biLinearFilter([3.9, 3.9]) - expect(result).toEqual([0, 0, 0, 255]) - }) - - it('should correctly handle out of range coordinates', () => { - const result1 = biLinearFilter([-1, -1]) - expect(result1).toEqual([255, 0, 0, 255]) - - const result2 = biLinearFilter([5, 5]) - expect(result2).toEqual([0, 0, 0, 255]) - }) - - it('should correctly interpolate in the x direction', () => { - const result = biLinearFilter([0.5, 0]) - expect(result[0]).toBeCloseTo(127.5) - expect(result[1]).toBeCloseTo(127.5) - expect(result[2]).toBeCloseTo(0) - expect(result[3]).toBe(255) - }) - - it('should correctly interpolate in the y direction', () => { - const result = biLinearFilter([0, 0.5]) - expect(result[0]).toBeCloseTo(127.5) - expect(result[1]).toBeCloseTo(127.5) - expect(result[2]).toBeCloseTo(127.5) - expect(result[3]).toBe(255) - }) -})