mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-08 22:34:03 +09:00
playmov: coloured ascii mode
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user