<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Block video encoding test</title> <style type="text/css"> .stretchy { width: 320px; height: 192px; object-fit: fill; image-rendering: pixelated; } </style> </head> <body> <h1>Block video encoding test</h1> <p> Test work for a video encoding using Atari 800-family GTIA grayscale mode on top of text cells. This allows a "block dictionary" of 128 2x8-pixel blocks on an 80x160 grayscale image, at cost of 1840 bytes per full frame or 800 bytes to reuse previous blocks. </p> <p> Currently just converts to grayscale and counts up unique blocks. Next step: decimate if > 128 unique blocks per image, and combine the most similar blocks in the output. </p> <h2>Source video</h2> <div> <video id="source" src="llamigos.webm" class="stretchy" muted controls playsinline></video> </div> <h2>Work canvas</h2> <div> <canvas id="work" width="80" height="160" class="stretchy"></canvas> <canvas id="font" width="80" height="160" class="stretchy"></canvas> </div> <div> <span id="block-count">n/a</span> unique blocks per frame </div> <script type="text/javascript"> let width = 80; let height = 160; let blockWidth = 2; let blockHeight = 8; let widthBlocks = width / blockWidth; let heightBlocks = height / blockHeight; function fromSRGB(val) { if (val <= 0.04045) { return val / 12.92; } return ((val + 0.055) / 1.055) ** 2.4; } function toSRGB(val) { if (val <= 0.0031308) { return val * 12.92; } return (val * 1.055) ** (1.0 / 2.4) - 0.055; } function luma(r, g, b) { return r * 0.299 + g * 0.586 + b * 0.114; } function hexify(block) { return Array.from(block).map((n) => n.toString(16)).join(''); } function inverse(block) { return block.map((n) => ~n & 0xf); } function update() { let ctx = work.getContext('2d'); let pixels = new Uint8Array(width * height); // Extract the luma ctx.drawImage(source, 0, 0); let bits = ctx.getImageData(0, 0, width, height); let data = bits.data; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { let i = y * width + x; let r = fromSRGB(data[i * 4] / 255); let g = fromSRGB(data[i * 4 + 1] / 255); let b = fromSRGB(data[i * 4 + 2] / 255); let grayLinear = luma(r, g, b); let gray = toSRGB(grayLinear); let gray16 = Math.round(gray * 15); pixels[i] = gray16; } } let blocks = []; let chars = new Uint16Array(widthBlocks * heightBlocks); for (y = 0; y < heightBlocks; y++) { for (let x = 0; x < widthBlocks; x++) { let i = y * widthBlocks + x; blocks[i] = new Uint8Array(blockWidth * blockHeight); chars[i] = i; for (let yy = 0; yy < blockHeight; yy++) { for (let xx = 0; xx < blockWidth; xx++) { let ii = yy * blockWidth + xx; blocks[i][ii] = pixels[(y * blockHeight + yy) * width + (x * blockWidth + xx)]; } } } } // Now we have 800 blocks for 80x160 image // But we can only use 128 + their mirror images // // First pass: uniques extraction // Convert the 4bpp pixel indices into hex strings let blockMap = {}; let uniques = []; /* for (let i = 0; i < chars.length; i++) { let char = chars[i]; let block = blocks[char]; let key = hexify(blocks[i]); let keyInverse = hexify(inverse(block)); if (blockMap[key]) { char = blockMap[key]; } else if (blockMap[keyInverse]) { char = blockMap[keyInverse]; } else { char = uniques.push(block) - 1; blockMap[key] = char; blockMap[keyInverse] = char; } chars[i] = char; } */ for (let threshold = 0; threshold < 16; threshold++) { charIter: for (let i = 0; i < chars.length; i++) { let char = chars[i]; let block = blocks[char]; if (!block) { debugger throw new Error('missing block'); } fontMatch: for (let j = 0; j < uniques.length; j++) { let other = uniques[j]; if (!block) { debugger throw new Error('missing other'); } for (let k = 0; k < blockWidth * blockHeight; k++) { if (Math.abs(block[k] - other[k]) > threshold) { continue fontMatch; } } // we're close enough to reuse a character chars[i] = j; continue charIter; } // add a new char chars[i] = uniques.push(block) - 1; } if (uniques.length < 128) { break; } // We need to decimate further blocks = uniques; uniques = []; } let span = document.querySelector('#block-count'); span.textContent = `${uniques.length}`; // Font (currently wrong! :D) let fontCtx = document.querySelector('#font').getContext('2d'); let font = fontCtx.createImageData(16 * blockWidth, 16 * blockHeight); for (let hi = 0; hi < 16; hi++) { for (let lo = 0; lo < 16; lo++) { let char = (hi << 4) | lo; let invert = Boolean(char & 0x80); char &= 0x7f; if (char >= uniques.length) { continue; } let block = uniques[char]; for (let y = 0; y < blockHeight; y++) { for (let x = 0; x < blockWidth; x++) { let i = y * blockWidth + x; let ii = (y + hi * blockHeight) * 16 * blockWidth + (x + lo * blockWidth); if (block.length < i) { debugger; } let gray16 = block[i]; if (invert) { gray16 = ~gray16 & 0x0f; } let gray256 = Math.round(gray16 * 255 / 15); font.data[ii * 4] = gray256; font.data[ii * 4 + 1] = gray256; font.data[ii * 4 + 2] = gray256; font.data[ii * 4 + 3] = 255; } } } } fontCtx.putImageData(font, 0, 0); // Redraw the blocks for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { let i = y * width + x; let gray16 = pixels[i]; let gray256 = Math.round(gray16 * 255 / 15); data[i * 4] = gray256; data[i * 4 + 1] = gray256; data[i * 4 + 2] = gray256; } } ctx.putImageData(bits, 0, 0); } let timer = null; source.addEventListener('playing', () => { if (!timer) { timer = setInterval(update, 1000 / 10); } update(); }); source.addEventListener('pause', () => { if (timer) { clearInterval(timer); timer = null; } }); </script> </body> </html>