Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ffmpeg-wasm/814.ffmpeg.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions ffmpeg-wasm/README.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 2 additions & 0 deletions ffmpeg-wasm/ffmpeg.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added tests/test-video.mp4
Binary file not shown.
244 changes: 244 additions & 0 deletions tests/test_video_compressor.py
Original file line number Diff line number Diff line change
@@ -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 <video> element.
expect(page.locator("#source-details")).to_contain_text("640×360")
expect(page.locator("#source-details")).to_contain_text("0:03")
expect(page.locator("#source-details")).to_contain_text("44100 Hz, stereo")

# The original card plus one card per version, none failed
expect(page.locator(".result.original")).to_have_count(1)
expect(page.locator(".result.failed")).to_have_count(0)
cards = page.locator(".result[data-state=done]")
expect(cards).to_have_count(3)

# Sorted smallest first. (The fixture itself is a low bitrate encode, so
# the versions are not necessarily smaller than it.)
sizes = [int(s) for s in cards.evaluate_all("els => els.map(e => e.dataset.bytes)")]
assert sizes == sorted(sizes)
assert all(size > 1000 for size in sizes)

# Each card shows the ffmpeg command line that reproduces it
first_command = cards.first.locator(".command").text_content()
assert first_command.startswith("ffmpeg ")
assert "-c:v libx264" in first_command
assert "-movflags +faststart" in first_command
assert "-map_metadata -1" in first_command

# Pull the actual bytes out of the blob URLs and inspect the MP4 structure
encoded = cards.evaluate_all("""async (els) => {
const out = [];
for (const el of els) {
const buf = await (await fetch(el.querySelector('video').src)).arrayBuffer();
let s = '';
for (const b of new Uint8Array(buf)) s += String.fromCharCode(b);
out.push({b64: btoa(s), width: el.dataset.width, height: el.dataset.height});
}
return out;
}""")
assert len(encoded) == 3
for item in encoded:
data = base64.b64decode(item["b64"])
info = describe_mp4(data)
assert info["boxes"][0] == b"ftyp"
# faststart: moov before mdat
assert info["boxes"].index(b"moov") < info["boxes"].index(b"mdat")
# 66 = Baseline, 77 = Main, 100 = High. x264 signals the lowest profile the
# stream actually conforms to, so Main can come out as Baseline; never High.
assert info["profile_idc"] in (66, 77), info
assert info["level_idc"] <= 31, info # 640x360 at 30fps needs no more than level 3.1
assert info["has_mp4a"]
assert (640, 360) in info["dims"]
assert (item["width"], item["height"]) == ("640", "360")


def test_sample_mode_estimates_full_size(page: Page, unused_port_server):
"""Same download as the full flow; checks the 'first N seconds' and 'no audio' options."""
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()

for row_id in ("xl", "l", "m", "s"):
page.locator(f"#variants-body tr[data-id={row_id}] input.enabled").uncheck()
page.select_option("#preset", "ultrafast")
page.check("#sample-mode")
page.fill("#sample-seconds", "1")
page.check("#no-audio")
expect(page.locator("#variants-body tr[data-id=xs] input.audio")).to_be_disabled()

page.click("#generate")
page.wait_for_selector("#results-card[data-state=done]", timeout=300_000)
card = page.locator(".result[data-state=done]")
expect(card).to_have_count(1)
expect(card.locator(".title")).to_contain_text("first 0:01")
expect(card.locator(".size")).to_contain_text("estimated full length")
expect(card.locator(".meta")).to_contain_text("no audio")
expect(card.locator("button", has_text="Encode full version")).to_be_visible()
command = card.locator(".command").text_content()
assert " -t 1 " in command
assert " -an " in command
assert "-c:a" not in command

# The file really has no audio track
b64 = card.evaluate("""async (el) => {
const buf = await (await fetch(el.querySelector('video').src)).arrayBuffer();
let s = '';
for (const b of new Uint8Array(buf)) s += String.fromCharCode(b);
return btoa(s);
}""")
info = describe_mp4(base64.b64decode(b64))
assert info["profile_idc"] in (66, 77)
assert not info["has_mp4a"], info
Loading
Loading