changeset 217:7ef4c9d2a72d hg-web

[DO NOT PUSH] Random values.
author MrJuneJune <me@mrjunejune.com>
date Sun, 25 Jan 2026 10:44:04 -0800
parents b818a4561a3c
children 09a96dcb2b4c
files medi/BUILD medi/README.md medi/main.c medi/src/app.tsx medi/src/index.html medi/src/main.tsx media_processor/BUILD media_processor/README.md media_processor/media_processor.c media_processor/media_processor.h media_processor/media_processor_test.c third_party/README.md third_party/ffmpeg/BUILD third_party/ffmpeg/README.md third_party/ffmpeg/ffmpeg.h third_party/ffmpeg/ffmpeg_cli.c third_party/ffmpeg/ffmpeg_cli.h third_party/ffmpeg/ffmpeg_wrapper.c
diffstat 18 files changed, 2198 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/medi/BUILD	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,49 @@
+load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("//gui_ze:gui_ze.bzl", "bun_bundle", "move_files_into_dir")
+
+cc_binary(
+    name = "medi",
+    srcs = ["main.c"],
+    deps = [
+        "//seobeo:seobeo",
+        "//media_processor:media_processor",
+        "//dowa:dowa",
+    ],
+    data = [
+        ":all_assets",
+    ],
+)
+
+filegroup(
+  name = "all_assets",
+  srcs = glob(["src/**"]) + [":compiled_js"],
+)
+
+move_files_into_dir(
+  name = "compiled_js",
+  srcs = [
+    ":medi_bundle",
+  ],
+  dest = "src",
+)
+
+filegroup(
+  name = "src_ts_files",
+  srcs = glob([
+    "src/**/*.ts",
+    "src/**/*.tsx",
+    "src/**/*.js",
+    "src/**/*.jsx",
+  ], allow_empty = True),
+)
+
+
+
+bun_bundle(
+  name = "medi_bundle",
+  src = "src/main.tsx",
+  deps = [
+    ":src_ts_files"
+  ],
+  visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/medi/README.md	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,111 @@
+# medi
+
+Media upload and processing server. Upload images and videos, get optimized WebP images and HLS video streams.
+
+## Features
+
+- **Image Upload**: Converts to WebP format with configurable quality
+- **Video Upload**: Converts to HLS streaming format with thumbnails
+- **Background Processing**: Non-blocking uploads with status polling
+- **Web UI**: Drag-and-drop upload interface
+- **REST API**: Programmatic access to upload and retrieve media
+
+## Running
+
+```bash
+# Build and run
+bazel run //medi:medi
+
+# Run on custom port
+bazel run //medi:medi -- 3000
+```
+
+Server starts at `http://localhost:8080` by default.
+
+## API
+
+### Upload Media
+
+```bash
+POST /api/upload
+Content-Type: multipart/form-data
+
+# Response
+{
+  "id": "1706123456_12345_0",
+  "type": "image",
+  "status": "processing"
+}
+```
+
+### Get Media Info
+
+```bash
+GET /api/media/{id}
+
+# Response (completed image)
+{
+  "id": "1706123456_12345_0",
+  "type": "image",
+  "status": "completed",
+  "webp": "/media/1706123456_12345_0/image.webp",
+  "original": "/media/1706123456_12345_0/original.jpg"
+}
+
+# Response (completed video)
+{
+  "id": "1706123456_12345_1",
+  "type": "video",
+  "status": "completed",
+  "hls": "/media/1706123456_12345_1/video.m3u8",
+  "thumbnail": "/media/1706123456_12345_1/video_thumb.jpg",
+  "original": "/media/1706123456_12345_1/original.mp4"
+}
+```
+
+### List All Media
+
+```bash
+GET /api/media
+
+# Response
+{
+  "items": [
+    {"id": "1706123456_12345_0", "type": "image"},
+    {"id": "1706123456_12345_1", "type": "video"}
+  ]
+}
+```
+
+## Output Structure
+
+```
+uploads/          # Temporary upload storage (deleted after processing)
+media/            # Processed media
+├── {id}/
+│   ├── original.{ext}   # Original uploaded file
+│   ├── image.webp       # (images) WebP conversion
+│   ├── video.m3u8       # (videos) HLS playlist
+│   ├── video_000.ts     # (videos) HLS segments
+│   └── video_thumb.jpg  # (videos) Thumbnail
+```
+
+## Supported Formats
+
+### Images
+JPG, JPEG, PNG, GIF, WebP, BMP, TIFF, HEIC, AVIF
+
+### Videos
+MP4, MOV, AVI, MKV, WebM, FLV, WMV, M4V, 3GP
+
+## Dependencies
+
+- `//seobeo:seobeo` - HTTP server
+- `//media_processor:media_processor` - Media processing
+- FFmpeg (system installed)
+
+## Building
+
+```bash
+bazel build //medi:medi
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/medi/main.c	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,531 @@
+#include "seobeo/seobeo.h"
+#include "media_processor/media_processor.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <dirent.h>
+#include <pthread.h>
+#include <unistd.h>
+
+#define UPLOAD_DIR "./uploads"
+#define PROCESSED_DIR "./media"
+#define MAX_UPLOAD_SIZE (100 * 1024 * 1024)  // 100MB
+
+// Background processing queue
+typedef struct ProcessJob {
+    char* input_path;
+    char* id;
+    MediaType type;
+    struct ProcessJob* next;
+} ProcessJob;
+
+static ProcessJob* job_queue_head = NULL;
+static ProcessJob* job_queue_tail = NULL;
+static pthread_mutex_t job_queue_mutex = PTHREAD_MUTEX_INITIALIZER;
+static pthread_cond_t job_queue_cond = PTHREAD_COND_INITIALIZER;
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+static void ensure_directories(void) {
+    mkdir(UPLOAD_DIR, 0755);
+    mkdir(PROCESSED_DIR, 0755);
+}
+
+static char* extract_filename_from_content_disposition(const char* header, Dowa_Arena* arena) {
+    const char* filename_start = strstr(header, "filename=\"");
+    if (!filename_start) return NULL;
+
+    filename_start += 10;  // Skip 'filename="'
+    const char* filename_end = strchr(filename_start, '"');
+    if (!filename_end) return NULL;
+
+    size_t len = filename_end - filename_start;
+    char* filename = Dowa_Arena_Allocate(arena, len + 1);
+    memcpy(filename, filename_start, len);
+    filename[len] = '\0';
+    return filename;
+}
+
+static char* find_multipart_boundary(const char* content_type, Dowa_Arena* arena) {
+    const char* boundary_start = strstr(content_type, "boundary=");
+    if (!boundary_start) return NULL;
+
+    boundary_start += 9;  // Skip 'boundary='
+
+    // Handle quoted boundary
+    if (*boundary_start == '"') {
+        boundary_start++;
+        const char* boundary_end = strchr(boundary_start, '"');
+        if (!boundary_end) return NULL;
+        size_t len = boundary_end - boundary_start;
+        char* boundary = Dowa_Arena_Allocate(arena, len + 1);
+        memcpy(boundary, boundary_start, len);
+        boundary[len] = '\0';
+        return boundary;
+    }
+
+    // Unquoted boundary
+    size_t len = strlen(boundary_start);
+    char* boundary = Dowa_Arena_Allocate(arena, len + 1);
+    strcpy(boundary, boundary_start);
+    // Trim whitespace/newline
+    while (len > 0 && (boundary[len-1] == '\r' || boundary[len-1] == '\n' || boundary[len-1] == ' ')) {
+        boundary[--len] = '\0';
+    }
+    return boundary;
+}
+
+// ============================================================================
+// Background Processing Worker
+// ============================================================================
+
+static void enqueue_job(const char* input_path, const char* id, MediaType type) {
+    ProcessJob* job = malloc(sizeof(ProcessJob));
+    job->input_path = strdup(input_path);
+    job->id = strdup(id);
+    job->type = type;
+    job->next = NULL;
+
+    pthread_mutex_lock(&job_queue_mutex);
+    if (job_queue_tail) {
+        job_queue_tail->next = job;
+        job_queue_tail = job;
+    } else {
+        job_queue_head = job_queue_tail = job;
+    }
+    pthread_cond_signal(&job_queue_cond);
+    pthread_mutex_unlock(&job_queue_mutex);
+}
+
+static void* processing_worker(void* arg) {
+    (void)arg;
+
+    while (1) {
+        pthread_mutex_lock(&job_queue_mutex);
+        while (!job_queue_head) {
+            pthread_cond_wait(&job_queue_cond, &job_queue_mutex);
+        }
+        ProcessJob* job = job_queue_head;
+        job_queue_head = job->next;
+        if (!job_queue_head) job_queue_tail = NULL;
+        pthread_mutex_unlock(&job_queue_mutex);
+
+        // Process the job
+        Seobeo_Log(SEOBEO_INFO, "Processing media: %s (id=%s)", job->input_path, job->id);
+
+        MediaProcessorOpts opts = media_processor_opts_default(PROCESSED_DIR);
+        MediaResult* result = media_process_file(job->input_path, job->id, &opts);
+
+        if (result->status == MEDIA_STATUS_COMPLETED) {
+            Seobeo_Log(SEOBEO_INFO, "Processing completed for %s", job->id);
+        } else {
+            Seobeo_Log(SEOBEO_ERROR, "Processing failed for %s: %s", job->id, result->error_message);
+        }
+
+        media_result_free(result);
+
+        // Clean up temp upload file
+        unlink(job->input_path);
+
+        free(job->input_path);
+        free(job->id);
+        free(job);
+    }
+
+    return NULL;
+}
+
+void Seobeo_Render_Html(
+    char *final_body,
+    char *template,
+    Dowa_Arena *arena
+)
+{
+  size_t current_offset = 0;
+  char *cursor = template;
+
+  int32 token_len = 2;
+
+  while (1)
+  {
+    char *start_tag = strstr(cursor, "{{");
+    if (!start_tag) break;
+
+    char *end_tag = strstr(start_tag, "}}");
+    if (!end_tag) break;
+
+    size_t leading_len = start_tag - cursor;
+    memcpy(final_body + current_offset, cursor, leading_len);
+    current_offset += leading_len;
+
+    size_t name_len = end_tag - (start_tag + token_len);
+    char *include_name = Dowa_Arena_Allocate(arena, name_len + 1);
+    memcpy(include_name, start_tag + token_len, name_len);
+    include_name[name_len] = '\0';
+
+    size_t sub_file_size = 0;
+    char *sub_content = Seobeo_Web_LoadFile(include_name, &sub_file_size);
+    Seobeo_Log(SEOBEO_DEBUG, "[Curr] Sub content: %s\n", sub_content);
+    if (sub_content)
+    {
+      memcpy(final_body + current_offset, sub_content, sub_file_size);
+      current_offset += sub_file_size;
+      free(sub_content);
+    }
+
+    cursor = end_tag + 2;
+  }
+  strcpy(final_body + current_offset, cursor);
+}
+
+
+void Seobeo_Render_Html_FilePath(
+    char *final_body,
+    char *path,
+    Dowa_Arena *arena
+) {
+  Seobeo_Log(SEOBEO_DEBUG, "[Curr] %s\n", path);
+  size_t html_size = 0;
+  char *template = Seobeo_Web_LoadFile(path, &html_size);
+  if (!template) return;
+  Seobeo_Render_Html(final_body, template, arena);
+}
+
+// ============================================================================
+// API Route Handlers
+// ============================================================================
+
+// GET / - Serve the main HTML page
+Seobeo_Request_Entry* handle_index(Seobeo_Request_Entry* req, Dowa_Arena* arena) {
+  Seobeo_Request_Entry *resp = NULL; 
+  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
+  Seobeo_Render_Html_FilePath(final_body, "/index.html", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  return resp;
+}
+
+// POST /api/upload - Handle file upload
+Seobeo_Request_Entry* handle_upload(Seobeo_Request_Entry* req, Dowa_Arena* arena) {
+    const char* content_type = Dowa_HashMap_Get(req, "content-type");
+    const char* body = Dowa_HashMap_Get(req, "Body");
+    const char* content_length_str = Dowa_HashMap_Get(req, "content-length");
+
+    Seobeo_Request_Entry* resp = NULL;
+
+    if (!content_type || !body) {
+        Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Missing content\"}", arena);
+        return resp;
+    }
+
+    size_t content_length = content_length_str ? atol(content_length_str) : strlen(body);
+
+    if (content_length > MAX_UPLOAD_SIZE) {
+        Dowa_HashMap_Push_Arena(resp, "status", "413", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"File too large\"}", arena);
+        return resp;
+    }
+
+    // Parse multipart form data
+    char* boundary = find_multipart_boundary(content_type, arena);
+    if (!boundary) {
+        Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Invalid multipart data\"}", arena);
+        return resp;
+    }
+
+    // Find file content in multipart data
+    char boundary_marker[256];
+    snprintf(boundary_marker, sizeof(boundary_marker), "--%s", boundary);
+
+    const char* part_start = strstr(body, boundary_marker);
+    if (!part_start) {
+        Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"No file found\"}", arena);
+        return resp;
+    }
+
+    // Skip to headers
+    part_start = strstr(part_start, "\r\n");
+    if (!part_start) {
+        Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Invalid format\"}", arena);
+        return resp;
+    }
+    part_start += 2;
+
+    // Find filename from Content-Disposition
+    char* filename = NULL;
+    const char* header_end = strstr(part_start, "\r\n\r\n");
+    if (header_end) {
+        char headers[1024] = {0};
+        size_t headers_len = header_end - part_start;
+        if (headers_len < sizeof(headers)) {
+            memcpy(headers, part_start, headers_len);
+            filename = extract_filename_from_content_disposition(headers, arena);
+        }
+    }
+
+    if (!filename) {
+        filename = "upload";
+    }
+
+    // Find file content
+    const char* file_start = header_end + 4;  // Skip \r\n\r\n
+
+    // Find end boundary
+    char end_boundary[256];
+    snprintf(end_boundary, sizeof(end_boundary), "\r\n--%s", boundary);
+    const char* file_end = strstr(file_start, end_boundary);
+
+    if (!file_end) {
+        Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Incomplete upload\"}", arena);
+        return resp;
+    }
+
+    size_t file_size = file_end - file_start;
+
+    // Generate ID and determine type
+    char* id = media_generate_id();
+    MediaType type = media_detect_type(filename);
+
+    if (type == MEDIA_TYPE_UNKNOWN) {
+        free(id);
+        Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Unsupported file type\"}", arena);
+        return resp;
+    }
+
+    // Save to temp file
+    const char* ext = media_get_extension(filename);
+    char temp_path[512];
+    snprintf(temp_path, sizeof(temp_path), "%s/%s%s", UPLOAD_DIR, id, ext ? ext : "");
+
+    FILE* f = fopen(temp_path, "wb");
+    if (!f) {
+        free(id);
+        Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Failed to save file\"}", arena);
+        return resp;
+    }
+
+    fwrite(file_start, 1, file_size, f);
+    fclose(f);
+
+    // Enqueue for processing
+    enqueue_job(temp_path, id, type);
+
+    // Return success with ID
+    char response_body[256];
+    snprintf(response_body, sizeof(response_body),
+        "{\"id\":\"%s\",\"type\":\"%s\",\"status\":\"processing\"}",
+        id, type == MEDIA_TYPE_IMAGE ? "image" : "video");
+
+    char* body_copy = Dowa_Arena_Allocate(arena, strlen(response_body) + 1);
+    strcpy(body_copy, response_body);
+
+    Dowa_HashMap_Push_Arena(resp, "status", "202", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "Body", body_copy, arena);
+
+    free(id);
+    return resp;
+}
+
+// GET /api/media/:id - Get media info/status
+Seobeo_Request_Entry* handle_media_info(Seobeo_Request_Entry* req, Dowa_Arena* arena) {
+    const char* path = Dowa_HashMap_Get(req, "path");
+
+    Seobeo_Request_Entry* resp = NULL;
+
+    // Extract ID from path (e.g., /api/media/123456)
+    const char* id = path + strlen("/api/media/");
+    if (!id || strlen(id) == 0) {
+        Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Missing media ID\"}", arena);
+        return resp;
+    }
+
+    // Check if processed directory exists
+    char media_dir[512];
+    snprintf(media_dir, sizeof(media_dir), "%s/%s", PROCESSED_DIR, id);
+
+    struct stat st;
+    if (stat(media_dir, &st) != 0) {
+        // Check if still processing
+        char upload_pattern[512];
+        snprintf(upload_pattern, sizeof(upload_pattern), "%s/%s", UPLOAD_DIR, id);
+
+        DIR* dir = opendir(UPLOAD_DIR);
+        boolean found = FALSE;
+        if (dir) {
+            struct dirent* entry;
+            while ((entry = readdir(dir)) != NULL) {
+                if (strncmp(entry->d_name, id, strlen(id)) == 0) {
+                    found = TRUE;
+                    break;
+                }
+            }
+            closedir(dir);
+        }
+
+        if (found) {
+            Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+            Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+            Dowa_HashMap_Push_Arena(resp, "Body", "{\"status\":\"processing\"}", arena);
+        } else {
+            Dowa_HashMap_Push_Arena(resp, "status", "404", arena);
+            Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+            Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Media not found\"}", arena);
+        }
+        return resp;
+    }
+
+    // Check what files exist
+    char webp_path[512], hls_path[512], thumb_path[512];
+    snprintf(webp_path, sizeof(webp_path), "%s/image.webp", media_dir);
+    snprintf(hls_path, sizeof(hls_path), "%s/video.m3u8", media_dir);
+    snprintf(thumb_path, sizeof(thumb_path), "%s/video_thumb.jpg", media_dir);
+
+    boolean is_image = (stat(webp_path, &st) == 0);
+    boolean is_video = (stat(hls_path, &st) == 0);
+
+    char response[1024];
+    if (is_image) {
+        snprintf(response, sizeof(response),
+            "{\"id\":\"%s\",\"type\":\"image\",\"status\":\"completed\","
+            "\"webp\":\"/media/%s/image.webp\","
+            "\"original\":\"/media/%s/original\"}",
+            id, id, id);
+    } else if (is_video) {
+        snprintf(response, sizeof(response),
+            "{\"id\":\"%s\",\"type\":\"video\",\"status\":\"completed\","
+            "\"hls\":\"/media/%s/video.m3u8\","
+            "\"thumbnail\":\"/media/%s/video_thumb.jpg\","
+            "\"original\":\"/media/%s/original\"}",
+            id, id, id, id);
+    } else {
+        snprintf(response, sizeof(response), "{\"id\":\"%s\",\"status\":\"processing\"}", id);
+    }
+
+    char* body = Dowa_Arena_Allocate(arena, strlen(response) + 1);
+    strcpy(body, response);
+
+    Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "Body", body, arena);
+
+    return resp;
+}
+
+// GET /api/media - List all media
+Seobeo_Request_Entry* handle_media_list(Seobeo_Request_Entry* req, Dowa_Arena* arena) {
+    (void)req;
+
+    Seobeo_Request_Entry* resp = NULL;
+
+    DIR* dir = opendir(PROCESSED_DIR);
+    if (!dir) {
+        Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+        Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+        Dowa_HashMap_Push_Arena(resp, "Body", "{\"items\":[]}", arena);
+        return resp;
+    }
+
+    // Build JSON array
+    char json[16384] = "{\"items\":[";
+    size_t json_len = strlen(json);
+    boolean first = TRUE;
+
+    struct dirent* entry;
+    while ((entry = readdir(dir)) != NULL) {
+        if (entry->d_name[0] == '.') continue;
+
+        char media_dir[512];
+        snprintf(media_dir, sizeof(media_dir), "%s/%s", PROCESSED_DIR, entry->d_name);
+
+        struct stat st;
+        if (stat(media_dir, &st) != 0 || !S_ISDIR(st.st_mode)) continue;
+
+        // Check type
+        char webp_path[512], hls_path[512];
+        snprintf(webp_path, sizeof(webp_path), "%s/image.webp", media_dir);
+        snprintf(hls_path, sizeof(hls_path), "%s/video.m3u8", media_dir);
+
+        const char* type = "unknown";
+        if (stat(webp_path, &st) == 0) type = "image";
+        else if (stat(hls_path, &st) == 0) type = "video";
+
+        char item[256];
+        snprintf(item, sizeof(item), "%s{\"id\":\"%s\",\"type\":\"%s\"}",
+            first ? "" : ",", entry->d_name, type);
+
+        size_t item_len = strlen(item);
+        if (json_len + item_len + 10 < sizeof(json)) {
+            strcpy(json + json_len, item);
+            json_len += item_len;
+            first = FALSE;
+        }
+    }
+
+    closedir(dir);
+
+    strcpy(json + json_len, "]}");
+
+    char* body = Dowa_Arena_Allocate(arena, strlen(json) + 1);
+    strcpy(body, json);
+
+    Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "Body", body, arena);
+
+    return resp;
+}
+
+// ============================================================================
+// Main
+// ============================================================================
+
+int main(int argc, char** argv) {
+    const char* port = "8080";
+
+    printf("===========================================\n");
+    printf("  Medi - Media Upload & Processing Server\n");
+    printf("===========================================\n");
+    printf("Starting on port %s...\n\n", port);
+
+    // Create directories
+    ensure_directories();
+
+    // Start background processing worker
+    pthread_t worker_thread;
+    pthread_create(&worker_thread, NULL, processing_worker, NULL);
+
+    // Initialize router
+    Seobeo_Router_Init();
+
+    // Register routes
+    Seobeo_Router_Register("GET", "/", handle_index);
+    Seobeo_Router_Register("POST", "/api/upload", handle_upload);
+    Seobeo_Router_Register("GET", "/api/media", handle_media_list);
+    Seobeo_Router_Register("GET", "/api/media/*", handle_media_info);
+
+    // Start server (serves static files from ./media for /media/* paths)
+    Seobeo_Web_Server_Start("medi/src", port, SEOBEO_MODE_EDGE, 4);
+
+    return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/medi/src/app.tsx	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,232 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+
+interface MediaItem {
+  id: string;
+  type: 'image' | 'video';
+  status: 'processing' | 'completed' | 'error';
+  webp?: string;
+  hls?: string;
+  thumbnail?: string;
+  original?: string;
+  error?: string;
+}
+
+const UploadIcon = () => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
+    <polyline points="17 8 12 3 7 8" />
+    <line x1="12" y1="3" x2="12" y2="15" />
+  </svg>
+);
+
+export function App() {
+  const [media, setMedia] = useState<MediaItem[]>([]);
+  const [uploading, setUploading] = useState(false);
+  const [dragover, setDragover] = useState(false);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // Fetch media list on mount
+  useEffect(() => {
+    fetchMediaList();
+  }, []);
+
+  // Poll for processing status
+  useEffect(() => {
+    const processingItems = media.filter(m => m.status === 'processing');
+    if (processingItems.length === 0) return;
+
+    const interval = setInterval(() => {
+      processingItems.forEach(item => {
+        checkMediaStatus(item.id);
+      });
+    }, 2000);
+
+    return () => clearInterval(interval);
+  }, [media]);
+
+  const fetchMediaList = async () => {
+    try {
+      const res = await fetch('/api/media');
+      const data = await res.json();
+
+      // Fetch details for each item
+      const items = await Promise.all(
+        data.items.map(async (item: { id: string; type: string }) => {
+          const infoRes = await fetch(`/api/media/${item.id}`);
+          return infoRes.json();
+        })
+      );
+
+      setMedia(items);
+    } catch (err) {
+      console.error('Failed to fetch media list:', err);
+    }
+  };
+
+  const checkMediaStatus = async (id: string) => {
+    try {
+      const res = await fetch(`/api/media/${id}`);
+      const data = await res.json();
+
+      setMedia(prev => prev.map(item =>
+        item.id === id ? { ...item, ...data } : item
+      ));
+    } catch (err) {
+      console.error('Failed to check status:', err);
+    }
+  };
+
+  const uploadFile = async (file: File) => {
+    const formData = new FormData();
+    formData.append('file', file);
+
+    setUploading(true);
+
+    try {
+      const res = await fetch('/api/upload', {
+        method: 'POST',
+        body: formData,
+      });
+
+      const data = await res.json();
+
+      if (data.error) {
+        throw new Error(data.error);
+      }
+
+      // Add to list with processing status
+      setMedia(prev => [{
+        id: data.id,
+        type: data.type,
+        status: 'processing',
+      }, ...prev]);
+
+    } catch (err: any) {
+      console.error('Upload failed:', err);
+      alert('Upload failed: ' + err.message);
+    } finally {
+      setUploading(false);
+    }
+  };
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (files && files.length > 0) {
+      Array.from(files).forEach(uploadFile);
+    }
+    e.target.value = '';
+  };
+
+  const handleDrop = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setDragover(false);
+
+    const files = e.dataTransfer.files;
+    if (files && files.length > 0) {
+      Array.from(files).forEach(uploadFile);
+    }
+  }, []);
+
+  const handleDragOver = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setDragover(true);
+  }, []);
+
+  const handleDragLeave = useCallback(() => {
+    setDragover(false);
+  }, []);
+
+  return (
+    <>
+      <h1>Medi</h1>
+      <p className="subtitle">Upload images and videos for processing</p>
+
+      <div
+        className={`upload-area ${dragover ? 'dragover' : ''}`}
+        onClick={() => fileInputRef.current?.click()}
+        onDrop={handleDrop}
+        onDragOver={handleDragOver}
+        onDragLeave={handleDragLeave}
+      >
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept="image/*,video/*"
+          multiple
+          onChange={handleFileSelect}
+        />
+        {uploading ? (
+          <>
+            <div className="loading-spinner" />
+            <h3>Uploading...</h3>
+          </>
+        ) : (
+          <>
+            <UploadIcon />
+            <h3>Drop files here or click to upload</h3>
+            <p>Supports images (JPG, PNG, GIF, WebP) and videos (MP4, MOV, WebM)</p>
+          </>
+        )}
+      </div>
+
+      {media.length === 0 ? (
+        <div className="empty-state">
+          <p>No media uploaded yet</p>
+        </div>
+      ) : (
+        <div className="media-grid">
+          {media.map(item => (
+            <MediaCard key={item.id} item={item} />
+          ))}
+        </div>
+      )}
+    </>
+  );
+}
+
+function MediaCard({ item }: { item: MediaItem }) {
+  const [videoError, setVideoError] = useState(false);
+
+  return (
+    <div className="media-card">
+      <div className="media-preview">
+        {item.status === 'processing' ? (
+          <div className="loading-spinner" />
+        ) : item.type === 'image' && item.webp ? (
+          <img src={item.webp} alt={item.id} />
+        ) : item.type === 'video' && item.thumbnail ? (
+          <img src={item.thumbnail} alt={item.id} />
+        ) : (
+          <span style={{ color: '#8b949e' }}>No preview</span>
+        )}
+      </div>
+
+      <div className="media-info">
+        <h4>{item.id}</h4>
+        <span className="type">{item.type}</span>
+        <div>
+          <span className={`status ${item.status}`}>
+            {item.status === 'processing' ? 'Processing...' :
+             item.status === 'completed' ? 'Ready' : 'Error'}
+          </span>
+        </div>
+      </div>
+
+      {item.status === 'completed' && (
+        <div className="media-links">
+          {item.type === 'image' ? (
+            <>
+              <a href={item.webp} target="_blank" rel="noreferrer">WebP</a>
+              <a href={item.original} target="_blank" rel="noreferrer">Original</a>
+            </>
+          ) : (
+            <>
+              <a href={item.hls} target="_blank" rel="noreferrer">HLS Stream</a>
+              <a href={item.thumbnail} target="_blank" rel="noreferrer">Thumbnail</a>
+            </>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/medi/src/index.html	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,191 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Medi - Media Upload</title>
+    <style>
+        :root {
+            --bg: #0d1117;
+            --bg-secondary: #161b22;
+            --border: #30363d;
+            --text: #e6edf3;
+            --text-secondary: #8b949e;
+            --accent: #58a6ff;
+            --success: #3fb950;
+            --error: #f85149;
+        }
+
+        * {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+        }
+
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: var(--bg);
+            color: var(--text);
+            min-height: 100vh;
+        }
+
+        #root {
+            max-width: 1200px;
+            margin: 0 auto;
+            padding: 40px 20px;
+        }
+
+        h1 {
+            font-size: 2rem;
+            margin-bottom: 8px;
+        }
+
+        .subtitle {
+            color: var(--text-secondary);
+            margin-bottom: 32px;
+        }
+
+        .upload-area {
+            border: 2px dashed var(--border);
+            border-radius: 12px;
+            padding: 60px 40px;
+            text-align: center;
+            cursor: pointer;
+            transition: all 0.2s;
+            background: var(--bg-secondary);
+            margin-bottom: 32px;
+        }
+
+        .upload-area:hover, .upload-area.dragover {
+            border-color: var(--accent);
+            background: rgba(88, 166, 255, 0.1);
+        }
+
+        .upload-area svg {
+            width: 64px;
+            height: 64px;
+            margin-bottom: 16px;
+            color: var(--text-secondary);
+        }
+
+        .upload-area h3 {
+            margin-bottom: 8px;
+        }
+
+        .upload-area p {
+            color: var(--text-secondary);
+            font-size: 14px;
+        }
+
+        .upload-area input {
+            display: none;
+        }
+
+        .media-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+            gap: 20px;
+        }
+
+        .media-card {
+            background: var(--bg-secondary);
+            border: 1px solid var(--border);
+            border-radius: 12px;
+            overflow: hidden;
+        }
+
+        .media-preview {
+            aspect-ratio: 16/9;
+            background: #000;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            position: relative;
+        }
+
+        .media-preview img, .media-preview video {
+            max-width: 100%;
+            max-height: 100%;
+            object-fit: contain;
+        }
+
+        .media-info {
+            padding: 16px;
+        }
+
+        .media-info h4 {
+            font-size: 14px;
+            margin-bottom: 4px;
+            word-break: break-all;
+        }
+
+        .media-info .type {
+            font-size: 12px;
+            color: var(--text-secondary);
+            text-transform: uppercase;
+        }
+
+        .media-info .status {
+            font-size: 12px;
+            margin-top: 8px;
+            padding: 4px 8px;
+            border-radius: 4px;
+            display: inline-block;
+        }
+
+        .status.processing {
+            background: rgba(88, 166, 255, 0.2);
+            color: var(--accent);
+        }
+
+        .status.completed {
+            background: rgba(63, 185, 80, 0.2);
+            color: var(--success);
+        }
+
+        .status.error {
+            background: rgba(248, 81, 73, 0.2);
+            color: var(--error);
+        }
+
+        .media-links {
+            padding: 0 16px 16px;
+            display: flex;
+            gap: 8px;
+        }
+
+        .media-links a {
+            font-size: 12px;
+            color: var(--accent);
+            text-decoration: none;
+        }
+
+        .media-links a:hover {
+            text-decoration: underline;
+        }
+
+        .loading-spinner {
+            width: 40px;
+            height: 40px;
+            border: 3px solid var(--border);
+            border-top-color: var(--accent);
+            border-radius: 50%;
+            animation: spin 1s linear infinite;
+        }
+
+        @keyframes spin {
+            to { transform: rotate(360deg); }
+        }
+
+        .empty-state {
+            text-align: center;
+            padding: 60px 20px;
+            color: var(--text-secondary);
+        }
+    </style>
+</head>
+<body>
+    <div id="root"></div>
+    <script src="/medi_bundle.js"></script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/medi/src/main.tsx	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,6 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './app';
+
+const root = createRoot(document.getElementById('root')!);
+root.render(<App />);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/media_processor/BUILD	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,19 @@
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+cc_library(
+    name = "media_processor",
+    hdrs = ["media_processor.h"],
+    srcs = ["media_processor.c"],
+    deps = [
+        "//third_party/ffmpeg:ffmpeg_cli",
+        "//dowa:dowa",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+cc_test(
+    name = "media_processor_test",
+    srcs = ["media_processor_test.c"],
+    deps = [":media_processor"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/media_processor/README.md	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,85 @@
+# media_processor
+
+Reusable media processing library for images and videos.
+
+## Features
+
+- **Image Processing**: Convert images to WebP format with quality/size options
+- **Video Processing**: Convert videos to HLS format for streaming
+- **Auto-detection**: Automatically detects media type from file extension
+- **Thumbnail Generation**: Creates thumbnails for videos
+- **Original Preservation**: Keeps original files alongside processed versions
+
+## Usage
+
+```c
+#include "media_processor/media_processor.h"
+
+// Process any media file (auto-detects type)
+MediaProcessorOpts opts = media_processor_opts_default("./output");
+opts.image_quality = 85;
+opts.video_max_width = 1920;
+
+char* id = media_generate_id();
+MediaResult* result = media_process_file("upload.mp4", id, &opts);
+
+if (result->status == MEDIA_STATUS_COMPLETED) {
+    printf("HLS playlist: %s\n", result->hls_playlist);
+    printf("Thumbnail: %s\n", result->thumbnail_path);
+}
+
+media_result_free(result);
+free(id);
+```
+
+## Output Structure
+
+For images:
+```
+output/
+└── {id}/
+    ├── original.jpg    # Original file
+    └── image.webp      # Converted WebP
+```
+
+For videos:
+```
+output/
+└── {id}/
+    ├── original.mp4    # Original file
+    ├── video.m3u8      # HLS playlist
+    ├── video_000.ts    # HLS segments
+    ├── video_001.ts
+    └── video_thumb.jpg # Thumbnail
+```
+
+## Options
+
+### Image Options
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `image_quality` | 80 | WebP quality (0-100) |
+| `image_max_width` | 0 | Max width (0 = no limit) |
+| `image_max_height` | 0 | Max height (0 = no limit) |
+
+### Video Options
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `video_segment_duration` | 6 | HLS segment length in seconds |
+| `video_bitrate` | 0 | Video bitrate in kbps (0 = auto) |
+| `video_max_width` | 0 | Max width (0 = no limit) |
+| `video_max_height` | 0 | Max height (0 = no limit) |
+| `generate_thumbnail` | true | Generate video thumbnail |
+
+## Building
+
+```bash
+bazel build //media_processor:media_processor
+```
+
+## Dependencies
+
+- `//third_party/ffmpeg:ffmpeg_cli` - FFmpeg CLI wrapper
+- `//dowa:dowa` - Memory and string utilities
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/media_processor/media_processor.c	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,316 @@
+#include "media_processor.h"
+#include "third_party/ffmpeg/ffmpeg_cli.h"
+#include "dowa/dowa.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdarg.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/stat.h>
+#include <errno.h>
+
+// File extension mappings
+static const struct {
+    const char* ext;
+    MediaType type;
+} EXTENSION_MAP[] = {
+    // Images
+    {".jpg", MEDIA_TYPE_IMAGE},
+    {".jpeg", MEDIA_TYPE_IMAGE},
+    {".png", MEDIA_TYPE_IMAGE},
+    {".gif", MEDIA_TYPE_IMAGE},
+    {".webp", MEDIA_TYPE_IMAGE},
+    {".bmp", MEDIA_TYPE_IMAGE},
+    {".tiff", MEDIA_TYPE_IMAGE},
+    {".tif", MEDIA_TYPE_IMAGE},
+    {".heic", MEDIA_TYPE_IMAGE},
+    {".heif", MEDIA_TYPE_IMAGE},
+    {".avif", MEDIA_TYPE_IMAGE},
+
+    // Videos
+    {".mp4", MEDIA_TYPE_VIDEO},
+    {".mov", MEDIA_TYPE_VIDEO},
+    {".avi", MEDIA_TYPE_VIDEO},
+    {".mkv", MEDIA_TYPE_VIDEO},
+    {".webm", MEDIA_TYPE_VIDEO},
+    {".flv", MEDIA_TYPE_VIDEO},
+    {".wmv", MEDIA_TYPE_VIDEO},
+    {".m4v", MEDIA_TYPE_VIDEO},
+    {".3gp", MEDIA_TYPE_VIDEO},
+
+    // Audio
+    {".mp3", MEDIA_TYPE_AUDIO},
+    {".wav", MEDIA_TYPE_AUDIO},
+    {".flac", MEDIA_TYPE_AUDIO},
+    {".aac", MEDIA_TYPE_AUDIO},
+    {".ogg", MEDIA_TYPE_AUDIO},
+    {".m4a", MEDIA_TYPE_AUDIO},
+    {".wma", MEDIA_TYPE_AUDIO},
+
+    {NULL, MEDIA_TYPE_UNKNOWN}
+};
+
+MediaProcessorOpts media_processor_opts_default(const char* output_dir) {
+    return (MediaProcessorOpts){
+        .output_dir = output_dir,
+        .image_quality = 80,
+        .image_max_width = 0,
+        .image_max_height = 0,
+        .video_segment_duration = 6,
+        .video_bitrate = 0,
+        .video_max_width = 0,
+        .video_max_height = 0,
+        .generate_thumbnail = true,
+    };
+}
+
+const char* media_get_extension(const char* filename) {
+    if (!filename) return NULL;
+    const char* dot = strrchr(filename, '.');
+    return dot ? dot : NULL;
+}
+
+MediaType media_detect_type(const char* filename) {
+    const char* ext = media_get_extension(filename);
+    if (!ext) return MEDIA_TYPE_UNKNOWN;
+
+    // Convert to lowercase for comparison
+    char ext_lower[16] = {0};
+    size_t len = strlen(ext);
+    if (len >= sizeof(ext_lower)) return MEDIA_TYPE_UNKNOWN;
+
+    for (size_t i = 0; i < len; i++) {
+        ext_lower[i] = (ext[i] >= 'A' && ext[i] <= 'Z') ? ext[i] + 32 : ext[i];
+    }
+
+    for (int i = 0; EXTENSION_MAP[i].ext != NULL; i++) {
+        if (strcmp(ext_lower, EXTENSION_MAP[i].ext) == 0) {
+            return EXTENSION_MAP[i].type;
+        }
+    }
+
+    return MEDIA_TYPE_UNKNOWN;
+}
+
+const char* media_type_to_mime(MediaType type) {
+    switch (type) {
+        case MEDIA_TYPE_IMAGE: return "image/*";
+        case MEDIA_TYPE_VIDEO: return "video/*";
+        case MEDIA_TYPE_AUDIO: return "audio/*";
+        default: return "application/octet-stream";
+    }
+}
+
+static MediaResult* create_result(void) {
+    MediaResult* result = calloc(1, sizeof(MediaResult));
+    result->status = MEDIA_STATUS_PENDING;
+    return result;
+}
+
+static void set_result_error(MediaResult* result, const char* fmt, ...) {
+    result->status = MEDIA_STATUS_FAILED;
+
+    va_list args;
+    va_start(args, fmt);
+
+    char buf[512];
+    vsnprintf(buf, sizeof(buf), fmt, args);
+    result->error_message = strdup(buf);
+
+    va_end(args);
+}
+
+void media_result_free(MediaResult* result) {
+    if (!result) return;
+    free(result->error_message);
+    free(result->original_path);
+    free(result->webp_path);
+    free(result->hls_playlist);
+    free(result->thumbnail_path);
+    free(result);
+}
+
+char* media_generate_id(void) {
+    static int counter = 0;
+    char buf[64];
+    snprintf(buf, sizeof(buf), "%ld_%d_%d",
+        (long)time(NULL), getpid(), counter++);
+    return strdup(buf);
+}
+
+bool media_path_is_safe(const char* path) {
+    if (!path) return false;
+    // Check for directory traversal
+    if (strstr(path, "..") != NULL) return false;
+    // Check for absolute paths (in uploaded filenames)
+    if (path[0] == '/') return false;
+    return true;
+}
+
+static bool ensure_directory(const char* path) {
+    struct stat st;
+    if (stat(path, &st) == 0) {
+        return S_ISDIR(st.st_mode);
+    }
+    return mkdir(path, 0755) == 0;
+}
+
+bool media_copy_file(const char* src, const char* dst) {
+    FILE* in = fopen(src, "rb");
+    if (!in) return false;
+
+    FILE* out = fopen(dst, "wb");
+    if (!out) {
+        fclose(in);
+        return false;
+    }
+
+    char buf[8192];
+    size_t n;
+    while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
+        if (fwrite(buf, 1, n, out) != n) {
+            fclose(in);
+            fclose(out);
+            return false;
+        }
+    }
+
+    fclose(in);
+    fclose(out);
+    return true;
+}
+
+MediaResult* media_process_image(const char* input_path, const char* id, MediaProcessorOpts* opts) {
+    MediaResult* result = create_result();
+    result->status = MEDIA_STATUS_PROCESSING;
+
+    if (!opts || !opts->output_dir) {
+        set_result_error(result, "Output directory not specified");
+        return result;
+    }
+
+    if (!ensure_directory(opts->output_dir)) {
+        set_result_error(result, "Failed to create output directory: %s", opts->output_dir);
+        return result;
+    }
+
+    // Create subdirectory for this media
+    char media_dir[1024];
+    snprintf(media_dir, sizeof(media_dir), "%s/%s", opts->output_dir, id);
+    if (!ensure_directory(media_dir)) {
+        set_result_error(result, "Failed to create media directory: %s", media_dir);
+        return result;
+    }
+
+    // Copy original
+    const char* ext = media_get_extension(input_path);
+    char original_path[1024];
+    snprintf(original_path, sizeof(original_path), "%s/original%s", media_dir, ext ? ext : "");
+
+    if (!media_copy_file(input_path, original_path)) {
+        set_result_error(result, "Failed to copy original file");
+        return result;
+    }
+    result->original_path = strdup(original_path);
+
+    // Convert to WebP
+    char webp_path[1024];
+    snprintf(webp_path, sizeof(webp_path), "%s/image.webp", media_dir);
+
+    ImageConvertOpts img_opts = ffmpeg_image_opts_default();
+    img_opts.quality = opts->image_quality;
+    img_opts.max_width = opts->image_max_width;
+    img_opts.max_height = opts->image_max_height;
+
+    FfmpegResult ffmpeg_result = ffmpeg_image_to_webp(input_path, webp_path, &img_opts);
+    if (ffmpeg_result != FFMPEG_OK) {
+        set_result_error(result, "FFmpeg conversion failed: %s", ffmpeg_last_error());
+        return result;
+    }
+
+    result->webp_path = strdup(webp_path);
+    result->status = MEDIA_STATUS_COMPLETED;
+    return result;
+}
+
+MediaResult* media_process_video(const char* input_path, const char* id, MediaProcessorOpts* opts) {
+    MediaResult* result = create_result();
+    result->status = MEDIA_STATUS_PROCESSING;
+
+    if (!opts || !opts->output_dir) {
+        set_result_error(result, "Output directory not specified");
+        return result;
+    }
+
+    if (!ensure_directory(opts->output_dir)) {
+        set_result_error(result, "Failed to create output directory: %s", opts->output_dir);
+        return result;
+    }
+
+    // Create subdirectory for this media
+    char media_dir[1024];
+    snprintf(media_dir, sizeof(media_dir), "%s/%s", opts->output_dir, id);
+    if (!ensure_directory(media_dir)) {
+        set_result_error(result, "Failed to create media directory: %s", media_dir);
+        return result;
+    }
+
+    // Copy original
+    const char* ext = media_get_extension(input_path);
+    char original_path[1024];
+    snprintf(original_path, sizeof(original_path), "%s/original%s", media_dir, ext ? ext : "");
+
+    if (!media_copy_file(input_path, original_path)) {
+        set_result_error(result, "Failed to copy original file");
+        return result;
+    }
+    result->original_path = strdup(original_path);
+
+    // Get duration
+    ffmpeg_get_duration(input_path, &result->duration);
+
+    // Convert to HLS
+    HlsConvertOpts hls_opts = ffmpeg_hls_opts_default();
+    hls_opts.segment_duration = opts->video_segment_duration;
+    hls_opts.video_bitrate = opts->video_bitrate;
+    hls_opts.max_width = opts->video_max_width;
+    hls_opts.max_height = opts->video_max_height;
+    hls_opts.generate_thumbnail = opts->generate_thumbnail;
+
+    FfmpegResult ffmpeg_result = ffmpeg_video_to_hls(input_path, media_dir, "video", &hls_opts);
+    if (ffmpeg_result != FFMPEG_OK) {
+        set_result_error(result, "FFmpeg HLS conversion failed: %s", ffmpeg_last_error());
+        return result;
+    }
+
+    char playlist_path[1024];
+    snprintf(playlist_path, sizeof(playlist_path), "%s/video.m3u8", media_dir);
+    result->hls_playlist = strdup(playlist_path);
+
+    if (opts->generate_thumbnail) {
+        char thumb_path[1024];
+        snprintf(thumb_path, sizeof(thumb_path), "%s/video_thumb.jpg", media_dir);
+        result->thumbnail_path = strdup(thumb_path);
+    }
+
+    result->status = MEDIA_STATUS_COMPLETED;
+    return result;
+}
+
+MediaResult* media_process_file(const char* input_path, const char* id, MediaProcessorOpts* opts) {
+    MediaType type = media_detect_type(input_path);
+
+    switch (type) {
+        case MEDIA_TYPE_IMAGE:
+            return media_process_image(input_path, id, opts);
+        case MEDIA_TYPE_VIDEO:
+            return media_process_video(input_path, id, opts);
+        default: {
+            MediaResult* result = create_result();
+            set_result_error(result, "Unsupported media type");
+            return result;
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/media_processor/media_processor.h	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,90 @@
+#ifndef MEDIA_PROCESSOR_H
+#define MEDIA_PROCESSOR_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+// Media types
+typedef enum {
+    MEDIA_TYPE_UNKNOWN = 0,
+    MEDIA_TYPE_IMAGE,
+    MEDIA_TYPE_VIDEO,
+    MEDIA_TYPE_AUDIO,
+} MediaType;
+
+// Processing status
+typedef enum {
+    MEDIA_STATUS_PENDING = 0,
+    MEDIA_STATUS_PROCESSING,
+    MEDIA_STATUS_COMPLETED,
+    MEDIA_STATUS_FAILED,
+} MediaStatus;
+
+// Result of a processing operation
+typedef struct {
+    MediaStatus status;
+    char* error_message;
+
+    // For images
+    char* original_path;    // Path to original file
+    char* webp_path;        // Path to WebP version
+
+    // For videos
+    char* hls_playlist;     // Path to .m3u8 file
+    char* thumbnail_path;   // Path to thumbnail
+    double duration;        // Duration in seconds
+} MediaResult;
+
+// Processing options
+typedef struct {
+    // Output directory (required)
+    const char* output_dir;
+
+    // Image options
+    int image_quality;      // 0-100, default 80
+    int image_max_width;    // 0 = no limit
+    int image_max_height;   // 0 = no limit
+
+    // Video options
+    int video_segment_duration; // HLS segment duration, default 6
+    int video_bitrate;          // kbps, 0 = auto
+    int video_max_width;        // 0 = no limit
+    int video_max_height;       // 0 = no limit
+    bool generate_thumbnail;    // default true
+} MediaProcessorOpts;
+
+// Initialize processor options with defaults
+MediaProcessorOpts media_processor_opts_default(const char* output_dir);
+
+// Detect media type from file extension
+MediaType media_detect_type(const char* filename);
+
+// Get MIME type string
+const char* media_type_to_mime(MediaType type);
+
+// Process a single file (auto-detects type)
+// Returns a MediaResult that must be freed with media_result_free()
+MediaResult* media_process_file(const char* input_path, const char* id, MediaProcessorOpts* opts);
+
+// Process image specifically
+MediaResult* media_process_image(const char* input_path, const char* id, MediaProcessorOpts* opts);
+
+// Process video specifically
+MediaResult* media_process_video(const char* input_path, const char* id, MediaProcessorOpts* opts);
+
+// Free a MediaResult
+void media_result_free(MediaResult* result);
+
+// Generate a unique ID for a media file
+char* media_generate_id(void);
+
+// Utility: Copy file to destination
+bool media_copy_file(const char* src, const char* dst);
+
+// Utility: Get file extension (returns pointer into filename, do not free)
+const char* media_get_extension(const char* filename);
+
+// Utility: Check if path is safe (no directory traversal)
+bool media_path_is_safe(const char* path);
+
+#endif // MEDIA_PROCESSOR_H
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/media_processor/media_processor_test.c	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,109 @@
+#include "media_processor.h"
+#include <stdio.h>
+#include <assert.h>
+#include <string.h>
+
+void test_detect_type() {
+    printf("Testing media_detect_type...\n");
+
+    // Images
+    assert(media_detect_type("photo.jpg") == MEDIA_TYPE_IMAGE);
+    assert(media_detect_type("photo.JPEG") == MEDIA_TYPE_IMAGE);
+    assert(media_detect_type("image.png") == MEDIA_TYPE_IMAGE);
+    assert(media_detect_type("animation.gif") == MEDIA_TYPE_IMAGE);
+    assert(media_detect_type("modern.webp") == MEDIA_TYPE_IMAGE);
+    assert(media_detect_type("apple.heic") == MEDIA_TYPE_IMAGE);
+
+    // Videos
+    assert(media_detect_type("video.mp4") == MEDIA_TYPE_VIDEO);
+    assert(media_detect_type("movie.mov") == MEDIA_TYPE_VIDEO);
+    assert(media_detect_type("clip.avi") == MEDIA_TYPE_VIDEO);
+    assert(media_detect_type("stream.mkv") == MEDIA_TYPE_VIDEO);
+    assert(media_detect_type("web.webm") == MEDIA_TYPE_VIDEO);
+
+    // Audio
+    assert(media_detect_type("song.mp3") == MEDIA_TYPE_AUDIO);
+    assert(media_detect_type("audio.wav") == MEDIA_TYPE_AUDIO);
+    assert(media_detect_type("music.flac") == MEDIA_TYPE_AUDIO);
+
+    // Unknown
+    assert(media_detect_type("document.pdf") == MEDIA_TYPE_UNKNOWN);
+    assert(media_detect_type("archive.zip") == MEDIA_TYPE_UNKNOWN);
+    assert(media_detect_type("noextension") == MEDIA_TYPE_UNKNOWN);
+
+    printf("  PASSED\n");
+}
+
+void test_get_extension() {
+    printf("Testing media_get_extension...\n");
+
+    assert(strcmp(media_get_extension("file.jpg"), ".jpg") == 0);
+    assert(strcmp(media_get_extension("path/to/file.mp4"), ".mp4") == 0);
+    assert(strcmp(media_get_extension("multiple.dots.png"), ".png") == 0);
+    assert(media_get_extension("noextension") == NULL);
+    assert(media_get_extension(NULL) == NULL);
+
+    printf("  PASSED\n");
+}
+
+void test_path_safety() {
+    printf("Testing media_path_is_safe...\n");
+
+    // Safe paths
+    assert(media_path_is_safe("file.jpg") == true);
+    assert(media_path_is_safe("folder/file.jpg") == true);
+    assert(media_path_is_safe("a/b/c/file.jpg") == true);
+
+    // Unsafe paths
+    assert(media_path_is_safe("../file.jpg") == false);
+    assert(media_path_is_safe("folder/../file.jpg") == false);
+    assert(media_path_is_safe("/absolute/path.jpg") == false);
+    assert(media_path_is_safe(NULL) == false);
+
+    printf("  PASSED\n");
+}
+
+void test_generate_id() {
+    printf("Testing media_generate_id...\n");
+
+    char* id1 = media_generate_id();
+    char* id2 = media_generate_id();
+
+    assert(id1 != NULL);
+    assert(id2 != NULL);
+    assert(strlen(id1) > 0);
+    assert(strlen(id2) > 0);
+    assert(strcmp(id1, id2) != 0);  // Should be unique
+
+    free(id1);
+    free(id2);
+
+    printf("  PASSED\n");
+}
+
+void test_default_opts() {
+    printf("Testing media_processor_opts_default...\n");
+
+    MediaProcessorOpts opts = media_processor_opts_default("./output");
+
+    assert(opts.output_dir != NULL);
+    assert(strcmp(opts.output_dir, "./output") == 0);
+    assert(opts.image_quality == 80);
+    assert(opts.video_segment_duration == 6);
+    assert(opts.generate_thumbnail == true);
+
+    printf("  PASSED\n");
+}
+
+int main() {
+    printf("\n=== Media Processor Tests ===\n\n");
+
+    test_detect_type();
+    test_get_extension();
+    test_path_safety();
+    test_generate_id();
+    test_default_opts();
+
+    printf("\n=== All tests passed! ===\n\n");
+    return 0;
+}
--- a/third_party/README.md	Sat Jan 24 21:52:14 2026 -0800
+++ b/third_party/README.md	Sun Jan 25 10:44:04 2026 -0800
@@ -8,6 +8,7 @@
 |-----------|-------------|
 | `bun/` | Bun JavaScript runtime |
 | `emsdk/` | Emscripten SDK for WASM compilation |
+| `ffmpeg/` | FFmpeg CLI wrapper for media processing |
 | `highlight/` | highlight.js for syntax highlighting |
 | `libuv/` | Async I/O library |
 | `luajit/` | LuaJIT interpreter |
@@ -23,4 +24,5 @@
 deps = ["//third_party/sqlite3:sqlite3"]
 deps = ["//third_party/raylib:raylib"]
 deps = ["//third_party/libuv:libuv"]
+deps = ["//third_party/ffmpeg:ffmpeg_cli"]
 ```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/BUILD	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,40 @@
+# Assumes ffmpeg is installed on the system (apt install ffmpeg / brew install ffmpeg)
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+cc_library(
+    name = "ffmpeg",
+    hdrs = ["ffmpeg.h"],
+    srcs = ["ffmpeg_wrapper.c"],
+    linkopts = select({
+        "@platforms//os:linux": [
+            "-lavcodec",
+            "-lavformat",
+            "-lavutil",
+            "-lswscale",
+            "-lswresample",
+        ],
+        "@platforms//os:macos": [
+            "-lavcodec",
+            "-lavformat",
+            "-lavutil",
+            "-lswscale",
+            "-lswresample",
+        ],
+        "//conditions:default": [],
+    }),
+    copts = select({
+        "@platforms//os:linux": ["-I/usr/include/ffmpeg"],
+        "@platforms//os:macos": ["-I/opt/homebrew/include", "-I/usr/local/include"],
+        "//conditions:default": [],
+    }),
+    visibility = ["//visibility:public"],
+)
+
+# For projects that just need to shell out to ffmpeg CLI
+cc_library(
+    name = "ffmpeg_cli",
+    hdrs = ["ffmpeg_cli.h"],
+    srcs = ["ffmpeg_cli.c"],
+    visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/README.md	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,75 @@
+# ffmpeg
+
+FFmpeg wrapper for Bazel projects.
+
+## Prerequisites
+
+FFmpeg must be installed on the system:
+
+```bash
+# Ubuntu/Debian
+sudo apt install ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev
+
+# macOS
+brew install ffmpeg
+
+# Arch Linux
+sudo pacman -S ffmpeg
+```
+
+## Libraries
+
+### ffmpeg_cli
+
+Shells out to the `ffmpeg` and `ffprobe` command-line tools. This is the recommended approach for most projects as it's simpler and doesn't require linking against FFmpeg libraries.
+
+```starlark
+deps = ["//third_party/ffmpeg:ffmpeg_cli"]
+```
+
+```c
+#include "third_party/ffmpeg/ffmpeg_cli.h"
+
+// Convert image to WebP
+ImageConvertOpts opts = ffmpeg_image_opts_default();
+opts.quality = 85;
+ffmpeg_image_to_webp("input.jpg", "output.webp", &opts);
+
+// Convert video to HLS
+HlsConvertOpts hls_opts = ffmpeg_hls_opts_default();
+ffmpeg_video_to_hls("input.mp4", "./output", "video", &hls_opts);
+```
+
+### ffmpeg (library)
+
+Links against FFmpeg libraries directly. Use this when you need low-level access to FFmpeg's encoding/decoding APIs.
+
+```starlark
+deps = ["//third_party/ffmpeg:ffmpeg"]
+```
+
+## API
+
+### Image Conversion
+
+```c
+FfmpegResult ffmpeg_image_to_webp(const char* input, const char* output, ImageConvertOpts* opts);
+```
+
+### Video to HLS
+
+```c
+FfmpegResult ffmpeg_video_to_hls(const char* input, const char* output_dir, const char* name, HlsConvertOpts* opts);
+```
+
+### Video Thumbnail
+
+```c
+FfmpegResult ffmpeg_video_thumbnail(const char* input, const char* output, int timestamp_seconds);
+```
+
+### Duration
+
+```c
+FfmpegResult ffmpeg_get_duration(const char* input, double* duration_out);
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/ffmpeg.h	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,18 @@
+// FFmpeg library wrapper header
+// This is a placeholder for projects that want to use libavcodec/libavformat directly
+// Most projects should use ffmpeg_cli.h instead which shells out to the ffmpeg binary
+
+#ifndef FFMPEG_WRAPPER_H
+#define FFMPEG_WRAPPER_H
+
+// Include FFmpeg headers when using the library directly
+// Uncomment these when building with FFmpeg libraries linked
+/*
+#include <libavcodec/avcodec.h>
+#include <libavformat/avformat.h>
+#include <libavutil/avutil.h>
+#include <libswscale/swscale.h>
+#include <libswresample/swresample.h>
+*/
+
+#endif // FFMPEG_WRAPPER_H
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/ffmpeg_cli.c	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,260 @@
+#include "ffmpeg_cli.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdarg.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <errno.h>
+
+#define MAX_CMD_LEN 4096
+#define MAX_ERROR_LEN 512
+
+static char last_error[MAX_ERROR_LEN] = {0};
+
+static void set_error(const char* fmt, ...) {
+    va_list args;
+    va_start(args, fmt);
+    vsnprintf(last_error, MAX_ERROR_LEN, fmt, args);
+    va_end(args);
+}
+
+static bool file_exists(const char* path) {
+    struct stat st;
+    return stat(path, &st) == 0;
+}
+
+static bool ensure_dir(const char* path) {
+    struct stat st;
+    if (stat(path, &st) == 0) {
+        return S_ISDIR(st.st_mode);
+    }
+    return mkdir(path, 0755) == 0;
+}
+
+static int run_command(const char* cmd) {
+    int ret = system(cmd);
+    if (ret == -1) {
+        set_error("Failed to execute command: %s", strerror(errno));
+        return -1;
+    }
+    return WEXITSTATUS(ret);
+}
+
+ImageConvertOpts ffmpeg_image_opts_default(void) {
+    return (ImageConvertOpts){
+        .quality = 80,
+        .max_width = 0,
+        .max_height = 0,
+        .preserve_aspect = true,
+    };
+}
+
+HlsConvertOpts ffmpeg_hls_opts_default(void) {
+    return (HlsConvertOpts){
+        .segment_duration = 6,
+        .video_bitrate = 0,
+        .audio_bitrate = 128,
+        .max_width = 0,
+        .max_height = 0,
+        .generate_thumbnail = true,
+    };
+}
+
+bool ffmpeg_is_available(void) {
+    return system("ffmpeg -version > /dev/null 2>&1") == 0;
+}
+
+const char* ffmpeg_last_error(void) {
+    return last_error;
+}
+
+FfmpegResult ffmpeg_image_to_webp(const char* input_path, const char* output_path, ImageConvertOpts* opts) {
+    if (!input_path || !output_path) {
+        set_error("Invalid arguments: input_path and output_path required");
+        return FFMPEG_ERR_INVALID_ARGS;
+    }
+
+    if (!file_exists(input_path)) {
+        set_error("Input file not found: %s", input_path);
+        return FFMPEG_ERR_INPUT_NOT_FOUND;
+    }
+
+    ImageConvertOpts default_opts = ffmpeg_image_opts_default();
+    if (!opts) opts = &default_opts;
+
+    char cmd[MAX_CMD_LEN];
+    char scale_filter[256] = "";
+
+    // Build scale filter if resizing
+    if (opts->max_width > 0 || opts->max_height > 0) {
+        int w = opts->max_width > 0 ? opts->max_width : -1;
+        int h = opts->max_height > 0 ? opts->max_height : -1;
+
+        if (opts->preserve_aspect) {
+            if (w > 0 && h > 0) {
+                snprintf(scale_filter, sizeof(scale_filter),
+                    "-vf \"scale='min(%d,iw)':min'(%d,ih)':force_original_aspect_ratio=decrease\"", w, h);
+            } else if (w > 0) {
+                snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale='min(%d,iw)':-1\"", w);
+            } else {
+                snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale=-1:'min(%d,ih)'\"", h);
+            }
+        } else {
+            snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale=%d:%d\"", w, h);
+        }
+    }
+
+    snprintf(cmd, sizeof(cmd),
+        "ffmpeg -y -i \"%s\" %s -quality %d \"%s\" 2>/dev/null",
+        input_path, scale_filter, opts->quality, output_path);
+
+    int ret = run_command(cmd);
+    if (ret != 0) {
+        set_error("FFmpeg conversion failed with code %d", ret);
+        return FFMPEG_ERR_PROCESS_FAILED;
+    }
+
+    if (!file_exists(output_path)) {
+        set_error("Output file was not created");
+        return FFMPEG_ERR_OUTPUT_FAILED;
+    }
+
+    return FFMPEG_OK;
+}
+
+FfmpegResult ffmpeg_video_to_hls(const char* input_path, const char* output_dir, const char* name, HlsConvertOpts* opts) {
+    if (!input_path || !output_dir || !name) {
+        set_error("Invalid arguments: input_path, output_dir, and name required");
+        return FFMPEG_ERR_INVALID_ARGS;
+    }
+
+    if (!file_exists(input_path)) {
+        set_error("Input file not found: %s", input_path);
+        return FFMPEG_ERR_INPUT_NOT_FOUND;
+    }
+
+    if (!ensure_dir(output_dir)) {
+        set_error("Failed to create output directory: %s", output_dir);
+        return FFMPEG_ERR_OUTPUT_FAILED;
+    }
+
+    HlsConvertOpts default_opts = ffmpeg_hls_opts_default();
+    if (!opts) opts = &default_opts;
+
+    char cmd[MAX_CMD_LEN];
+    char scale_filter[256] = "";
+    char video_bitrate[64] = "";
+    char audio_bitrate[64] = "";
+
+    // Build scale filter if resizing
+    if (opts->max_width > 0 || opts->max_height > 0) {
+        int w = opts->max_width > 0 ? opts->max_width : -2;
+        int h = opts->max_height > 0 ? opts->max_height : -2;
+        snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale=%d:%d\"", w, h);
+    }
+
+    // Video bitrate
+    if (opts->video_bitrate > 0) {
+        snprintf(video_bitrate, sizeof(video_bitrate), "-b:v %dk", opts->video_bitrate);
+    }
+
+    // Audio bitrate
+    snprintf(audio_bitrate, sizeof(audio_bitrate), "-b:a %dk",
+        opts->audio_bitrate > 0 ? opts->audio_bitrate : 128);
+
+    char playlist_path[1024];
+    char segment_pattern[1024];
+    snprintf(playlist_path, sizeof(playlist_path), "%s/%s.m3u8", output_dir, name);
+    snprintf(segment_pattern, sizeof(segment_pattern), "%s/%s_%%03d.ts", output_dir, name);
+
+    // Main HLS conversion command
+    snprintf(cmd, sizeof(cmd),
+        "ffmpeg -y -i \"%s\" "
+        "-c:v libx264 -preset fast -crf 22 %s %s "
+        "-c:a aac %s "
+        "-f hls "
+        "-hls_time %d "
+        "-hls_list_size 0 "
+        "-hls_segment_filename \"%s\" "
+        "\"%s\" 2>/dev/null",
+        input_path,
+        scale_filter, video_bitrate,
+        audio_bitrate,
+        opts->segment_duration,
+        segment_pattern,
+        playlist_path);
+
+    int ret = run_command(cmd);
+    if (ret != 0) {
+        set_error("FFmpeg HLS conversion failed with code %d", ret);
+        return FFMPEG_ERR_PROCESS_FAILED;
+    }
+
+    // Generate thumbnail if requested
+    if (opts->generate_thumbnail) {
+        char thumb_path[1024];
+        snprintf(thumb_path, sizeof(thumb_path), "%s/%s_thumb.jpg", output_dir, name);
+        ffmpeg_video_thumbnail(input_path, thumb_path, 1);
+    }
+
+    return FFMPEG_OK;
+}
+
+FfmpegResult ffmpeg_video_thumbnail(const char* input_path, const char* output_path, int timestamp_seconds) {
+    if (!input_path || !output_path) {
+        set_error("Invalid arguments: input_path and output_path required");
+        return FFMPEG_ERR_INVALID_ARGS;
+    }
+
+    if (!file_exists(input_path)) {
+        set_error("Input file not found: %s", input_path);
+        return FFMPEG_ERR_INPUT_NOT_FOUND;
+    }
+
+    char cmd[MAX_CMD_LEN];
+    snprintf(cmd, sizeof(cmd),
+        "ffmpeg -y -i \"%s\" -ss %d -vframes 1 -q:v 2 \"%s\" 2>/dev/null",
+        input_path, timestamp_seconds, output_path);
+
+    int ret = run_command(cmd);
+    if (ret != 0) {
+        set_error("FFmpeg thumbnail generation failed with code %d", ret);
+        return FFMPEG_ERR_PROCESS_FAILED;
+    }
+
+    return FFMPEG_OK;
+}
+
+FfmpegResult ffmpeg_get_duration(const char* input_path, double* duration_out) {
+    if (!input_path || !duration_out) {
+        set_error("Invalid arguments");
+        return FFMPEG_ERR_INVALID_ARGS;
+    }
+
+    if (!file_exists(input_path)) {
+        set_error("Input file not found: %s", input_path);
+        return FFMPEG_ERR_INPUT_NOT_FOUND;
+    }
+
+    char cmd[MAX_CMD_LEN];
+    snprintf(cmd, sizeof(cmd),
+        "ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"%s\" 2>/dev/null",
+        input_path);
+
+    FILE* fp = popen(cmd, "r");
+    if (!fp) {
+        set_error("Failed to run ffprobe");
+        return FFMPEG_ERR_PROCESS_FAILED;
+    }
+
+    char output[64];
+    if (fgets(output, sizeof(output), fp) != NULL) {
+        *duration_out = atof(output);
+    } else {
+        *duration_out = 0;
+    }
+
+    pclose(fp);
+    return FFMPEG_OK;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/ffmpeg_cli.h	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,57 @@
+#ifndef FFMPEG_CLI_H
+#define FFMPEG_CLI_H
+
+#include <stdbool.h>
+
+// Result codes
+typedef enum {
+    FFMPEG_OK = 0,
+    FFMPEG_ERR_INPUT_NOT_FOUND = 1,
+    FFMPEG_ERR_OUTPUT_FAILED = 2,
+    FFMPEG_ERR_INVALID_ARGS = 3,
+    FFMPEG_ERR_PROCESS_FAILED = 4,
+} FfmpegResult;
+
+// Image conversion options
+typedef struct {
+    int quality;        // 0-100, default 80
+    int max_width;      // 0 = no resize
+    int max_height;     // 0 = no resize
+    bool preserve_aspect;
+} ImageConvertOpts;
+
+// Video conversion options for HLS
+typedef struct {
+    int segment_duration;   // seconds, default 6
+    int video_bitrate;      // kbps, 0 = auto
+    int audio_bitrate;      // kbps, 0 = 128
+    int max_width;          // 0 = no resize
+    int max_height;         // 0 = no resize
+    bool generate_thumbnail;
+} HlsConvertOpts;
+
+// Initialize default options
+ImageConvertOpts ffmpeg_image_opts_default(void);
+HlsConvertOpts ffmpeg_hls_opts_default(void);
+
+// Convert image to WebP format
+// output_path should end with .webp
+FfmpegResult ffmpeg_image_to_webp(const char* input_path, const char* output_path, ImageConvertOpts* opts);
+
+// Convert video to HLS format
+// output_dir is the directory where .m3u8 and .ts files will be created
+FfmpegResult ffmpeg_video_to_hls(const char* input_path, const char* output_dir, const char* name, HlsConvertOpts* opts);
+
+// Generate video thumbnail
+FfmpegResult ffmpeg_video_thumbnail(const char* input_path, const char* output_path, int timestamp_seconds);
+
+// Get media duration in seconds (works for both audio and video)
+FfmpegResult ffmpeg_get_duration(const char* input_path, double* duration_out);
+
+// Check if ffmpeg is available on the system
+bool ffmpeg_is_available(void);
+
+// Get last error message
+const char* ffmpeg_last_error(void);
+
+#endif // FFMPEG_CLI_H
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/ffmpeg_wrapper.c	Sun Jan 25 10:44:04 2026 -0800
@@ -0,0 +1,7 @@
+// FFmpeg library wrapper
+// Placeholder implementation for direct FFmpeg library usage
+// Most projects should use ffmpeg_cli.c instead
+
+#include "ffmpeg.h"
+
+// Add FFmpeg library wrapper functions here when needed