# HG changeset patch # User MrJuneJune # Date 1785788081 25200 # Node ID 543df0fe7168d629e7e8db330af48f83b792d057 # Parent 9c2eec61a15268d5d7b6021cce3c991bee43ba20 [tools] Add full HLS player support diff -r 9c2eec61a152 -r 543df0fe7168 MODULE.bazel --- a/MODULE.bazel Mon Aug 03 11:26:30 2026 -0700 +++ b/MODULE.bazel Mon Aug 03 13:14:41 2026 -0700 @@ -43,6 +43,18 @@ """, ) +http_archive( + name = "hlsjs", + urls = ["https://github.com/video-dev/hls.js/releases/download/v1.6.16/release.zip"], + sha256 = "09c827969e702e82be694da80dbf16dbdf5ff6819c33a374b1757e1bc0adb458", + build_file_content = """ +exports_files( + ["dist/hls.min.js"], + visibility = ["//visibility:public"], +) +""", +) + node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") node.toolchain(node_version = "20.18.0") use_repo(node, "nodejs") diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/BUILD --- a/mrjunejune/BUILD Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/BUILD Mon Aug 03 13:14:41 2026 -0700 @@ -1,5 +1,6 @@ load("@rules_cc//cc:cc_binary.bzl", "cc_binary") load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@aspect_rules_js//js:defs.bzl", "js_library") load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "webp_image") @@ -66,6 +67,26 @@ ], ) +filegroup( + name = "sample_hls_mp4", + srcs = ["assets/video/sample_hls.mp4"], + visibility = [ + "//mrjunejune:__pkg__", + "//third_party/ffmpeg:__pkg__", + ], +) + +cc_binary( + name = "generate_hls_sample", + srcs = ["generate_hls_sample.c"], + args = [ + "$(location :sample_hls_mp4)", + "mrjunejune/src/public/hls-sample", + ], + data = [":sample_hls_mp4"], + deps = ["//third_party/ffmpeg:ffmpeg_cli"], +) + move_files_into_dir( name = "generated_public_webp", srcs = [":generated_webp_assets"], @@ -74,6 +95,12 @@ # Files move_files_into_dir( + name = "hlsjs_runtime", + srcs = ["@hlsjs//:dist/hls.min.js"], + dest = "src/public", +) + +move_files_into_dir( name = "react_pages", srcs = [ "//react_games:games" @@ -119,7 +146,7 @@ srcs = glob( ["src/public/*"], exclude = ["src/public/*.png"], - ) + [":generated_public_webp"], + ) + [":generated_public_webp", ":hlsjs_runtime"], visibility = ["//visibility:public"], ) @@ -134,7 +161,13 @@ srcs = glob( ["src/**"], exclude = ["src/**/*.png"], - ) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons", ":generated_public_webp"], + ) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons", ":generated_public_webp", ":hlsjs_runtime"], + visibility = ["//mrjunejune/test:__pkg__"], +) + +js_library( + name = "hls_player_js", + srcs = ["src/tools/hls_player/hls-player.js"], visibility = ["//mrjunejune/test:__pkg__"], ) diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/README.md --- a/mrjunejune/README.md Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/README.md Mon Aug 03 13:14:41 2026 -0700 @@ -17,6 +17,28 @@ corresponding list in `mrjunejune/BUILD` or `assets/BUILD`, then reference the generated `.webp` URL from the site. +## HLS player + +`/tools/hls_player` is a dependency-free JavaScript HLS player. It uses native +HLS where available and a MediaSource fallback for unencrypted VOD playlists +with fragmented MP4 segments. A VP9/Opus HLS sample is bundled at: + +```text +/public/hls-sample/master.m3u8 +``` + +The sample is generated from `mrjunejune/assets/video/sample_hls.mp4` through +the restored FFmpeg CLI wrapper: + +```bash +bazel run //mrjunejune:generate_hls_sample +``` + +```bash +bazel test //mrjunejune/test:hls_player_test +bazel test //mrjunejune/test:theme_and_webp_test +``` + ## TODO - Add caching layer diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/assets/video/sample_hls.mp4 Binary file mrjunejune/assets/video/sample_hls.mp4 has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/generate_hls_sample.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/generate_hls_sample.c Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,174 @@ +#include "third_party/ffmpeg/ffmpeg_cli.h" + +#include +#include +#include +#include +#include +#include +#include + +static int remove_flat_directory(const char *directory) +{ + DIR *entries = opendir(directory); + if (!entries) + return errno == ENOENT ? 0 : -1; + + struct dirent *entry; + while ((entry = readdir(entries)) != NULL) + { + if (strcmp(entry->d_name, ".") == 0 || + strcmp(entry->d_name, "..") == 0) + continue; + char path[1024]; + int length = snprintf(path, sizeof(path), "%s/%s", directory, entry->d_name); + if (length < 0 || (size_t)length >= sizeof(path) || unlink(path) != 0) + { + closedir(entries); + return -1; + } + } + closedir(entries); + return rmdir(directory); +} + +static int write_master_playlist(const char *directory) +{ + char path[1024]; + int length = snprintf(path, sizeof(path), "%s/master.m3u8", directory); + if (length < 0 || (size_t)length >= sizeof(path)) + return -1; + + FILE *playlist = fopen(path, "wb"); + if (!playlist) + return -1; + const char *content = + "#EXTM3U\n" + "#EXT-X-VERSION:7\n" + "#EXT-X-STREAM-INF:BANDWIDTH=1200000,AVERAGE-BANDWIDTH=1000000," + "CODECS=\"avc1.4d401f,mp4a.40.2\"\n" + "h264-stream.m3u8\n" + "#EXT-X-STREAM-INF:BANDWIDTH=900000,AVERAGE-BANDWIDTH=750000," + "CODECS=\"vp09.00.10.08,opus\"\n" + "vp9-stream.m3u8\n"; + size_t content_length = strlen(content); + int result = fwrite(content, 1, content_length, playlist) == content_length + ? 0 + : -1; + fclose(playlist); + return result; +} + +static const char *workspace_path( + const char *path, + char *buffer, + size_t buffer_size) +{ + const char *workspace = getenv("BUILD_WORKSPACE_DIRECTORY"); + if (!workspace || path[0] == '/') + return path; + int length = snprintf(buffer, buffer_size, "%s/%s", workspace, path); + return length < 0 || (size_t)length >= buffer_size ? NULL : buffer; +} + +int main(int argc, char **argv) +{ + if (argc != 3) + { + fprintf(stderr, "Usage: %s \n", argv[0]); + return 2; + } + + char final_buffer[1024]; + const char *final_directory = workspace_path( + argv[2], + final_buffer, + sizeof(final_buffer)); + if (!final_directory) + { + fprintf(stderr, "HLS output path is too long.\n"); + return 1; + } + + char stage_template[1100]; + int stage_length = snprintf( + stage_template, + sizeof(stage_template), + "%s.stage-XXXXXX", + final_directory); + if (stage_length < 0 || (size_t)stage_length >= sizeof(stage_template) || + !mkdtemp(stage_template)) + { + fprintf(stderr, "Unable to create the HLS staging directory.\n"); + return 1; + } + + Fmp4HlsOpts options = ffmpeg_fmp4_hls_opts_default(); + options.codec = FFMPEG_HLS_VP9_OPUS; + FfmpegResult result = ffmpeg_video_to_fmp4_hls( + argv[1], + stage_template, + "vp9", + &options); + if (result == FFMPEG_OK) + { + options.codec = FFMPEG_HLS_H264_AAC; + options.video_bitrate = 1200; + options.audio_bitrate = 128; + result = ffmpeg_video_to_fmp4_hls( + argv[1], + stage_template, + "h264", + &options); + } + if (result == FFMPEG_OK) + { + options.use_mpeg_ts = true; + result = ffmpeg_video_to_fmp4_hls( + argv[1], + stage_template, + "h264-ts", + &options); + } + if (result != FFMPEG_OK || write_master_playlist(stage_template) != 0) + { + fprintf(stderr, "%s\n", ffmpeg_last_error()); + remove_flat_directory(stage_template); + return 1; + } + + char backup_directory[1100]; + int backup_length = snprintf( + backup_directory, + sizeof(backup_directory), + "%s.backup", + final_directory); + if (backup_length < 0 || (size_t)backup_length >= sizeof(backup_directory)) + { + remove_flat_directory(stage_template); + return 1; + } + remove_flat_directory(backup_directory); + + bool had_previous = rename(final_directory, backup_directory) == 0; + if (!had_previous && errno != ENOENT) + { + remove_flat_directory(stage_template); + return 1; + } + if (rename(stage_template, final_directory) != 0) + { + if (had_previous) + rename(backup_directory, final_directory); + remove_flat_directory(stage_template); + return 1; + } + if (had_previous && remove_flat_directory(backup_directory) != 0) + { + fprintf(stderr, "Generated HLS sample, but could not remove its backup.\n"); + return 1; + } + + printf("Generated fMP4 and MPEG-TS HLS samples from %s in %s\n", argv[1], final_directory); + return 0; +} diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/main.c --- a/mrjunejune/main.c Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/main.c Mon Aug 03 13:14:41 2026 -0700 @@ -295,6 +295,15 @@ return resp; } +Seobeo_Request_Entry* GetHlsPlayer(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, "/tools/hls_player/index.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + // Background thread function for media processing void *Simple_WebpConverter_Background(void *arg) { @@ -736,6 +745,7 @@ CREATE_REDIRECT_HANDLER(Tools, "/tools") CREATE_REDIRECT_HANDLER(MarkDownToHtml, "/tools/markdown_to_html") CREATE_REDIRECT_HANDLER(FileConverter, "/tools/file_converter") +CREATE_REDIRECT_HANDLER(HlsPlayer, "/tools/hls_player") CREATE_REDIRECT_HANDLER(Talk, "/talk") CREATE_REDIRECT_HANDLER(Editor, "/editor") @@ -1856,6 +1866,8 @@ Seobeo_Router_Register("GET", "/tools/file_converter", GetFileConverter); Seobeo_Router_Register("GET", "/tools/file_converter/index.html", GetRedirectFileConverter); + Seobeo_Router_Register("GET", "/tools/hls_player", GetHlsPlayer); + Seobeo_Router_Register("GET", "/tools/hls_player/index.html", GetRedirectHlsPlayer); // -- File converter --/ Seobeo_Router_Register("POST", "/api/convert/image-to-webp", ConvertImageToWebP); diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-init.mp4 Binary file mrjunejune/src/public/hls-sample/h264-init.mp4 has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment000.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment000.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment001.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment001.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment002.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment002.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment003.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment003.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment004.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment004.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment005.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment005.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment006.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment006.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-segment007.m4s Binary file mrjunejune/src/public/hls-sample/h264-segment007.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-stream.m3u8 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/hls-sample/h264-stream.m3u8 Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,23 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MAP:URI="h264-init.mp4" +#EXTINF:2.000000, +h264-segment000.m4s +#EXTINF:2.000000, +h264-segment001.m4s +#EXTINF:2.000000, +h264-segment002.m4s +#EXTINF:2.000000, +h264-segment003.m4s +#EXTINF:2.000000, +h264-segment004.m4s +#EXTINF:2.000000, +h264-segment005.m4s +#EXTINF:2.000000, +h264-segment006.m4s +#EXTINF:0.633333, +h264-segment007.m4s +#EXT-X-ENDLIST diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment000.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment000.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment001.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment001.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment002.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment002.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment003.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment003.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment004.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment004.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment005.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment005.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment006.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment006.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-segment007.ts Binary file mrjunejune/src/public/hls-sample/h264-ts-segment007.ts has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/h264-ts-stream.m3u8 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/hls-sample/h264-ts-stream.m3u8 Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,22 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXTINF:2.000000, +h264-ts-segment000.ts +#EXTINF:2.000000, +h264-ts-segment001.ts +#EXTINF:2.000000, +h264-ts-segment002.ts +#EXTINF:2.000000, +h264-ts-segment003.ts +#EXTINF:2.000000, +h264-ts-segment004.ts +#EXTINF:2.000000, +h264-ts-segment005.ts +#EXTINF:2.000000, +h264-ts-segment006.ts +#EXTINF:0.633333, +h264-ts-segment007.ts +#EXT-X-ENDLIST diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/master.m3u8 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/hls-sample/master.m3u8 Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,6 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-STREAM-INF:BANDWIDTH=1200000,AVERAGE-BANDWIDTH=1000000,CODECS="avc1.4d401f,mp4a.40.2" +h264-stream.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=900000,AVERAGE-BANDWIDTH=750000,CODECS="vp09.00.10.08,opus" +vp9-stream.m3u8 diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-init.mp4 Binary file mrjunejune/src/public/hls-sample/vp9-init.mp4 has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment000.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment000.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment001.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment001.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment002.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment002.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment003.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment003.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment004.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment004.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment005.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment005.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment006.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment006.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-segment007.m4s Binary file mrjunejune/src/public/hls-sample/vp9-segment007.m4s has changed diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/hls-sample/vp9-stream.m3u8 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/hls-sample/vp9-stream.m3u8 Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,23 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MAP:URI="vp9-init.mp4" +#EXTINF:2.000000, +vp9-segment000.m4s +#EXTINF:2.000000, +vp9-segment001.m4s +#EXTINF:2.000000, +vp9-segment002.m4s +#EXTINF:2.000000, +vp9-segment003.m4s +#EXTINF:2.000000, +vp9-segment004.m4s +#EXTINF:2.000000, +vp9-segment005.m4s +#EXTINF:2.000000, +vp9-segment006.m4s +#EXTINF:0.633333, +vp9-segment007.m4s +#EXT-X-ENDLIST diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/public/sw.js --- a/mrjunejune/src/public/sw.js Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/src/public/sw.js Mon Aug 03 13:14:41 2026 -0700 @@ -1,5 +1,5 @@ // Service Worker for MrJuneJune PWA -const CACHE_VERSION = 'v2-webp'; +const CACHE_VERSION = 'v4-hlsjs'; const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; // Files to cache immediately on install @@ -69,6 +69,12 @@ return; } + // The HLS tool and media must always reflect the current player version. + if (url.pathname.startsWith('/tools/hls_player') || + url.pathname.startsWith('/public/hls-sample/')) { + return; + } + event.respondWith( caches.match(request).then((cachedResponse) => { if (cachedResponse) { diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/tools/hls_player/hls-player.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/tools/hls_player/hls-player.js Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,672 @@ +(function hlsPlayerModule(global) { + "use strict"; + + const DEFAULT_CODECS = "vp09.00.10.08,opus"; + const HLS_MIME_TYPES = [ + "application/vnd.apple.mpegurl", + "application/x-mpegURL", + ]; + + function meaningfulLines(text) { + return text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + } + + function parseAttributeList(value) { + const attributes = {}; + const expression = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/g; + let match; + while ((match = expression.exec(value)) !== null) { + const rawValue = match[2]; + attributes[match[1]] = rawValue.startsWith('"') + ? rawValue.slice(1, -1) + : rawValue; + } + return attributes; + } + + function resolveUri(value, baseUrl) { + return new URL(value, baseUrl).href; + } + + function normalizePlaylistUrl(value, baseUrl) { + const url = new URL(value, baseUrl); + if (url.protocol !== "http:" && + url.protocol !== "https:" && + url.protocol !== "blob:") { + throw new Error("HLS playlist URL must use HTTP, HTTPS, or a local file."); + } + return url.href; + } + + function normalizeLocalPath(path) { + const output = []; + for (const part of path.replace(/\\/g, "/").split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + if (!output.length) throw new Error("Local playlist path escapes its selected folder."); + output.pop(); + } else { + output.push(part); + } + } + return output.join("/"); + } + + function localRelativePath(currentPath, reference) { + const cleanReference = reference.split(/[?#]/)[0]; + if (/^https?:\/\//i.test(cleanReference) || + cleanReference.startsWith("blob:")) { + return null; + } + const slash = currentPath.lastIndexOf("/"); + const rootRelative = cleanReference.startsWith("/"); + const directory = !rootRelative && slash >= 0 + ? currentPath.slice(0, slash + 1) + : ""; + return normalizeLocalPath( + `${directory}${decodeURIComponent(cleanReference).replace(/^\/+/, "")}`, + ); + } + + async function rewritePlaylistUris(text, resolveReference) { + const output = []; + for (const originalLine of text.split(/\r?\n/)) { + const line = originalLine.trim(); + if (line.startsWith("#")) { + let rewrittenLine = originalLine; + const references = [ + ...originalLine.matchAll(/URI="([^"]+)"/g), + ].map(match => match[1]); + for (const reference of references) { + rewrittenLine = rewrittenLine.replace( + `URI="${reference}"`, + `URI="${await resolveReference(reference)}"`, + ); + } + output.push(rewrittenLine); + } else if (line && !line.startsWith("#")) { + output.push(await resolveReference(line)); + } else { + output.push(originalLine); + } + } + return output.join("\n"); + } + + async function createLocalHlsUrl(fileList) { + const files = Array.from(fileList || []); + if (!files.length) throw new Error("Choose an HLS playlist and its media files."); + + const byPath = new Map(); + const byName = new Map(); + for (const file of files) { + const relativePath = normalizeLocalPath(file.webkitRelativePath || file.name); + byPath.set(relativePath, file); + const basename = relativePath.split("/").pop(); + if (!byName.has(basename)) byName.set(basename, file); + else byName.set(basename, null); + } + + const playlists = [...byPath.entries()].filter(([filePath]) => + filePath.toLowerCase().endsWith(".m3u8") + ); + if (!playlists.length) throw new Error("No .m3u8 playlist was selected."); + const master = + playlists.find(([filePath]) => /(^|\/)master\.m3u8$/i.test(filePath)) || + playlists.find(([filePath]) => /stream\.m3u8$/i.test(filePath)) || + playlists[0]; + + const objectUrls = []; + const assetUrls = new Map(); + const playlistUrls = new Map(); + const resolving = new Set(); + + const rememberUrl = blob => { + const url = URL.createObjectURL(blob); + objectUrls.push(url); + return url; + }; + + const findFile = (reference, currentPath) => { + const relativePath = localRelativePath(currentPath, reference); + if (relativePath === null) return null; + const direct = byPath.get(relativePath); + if (direct) return [relativePath, direct]; + const basename = relativePath.split("/").pop(); + const unique = byName.get(basename); + if (unique) { + const uniquePath = [...byPath.entries()].find(([, file]) => file === unique)[0]; + return [uniquePath, unique]; + } + throw new Error(`Local HLS file is missing: ${reference}`); + }; + + const materializePlaylist = async (filePath, file) => { + if (playlistUrls.has(filePath)) return playlistUrls.get(filePath); + if (resolving.has(filePath)) throw new Error("Local HLS playlists contain a cycle."); + resolving.add(filePath); + try { + const rewritten = await rewritePlaylistUris( + await file.text(), + async reference => { + if (/^https?:\/\//i.test(reference) || + reference.startsWith("blob:")) return reference; + const [resolvedPath, resolvedFile] = findFile(reference, filePath); + if (resolvedPath.toLowerCase().endsWith(".m3u8")) { + return materializePlaylist(resolvedPath, resolvedFile); + } + if (!assetUrls.has(resolvedPath)) { + assetUrls.set(resolvedPath, rememberUrl(resolvedFile)); + } + return assetUrls.get(resolvedPath); + }, + ); + const url = rememberUrl(new Blob( + [rewritten], + { type: "application/vnd.apple.mpegurl" }, + )); + playlistUrls.set(filePath, url); + return url; + } finally { + resolving.delete(filePath); + } + }; + + try { + const url = await materializePlaylist(master[0], master[1]); + return { + url, + playlistName: master[0], + revoke() { + for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl); + objectUrls.length = 0; + }, + }; + } catch (error) { + for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl); + throw error; + } + } + + function parseMasterPlaylist(text, baseUrl) { + const lines = meaningfulLines(text); + const variants = []; + for (let index = 0; index < lines.length; index++) { + if (!lines[index].startsWith("#EXT-X-STREAM-INF:")) continue; + const attributes = parseAttributeList( + lines[index].slice("#EXT-X-STREAM-INF:".length), + ); + const uri = lines[index + 1]; + if (!uri || uri.startsWith("#")) { + throw new Error("HLS variant is missing its playlist URL."); + } + variants.push({ + url: resolveUri(uri, baseUrl), + bandwidth: Number(attributes.BANDWIDTH || 0), + codecs: attributes.CODECS || "", + resolution: attributes.RESOLUTION || "", + }); + index++; + } + return variants; + } + + function parseMediaPlaylist(text, baseUrl) { + const lines = meaningfulLines(text); + const segments = []; + let initSegment = null; + let duration = null; + let totalDuration = 0; + let endList = false; + + for (const line of lines) { + if (line.startsWith("#EXT-X-KEY:")) { + const attributes = parseAttributeList(line.slice("#EXT-X-KEY:".length)); + if ((attributes.METHOD || "NONE") !== "NONE") { + throw new Error("Encrypted HLS playlists are not supported by the JavaScript fallback."); + } + } else if (line.startsWith("#EXT-X-BYTERANGE")) { + throw new Error("Byte-range HLS playlists are not supported by the JavaScript fallback."); + } else if (line.startsWith("#EXT-X-MAP:")) { + const attributes = parseAttributeList(line.slice("#EXT-X-MAP:".length)); + if (!attributes.URI) throw new Error("HLS initialization segment URL is missing."); + initSegment = resolveUri(attributes.URI, baseUrl); + } else if (line.startsWith("#EXTINF:")) { + duration = Number(line.slice("#EXTINF:".length).split(",")[0]); + if (!Number.isFinite(duration)) throw new Error("Invalid HLS segment duration."); + } else if (line === "#EXT-X-ENDLIST") { + endList = true; + } else if (!line.startsWith("#")) { + if (duration === null) continue; + segments.push({ + url: resolveUri(line, baseUrl), + duration, + }); + totalDuration += duration; + duration = null; + } + } + + return { initSegment, segments, totalDuration, endList }; + } + + function chooseVariant(variants, isSupported = () => true) { + const supported = variants.filter(isSupported); + if (!supported.length) return null; + return supported.reduce((best, current) => + current.bandwidth > best.bandwidth ? current : best + ); + } + + function once(target, eventName, errorName) { + return new Promise((resolve, reject) => { + const cleanup = () => { + target.removeEventListener(eventName, onEvent); + if (errorName) target.removeEventListener(errorName, onError); + }; + const onEvent = event => { + cleanup(); + resolve(event); + }; + const onError = () => { + cleanup(); + reject(new Error(`Media event failed: ${errorName}`)); + }; + target.addEventListener(eventName, onEvent, { once: true }); + if (errorName) target.addEventListener(errorName, onError, { once: true }); + }); + } + + async function appendBuffer(sourceBuffer, bytes) { + sourceBuffer.appendBuffer(bytes); + await once(sourceBuffer, "updateend", "error"); + } + + class HlsPlayer { + constructor(video, options = {}) { + if (!video) throw new Error("A video element is required."); + this.video = video; + this.statusElement = options.statusElement || null; + this.detailsElement = options.detailsElement || null; + this.abortController = null; + this.mediaSource = null; + this.objectUrl = null; + this.hls = null; + this.generation = 0; + } + + setStatus(message, state = "loading") { + if (!this.statusElement) return; + this.statusElement.textContent = message; + this.statusElement.dataset.state = state; + } + + setDetails(details) { + if (!this.detailsElement) return; + for (const [key, value] of Object.entries(details)) { + const target = this.detailsElement.querySelector(`[data-detail="${key}"]`); + if (target) target.textContent = String(value); + } + this.detailsElement.hidden = false; + } + + destroy() { + this.generation++; + if (this.abortController) this.abortController.abort(); + this.abortController = null; + if (this.hls) this.hls.destroy(); + this.hls = null; + this.video.pause(); + this.video.removeAttribute("src"); + this.video.load(); + if (this.objectUrl) URL.revokeObjectURL(this.objectUrl); + this.objectUrl = null; + this.mediaSource = null; + } + + async fetchText(url, signal) { + const response = await fetch(url, { signal, cache: "no-store" }); + if (!response.ok) { + throw new Error(`Playlist request failed (${response.status}).`); + } + return response.text(); + } + + async fetchBytes(url, signal) { + const response = await fetch(url, { signal, cache: "no-store" }); + if (!response.ok) { + throw new Error(`Media request failed (${response.status}): ${url}`); + } + return response.arrayBuffer(); + } + + nativeHlsSupported() { + return HLS_MIME_TYPES.some(type => this.video.canPlayType(type) !== ""); + } + + hlsJsSupported() { + return Boolean(global.Hls && global.Hls.isSupported()); + } + + async loadWithHlsJs(playlistUrl, generation, signal) { + const Hls = global.Hls; + const hls = new Hls({ enableWorker: false }); + this.hls = hls; + this.setStatus("Loading HLS manifest..."); + + const manifest = await new Promise((resolve, reject) => { + let manifestData = null; + let mediaReady = this.video.readyState >= 2; + let settled = false; + + const cleanup = () => { + hls.off(Hls.Events.MEDIA_ATTACHED, onMediaAttached); + hls.off(Hls.Events.MANIFEST_PARSED, onManifestParsed); + this.video.removeEventListener("loadeddata", onLoadedData); + this.video.removeEventListener("error", onMediaError); + signal.removeEventListener("abort", onAbort); + }; + const finish = (callback, value) => { + if (settled) return; + settled = true; + cleanup(); + callback(value); + }; + const maybeResolve = () => { + if (manifestData && mediaReady) finish(resolve, manifestData); + }; + const onMediaAttached = () => hls.loadSource(playlistUrl); + const onManifestParsed = (_event, data) => { + manifestData = data; + maybeResolve(); + }; + const onLoadedData = () => { + mediaReady = true; + maybeResolve(); + }; + const onMediaError = () => { + finish(reject, new Error("The browser could not decode this HLS stream.")); + }; + const onHlsError = (_event, data) => { + if (!data.fatal) return; + const reason = data.error?.message || + data.reason || + data.details || + data.type || + "unknown error"; + const error = new Error(`HLS playback failed: ${reason}`); + if (!settled) { + finish(reject, error); + return; + } + if (generation === this.generation && this.hls === hls) { + hls.destroy(); + this.hls = null; + this.setStatus(error.message, "error"); + } + }; + const onAbort = () => { + const error = new Error("HLS loading was cancelled."); + error.name = "AbortError"; + finish(reject, error); + }; + + hls.on(Hls.Events.MEDIA_ATTACHED, onMediaAttached); + hls.on(Hls.Events.MANIFEST_PARSED, onManifestParsed); + hls.on(Hls.Events.ERROR, onHlsError); + this.video.addEventListener("loadeddata", onLoadedData); + this.video.addEventListener("error", onMediaError); + signal.addEventListener("abort", onAbort, { once: true }); + hls.attachMedia(this.video); + }); + + if (generation !== this.generation || signal.aborted) return; + const selectedLevelIndex = hls.currentLevel >= 0 + ? hls.currentLevel + : hls.loadLevel; + const selectedLevel = hls.levels[selectedLevelIndex] || hls.levels[0] || {}; + const codecs = [ + selectedLevel.videoCodec, + selectedLevel.audioCodec, + ].filter(Boolean).join(", ") || "Detected from stream"; + const live = !Number.isFinite(this.video.duration); + + if (!live && this.video.duration > 0 && this.video.seekable.length) { + this.video.currentTime = Math.min(0.05, this.video.duration); + await once(this.video, "seeked", "error"); + } + if (generation !== this.generation || signal.aborted) return; + + this.setDetails({ + mode: "hls.js", + segments: manifest.levels?.length > 1 + ? `${manifest.levels.length} adaptive levels` + : "Managed by hls.js", + duration: live ? "Live" : `${this.video.duration.toFixed(1)} seconds`, + codecs, + }); + this.setStatus( + live ? "Live stream ready. Press play." : "Stream ready. Press play.", + "ready", + ); + } + + async load(inputUrl, options = {}) { + this.destroy(); + const generation = this.generation; + this.abortController = new AbortController(); + const signal = this.abortController.signal; + try { + const playlistUrl = normalizePlaylistUrl( + inputUrl, + global.location?.href || "http://localhost/", + ); + this.setStatus("Loading playlist..."); + if (this.nativeHlsSupported() && !options.forceMediaSource) { + this.video.src = playlistUrl; + await once(this.video, "loadedmetadata", "error"); + if (generation !== this.generation || signal.aborted) return; + this.setDetails({ + mode: "Native HLS", + segments: "Managed by browser", + duration: Number.isFinite(this.video.duration) + ? `${this.video.duration.toFixed(1)} seconds` + : "Live", + codecs: "Managed by browser", + }); + this.setStatus("Stream ready.", "ready"); + return; + } + + if (this.hlsJsSupported()) { + await this.loadWithHlsJs(playlistUrl, generation, signal); + return; + } + + if (!global.MediaSource) { + throw new Error("This browser does not support native HLS or MediaSource playback."); + } + + let mediaPlaylistUrl = playlistUrl; + let codecs = ""; + let playlistText = await this.fetchText(mediaPlaylistUrl, signal); + const variants = parseMasterPlaylist(playlistText, mediaPlaylistUrl); + if (variants.length) { + const variant = chooseVariant(variants, candidate => { + const candidateCodecs = candidate.codecs || DEFAULT_CODECS; + return global.MediaSource.isTypeSupported( + `video/mp4; codecs="${candidateCodecs}"`, + ); + }); + if (!variant) { + throw new Error("No HLS variant uses a codec supported by this browser."); + } + mediaPlaylistUrl = variant.url; + codecs = variant.codecs; + this.setStatus(`Loading ${variant.bandwidth || "selected"} bps variant...`); + playlistText = await this.fetchText(mediaPlaylistUrl, signal); + } + + const playlist = parseMediaPlaylist(playlistText, mediaPlaylistUrl); + if (!playlist.endList) { + throw new Error("The JavaScript fallback currently supports VOD playlists only."); + } + if (!playlist.initSegment || !playlist.segments.length) { + throw new Error("The JavaScript fallback requires an fMP4 playlist with EXT-X-MAP."); + } + + codecs = codecs || DEFAULT_CODECS; + const mimeType = `video/mp4; codecs="${codecs}"`; + if (!global.MediaSource.isTypeSupported(mimeType)) { + throw new Error(`Browser does not support ${mimeType}.`); + } + + this.mediaSource = new global.MediaSource(); + this.objectUrl = URL.createObjectURL(this.mediaSource); + this.video.src = this.objectUrl; + await once(this.mediaSource, "sourceopen"); + if (generation !== this.generation) return; + + const sourceBuffer = this.mediaSource.addSourceBuffer(mimeType); + sourceBuffer.mode = "segments"; + const initBytes = await this.fetchBytes(playlist.initSegment, signal); + if (generation !== this.generation || signal.aborted) return; + await appendBuffer(sourceBuffer, initBytes); + + for (let index = 0; index < playlist.segments.length; index++) { + this.setStatus(`Loading segment ${index + 1} of ${playlist.segments.length}...`); + const segmentBytes = await this.fetchBytes( + playlist.segments[index].url, + signal, + ); + if (generation !== this.generation || signal.aborted) return; + await appendBuffer(sourceBuffer, segmentBytes); + } + + if (generation !== this.generation) return; + this.mediaSource.endOfStream(); + this.video.currentTime = Math.min(0.05, playlist.totalDuration); + await once(this.video, "seeked", "error"); + if (generation !== this.generation || signal.aborted) return; + this.setDetails({ + mode: "JavaScript MediaSource", + segments: playlist.segments.length, + duration: `${playlist.totalDuration.toFixed(1)} seconds`, + codecs, + }); + this.setStatus("Stream ready. Press play.", "ready"); + } catch (error) { + if (error.name === "AbortError" || generation !== this.generation) return; + if (this.hls) { + this.hls.destroy(); + this.hls = null; + } + this.setStatus(error.message || "Unable to load HLS stream.", "error"); + throw error; + } + } + } + + const exportsObject = { + HlsPlayer, + chooseVariant, + parseAttributeList, + parseMasterPlaylist, + parseMediaPlaylist, + normalizePlaylistUrl, + createLocalHlsUrl, + localRelativePath, + rewritePlaylistUris, + }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = exportsObject; + } + global.HlsPlayerModule = exportsObject; + + if (global.document) { + global.addEventListener("DOMContentLoaded", () => { + const form = document.querySelector("#hlsForm"); + const input = document.querySelector("#hlsUrl"); + const video = document.querySelector("#hlsVideo"); + const status = document.querySelector("#hlsStatus"); + const details = document.querySelector("#hlsDetails"); + const sampleButton = document.querySelector("#sampleButton"); + const fileInputs = [ + document.querySelector("#hlsFiles"), + document.querySelector("#hlsFolder"), + ].filter(Boolean); + if (!form || !input || !video) return; + + const player = new HlsPlayer(video, { + statusElement: status, + detailsElement: details, + }); + global.hlsPlayer = player; + let localSelection = null; + let localLoadGeneration = 0; + + const load = () => { + localLoadGeneration++; + if (localSelection) { + localSelection.revoke(); + localSelection = null; + } + const value = input.value.trim(); + if (!value) return; + const url = new URL(global.location.href); + url.searchParams.set("url", value); + global.history.replaceState({}, "", url); + player.load(value).catch(() => {}); + }; + + const loadLocal = async files => { + const generation = ++localLoadGeneration; + if (localSelection) { + localSelection.revoke(); + localSelection = null; + } + let selection = null; + try { + selection = await createLocalHlsUrl(files); + if (generation !== localLoadGeneration) { + selection.revoke(); + return; + } + localSelection = selection; + input.value = `Local: ${selection.playlistName}`; + player.setStatus(`Loading local playlist ${selection.playlistName}...`); + await player.load(selection.url, { forceMediaSource: true }); + if (generation !== localLoadGeneration) return; + } catch (error) { + if (selection) selection.revoke(); + if (localSelection === selection) localSelection = null; + if (generation !== localLoadGeneration) return; + player.setStatus(error.message || "Unable to load local HLS files.", "error"); + } + }; + + form.addEventListener("submit", event => { + event.preventDefault(); + load(); + }); + sampleButton.addEventListener("click", () => { + input.value = "/public/hls-sample/h264-ts-stream.m3u8"; + load(); + }); + for (const fileInput of fileInputs) { + fileInput.addEventListener("change", () => { + if (fileInput.files?.length) loadLocal(fileInput.files); + fileInput.value = ""; + }); + } + + const requestedUrl = new URL(global.location.href).searchParams.get("url"); + if (requestedUrl) input.value = requestedUrl; + load(); + }); + } +})(typeof window !== "undefined" ? window : globalThis); diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/tools/hls_player/index.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/tools/hls_player/index.css Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,171 @@ +.hls-form { + margin: 1.5rem 0; +} + +.hls-form label { + display: block; + margin-bottom: 0.4rem; + font-weight: 700; +} + +.hls-url-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.6rem; +} + +.hls-url-row input, +.hls-url-row button { + min-height: 44px; + font: inherit; +} + +.hls-url-row input { + width: 100%; + padding: 0.65rem 0.8rem; + color: rgb(var(--gray-dark)); + background: var(--white); + border: 1px solid var(--darkgray); + border-radius: 6px; +} + +.hls-url-row button { + padding: 0.65rem 1rem; + color: var(--white); + background: var(--darkgray); + border: 1px solid var(--darkgray); + border-radius: 6px; + cursor: pointer; +} + +.hls-url-row button:hover, +.hls-url-row button:focus-visible { + color: var(--white); + background: var(--awesome); + border-color: var(--awesome); +} + +.hls-player-shell { + overflow: hidden; + border: 1px solid var(--darkgray); + border-radius: 8px; + background: #10131a; +} + +.hls-local-picker { + margin: 1rem 0 1.5rem; + padding: 0.9rem; + border: 1px solid var(--gray-light); + border-radius: 8px; +} + +.hls-local-picker legend { + padding: 0 0.4rem; + font-weight: 700; +} + +.hls-local-picker p { + margin: 0 0 0.75rem; +} + +.hls-local-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.hls-file-button { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 0.65rem 1rem; + color: var(--white); + background: var(--darkgray); + border-radius: 6px; + cursor: pointer; +} + +.hls-file-button:hover, +.hls-file-button:focus-within { + background: var(--awesome); +} + +.hls-file-button input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +#hlsVideo { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + background: #05070b; +} + +.hls-status { + min-height: 2.75rem; + margin: 0; + padding: 0.65rem 0.9rem; + color: #f3f5f8; + background: #171b24; +} + +.hls-status[data-state="error"] { + color: #ffd7df; + background: #6d1732; +} + +.hls-status[data-state="ready"] { + color: #d8ffe9; + background: #185b3a; +} + +.hls-details { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin: 1rem 0; +} + +.hls-details[hidden] { + display: none; +} + +.hls-details div { + padding: 0.7rem; + border: 1px solid var(--gray-light); + border-radius: 6px; +} + +.hls-details dt { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; +} + +.hls-details dd { + margin: 0.25rem 0 0; + overflow-wrap: anywhere; +} + +.hls-notes { + margin-top: 1.5rem; +} + +@media (max-width: 720px) { + .hls-url-row { + grid-template-columns: 1fr 1fr; + } + + .hls-url-row input { + grid-column: 1 / -1; + } + + .hls-details { + grid-template-columns: 1fr 1fr; + } +} diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/tools/hls_player/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/tools/hls_player/index.html Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,94 @@ + + + + {{/parts/base_head.html}} + HLS Player - MrJuneJune + + + + + + {{/parts/header.html}} +
+

Online HLS Player

+

Load an HLS playlist from a URL, or try the sample stream bundled with this site.

+ +
+ +
+ + + +
+
+ +
+ Local HLS +

Select the playlist together with every init and segment file it references.

+
+ + +
+
+ +
+ +

Ready to load a playlist.

+
+ + + +
+

Notes

+
    +
  • Remote playlists must allow cross-origin requests.
  • +
  • Local loading stays in your browser; selected files are not uploaded.
  • +
  • Chrome, Edge, and Firefox support MPEG-TS, fragmented MP4, live, and adaptive HLS through hls.js.
  • +
  • Safari uses native HLS playback when available.
  • +
+
+
+ {{/parts/footer.html}} + + diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/src/tools/index.html --- a/mrjunejune/src/tools/index.html Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/src/tools/index.html Mon Aug 03 13:14:41 2026 -0700 @@ -11,12 +11,12 @@

TODOs

{{/parts/footer.html}} diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/test/BUILD --- a/mrjunejune/test/BUILD Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/test/BUILD Mon Aug 03 13:14:41 2026 -0700 @@ -49,6 +49,14 @@ ) js_test( + name = "hls_player_test", + entry_point = "hls_player_test.js", + data = ["//mrjunejune:hls_player_js"], + size = "small", + timeout = "short", +) + +js_test( name = "theme_and_webp_test", entry_point = "theme_and_webp_test.js", data = [ diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/test/auto_generated_test.c --- a/mrjunejune/test/auto_generated_test.c Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/test/auto_generated_test.c Mon Aug 03 13:14:41 2026 -0700 @@ -28,6 +28,8 @@ {"/tools/markdown_to_html/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, {"/tools/file_converter", 200, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, {"/tools/file_converter/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, + {"/tools/hls_player", 200, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, + {"/tools/hls_player/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, // TODO: POST route - POST /api/convert/image-to-webp - requires request body // TODO: POST route - POST /api/convert/video-to-mp4 - requires request body // TODO: Dynamic route - GET /api/download/:filename - fill in actual path diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/test/hls_player_test.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/hls_player_test.js Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,86 @@ +const assert = require('node:assert/strict'); +const { + chooseVariant, + parseAttributeList, + parseMasterPlaylist, + parseMediaPlaylist, + normalizePlaylistUrl, + localRelativePath, + rewritePlaylistUris, +} = require('../src/tools/hls_player/hls-player.js'); + +const attributes = parseAttributeList( + 'BANDWIDTH=500000,RESOLUTION=640x360,CODECS="avc1.42c01e,mp4a.40.2"', +); +assert.equal(attributes.BANDWIDTH, '500000'); +assert.equal(attributes.RESOLUTION, '640x360'); +assert.equal(attributes.CODECS, 'avc1.42c01e,mp4a.40.2'); + +const variants = parseMasterPlaylist( + '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1.42c01e"\nhigh.m3u8\n', + 'https://example.com/master.m3u8', +); +assert.equal(variants.length, 2); +assert.equal(chooseVariant(variants).url, 'https://example.com/high.m3u8'); +assert.equal( + chooseVariant(variants, variant => variant.bandwidth < 200).url, + 'https://example.com/low.m3u8', +); +assert.equal(chooseVariant(variants, () => false), null); + +const media = parseMediaPlaylist( + '#EXTM3U\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:1.5,\nseg0.m4s\n#EXTINF:2.0,\nseg1.m4s\n#EXT-X-ENDLIST\n', + 'https://example.com/path/stream.m3u8', +); +assert.equal(media.initSegment, 'https://example.com/path/init.mp4'); +assert.equal(media.segments.length, 2); +assert.equal(media.segments[1].url, 'https://example.com/path/seg1.m4s'); +assert.equal(media.totalDuration, 3.5); +assert.equal(media.endList, true); + +assert.equal( + normalizePlaylistUrl('/stream.m3u8', 'https://example.com/tool'), + 'https://example.com/stream.m3u8', +); +assert.throws( + () => normalizePlaylistUrl('file:///tmp/stream.m3u8', 'https://example.com'), + /HTTP, HTTPS, or a local file/, +); +assert.equal( + localRelativePath('folder/master.m3u8', 'video/stream.m3u8'), + 'folder/video/stream.m3u8', +); +assert.equal( + localRelativePath('folder/video/stream.m3u8', '../init.mp4'), + 'folder/init.mp4', +); +assert.equal( + localRelativePath('folder/video/stream.m3u8', '/init.mp4'), + 'init.mp4', +); +assert.throws( + () => localRelativePath('master.m3u8', '../outside.m4s'), + /escapes/, +); + +assert.throws( + () => parseMediaPlaylist( + '#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key"\n#EXTINF:1,\nseg.m4s\n', + 'https://example.com/stream.m3u8', + ), + /Encrypted HLS/, +); + +(async () => { + const rewritten = await rewritePlaylistUris( + '#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,URI="audio.m3u8"\n#EXT-X-MAP:URI="init.mp4"\n#EXT-X-KEY:METHOD=AES-128,URI="key.bin"\n#EXTINF:1,\nseg.m4s\n', + async reference => `blob:${reference}`, + ); + assert.match(rewritten, /URI="blob:audio\.m3u8"/); + assert.match(rewritten, /URI="blob:init\.mp4"/); + assert.match(rewritten, /URI="blob:key\.bin"/); + assert.match(rewritten, /blob:seg\.m4s/); +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/test/integration_test.c --- a/mrjunejune/test/integration_test.c Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/test/integration_test.c Mon Aug 03 13:14:41 2026 -0700 @@ -391,6 +391,7 @@ {"/tools", 200, NULL, NULL, NULL, 0}, {"/tools/markdown_to_html", 200, NULL, NULL, NULL, 0}, {"/tools/file_converter", 200, NULL, NULL, NULL, 0}, + {"/tools/hls_player", 200, NULL, NULL, NULL, 0}, {"/talk", 200, NULL, NULL, NULL, 0}, }; int num_success_tests = sizeof(success_tests) / sizeof(success_tests[0]); @@ -401,6 +402,7 @@ {"/tools/index.html", 301, NULL, NULL, NULL, 0}, {"/tools/markdown_to_html/index.html", 301, NULL, NULL, NULL, 0}, {"/tools/file_converter/index.html", 301, NULL, NULL, NULL, 0}, + {"/tools/hls_player/index.html", 301, NULL, NULL, NULL, 0}, }; int num_redirect_tests = sizeof(redirect_tests) / sizeof(redirect_tests[0]); diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/test/snapshots/tools.snapshot --- a/mrjunejune/test/snapshots/tools.snapshot Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/test/snapshots/tools.snapshot Mon Aug 03 13:14:41 2026 -0700 @@ -3,7 +3,10 @@ + + + @@ -19,6 +22,8 @@ + + @@ -166,12 +171,12 @@

TODOs

-

Probably should add this...

diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/test/snapshots/tools_hls_player.snapshot --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/snapshots/tools_hls_player.snapshot Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + HLS Player - MrJuneJune + + + + + + + +
+ +
+ +
+

MrJuneJune

+
+ + + +
+

Online HLS Player

+

Load an HLS playlist from a URL, or try the sample stream bundled with this site.

+ +
+ +
+ + + +
+
+ +
+ Local HLS +

Select the playlist together with every init and segment file it references.

+
+ + +
+
+ +
+ +

Ready to load a playlist.

+
+ + + +
+

Notes

+
    +
  • Remote playlists must allow cross-origin requests.
  • +
  • Local loading stays in your browser; selected files are not uploaded.
  • +
  • Chrome, Edge, and Firefox support MPEG-TS, fragmented MP4, live, and adaptive HLS through hls.js.
  • +
  • Safari uses native HLS playback when available.
  • +
+
+
+
+ © 2026 June Park +
+ + + diff -r 9c2eec61a152 -r 543df0fe7168 mrjunejune/test/theme_and_webp_test.js --- a/mrjunejune/test/theme_and_webp_test.js Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Mon Aug 03 13:14:41 2026 -0700 @@ -122,6 +122,228 @@ return sample; } +async function testHlsPlayer(browser, siteRoot) { + const page = await browser.newPage(); + const errors = []; + const mediaRequests = []; + let testingExpectedFailure = false; + let testingExpectedReload = false; + page.on('pageerror', error => errors.push(`pageerror: ${error.message}`)); + page.on('console', message => { + if (message.type() !== 'error') return; + if (testingExpectedFailure && + message.text().startsWith('Failed to load resource:')) return; + errors.push(`console: ${message.text()}`); + }); + page.on('requestfailed', request => { + if (testingExpectedReload && + request.failure()?.errorText === 'net::ERR_ABORTED') return; + errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); + }); + page.on('request', request => { + if (/\.(?:m3u8|m4s|mp4)(?:\?|$)/.test(request.url())) { + mediaRequests.push(request.url()); + } + }); + + const playlistResponse = await fetch( + `${baseUrl}/public/hls-sample/master.m3u8`, + ); + assert.equal(playlistResponse.status, 200); + assert.match( + playlistResponse.headers.get('content-type') || '', + /^application\/vnd\.apple\.mpegurl/, + ); + assert.match( + await playlistResponse.text(), + /#EXT-X-STREAM-INF:.*CODECS="vp09\.00\.10\.08,opus"/, + ); + + const variantResponse = await fetch( + `${baseUrl}/public/hls-sample/vp9-stream.m3u8`, + ); + assert.equal(variantResponse.status, 200); + assert.match( + await variantResponse.text(), + /#EXT-X-MAP:URI="vp9-init\.mp4"/, + ); + + const segmentResponse = await fetch( + `${baseUrl}/public/hls-sample/vp9-segment000.m4s`, + ); + assert.equal(segmentResponse.status, 200); + assert.match( + segmentResponse.headers.get('content-type') || '', + /^video\/iso\.segment/, + ); + const transportStreamResponse = await fetch( + `${baseUrl}/public/hls-sample/h264-ts-segment000.ts`, + ); + assert.equal(transportStreamResponse.status, 200); + assert.match( + transportStreamResponse.headers.get('content-type') || '', + /^video\/mp2t/, + ); + assert.ok((await transportStreamResponse.arrayBuffer()).byteLength > 0); + + await page.goto( + `${baseUrl}/tools/hls_player?url=${encodeURIComponent('/public/hls-sample/master.m3u8')}`, + { + waitUntil: 'networkidle', + }, + ); + await page.getByRole('button', { name: 'Sample', exact: true }).waitFor(); + assert.equal( + await page.locator('#hlsUrl').evaluate(input => input.defaultValue), + '/public/hls-sample/h264-ts-stream.m3u8', + ); + await page.waitForFunction(() => { + const status = document.querySelector('#hlsStatus'); + return status?.dataset.state === 'ready' || + status?.dataset.state === 'error'; + }, null, { timeout: 15000 }); + const terminalState = await page.locator('#hlsStatus').getAttribute('data-state'); + if (terminalState !== 'ready') { + throw new Error( + `HLS player failed: ${await page.locator('#hlsStatus').textContent()}\n${errors.join('\n')}`, + ); + } + assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/); + assert.equal( + await page.locator('[data-detail="mode"]').textContent(), + 'hls.js', + ); + assert.match( + await page.locator('[data-detail="segments"]').textContent(), + /Managed by hls\.js|adaptive levels/, + ); + await page.waitForFunction(() => { + const video = document.querySelector('#hlsVideo'); + return video.readyState >= HTMLMediaElement.HAVE_METADATA && + Number.isFinite(video.duration) && + video.duration >= 14; + }); + const firstFrame = await page.locator('#hlsVideo').evaluate(video => { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext('2d'); + context.drawImage(video, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let total = 0; + for (let index = 0; index < pixels.length; index += 4) { + total += pixels[index] + pixels[index + 1] + pixels[index + 2]; + } + return total / (canvas.width * canvas.height * 3); + }); + assert.ok(firstFrame > 10, `Initial HLS frame is black: ${firstFrame}`); + await page.locator('#hlsVideo').evaluate(video => video.play()); + await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.2); + await page.locator('#hlsVideo').evaluate(video => video.pause()); + assert.ok(mediaRequests.some(url => url.endsWith('/hls-sample/master.m3u8'))); + assert.ok(mediaRequests.some(url => url.endsWith('-stream.m3u8'))); + assert.ok(mediaRequests.some(url => url.endsWith('-init.mp4'))); + assert.ok(mediaRequests.some(url => /-segment\d+\.m4s$/.test(url))); + + await page.evaluate(() => { + window.hlsPlayer.hls.trigger(window.Hls.Events.ERROR, { + fatal: true, + type: window.Hls.ErrorTypes.MEDIA_ERROR, + details: 'testRuntimeFailure', + error: new Error('runtime segment failed'), + }); + }); + await page.locator('#hlsStatus[data-state="error"]').waitFor(); + assert.match( + await page.locator('#hlsStatus').textContent(), + /runtime segment failed/, + ); + await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8'); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.locator('#hlsStatus[data-state="ready"]').waitFor(); + + let delayedSegment = true; + await page.route('**/hls-sample/vp9-segment000.m4s', async route => { + if (delayedSegment) { + delayedSegment = false; + await new Promise(resolve => setTimeout(resolve, 250)); + } + await route.continue(); + }); + testingExpectedReload = true; + await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8'); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.waitForTimeout(25); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.locator('#hlsStatus[data-state="ready"]').waitFor(); + testingExpectedReload = false; + assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/); + + await page.evaluate(() => { + window.__hlsObjectUrls = { created: [], revoked: [] }; + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = value => { + const url = createObjectURL(value); + window.__hlsObjectUrls.created.push(url); + return url; + }; + URL.revokeObjectURL = url => { + window.__hlsObjectUrls.revoked.push(url); + revokeObjectURL(url); + }; + }); + const localFiles = listFiles(path.join(siteRoot, 'public/hls-sample')); + await page.locator('#hlsFiles').setInputFiles(localFiles); + await page.waitForFunction(() => + document.querySelector('#hlsUrl')?.value.startsWith('Local: ') + ); + await page.locator('#hlsStatus[data-state="ready"]').waitFor(); + assert.match(await page.locator('#hlsUrl').inputValue(), /^Local: /); + assert.equal( + await page.locator('[data-detail="mode"]').textContent(), + 'hls.js', + ); + await page.locator('#hlsVideo').evaluate(video => video.play()); + await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.3); + await page.locator('#hlsVideo').evaluate(video => video.pause()); + const incompleteLocalFiles = localFiles.filter(file => + file.endsWith('master.m3u8') || + file.endsWith('vp9-stream.m3u8') + ); + await page.locator('#hlsFiles').setInputFiles(incompleteLocalFiles); + await page.locator('#hlsStatus[data-state="error"]').waitFor(); + assert.match( + await page.locator('#hlsStatus').textContent(), + /Local HLS file is missing/, + ); + const objectUrlCounts = await page.evaluate(() => ({ + created: window.__hlsObjectUrls.created.length, + revoked: window.__hlsObjectUrls.revoked.length, + })); + assert.ok(objectUrlCounts.created > 10, JSON.stringify(objectUrlCounts)); + assert.ok( + objectUrlCounts.revoked >= objectUrlCounts.created, + JSON.stringify(objectUrlCounts), + ); + + await page.route('**/invalid.m3u8', route => route.fulfill({ + status: 404, + body: 'not found', + })); + testingExpectedFailure = true; + await page.locator('#hlsUrl').fill(`${baseUrl}/invalid.m3u8`); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.locator('#hlsStatus[data-state="error"]').waitFor(); + assert.match( + await page.locator('#hlsStatus').textContent(), + /404|manifestLoadError|Playlist request failed/, + ); + + if (errors.length) throw new Error(errors.join('\n')); + await page.close(); +} + function findFreePort() { return new Promise((resolve, reject) => { const socket = net.createServer(); @@ -172,9 +394,14 @@ const manifest = await ( await fetch(`${baseUrl}/public/manifest.json`) ).text(); + const serviceWorker = await ( + await fetch(`${baseUrl}/public/sw.js`) + ).text(); for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } + assert.match(serviceWorker, /v4-hlsjs/); + assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')")); for (const asset of [ 'sprite_shiba0.webp', @@ -199,6 +426,7 @@ assert.ok(dark.count > 0); assert.ok(light.luminance < 175, JSON.stringify(light)); assert.ok(dark.luminance > 200, JSON.stringify(dark)); + await testHlsPlayer(browser, siteRoot); } finally { if (browser) await browser.close(); await stopProcess(server); diff -r 9c2eec61a152 -r 543df0fe7168 seobeo/s_web.c --- a/seobeo/s_web.c Mon Aug 03 11:26:30 2026 -0700 +++ b/seobeo/s_web.c Mon Aug 03 13:14:41 2026 -0700 @@ -292,6 +292,9 @@ else if (strstr(file_path, ".xml")) mime = "application/xml"; else if (strstr(file_path, ".pdf")) mime = "application/pdf"; else if (strstr(file_path, ".txt")) mime = "text/plain"; + else if (strstr(file_path, ".m3u8")) mime = "application/vnd.apple.mpegurl"; + else if (strstr(file_path, ".m4s")) mime = "video/iso.segment"; + else if (strstr(file_path, ".ts")) mime = "video/mp2t"; else if (strstr(file_path, ".mp4")) mime = "video/mp4"; else if (strstr(file_path, ".webm")) mime = "video/webm"; else if (strstr(file_path, ".mp3")) mime = "audio/mpeg"; diff -r 9c2eec61a152 -r 543df0fe7168 third_party/ffmpeg/BUILD --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/third_party/ffmpeg/BUILD Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,20 @@ +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +cc_library( + name = "ffmpeg_cli", + srcs = ["ffmpeg_cli.c"], + hdrs = ["ffmpeg_cli.h"], + visibility = ["//visibility:public"], +) + +cc_test( + name = "ffmpeg_hls_test", + srcs = ["ffmpeg_hls_test.c"], + args = ["$(location //mrjunejune:sample_hls_mp4)"], + data = ["//mrjunejune:sample_hls_mp4"], + deps = [":ffmpeg_cli"], + size = "large", + timeout = "long", +) diff -r 9c2eec61a152 -r 543df0fe7168 third_party/ffmpeg/README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/third_party/ffmpeg/README.md Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,24 @@ +# FFmpeg CLI wrapper + +This package restores the existing process-based FFmpeg wrapper and adds +fragmented-MP4 HLS VOD generation. + +The wrapper uses `fork`/`execvp` with explicit arguments instead of building a +shell command. FFmpeg must be installed on the machine running the target. + +```c +Fmp4HlsOpts options = ffmpeg_fmp4_hls_opts_default(); +options.codec = FFMPEG_HLS_VP9_OPUS; +ffmpeg_video_to_fmp4_hls( + "input.mp4", + "output", + "sample", + &options); +``` + +The wrapper supports VP9/Opus and H.264/AAC fMP4 renditions so callers can +build codec-compatible HLS master playlists. + +```bash +bazel test //third_party/ffmpeg:ffmpeg_hls_test +``` diff -r 9c2eec61a152 -r 543df0fe7168 third_party/ffmpeg/ffmpeg_cli.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/third_party/ffmpeg/ffmpeg_cli.c Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,257 @@ +#include "third_party/ffmpeg/ffmpeg_cli.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_ERROR_LENGTH 512 +#define MAX_PATH_LENGTH 1024 + +static char g_last_error[MAX_ERROR_LENGTH]; + +static void set_error(const char *format, ...) +{ + va_list arguments; + va_start(arguments, format); + vsnprintf(g_last_error, sizeof(g_last_error), format, arguments); + va_end(arguments); +} + +static bool file_exists(const char *path) +{ + struct stat status; + return path && stat(path, &status) == 0 && S_ISREG(status.st_mode); +} + +static bool ensure_directory(const char *path) +{ + struct stat status; + if (stat(path, &status) == 0) + return S_ISDIR(status.st_mode); + return mkdir(path, 0755) == 0; +} + +static int run_process(char *const arguments[]) +{ + pid_t child = fork(); + if (child < 0) + { + set_error("fork failed: %s", strerror(errno)); + return -1; + } + if (child == 0) + { + execvp(arguments[0], arguments); + _exit(127); + } + + int status = 0; + while (waitpid(child, &status, 0) < 0) + { + if (errno == EINTR) + continue; + set_error("waitpid failed: %s", strerror(errno)); + return -1; + } + if (!WIFEXITED(status)) + return -1; + return WEXITSTATUS(status); +} + +Fmp4HlsOpts ffmpeg_fmp4_hls_opts_default(void) +{ + return (Fmp4HlsOpts){ + .segment_duration = 2, + .video_bitrate = 900, + .audio_bitrate = 96, + .max_width = 960, + .max_height = 720, + .include_audio = true, + .use_mpeg_ts = false, + .codec = FFMPEG_HLS_VP9_OPUS, + }; +} + +bool ffmpeg_is_available(void) +{ + char *const arguments[] = { + "ffmpeg", "-version", NULL, + }; + return run_process(arguments) == 0; +} + +const char *ffmpeg_last_error(void) +{ + return g_last_error; +} + +FfmpegResult ffmpeg_video_to_fmp4_hls( + const char *input_path, + const char *output_dir, + const char *name, + const Fmp4HlsOpts *options) +{ + if (!input_path || !output_dir || !name || name[0] == '\0') + { + set_error("input path, output directory, and name are 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_directory(output_dir)) + { + set_error("unable to create output directory: %s", output_dir); + return FFMPEG_ERR_OUTPUT_FAILED; + } + + Fmp4HlsOpts defaults = ffmpeg_fmp4_hls_opts_default(); + const Fmp4HlsOpts *opts = options ? options : &defaults; + if (opts->segment_duration <= 0 || + opts->video_bitrate <= 0 || + opts->max_width <= 0 || + opts->max_height <= 0) + { + set_error("invalid fMP4 HLS options"); + return FFMPEG_ERR_INVALID_ARGS; + } + + char playlist_path[MAX_PATH_LENGTH]; + char init_name[256]; + char segment_pattern[MAX_PATH_LENGTH]; + char segment_duration[16]; + char video_bitrate[32]; + char audio_bitrate[32]; + char keyframe_expression[32]; + char scale_filter[256]; + + snprintf( + playlist_path, + sizeof(playlist_path), + "%s/%s-stream.m3u8", + output_dir, + name); + snprintf(init_name, sizeof(init_name), "%s-init.mp4", name); + snprintf( + segment_pattern, + sizeof(segment_pattern), + "%s/%s-segment%%03d.%s", + output_dir, + name, + opts->use_mpeg_ts ? "ts" : "m4s"); + snprintf(segment_duration, sizeof(segment_duration), "%d", opts->segment_duration); + snprintf(video_bitrate, sizeof(video_bitrate), "%dk", opts->video_bitrate); + snprintf(audio_bitrate, sizeof(audio_bitrate), "%dk", opts->audio_bitrate); + snprintf( + keyframe_expression, + sizeof(keyframe_expression), + "expr:gte(t,n_forced*%d)", + opts->segment_duration); + snprintf( + scale_filter, + sizeof(scale_filter), + "scale=w='min(iw,%d)':h='min(ih,%d)':force_original_aspect_ratio=decrease," + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + opts->max_width, + opts->max_height); + + char *arguments[48]; + size_t count = 0; +#define ADD_ARGUMENT(value) arguments[count++] = (char *)(value) + ADD_ARGUMENT("ffmpeg"); + ADD_ARGUMENT("-hide_banner"); + ADD_ARGUMENT("-loglevel"); + ADD_ARGUMENT("error"); + ADD_ARGUMENT("-nostdin"); + ADD_ARGUMENT("-y"); + ADD_ARGUMENT("-i"); + ADD_ARGUMENT(input_path); + ADD_ARGUMENT("-map"); + ADD_ARGUMENT("0:v:0"); + if (opts->include_audio) + { + ADD_ARGUMENT("-map"); + ADD_ARGUMENT("0:a:0?"); + } + ADD_ARGUMENT("-c:v"); + if (opts->codec == FFMPEG_HLS_H264_AAC) + { + ADD_ARGUMENT("libx264"); + ADD_ARGUMENT("-preset"); + ADD_ARGUMENT("medium"); + ADD_ARGUMENT("-profile:v"); + ADD_ARGUMENT("main"); + ADD_ARGUMENT("-level:v"); + ADD_ARGUMENT("3.1"); + } + else + { + ADD_ARGUMENT("libvpx-vp9"); + ADD_ARGUMENT("-deadline"); + ADD_ARGUMENT("good"); + ADD_ARGUMENT("-cpu-used"); + ADD_ARGUMENT("4"); + } + ADD_ARGUMENT("-pix_fmt"); + ADD_ARGUMENT("yuv420p"); + ADD_ARGUMENT("-b:v"); + ADD_ARGUMENT(video_bitrate); + ADD_ARGUMENT("-vf"); + ADD_ARGUMENT(scale_filter); + ADD_ARGUMENT("-force_key_frames"); + ADD_ARGUMENT(keyframe_expression); + if (opts->include_audio) + { + ADD_ARGUMENT("-c:a"); + ADD_ARGUMENT( + opts->codec == FFMPEG_HLS_H264_AAC + ? "aac" + : "libopus"); + ADD_ARGUMENT("-b:a"); + ADD_ARGUMENT(audio_bitrate); + } + else + { + ADD_ARGUMENT("-an"); + } + ADD_ARGUMENT("-f"); + ADD_ARGUMENT("hls"); + ADD_ARGUMENT("-hls_time"); + ADD_ARGUMENT(segment_duration); + ADD_ARGUMENT("-hls_playlist_type"); + ADD_ARGUMENT("vod"); + if (!opts->use_mpeg_ts) + { + ADD_ARGUMENT("-hls_segment_type"); + ADD_ARGUMENT("fmp4"); + ADD_ARGUMENT("-hls_fmp4_init_filename"); + ADD_ARGUMENT(init_name); + } + ADD_ARGUMENT("-hls_segment_filename"); + ADD_ARGUMENT(segment_pattern); + ADD_ARGUMENT(playlist_path); + arguments[count] = NULL; +#undef ADD_ARGUMENT + + int result = run_process(arguments); + if (result != 0) + { + set_error("ffmpeg HLS conversion failed with exit code %d", result); + return FFMPEG_ERR_PROCESS_FAILED; + } + if (!file_exists(playlist_path)) + { + set_error("playlist was not created: %s", playlist_path); + return FFMPEG_ERR_OUTPUT_FAILED; + } + + return FFMPEG_OK; +} diff -r 9c2eec61a152 -r 543df0fe7168 third_party/ffmpeg/ffmpeg_cli.h --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/third_party/ffmpeg/ffmpeg_cli.h Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,41 @@ +#ifndef FFMPEG_CLI_H +#define FFMPEG_CLI_H + +#include + +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; + +typedef enum { + FFMPEG_HLS_VP9_OPUS = 0, + FFMPEG_HLS_H264_AAC = 1, +} Fmp4HlsCodec; + +typedef struct { + int segment_duration; + int video_bitrate; + int audio_bitrate; + int max_width; + int max_height; + bool include_audio; + bool use_mpeg_ts; + Fmp4HlsCodec codec; +} Fmp4HlsOpts; + +Fmp4HlsOpts ffmpeg_fmp4_hls_opts_default(void); + +FfmpegResult ffmpeg_video_to_fmp4_hls( + const char *input_path, + const char *output_dir, + const char *name, + const Fmp4HlsOpts *opts); + +bool ffmpeg_is_available(void); +const char *ffmpeg_last_error(void); + +#endif diff -r 9c2eec61a152 -r 543df0fe7168 third_party/ffmpeg/ffmpeg_hls_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/third_party/ffmpeg/ffmpeg_hls_test.c Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,82 @@ +#include "third_party/ffmpeg/ffmpeg_cli.h" + +#include +#include +#include +#include +#include + +static char *read_file(const char *path) +{ + FILE *file = fopen(path, "rb"); + if (!file) + return NULL; + fseek(file, 0, SEEK_END); + long length = ftell(file); + fseek(file, 0, SEEK_SET); + char *content = malloc((size_t)length + 1); + fread(content, 1, (size_t)length, file); + content[length] = '\0'; + fclose(file); + return content; +} + +int main(int argc, char **argv) +{ + assert(argc == 2); + char temporary[] = "/tmp/ffmpeg-hls-test-XXXXXX"; + assert(mkdtemp(temporary)); + + Fmp4HlsOpts options = ffmpeg_fmp4_hls_opts_default(); + options.max_width = 480; + options.max_height = 360; + options.video_bitrate = 350; + options.codec = FFMPEG_HLS_VP9_OPUS; + FfmpegResult result = ffmpeg_video_to_fmp4_hls( + argv[1], + temporary, + "test", + &options); + if (result != FFMPEG_OK) + { + fprintf(stderr, "%s\n", ffmpeg_last_error()); + return 1; + } + + char playlist_path[512]; + snprintf(playlist_path, sizeof(playlist_path), "%s/test-stream.m3u8", temporary); + char *playlist = read_file(playlist_path); + assert(playlist); + assert(strstr(playlist, "#EXT-X-MAP:URI=\"test-init.mp4\"")); + assert(strstr(playlist, "test-segment000.m4s")); + assert(strstr(playlist, "#EXT-X-ENDLIST")); + free(playlist); + + options.codec = FFMPEG_HLS_H264_AAC; + options.use_mpeg_ts = true; + result = ffmpeg_video_to_fmp4_hls( + argv[1], + temporary, + "test-ts", + &options); + if (result != FFMPEG_OK) + { + fprintf(stderr, "%s\n", ffmpeg_last_error()); + return 1; + } + snprintf( + playlist_path, + sizeof(playlist_path), + "%s/test-ts-stream.m3u8", + temporary); + playlist = read_file(playlist_path); + assert(playlist); + assert(!strstr(playlist, "#EXT-X-MAP")); + assert(strstr(playlist, "test-ts-segment000.ts")); + assert(strstr(playlist, "#EXT-X-ENDLIST")); + free(playlist); + + char cleanup[640]; + snprintf(cleanup, sizeof(cleanup), "rm -rf '%s'", temporary); + return system(cleanup) == 0 ? 0 : 1; +}