// Created by CuriousTorvald and Claude on 2025-08-18. // TSVM Enhanced Video (TEV) Format Decoder - YCoCg-R 4:2:0 Version // Usage: playtev moviefile.tev [options] // Options: -i (interactive), -debug-mv (show motion vector debug visualization) // -deinterlace=algorithm (yadif or bwdif, default: yadif) // -nodeblock (disable post-processing deblocking filter) // -boundaryaware (enable boundary-aware decoding to prevent artifacts at DCT level) const WIDTH = 560 const HEIGHT = 448 const BLOCK_SIZE = 16 // 16x16 blocks for YCoCg-R const TEV_MAGIC = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x45, 0x56] // "\x1FTSVM TEV" const TEV_VERSION_YCOCG = 2 // YCoCg-R version const TEV_VERSION_ICtCp = 3 // ICtCp version const SND_BASE_ADDR = audio.getBaseAddr() const pcm = require("pcm") const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728] // Block encoding modes const TEV_MODE_SKIP = 0x00 const TEV_MODE_INTRA = 0x01 const TEV_MODE_INTER = 0x02 const TEV_MODE_MOTION = 0x03 // Packet types const TEV_PACKET_IFRAME = 0x10 const TEV_PACKET_PFRAME = 0x11 const TEV_PACKET_AUDIO_MP2 = 0x20 const TEV_PACKET_SUBTITLE = 0x30 // Legacy SSF (frame-locked) const TEV_PACKET_SUBTITLE_TC = 0x31 // SSF-TC (timecode-based) const TEV_PACKET_SYNC = 0xFF // Subtitle opcodes (SSF format) 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 // Subtitle state let subtitleVisible = false let subtitleText = "" let subtitlePosition = 0 // 0=bottom center (default) // SSF-TC subtitle event buffer let subtitleEvents = [] // Array of {timecode_ns, index, opcode, text} let nextSubtitleEventIndex = 0 // Next event to check // Parse command line options let interactive = false let debugMotionVectors = false let deinterlaceAlgorithm = "yadif" let enableDeblocking = false // Default: disabled (use -deblock to enable) let enableBoundaryAwareDecoding = false // Default: disabled (use -boundaryaware to enable) // suitable for still frame and slide shows, absolutely unsuitable for videos if (exec_args.length > 2) { for (let i = 2; i < exec_args.length; i++) { const arg = exec_args[i].toLowerCase() if (arg === "-i") { interactive = true } else if (arg === "-debug-mv") { debugMotionVectors = true } else if (arg === "-deblock") { enableDeblocking = true } else if (arg === "-boundaryaware") { enableBoundaryAwareDecoding = true } else if (arg.startsWith("-deinterlace=")) { deinterlaceAlgorithm = arg.substring(13) } } } const fullFilePath = _G.shell.resolvePathInput(exec_args[1]) const FILE_LENGTH = files.open(fullFilePath.full).size let videoRateBin = [] let errorlevel = 0 let notifHideTimer = 0 const NOTIF_SHOWUPTIME = 3000000000 let [cy, cx] = con.getyx() let gui = require("playgui") let seqread = undefined let fullFilePathStr = fullFilePath.full // Select seqread driver to use if (fullFilePathStr.startsWith('$:/TAPE') || fullFilePathStr.startsWith('$:\\TAPE')) { seqread = require("seqreadtape") seqread.prepare(fullFilePathStr) seqread.seek(0) } else { seqread = require("seqread") seqread.prepare(fullFilePathStr) } con.clear() con.curs_set(0) graphics.setGraphicsMode(4) // 4096-color mode graphics.clearPixels(0) graphics.clearPixels2(0) // Initialize audio audio.resetParams(0) audio.purgeQueue(0) audio.setPcmMode(0) audio.setMasterVolume(0, 255) // set colour zero as half-opaque black graphics.setPalette(0, 0, 0, 0, 9) // Subtitle display functions function clearSubtitleArea() { // Clear the subtitle area at the bottom of the screen // Text mode is 80x32, so clear the bottom few lines let oldFgColor = con.get_color_fore() let oldBgColor = con.get_color_back() con.color_pair(255, 255) // transparent to clear // Clear bottom 4 lines for subtitles for (let row = 29; row <= 32; row++) { con.move(row, 1) for (let col = 1; col <= 80; col++) { print(" ") } } con.color_pair(oldFgColor, oldBgColor) } function getVisualLength(line) { // Calculate the visual length of a line excluding formatting tags let visualLength = 0 let i = 0 while (i < line.length) { if (i < line.length - 2 && line[i] === '<') { // Check for formatting tags and skip them if (line.substring(i, i + 3).toLowerCase() === '' || line.substring(i, i + 3).toLowerCase() === '') { i += 3 // Skip tag } else if (i < line.length - 3 && (line.substring(i, i + 4).toLowerCase() === '' || line.substring(i, i + 4).toLowerCase() === '')) { i += 4 // Skip closing tag } else { // Not a formatting tag, count the character visualLength++ i++ } } else { // Regular character, count it visualLength++ i++ } } return visualLength } function displayFormattedLine(line) { // Parse line and handle and tags with color changes // Default subtitle color: yellow (231), formatted text: white (254) let i = 0 let inBoldOrItalic = false // insert initial padding block con.color_pair(0, 255) con.prnch(0xDE) con.color_pair(231, 0) while (i < line.length) { if (i < line.length - 2 && line[i] === '<') { // Check for opening tags if (line.substring(i, i + 3).toLowerCase() === '' || line.substring(i, i + 3).toLowerCase() === '') { con.color_pair(254, 0) // Switch to white for formatted text inBoldOrItalic = true i += 3 } else if (i < line.length - 3 && (line.substring(i, i + 4).toLowerCase() === '' || line.substring(i, i + 4).toLowerCase() === '')) { con.color_pair(231, 0) // Switch back to yellow for normal text inBoldOrItalic = false i += 4 } else { // Not a formatting tag, print the character print(line[i]) i++ } } else { // Regular character, print it print(line[i]) i++ } } // insert final padding block con.color_pair(0, 255) con.prnch(0xDD) con.color_pair(231, 0) } function displaySubtitle(text, position = 0) { if (!text || text.length === 0) { clearSubtitleArea() return } // Set subtitle colors: yellow (231) on black (0) let oldFgColor = con.get_color_fore() let oldBgColor = con.get_color_back() con.color_pair(231, 0) // Split text into lines let lines = text.split('\n') // Calculate position based on subtitle position setting let startRow, startCol // Calculate visual length without formatting tags for positioning let longestLineLength = lines.map(s => getVisualLength(s)).sort().last() switch (position) { case 2: // center left case 6: // center right case 8: // dead center startRow = 16 - Math.floor(lines.length / 2) break case 3: // top left case 4: // top center case 5: // top right startRow = 2 break case 0: // bottom center case 1: // bottom left case 7: // bottom right default: startRow = 32 - lines.length startRow = 32 - lines.length startRow = 32 - lines.length // Default to bottom center } // Display each line for (let i = 0; i < lines.length; i++) { let line = lines[i].trim() if (line.length === 0) continue let row = startRow + i if (row < 1) row = 1 if (row > 32) row = 32 // Calculate column based on alignment switch (position) { case 1: // bottom left case 2: // center left case 3: // top left startCol = 1 break case 5: // top right case 6: // center right case 7: // bottom right startCol = Math.max(1, 78 - getVisualLength(line) - 2) break case 0: // bottom center case 4: // top center case 8: // dead center default: startCol = Math.max(1, Math.floor((80 - longestLineLength - 2) / 2) + 1) break } con.move(row, startCol) // Parse and display line with formatting tag support displayFormattedLine(line) } con.color_pair(oldFgColor, oldBgColor) } // Parse SSF-TC subtitle packet and add to event buffer (0x31) function parseSubtitlePacketTC(packetSize) { // Read subtitle index (24-bit, little-endian) let indexByte0 = seqread.readOneByte() let indexByte1 = seqread.readOneByte() let indexByte2 = seqread.readOneByte() let index = indexByte0 | (indexByte1 << 8) | (indexByte2 << 16) // Read timecode (64-bit, little-endian) let timecode_ns = 0 for (let i = 0; i < 8; i++) { let byte = seqread.readOneByte() timecode_ns += byte * Math.pow(2, i * 8) } // Read opcode let opcode = seqread.readOneByte() let remainingBytes = packetSize - 12 // Subtract 3 (index) + 8 (timecode) + 1 (opcode) // Read text if present let text = null if (remainingBytes > 1 && (opcode === SSF_OP_SHOW || (opcode >= 0x10 && opcode <= 0x2F))) { let textBytes = seqread.readBytes(remainingBytes) text = "" for (let i = 0; i < remainingBytes - 1; i++) { // -1 for null terminator let byte = sys.peek(textBytes + i) if (byte === 0) break text += String.fromCharCode(byte) } sys.free(textBytes) } else if (remainingBytes > 0) { // Skip remaining bytes let skipBytes = seqread.readBytes(remainingBytes) sys.free(skipBytes) } // Add event to buffer subtitleEvents.push({ timecode_ns: timecode_ns, index: index, opcode: opcode, text: text }) } // Process subtitle events based on current playback time function processSubtitleEvents(currentTimeNs) { // Process all events whose timecode has been reached while (nextSubtitleEventIndex < subtitleEvents.length) { let event = subtitleEvents[nextSubtitleEventIndex] if (event.timecode_ns > currentTimeNs) { break // Haven't reached this event yet } // Execute the subtitle event switch (event.opcode) { case SSF_OP_SHOW: subtitleText = event.text || "" subtitleVisible = true displaySubtitle(subtitleText, subtitlePosition) break case SSF_OP_HIDE: subtitleVisible = false subtitleText = "" clearSubtitleArea() break case SSF_OP_MOVE: if (event.text && event.text.length > 0) { let newPosition = event.text.charCodeAt(0) if (newPosition >= 0 && newPosition <= 8) { subtitlePosition = newPosition if (subtitleVisible && subtitleText.length > 0) { clearSubtitleArea() displaySubtitle(subtitleText, subtitlePosition) } } } break case SSF_OP_UPLOAD_LOW_FONT: case SSF_OP_UPLOAD_HIGH_FONT: // Font upload handled during packet parsing break } nextSubtitleEventIndex++ } } // Process legacy frame-locked subtitle packet (0x30) function processSubtitlePacket(packetSize) { // Read subtitle packet data according to SSF format // uint24 index + uint8 opcode + variable arguments let index = 0 // Read 24-bit index (little-endian) let indexByte0 = seqread.readOneByte() let indexByte1 = seqread.readOneByte() let indexByte2 = seqread.readOneByte() index = indexByte0 | (indexByte1 << 8) | (indexByte2 << 16) let opcode = seqread.readOneByte() let remainingBytes = packetSize - 4 // Subtract 3 bytes for index + 1 byte for opcode switch (opcode) { case SSF_OP_SHOW: { // Read UTF-8 text until null terminator if (remainingBytes > 1) { let textBytes = seqread.readBytes(remainingBytes) let textStr = "" // Convert bytes to string, stopping at null terminator for (let i = 0; i < remainingBytes - 1; i++) { // -1 for null terminator let byte = sys.peek(textBytes + i) if (byte === 0) break textStr += String.fromCharCode(byte) } sys.free(textBytes) subtitleText = textStr subtitleVisible = true displaySubtitle(subtitleText, subtitlePosition) } break } case SSF_OP_HIDE: { subtitleVisible = false subtitleText = "" clearSubtitleArea() break } case SSF_OP_MOVE: { if (remainingBytes >= 2) { // Need at least 1 byte for position + 1 null terminator let newPosition = seqread.readOneByte() seqread.readOneByte() // Read null terminator if (newPosition >= 0 && newPosition <= 7) { subtitlePosition = newPosition // Re-display current subtitle at new position if visible if (subtitleVisible && subtitleText.length > 0) { clearSubtitleArea() displaySubtitle(subtitleText, subtitlePosition) } } } break } case SSF_OP_UPLOAD_LOW_FONT: case SSF_OP_UPLOAD_HIGH_FONT: { // Font upload - read payload length and font data if (remainingBytes >= 3) { // uint16 length + at least 1 byte data let payloadLen = seqread.readShort() if (remainingBytes >= payloadLen + 2) { let fontData = seqread.readBytes(payloadLen) // upload font data for (let i = 0; i < Math.min(payloadLen, 1920); i++) sys.poke(-1300607 - i, sys.peek(fontData + i)) sys.poke(-1299460, (opcode == SSF_OP_UPLOAD_LOW_FONT) ? 18 : 19) sys.free(fontData) } } break } case SSF_OP_NOP: default: { // Skip remaining bytes if (remainingBytes > 0) { let skipBytes = seqread.readBytes(remainingBytes) sys.free(skipBytes) } if (interactive && opcode !== SSF_OP_NOP) { serial.println(`[SUBTITLE UNKNOWN] Index: ${index}, Opcode: 0x${opcode.toString(16).padStart(2, '0')}`) } break } } } // Check magic number let magic = seqread.readBytes(8) let magicMatching = true let actualMagic = [] TEV_MAGIC.forEach((b, i) => { let testb = sys.peek(magic + i) & 255 actualMagic.push(testb) if (testb != b) { magicMatching = false } }) sys.free(magic) if (!magicMatching) { println("Not a TEV file (MAGIC mismatch) -- got " + actualMagic.join()) return 1 } // Read header let version = seqread.readOneByte() if (version !== TEV_VERSION_YCOCG && version !== TEV_VERSION_ICtCp) { println(`Unsupported TEV version: ${version} (expected ${TEV_VERSION_YCOCG} for YCoCg-R or ${TEV_VERSION_ICtCp} for ICtCp)`) return 1 } let colorSpace = (version === TEV_VERSION_ICtCp) ? "ICtCp" : "YCoCg" if (interactive) { con.move(1,1) println(`Push and hold Backspace to exit | ${colorSpace} | Deblock: ${enableDeblocking ? 'ON' : 'OFF'} | EdgeAware: ${enableBoundaryAwareDecoding ? 'ON' : 'OFF'}`); } let width = seqread.readShort() let height = seqread.readShort() let fps = seqread.readOneByte() let totalFrames = seqread.readInt() let qualityY = seqread.readOneByte() let qualityCo = seqread.readOneByte() let qualityCg = seqread.readOneByte() let flags = seqread.readOneByte() let hasAudio = !!(flags & 1) let hasSubtitle = !!(flags & 2) let videoFlags = seqread.readOneByte() let isInterlaced = !!(videoFlags & 1) let isNTSC = !!(videoFlags & 2) let unused2 = seqread.readOneByte() serial.println(`Video metadata:`) serial.println(` Frames: ${totalFrames}`) serial.println(` FPS: ${(isNTSC) ? (fps * 1000 / 1001) : fps}`) serial.println(` Duration: ${totalFrames / fps}`) serial.println(` Audio: ${hasAudio ? "Yes" : "No"}`) serial.println(` Resolution: ${width}x${height}, ${isInterlaced ? "interlaced" : "progressive"}`) serial.println(` Quality: Y=${qualityY}, Co=${qualityCo}, Cg=${qualityCg}`) // DEBUG interlace raw output //if (isInterlaced) { // height = height >> 1 // isInterlaced = false //} // END OF DEBUG serial.println(`TEV Format ${version} (${colorSpace}); Q: ${qualityY} ${qualityCo} ${qualityCg}; Interlaced: ${isInterlaced ? 'Yes' : 'No'}`) function updateDataRateBin(rate) { videoRateBin.push(rate) if (videoRateBin.length > fps) { videoRateBin.shift() } } function getVideoRate() { let baseRate = videoRateBin.reduce((a, c) => a + c, 0) let mult = fps / videoRateBin.length return baseRate * mult } let FRAME_TIME = 1.0 / fps let FRAME_TIME_NS = (1000000000.0 / fps) // Frame time in nanoseconds for subtitle timing // Ultra-fast approach: always render to display, use dedicated previous frame buffer const FRAME_PIXELS = width * height // Frame buffer addresses for graphics display const DISPLAY_RG_ADDR = -1048577 // Main graphics RG plane (displayed) const DISPLAY_BA_ADDR = -1310721 // Main graphics BA plane (displayed) // RGB frame buffers (24-bit: R,G,B per pixel) const FRAME_SIZE = 560*448*3 // Total frame size = 752,640 bytes // Ping-pong frame buffers to eliminate memcpy overhead const RGB_BUFFER_A = sys.malloc(FRAME_SIZE) const RGB_BUFFER_B = sys.malloc(FRAME_SIZE) // Static Yadif deinterlacing buffers (half-height field buffers for interlaced mode) const FIELD_SIZE = 560 * 224 * 3 // Half-height field buffer size const CURR_FIELD_BUFFER = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 const PREV_FIELD_BUFFER = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 const NEXT_FIELD_BUFFER = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 // For temporal prediction // Ping-pong buffer pointers (swap instead of copy) let CURRENT_RGB_ADDR = RGB_BUFFER_A let PREV_RGB_ADDR = RGB_BUFFER_B // Initialize RGB frame buffers to black (0,0,0) sys.memset(RGB_BUFFER_A, 0, FRAME_PIXELS * 3) sys.memset(RGB_BUFFER_B, 0, FRAME_PIXELS * 3) // Initialize Yadif field buffers to black sys.memset(CURR_FIELD_BUFFER, 0, FIELD_SIZE) sys.memset(PREV_FIELD_BUFFER, 0, FIELD_SIZE) sys.memset(NEXT_FIELD_BUFFER, 0, FIELD_SIZE) // Initialize display framebuffer to black sys.memset(DISPLAY_RG_ADDR, 0, FRAME_PIXELS) // Black in RG plane sys.memset(DISPLAY_BA_ADDR, 15, FRAME_PIXELS) // Black with alpha=15 (opaque) in BA plane let frameCount = 0 let trueFrameCount = 0 let stopPlay = false let akku = FRAME_TIME let akku2 = 0.0 // Frame buffering for temporal prediction (interlaced mode only) let bufferedFrames = [] // Queue of decoded frames for temporal prediction let frameBuffer1 = null // Current frame data let frameBuffer2 = null // Previous frame data let frameDisplayDelay = 1 // Display frames 1 frame delayed for temporal prediction let mp2Initialised = false let audioFired = false // Performance tracking variables let decompressTime = 0 let decodeTime = 0 let uploadTime = 0 let biasTime = 0 const BIAS_LIGHTING_MIN = 1.0 / 16.0 let oldBgcol = [BIAS_LIGHTING_MIN, BIAS_LIGHTING_MIN, BIAS_LIGHTING_MIN] let notifHidden = false function getRGBfromScr(x, y) { let offset = y * WIDTH + x let rg = sys.peek(-1048577 - offset) let ba = sys.peek(-1310721 - offset) return [(rg >>> 4) / 15.0, (rg & 15) / 15.0, (ba >>> 4) / 15.0] } function setBiasLighting() { let samples = [] // Get native resolution for centering calculation let nativeWidth = graphics.getPixelDimension()[0] let nativeHeight = graphics.getPixelDimension()[1] // Calculate video position offset (centered) let offsetX = Math.floor((nativeWidth - width) / 2) let offsetY = Math.floor((nativeHeight - height) / 2) // Sample from video borders, scaled to actual video dimensions let sampleStepX = Math.max(8, Math.floor(width / 18)) // ~18 samples across width let sampleStepY = Math.max(8, Math.floor(height / 17)) // ~17 samples across height let borderMargin = Math.min(8, Math.floor(width / 70)) // Proportional border margin // Sample top and bottom borders for (let x = borderMargin; x < width - borderMargin; x += sampleStepX) { samples.push(getRGBfromScr(x + offsetX, borderMargin + offsetY)) samples.push(getRGBfromScr(x + offsetX, height - borderMargin - 1 + offsetY)) } // Sample left and right borders for (let y = borderMargin; y < height - borderMargin; y += sampleStepY) { samples.push(getRGBfromScr(borderMargin + offsetX, y + offsetY)) samples.push(getRGBfromScr(width - borderMargin - 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_LIGHTING_MIN + (out[0] / samples.length / 2.0) // darken a bit out[1] = BIAS_LIGHTING_MIN + (out[1] / samples.length / 2.0) out[2] = BIAS_LIGHTING_MIN + (out[2] / samples.length / 2.0) let bgr = (oldBgcol[0]*5 + out[0]) / 6.0 let bgg = (oldBgcol[1]*5 + out[1]) / 6.0 let bgb = (oldBgcol[2]*5 + out[2]) / 6.0 oldBgcol = [bgr, bgg, bgb] graphics.setBackground(Math.round(bgr * 255), Math.round(bgg * 255), Math.round(bgb * 255)) } let blockDataPtr = sys.malloc(FRAME_SIZE) // Streaming frame buffer rotation for temporal prediction // Buffers rotate: NEXT -> CURRENT -> PREV each frame let currentFieldAddr = CURR_FIELD_BUFFER // Currently being decoded let prevFieldAddr = PREV_FIELD_BUFFER // Previous field for temporal prediction let nextFieldAddr = NEXT_FIELD_BUFFER // Next field for temporal prediction function rotateFieldBuffers() { // Rotate buffers: NEXT -> CURRENT -> PREV let temp = prevFieldAddr prevFieldAddr = currentFieldAddr currentFieldAddr = nextFieldAddr nextFieldAddr = temp } let frameDuped = false let currentFrameType = "I" // Main decoding loop - simplified for performance try { let t1 = sys.nanoTime() while (!stopPlay && seqread.getReadCount() < FILE_LENGTH /*&& trueFrameCount < totalFrames*/) { // Handle interactive controls if (interactive) { sys.poke(-40, 1) if (sys.peek(-41) == 67) { // Backspace stopPlay = true break } } if (akku >= FRAME_TIME) { // Read packet (1 byte: type) let packetType = seqread.readOneByte() if (packetType == 0xFF) { // Sync packet akku -= FRAME_TIME // Sync packet - frame complete frameCount++ trueFrameCount++ // Swap ping-pong buffers instead of expensive memcpy (752KB copy eliminated!) let temp = CURRENT_RGB_ADDR CURRENT_RGB_ADDR = PREV_RGB_ADDR PREV_RGB_ADDR = temp } else if (packetType == TEV_PACKET_IFRAME || packetType == TEV_PACKET_PFRAME) { // Video frame packet let payloadLen = seqread.readInt() let compressedPtr = seqread.readBytes(payloadLen) updateDataRateBin(payloadLen) // Basic sanity check on compressed data if (payloadLen <= 0 || payloadLen > 1000000) { serial.println(`Frame ${frameCount}: Invalid payload length: ${payloadLen}`) sys.free(compressedPtr) continue } // Decompress using gzip // Optimized buffer size calculation for TEV YCoCg-R blocks let actualSize let decompressStart = sys.nanoTime() try { // Use gzip decompression (only compression format supported in TSVM JS) actualSize = gzip.decompFromTo(compressedPtr, payloadLen, blockDataPtr) decompressTime = (sys.nanoTime() - decompressStart) / 1000000.0 // Convert to milliseconds } catch (e) { // Decompression failed - skip this frame decompressTime = (sys.nanoTime() - decompressStart) / 1000000.0 // Still measure time serial.println(`Frame ${frameCount}: Gzip decompression failed, skipping (compressed size: ${payloadLen}, error: ${e})`) sys.free(compressedPtr) continue } // Hardware-accelerated TEV decoding to RGB buffers (YCoCg-R or ICtCp based on version) try { // duplicate every 1000th frame (pass a turn every 1000n+501st) if NTSC if (!isNTSC || frameCount % 1000 != 501 || frameDuped) { frameDuped = false let decodeStart = sys.nanoTime() let decodingHeight = isInterlaced ? (height / 2)|0 : height if (isInterlaced) { // For interlaced: decode current frame into currentFieldAddr // For display: use prevFieldAddr as current, currentFieldAddr as next graphics.tevDecode(blockDataPtr, nextFieldAddr, currentFieldAddr, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMotionVectors, version, enableDeblocking, enableBoundaryAwareDecoding) graphics.tevDeinterlace(trueFrameCount, width, decodingHeight, prevFieldAddr, currentFieldAddr, nextFieldAddr, CURRENT_RGB_ADDR, deinterlaceAlgorithm) // Rotate field buffers for next frame: NEXT -> CURRENT -> PREV rotateFieldBuffers() } else { // Progressive or first frame: normal decoding without temporal prediction graphics.tevDecode(blockDataPtr, CURRENT_RGB_ADDR, PREV_RGB_ADDR, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMotionVectors, version, enableDeblocking, enableBoundaryAwareDecoding) } decodeTime = (sys.nanoTime() - decodeStart) / 1000000.0 // Convert to milliseconds // Upload RGB buffer to display framebuffer with dithering let uploadStart = sys.nanoTime() graphics.uploadRGBToFramebuffer(CURRENT_RGB_ADDR, width, height, frameCount, false) uploadTime = (sys.nanoTime() - uploadStart) / 1000000.0 // Convert to milliseconds } else { frameCount -= 1 frameDuped = true serial.println(`Frame ${frameCount}: Duplicating previous frame`) } // Process SSF-TC subtitle events based on current playback time if (subtitleEvents.length > 0) { let currentTimeNs = frameCount * FRAME_TIME_NS processSubtitleEvents(currentTimeNs) } // Defer audio playback until a first frame is sent if (isInterlaced) { // fire audio after frame 1 if (!audioFired && frameCount > 0) { audio.play(0) audioFired = true } } else { // fire audio after frame 0 if (!audioFired) { audio.play(0) audioFired = true } } } catch (e) { serial.println(`Frame ${frameCount}: Hardware ${colorSpace} decode failed: ${e}`) } sys.free(compressedPtr) let biasStart = sys.nanoTime() setBiasLighting() biasTime = (sys.nanoTime() - biasStart) / 1000000.0 // Convert to milliseconds // Log performance data every 60 frames (and also frame 0 for debugging) if (frameCount % 60 == 0 || frameCount == 0) { let totalTime = decompressTime + decodeTime + uploadTime + biasTime serial.println(`Frame ${frameCount}: Decompress=${decompressTime.toFixed(1)}ms, Decode=${decodeTime.toFixed(1)}ms, Upload=${uploadTime.toFixed(1)}ms, Bias=${biasTime.toFixed(1)}ms, Total=${totalTime.toFixed(1)}ms`) } currentFrameType = packetType == TEV_PACKET_IFRAME ? "I" : "P" } else if (packetType == TEV_PACKET_AUDIO_MP2) { // MP2 Audio packet let audioLen = seqread.readInt() if (!mp2Initialised) { mp2Initialised = true audio.mp2Init() } seqread.readBytes(audioLen, SND_BASE_ADDR - 2368) audio.mp2Decode() audio.mp2UploadDecoded(0) } else if (packetType == TEV_PACKET_SUBTITLE) { // Legacy frame-locked subtitle packet (0x30) let packetSize = seqread.readInt() processSubtitlePacket(packetSize) } else if (packetType == TEV_PACKET_SUBTITLE_TC) { // SSF-TC subtitle packet (0x31) - parse and buffer for later playback let packetSize = seqread.readInt() parseSubtitlePacketTC(packetSize) } else if (packetType == 0x00) { // Silently discard, faulty subtitle creation can cause this as 0x00 is used as an argument terminator } else { println(`Unknown packet type: 0x${packetType.toString(16)}`) break } } let t2 = sys.nanoTime() akku += (t2 - t1) / 1000000000.0 akku2 += (t2 - t1) / 1000000000.0 // Simple progress display if (interactive) { notifHideTimer += (t2 - t1) if (!notifHidden && notifHideTimer > (NOTIF_SHOWUPTIME + FRAME_TIME)) { // clearing function here notifHidden = true } con.color_pair(253, 0) let guiStatus = { fps: fps, videoRate: getVideoRate(), frameCount: frameCount, totalFrames: totalFrames, frameMode: currentFrameType, qY: qualityY, qCo: qualityCo, qCg: qualityCg, akku: akku2, fileName: fullFilePathStr, fileOrd: 1, resolution: `${width}x${height}${(isInterlaced) ? 'i' : ''}`, colourSpace: colorSpace, currentStatus: 1 } gui.printBottomBar(guiStatus) gui.printTopBar(guiStatus, 1) } t1 = t2 } } catch (e) { serial.printerr(`TEV ${colorSpace} decode error: ${e}`) errorlevel = 1 } finally { // Cleanup working memory (graphics memory is automatically managed) if (blockDataPtr > 0) sys.free(blockDataPtr) if (RGB_BUFFER_A > 0) sys.free(RGB_BUFFER_A) if (RGB_BUFFER_B > 0) sys.free(RGB_BUFFER_B) if (CURR_FIELD_BUFFER > 0) sys.free(CURR_FIELD_BUFFER) if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER) if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER) audio.stop(0) audio.purgeQueue(0) if (interactive) { //con.clear() } // set colour zero as opaque black } graphics.setPalette(0, 0, 0, 0, 0) con.move(cy, cx) // restore cursor return errorlevel