/* * 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 }