playmov: coloured ascii mode

This commit is contained in:
minjaesong
2026-06-07 22:38:41 +09:00
parent ce45929c4e
commit c8fc363445
7 changed files with 166 additions and 18 deletions

View File

@@ -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 0239, white corner at
// 239) plus a 15-step grey ramp (indices 240254 = 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 // 0255 value → cube level index
let _greyIdx = null, _greyVal = null // 0255 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

View File

@@ -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" }
},

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) },

View File

@@ -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

View File

@@ -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) },