video: libmediadec and playmov

This commit is contained in:
minjaesong
2026-06-07 20:13:43 +09:00
parent aa45c2194f
commit 0f5ede5276
10 changed files with 1939 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -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 15 + 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 47: 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 }

View File

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