From c8fc363445c94b7c6281895b4661864d5e5a8ff6 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sun, 7 Jun 2026 22:38:41 +0900 Subject: [PATCH] playmov: coloured ascii mode --- assets/disk0/tvdos/bin/playmov.js | 136 +++++++++++++++--- assets/disk0/tvdos/bin/playmov.js.synopsis | 3 +- assets/disk0/tvdos/include/mediadec.mjs | 1 + .../disk0/tvdos/include/mediadec_common.mjs | 38 ++++- assets/disk0/tvdos/include/mediadec_ipf.mjs | 2 + assets/disk0/tvdos/include/mediadec_tav.mjs | 2 + assets/disk0/tvdos/include/mediadec_tev.mjs | 2 + 7 files changed, 166 insertions(+), 18 deletions(-) diff --git a/assets/disk0/tvdos/bin/playmov.js b/assets/disk0/tvdos/bin/playmov.js index daad64b..ac41546 100644 --- a/assets/disk0/tvdos/bin/playmov.js +++ b/assets/disk0/tvdos/bin/playmov.js @@ -11,13 +11,14 @@ // [draw] dec.blit() (graphics) OR sampleGray + aa.mjs (ASCII), // then subtitle overlay + playgui chrome // -// Usage: playmov FILE [-i] [-ascii] [-deblock] [-boundaryaware] +// Usage: playmov FILE [-i] [-ascii] [-colour] [-deblock] [-boundaryaware] // [-deinterlace=yadif|bwdif] [-debug-mv] // -i interactive (controls + on-screen chrome) // -ascii start in ASCII-render mode (proves the framebuffer flow; aa.mjs) +// -colour colourise ASCII glyphs from the video (implies -ascii); -color alias // (others forwarded to the TEV backend, matching playtev) // Controls: Bksp quit | Space pause | Left/Right seek | Up/Down volume -// PgUp/PgDn cue prev/next | A toggle ASCII +// PgUp/PgDn cue prev/next | A toggle ASCII | C toggle colour const mediadec = require("mediadec") const gui = require("playgui") @@ -40,29 +41,120 @@ const COL_TRANSPARENT = 255 const COL_PURE_BLACK = 240 const GUI_BG = 0 -// Text back-plane addressing (mirrors aa.mjs _TA_BACK / _TA_BASE), VT-aware. +// Text fore/back-plane addressing (mirrors aa.mjs _TA_FORE / _TA_BACK / _TA_BASE), +// VT-aware. +const TXT_FORE_OFF = 2 const TXT_BACK_OFF = 2562 const TXT_AREA_BASE = 253950 -const asciiBackFill = new Uint8Array(80 * 32).fill(COL_PURE_BLACK) +const AA_W = 80, AA_H = 32 +const asciiBackFill = new Uint8Array(AA_W * AA_H).fill(COL_PURE_BLACK) + +// Resolve the address of text-area byte `off` for the current environment +// (VT pane: forward from VT_TEXT_PLANE; physical: backward from the GPU base), +// exactly as aa.mjs's _va() does, so writes land in the same plane aa.flush uses. +function txtAddr(off) { + if (typeof globalThis.VT_TEXT_PLANE !== 'undefined') + return globalThis.VT_TEXT_PLANE + off + return graphics.getGpuMemBase() - TXT_AREA_BASE - off +} // Overwrite every text cell's background with opaque pure-black (240), so ASCII // glyphs sit on solid black instead of aa.mjs's transparent (255) cells. function paintAsciiBgOpaque() { - if (typeof globalThis.VT_TEXT_PLANE !== 'undefined') - sys.pokeBytes(globalThis.VT_TEXT_PLANE + TXT_BACK_OFF, asciiBackFill, asciiBackFill.length) - else - sys.pokeBytes(graphics.getGpuMemBase() - TXT_AREA_BASE - TXT_BACK_OFF, asciiBackFill, asciiBackFill.length) + sys.pokeBytes(txtAddr(TXT_BACK_OFF), asciiBackFill, asciiBackFill.length) +} + +// ── Colour postprocessor (-colour) ─────────────────────────────────────────── +// AAlib chooses each glyph from brightness; colour mode additionally tints the +// glyph's FOREGROUND (never the background) with the nearest opaque colour of +// the TSVM 256-palette, sampled from the video's RGB plane. +// +// That palette is a *separable* 6×8×5 RGB cube (indices 0–239, white corner at +// 239) plus a 15-step grey ramp (indices 240–254 = 0,17,…,238; index 255 is +// always transparent and cube index 0 is translucent, so both are excluded as +// ink). Because the cube is separable, its nearest entry is just the independent +// nearest level per channel; the global nearest opaque colour is then whichever +// of {best cube, best grey} is closer — all via small precomputed LUTs, O(1)/cell. +const CUBE_R = [0, 51, 102, 153, 204, 255] +const CUBE_G = [0, 34, 68, 102, 153, 187, 221, 255] +const CUBE_B = [0, 68, 136, 187, 255] + +let _rNear = null, _gNear = null, _bNear = null // 0–255 value → cube level index +let _greyIdx = null, _greyVal = null // 0–255 mean → grey palette idx / value +const colourBuf = new Uint8Array(AA_W * AA_H * 3) // sampled R,G,B per cell +const foreBuf = new Uint8Array(AA_W * AA_H) // resolved palette ink per cell + +function _nearestLevel(levels) { + const lut = new Uint8Array(256) + for (let v = 0; v < 256; v++) { + let best = 0, bestD = 1e9 + for (let k = 0; k < levels.length; k++) { + const d = Math.abs(v - levels[k]) + if (d < bestD) { bestD = d; best = k } + } + lut[v] = best + } + return lut +} + +function ensureColourLuts() { + if (_rNear) return + _rNear = _nearestLevel(CUBE_R) + _gNear = _nearestLevel(CUBE_G) + _bNear = _nearestLevel(CUBE_B) + // Grey-ramp candidates: palette idx 240+k holds grey value 17·k, k = 0..14 + // (idx 240 = black … 254 = 238; idx 255 is transparent, so it is excluded). + const gv = [], gi = [] + for (let k = 0; k < 15; k++) { gv.push(17 * k); gi.push(240 + k) } + _greyIdx = new Uint8Array(256) + _greyVal = new Uint8Array(256) + for (let m = 0; m < 256; m++) { + let best = 0, bestD = 1e9 + for (let k = 0; k < gv.length; k++) { + const d = Math.abs(m - gv[k]) + if (d < bestD) { bestD = d; best = k } + } + _greyIdx[m] = gi[best]; _greyVal[m] = gv[best] + } +} + +function nearestPaletteIndex(r, g, b) { + const ri = _rNear[r], gi = _gNear[g], bi = _bNear[b] + const cr = CUBE_R[ri], cg = CUBE_G[gi], cb = CUBE_B[bi] + const dCube = (r - cr) * (r - cr) + (g - cg) * (g - cg) + (b - cb) * (b - cb) + // Nearest grey level sits at the rounded mean of the channels (the vertex of + // the achromatic-distance parabola); rounding — not flooring — makes the + // {cube vs grey} pick the exact global nearest opaque palette entry. + const m = ((r + g + b) / 3 + 0.5) | 0 + const gvv = _greyVal[m] + const dGrey = (r - gvv) * (r - gvv) + (g - gvv) * (g - gvv) + (b - gvv) * (b - gvv) + // Prefer grey on ties (so near-black resolves to opaque grey idx 240, not the + // translucent cube corner); `|| 240` is a belt-and-braces guard for idx 0. + const cubeIdx = ri * 40 + gi * 5 + bi + return (dGrey <= dCube) ? _greyIdx[m] : (cubeIdx || 240) +} + +// Sample the frame's colour per cell, map to nearest palette ink, and write the +// foreground plane (over what aa.flush wrote). Background is left to +// paintAsciiBgOpaque(); only the FG is colourised, per spec. +function applyColourFore(dec) { + dec.sampleColour(colourBuf, AA_W, AA_H) + for (let i = 0, n = AA_W * AA_H; i < n; i++) + foreBuf[i] = nearestPaletteIndex(colourBuf[i * 3], colourBuf[i * 3 + 1], colourBuf[i * 3 + 2]) + sys.pokeBytes(txtAddr(TXT_FORE_OFF), foreBuf, foreBuf.length) } // ── Parse args ─────────────────────────────────────────────────────────────── let interactive = false let asciiMode = false +let colourMode = false const decOpts = { interactive: false, deinterlaceAlgorithm: "yadif" } for (let i = 2; i < exec_args.length; i++) { const arg = ("" + exec_args[i]).toLowerCase() if (arg === "-i") { interactive = true; decOpts.interactive = true } else if (arg === "-ascii") asciiMode = true + else if (arg === "-colour" || arg === "-color") { asciiMode = true; colourMode = true } else if (arg === "-debug-mv") decOpts.debugMotionVectors = true else if (arg === "-deblock") decOpts.enableDeblocking = true else if (arg === "-boundaryaware") decOpts.enableBoundaryAwareDecoding = true @@ -73,13 +165,14 @@ for (let i = 2; i < exec_args.length; i++) { } } -// Graceful degradation: ASCII mode needs aa.mjs. +// Graceful degradation: ASCII (and therefore colour) mode needs aa.mjs. if (asciiMode && !aa) { - serial.println("playmov: aa.mjs not found; ASCII mode unavailable, -ascii ignored") + serial.println("playmov: aa.mjs not found; ASCII mode unavailable, -ascii/-colour ignored") asciiMode = false + colourMode = false } -if (!exec_args[1]) { printerrln("usage: playmov FILE [-i] [-ascii] [options]"); return 1 } +if (!exec_args[1]) { printerrln("usage: playmov FILE [-i] [-ascii] [-colour] [options]"); return 1 } const fullPath = _G.shell.resolvePathInput(exec_args[1]).full // ── ASCII-render state (aa.mjs) — lazily initialised on first use ──────────── @@ -88,9 +181,10 @@ let aaParams = null function ensureAscii() { if (aaCtx) return const font = aa.loadChrFontROM(AA_FONT_PATH) - aaCtx = aa.init(80, 32, { font: font }) + aaCtx = aa.init(AA_W, AA_H, { font: font }) aaParams = aa.getrenderparams() aaParams.dither = aa.AA_FLOYD_S + ensureColourLuts() // cheap; keeps the C-key colour toggle ready } // ── Open ───────────────────────────────────────────────────────────────────── @@ -156,16 +250,25 @@ try { else exitAsciiVisual() } + // Colour only affects the foreground plane and is re-applied every drawn + // frame, so toggling it just flips the flag; the next flush+draw reverts the + // ink to aa.mjs's grey when off. Ensure the LUTs exist if A was never pressed. + function toggleColour() { + if (!aaCtx) ensureColourLuts() + colourMode = !colourMode + } + // ── Input ───────────────────────────────────────────────────────────────── // Bksp is hold-to-quit (like the old players); everything else is edge- - // triggered so a held key fires once. Quit + ASCII toggle work even without - // -i; the rest of the transport is interactive-only. + // triggered so a held key fires once. Quit + ASCII/colour toggles work even + // without -i; the rest of the transport is interactive-only. function readInput() { sys.poke(-40, 1) const key = sys.peek(-41) if (key == K.BACKSPACE) { quit = true; return } if (key && key !== lastKey) { - if (key == K.A) { if (aa) toggleAscii() } // inert when aa.mjs is absent + if (key == K.A) { if (aa) toggleAscii() } // inert when aa.mjs is absent + else if (key == K.C) { if (aa) toggleColour() } // colour shows only while in ASCII else if (interactive) { switch (key) { case K.SPACE: dec.pause(!dec.isPaused()); break @@ -186,10 +289,11 @@ try { if (asciiMode) { // Sample the frame off the framebuffer, then cover the picture with // solid-black (240) text cells — cheaper than clearing the pixel planes. - dec.blit() // frame -> framebuffer (so sampleGray can read it) + dec.blit() // frame -> framebuffer (so sample* can read it) dec.sampleGray(aaCtx.imagebuffer, aaCtx.imgW, aaCtx.imgH) aa.render(aaCtx, aaParams) aa.flush(aaCtx) + if (colourMode) applyColourFore(dec) // recolour the FG plane from the video's RGB paintAsciiBgOpaque() // cover with opaque 240 (not transparent 255) } else { dec.blit() // copy the frame to the framebuffer diff --git a/assets/disk0/tvdos/bin/playmov.js.synopsis b/assets/disk0/tvdos/bin/playmov.js.synopsis index 7ccef77..6662fce 100644 --- a/assets/disk0/tvdos/bin/playmov.js.synopsis +++ b/assets/disk0/tvdos/bin/playmov.js.synopsis @@ -5,6 +5,7 @@ "symbols": { "interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (controls + on-screen info)" }, "ascii": { "kind": "option", "long": "-ascii", "summary": "Start in ASCII-render mode" }, + "colour": { "kind": "option", "long": "-colour", "summary": "Colourise ASCII glyphs from the video (implies -ascii); -color alias" }, "deblock": { "kind": "option", "long": "-deblock", "summary": "TEV: enable deblocking filter" }, "boundaryAware": { "kind": "option", "long": "-boundaryaware", "summary": "TEV: boundary-aware decoding" }, "debugMv": { "kind": "option", "long": "-debug-mv", "summary": "TEV: show motion-vector debug overlay" }, @@ -23,7 +24,7 @@ "options": { "kind": "group", "summary": "Options", - "members": ["interactive", "ascii", "deblock", "boundaryAware", "debugMv", "deinterlace", "filmGrain"] + "members": ["interactive", "ascii", "colour", "deblock", "boundaryAware", "debugMv", "deinterlace", "filmGrain"] }, "file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to play" } }, diff --git a/assets/disk0/tvdos/include/mediadec.mjs b/assets/disk0/tvdos/include/mediadec.mjs index 49521a1..65543c0 100644 --- a/assets/disk0/tvdos/include/mediadec.mjs +++ b/assets/disk0/tvdos/include/mediadec.mjs @@ -21,6 +21,7 @@ * .step() -> { type:'frame'|'idle'|'eof'|'newfile'|'error', frameCount } * .blit() present the current native frame to the screen * .sampleGray(dst,w,h) fill an ASCII brightness buffer from the framebuffer + * .sampleColour(dst,w,h) fill a per-cell RGB buffer (w*h*3) from the framebuffer * .subtitle {visible,text,position,useUnicode,dirty} (resolved by the lib) * .pause(b)/.isPaused() .setVolume(v)/.getVolume() * .seekSeconds(n) .cue(d) .cues diff --git a/assets/disk0/tvdos/include/mediadec_common.mjs b/assets/disk0/tvdos/include/mediadec_common.mjs index b73b509..3f67f78 100644 --- a/assets/disk0/tvdos/include/mediadec_common.mjs +++ b/assets/disk0/tvdos/include/mediadec_common.mjs @@ -360,6 +360,42 @@ function sampleGrayScreen(width, height, dst, dstW, dstH, mode) { } } +// ── sampleColour source ────────────────────────────────────────────────────── +// Companion to sampleGrayScreen: fill an RGB buffer (dst, length dstW·dstH·3, +// laid out R,G,B per cell) by point-sampling the GPU framebuffer at the CENTRE +// of each cell. Used by the player's colour-ASCII postprocessor — aa.mjs picks +// each glyph from brightness, this supplies the per-cell ink colour. Same +// backend-specific `mode` (4/5/8-bpp unpacking) and same cheap ~dstW·dstH peek +// count as sampleGrayScreen. +function sampleColourScreen(width, height, dst, dstW, dstH, mode) { + for (let y = 0; y < dstH; y++) { + let sy = ((y + 0.5) * height / dstH) | 0 + if (sy >= height) sy = height - 1 + let dstRow = y * dstW * 3 + for (let x = 0; x < dstW; x++) { + let sx = ((x + 0.5) * width / dstW) | 0 + if (sx >= width) sx = width - 1 + let off = sy * 560 + sx + let fb1 = sys.peek(DISP_RG - off) & 255 + let fb2 = sys.peek(DISP_BA - off) & 255 + let r, g, b + if (mode == 5) { + r = ((fb1 >>> 2) & 31) * 255 / 31 + g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31 + b = (fb2 & 31) * 255 / 31 + } else if (mode == 8) { + r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255 + } else { // mode 4 + r = (fb1 >>> 4) * 17 + g = (fb1 & 15) * 17 + b = (fb2 >>> 4) * 17 + } + let di = dstRow + x * 3 + dst[di] = r | 0; dst[di + 1] = g | 0; dst[di + 2] = b | 0 + } + } +} + exports = { MAGIC_MOV, MAGIC_TEV, MAGIC_TAV, MAGIC_TAP, MAGIC_UCF, MP2_FRAME_SIZE, QLUT, @@ -369,5 +405,5 @@ exports = { openSeqread, readMagic, detectFormat, magicEquals, luma8, makeAudioRouter, makeSubtitleEngine, makeBias, - sampleGrayScreen + sampleGrayScreen, sampleColourScreen } diff --git a/assets/disk0/tvdos/include/mediadec_ipf.mjs b/assets/disk0/tvdos/include/mediadec_ipf.mjs index 3c48535..31794f7 100644 --- a/assets/disk0/tvdos/include/mediadec_ipf.mjs +++ b/assets/disk0/tvdos/include/mediadec_ipf.mjs @@ -150,6 +150,7 @@ function create(magic, sr, fileLength, opts, common) { // Frame is already on the display planes, so the player can sample the screen. function sampleGray(dst, w, h) { common.sampleGrayScreen(width, height, dst, w, h, 4) } + function sampleColour(dst, w, h) { common.sampleColourScreen(width, height, dst, w, h, 4) } return { info: info, @@ -164,6 +165,7 @@ function create(magic, sr, fileLength, opts, common) { blit: blit, bias() { if (autoBg) applyBias() }, // skipped when an explicit bg packet set the colour sampleGray: sampleGray, + sampleColour: sampleColour, pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } }, isPaused() { return paused }, setVolume(v) { audioR.setVolume(v) }, diff --git a/assets/disk0/tvdos/include/mediadec_tav.mjs b/assets/disk0/tvdos/include/mediadec_tav.mjs index db283d0..3426a65 100644 --- a/assets/disk0/tvdos/include/mediadec_tav.mjs +++ b/assets/disk0/tvdos/include/mediadec_tav.mjs @@ -615,6 +615,7 @@ function create(magic, sr, fileLength, opts, common, isTap) { // Player calls blit() before sampleGray() in ASCII mode, so the framebuffer // already holds the current frame regardless of kind. function sampleGray(dst, w, h) { common.sampleGrayScreen(width, height, dst, w, h, gpuGraphicsMode) } + function sampleColour(dst, w, h) { common.sampleColourScreen(width, height, dst, w, h, gpuGraphicsMode) } // ── TAP still: decode the single image now ────────────────────────────── if (isTap) { @@ -658,6 +659,7 @@ function create(magic, sr, fileLength, opts, common, isTap) { blit: blit, bias() { applyBias() }, sampleGray: sampleGray, + sampleColour: sampleColour, pause(p) { paused = p diff --git a/assets/disk0/tvdos/include/mediadec_tev.mjs b/assets/disk0/tvdos/include/mediadec_tev.mjs index 800775e..a40696e 100644 --- a/assets/disk0/tvdos/include/mediadec_tev.mjs +++ b/assets/disk0/tvdos/include/mediadec_tev.mjs @@ -189,6 +189,7 @@ function create(magic, sr, fileLength, opts, common) { // Player calls blit() (which uploads currentFrameSrc) before sampleGray in // ASCII mode, so we read the framebuffer the upload just produced. function sampleGray(dst, w, h) { common.sampleGrayScreen(width, height, dst, w, h, 4) } + function sampleColour(dst, w, h) { common.sampleColourScreen(width, height, dst, w, h, 4) } return { info: info, @@ -204,6 +205,7 @@ function create(magic, sr, fileLength, opts, common) { blit: blit, bias() { applyBias() }, sampleGray: sampleGray, + sampleColour: sampleColour, pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } }, isPaused() { return paused }, setVolume(v) { audioR.setVolume(v) },