From 0f5ede5276f591a00b5784529678708fd70e7009 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sun, 7 Jun 2026 20:13:43 +0900 Subject: [PATCH] video: libmediadec and playmov --- assets/disk0/tvdos/bin/playmov.js | 253 ++++++ assets/disk0/tvdos/bin/playmov.js.synopsis | 37 + assets/disk0/tvdos/bin/zfm.js | 12 +- assets/disk0/tvdos/hopper/libmediadec.hop.per | 12 + assets/disk0/tvdos/include/mediadec.mjs | 76 ++ .../disk0/tvdos/include/mediadec_common.mjs | 373 +++++++++ assets/disk0/tvdos/include/mediadec_ipf.mjs | 181 +++++ assets/disk0/tvdos/include/mediadec_tav.mjs | 717 ++++++++++++++++++ assets/disk0/tvdos/include/mediadec_tev.mjs | 223 ++++++ doc/tvdos.tex | 61 ++ 10 files changed, 1939 insertions(+), 6 deletions(-) create mode 100644 assets/disk0/tvdos/bin/playmov.js create mode 100644 assets/disk0/tvdos/bin/playmov.js.synopsis create mode 100644 assets/disk0/tvdos/hopper/libmediadec.hop.per create mode 100644 assets/disk0/tvdos/include/mediadec.mjs create mode 100644 assets/disk0/tvdos/include/mediadec_common.mjs create mode 100644 assets/disk0/tvdos/include/mediadec_ipf.mjs create mode 100644 assets/disk0/tvdos/include/mediadec_tav.mjs create mode 100644 assets/disk0/tvdos/include/mediadec_tev.mjs diff --git a/assets/disk0/tvdos/bin/playmov.js b/assets/disk0/tvdos/bin/playmov.js new file mode 100644 index 0000000..daad64b --- /dev/null +++ b/assets/disk0/tvdos/bin/playmov.js @@ -0,0 +1,253 @@ +// 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] [-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) +// (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 + +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 back-plane addressing (mirrors aa.mjs _TA_BACK / _TA_BASE), VT-aware. +const TXT_BACK_OFF = 2562 +const TXT_AREA_BASE = 253950 +const asciiBackFill = new Uint8Array(80 * 32).fill(COL_PURE_BLACK) + +// 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) +} + +// ── Parse args ─────────────────────────────────────────────────────────────── +let interactive = false +let asciiMode = 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 === "-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 mode needs aa.mjs. +if (asciiMode && !aa) { + serial.println("playmov: aa.mjs not found; ASCII mode unavailable, -ascii ignored") + asciiMode = false +} + +if (!exec_args[1]) { printerrln("usage: playmov FILE [-i] [-ascii] [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(80, 32, { font: font }) + aaParams = aa.getrenderparams() + aaParams.dither = aa.AA_FLOYD_S +} + +// ── 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() + } + + // ── 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. + 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 (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 sampleGray can read it) + dec.sampleGray(aaCtx.imagebuffer, aaCtx.imgW, aaCtx.imgH) + aa.render(aaCtx, aaParams) + aa.flush(aaCtx) + 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 diff --git a/assets/disk0/tvdos/bin/playmov.js.synopsis b/assets/disk0/tvdos/bin/playmov.js.synopsis new file mode 100644 index 0000000..7ccef77 --- /dev/null +++ b/assets/disk0/tvdos/bin/playmov.js.synopsis @@ -0,0 +1,37 @@ +{ + "tsfVersion": "1.0", + "name": "playmov", + "summary": "Play a movie file (MOV/iPF, TEV, TAV or TAP)", + "symbols": { + "interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (controls + on-screen info)" }, + "ascii": { "kind": "option", "long": "-ascii", "summary": "Start in ASCII-render mode" }, + "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" }, + "deinterlace": { + "kind": "option", + "long": "-deinterlace", + "summary": "TEV: deinterlacing algorithm", + "value": { "name": "ALGO", "type": "enum", "values": ["yadif", "bwdif"], "required": true, "summary": "Deinterlacer" } + }, + "filmGrain": { + "kind": "option", + "long": "--filter-film-grain", + "summary": "TAV: apply a film-grain filter", + "value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" } + }, + "options": { + "kind": "group", + "summary": "Options", + "members": ["interactive", "ascii", "deblock", "boundaryAware", "debugMv", "deinterlace", "filmGrain"] + }, + "file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to play" } + }, + "synopsis": { + "type": "sequence", + "children": [ + { "type": "reference", "symbol": "file" }, + { "type": "repeat", "child": { "type": "reference", "symbol": "options" } } + ] + } +} diff --git a/assets/disk0/tvdos/bin/zfm.js b/assets/disk0/tvdos/bin/zfm.js index 2f8710b..7883fb9 100644 --- a/assets/disk0/tvdos/bin/zfm.js +++ b/assets/disk0/tvdos/bin/zfm.js @@ -54,12 +54,12 @@ const EXEC_FUNS = { "adpcm": (f) => _G.shell.execute(`playwav "${f}" -i`), // "mp3": (f) => _G.shell.execute(`playmp3 "${f}" -i`), "mp2": (f) => _G.shell.execute(`playmp2 "${f}" -i`), - "mv1": (f) => _G.shell.execute(`playmv1 "${f}" -i`), - "mv2": (f) => _G.shell.execute(`playtev "${f}" -i`), - "mv3": (f) => _G.shell.execute(`playtav "${f}" -i`), - "tav": (f) => _G.shell.execute(`playtav "${f}" -i`), - "im3": (f) => _G.shell.execute(`playtav "${f}" -i`), - "tap": (f) => _G.shell.execute(`playtav "${f}" -i`), + "mv1": (f) => _G.shell.execute(`playmov "${f}" -i`), + "mv2": (f) => _G.shell.execute(`playmov "${f}" -i`), + "mv3": (f) => _G.shell.execute(`playmov "${f}" -i`), + "tav": (f) => _G.shell.execute(`playmov "${f}" -i`), + "im3": (f) => _G.shell.execute(`playmov "${f}" -i`), + "tap": (f) => _G.shell.execute(`playmov "${f}" -i`), "tad": (f) => _G.shell.execute(`playtad "${f}" -i`), "pcm": (f) => _G.shell.execute(`playpcm "${f}" -i`), "ipf": (f) => _G.shell.execute(`decodeipf "${f}" -i`), diff --git a/assets/disk0/tvdos/hopper/libmediadec.hop.per b/assets/disk0/tvdos/hopper/libmediadec.hop.per new file mode 100644 index 0000000..4a3eed8 --- /dev/null +++ b/assets/disk0/tvdos/hopper/libmediadec.hop.per @@ -0,0 +1,12 @@ +HopperManifestVersion:1 +HopperPackageName:libmediadec +HopperPackageVersion:1.0.0 +HopperPackageMaintainer:CuriousTorvald +HopperProvides:libmediadec +HopperRequires:libseqread 1.* +ProperName:LibMediaDec +ProperAuthor:CuriousTorvald +ProperDescription:Video decoding library for TSVM +Licence:MIT +SupportMe:https://github.com/sponsors/curioustorvald/ +SystemPackagePath:/tvdos/include/mediadec.mjs;/tvdos/include/mediadec_common.mjs;/tvdos/include/mediadec_ipf.mjs;/tvdos/include/mediadec_tav.mjs;/tvdos/include/mediadec_tev.mjs diff --git a/assets/disk0/tvdos/include/mediadec.mjs b/assets/disk0/tvdos/include/mediadec.mjs new file mode 100644 index 0000000..49521a1 --- /dev/null +++ b/assets/disk0/tvdos/include/mediadec.mjs @@ -0,0 +1,76 @@ +/* + * mediadec.mjs — the all-in-one media-decoding library for TVDOS movie players. + * + * One simple public API, three internal backends (iPF/MOV, TEV, TAV/TAP), + * sharing the front-end utilities in mediadec_common.mjs. Used by playmov.js. + * + * const mediadec = require("mediadec") + * const dec = mediadec.open("A:\\film.tav", { interactive: true }) + * while (true) { + * const ev = dec.step() // [backend] decode the next due frame + * if (ev.type === 'eof') break + * if (ev.type !== 'frame') { sys.sleep(1); continue } + * dec.blit() // [draw] copy the frame to the screen + * // ...or in ASCII mode: dec.blit(); dec.sampleGray(buf,w,h); aa.render/flush + * } + * dec.close() + * + * The decoder object every backend returns exposes a uniform interface: + * .info {format,width,height,fps,totalFrames,hasAudio,hasSubtitles, + * isInterlaced,colourSpace,graphicsMode,isStill} + * .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 + * .subtitle {visible,text,position,useUnicode,dirty} (resolved by the lib) + * .pause(b)/.isPaused() .setVolume(v)/.getVolume() + * .seekSeconds(n) .cue(d) .cues + * .frameCount .currentTimecodeNs .videoRate .frameMode [.qY/.qCo/.qCg] + * .close() + */ + +// NOTE: every require() below is deliberately made at call time (inside open()), +// never at module top level. TVDOS's require() loads a module by eval()-ing it, +// and requiring one module *while another module is still being eval()-ed* nests +// the evals — which can collide on the loader's `let exports` binding and throw +// "Identifier 'exports' has already been declared" at load, breaking every file. +// Keeping requires at runtime means each is a single, non-nested eval. + +// Open a movie file: sniff the magic, then hand off to the matching backend. +// `opts` (all optional): interactive, debugMotionVectors, enableDeblocking, +// enableBoundaryAwareDecoding, deinterlaceAlgorithm, filmGrainLevel. +function open(fullPathStr, opts) { + opts = opts || {} + + const common = require("mediadec_common") + + // IMPORTANT: query the file size via files.open() BEFORE preparing seqread. + // On the real disk driver both share the drive's serial port, so a files.open() + // *after* seqread.prepare() clobbers the read position and the first readBytes() + // returns driver leftovers (the size as an ASCII string) instead of the file's + // bytes — which made every file fail the magic check. Every original player + // reads the size first, then prepares seqread. + const fileLength = files.open(fullPathStr).size + const sr = common.openSeqread(fullPathStr) + const magic = common.readMagic(sr) + const fmt = common.detectFormat(magic) + + con.clear() + con.curs_set(0) + + switch (fmt) { + case 'mov': return require("mediadec_ipf").create(magic, sr, fileLength, opts, common) + case 'tev': return require("mediadec_tev").create(magic, sr, fileLength, opts, common) + case 'tav': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, false) + case 'tap': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, true) + case 'ucf': + throw Error("UCF cue files are not directly playable; play the TAV stream they index") + default: + throw Error("Unrecognised movie file (magic: " + magic.map(b => b.toString(16)).join(' ') + ")") + } +} + +exports = { + open: open, + // Lazy require so this module never requires another at load time (see note above). + detectFormat: function (magic) { return require("mediadec_common").detectFormat(magic) } +} diff --git a/assets/disk0/tvdos/include/mediadec_common.mjs b/assets/disk0/tvdos/include/mediadec_common.mjs new file mode 100644 index 0000000..b73b509 --- /dev/null +++ b/assets/disk0/tvdos/include/mediadec_common.mjs @@ -0,0 +1,373 @@ +/* + * mediadec_common.mjs — shared front-end utilities for the mediadec library. + * + * Holds everything the three movie backends (iPF/MOV, TEV, TAV) duplicated in + * the old standalone players: magic constants, packet-type / SSF-opcode tables, + * the TAV quality LUT, seqread selection, the audio router, the subtitle + * engine, bias lighting, and the two `sampleGray` source samplers used by the + * player's ASCII-render path. + * + * Runs in the same GraalVM context as the player, so the host globals + * (sys/graphics/audio/con/serial/files/gzip) are visible directly, exactly as + * in seqread.mjs / playgui.mjs. + */ + +// ── Magic numbers ─────────────────────────────────────────────────────────── +const MAGIC_MOV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x4D, 0x4F, 0x56] // "\x1FTSVMMOV" +const MAGIC_TEV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x45, 0x56] // "\x1FTSVMTEV" +const MAGIC_TAV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x56] // "\x1FTSVMTAV" +const MAGIC_TAP = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x50] // "\x1FTSVMTAP" +const MAGIC_UCF = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x55, 0x43, 0x46] // "\x1FTSVMUCF" + +// ── MP2 frame-size table (shared by iPF/TEV/TAV) ──────────────────────────── +const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728] + +// ── SSF subtitle opcodes (shared) ─────────────────────────────────────────── +const SSF_OP_NOP = 0x00 +const SSF_OP_SHOW = 0x01 +const SSF_OP_HIDE = 0x02 +const SSF_OP_MOVE = 0x03 +const SSF_OP_UPLOAD_LOW_FONT = 0x80 +const SSF_OP_UPLOAD_HIGH_FONT = 0x81 + +// ── TAV quality LUT (index → quantiser) ───────────────────────────────────── +const QLUT = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124,126,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,264,272,280,288,296,304,312,320,328,336,344,352,360,368,376,384,392,400,408,416,424,432,440,448,456,464,472,480,488,496,504,512,528,544,560,576,592,608,624,640,656,672,688,704,720,736,752,768,784,800,816,832,848,864,880,896,912,928,944,960,976,992,1008,1024,1056,1088,1120,1152,1184,1216,1248,1280,1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,1888,1920,1952,1984,2016,2048,2112,2176,2240,2304,2368,2432,2496,2560,2624,2688,2752,2816,2880,2944,3008,3072,3136,3200,3264,3328,3392,3456,3520,3584,3648,3712,3776,3840,3904,3968,4032,4096] + +// ── Display-plane addresses (4bpp / mode 4) ───────────────────────────────── +const DISP_RG = -1048577 +const DISP_BA = -1310721 +const DISP_PLANE3 = -1310721 - 262144 // mode-8 third plane base (for getRGBfromScr) + +// ── seqread selection ─────────────────────────────────────────────────────── +// Mirrors the tape-vs-disk branch every old player carried. Returns a prepared +// seqread module instance (a stateful singleton — only one decoder at a time). +function openSeqread(fullPathStr) { + let sr + if (fullPathStr.startsWith('$:/TAPE') || fullPathStr.startsWith('$:\\TAPE')) { + sr = require("seqreadtape") + sr.prepare(fullPathStr) + sr.seek(0) + } else { + sr = require("seqread") + sr.prepare(fullPathStr) + } + return sr +} + +// Read the 8-byte magic into a JS array (frees the scratch buffer). +function readMagic(sr) { + let p = sr.readBytes(8) + let out = [] + for (let i = 0; i < 8; i++) out.push(sys.peek(p + i) & 255) + sys.free(p) + return out +} + +function magicEquals(got, want) { + for (let i = 0; i < 8; i++) if (got[i] !== want[i]) return false + return true +} + +// Detect container format from the 8-byte magic. Returns 'mov'|'tev'|'tav'|'tap'|'ucf'|null. +function detectFormat(magic) { + if (magicEquals(magic, MAGIC_MOV)) return 'mov' + if (magicEquals(magic, MAGIC_TEV)) return 'tev' + if (magicEquals(magic, MAGIC_TAV)) return 'tav' + if (magicEquals(magic, MAGIC_TAP)) return 'tap' + if (magicEquals(magic, MAGIC_UCF)) return 'ucf' + return null +} + +// ── Luma ───────────────────────────────────────────────────────────────────── +// BT.601 integer luma from 8-bit RGB. +function luma8(r, g, b) { return (r * 77 + g * 150 + b * 29) >> 8 } + +// ── Audio router ───────────────────────────────────────────────────────────── +// One playhead, deferred play(). Handles the per-packet audio codecs shared by +// the backends. TAV's bundled-MP2 (0x40) pre-decode/streaming stays in the TAV +// backend because it interleaves with the GOP display loop. +function makeAudioRouter(sr) { + const playhead = audio.getFreePlayhead(0) + const SND_BASE = audio.getBaseAddr() + const SND_MEM = audio.getMemAddr() + audio.resetParams(playhead) + audio.purgeQueue(playhead) + audio.setPcmMode(playhead) + let volume = 255 + audio.setMasterVolume(playhead, volume) + + let mp2Init = false + let fired = false + + return { + playhead: playhead, + sndBase: SND_BASE, + sndMem: SND_MEM, + + // Fire playback once, on the first displayed frame. + fire() { if (!fired) { audio.play(playhead); fired = true } }, + isFired() { return fired }, + + stop() { audio.stop(playhead) }, + resume() { audio.play(playhead) }, + purge() { audio.purgeQueue(playhead); fired = false }, + + setVolume(v) { volume = (v < 0) ? 0 : (v > 255) ? 255 : v; audio.setMasterVolume(playhead, volume) }, + getVolume() { return volume }, + + // MP2 packet: payload already length-known by caller; reads `len` bytes. + mp2(len) { + if (!mp2Init) { mp2Init = true; audio.mp2Init() } + sr.readBytes(len, SND_BASE - 2368) + audio.mp2Decode() + audio.mp2UploadDecoded(playhead) + }, + // MP2 frame whose size is implicit in the iPF packet type. + ensureMp2() { if (!mp2Init) { mp2Init = true; audio.mp2Init() } }, + + // TAD packet. + tad(sampleLen, payloadLen) { + sr.readBytes(payloadLen, SND_MEM - 917504) + audio.tadDecode() + audio.tadUploadDecoded(playhead, sampleLen) + }, + // Native (zstd PCMu8) packet. + nativePcm(zstdLen) { + let zstdPtr = sys.malloc(zstdLen) + sr.readBytes(zstdLen, zstdPtr) + let pcmPtr = sys.malloc(65536) + let pcmLen = gzip.decompFromTo(zstdPtr, zstdLen, pcmPtr) + if (pcmLen > 65536) { sys.free(zstdPtr); sys.free(pcmPtr); throw Error(`PCM data too long -- got ${pcmLen} bytes`) } + audio.putPcmDataByPtr(playhead, pcmPtr, pcmLen, 0) + audio.setSampleUploadLength(playhead, pcmLen) + audio.startSampleUpload(playhead) + sys.free(zstdPtr) + sys.free(pcmPtr) + }, + // Raw PCM (iPF 0x1000/0x1001): payload bytes streamed directly. + rawPcm(len) { + let frame = sr.readBytes(len) + audio.putPcmDataByPtr(playhead, frame, len, 0) + audio.setSampleUploadLength(playhead, len) + audio.startSampleUpload(playhead) + sys.free(frame) + }, + + close() { audio.stop(playhead); audio.purgeQueue(playhead) } + } +} + +// ── Subtitle engine ────────────────────────────────────────────────────────── +// Parses SSF (frame-locked 0x30) and SSF-TC (timecode 0x31) packets and exposes +// the *active* subtitle as state; the player renders it (the "postprocessor" +// stage). Font-ROM uploads are hardware writes, so the engine performs them. +// fontUploadBase: -1300607 (TEV) or -133121 (TAV) — kept per-format for parity. +function makeSubtitleEngine(sr, fontUploadBase) { + const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false } + let events = [] + let nextIndex = 0 + let fontUploaded = false + + function uploadFont(opcode, remainingBytes) { + if (remainingBytes >= 3) { + let payloadLen = sr.readShort() + if (remainingBytes >= payloadLen + 2) { + let fontData = sr.readBytes(payloadLen) + for (let i = 0; i < Math.min(payloadLen, 1920); i++) sys.poke(fontUploadBase - i, sys.peek(fontData + i)) + sys.poke(-1299460, (opcode == SSF_OP_UPLOAD_LOW_FONT) ? 18 : 19) + sys.free(fontData) + } + fontUploaded = true + subtitle.useUnicode = true + } + } + + return { + subtitle: subtitle, + get fontUploaded() { return fontUploaded }, + + // Frame-locked subtitle packet (0x30): applies immediately. + parseLegacy(packetSize) { + sr.readOneByte(); sr.readOneByte(); sr.readOneByte() // 24-bit index + let opcode = sr.readOneByte() + let remainingBytes = packetSize - 4 + switch (opcode) { + case SSF_OP_SHOW: { + if (remainingBytes > 1) { + let tb = sr.readBytes(remainingBytes) + let s = "" + for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; s += String.fromCharCode(b) } + sys.free(tb) + subtitle.text = s; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true + } + break + } + case SSF_OP_HIDE: { subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break } + case SSF_OP_MOVE: { + if (remainingBytes >= 2) { + let pos = sr.readOneByte(); sr.readOneByte() + if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true } + } + break + } + case SSF_OP_UPLOAD_LOW_FONT: + case SSF_OP_UPLOAD_HIGH_FONT: { uploadFont(opcode, remainingBytes); break } + default: { if (remainingBytes > 0) { let s = sr.readBytes(remainingBytes); sys.free(s) } break } + } + }, + + // Timecode subtitle packet (0x31): buffered, applied by poll(). + parseTC(packetSize) { + let i0 = sr.readOneByte(), i1 = sr.readOneByte(), i2 = sr.readOneByte() + let index = i0 | (i1 << 8) | (i2 << 16) + let tc = 0 + for (let i = 0; i < 8; i++) { tc += sr.readOneByte() * Math.pow(2, i * 8) } + let opcode = sr.readOneByte() + let remainingBytes = packetSize - 12 + let text = null + if (remainingBytes > 1 && (opcode === SSF_OP_SHOW || (opcode >= 0x10 && opcode <= 0x2F))) { + let tb = sr.readBytes(remainingBytes) + text = "" + for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; text += String.fromCharCode(b) } + sys.free(tb) + } else if (remainingBytes > 0) { + let s = sr.readBytes(remainingBytes); sys.free(s) + } + events.push({ timecode_ns: tc, index: index, opcode: opcode, text: text }) + }, + + // Advance through timecode events whose time has been reached. + poll(currentTimeNs) { + while (nextIndex < events.length) { + let ev = events[nextIndex] + if (ev.timecode_ns > currentTimeNs) break + switch (ev.opcode) { + case SSF_OP_SHOW: subtitle.text = ev.text || ""; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true; break + case SSF_OP_HIDE: subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break + case SSF_OP_MOVE: + if (ev.text && ev.text.length > 0) { + let pos = ev.text.charCodeAt(0) + if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true } + } + break + } + nextIndex++ + } + }, + + // After a seek: jump the event cursor to the first event at/after `tc`. + resetTo(tc) { + nextIndex = 0 + for (let i = 0; i < events.length; i++) { if (events[i].timecode_ns >= tc) { nextIndex = i; break } } + subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true + }, + + hasEvents() { return events.length > 0 } + } +} + +// ── Bias lighting ──────────────────────────────────────────────────────────── +// Samples the screen borders and drifts the background colour toward them — +// the "ambilight" the old players ran after each frame upload. Mode-aware +// (4/5/8 bpp) read-back, matching playtav's getRGBfromScr. +function makeBias(width, height, graphicsMode) { + const BIAS_MIN = 1.0 / 16.0 + let old = [BIAS_MIN, BIAS_MIN, BIAS_MIN] + const nativeWidth = graphics.getPixelDimension()[0] + const nativeHeight = graphics.getPixelDimension()[1] + const STRIDE = 560 + + function rgbFromScr(x, y) { + let off = y * STRIDE + x + let fb1 = sys.peek(DISP_RG - off) + let fb2 = sys.peek(DISP_BA - off) + if (graphicsMode == 5) { + let fb3 = sys.peek(DISP_PLANE3 - off) + return [((fb1 >>> 2) & 31) / 31.0, (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) / 31.0, (fb2 & 31) / 31.0] + } else if (graphicsMode == 4) { + return [(fb1 >>> 4) / 15.0, (fb1 & 15) / 15.0, (fb2 >>> 4) / 15.0] + } else { + let fb3 = sys.peek(DISP_PLANE3 - off) + return [fb1 / 255.0, fb2 / 255.0, fb3 / 255.0] + } + } + + return function setBiasLighting() { + let samples = [] + let offsetX = Math.floor((nativeWidth - width) / 2) + let offsetY = Math.floor((nativeHeight - height) / 2) + let stepX = Math.max(8, Math.floor(width / 18)) + let stepY = Math.max(8, Math.floor(height / 17)) + let margin = Math.min(8, Math.floor(width / 70)) + + for (let x = margin; x < width - margin; x += stepX) { + samples.push(rgbFromScr(x + offsetX, margin + offsetY)) + samples.push(rgbFromScr(x + offsetX, height - margin - 1 + offsetY)) + } + for (let y = margin; y < height - margin; y += stepY) { + samples.push(rgbFromScr(margin + offsetX, y + offsetY)) + samples.push(rgbFromScr(width - margin - 1 + offsetX, y + offsetY)) + } + + let out = [0.0, 0.0, 0.0] + samples.forEach(rgb => { out[0] += rgb[0]; out[1] += rgb[1]; out[2] += rgb[2] }) + out[0] = BIAS_MIN + (out[0] / samples.length / 2.0) + out[1] = BIAS_MIN + (out[1] / samples.length / 2.0) + out[2] = BIAS_MIN + (out[2] / samples.length / 2.0) + + let bgr = (old[0] * 5 + out[0]) / 6.0 + let bgg = (old[1] * 5 + out[1]) / 6.0 + let bgb = (old[2] * 5 + out[2]) / 6.0 + old = [bgr, bgg, bgb] + graphics.setBackground(Math.round(bgr * 255), Math.round(bgg * 255), Math.round(bgb * 255)) + } +} + +// ── sampleGray source ──────────────────────────────────────────────────────── +// Fill an ASCII brightness buffer (dst, dstW×dstH) by nearest-sampling the GPU +// framebuffer (the shared "player framebuffer" the backend has just blit()ted +// to). Reading the screen — rather than each backend's private frame store — +// keeps one sampler for every format/kind (TAV's GOP videoBuffer is Java-heap +// and has no JS-addressable VM address, so reading it directly is impossible). +// +// Only ~dstW·dstH peeks per call, so it is cheap regardless of frame size. +// Pixel `off` is backward-addressed (DISP_RG-off / DISP_BA-off), matching how +// every decoder writes the framebuffer. `mode` selects 4/5/8-bpp unpacking +// (mirrors playtav's getRGBfromScr). +function sampleGrayScreen(width, height, dst, dstW, dstH, mode) { + for (let y = 0; y < dstH; y++) { + let sy = (y * height / dstH) | 0 + let dstRow = y * dstW + for (let x = 0; x < dstW; x++) { + let sx = (x * width / dstW) | 0 + 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 + } + dst[dstRow + x] = luma8(r | 0, g | 0, b | 0) + } + } +} + +exports = { + MAGIC_MOV, MAGIC_TEV, MAGIC_TAV, MAGIC_TAP, MAGIC_UCF, + MP2_FRAME_SIZE, QLUT, + SSF_OP_NOP, SSF_OP_SHOW, SSF_OP_HIDE, SSF_OP_MOVE, + SSF_OP_UPLOAD_LOW_FONT, SSF_OP_UPLOAD_HIGH_FONT, + DISP_RG, DISP_BA, + openSeqread, readMagic, detectFormat, magicEquals, + luma8, + makeAudioRouter, makeSubtitleEngine, makeBias, + sampleGrayScreen +} diff --git a/assets/disk0/tvdos/include/mediadec_ipf.mjs b/assets/disk0/tvdos/include/mediadec_ipf.mjs new file mode 100644 index 0000000..3c48535 --- /dev/null +++ b/assets/disk0/tvdos/include/mediadec_ipf.mjs @@ -0,0 +1,181 @@ +/* + * mediadec_ipf.mjs — legacy MOV / iPF backend for the mediadec library. + * + * Ported from assets/disk0/tvdos/bin/playmv1.js. Decodes iPF1 / iPF1a / + * iPF2 / iPF2a / iPF1-delta video packets straight to the 4bpp display planes + * (the proven, fast path), plus MP2 and raw-PCM audio and the background-colour + * packet. Presents at decode time (so blit() is a no-op); bias lighting is a + * separate player-driven stage via the bias() method; the ASCII path reads the + * planes back via common.sampleGrayScreen. + */ + +const WIDTH = 560 +const HEIGHT = 448 +const FBUF_SIZE = WIDTH * HEIGHT + +function create(magic, sr, fileLength, opts, common) { + const audioR = common.makeAudioRouter(sr) + + // Header (after the 8-byte magic): w, h, fps, frameCount, queue info. + let width = sr.readShort() + let height = sr.readShort() + let fps = sr.readShort(); if (fps == 0) fps = 9999 + const FRAME_COUNT = sr.readInt() % 16777216 + sr.readShort() // skip unused + sr.readShort() // audioQueueInfo (unused for playback) + sr.skip(10) + + graphics.setGraphicsMode(4) + graphics.clearPixels(255) + graphics.clearPixels2(240) + + const FRAME_TIME = 1.0 / fps + const applyBias = common.makeBias(width, height, 4) + + const ipfbuf = sys.malloc(FBUF_SIZE) + + const info = { + format: 'ipf', width: width, height: height, fps: fps, + totalFrames: FRAME_COUNT, hasAudio: true, hasSubtitles: false, + isInterlaced: false, colourSpace: 'YCoCg', graphicsMode: 4, isStill: false + } + + // No subtitles in iPF; expose an inert state object for the uniform API. + const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false } + + let akku = FRAME_TIME + let lastT = sys.nanoTime() + let doFrameskip = true + let autoBg = true + let framesRead = 0 + let frameCount = 0 + let paused = false + + function setBackgroundPacket() { + autoBg = false + let rgbx = sr.readInt() + graphics.setBackground((rgbx & 0xFF000000) >>> 24, (rgbx & 0x00FF0000) >>> 16, (rgbx & 0x0000FF00) >>> 8) + } + + function step() { + const now = sys.nanoTime() + if (paused) { lastT = now; return { type: 'idle' } } + akku += (now - lastT) / 1000000000.0 + lastT = now + + if (sr.getReadCount() >= fileLength) return { type: 'eof' } + if (akku < FRAME_TIME) return { type: 'idle' } + + // Drain accumulated time into a frame budget (frameskip drops late frames). + let frameUnit = 0 + while (akku >= FRAME_TIME) { akku -= FRAME_TIME; frameUnit += 1 } + if (!doFrameskip) frameUnit = 1 + + let displayed = false + while (frameUnit >= 1 && sr.getReadCount() < fileLength) { + let packetType = sr.readShort() + + if (0xFFFF === packetType) { // sync — one frame boundary + frameUnit -= 1 + } + else if (0xFEFF === packetType) { // explicit background colour + setBackgroundPacket() + } + else if (packetType < 2047) { // video + if (packetType == 4 || packetType == 5 || packetType == 260 || packetType == 261) { + let decodefun = (packetType > 255) ? graphics.decodeIpf2 : graphics.decodeIpf1 + let payloadLen = sr.readInt() + if (framesRead >= FRAME_COUNT) return { type: 'eof' } + framesRead += 1 + let gz = sr.readBytes(payloadLen) + if (frameUnit == 1) { + gzip.decompFromTo(gz, payloadLen, ipfbuf) + decodefun(ipfbuf, common.DISP_RG, common.DISP_BA, width, height, (packetType & 255) == 5) + audioR.fire() + displayed = true + frameCount += 1 + } + sys.free(gz) + } + else if (packetType == 516) { // iPF1-delta + doFrameskip = false + let payloadLen = sr.readInt() + if (framesRead >= FRAME_COUNT) return { type: 'eof' } + framesRead += 1 + let gz = sr.readBytes(payloadLen) + if (frameUnit == 1) { + gzip.decompFromTo(gz, payloadLen, ipfbuf) + graphics.applyIpf1d(ipfbuf, common.DISP_RG, common.DISP_BA, width, height) + audioR.fire() + displayed = true + frameCount += 1 + } + sys.free(gz) + } + else { + throw Error(`Unknown iPF video packet type ${packetType} at ${sr.getReadCount() - 2}`) + } + } + else if (4096 <= packetType && packetType <= 6143) { // audio + let readLength = (packetType >>> 8 == 17) + ? common.MP2_FRAME_SIZE[(packetType & 255) >>> 1] + : sr.readInt() + if (readLength == 0) throw Error("iPF audio read length is zero") + if (packetType >>> 8 == 17) { // MP2 + audioR.ensureMp2() + sr.readBytes(readLength, audioR.sndBase - 2368) + audio.mp2Decode() + audio.mp2UploadDecoded(0) + } + else if (packetType == 0x1000 || packetType == 0x1001) { // raw PCM + audioR.rawPcm(readLength) + } + else { + throw Error(`iPF audio packet type ${packetType} at ${sr.getReadCount() - 2}`) + } + } + else { + // Unknown — stop to avoid desync (matches old players' break). + return { type: 'eof' } + } + } + + return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' } + } + + // The frame is already on the display planes (decoded there in step()), so + // presenting is a no-op. Bias lighting is a separate, player-driven stage + // (bias() below) and is skipped when an explicit background packet disabled it. + function blit() { } + + // 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) } + + return { + info: info, + subtitle: subtitle, + get frameCount() { return frameCount }, + get currentTimecodeNs() { return Math.floor(frameCount * (1000000000.0 / fps)) }, + get videoRate() { return 0 }, + get frameMode() { return ' ' }, + cues: [], + + step: step, + blit: blit, + bias() { if (autoBg) applyBias() }, // skipped when an explicit bg packet set the colour + sampleGray: sampleGray, + pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } }, + isPaused() { return paused }, + setVolume(v) { audioR.setVolume(v) }, + getVolume() { return audioR.getVolume() }, + seekSeconds(_n) { /* iPF has no index; seeking unsupported */ }, + cue(_d) { /* no cues */ }, + + close() { + sys.free(ipfbuf) + audioR.close() + } + } +} + +exports = { create } diff --git a/assets/disk0/tvdos/include/mediadec_tav.mjs b/assets/disk0/tvdos/include/mediadec_tav.mjs new file mode 100644 index 0000000..db283d0 --- /dev/null +++ b/assets/disk0/tvdos/include/mediadec_tav.mjs @@ -0,0 +1,717 @@ +/* + * mediadec_tav.mjs — TAV (TSVM Advanced Video) backend for the mediadec library. + * + * Ported from assets/disk0/tvdos/bin/playtav.js — the heaviest backend. DWT + * codec with: I/P frames, unified 3D-DWT GOPs (async triple-buffer + overflow + * queue), interlaced fields (yadif), TAP still images, UCF cue files + + * multi-file concatenation, Left/Right + cue seeking, screen masking, videotex + * (text-mode video), bundled MP2, and MP2/TAD/native-PCM audio, plus extended + * headers (XFPS) and timecode-driven subtitles. + * + * The original main-loop body becomes step(): each call performs one iteration + * (optional packet read + GOP state machine + a time-gated display) and returns + * 'frame' when a frame is displayed. The actual upload is deferred to blit() + * (or sampleGray() in ASCII mode), which is the only structural change from the + * original — it lets the same decoded frame feed either the graphics path or + * the ASCII path. + */ + +const TAV_VERSION = 1 +const UCF_VERSION = 1 +const ADDRESSING_EXTERNAL = 0x01 +const ADDRESSING_INTERNAL = 0x02 +const TAV_TEMPORAL_LEVELS = 2 + +const TAV_PACKET_IFRAME = 0x10 +const TAV_PACKET_PFRAME = 0x11 +const TAV_PACKET_GOP_UNIFIED = 0x12 +const TAV_PACKET_AUDIO_MP2 = 0x20 +const TAV_PACKET_AUDIO_NATIVE = 0x21 +const TAV_PACKET_AUDIO_PCM_16LE = 0x22 +const TAV_PACKET_AUDIO_ADPCM = 0x23 +const TAV_PACKET_AUDIO_TAD = 0x24 +const TAV_PACKET_SUBTITLE = 0x30 +const TAV_PACKET_SUBTITLE_TC = 0x31 +const TAV_PACKET_VIDEOTEX = 0x3F +const TAV_PACKET_AUDIO_BUNDLED = 0x40 +const TAV_PACKET_EXTENDED_HDR = 0xEF +const TAV_PACKET_SCREEN_MASK = 0xF2 +const TAV_PACKET_GOP_SYNC = 0xFC +const TAV_PACKET_TIMECODE = 0xFD +const TAV_PACKET_SYNC_NTSC = 0xFE +const TAV_PACKET_SYNC = 0xFF +const TAV_FILE_HEADER_FIRST = 0x1F + +const BLIP = '\x847u' + +const BUFFER_SLOTS = 3 +const MAX_GOP_SIZE = 24 + +function create(magic, sr, fileLength, opts, common, isTap) { + const QLUT = common.QLUT + const audioR = common.makeAudioRouter(sr) + const subEngine = common.makeSubtitleEngine(sr, -133121) // TAV font-ROM base + const SND_BASE = audioR.sndBase + const AUDIO_DEVICE = audioR.playhead + + // ── Header (32 bytes incl. magic) ─────────────────────────────────────── + let version = sr.readOneByte() + let width = sr.readShort() + let height = sr.readShort() + let fps = sr.readOneByte() + let fps_num = fps, fps_den = 1 + let totalFrames = sr.readInt() + let waveletFilter = sr.readOneByte() + let decompLevels = sr.readOneByte() + let qualityY = sr.readOneByte() + let qualityCo = sr.readOneByte() + let qualityCg = sr.readOneByte() + let extraFlags = sr.readOneByte() + let videoFlags = sr.readOneByte() + let qualityLevel = sr.readOneByte() + let channelLayout = sr.readOneByte() + let entropyCoder = sr.readOneByte() + let encoderPreset = sr.readOneByte() + sr.skip(2) // reserved + device orientation + let fileRole = sr.readOneByte() + + let baseVersion = (version > 8) ? (version - 8) : version + let temporalMotionCoder = (version > 8) ? 1 : 0 + if (baseVersion < 1 || baseVersion > 8) throw Error(`Unsupported TAV base version ${baseVersion}`) + + const hasAudio = (extraFlags & 0x01) !== 0 + const hasSubtitles = (extraFlags & 0x02) !== 0 + let isInterlaced = (videoFlags & 0x01) !== 0 + let isNTSC = (videoFlags & 0x02) !== 0 + let isLossless = (videoFlags & 0x04) !== 0 + let colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg" + + // ── Graphics ───────────────────────────────────────────────────────────── + graphics.setGraphicsMode(4) + graphics.setGraphicsMode(5) + graphics.clearPixels(0); graphics.clearPixels2(0); graphics.clearPixels3(0); graphics.clearPixels4(0) + let gpuGraphicsMode = graphics.getGraphicsMode() + + let decodeHeight = isInterlaced ? (height >> 1) : height + let frametime = 1000000000.0 / fps + let FRAME_TIME = 1.0 / fps + let applyBias = common.makeBias(width, height, gpuGraphicsMode) + + // ── Frame buffers ──────────────────────────────────────────────────────── + let FRAME_SIZE = width * height * 3 + const SLOT_SIZE = MAX_GOP_SIZE * width * height * 3 + const RGB_BUFFER_A = sys.malloc(FRAME_SIZE) + const RGB_BUFFER_B = sys.malloc(FRAME_SIZE) + sys.memset(RGB_BUFFER_A, 0, FRAME_SIZE) + sys.memset(RGB_BUFFER_B, 0, FRAME_SIZE) + let CURRENT_RGB = RGB_BUFFER_A + let PREV_RGB = RGB_BUFFER_B + + const FIELD_SIZE = width * decodeHeight * 3 + const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 + const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 + const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 + if (isInterlaced) { sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE) } + let prevField = PREV_FIELD, curField = CURR_FIELD, nextField = NEXT_FIELD + + const info = { + format: isTap ? 'tap' : 'tav', width: width, height: height, fps: fps, + totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitles, + isInterlaced: isInterlaced, colourSpace: colourSpace, graphicsMode: gpuGraphicsMode, + isStill: !!isTap + } + + // ── Playback / GOP state ───────────────────────────────────────────────── + let frameCount = 0, trueFrameCount = 0 + let akku = FRAME_TIME, akku2 = 0.0 + let firstFrameIssued = false + let nextFrameTime = 0 + let paused = false + let decoderDbgInfo = {} + let videoRate = 0 + let videoRateBin = [] + + let currentGopBufferSlot = 0, currentGopSize = 0, currentGopFrameIndex = 0 + let readyGopData = null, decodingGopData = null + let asyncDecodeInProgress = false, asyncDecodeSlot = 0, asyncDecodeGopSize = 0 + let asyncDecodePtr = 0, asyncDecodeStartTime = 0 + let iframeReady = false + let shouldReadPackets = true + let overflowQueue = [] + + let predecodedPcmBuffer = null, predecodedPcmSize = 0, predecodedPcmOffset = 0 + const PCM_UPLOAD_CHUNK = 2304 + + let cueElements = [], currentCueIndex = -1, skipped = false + let iframePositions = [] + let currentFileIndex = 1 + + // Subtitle/timecode + let currentTimecodeNs = 0, baseTimecodeNs = 0, baseTimecodeFrameCount = 0 + + // Screen mask + let screenMaskEntries = [], screenMaskTop = 0, screenMaskRight = 0, screenMaskBottom = 0, screenMaskLeft = 0 + + // Deferred-display descriptor consumed by blit()/sampleGray(). + let pending = { kind: null, src: 0, frameIndex: 0, bufferOffset: 0, frameNo: 0, gopSize: 0 } + + let lastT = sys.nanoTime() + + // ── Helpers ────────────────────────────────────────────────────────────── + function updateDataRateBin(rate) { videoRateBin.push(rate); if (videoRateBin.length > 10) videoRateBin.shift() } + function getVideoRate() { let b = videoRateBin.reduce((a, c) => a + c, 0); return b * fps / videoRateBin.length } + + function parseXFPS(s) { + let p = s.split("/") + if (p.length === 2) { let n = parseInt(p[0], 10), d = parseInt(p[1], 10); if (!isNaN(n) && !isNaN(d) && d > 0) { fps_num = n; fps_den = d; fps = n / d; return true } } + return false + } + + function updateScreenMask(frameNum) { + if (screenMaskEntries.length === 0) return + for (let i = screenMaskEntries.length - 1; i >= 0; i--) { + if (screenMaskEntries[i].frameNum <= frameNum) { + screenMaskTop = screenMaskEntries[i].top; screenMaskRight = screenMaskEntries[i].right + screenMaskBottom = screenMaskEntries[i].bottom; screenMaskLeft = screenMaskEntries[i].left + return + } + } + } + function fillMaskedRegions() { return } // disabled upstream; kept for parity + + function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t } + + function cleanupAsyncDecode() { + if (asyncDecodeInProgress && asyncDecodePtr) { sys.free(asyncDecodePtr); asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0 } + if (readyGopData && readyGopData.compressedPtr) { sys.free(readyGopData.compressedPtr); readyGopData.compressedPtr = 0 } + readyGopData = null + if (decodingGopData && decodingGopData.compressedPtr) { sys.free(decodingGopData.compressedPtr); decodingGopData.compressedPtr = 0 } + decodingGopData = null + if (predecodedPcmBuffer !== null) { sys.free(predecodedPcmBuffer); predecodedPcmBuffer = null; predecodedPcmSize = 0; predecodedPcmOffset = 0 } + currentGopSize = 0; currentGopFrameIndex = 0; nextFrameTime = 0; shouldReadPackets = true + } + + function findNearestIframe(targetFrame) { + if (iframePositions.length === 0) return null + let result = null + for (let i = iframePositions.length - 1; i >= 0; i--) { if (iframePositions[i].frameNum <= targetFrame) { result = iframePositions[i]; break } } + return result || iframePositions[0] + } + + function scanForwardToIframe(targetFrame) { + let savedPos = sr.getReadCount() + try { + let scanFrameCount = frameCount + while (sr.getReadCount() < fileLength) { + let packetPos = sr.getReadCount() + let pType = sr.readOneByte() + if (pType === TAV_PACKET_SYNC || pType === TAV_PACKET_SYNC_NTSC) { if (pType === TAV_PACKET_SYNC) scanFrameCount++; continue } + if (pType === TAV_PACKET_IFRAME && scanFrameCount >= targetFrame) { iframePositions.push({ offset: packetPos, frameNum: scanFrameCount }); return { offset: packetPos, frameNum: scanFrameCount } } + if (pType !== TAV_PACKET_SYNC && pType !== TAV_PACKET_SYNC_NTSC && pType !== TAV_FILE_HEADER_FIRST) { let s = sr.readInt(); sr.skip(s) } + else if (pType === TAV_FILE_HEADER_FIRST) break + } + return null + } catch (e) { serial.printerr(`Scan error: ${e}`); return null } + finally { sr.seek(savedPos) } + } + + function applyNewHeader(h) { + version = h.version; width = h.width; height = h.height; fps = h.fps + totalFrames = h.totalFrames; waveletFilter = h.waveletFilter; decompLevels = h.decompLevels + qualityY = h.qualityY; qualityCo = h.qualityCo; qualityCg = h.qualityCg + extraFlags = h.extraFlags; videoFlags = h.videoFlags; qualityLevel = h.qualityLevel + channelLayout = h.channelLayout + baseVersion = (version > 8) ? (version - 8) : version + temporalMotionCoder = (version > 8) ? 1 : 0 + isInterlaced = (videoFlags & 0x01) !== 0; isNTSC = (videoFlags & 0x02) !== 0; isLossless = (videoFlags & 0x04) !== 0 + colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg" + decodeHeight = isInterlaced ? (height >> 1) : height + frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps + applyBias = common.makeBias(width, height, gpuGraphicsMode) + info.width = width; info.height = height; info.fps = fps; info.totalFrames = totalFrames + info.isInterlaced = isInterlaced; info.colourSpace = colourSpace + } + + // Returns a header object on success, or null/error code. + function tryReadNextTAVHeader() { + let newMagic = new Array(7) + try { + for (let i = 0; i < newMagic.length; i++) newMagic[i] = sr.readOneByte() + while (newMagic[0] == 255) { newMagic.shift(); newMagic[newMagic.length - 1] = sr.readOneByte() } + + let isValidTAV = true, isValidUCF = true + for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_TAV[i + 1]) isValidTAV = false } + for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_UCF[i + 1]) isValidUCF = false } + if (!isValidTAV && !isValidUCF) { serial.printerr("Header mismatch: got " + newMagic.join()); return null } + + if (isValidTAV) { + let h = { + version: sr.readOneByte(), width: sr.readShort(), height: sr.readShort(), + fps: sr.readOneByte(), totalFrames: sr.readInt(), waveletFilter: sr.readOneByte(), + decompLevels: sr.readOneByte(), qualityY: sr.readOneByte(), qualityCo: sr.readOneByte(), + qualityCg: sr.readOneByte(), extraFlags: sr.readOneByte(), videoFlags: sr.readOneByte(), + qualityLevel: sr.readOneByte(), channelLayout: sr.readOneByte(), fileRole: sr.readOneByte() + } + for (let i = 0; i < 4; i++) sr.readOneByte() // reserved + return h + } + // UCF cue file: parse cue table then recurse to the following TAV header. + let uver = sr.readOneByte() + if (uver !== UCF_VERSION) { serial.println(`Unsupported UCF version ${uver}`); return null } + let numElements = sr.readShort() + let cueSize = sr.readInt() + sr.skip(1) + for (let i = 0; i < numElements; i++) { + let el = {} + el.addressingModeAndIntent = sr.readOneByte() + el.addressingMode = el.addressingModeAndIntent & 15 + let nameLen = sr.readShort() + el.name = sr.readString(nameLen) + if (el.addressingMode === ADDRESSING_EXTERNAL) { let pl = sr.readShort(); el.path = sr.readString(pl) } + else if (el.addressingMode === ADDRESSING_INTERNAL) { + let ob = [] + for (let j = 0; j < 6; j++) ob.push(sr.readOneByte()) + let low32 = 0; for (let j = 0; j < 4; j++) low32 |= (ob[j] << (j * 8)) + let high16 = 0; for (let j = 4; j < 6; j++) high16 |= (ob[j] << ((j - 4) * 8)) + el.offset = (high16 * 0x100000000) + (low32 >>> 0) + } else { serial.println(`Unknown addressing mode ${el.addressingMode}`); return null } + cueElements.push(el) + } + let rc = sr.getReadCount() + sr.skip(cueSize - rc + 1) + currentFileIndex -= 1 + return tryReadNextTAVHeader() + } catch (e) { serial.printerr(e); return null } + } + + function feedPredecodedPcm() { + if (predecodedPcmBuffer !== null && predecodedPcmOffset < predecodedPcmSize) { + let remaining = predecodedPcmSize - predecodedPcmOffset + let uploadSize = Math.min(PCM_UPLOAD_CHUNK, remaining) + sys.memcpy(predecodedPcmBuffer + predecodedPcmOffset, SND_BASE, uploadSize) + audio.setSampleUploadLength(AUDIO_DEVICE, uploadSize) + audio.startSampleUpload(AUDIO_DEVICE) + predecodedPcmOffset += uploadSize + } + } + + function startAsyncGop(d) { + graphics.tavDecodeGopToVideoBufferAsync( + d.compressedPtr, d.compressedSize, d.gopSize, + width, decodeHeight, baseVersion >= 5, qualityLevel, + QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout, + waveletFilter, decompLevels, TAV_TEMPORAL_LEVELS, entropyCoder, + d.slot * SLOT_SIZE, temporalMotionCoder, encoderPreset + ) + asyncDecodeInProgress = true; asyncDecodeSlot = d.slot; asyncDecodeGopSize = d.gopSize + asyncDecodePtr = d.compressedPtr; asyncDecodeStartTime = sys.nanoTime() + } + + // ── Decode one I/P video packet into CURRENT_RGB (or field buffer) ─────── + function decodeIPFrame(packetType, packetOffset) { + updateScreenMask(frameCount) + if (packetType === TAV_PACKET_IFRAME) iframePositions.push({ offset: packetOffset, frameNum: frameCount }) + const compressedSize = sr.readInt() + let compressedPtr = sr.readBytes(compressedSize) + updateDataRateBin(compressedSize) + videoRate = compressedSize + try { + let decodeTarget = isInterlaced ? curField : CURRENT_RGB + decoderDbgInfo = graphics.tavDecodeCompressed( + compressedPtr, compressedSize, decodeTarget, PREV_RGB, + width, decodeHeight, qualityLevel, + QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout, + trueFrameCount, waveletFilter, decompLevels, isLossless, version, entropyCoder, encoderPreset + ) + if (isInterlaced) { + graphics.tavDeinterlace(trueFrameCount, width, decodeHeight, prevField, curField, nextField, CURRENT_RGB, "yadif") + rotateFields() + } + iframeReady = true + } catch (e) { console.log(`TAV frame ${frameCount}: decode failed: ${e}`) } + finally { sys.free(compressedPtr) } + } + + // ── GOP packet handling (Cases 1–5 + overflow) ────────────────────────── + function handleGopPacket() { + const gopSize = sr.readOneByte() + const compressedSize = sr.readInt() + let compressedPtr = sr.readBytes(compressedSize) + updateDataRateBin(compressedSize / gopSize) + decoderDbgInfo.frameMode = " " + + if (gopSize > MAX_GOP_SIZE) { sys.free(compressedPtr); return } + + if (currentGopSize === 0 && !asyncDecodeInProgress) { + if (asyncDecodePtr !== 0) { sys.free(asyncDecodePtr); asyncDecodePtr = 0 } + startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: currentGopBufferSlot }) + } + else if (currentGopSize === 0 && asyncDecodeInProgress) { + if (readyGopData === null) { + readyGopData = { gopSize, slot: (currentGopBufferSlot + 1) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 } + } else if (decodingGopData === null) { + decodingGopData = { gopSize, slot: (currentGopBufferSlot + 2) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 } + shouldReadPackets = false + } else { sys.free(compressedPtr) } + } + else if (currentGopSize > 0 && readyGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) { + let nextSlot = (currentGopBufferSlot + 1) % BUFFER_SLOTS + startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: nextSlot }) + readyGopData = { gopSize, slot: nextSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 } + shouldReadPackets = false + } + else if (currentGopSize > 0 && readyGopData !== null && decodingGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) { + let decodingSlot = (currentGopBufferSlot + 2) % BUFFER_SLOTS + startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: decodingSlot }) + decodingGopData = { gopSize, slot: decodingSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 } + shouldReadPackets = false + } + else { + overflowQueue.push({ gopSize, compressedPtr, compressedSize }) + } + } + + // ── One packet ─────────────────────────────────────────────────────────── + // Returns true if a multi-file header switch happened (caller emits 'newfile'). + function readOnePacket() { + let packetOffset = sr.getReadCount() + let packetType = sr.readOneByte() + let newfile = false + + if (packetType == TAV_FILE_HEADER_FIRST) { + let nh = tryReadNextTAVHeader() + if (nh) { + applyNewHeader(nh) + frameCount = 0; akku = 0.0; akku2 = 0.0; firstFrameIssued = false + baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0 + audio.purgeQueue(AUDIO_DEVICE) + currentFileIndex++ + if (skipped) skipped = false; else currentCueIndex++ + packetType = sr.readOneByte() + newfile = true + } else { return { eof: true } } + } + + if (packetType === TAV_PACKET_SYNC || packetType == TAV_PACKET_SYNC_NTSC) { + // vestigial in TAV's time-based model + } + else if (packetType === TAV_PACKET_IFRAME || packetType === TAV_PACKET_PFRAME) { + decodeIPFrame(packetType, packetOffset) + } + else if (packetType === TAV_PACKET_GOP_UNIFIED) { + handleGopPacket() + } + else if (packetType === TAV_PACKET_GOP_SYNC) { + sr.readOneByte() // frames-in-GOP (ignored; time-based) + if (currentGopSize > 0 && readyGopData !== null && decodingGopData !== null) shouldReadPackets = false + } + else if (packetType === TAV_PACKET_AUDIO_BUNDLED) { + let totalAudioSize = sr.readInt() + audioR.ensureMp2() + let mp2Buffer = sys.malloc(totalAudioSize) + sr.readBytes(totalAudioSize, mp2Buffer) + const estimatedPcmSize = totalAudioSize * 12 + predecodedPcmBuffer = sys.malloc(estimatedPcmSize); predecodedPcmSize = 0; predecodedPcmOffset = 0 + const MP2_DECODE_CHUNK = 2304 + let srcOffset = 0 + while (srcOffset < totalAudioSize) { + let chunkSize = Math.min(MP2_DECODE_CHUNK, totalAudioSize - srcOffset) + sys.memcpy(mp2Buffer + srcOffset, SND_BASE - 2368, chunkSize) + audio.mp2Decode() + sys.memcpy(SND_BASE, predecodedPcmBuffer + predecodedPcmSize, 2304) + predecodedPcmSize += 2304 + srcOffset += chunkSize + } + sys.free(mp2Buffer) + } + else if (packetType === TAV_PACKET_AUDIO_MP2) { let len = sr.readInt(); audioR.mp2(len) } + else if (packetType === TAV_PACKET_AUDIO_TAD) { let sampleLen = sr.readShort(); let payloadLen = sr.readInt(); audioR.tad(sampleLen, payloadLen) } + else if (packetType === TAV_PACKET_AUDIO_NATIVE) { let zstdLen = sr.readInt(); audioR.nativePcm(zstdLen) } + else if (packetType === TAV_PACKET_SUBTITLE) { let size = sr.readInt(); subEngine.parseLegacy(size) } + else if (packetType === TAV_PACKET_SUBTITLE_TC) { let size = sr.readInt(); subEngine.parseTC(size) } + else if (packetType === TAV_PACKET_VIDEOTEX) { + let compressedSize = sr.readInt() + let compressedPtr = sr.readBytes(compressedSize) + let decompressedPtr = sys.malloc(8192) + gzip.decompFromTo(compressedPtr, compressedSize, decompressedPtr) + let rows = sys.peek(decompressedPtr), cols = sys.peek(decompressedPtr + 1) + let gridSize = rows * cols + sys.memcpy(decompressedPtr + 2, -1302529, gridSize * 3) + sys.free(compressedPtr); sys.free(decompressedPtr) + iframeReady = true // displayed via the I/P path (uploads CURRENT_RGB under the text) + } + else if (packetType === TAV_PACKET_EXTENDED_HDR) { + let numPairs = sr.readShort() + for (let i = 0; i < numPairs; i++) { + let keyBytes = sr.readBytes(4); let key = "" + for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(keyBytes + j)) + sys.free(keyBytes) + let valueType = sr.readOneByte() + if (valueType === 0x04) { sr.readInt(); sr.readInt() } + else if (valueType === 0x10) { + let length = sr.readShort(); let dataBytes = sr.readBytes(length); let dataStr = "" + for (let j = 0; j < length; j++) dataStr += String.fromCharCode(sys.peek(dataBytes + j)) + sys.free(dataBytes) + if (key === "XFPS" && parseXFPS(dataStr)) { frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps } + } + } + } + else if (packetType === TAV_PACKET_SCREEN_MASK) { + let frameNum = sr.readInt() + let top = sr.readOneByte() | (sr.readOneByte() << 8) + let right = sr.readOneByte() | (sr.readOneByte() << 8) + let bottom = sr.readOneByte() | (sr.readOneByte() << 8) + let left = sr.readOneByte() | (sr.readOneByte() << 8) + screenMaskEntries.push({ frameNum, top, right, bottom, left }) + } + else if (packetType === TAV_PACKET_TIMECODE) { + let lo = sr.readInt(), hi = sr.readInt() + let tc = hi * 0x100000000 + (lo >>> 0) + baseTimecodeNs = tc; baseTimecodeFrameCount = frameCount; currentTimecodeNs = tc + decoderDbgInfo.frameMode = BLIP + } + else if (packetType == 0x00) { /* stray arg-terminator byte */ } + else { serial.println(`TAV unknown packet 0x${packetType.toString(16)}`); return { eof: true } } + + return { newfile: newfile } + } + + // ── step(): one main-loop iteration ───────────────────────────────────── + function step() { + // TAP still: show the pre-decoded frame once, then idle. + if (isTap) { + if (!firstFrameIssued) { firstFrameIssued = true; pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: 0 }; return { type: 'frame', frameCount: 1 } } + return { type: 'idle' } + } + + // EOF: stream exhausted and nothing buffered. + if (sr.getReadCount() >= fileLength && currentGopSize === 0 && readyGopData === null && decodingGopData === null && !asyncDecodeInProgress && overflowQueue.length === 0) { + return { type: 'eof' } + } + + let newfileEvent = false + + // 1) Gated packet read. + if (shouldReadPackets && !paused && sr.getReadCount() < fileLength) { + let r = readOnePacket() + if (r.eof) return { type: 'eof' } + if (r.newfile) newfileEvent = true + } + + // Time accumulation (only while a GOP plays / after first frame). + let t2 = sys.nanoTime() + if (!paused && firstFrameIssued) { + let dt = (t2 - lastT) / 1000000000.0 + if (currentGopSize > 0) akku += dt + akku2 += dt + } + lastT = t2 + + let displayed = false + + // Step 1: first-GOP decode wait. + if (asyncDecodeInProgress && currentGopSize === 0) { + if (!graphics.tavDecodeGopIsComplete()) { sys.sleep(1) } + else { + const res = graphics.tavDecodeGopGetResult(); decoderDbgInfo = res[1] + currentGopSize = asyncDecodeGopSize; currentGopFrameIndex = 0; currentGopBufferSlot = asyncDecodeSlot + asyncDecodeInProgress = false + if (nextFrameTime === 0) nextFrameTime = sys.nanoTime() + if (!(currentGopSize > 0 && readyGopData !== null && decodingGopData !== null)) shouldReadPackets = true + sys.free(asyncDecodePtr); asyncDecodePtr = 0; asyncDecodeGopSize = 0 + if (readyGopData !== null && readyGopData.needsDecode) { + startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = asyncDecodeStartTime + } + } + } + + // Step 2a: display I/P frame when due. + if (!paused && iframeReady && currentGopSize === 0) { + if (nextFrameTime === 0) nextFrameTime = sys.nanoTime() + while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1) + if (!paused) { + pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: trueFrameCount } + audioR.fire() + firstFrameIssued = true + frameCount++; trueFrameCount++; iframeReady = false + currentTimecodeNs = Math.floor(akku2 * 1000000000) + if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs) + let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t + nextFrameTime += frametime + displayed = true + } + } + + // Step 2&3: display GOP frame when due. + if (!paused && currentGopSize > 0 && currentGopFrameIndex < currentGopSize) { + while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1) + if (!paused) { + if (isInterlaced) pending = { kind: 'gop-interlaced', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize } + else pending = { kind: 'gop', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize } + audioR.fire() + firstFrameIssued = true + currentGopFrameIndex++; frameCount++; trueFrameCount++ + currentTimecodeNs = Math.floor(akku2 * 1000000000) + if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs) + feedPredecodedPcm() + if (decodingGopData !== null && decodingGopData.needsDecode && graphics.tavDecodeGopIsComplete()) { + startAsyncGop(decodingGopData); decodingGopData.needsDecode = false; decodingGopData.startTime = asyncDecodeStartTime + } + nextFrameTime += frametime + displayed = true + } + } + + // Step 4–7: GOP finished → transition to ready GOP (triple-buffer rotate). + if (!paused && currentGopSize > 0 && currentGopFrameIndex >= currentGopSize) { + if (readyGopData !== null) { + if (readyGopData.needsDecode) { startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = sys.nanoTime() } + while (!graphics.tavDecodeGopIsComplete() && !paused) sys.sleep(1) + if (!paused) { + graphics.tavDecodeGopGetResult() + sys.free(readyGopData.compressedPtr) + currentGopBufferSlot = readyGopData.slot; currentGopSize = readyGopData.gopSize; currentGopFrameIndex = 0 + readyGopData = decodingGopData; decodingGopData = null + if (graphics.tavDecodeGopIsComplete()) { asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0 } + shouldReadPackets = true + // Drain overflow queue into a free slot. + if (overflowQueue.length > 0 && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) { + const ov = overflowQueue.shift() + let targetSlot = (readyGopData === null) ? (currentGopBufferSlot + 1) % BUFFER_SLOTS + : (decodingGopData === null) ? (currentGopBufferSlot + 2) % BUFFER_SLOTS : -1 + if (targetSlot < 0) overflowQueue.unshift(ov) + else { + startAsyncGop({ compressedPtr: ov.compressedPtr, compressedSize: ov.compressedSize, gopSize: ov.gopSize, slot: targetSlot }) + let rec = { gopSize: ov.gopSize, slot: targetSlot, compressedPtr: ov.compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 } + if (readyGopData === null) readyGopData = rec; else decodingGopData = rec + } + } + } + } else { + currentGopSize = 0; currentGopFrameIndex = 0; shouldReadPackets = true + } + } + + sys.sleep(1) + + if (newfileEvent) return { type: 'newfile', frameCount: frameCount } + return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' } + } + + // ── Present / sample ───────────────────────────────────────────────────── + function blit() { + if (pending.kind === 'rgb') { + graphics.uploadRGBToFramebuffer(pending.src, width, height, pending.frameNo, false) + } else if (pending.kind === 'gop') { + graphics.uploadVideoBufferFrameToFramebuffer(pending.frameIndex, width, height, pending.frameNo, pending.bufferOffset) + updateScreenMask(frameCount); fillMaskedRegions() + } else if (pending.kind === 'gop-interlaced') { + graphics.uploadInterlacedGopFrameToFramebuffer(pending.frameIndex, pending.gopSize, width, decodeHeight, height, pending.frameNo, pending.bufferOffset, prevField, curField, nextField, CURRENT_RGB) + updateScreenMask(frameCount); fillMaskedRegions() + } + // bias lighting is a separate, player-driven stage (bias() below) + } + + // 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) } + + // ── TAP still: decode the single image now ────────────────────────────── + if (isTap) { + let packetType = sr.readOneByte() + while (packetType !== TAV_PACKET_IFRAME && sr.getReadCount() < fileLength) { + if (packetType === TAV_PACKET_EXTENDED_HDR) { + let numPairs = sr.readShort() + for (let i = 0; i < numPairs; i++) { + let kb = sr.readBytes(4); let key = ""; for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(kb + j)); sys.free(kb) + let vt = sr.readOneByte() + if (vt === 0x04) sr.skip(8) + else if (vt === 0x10) { let len = sr.readShort(); let db = sr.readBytes(len); if (key === "XFPS") { let s = ""; for (let j = 0; j < len; j++) s += String.fromCharCode(sys.peek(db + j)); parseXFPS(s) } sys.free(db) } + } + } else if (packetType === TAV_PACKET_SCREEN_MASK) { sr.skip(12) } + else if (packetType === TAV_PACKET_TIMECODE) { sr.skip(8) } + else { let size = sr.readInt(); sr.skip(size) } + packetType = sr.readOneByte() + } + if (packetType === TAV_PACKET_IFRAME) { + const compressedSize = sr.readInt() + const compressedPtr = sr.readBytes(compressedSize) + graphics.tavDecodeCompressed(compressedPtr, compressedSize, CURRENT_RGB, PREV_RGB, width, height, qualityLevel, QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout, 0, waveletFilter, decompLevels, isLossless, version, entropyCoder, 2) + sys.free(compressedPtr) + } + } + + return { + info: info, + subtitle: subEngine.subtitle, + get frameCount() { return frameCount }, + get currentTimecodeNs() { return currentTimecodeNs }, + get akku() { return akku2 }, + get videoRate() { return getVideoRate() }, + get frameMode() { return decoderDbgInfo.frameMode || ' ' }, + get qY() { return decoderDbgInfo.qY }, get qCo() { return decoderDbgInfo.qCo }, get qCg() { return decoderDbgInfo.qCg }, + get cues() { return cueElements }, + get currentCueIndex() { return currentCueIndex }, + get currentFileIndex() { return currentFileIndex }, + + step: step, + blit: blit, + bias() { applyBias() }, + sampleGray: sampleGray, + + pause(p) { + paused = p + if (p) audioR.stop() + else { audioR.resume(); lastT = sys.nanoTime() } + }, + isPaused() { return paused }, + setVolume(v) { audioR.setVolume(v) }, + getVolume() { return audioR.getVolume() }, + + seekSeconds(n) { + if (isTap) return + let target + if (n < 0) target = Math.max(0, frameCount - Math.floor(fps * (-n))) + else target = Math.min(totalFrames - 1, frameCount + Math.floor(fps * n)) + let seekTarget = findNearestIframe(target) + if (n > 0 && (!seekTarget || seekTarget.frameNum <= frameCount)) seekTarget = scanForwardToIframe(target) + if (!seekTarget) return + if (n > 0 && seekTarget.frameNum <= frameCount) return + cleanupAsyncDecode() + sr.seek(seekTarget.offset) + frameCount = seekTarget.frameNum; akku = FRAME_TIME; akku2 += n; firstFrameIssued = false + baseTimecodeNs = Math.floor(seekTarget.frameNum * frametime); baseTimecodeFrameCount = seekTarget.frameNum; currentTimecodeNs = baseTimecodeNs + subEngine.resetTo(baseTimecodeNs) + audio.purgeQueue(AUDIO_DEVICE) + skipped = true + }, + + cue(d) { + if (cueElements.length === 0) return + currentCueIndex = (d < 0) + ? ((currentCueIndex <= 0) ? cueElements.length - 1 : currentCueIndex - 1) + : ((currentCueIndex >= cueElements.length - 1) ? 0 : currentCueIndex + 1) + let cue = cueElements[currentCueIndex] + if (cue.addressingMode !== ADDRESSING_INTERNAL) return + cleanupAsyncDecode() + sr.seek(cue.offset) + frameCount = 0; akku = FRAME_TIME; akku2 = 0.0; firstFrameIssued = false + baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0 + subEngine.resetTo(0) + audio.purgeQueue(AUDIO_DEVICE) + skipped = true + }, + + close() { + cleanupAsyncDecode() + sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B) + if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) } + while (overflowQueue.length > 0) { const ov = overflowQueue.shift(); sys.free(ov.compressedPtr) } + audioR.close() + sys.poke(-1299460, 20); sys.poke(-1299460, 21) // reset font ROM + graphics.resetPalette() + } + } +} + +exports = { create } diff --git a/assets/disk0/tvdos/include/mediadec_tev.mjs b/assets/disk0/tvdos/include/mediadec_tev.mjs new file mode 100644 index 0000000..800775e --- /dev/null +++ b/assets/disk0/tvdos/include/mediadec_tev.mjs @@ -0,0 +1,223 @@ +/* + * mediadec_tev.mjs — TEV (TSVM Enhanced Video) backend for the mediadec library. + * + * Ported from assets/disk0/tvdos/bin/playtev.js. DCT codec, YCoCg-R / ICtCp, + * motion compensation, optional deblock / boundary-aware decoding, interlaced + * (yadif/bwdif) support, NTSC frame duplication, MP2 audio, SSF + SSF-TC + * subtitles. Decodes into an off-screen RGB888 ping-pong buffer; blit() uploads + * it (deferred from decode so the ASCII path can sample the same buffer). + */ + +const TEV_VERSION_YCOCG = 2 +const TEV_VERSION_ICtCp = 3 + +const TEV_PACKET_IFRAME = 0x10 +const TEV_PACKET_PFRAME = 0x11 +const TEV_PACKET_AUDIO_MP2 = 0x20 +const TEV_PACKET_SUBTITLE = 0x30 +const TEV_PACKET_SUBTITLE_TC = 0x31 +const TEV_PACKET_SYNC = 0xFF + +function create(magic, sr, fileLength, opts, common) { + const audioR = common.makeAudioRouter(sr) + const subEngine = common.makeSubtitleEngine(sr, -1300607) // TEV font-ROM base + + // Header + let version = sr.readOneByte() + if (version !== TEV_VERSION_YCOCG && version !== TEV_VERSION_ICtCp) { + throw Error(`Unsupported TEV version: ${version}`) + } + let width = sr.readShort() + let height = sr.readShort() + let fps = sr.readOneByte() + let totalFrames = sr.readInt() + let qualityY = sr.readOneByte() + let qualityCo = sr.readOneByte() + let qualityCg = sr.readOneByte() + let flags = sr.readOneByte() + let videoFlags = sr.readOneByte() + sr.readOneByte() // unused + const hasAudio = !!(flags & 1) + const hasSubtitle = !!(flags & 2) + const isInterlaced = !!(videoFlags & 1) + const isNTSC = !!(videoFlags & 2) + const colorSpace = (version === TEV_VERSION_ICtCp) ? "ICtCp" : "YCoCg" + + // Options + const debugMV = !!opts.debugMotionVectors + const enableDeblock = !!opts.enableDeblocking + const enableBoundaryAware = !!opts.enableBoundaryAwareDecoding + const deinterlaceAlgo = opts.deinterlaceAlgorithm || "yadif" + + graphics.setGraphicsMode(4) + graphics.clearPixels(0) + graphics.clearPixels2(0) + // NB: palette 0 is translucent black by default (used by the playgui chrome); + // we deliberately do NOT redefine it, nor reset it on close. + + const FRAME_PIXELS = width * height + const FRAME_SIZE = 560 * 448 * 3 + const FIELD_SIZE = 560 * 224 * 3 + + const RGB_BUFFER_A = sys.malloc(FRAME_SIZE) + const RGB_BUFFER_B = sys.malloc(FRAME_SIZE) + sys.memset(RGB_BUFFER_A, 0, FRAME_PIXELS * 3) + sys.memset(RGB_BUFFER_B, 0, FRAME_PIXELS * 3) + let CURRENT_RGB = RGB_BUFFER_A + let PREV_RGB = RGB_BUFFER_B + + const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 + const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 + const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 + if (isInterlaced) { + sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE) + } + let curField = CURR_FIELD, prevField = PREV_FIELD, nextField = NEXT_FIELD + + sys.memset(common.DISP_RG, 0, FRAME_PIXELS) + sys.memset(common.DISP_BA, 15, FRAME_PIXELS) + + const FRAME_TIME = 1.0 / fps + const FRAME_TIME_NS = 1000000000.0 / fps + const applyBias = common.makeBias(width, height, 4) + + const info = { + format: 'tev', width: width, height: height, fps: fps, + totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitle, + isInterlaced: isInterlaced, colourSpace: colorSpace, graphicsMode: 4, isStill: false + } + + let akku = FRAME_TIME + let lastT = sys.nanoTime() + let frameCount = 0 + let trueFrameCount = 0 + let frameDuped = false + let paused = false + let currentFrameType = "I" + let videoRate = 0 + let currentFrameSrc = CURRENT_RGB + + const blockDataPtr = sys.malloc(FRAME_SIZE) + + function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t } + + function decodeVideo(packetType) { + let payloadLen = sr.readInt() + videoRate = payloadLen + let compressedPtr = sr.readBytes(payloadLen) + currentFrameType = (packetType == TEV_PACKET_IFRAME) ? "I" : "P" + + // NTSC frame duplication: drop one decode every 1000 frames (≈29.97). + if (isNTSC && frameCount % 1000 == 501 && !frameDuped) { + frameDuped = true + sys.free(compressedPtr) + return false // keep previous frame on screen + } + frameDuped = false + + let actualSize + try { actualSize = gzip.decompFromTo(compressedPtr, payloadLen, blockDataPtr) } + catch (e) { sys.free(compressedPtr); serial.println(`TEV frame ${frameCount}: gzip failed: ${e}`); return false } + + let decodingHeight = isInterlaced ? (height / 2) | 0 : height + if (isInterlaced) { + graphics.tevDecode(blockDataPtr, nextField, curField, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware) + graphics.tevDeinterlace(trueFrameCount, width, decodingHeight, prevField, curField, nextField, CURRENT_RGB, deinterlaceAlgo) + rotateFields() + } else { + graphics.tevDecode(blockDataPtr, CURRENT_RGB, PREV_RGB, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware) + } + currentFrameSrc = CURRENT_RGB + sys.free(compressedPtr) + return true + } + + function step() { + const now = sys.nanoTime() + if (paused) { lastT = now; return { type: 'idle' } } + akku += (now - lastT) / 1000000000.0 + lastT = now + + if (sr.getReadCount() >= fileLength) return { type: 'eof' } + if (akku < FRAME_TIME) return { type: 'idle' } + + let packetType = sr.readOneByte() + + if (packetType == TEV_PACKET_SYNC) { + akku -= FRAME_TIME + frameCount++ + trueFrameCount++ + // Swap ping-pong: the just-shown frame becomes the reference. + let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t + return { type: 'idle' } + } + else if (packetType == TEV_PACKET_IFRAME || packetType == TEV_PACKET_PFRAME) { + let shown = decodeVideo(packetType) + if (shown) { + // audio after frame 0 (progressive) / frame 1 (interlaced) + if (!isInterlaced || frameCount > 0) audioR.fire() + if (subEngine.hasEvents()) subEngine.poll(frameCount * FRAME_TIME_NS) + return { type: 'frame', frameCount: frameCount } + } + return { type: 'idle' } + } + else if (packetType == TEV_PACKET_AUDIO_MP2) { + let audioLen = sr.readInt() + audioR.mp2(audioLen) + return { type: 'idle' } + } + else if (packetType == TEV_PACKET_SUBTITLE) { + let size = sr.readInt(); subEngine.parseLegacy(size); return { type: 'idle' } + } + else if (packetType == TEV_PACKET_SUBTITLE_TC) { + let size = sr.readInt(); subEngine.parseTC(size); return { type: 'idle' } + } + else if (packetType == 0x00) { + return { type: 'idle' } // stray arg-terminator byte + } + else { + serial.println(`TEV unknown packet type 0x${packetType.toString(16)}`) + return { type: 'eof' } + } + } + + // Present only; bias lighting is a separate, player-driven stage (bias() below). + function blit() { + graphics.uploadRGBToFramebuffer(currentFrameSrc, width, height, frameCount, false) + } + + // 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) } + + return { + info: info, + subtitle: subEngine.subtitle, + get frameCount() { return frameCount }, + get currentTimecodeNs() { return Math.floor(frameCount * FRAME_TIME_NS) }, + get videoRate() { return videoRate * fps }, + get frameMode() { return currentFrameType }, + get qY() { return qualityY }, get qCo() { return qualityCo }, get qCg() { return qualityCg }, + cues: [], + + step: step, + blit: blit, + bias() { applyBias() }, + sampleGray: sampleGray, + pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } }, + isPaused() { return paused }, + setVolume(v) { audioR.setVolume(v) }, + getVolume() { return audioR.getVolume() }, + seekSeconds(_n) { /* TEV has no index; seeking unsupported */ }, + cue(_d) {}, + + close() { + sys.free(blockDataPtr) + sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B) + if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) } + audioR.close() + } + } +} + +exports = { create } diff --git a/doc/tvdos.tex b/doc/tvdos.tex index 504fc44..8ca7ec1 100644 --- a/doc/tvdos.tex +++ b/doc/tvdos.tex @@ -194,6 +194,7 @@ The TAV video format below also have still-picture variants, used for high-fidel TEV and TAV files are prepared on a host computer and copied to the disk for playback; MOV and iPF content can also be produced on the machine itself. Both TEV and TAV are encoded at a chosen \emph{quality level} when they are made, trading file size against fidelity --- as a viewer you simply play the result. \begin{outline} +\1\dossynopsis{playmov}[file]{Plays any movie --- MV1, TEV or TAV, including TAV still pictures --- detecting the format from the file itself. This is the recommended player; see below.} \1\dossynopsis{playmv1}[file]{Plays a MV1-format movie.} \1\dossynopsis{playtev}[file]{Plays a TEV-format video.} \1\dossynopsis{playtav}[file]{Plays a TAV-format video.} @@ -201,6 +202,23 @@ TEV and TAV files are prepared on a host computer and copied to the disk for pla \1\dossynopsis{playucf}[file]{Plays a chaptered movie (UCF), presenting a chapter selector.} \end{outline} +\subsection{The All-in-One Player} + +\index{playmov}\code{playmov} plays every video format above --- MV1, TEV and TAV, including TAV still pictures --- from a single command, choosing the right decoder by inspecting the file. It is the recommended way to play video; the format-specific players remain available for compatibility. Subtitles are shown automatically for files that carry them, and chaptered (UCF) streams are navigable. + +Run with \code{-i} for interactive playback. The controls are: + +\begin{outline} +\1\textbf{Backspace} --- stop and exit (hold). +\1\textbf{Space} --- pause and resume. +\1\textbf{Left} / \textbf{Right} --- seek backward / forward. +\1\textbf{Up} / \textbf{Down} --- volume up / down. +\1\textbf{Page Up} / \textbf{Page Down} --- previous / next chapter, for chaptered files. +\1\textbf{A} --- toggle ASCII-render mode. +\end{outline} + +\index{ASCII-render mode}In \textbf{ASCII-render mode} the picture is drawn as text-mode character art rather than pixels --- a novelty, low-fidelity view of the same playing video. Begin in this mode with \code{playmov -ascii file}, or press \textbf{A} to switch in and out at any time, including mid-playback. + \section{Audio} \index{MP2}\index{TAD}For sound, \thedos\ plays standard \textbf{MP2} (MPEG-1 Audio Layer II), raw and wave-wrapped PCM, and its own compressed format, \textbf{TAD} (\thismachine\ Advanced Audio). All audio plays back at the hardware's 32\,kHz stereo. As with video, TAD files are prepared on a host computer; MP2 is a widely interchangeable format, and PCM/WAV are uncompressed. @@ -852,6 +870,49 @@ The wave generators take an offset, frequency, amplitude, pan and a mixing opera \1\inlinesynopsis{captureTrackerDataToFile}[outFile]{writes the current tracker state (samples, instruments, patterns and cue sheet) out to a single-song Taud file.} \end{outline} +\section{mediadec --- Movie and Still Playback} + +\index{mediadec (library)}\code{mediadec} is the decoding engine behind \code{playmov}. Most apps will not need it --- to simply play a file, run \code{playmov}. It is useful when you want to show a \textbf{short intro movie} or a \textbf{still picture} from inside your own program, in any of the machine's formats (MV1, TEV, TAV, and TAV still pictures), without writing a decoder yourself. It detects the format from the file, sets up the graphics and audio hardware, and decodes one frame at a time under your control. + +\code{open} returns a \emph{decoder}; you then call \code{step} in a loop. Each call advances the stream and returns an event describing what happened; when the event is a \code{frame}, call \code{blit} to put it on screen. Always \code{close} the decoder when done --- it frees its buffers and stops audio. The loop below plays an intro the viewer can skip with Backspace: + +\begin{lstlisting} +let mediadec = require("mediadec") +let dec = mediadec.open("A:/intro.tav") +let ev +while ((ev = dec.step()).type != "eof") { + if (ev.type == "frame") dec.blit() + else sys.sleep(1) + sys.poke(-40, 1) + if (sys.peek(-41) == 67) break // Backspace: skip the intro +} +dec.close() +\end{lstlisting} + +A \textbf{still picture} (a TAV still, played the same way) produces a single frame and then keeps reporting \code{idle}, so you decode it once, show it, and hold it on screen for as long as you like: + +\begin{lstlisting} +let dec = mediadec.open("A:/splash.tap") +if (dec.step().type == "frame") dec.blit() // decode and show +// ...hold it on screen, then: +dec.close() +\end{lstlisting} + +The library exports \code{open}; everything else is a method on the decoder it returns (called \code{dec} here): + +\begin{outline} +\1\inlinesynopsis{open}[path, options]{opens a movie or still and returns a decoder. \code{path} is a fully-qualified path; \code{options} is an optional settings object, rarely needed.} +\1\inlinesynopsis[dec]{step}[]{advances the stream and returns \code{\{ type \}}: \code{"frame"} (a frame is ready --- call \code{blit}), \code{"idle"} (nothing to show yet), \code{"eof"} (finished), or \code{"newfile"} (a concatenated next title started).} +\1\inlinesynopsis[dec]{blit}[]{draws the current frame to the screen.} +\1\inlinesynopsis[dec]{close}[]{frees buffers, stops audio and restores hardware state.} +\1\inlinesynopsis[dec]{pause}[on]{pauses (\code{true}) or resumes (\code{false}) playback.} +\1\inlinesynopsis[dec]{setVolume}[v]{sets playback volume, 0--255.} +\1\inlinesynopsis[dec]{seekSeconds}[n]{seeks \code{n} seconds forward (positive) or back (negative); best-effort, TAV only.} +\1\inlinesynopsis[dec]{cue}[dir]{jumps to the previous (\code{-1}) or next (\code{1}) chapter of a chaptered file.} +\end{outline} + +The decoder also exposes \code{dec.info} --- a description of the file, with \code{width}, \code{height}, \code{fps}, \code{totalFrames}, \code{isStill}, \code{hasAudio}, \code{hasSubtitles} and more --- and \code{dec.subtitle}, the currently-active subtitle for files that carry one. + \section{typesetter --- Rich-text Layout} \index{typesetter (library)}\code{typesetter} wraps, aligns and justifies text for the console using a small markup language. It returns an array of strings, each padded to the requested width, ready to print.