|
| 1 | +import { ENUM, RANGE } from "../constants/controlTypes"; |
| 2 | +import { cloneCanvas, logFilterBackend, releasePooledCanvas, takePooledCanvas } from "../utils/index"; |
| 3 | +import { encodeHam6Scanline } from "./retroHardwareCodecs"; |
| 4 | +import { defineFilter, type FilterCanvas, type FilterOptionValues } from "./types"; |
| 5 | + |
| 6 | +const STANDARD_NTSC = "NTSC"; |
| 7 | +const STANDARD_PAL = "PAL"; |
| 8 | +const PALETTE_ADAPTIVE = "ADAPTIVE"; |
| 9 | +const PALETTE_OCS = "OCS"; |
| 10 | + |
| 11 | +export const optionTypes = { |
| 12 | + standard: { |
| 13 | + type: ENUM, |
| 14 | + options: [ |
| 15 | + { name: "NTSC 320×200", value: STANDARD_NTSC }, |
| 16 | + { name: "PAL 320×256", value: STANDARD_PAL }, |
| 17 | + ], |
| 18 | + default: STANDARD_NTSC, |
| 19 | + desc: "Nominal low-resolution Amiga display geometry used for HAM6 encoding", |
| 20 | + }, |
| 21 | + paletteMode: { |
| 22 | + type: ENUM, |
| 23 | + options: [ |
| 24 | + { name: "Adaptive 16 registers", value: PALETTE_ADAPTIVE }, |
| 25 | + { name: "OCS demonstration palette", value: PALETTE_OCS }, |
| 26 | + ], |
| 27 | + default: PALETTE_ADAPTIVE, |
| 28 | + desc: "Six-plane HAM reserves direct opcodes for 16 base color registers; adaptive mode derives them from the image", |
| 29 | + }, |
| 30 | + paletteIterations: { type: RANGE, range: [1, 12], step: 1, default: 6, desc: "Deterministic clustering passes used to fill the 16 direct-color registers" }, |
| 31 | +}; |
| 32 | + |
| 33 | +export const defaults = { |
| 34 | + standard: optionTypes.standard.default, |
| 35 | + paletteMode: optionTypes.paletteMode.default, |
| 36 | + paletteIterations: optionTypes.paletteIterations.default, |
| 37 | +}; |
| 38 | + |
| 39 | +type Ham6Options = FilterOptionValues & Partial<typeof defaults>; |
| 40 | + |
| 41 | +const OCS_PALETTE = [ |
| 42 | + 0x000, 0xfff, 0x05a, 0xf80, 0x08f, 0x0c0, 0xf00, 0x0dd, |
| 43 | + 0x06f, 0x0a0, 0x608, 0xc30, 0x777, 0xaaa, 0xddd, 0x444, |
| 44 | +]; |
| 45 | + |
| 46 | +const packedPalette = (colors: number[]): Uint8Array => Uint8Array.from(colors.flatMap((color) => [ |
| 47 | + (color >>> 8 & 15) * 17, |
| 48 | + (color >>> 4 & 15) * 17, |
| 49 | + (color & 15) * 17, |
| 50 | + 255, |
| 51 | +])); |
| 52 | + |
| 53 | +const quantize4 = (value: number): number => Math.max(0, Math.min(255, Math.round(value / 17) * 17)); |
| 54 | + |
| 55 | +export const buildHam6Palette = (pixels: Uint8ClampedArray, iterations = 6): Uint8Array => { |
| 56 | + const count = Math.floor(pixels.length / 4); |
| 57 | + if (count < 1) return packedPalette(OCS_PALETTE); |
| 58 | + const stride = Math.max(1, Math.floor(count / 4096)); |
| 59 | + const samples: number[][] = []; |
| 60 | + for (let pixel = 0; pixel < count; pixel += stride) { |
| 61 | + const offset = pixel * 4; |
| 62 | + samples.push([pixels[offset] ?? 0, pixels[offset + 1] ?? 0, pixels[offset + 2] ?? 0]); |
| 63 | + } |
| 64 | + samples.sort((a, b) => (a[0]! * 3 + a[1]! * 6 + a[2]!) - (b[0]! * 3 + b[1]! * 6 + b[2]!)); |
| 65 | + const centroids = Array.from({ length: 16 }, (_, index) => { |
| 66 | + const sample = samples[Math.min(samples.length - 1, Math.floor((index + 0.5) * samples.length / 16))]!; |
| 67 | + return [...sample]; |
| 68 | + }); |
| 69 | + const passes = Math.max(1, Math.min(12, Math.round(iterations))); |
| 70 | + for (let pass = 0; pass < passes; pass++) { |
| 71 | + const sums = Array.from({ length: 16 }, () => [0, 0, 0, 0]); |
| 72 | + for (const sample of samples) { |
| 73 | + let best = 0; |
| 74 | + let bestDistance = Number.POSITIVE_INFINITY; |
| 75 | + for (let index = 0; index < centroids.length; index++) { |
| 76 | + const center = centroids[index]!; |
| 77 | + const dr = sample[0]! - center[0]!; |
| 78 | + const dg = sample[1]! - center[1]!; |
| 79 | + const db = sample[2]! - center[2]!; |
| 80 | + const distance = dr * dr + dg * dg + db * db; |
| 81 | + if (distance < bestDistance) { |
| 82 | + bestDistance = distance; |
| 83 | + best = index; |
| 84 | + } |
| 85 | + } |
| 86 | + const sum = sums[best]!; |
| 87 | + sum[0]! += sample[0]!; |
| 88 | + sum[1]! += sample[1]!; |
| 89 | + sum[2]! += sample[2]!; |
| 90 | + sum[3]! += 1; |
| 91 | + } |
| 92 | + for (let index = 0; index < centroids.length; index++) { |
| 93 | + const sum = sums[index]!; |
| 94 | + if (sum[3]! > 0) centroids[index] = [sum[0]! / sum[3]!, sum[1]! / sum[3]!, sum[2]! / sum[3]!]; |
| 95 | + } |
| 96 | + } |
| 97 | + centroids.sort((a, b) => (a[0]! * 3 + a[1]! * 6 + a[2]!) - (b[0]! * 3 + b[1]! * 6 + b[2]!)); |
| 98 | + return Uint8Array.from(centroids.flatMap((color) => [ |
| 99 | + quantize4(color[0]!), quantize4(color[1]!), quantize4(color[2]!), 255, |
| 100 | + ])); |
| 101 | +}; |
| 102 | + |
| 103 | +const amigaHam6 = (input: FilterCanvas, options: Ham6Options = defaults): FilterCanvas => { |
| 104 | + if (input.width < 1 || input.height < 1) return input; |
| 105 | + const width = 320; |
| 106 | + const height = options.standard === STANDARD_PAL ? 256 : 200; |
| 107 | + const reduced = takePooledCanvas(width, height); |
| 108 | + const reducedContext = reduced.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; |
| 109 | + if (!reducedContext) { |
| 110 | + releasePooledCanvas(reduced); |
| 111 | + return input; |
| 112 | + } |
| 113 | + reducedContext.imageSmoothingEnabled = true; |
| 114 | + reducedContext.drawImage(input as CanvasImageSource, 0, 0, width, height); |
| 115 | + const image = reducedContext.getImageData(0, 0, width, height); |
| 116 | + const iterations = Math.max(1, Math.min(12, Math.round(Number(options.paletteIterations) || defaults.paletteIterations))); |
| 117 | + const palette = options.paletteMode === PALETTE_OCS |
| 118 | + ? packedPalette(OCS_PALETTE) |
| 119 | + : buildHam6Palette(image.data, iterations); |
| 120 | + |
| 121 | + const row = new Uint8Array(width * 3); |
| 122 | + for (let y = 0; y < height; y++) { |
| 123 | + for (let x = 0; x < width; x++) { |
| 124 | + const sourceOffset = (y * width + x) * 4; |
| 125 | + const rowOffset = x * 3; |
| 126 | + row[rowOffset] = image.data[sourceOffset] ?? 0; |
| 127 | + row[rowOffset + 1] = image.data[sourceOffset + 1] ?? 0; |
| 128 | + row[rowOffset + 2] = image.data[sourceOffset + 2] ?? 0; |
| 129 | + } |
| 130 | + const encoded = encodeHam6Scanline(row, palette); |
| 131 | + image.data.set(encoded.output, y * width * 4); |
| 132 | + } |
| 133 | + reducedContext.putImageData(image, 0, 0); |
| 134 | + |
| 135 | + const output = cloneCanvas(input, false); |
| 136 | + const outputContext = output.getContext("2d"); |
| 137 | + if (!outputContext) { |
| 138 | + releasePooledCanvas(reduced); |
| 139 | + return input; |
| 140 | + } |
| 141 | + outputContext.imageSmoothingEnabled = false; |
| 142 | + outputContext.drawImage(reduced, 0, 0, output.width, output.height); |
| 143 | + releasePooledCanvas(reduced); |
| 144 | + logFilterBackend("Amiga HAM6", "JavaScript", `${width}x${height} sequential hold-and-modify scanlines`); |
| 145 | + return output; |
| 146 | +}; |
| 147 | + |
| 148 | +export default defineFilter({ |
| 149 | + name: "Amiga HAM6", |
| 150 | + func: amigaHam6, |
| 151 | + optionTypes, |
| 152 | + defaults, |
| 153 | + options: defaults, |
| 154 | + description: "Amiga OCS six-plane hold-and-modify encoding with legal direct, red, green, and blue opcodes", |
| 155 | + noGL: "HAM6 output is a left-to-right state machine: each modify opcode changes the color held by the next pixel", |
| 156 | +}); |
0 commit comments