# HG changeset patch # User MrJuneJune # Date 1769366644 28800 # Node ID 7ef4c9d2a72db37de73a3a57259047586812c044 # Parent b818a4561a3ca236ba94f120070b4baed90387a8 [DO NOT PUSH] Random values. diff -r b818a4561a3c -r 7ef4c9d2a72d medi/BUILD --- /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"], +) diff -r b818a4561a3c -r 7ef4c9d2a72d medi/README.md --- /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 +``` diff -r b818a4561a3c -r 7ef4c9d2a72d medi/main.c --- /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 +#include +#include +#include +#include +#include +#include + +#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; +} diff -r b818a4561a3c -r 7ef4c9d2a72d medi/src/app.tsx --- /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 = () => ( + + + + + +); + +export function App() { + const [media, setMedia] = useState([]); + const [uploading, setUploading] = useState(false); + const [dragover, setDragover] = useState(false); + const fileInputRef = useRef(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) => { + 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 ( + <> +

Medi

+

Upload images and videos for processing

+ +
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + > + + {uploading ? ( + <> +
+

Uploading...

+ + ) : ( + <> + +

Drop files here or click to upload

+

Supports images (JPG, PNG, GIF, WebP) and videos (MP4, MOV, WebM)

+ + )} +
+ + {media.length === 0 ? ( +
+

No media uploaded yet

+
+ ) : ( +
+ {media.map(item => ( + + ))} +
+ )} + + ); +} + +function MediaCard({ item }: { item: MediaItem }) { + const [videoError, setVideoError] = useState(false); + + return ( +
+
+ {item.status === 'processing' ? ( +
+ ) : item.type === 'image' && item.webp ? ( + {item.id} + ) : item.type === 'video' && item.thumbnail ? ( + {item.id} + ) : ( + No preview + )} +
+ +
+

{item.id}

+ {item.type} +
+ + {item.status === 'processing' ? 'Processing...' : + item.status === 'completed' ? 'Ready' : 'Error'} + +
+
+ + {item.status === 'completed' && ( +
+ {item.type === 'image' ? ( + <> + WebP + Original + + ) : ( + <> + HLS Stream + Thumbnail + + )} +
+ )} +
+ ); +} diff -r b818a4561a3c -r 7ef4c9d2a72d medi/src/index.html --- /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 @@ + + + + + + Medi - Media Upload + + + +
+ + + diff -r b818a4561a3c -r 7ef4c9d2a72d medi/src/main.tsx --- /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(); diff -r b818a4561a3c -r 7ef4c9d2a72d media_processor/BUILD --- /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"], +) diff -r b818a4561a3c -r 7ef4c9d2a72d media_processor/README.md --- /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 diff -r b818a4561a3c -r 7ef4c9d2a72d media_processor/media_processor.c --- /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 +#include +#include +#include +#include +#include +#include +#include + +// 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; + } + } +} diff -r b818a4561a3c -r 7ef4c9d2a72d media_processor/media_processor.h --- /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 +#include + +// 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 diff -r b818a4561a3c -r 7ef4c9d2a72d media_processor/media_processor_test.c --- /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 +#include +#include + +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; +} diff -r b818a4561a3c -r 7ef4c9d2a72d third_party/README.md --- 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"] ``` diff -r b818a4561a3c -r 7ef4c9d2a72d third_party/ffmpeg/BUILD --- /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"], +) diff -r b818a4561a3c -r 7ef4c9d2a72d third_party/ffmpeg/README.md --- /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); +``` diff -r b818a4561a3c -r 7ef4c9d2a72d third_party/ffmpeg/ffmpeg.h --- /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 +#include +#include +#include +#include +*/ + +#endif // FFMPEG_WRAPPER_H diff -r b818a4561a3c -r 7ef4c9d2a72d third_party/ffmpeg/ffmpeg_cli.c --- /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 +#include +#include +#include +#include +#include +#include + +#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; +} diff -r b818a4561a3c -r 7ef4c9d2a72d third_party/ffmpeg/ffmpeg_cli.h --- /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 + +// 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 diff -r b818a4561a3c -r 7ef4c9d2a72d third_party/ffmpeg/ffmpeg_wrapper.c --- /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