Files
tsvm/assets/disk0/tvdos/include/mediadec_common.mjs
2026-06-08 02:26:23 +09:00

449 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 `sampleGray` / `sampleColour` source samplers
* used by the player's ASCII-render path — both a *Screen pair (read the GPU
* display planes, for iPF) and a *RGB pair (read a RAM RGB888 frame, for the
* decode-into-RAM backends TEV / TAV).
*
* 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)
}
}
}
// ── sampleColour source ──────────────────────────────────────────────────────
// Companion to sampleGrayScreen: fill an RGB buffer (dst, length dstW·dstH·3,
// laid out R,G,B per cell) by point-sampling the GPU framebuffer at the CENTRE
// of each cell. Used by the player's colour-ASCII postprocessor — aa.mjs picks
// each glyph from brightness, this supplies the per-cell ink colour. Same
// backend-specific `mode` (4/5/8-bpp unpacking) and same cheap ~dstW·dstH peek
// count as sampleGrayScreen.
function sampleColourScreen(width, height, dst, dstW, dstH, mode) {
for (let y = 0; y < dstH; y++) {
let sy = ((y + 0.5) * height / dstH) | 0
if (sy >= height) sy = height - 1
let dstRow = y * dstW * 3
for (let x = 0; x < dstW; x++) {
let sx = ((x + 0.5) * width / dstW) | 0
if (sx >= width) sx = width - 1
let off = sy * 560 + sx
let fb1 = sys.peek(DISP_RG - off) & 255
let fb2 = sys.peek(DISP_BA - off) & 255
let r, g, b
if (mode == 5) {
r = ((fb1 >>> 2) & 31) * 255 / 31
g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31
b = (fb2 & 31) * 255 / 31
} else if (mode == 8) {
r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255
} else { // mode 4
r = (fb1 >>> 4) * 17
g = (fb1 & 15) * 17
b = (fb2 >>> 4) * 17
}
let di = dstRow + x * 3
dst[di] = r | 0; dst[di + 1] = g | 0; dst[di + 2] = b | 0
}
}
}
// ── sampleGray / sampleColour from a RAM RGB888 frame ─────────────────────────
// Companions to the *Screen samplers that read a decoded frame straight out of a
// JS-addressable RGB888 RAM buffer (3 bytes/pixel, forward-addressed) instead of
// the GPU display planes. Backends that decode into RAM (TEV / TAV) use these so
// the ASCII renderer can sample the frame WITHOUT it ever being uploaded to the
// video adapter — the whole point of the generic RAM-frame model. Same cheap
// ~dstW·dstH·3 peek count and the same nearest-sampling geometry as the *Screen
// versions (sampleGrayRGB row-aligned; sampleColourRGB at the cell centre).
function sampleGrayRGB(srcPtr, width, height, dst, dstW, dstH) {
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 o = srcPtr + (sy * width + sx) * 3
let r = sys.peek(o) & 255, g = sys.peek(o + 1) & 255, b = sys.peek(o + 2) & 255
dst[dstRow + x] = luma8(r, g, b)
}
}
}
function sampleColourRGB(srcPtr, width, height, dst, dstW, dstH) {
for (let y = 0; y < dstH; y++) {
let sy = ((y + 0.5) * height / dstH) | 0
if (sy >= height) sy = height - 1
let dstRow = y * dstW * 3
for (let x = 0; x < dstW; x++) {
let sx = ((x + 0.5) * width / dstW) | 0
if (sx >= width) sx = width - 1
let o = srcPtr + (sy * width + sx) * 3
let di = dstRow + x * 3
dst[di] = sys.peek(o) & 255; dst[di + 1] = sys.peek(o + 1) & 255; dst[di + 2] = sys.peek(o + 2) & 255
}
}
}
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, sampleColourScreen,
sampleGrayRGB, sampleColourRGB
}