diff --git a/assets/disk0/tvdos/bin/playtev.js b/assets/disk0/tvdos/bin/playtev.js index b11af62..b5fdc0a 100644 --- a/assets/disk0/tvdos/bin/playtev.js +++ b/assets/disk0/tvdos/bin/playtev.js @@ -67,6 +67,203 @@ audio.purgeQueue(0) audio.setPcmMode(0) audio.setMasterVolume(0, 255) +// 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 displaySubtitle(text, position = 0) { + if (!text || text.length === 0) { + clearSubtitleArea() + return + } + + // Set subtitle colors: yellow (230) on black (0) + let oldFgColor = con.get_color_fore() + let oldBgColor = con.get_color_back() + con.color_pair_pair(230, 0) + + // Split text into lines + let lines = text.split('\n') + + // Calculate position based on subtitle position setting + let startRow, startCol + + switch (position) { + case 0: // bottom center + startRow = 32 - lines.length + 1 + break + case 1: // bottom left + startRow = 32 - lines.length + 1 + break + case 2: // center left + startRow = 16 - Math.floor(lines.length / 2) + break + case 3: // top left + startRow = 2 + break + case 4: // top center + startRow = 2 + break + case 5: // top right + startRow = 2 + break + case 6: // center right + startRow = 16 - Math.floor(lines.length / 2) + break + case 7: // bottom right + startRow = 32 - lines.length + 1 + break + default: + startRow = 32 - lines.length + 1 // 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 0: // bottom center + case 4: // top center + startCol = Math.max(1, Math.floor((80 - line.length) / 2) + 1) + break + case 1: // bottom left + case 2: // center left + case 3: // top left + startCol = 2 + break + case 5: // top right + case 6: // center right + case 7: // bottom right + startCol = Math.max(1, 80 - line.length) + break + default: + startCol = Math.max(1, Math.floor((80 - line.length) / 2) + 1) + } + + con.move(row, startCol) + print(line) // Unicode-capable print function + } + + con.color_pair(oldFgColor, oldBgColor) +} + +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 @@ -124,18 +321,17 @@ 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 -// Allocate frame buffers - malloc works correctly, addresses are start addresses -const CURRENT_RGB_ADDR = sys.malloc(FRAME_SIZE) -const PREV_RGB_ADDR = sys.malloc(FRAME_SIZE) +// Ping-pong frame buffers to eliminate memcpy overhead +const RGB_BUFFER_A = sys.malloc(FRAME_SIZE) +const RGB_BUFFER_B = sys.malloc(FRAME_SIZE) - -// Working memory for blocks (minimal allocation) -let ycocgWorkspace = sys.malloc(BLOCK_SIZE * BLOCK_SIZE * 3) // Y+Co+Cg workspace -let dctWorkspace = sys.malloc(BLOCK_SIZE * BLOCK_SIZE * 4) // DCT coefficients (floats) +// 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(CURRENT_RGB_ADDR, 0, FRAME_PIXELS * 3) -sys.memset(PREV_RGB_ADDR, 0, FRAME_PIXELS * 3) +sys.memset(RGB_BUFFER_A, 0, FRAME_PIXELS * 3) +sys.memset(RGB_BUFFER_B, 0, FRAME_PIXELS * 3) // Initialize display framebuffer to black sys.memset(DISPLAY_RG_ADDR, 0, FRAME_PIXELS) // Black in RG plane @@ -148,31 +344,15 @@ let akku2 = 0.0 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] -// 4x4 Bayer dithering matrix -const BAYER_MATRIX = [ - [ 0, 8, 2,10], - [12, 4,14, 6], - [ 3,11, 1, 9], - [15, 7,13, 5] -] - - -// Apply Bayer dithering to reduce banding when quantizing to 4-bit -function ditherValue(value, x, y) { - // Get the dither threshold for this pixel position - const threshold = BAYER_MATRIX[y & 3][x & 3] - - // Scale threshold from 0-15 to 0-15.9375 (16 steps over 16 values) - const scaledThreshold = threshold / 16.0 - - // Add dither and quantize to 4-bit (0-15) - const dithered = value + scaledThreshold - return Math.max(0, Math.min(15, Math.floor(dithered * 15 / 255))) -} - function getRGBfromScr(x, y) { let offset = y * WIDTH + x let rg = sys.peek(-1048577 - offset) @@ -237,9 +417,10 @@ try { // Sync packet - frame complete frameCount++ - // Copy current RGB frame to previous frame buffer for next frame reference - // memcpy(source, destination, length) - so CURRENT (source) -> PREV (destination) - sys.memcpy(CURRENT_RGB_ADDR, PREV_RGB_ADDR, FRAME_PIXELS * 3) + // 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 (always includes rate control factor) @@ -275,11 +456,14 @@ try { let decompressedSize = Math.max(payloadLen * 4, blocksX * blocksY * tevBlockSize) // More efficient sizing 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 @@ -287,11 +471,15 @@ try { // Hardware-accelerated TEV YCoCg-R decoding to RGB buffers (with rate control factor) try { + let decodeStart = sys.nanoTime() graphics.tevDecode(blockDataPtr, CURRENT_RGB_ADDR, PREV_RGB_ADDR, width, height, quality, debugMotionVectors, rateControlFactor) + decodeTime = (sys.nanoTime() - decodeStart) / 1000000.0 // Convert to milliseconds // Upload RGB buffer to display framebuffer with dithering - graphics.uploadRGBToFramebuffer(CURRENT_RGB_ADDR, DISPLAY_RG_ADDR, DISPLAY_BA_ADDR, - width, height, frameCount) + let uploadStart = sys.nanoTime() + graphics.uploadRGBToFramebuffer(CURRENT_RGB_ADDR, width, height, frameCount) + uploadTime = (sys.nanoTime() - uploadStart) / 1000000.0 // Convert to milliseconds + // Defer audio playback until a first frame is sent if (!audioFired) { @@ -304,7 +492,15 @@ try { 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`) + } } else if (packetType == TEV_PACKET_AUDIO_MP2) { // MP2 Audio packet @@ -319,6 +515,10 @@ try { audio.mp2Decode() audio.mp2UploadDecoded(0) + } else if (packetType == TEV_PACKET_SUBTITLE) { + // Subtitle packet - NEW! + let packetSize = seqread.readInt() + processSubtitlePacket(packetSize) } else { println(`Unknown packet type: 0x${packetType.toString(16)}`) break @@ -349,11 +549,9 @@ catch (e) { } finally { // Cleanup working memory (graphics memory is automatically managed) - sys.free(ycocgWorkspace) - sys.free(dctWorkspace) sys.free(blockDataPtr) - if (CURRENT_RGB_ADDR > 0) sys.free(CURRENT_RGB_ADDR) - if (PREV_RGB_ADDR > 0) sys.free(PREV_RGB_ADDR) + if (RGB_BUFFER_A > 0) sys.free(RGB_BUFFER_A) + if (RGB_BUFFER_B > 0) sys.free(RGB_BUFFER_B) audio.stop(0) audio.purgeQueue(0) diff --git a/terranmon.txt b/terranmon.txt index 95acca6..10185a0 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -707,6 +707,7 @@ DCT-based compression, motion compensation, and efficient temporal coding. 0x10: I-frame (intra-coded frame) 0x11: P-frame (predicted frame) 0x20: MP2 audio packet + 0x30: Subtitle in "Simple" format 0xFF: sync packet ## Video Packet Structure @@ -767,6 +768,22 @@ to larger block sizes and hardware acceleration. Reuses existing MP2 audio infrastructure from TSVM MOV format for seamless compatibility with existing audio processing pipeline. +## Simple Subtitle Format +SSF is a simple subtitle that is intended to use text buffer to display texts. +The format is designed to be compatible with SubRip and SAMI (without markups). + +### SSF Packet Structure + uint24 index (used to specify target subtitle object) + uint8 opcode + 0x00 = , is NOP when used here + 0x01 = show (arguments: UTF-8 text) + 0x02 = hide (arguments: none) + 0x10 = move to different nonant (arguments: 0x00-bottom centre; 0x01-bottom left; 0x02-centre left; 0x03-top left; 0x04-top centre; 0x05-top right; 0x06-centre right; 0x07-bottom right; 0x08-centre + 0x30 = upload to low font rom (arguments: uint16 payload length, var bytes) + 0x31 = upload to high font rom (arguments: uint16 payload length, var bytes) + note: changing the font rom will change the appearance of the every subtitle currently being displayed + * arguments separated AND terminated by 0x00 + -------------------------------------------------------------------------------- Sound Adapter diff --git a/tsvm_core/src/net/torvald/tsvm/GraphicsJSR223Delegate.kt b/tsvm_core/src/net/torvald/tsvm/GraphicsJSR223Delegate.kt index 04f00b9..9f4f792 100644 --- a/tsvm_core/src/net/torvald/tsvm/GraphicsJSR223Delegate.kt +++ b/tsvm_core/src/net/torvald/tsvm/GraphicsJSR223Delegate.kt @@ -17,6 +17,7 @@ class GraphicsJSR223Delegate(private val vm: VM) { private val idctTempBuffer = FloatArray(64) private val idct16TempBuffer = FloatArray(256) // For 16x16 IDCT private val idct16SeparableBuffer = FloatArray(256) // For separable 16x16 IDCT + private fun getFirstGPU(): GraphicsAdapter? { return vm.findPeribyType(VM.PERITYPE_GPU_AND_TERM)?.peripheral as? GraphicsAdapter @@ -483,6 +484,33 @@ class GraphicsJSR223Delegate(private val vm: VM) { ) ).map{ it.map { (it.toFloat() + 0.5f) / 16f }.toFloatArray() } + private val bayerKernels2 = arrayOf( + intArrayOf( + 0,8,2,10, + 12,4,14,6, + 3,11,1,9, + 15,7,13,5, + ), + intArrayOf( + 8,2,10,0, + 4,14,6,12, + 11,1,9,3, + 7,13,5,15, + ), + intArrayOf( + 7,13,5,15, + 8,2,10,0, + 4,14,6,12, + 11,1,9,3, + ), + intArrayOf( + 15,7,13,5, + 0,8,2,10, + 12,4,14,6, + 3,11,1,9, + ) + ) + /** * This method always assume that you're using the default palette * @@ -1307,22 +1335,35 @@ class GraphicsJSR223Delegate(private val vm: VM) { /** * Upload RGB frame buffer to graphics framebuffer with dithering * @param rgbAddr Source RGB buffer (24-bit: R,G,B bytes) - * @param rgPlaneAddr Destination RG framebuffer - * @param baPlaneAddr Destination BA framebuffer * @param width Frame width * @param height Frame height */ - fun uploadRGBToFramebuffer(rgbAddr: Long, rgPlaneAddr: Long, baPlaneAddr: Long, width: Int, height: Int, frameCounter: Int) { - val rgAddrIncVec = if (rgPlaneAddr >= 0) 1 else -1 - val baAddrIncVec = if (baPlaneAddr >= 0) 1 else -1 + fun uploadRGBToFramebuffer(rgbAddr: Long, width: Int, height: Int, frameCounter: Int) { + val gpu = (vm.peripheralTable[1].peripheral as GraphicsAdapter) + val rgbAddrIncVec = if (rgbAddr >= 0) 1 else -1 - for (y in 0 until height) { - for (x in 0 until width) { - val pixelOffset = y.toLong() * width + x - val rgbOffset = pixelOffset * 3 * rgbAddrIncVec + val totalPixels = width * height + + // Process in 8KB chunks to balance memory usage and performance + val chunkSize = 8192 + val rgChunk = ByteArray(chunkSize) + val baChunk = ByteArray(chunkSize) + + var pixelsProcessed = 0 + + while (pixelsProcessed < totalPixels) { + val pixelsInChunk = kotlin.math.min(chunkSize, totalPixels - pixelsProcessed) + + // Batch process chunk of pixels + for (i in 0 until pixelsInChunk) { + val pixelIndex = pixelsProcessed + i + val y = pixelIndex / width + val x = pixelIndex % width + + val rgbOffset = (pixelIndex.toLong() * 3) * rgbAddrIncVec - // Read RGB values + // Read RGB values (3 peek operations per pixel - still the bottleneck) val r = vm.peek(rgbAddr + rgbOffset)!!.toUint() val g = vm.peek(rgbAddr + rgbOffset + rgbAddrIncVec)!!.toUint() val b = vm.peek(rgbAddr + rgbOffset + rgbAddrIncVec * 2)!!.toUint() @@ -1332,14 +1373,24 @@ class GraphicsJSR223Delegate(private val vm: VM) { val g4 = ditherValue(g, x, y, frameCounter) val b4 = ditherValue(b, x, y, frameCounter) - // Pack into 4096-color format - val rgValue = (r4 shl 4) or g4 // R in MSB, G in LSB - val baValue = (b4 shl 4) or 15 // B in MSB, A=15 (opaque) in LSB - - // Write to framebuffer - vm.poke(rgPlaneAddr + pixelOffset * rgAddrIncVec, rgValue.toByte()) - vm.poke(baPlaneAddr + pixelOffset * baAddrIncVec, baValue.toByte()) + // Pack and store in chunk buffers + rgChunk[i] = ((r4 shl 4) or g4).toByte() + baChunk[i] = ((b4 shl 4) or 15).toByte() } + + // Batch write entire chunk to framebuffer + val pixelOffset = (pixelsProcessed).toLong() + + gpu.let { + UnsafeHelper.memcpyRaw( + rgChunk, UnsafeHelper.getArrayOffset(rgChunk), + null, it.framebuffer.ptr + pixelOffset, pixelsInChunk.toLong()) + UnsafeHelper.memcpyRaw( + baChunk, UnsafeHelper.getArrayOffset(baChunk), + null, it.framebuffer2!!.ptr + pixelOffset, pixelsInChunk.toLong()) + } + + pixelsProcessed += pixelsInChunk } } @@ -1356,6 +1407,7 @@ class GraphicsJSR223Delegate(private val vm: VM) { return round(15f * q) } + val dctBasis8 = Array(8) { u -> FloatArray(8) { x -> val cu = if (u == 0) 1.0 / sqrt(2.0) else 1.0 diff --git a/video_encoder/encoder_tev.c b/video_encoder/encoder_tev.c index a41473b..12ba31c 100644 --- a/video_encoder/encoder_tev.c +++ b/video_encoder/encoder_tev.c @@ -29,6 +29,7 @@ #define TEV_PACKET_IFRAME 0x10 // Intra frame (keyframe) #define TEV_PACKET_PFRAME 0x11 // Predicted frame #define TEV_PACKET_AUDIO_MP2 0x20 // MP2 audio +#define TEV_PACKET_SUBTITLE 0x30 // Subtitle packet #define TEV_PACKET_SYNC 0xFF // Sync packet // Utility macros @@ -100,9 +101,18 @@ typedef struct __attribute__((packed)) { int16_t cg_coeffs[64]; // quantised Cg DCT coefficients (8x8) } tev_block_t; +// Subtitle entry structure +typedef struct subtitle_entry { + int start_frame; + int end_frame; + char *text; + struct subtitle_entry *next; +} subtitle_entry_t; + typedef struct { char *input_file; char *output_file; + char *subtitle_file; // SubRip (.srt) file path int width; int height; int fps; @@ -110,6 +120,7 @@ typedef struct { int total_frames; double duration; int has_audio; + int has_subtitles; int output_to_stdout; int quality; // 0-4, higher = better quality int verbose; @@ -156,6 +167,10 @@ typedef struct { float complexity_history[60]; // Rolling window for complexity int complexity_history_index; float average_complexity; + + // Subtitle handling + subtitle_entry_t *subtitle_list; + subtitle_entry_t *current_subtitle; } tev_encoder_t; // RGB to YCoCg-R transform (per YCoCg-R specification with truncated division) @@ -820,6 +835,223 @@ static void encode_block(tev_encoder_t *enc, int block_x, int block_y, int is_ke block->cbp = 0x07; // Y, Co, Cg all present } +// Convert SubRip time format (HH:MM:SS,mmm) to frame number +static int srt_time_to_frame(const char *time_str, int fps) { + int hours, minutes, seconds, milliseconds; + if (sscanf(time_str, "%d:%d:%d,%d", &hours, &minutes, &seconds, &milliseconds) != 4) { + return -1; + } + + double total_seconds = hours * 3600.0 + minutes * 60.0 + seconds + milliseconds / 1000.0; + return (int)(total_seconds * fps + 0.5); // Round to nearest frame +} + +// Parse SubRip subtitle file +static subtitle_entry_t* parse_srt_file(const char *filename, int fps) { + FILE *file = fopen(filename, "r"); + if (!file) { + fprintf(stderr, "Failed to open subtitle file: %s\n", filename); + return NULL; + } + + subtitle_entry_t *head = NULL; + subtitle_entry_t *tail = NULL; + char line[1024]; + int state = 0; // 0=index, 1=time, 2=text, 3=blank + + subtitle_entry_t *current_entry = NULL; + char *text_buffer = NULL; + size_t text_buffer_size = 0; + + while (fgets(line, sizeof(line), file)) { + // Remove trailing newline + size_t len = strlen(line); + if (len > 0 && line[len-1] == '\n') { + line[len-1] = '\0'; + len--; + } + if (len > 0 && line[len-1] == '\r') { + line[len-1] = '\0'; + len--; + } + + if (state == 0) { // Expecting subtitle index + if (strlen(line) == 0) continue; // Skip empty lines + // Create new subtitle entry + current_entry = calloc(1, sizeof(subtitle_entry_t)); + if (!current_entry) break; + state = 1; + } else if (state == 1) { // Expecting time range + char start_time[32], end_time[32]; + if (sscanf(line, "%31s --> %31s", start_time, end_time) == 2) { + current_entry->start_frame = srt_time_to_frame(start_time, fps); + current_entry->end_frame = srt_time_to_frame(end_time, fps); + + if (current_entry->start_frame < 0 || current_entry->end_frame < 0) { + free(current_entry); + current_entry = NULL; + state = 3; // Skip to next blank line + continue; + } + + // Initialize text buffer + text_buffer_size = 256; + text_buffer = malloc(text_buffer_size); + if (!text_buffer) { + free(current_entry); + current_entry = NULL; + fprintf(stderr, "Memory allocation failed while parsing subtitles\n"); + break; + } + text_buffer[0] = '\0'; + state = 2; + } else { + free(current_entry); + current_entry = NULL; + state = 3; // Skip malformed entry + } + } else if (state == 2) { // Collecting subtitle text + if (strlen(line) == 0) { + // End of subtitle text + current_entry->text = strdup(text_buffer); + free(text_buffer); + text_buffer = NULL; + + // Add to list + if (!head) { + head = current_entry; + tail = current_entry; + } else { + tail->next = current_entry; + tail = current_entry; + } + current_entry = NULL; + state = 0; + } else { + // Append text line + size_t current_len = strlen(text_buffer); + size_t line_len = strlen(line); + size_t needed = current_len + line_len + 2; // +2 for newline and null + + if (needed > text_buffer_size) { + text_buffer_size = needed + 256; + char *new_buffer = realloc(text_buffer, text_buffer_size); + if (!new_buffer) { + free(text_buffer); + free(current_entry); + current_entry = NULL; + fprintf(stderr, "Memory allocation failed while parsing subtitles\n"); + break; + } + text_buffer = new_buffer; + } + + if (current_len > 0) { + strcat(text_buffer, "\n"); + } + strcat(text_buffer, line); + } + } else if (state == 3) { // Skip to next blank line + if (strlen(line) == 0) { + state = 0; + } + } + } + + // Handle final subtitle if file doesn't end with blank line + if (current_entry && text_buffer) { + current_entry->text = strdup(text_buffer); + free(text_buffer); + + if (!head) { + head = current_entry; + } else { + tail->next = current_entry; + } + } + + fclose(file); + return head; +} + +// Free subtitle list +static void free_subtitle_list(subtitle_entry_t *list) { + while (list) { + subtitle_entry_t *next = list->next; + free(list->text); + free(list); + list = next; + } +} + +// Write subtitle packet to output +static int write_subtitle_packet(FILE *output, uint32_t index, uint8_t opcode, const char *text) { + // Calculate packet size + size_t text_len = text ? strlen(text) : 0; + size_t packet_size = 3 + 1 + text_len + 1; // index (3 bytes) + opcode + text + null terminator + + // Write packet type and size + uint8_t packet_type = TEV_PACKET_SUBTITLE; + fwrite(&packet_type, 1, 1, output); + fwrite(&packet_size, 4, 1, output); + + // Write subtitle packet data + uint8_t index_bytes[3]; + index_bytes[0] = index & 0xFF; + index_bytes[1] = (index >> 8) & 0xFF; + index_bytes[2] = (index >> 16) & 0xFF; + fwrite(index_bytes, 1, 3, output); + + fwrite(&opcode, 1, 1, output); + + if (text && text_len > 0) { + fwrite(text, 1, text_len, output); + } + + // Write null terminator + uint8_t null_term = 0x00; + fwrite(&null_term, 1, 1, output); + + return packet_size + 5; // packet_size + packet_type + size field +} + +// Process subtitles for the current frame +static int process_subtitles(tev_encoder_t *enc, int frame_num, FILE *output) { + if (!enc->has_subtitles) return 0; + + int bytes_written = 0; + + // Check if any subtitles need to be shown at this frame + subtitle_entry_t *sub = enc->current_subtitle; + while (sub && sub->start_frame <= frame_num) { + if (sub->start_frame == frame_num) { + // Show subtitle + bytes_written += write_subtitle_packet(output, 0, 0x01, sub->text); + if (enc->verbose) { + printf("Frame %d: Showing subtitle: %.50s%s\n", + frame_num, sub->text, strlen(sub->text) > 50 ? "..." : ""); + } + } + + if (sub->end_frame == frame_num) { + // Hide subtitle + bytes_written += write_subtitle_packet(output, 0, 0x02, NULL); + if (enc->verbose) { + printf("Frame %d: Hiding subtitle\n", frame_num); + } + } + + // Move to next subtitle if we're past the end of current one + if (sub->end_frame <= frame_num) { + enc->current_subtitle = sub->next; + } + + sub = sub->next; + } + + return bytes_written; +} + // Initialize encoder static tev_encoder_t* init_encoder(void) { tev_encoder_t *enc = calloc(1, sizeof(tev_encoder_t)); @@ -836,6 +1068,10 @@ static tev_encoder_t* init_encoder(void) { enc->fps = 0; // Will be detected from input enc->output_fps = 0; // No frame rate conversion by default enc->verbose = 0; + enc->subtitle_file = NULL; + enc->has_subtitles = 0; + enc->subtitle_list = NULL; + enc->current_subtitle = NULL; // Rate control defaults enc->target_bitrate_kbps = 0; // 0 = quality mode @@ -1092,6 +1328,11 @@ static char *execute_command(const char *command) { if (!pipe) return NULL; char *result = malloc(4096); + if (!result) { + pclose(pipe); + return NULL; + } + size_t len = fread(result, 1, 4095, pipe); result[len] = '\0'; @@ -1197,7 +1438,7 @@ static int start_video_conversion(tev_encoder_t *enc) { "ffmpeg -v quiet -i \"%s\" -f rawvideo -pix_fmt rgb24 " "-vf \"fps=%d,scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d\" " "-y - 2>&1", - enc->input_file, enc->width, enc->height, enc->width, enc->height, enc->output_fps); + enc->input_file, enc->output_fps, enc->width, enc->height, enc->width, enc->height); } else { // No frame rate conversion snprintf(command, sizeof(command), @@ -1373,6 +1614,7 @@ static void show_usage(const char *program_name) { printf("Options:\n"); printf(" -i, --input FILE Input video file\n"); printf(" -o, --output FILE Output video file (use '-' for stdout)\n"); + printf(" -s, --subtitles FILE SubRip (.srt) subtitle file\n"); printf(" -w, --width N Video width (default: %d)\n", DEFAULT_WIDTH); printf(" -h, --height N Video height (default: %d)\n", DEFAULT_HEIGHT); printf(" -f, --fps N Output frames per second (enables frame rate conversion)\n"); @@ -1398,6 +1640,7 @@ static void show_usage(const char *program_name) { printf("Examples:\n"); printf(" %s -i input.mp4 -o output.mv2 # Use default setting (q=2)\n", program_name); printf(" %s -i input.avi -f 15 -q 3 -o output.mv2 # 15fps @ q=3\n", program_name); + printf(" %s -i input.mp4 -s input.srt -o output.mv2 # With SubRip subtitles\n", program_name); // printf(" %s -i input.mp4 -b 800 -o output.mv2 # 800 kbps bitrate target\n", program_name); // printf(" %s -i input.avi -f 15 -b 500 -o output.mv2 # 15fps @ 500 kbps\n", program_name); // printf(" %s --test -b 1000 -o test.mv2 # Test with 1000 kbps target\n", program_name); @@ -1414,6 +1657,11 @@ static void cleanup_encoder(tev_encoder_t *enc) { unlink(TEMP_AUDIO_FILE); // Remove temporary audio file } + free(enc->input_file); + free(enc->output_file); + free(enc->subtitle_file); + free_subtitle_list(enc->subtitle_list); + free_encoder(enc); } @@ -1432,6 +1680,7 @@ int main(int argc, char *argv[]) { static struct option long_options[] = { {"input", required_argument, 0, 'i'}, {"output", required_argument, 0, 'o'}, + {"subtitles", required_argument, 0, 's'}, {"width", required_argument, 0, 'w'}, {"height", required_argument, 0, 'h'}, {"fps", required_argument, 0, 'f'}, @@ -1446,7 +1695,7 @@ int main(int argc, char *argv[]) { int option_index = 0; int c; - while ((c = getopt_long(argc, argv, "i:o:w:h:f:q:b:vt", long_options, &option_index)) != -1) { + while ((c = getopt_long(argc, argv, "i:o:s:w:h:f:q:b:vt", long_options, &option_index)) != -1) { switch (c) { case 'i': enc->input_file = strdup(optarg); @@ -1455,6 +1704,9 @@ int main(int argc, char *argv[]) { enc->output_file = strdup(optarg); enc->output_to_stdout = (strcmp(optarg, "-") == 0); break; + case 's': + enc->subtitle_file = strdup(optarg); + break; case 'w': enc->width = atoi(optarg); break; @@ -1528,6 +1780,21 @@ int main(int argc, char *argv[]) { } } + // Load subtitle file if specified + if (enc->subtitle_file) { + enc->subtitle_list = parse_srt_file(enc->subtitle_file, enc->fps); + if (enc->subtitle_list) { + enc->has_subtitles = 1; + enc->current_subtitle = enc->subtitle_list; + if (enc->verbose) { + printf("Loaded subtitles from: %s\n", enc->subtitle_file); + } + } else { + fprintf(stderr, "Failed to parse subtitle file: %s\n", enc->subtitle_file); + // Continue without subtitles + } + } + // Allocate buffers if (!alloc_encoder_buffers(enc)) { fprintf(stderr, "Failed to allocate encoder buffers\n"); @@ -1641,6 +1908,9 @@ int main(int argc, char *argv[]) { // Process audio for this frame process_audio(enc, frame_count, output); + // Process subtitles for this frame + process_subtitles(enc, frame_count, output); + // Encode frame if (!encode_frame(enc, output, frame_count)) { fprintf(stderr, "Failed to encode frame %d\n", frame_count);