diff --git a/ffmpeg-wasm/814.ffmpeg.js b/ffmpeg-wasm/814.ffmpeg.js new file mode 100644 index 00000000..dc17f6ae --- /dev/null +++ b/ffmpeg-wasm/814.ffmpeg.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FFmpegWASM=t():e.FFmpegWASM=t()}(self,(()=>(()=>{var e={454:e=>{function t(e){return Promise.resolve().then((()=>{var t=new Error("Cannot find module '"+e+"'");throw t.code="MODULE_NOT_FOUND",t}))}t.keys=()=>[],t.resolve=t,t.id=454,e.exports=t}},t={};function r(o){var s=t[o];if(void 0!==s)return s.exports;var a=t[o]={exports:{}};return e[o](a,a.exports,r),a.exports}return r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{"use strict";const e="https://unpkg.com/@ffmpeg/core@0.12.9/dist/umd/ffmpeg-core.js";var t;!function(e){e.LOAD="LOAD",e.EXEC="EXEC",e.FFPROBE="FFPROBE",e.WRITE_FILE="WRITE_FILE",e.READ_FILE="READ_FILE",e.DELETE_FILE="DELETE_FILE",e.RENAME="RENAME",e.CREATE_DIR="CREATE_DIR",e.LIST_DIR="LIST_DIR",e.DELETE_DIR="DELETE_DIR",e.ERROR="ERROR",e.DOWNLOAD="DOWNLOAD",e.PROGRESS="PROGRESS",e.LOG="LOG",e.MOUNT="MOUNT",e.UNMOUNT="UNMOUNT"}(t||(t={}));const o=new Error("unknown message type"),s=new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"),a=(new Error("called FFmpeg.terminate()"),new Error("failed to import ffmpeg-core.js"));let n;self.onmessage=async({data:{id:E,type:c,data:i}})=>{const p=[];let f;try{if(c!==t.LOAD&&!n)throw s;switch(c){case t.LOAD:f=await(async({coreURL:o,wasmURL:s,workerURL:E})=>{const c=!n;try{o||(o=e),importScripts(o)}catch{if(o&&o!==e||(o=e.replace("/umd/","/esm/")),self.createFFmpegCore=(await r(454)(o)).default,!self.createFFmpegCore)throw a}const i=o,p=s||o.replace(/.js$/g,".wasm"),f=E||o.replace(/.js$/g,".worker.js");return n=await self.createFFmpegCore({mainScriptUrlOrBlob:`${i}#${btoa(JSON.stringify({wasmURL:p,workerURL:f}))}`}),n.setLogger((e=>self.postMessage({type:t.LOG,data:e}))),n.setProgress((e=>self.postMessage({type:t.PROGRESS,data:e}))),c})(i);break;case t.EXEC:f=(({args:e,timeout:t=-1})=>{n.setTimeout(t),n.exec(...e);const r=n.ret;return n.reset(),r})(i);break;case t.FFPROBE:f=(({args:e,timeout:t=-1})=>{n.setTimeout(t),n.ffprobe(...e);const r=n.ret;return n.reset(),r})(i);break;case t.WRITE_FILE:f=(({path:e,data:t})=>(n.FS.writeFile(e,t),!0))(i);break;case t.READ_FILE:f=(({path:e,encoding:t})=>n.FS.readFile(e,{encoding:t}))(i);break;case t.DELETE_FILE:f=(({path:e})=>(n.FS.unlink(e),!0))(i);break;case t.RENAME:f=(({oldPath:e,newPath:t})=>(n.FS.rename(e,t),!0))(i);break;case t.CREATE_DIR:f=(({path:e})=>(n.FS.mkdir(e),!0))(i);break;case t.LIST_DIR:f=(({path:e})=>{const t=n.FS.readdir(e),r=[];for(const o of t){const t=n.FS.stat(`${e}/${o}`),s=n.FS.isDir(t.mode);r.push({name:o,isDir:s})}return r})(i);break;case t.DELETE_DIR:f=(({path:e})=>(n.FS.rmdir(e),!0))(i);break;case t.MOUNT:f=(({fsType:e,options:t,mountPoint:r})=>{const o=e,s=n.FS.filesystems[o];return!!s&&(n.FS.mount(s,t,r),!0)})(i);break;case t.UNMOUNT:f=(({mountPoint:e})=>(n.FS.unmount(e),!0))(i);break;default:throw o}}catch(e){return void self.postMessage({id:E,type:t.ERROR,data:e.toString()})}f instanceof Uint8Array&&p.push(f.buffer),self.postMessage({id:E,type:c,data:f},p)}})(),{}})())); +//# sourceMappingURL=814.ffmpeg.js.map \ No newline at end of file diff --git a/ffmpeg-wasm/README.md b/ffmpeg-wasm/README.md new file mode 100644 index 00000000..657b09e5 --- /dev/null +++ b/ffmpeg-wasm/README.md @@ -0,0 +1,13 @@ +# ffmpeg-wasm + +Vendored copy of the UMD build of [`@ffmpeg/ffmpeg`](https://github.com/ffmpegwasm/ffmpeg.wasm) 0.12.15 (MIT license). + +- `ffmpeg.js` - the `FFmpegWASM` wrapper class +- `814.ffmpeg.js` - the Web Worker the wrapper spawns + +These two small files are served from this origin because browsers refuse to +start a Web Worker from a cross-origin (CDN) URL. The much larger +`@ffmpeg/core` files (`ffmpeg-core.js` and the ~32 MB `ffmpeg-core.wasm`) +are loaded from jsDelivr at runtime by the tools that use them. + +Used by `video-compressor.html`. diff --git a/ffmpeg-wasm/ffmpeg.js b/ffmpeg-wasm/ffmpeg.js new file mode 100644 index 00000000..dd26207a --- /dev/null +++ b/ffmpeg-wasm/ffmpeg.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FFmpegWASM=t():e.FFmpegWASM=t()}(self,(()=>(()=>{"use strict";var e={m:{},d:(t,s)=>{for(var r in s)e.o(s,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:s[r]})},u:e=>e+".ffmpeg.js"};e.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),e.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),e.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var t;e.g.importScripts&&(t=e.g.location+"");var s=e.g.document;if(!t&&s&&(s.currentScript&&(t=s.currentScript.src),!t)){var r=s.getElementsByTagName("script");if(r.length)for(var a=r.length-1;a>-1&&!t;)t=r[a--].src}if(!t)throw new Error("Automatic publicPath is not supported in this browser");t=t.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),e.p=t})(),e.b=document.baseURI||self.location.href;var t,s={};e.r(s),e.d(s,{FFFSType:()=>n,FFmpeg:()=>i}),function(e){e.LOAD="LOAD",e.EXEC="EXEC",e.FFPROBE="FFPROBE",e.WRITE_FILE="WRITE_FILE",e.READ_FILE="READ_FILE",e.DELETE_FILE="DELETE_FILE",e.RENAME="RENAME",e.CREATE_DIR="CREATE_DIR",e.LIST_DIR="LIST_DIR",e.DELETE_DIR="DELETE_DIR",e.ERROR="ERROR",e.DOWNLOAD="DOWNLOAD",e.PROGRESS="PROGRESS",e.LOG="LOG",e.MOUNT="MOUNT",e.UNMOUNT="UNMOUNT"}(t||(t={}));const r=(()=>{let e=0;return()=>e++})(),a=(new Error("unknown message type"),new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first")),o=new Error("called FFmpeg.terminate()");new Error("failed to import ffmpeg-core.js");class i{#e=null;#t={};#s={};#r=[];#a=[];loaded=!1;#o=()=>{this.#e&&(this.#e.onmessage=({data:{id:e,type:s,data:r}})=>{switch(s){case t.LOAD:this.loaded=!0,this.#t[e](r);break;case t.MOUNT:case t.UNMOUNT:case t.EXEC:case t.FFPROBE:case t.WRITE_FILE:case t.READ_FILE:case t.DELETE_FILE:case t.RENAME:case t.CREATE_DIR:case t.LIST_DIR:case t.DELETE_DIR:this.#t[e](r);break;case t.LOG:this.#r.forEach((e=>e(r)));break;case t.PROGRESS:this.#a.forEach((e=>e(r)));break;case t.ERROR:this.#s[e](r)}delete this.#t[e],delete this.#s[e]})};#i=({type:e,data:t},s=[],o)=>this.#e?new Promise(((a,i)=>{const n=r();this.#e&&this.#e.postMessage({id:n,type:e,data:t},s),this.#t[n]=a,this.#s[n]=i,o?.addEventListener("abort",(()=>{i(new DOMException(`Message # ${n} was aborted`,"AbortError"))}),{once:!0})})):Promise.reject(a);on(e,t){"log"===e?this.#r.push(t):"progress"===e&&this.#a.push(t)}off(e,t){"log"===e?this.#r=this.#r.filter((e=>e!==t)):"progress"===e&&(this.#a=this.#a.filter((e=>e!==t)))}load=({classWorkerURL:s,...r}={},{signal:a}={})=>(this.#e||(this.#e=s?new Worker(new URL(s,"file:///Users/focus/Projects/ffmpeg.wasm/packages/ffmpeg/dist/esm/classes.js"),{type:"module"}):new Worker(new URL(e.p+e.u(814),e.b),{type:void 0}),this.#o()),this.#i({type:t.LOAD,data:r},void 0,a));exec=(e,s=-1,{signal:r}={})=>this.#i({type:t.EXEC,data:{args:e,timeout:s}},void 0,r);ffprobe=(e,s=-1,{signal:r}={})=>this.#i({type:t.FFPROBE,data:{args:e,timeout:s}},void 0,r);terminate=()=>{const e=Object.keys(this.#s);for(const t of e)this.#s[t](o),delete this.#s[t],delete this.#t[t];this.#e&&(this.#e.terminate(),this.#e=null,this.loaded=!1)};writeFile=(e,s,{signal:r}={})=>{const a=[];return s instanceof Uint8Array&&a.push(s.buffer),this.#i({type:t.WRITE_FILE,data:{path:e,data:s}},a,r)};mount=(e,s,r)=>this.#i({type:t.MOUNT,data:{fsType:e,options:s,mountPoint:r}},[]);unmount=e=>this.#i({type:t.UNMOUNT,data:{mountPoint:e}},[]);readFile=(e,s="binary",{signal:r}={})=>this.#i({type:t.READ_FILE,data:{path:e,encoding:s}},void 0,r);deleteFile=(e,{signal:s}={})=>this.#i({type:t.DELETE_FILE,data:{path:e}},void 0,s);rename=(e,s,{signal:r}={})=>this.#i({type:t.RENAME,data:{oldPath:e,newPath:s}},void 0,r);createDir=(e,{signal:s}={})=>this.#i({type:t.CREATE_DIR,data:{path:e}},void 0,s);listDir=(e,{signal:s}={})=>this.#i({type:t.LIST_DIR,data:{path:e}},void 0,s);deleteDir=(e,{signal:s}={})=>this.#i({type:t.DELETE_DIR,data:{path:e}},void 0,s)}var n;return function(e){e.MEMFS="MEMFS",e.NODEFS="NODEFS",e.NODERAWFS="NODERAWFS",e.IDBFS="IDBFS",e.WORKERFS="WORKERFS",e.PROXYFS="PROXYFS"}(n||(n={})),s})())); +//# sourceMappingURL=ffmpeg.js.map \ No newline at end of file diff --git a/tests/test-video.mp4 b/tests/test-video.mp4 new file mode 100644 index 00000000..cf04eaa2 Binary files /dev/null and b/tests/test-video.mp4 differ diff --git a/tests/test_video_compressor.py b/tests/test_video_compressor.py new file mode 100644 index 00000000..656873c9 --- /dev/null +++ b/tests/test_video_compressor.py @@ -0,0 +1,244 @@ +""" +Playwright tests for video-compressor.html + +The initial-state test runs offline. The full flow downloads the ~32 MB +ffmpeg.wasm core from jsDelivr, encodes a small fixture video into several +MP4 versions, and then checks the resulting files really are the kind of +MP4 the tool promises (H.264 Main profile, yuv420p, AAC, faststart). + +Set FFMPEG_WASM_CACHE_DIR to a directory containing ffmpeg-core.js and +ffmpeg-core.wasm to serve the core from disk instead of the CDN. +""" + +import base64 +import os +import pathlib +import struct + +from playwright.sync_api import Page, expect + + +test_dir = pathlib.Path(__file__).parent.absolute() +root = test_dir.parent.absolute() +fixture = test_dir / "test-video.mp4" + + +def iter_boxes(data, start=0, end=None): + """Yield (type, header_size, body_start, body_end) for MP4 boxes in data[start:end].""" + end = len(data) if end is None else end + pos = start + while pos + 8 <= end: + size, box_type = struct.unpack(">I4s", data[pos:pos + 8]) + header = 8 + if size == 1: + size = struct.unpack(">Q", data[pos + 8:pos + 16])[0] + header = 16 + elif size == 0: + size = end - pos + if size < header: + break + yield box_type, header, pos + header, pos + size + pos += size + + +def find_box(data, path, start=0, end=None): + """Find a nested box by path like [b"moov", b"trak"]; returns (body_start, body_end) or None.""" + # Sample description boxes carry extra header bytes before their children + extra = {b"stsd": 8, b"avc1": 78, b"mp4a": 28} + for box_type, header, body_start, body_end in iter_boxes(data, start, end): + if box_type == path[0]: + if len(path) == 1: + return body_start, body_end + skip = extra.get(box_type, 0) + found = find_box(data, path[1:], body_start + skip, body_end) + if found: + return found + return None + + +def describe_mp4(data): + """Return a dict describing the container and codec layout of an MP4 file.""" + top_level = [box_type for box_type, _, _, _ in iter_boxes(data)] + info = {"boxes": top_level} + avcc = find_box(data, [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"stsd", b"avc1", b"avcC"]) + if avcc: + body = data[avcc[0]:avcc[1]] + info["profile_idc"] = body[1] + info["level_idc"] = body[3] + info["has_mp4a"] = find_box(data, [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"stsd", b"mp4a"]) is not None + tkhd_dims = [] + moov = find_box(data, [b"moov"]) + if moov: + for box_type, _, body_start, body_end in iter_boxes(data, *moov): + if box_type == b"trak": + tkhd = find_box(data, [b"tkhd"], body_start, body_end) + if tkhd: + # width and height are the last two 16.16 fixed point fields of tkhd + offset = tkhd[1] - 8 + width, height = struct.unpack(">II", data[offset:offset + 8]) + if width and height: + tkhd_dims.append((width >> 16, height >> 16)) + info["dims"] = tkhd_dims + return info + + +def test_initial_state(page: Page, unused_port_server): + unused_port_server.start(root) + page.goto(f"http://localhost:{unused_port_server.port}/video-compressor.html") + + expect(page).to_have_title("Video compressor") + expect(page.locator("h1")).to_have_text("Video compressor") + expect(page.locator("#drop-zone")).to_be_visible() + + # Settings and results only appear once a video has been chosen + expect(page.locator("#settings-card")).to_be_hidden() + expect(page.locator("#results-card")).to_be_hidden() + expect(page.locator("#error")).to_be_hidden() + + # The vendored ffmpeg.wasm wrapper loaded from this origin + assert page.evaluate("typeof FFmpegWASM.FFmpeg") == "function" + + +def test_full_flow_encodes_compatible_mp4s(page: Page, unused_port_server): + """Downloads ffmpeg.wasm from the CDN unless FFMPEG_WASM_CACHE_DIR is set.""" + cache_dir = os.environ.get("FFMPEG_WASM_CACHE_DIR") + if cache_dir: + cache = pathlib.Path(cache_dir) + + def serve_from_cache(route, request): + local = cache / request.url.rsplit("/", 1)[-1] + if local.exists(): + content_type = "application/wasm" if local.suffix == ".wasm" else "text/javascript" + route.fulfill(status=200, body=local.read_bytes(), content_type=content_type, + headers={"Access-Control-Allow-Origin": "*"}) + else: + route.abort() + + page.route("https://cdn.jsdelivr.net/**", serve_from_cache) + + unused_port_server.start(root) + page.goto(f"http://localhost:{unused_port_server.port}/video-compressor.html") + + page.set_input_files("#file-input", str(fixture)) + expect(page.locator("#settings-card")).to_be_visible() + expect(page.locator("#source-details")).to_contain_text("test-video.mp4") + + # Five default versions, all selected. The fixture is 640x360 so every + # version is capped to the source resolution; only CRF differs. Keep the + # three fastest. + rows = page.locator("#variants-body tr") + expect(rows).to_have_count(5) + for row_id in ("xl", "l"): + page.locator(f"#variants-body tr[data-id={row_id}] input.enabled").uncheck() + + page.click("#generate") + # ~32 MB download plus three encodes of a 3 second clip + page.wait_for_selector("#results-card[data-state=done]", timeout=300_000) + expect(page.locator("#status")).to_contain_text("Done: 3 versions") + expect(page.locator("#error")).to_be_hidden() + + # Playwright's Chromium has no H.264 decoder, so these details come from + # ffmpeg probing the file rather than from the