mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-08 14:24:05 +09:00
234 lines
9.3 KiB
JavaScript
234 lines
9.3 KiB
JavaScript
/*
|
|
* 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 (the generic
|
|
* RAM frame): blit() uploads it to the adapter, while the ASCII path samples it
|
|
* straight from RAM, and `frameBuffer` exposes it for arbitrary reuse.
|
|
*/
|
|
|
|
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 the decoded RAM frame to the display planes (with dithering).
|
|
// bias lighting is a separate, player-driven stage (bias() below).
|
|
function blit() {
|
|
graphics.uploadRGBToFramebuffer(currentFrameSrc, width, height, frameCount, false)
|
|
}
|
|
|
|
// The decoded frame already sits in currentFrameSrc (RGB888 RAM), so sampling
|
|
// reads RAM directly — ASCII mode needs no blit() / display-plane round-trip.
|
|
function sampleGray(dst, w, h) { common.sampleGrayRGB(currentFrameSrc, width, height, dst, w, h) }
|
|
function sampleColour(dst, w, h) { common.sampleColourRGB(currentFrameSrc, width, height, dst, w, h) }
|
|
|
|
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: [],
|
|
|
|
// Generic RAM frame: the current decoded frame as RGB888 (the live
|
|
// ping-pong buffer), valid after step() returns 'frame'. Callers may read it.
|
|
get frameBuffer() { return currentFrameSrc },
|
|
get frameWidth() { return width },
|
|
get frameHeight() { return height },
|
|
|
|
step: step,
|
|
blit: blit,
|
|
bias() { applyBias() },
|
|
sampleGray: sampleGray,
|
|
sampleColour: sampleColour,
|
|
pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } },
|
|
isPaused() { return paused },
|
|
setVolume(v) { audioR.setVolume(v) },
|
|
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 }
|