feat: adaptive raw/zlib/delta frame codec (opt-in, backward compatible)

The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:

  0 RAW    framebuffer as-is (legacy)
  1 ZLIB   zlib(framebuffer)
  2 DELTA  only the cells changed since the previous frame, patched on top

Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.

Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.

Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.

Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)
This commit is contained in:
Nate 2026-06-13 02:14:42 -04:00
parent 8c60ef12a0
commit e3f282910d
10 changed files with 478 additions and 10 deletions

View file

@ -0,0 +1,55 @@
/**
* Decode the Python-generated test vectors with the SHIPPED codec.js and verify
* every frame matches the ground-truth framebuffer byte-for-byte.
*
* This exercises the real cross-language risk surface: zlib (Python) ->
* DecompressionStream (JS), little-endian delta indices, and delta patching.
*/
const fs = require('fs');
const path = require('path');
const codec = require('../codec.js');
function readChunks(buf) {
const out = [];
let off = 0;
while (off + 4 <= buf.length) {
const len = buf.readUInt32BE(off); off += 4;
out.push(new Uint8Array(buf.subarray(off, off + len))); off += len;
}
return out;
}
async function checkDir(name) {
const dir = path.join(__dirname, 'vectors', name);
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json')));
const msgs = readChunks(fs.readFileSync(path.join(dir, 'adaptive.bin')));
const truth = readChunks(fs.readFileSync(path.join(dir, 'truth.bin')));
const dec = codec.makeDecoder(meta.cellBytes);
let mismatches = 0, firstBad = null;
for (let i = 0; i < msgs.length; i++) {
const { frame } = await dec.decode(msgs[i]);
const want = truth[i];
if (frame.length !== want.length) { mismatches++; firstBad ??= [i, 'len', want.length, frame.length]; continue; }
for (let j = 0; j < want.length; j++) {
if (frame[j] !== want[j]) { mismatches++; firstBad ??= [i, 'byte@' + j, want[j], frame[j]]; break; }
}
}
const pct = (100 * meta.adaptiveBytes / meta.legacyBytes).toFixed(1);
const status = mismatches === 0 ? 'PASS bit-exact' : `FAIL (${mismatches})`;
console.log(
`${name.padEnd(20)} ${String(msgs.length).padStart(3)} frames ` +
`${status.padEnd(16)} wire ${pct}% of legacy` +
(firstBad ? ` firstBad=${JSON.stringify(firstBad)}` : '')
);
return mismatches === 0;
}
(async () => {
const names = fs.readdirSync(path.join(__dirname, 'vectors'));
console.log('Decoding with codec.js, comparing to ground truth:\n');
let allPass = true;
for (const n of names) allPass = (await checkDir(n)) && allPass;
console.log('\n' + (allPass ? 'ALL VECTORS BIT-EXACT' : 'SOME VECTORS FAILED'));
process.exit(allPass ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(2); });

View file

@ -0,0 +1,60 @@
"""
Generate cross-language test vectors: encode real frames with codec.py exactly
as the server would, and dump both the adaptive messages and the ground-truth
raw framebuffers so codec.js (Node) can decode and verify byte-for-byte.
Output dir layout (experiments/vectors/<name>/):
meta.json {cellBytes, nframes, rows, cols}
adaptive.bin concat of [4B len][message] ... (what the server would send)
truth.bin concat of [4B len][framebuffer] ... (legacy raw bodies)
"""
import os, sys, json, struct
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ascii_video_player2 import VideoDecoder, AsciiMapper
from codec import encode_frame
def gen(path, name, mode, pixel, cols=200, rows=80, limit=90, tol=0):
mapper = AsciiMapper(); qb = {5:0,4:2,3:3,2:5}.get(mode,0)
lut = np.array([ord(c) for c in mapper._lut], np.uint8)
dec = VideoDecoder(path, cols, rows, skip_gray=pixel)
outdir = os.path.join("experiments/vectors", name); os.makedirs(outdir, exist_ok=True)
fa = open(os.path.join(outdir,"adaptive.bin"),"wb")
ft = open(os.path.join(outdir,"truth.bin"),"wb")
prev = None; n = 0; raw_total = adapt_total = 0
for gray, bgr in dec:
if pixel:
frame = np.ascontiguousarray(bgr) # (rows,cols,3) BGR
else:
idx = np.floor_divide(gray, max(1,256//mapper._n)); np.clip(idx,0,mapper._n-1,out=idx)
rgb = bgr[:,:,::-1]
if qb: rgb = (rgb>>qb)<<qb
frame = np.empty((rows,cols,4),np.uint8); frame[:,:,0]=lut[idx]; frame[:,:,1:]=rgb
msg, prev = encode_frame(frame, prev, n, tolerance=tol)
# Truth = the encoder's intended frame (prev/shown), which for lossy is
# the bounded approximation the client must reconstruct exactly.
body = prev.tobytes()
fa.write(struct.pack(">I", len(msg))); fa.write(msg)
ft.write(struct.pack(">I", len(body))); ft.write(body)
raw_total += 4 + len(body); adapt_total += len(msg)
n += 1
if n >= limit: break
dec.release(); fa.close(); ft.close()
cell = 3 if pixel else 4
json.dump({"cellBytes":cell,"nframes":n,"rows":rows,"cols":cols,
"legacyBytes":raw_total,"adaptiveBytes":adapt_total},
open(os.path.join(outdir,"meta.json"),"w"))
print(f"{name:28} {n} frames legacy={raw_total/1024:7.0f}KB "
f"adaptive={adapt_total/1024:6.0f}KB ({adapt_total/raw_total:5.1%})")
print("Generating test vectors (Python encoder):\n")
# lossless (must decode bit-exact to the true frame)
gen("videos/bars.mp4", "bars_color_m5", mode=5, pixel=False)
gen("videos/test.mp4", "test_color_m5", mode=5, pixel=False)
gen("videos/mandel.mp4", "mandel_color_m3", mode=3, pixel=False)
gen("videos/bars.mp4", "bars_pixel", mode=5, pixel=True)
gen("videos/test.mp4", "test_pixel", mode=5, pixel=True)
# lossy (must decode bit-exact to the encoder's bounded approximation)
gen("videos/test.mp4", "test_color_T8", mode=5, pixel=False, tol=8)
gen("videos/mandel.mp4", "mandel_color_T8", mode=3, pixel=False, tol=8)
gen("videos/test.mp4", "test_pixel_T8", mode=5, pixel=True, tol=8)

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Generate the synthetic test clips the test suite uses (ffmpeg lavfi sources).
# Deterministic and dependency-free so CI and local runs match.
set -eu
cd "$(dirname "$0")/.."
mkdir -p videos
ff(){ ffmpeg -y -loglevel error "$@"; }
ff -f lavfi -i "testsrc2=size=640x360:rate=30" -f lavfi -i "sine=frequency=440:duration=6" \
-t 6 -pix_fmt yuv420p videos/test.mp4
ff -f lavfi -i "mandelbrot=size=640x480:rate=24:end_scale=0.3" -t 5 -pix_fmt yuv420p videos/mandel.mp4
ff -f lavfi -i "life=size=320x240:rate=24:mold=10:ratio=0.1:death_color=#101030:life_color=#30ff80" \
-t 5 -pix_fmt yuv420p videos/life.mp4
ff -f lavfi -i "smptebars=size=640x360:rate=24" \
-vf "drawtext=text='ASCILINE':fontsize=60:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:box=1:boxcolor=black@0.5" \
-t 4 -pix_fmt yuv420p videos/bars.mp4
echo "generated: $(ls videos/*.mp4 | tr '\n' ' ')"

79
experiments/test_e2e.js Normal file
View file

@ -0,0 +1,79 @@
/**
* End-to-end correctness test across the Python<->JS boundary.
*
* Connects to the live ASCILINE server twice:
* 1. /ws -> legacy raw frames (ground truth)
* 2. /ws?codec=adaptive -> adaptive frames, decoded with the SHIPPED codec.js
*
* Asserts every adaptive-decoded frame is byte-identical to the legacy frame,
* and reports bytes-on-wire savings.
*
* Usage: node experiments/test_e2e.js <port> [maxFrames]
*/
const codec = require('../codec.js');
const PORT = process.argv[2] || '8011';
const MAX = parseInt(process.argv[3] || '60', 10);
function collect(url, { decode }) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
const frames = new Map(); // frameIndex -> Uint8Array
let wireBytes = 0, cellBytes = 4, decoder = null, chain = Promise.resolve();
ws.onmessage = (ev) => {
if (typeof ev.data === 'string') {
if (ev.data.startsWith('INIT:')) {
const p = ev.data.split(':');
const pixel = p.length > 5 && parseInt(p[5]) === 1;
cellBytes = pixel ? 3 : 4;
if (decode) decoder = codec.makeDecoder(cellBytes);
}
return;
}
wireBytes += ev.data.byteLength;
if (decode) {
chain = chain.then(async () => {
const { frameIndex, frame } = await decoder.decode(ev.data);
if (frames.size < MAX) frames.set(frameIndex, frame);
if (frames.size >= MAX) ws.close();
});
} else {
const u = new Uint8Array(ev.data);
const dv = new DataView(ev.data);
const idx = dv.getUint32(0, false);
if (frames.size < MAX) frames.set(idx, u.subarray(4)); // strip 4B header
if (frames.size >= MAX) ws.close();
}
};
ws.onclose = async () => { await chain; resolve({ frames, wireBytes }); };
ws.onerror = (e) => reject(e.error || new Error('ws error'));
});
}
(async () => {
const base = `ws://localhost:${PORT}/ws`;
console.log(`Collecting ${MAX} frames from each stream on port ${PORT}...`);
const legacy = await collect(base, { decode: false });
const adaptive = await collect(base + '?codec=adaptive', { decode: true });
let compared = 0, mismatches = 0, firstBad = null;
for (const [idx, legFrame] of legacy.frames) {
const advFrame = adaptive.frames.get(idx);
if (!advFrame) continue;
compared++;
if (legFrame.length !== advFrame.length) { mismatches++; firstBad ??= [idx, 'len', legFrame.length, advFrame.length]; continue; }
for (let i = 0; i < legFrame.length; i++) {
if (legFrame[i] !== advFrame[i]) { mismatches++; firstBad ??= [idx, 'byte', i, legFrame[i], advFrame[i]]; break; }
}
}
const kb = (x) => (x / 1024).toFixed(0);
console.log(`\nframes compared : ${compared}`);
console.log(`mismatches : ${mismatches} ${mismatches === 0 ? 'PASS (bit-exact)' : 'FAIL'}`);
if (firstBad) console.log(`first mismatch : frame=${firstBad[0]} ${firstBad.slice(1).join(' ')}`);
console.log(`\nwire bytes legacy : ${kb(legacy.wireBytes)} KB`);
console.log(`wire bytes adaptive : ${kb(adaptive.wireBytes)} KB (${(100 * adaptive.wireBytes / legacy.wireBytes).toFixed(1)}% of legacy)`);
process.exit(mismatches === 0 ? 0 : 1);
})().catch((e) => { console.error('ERROR', e); process.exit(2); });