Files
tsvm/assets/disk0/tvdos/bin/playmov.js
2026-06-07 22:38:45 +09:00

358 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// playmov — all-in-one movie player (MOV/iPF, TEV, TAV, TAP).
//
// Consolidates playmv1 / playtev / playtav behind one decode library
// (mediadec.mjs) and one simple pipeline:
//
// loop:
// read input (quit / pause / seek / volume / cue / ASCII-toggle)
// [backend] dec.step() -> decode the next due frame into the framebuffer
// [player] hold the frame
// [postprocessor] subtitle state resolved by the library
// [draw] dec.blit() (graphics) OR sampleGray + aa.mjs (ASCII),
// then subtitle overlay + playgui chrome
//
// 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 | C toggle colour
const mediadec = require("mediadec")
const gui = require("playgui")
const K = require("keysym")
// aa.mjs (the ASCII renderer) is OPTIONAL. If it isn't installed, playmov still
// plays everything normally; ASCII mode just isn't available (-ascii is ignored
// and the A key is inert). require() throws when the module is missing, so guard it.
let aa = null
try { aa = require("aa") } catch (e) { aa = null } // hopper/include/aa.mjs
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
const VOL_STEP = 16
// Text-plane palette indices: 0 = GUI background (translucent black), 240 = pure
// opaque black, 255 = transparent (GraphicsAdapter: "palette 255 is always
// transparent"). aa.mjs paints cell backgrounds with 255, so over live graphics
// the picture bleeds through the ASCII; we force opaque 240 instead.
const COL_TRANSPARENT = 255
const COL_PURE_BLACK = 240
const GUI_BG = 0
// 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 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() {
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
else if (arg.startsWith("-deinterlace=")) decOpts.deinterlaceAlgorithm = arg.substring(13)
else if (arg.startsWith("--filter-film-grain")) {
const parts = arg.split(/[=\s]/)
if (parts.length > 1) { const lv = parseInt(parts[1]); if (!isNaN(lv)) decOpts.filmGrainLevel = lv }
}
}
// Graceful degradation: ASCII (and therefore colour) mode needs aa.mjs.
if (asciiMode && !aa) {
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] [-colour] [options]"); return 1 }
const fullPath = _G.shell.resolvePathInput(exec_args[1]).full
// ── ASCII-render state (aa.mjs) — lazily initialised on first use ────────────
let aaCtx = null
let aaParams = null
function ensureAscii() {
if (aaCtx) return
const font = aa.loadChrFontROM(AA_FONT_PATH)
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 ─────────────────────────────────────────────────────────────────────
let [cy, cx] = con.getyx()
let errorlevel = 0
let dec = null
let stage = "open" // breadcrumb for the error log
try {
dec = mediadec.open(fullPath, decOpts)
const info = dec.info
// NB: palette 0 is translucent black by default — exactly what the playgui
// chrome (bg colour 0) wants — so we never redefine it. (Backends must not
// either, or the chrome turns opaque for the next file played.)
if (info.isStill) { con.move(1, 1); println("Push and hold Backspace to exit") }
let startNs = 0
let lastKey = 0
let quit = false
// Build the playgui status object for the on-screen chrome.
function status() {
const usingCues = dec.cues && dec.cues.length > 0
const akku = startNs ? (sys.nanoTime() - startNs) / 1000000000.0 : 0.0001
return {
fps: info.fps,
videoRate: dec.videoRate | 0,
frameCount: dec.frameCount,
totalFrames: info.totalFrames,
frameMode: dec.frameMode,
qY: dec.qY || 0, qCo: dec.qCo || 0, qCg: dec.qCg || 0,
akku: akku,
fileName: usingCues ? dec.cues[dec.currentCueIndex].name : fullPath,
fileOrd: usingCues ? (dec.currentCueIndex + 1) : (dec.currentFileIndex || 1),
resolution: `${info.width}x${info.height}${info.isInterlaced ? 'i' : ''}`,
colourSpace: info.colourSpace,
currentStatus: dec.isPaused() ? 2 : 1
}
}
// Entering ASCII: clear the text plane; the pixel framebuffer is left as-is and
// simply covered each frame by solid-black (240) text cells (see draw()).
// Bias lighting is pinned to pure black ONCE here and not updated again while
// in ASCII (draw() skips the bias stage), so the backdrop stays steady.
function enterAsciiVisual() {
ensureAscii()
graphics.setBackground(0, 0, 0)
con.clear()
}
// Leaving ASCII: fill the viewing area with transparency (255), NOT the GUI's
// translucent-black (colour 0), so the resumed video shows through cleanly.
function exitAsciiVisual() {
con.color_pair(COL_TRANSPARENT, COL_TRANSPARENT)
con.clear()
}
function toggleAscii() {
asciiMode = !asciiMode
if (asciiMode) enterAsciiVisual()
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/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
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
case K.LEFT: dec.seekSeconds(-5.5); break
case K.RIGHT: dec.seekSeconds(5.0); break
case K.UP: dec.setVolume(dec.getVolume() + VOL_STEP); break
case K.DOWN: dec.setVolume(dec.getVolume() - VOL_STEP); break
case K.PAGE_UP: dec.cue(-1); break
case K.PAGE_DOWN: dec.cue(1); break
}
}
}
lastKey = key
}
// ── Draw a decoded frame: framebuffer -> screen -> overlays -> chrome ──────
function draw() {
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 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
dec.bias() // bias lighting (player-owned stage; graphics only)
}
// Postprocessor output: subtitle overlay (text plane, on top of the frame).
if (asciiMode) {
// aa.flush rewrote the whole text plane, so redraw the subtitle each frame.
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
dec.subtitle.dirty = false
} else if (dec.subtitle.dirty) {
gui.clearSubtitleArea()
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
dec.subtitle.dirty = false
}
if (interactive) { gui.printBottomBar(status()); gui.printTopBar(status(), 1) }
}
// Start in ASCII if requested (-ascii). Done here, after the helpers above are
// defined, since they are block-scoped function declarations.
if (asciiMode) enterAsciiVisual()
// ── Main loop ───────────────────────────────────────────────────────────
while (!quit) {
stage = "input"; readInput()
if (quit) break
stage = "step"
const ev = dec.step()
if (ev.type === 'eof') break
if (ev.type === 'error') { errorlevel = 1; break }
if (ev.type === 'frame') {
if (!startNs) startNs = sys.nanoTime()
stage = "draw"; draw()
} else {
// 'idle' or 'newfile' — nothing to draw this turn.
sys.sleep(1)
}
}
}
catch (e) {
// Log to serial too (persists in the console log next to errorlevel) and
// keep it on screen — con.clear() in finally only runs on success.
serial.printerr("playmov failed at stage [" + stage + "]: " + e)
if (e && e.message) serial.println(" message: " + e.message)
if (e && e.stack) serial.println(" stack: " + e.stack)
if (e && e.printStackTrace) e.printStackTrace()
printerrln(e)
errorlevel = 1
}
finally {
if (dec) dec.close()
if (aa && aaCtx) aa.close(aaCtx)
if (errorlevel === 0) con.clear()
con.curs_set(1)
con.move(cy, cx)
}
return errorlevel