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
116 changes: 61 additions & 55 deletions CHANGELOG.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Follow assertion style (actual on left, expected on right)
- Always bump the version in `package.json` appropriately when any file under `src/` (except `tests/`), `configs/`, or `package.json`/`package-lock.json` itself, is changed. Bump once per PR: if the version was already bumped by earlier work on the same PR/branch and it hasn't been merged yet, do not bump it again for follow-up commits on that same PR, keep adding entries under the existing top-most `CHANGELOG.md` heading instead
- This project has no formal releases, so there is no `## Upcoming` staging section in `CHANGELOG.md`. Leave a short description of the change or addition directly under the top-most version heading (the same version just bumped in `package.json`; create the heading if it does not yet exist) under the appropriate subsection (`#### 🚀 Enhancement`, `#### 🐛 Bug Fix`, or `#### 🏠 Internal`); create the subsection if it does not yet exist; include the GitHub PR link at the end of each entry in the format `([#N](https://github.com/brain-bbqs/encoding-helper/pull/N))`
- Keep `CHANGELOG.md` entries to a single sentence each, roughly 25 words or fewer: what changed, from the reader's side. Not why, not how, not the reasoning behind it, which belong in the code comments and the PR. Fold related work into one entry rather than giving each part its own
- PR titles should be human-readable and in the past tense; they should NOT use conventional commit style
- Keep PR descriptions short and to the point
- Limit use of em-dashes in all text
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Companion to [Video Info Tool](https://vibes.tlab.sh/video-info-tool/) and [Fram
- **Reencode In-Browser** - encodes the whole video to H.264/MP4 in its own tab, saved back to disk via the File System Access API, with two engines:
- **ffmpeg.wasm (exact)** - runs the literal CRF/preset command, byte-for-byte equivalent to the CLI, lazy-loaded (~30 MB), GPL
- **mediabunny / WebCodecs (fast)** - hardware-accelerated, no CRF (bitrate/quality-preset only), surfaced honestly as an approximation
- **Compare Quality (A/B)** - encodes just a short window (1-10s) of the video at the chosen CRF/preset, then decodes the original and the result side-by-side with synchronized pixel-level zoom & pan, one-click **Fit**/**Actual Size (100%)** buttons, a pixel grid that appears once zoomed in far enough to make individual pixels visible, and a scrub slider, so you can judge a quality setting before committing to a full reencode
- **Compare Quality (A/B)** - encodes just a short window (1-10s) of the video at the chosen CRF/preset, then decodes the original and the result side-by-side with synchronized pixel-level zoom & pan, one-click **Fit**/**Actual Size (100%)** buttons, a pixel grid that appears once zoomed in far enough to make individual pixels visible, and a scrub slider, so you can judge a quality setting before committing to a full reencode. It also estimates the data savings: the encoded snippet against what the same seconds cost in the source, that ratio projected onto the whole file, with a range on it
- **Always emits a CLI command** - a live, editable `ffmpeg` command mirroring [sleap-io](https://github.com/talmolab/sleap-io)'s `reencode`, for anyone who wants to run it locally, headless, or in batch
- **Full Analysis** - one button beside the tabs bundles everything the tabs worked out into a single document: container and track metadata with the explainer that goes with each number, the bitrate plot, the atom map, GOP/keyframe structure with its histogram, the seeking test with its scatter plot, Compare Quality and in-browser reencode results (once run), and the CLI command. Read it in the page, save it as a self-contained `.html` file (no external assets, so it opens anywhere), print it to PDF, or copy it as Markdown
- **Shareable links** - the active tab lives in the URL (`?tab=seek`), and a video loaded from a remote URL is recorded alongside it (`?src=…`) and re-opened automatically, so a link points a colleague at the same file on the same tab
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "encoding-helper",
"version": "0.3.0",
"version": "0.3.1",
"description": "A didactic, in-browser video encoding lab: inspect MP4/H.264 structure, learn how encoding works, run empirical seeking tests, and re-encode video directly in the browser.",
"type": "module",
"license": "MIT",
Expand Down
72 changes: 72 additions & 0 deletions src/lib/explainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { BitrateTimeline } from "./bitrateTimeline";
import type { ContainerInfo } from "./containerKb";
import { escapeHtml } from "./dom";
import { fmtBits } from "./format";
import type { SizeEstimate } from "./sizeEstimate";
import type { CodecInfo } from "./types";

/** The Overview's whole-file bitrate, which is not the same as any one track's bitrate. */
Expand Down Expand Up @@ -178,3 +179,74 @@ export const GOP_TEACH =
export const SEEK_TEST_INTRO =
"Samples N evenly-spaced timestamps across the video and measures how far back the nearest keyframe is, " +
"plus how long it takes to decode that frame.";

/** Why the Compare Quality tab reports a size at all, and on what terms it projects one. */
export const SIZE_SAVINGS_INTRO =
"Quality is only ever traded against bytes, so the other half of this comparison is what the settings cost. " +
"Only a few seconds were encoded, but that is enough to estimate the whole file: the snippet is compared " +
"against what the <i>same</i> seconds cost in the source, and that ratio is applied to the source's real size.";

/** How the sampled stretch's cost in the source was arrived at, which depends on the file. */
export function sizeEstimateTeach(estimate: SizeEstimate): string {
const basis =
estimate.basis === "sample-table"
? `The original side of that ratio is measured, not assumed: the container's sample table lists every ` +
`frame's size, so the bytes this exact stretch costs in the source were summed out of it, plus the ` +
`stretch's share of the audio track and the container's own overhead.`
: `The original side of that ratio had to be approximated: no sample table was available for this file, ` +
`so the source's cost for the stretch is its total size spread evenly across its running time. That is ` +
`exact only for a constant-bitrate file, and this estimate is the rougher for it.`;
const difficulty = windowDifficultySentence(estimate);
const band = estimate.projectedRange
? `<p>The range is not a confidence interval in any formal sense: it is how far the file's own ` +
`equal-length windows sit from one another, narrowed by how much of the file was sampled. A file whose ` +
`windows all cost about the same is one where any window predicts the rest; a file that swings between ` +
`still shots and fast motion is one where a single snippet cannot.</p>`
: "";
return (
`${basis} ${difficulty}` +
band +
`<p><b>Why it is still only an estimate.</b> A CRF encode spends bits per content, so a stretch this ` +
`snippet never saw may compress on quite different terms. Ratios also hold better than totals: expect the ` +
`percentage to survive better than the megabytes. The settings that apply file-wide (the keyframe ` +
`interval, whether audio is copied or dropped) are already reflected here, since the snippet was encoded ` +
`with them, but per-file one-offs such as the <code>moov</code> index and faststart are assumed to scale ` +
`with length. For an exact number, encode the whole file in the <b>Reencode &amp; CLI</b> tab.</p>`
);
}

/** How representative the sampled stretch is, when the sample table lets that be measured. */
function windowDifficultySentence(estimate: SizeEstimate): string {
const d = estimate.windowDifficulty;
if (d == null || !isFinite(d) || d <= 0) return "";
if (d >= 1.15) {
return (
`The stretch picked here is a <b>busy</b> one, costing ${escapeHtml(d.toFixed(1))}&times; the source's ` +
`average rate. Dividing by the source's cost for those same seconds is what keeps the projection from ` +
`pricing the entire file at this stretch's rate.`
);
}
if (d <= 0.85) {
return (
`The stretch picked here is a <b>calm</b> one, costing ${escapeHtml(d.toFixed(2))}&times; the source's ` +
`average rate. Dividing by the source's cost for those same seconds is what keeps the projection from ` +
`pricing the entire file at this stretch's rate.`
);
}
return `The stretch picked here costs about what the source averages, so it is a fair sample to project from.`;
}

export const ORIGINAL_SEGMENT_INFO =
"What the <i>source</i> spends on the same seconds the encode covered, counted on the same terms: video " +
"frames, plus the stretch's share of the audio track and the container's overhead. This is the number the " +
"encoded segment is compared against, since comparing it with the whole file's size would only be " +
"comparing three seconds with an hour.";

export const PROJECTED_SIZE_INFO =
"The source's size times the ratio the snippet came to (encoded segment &divide; the same stretch of the " +
"source), i.e. what the whole file would come to at these settings if the rest of it compresses like the " +
"part that was sampled. It is an extrapolation from a few seconds, not a measurement.";

export const SAMPLED_WINDOW_INFO =
"How much of the file this estimate actually saw. The smaller this is, the more the projection is leaning " +
"on the sampled seconds being typical of the rest; lengthening the segment above narrows the range.";
78 changes: 66 additions & 12 deletions src/lib/ffmpegEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,42 @@ let ffmpegInstance: FFmpeg | null = null;
let logHandler: FfmpegLogHandler | null = null;
let progressHandler: FfmpegProgressHandler | null = null;

/**
* The core's two blob: URLs, kept across instances. A crashed core has to be replaced by a fresh
* one (see resetFfmpeg), and re-fetching ~30 MB to do it would make every crash cost a download.
*/
let coreUrls: Promise<{ coreURL: string; wasmURL: string }> | null = null;

function loadCoreUrls(): Promise<{ coreURL: string; wasmURL: string }> {
if (coreUrls) return coreUrls;
logHandler?.("Downloading ffmpeg-core (~30 MB, first use only)…");
coreUrls = Promise.all([
toBlobURL(`${FFMPEG_CORE_BASE}/ffmpeg-core.js`, "text/javascript"),
toBlobURL(`${FFMPEG_CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"),
])
.then(([coreURL, wasmURL]) => ({ coreURL, wasmURL }))
// A failed download must not be remembered as the answer for every later attempt.
.catch((err) => {
coreUrls = null;
throw err;
});
return coreUrls;
}

/**
* Throws away the loaded core, so the next run builds a new one.
*
* Emscripten's `abort()` does not fail one call, it kills the runtime: the module sets its abort
* flag and every later call into that instance throws the same way, whatever it is asked to do. A
* cached instance that has aborted therefore fails every subsequent encode until the page is
* reloaded — including encodes with settings that would have worked — so a run that crashes has to
* drop the instance rather than keep it for next time.
*/
export function resetFfmpeg(): void {
ffmpegInstance?.terminate();
ffmpegInstance = null;
}

/** Rebinds the log/progress callbacks used by the shared FFmpeg instance for its next run. */
export function setFfmpegHandlers(onLog: FfmpegLogHandler | null, onProgress: FfmpegProgressHandler | null): void {
logHandler = onLog;
Expand All @@ -43,12 +79,7 @@ export async function ensureFfmpegLoaded(): Promise<FFmpeg> {
const ffmpeg = new FFmpeg();
ffmpeg.on("log", ({ message }) => logHandler?.(message));
ffmpeg.on("progress", ({ progress }) => progressHandler?.(progress));
logHandler?.("Downloading ffmpeg-core (~30 MB, first use only)…");
const [coreURL, wasmURL] = await Promise.all([
toBlobURL(`${FFMPEG_CORE_BASE}/ffmpeg-core.js`, "text/javascript"),
toBlobURL(`${FFMPEG_CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"),
]);
await ffmpeg.load({ coreURL, wasmURL });
await ffmpeg.load(await loadCoreUrls());
ffmpegInstance = ffmpeg;
return ffmpeg;
}
Expand All @@ -68,15 +99,38 @@ export async function runFfmpegEncode(
outputName: string,
): Promise<FfmpegRunResult> {
const ffmpeg = await ensureFfmpegLoaded();
await ffmpeg.writeFile(inputName, inputData);
let data: Uint8Array<ArrayBuffer>;
try {
await ffmpeg.writeFile(inputName, inputData);
await ffmpeg.exec(args);
const data = await ffmpeg.readFile(outputName);
// ffmpeg.wasm's FileData type is generically `Uint8Array<ArrayBufferLike> | string`; copying into
// a fresh Uint8Array guarantees a plain ArrayBuffer-backed view, which is what Blob/BlobPart expect.
return { data: new Uint8Array(data as Uint8Array) };
} finally {
await ffmpeg.deleteFile(inputName).catch(() => {});
await ffmpeg.deleteFile(outputName).catch(() => {});
data = new Uint8Array((await ffmpeg.readFile(outputName)) as Uint8Array);
} catch (err) {
// Anything that rejects here reached us through the worker's catch-all, which means the call
// into wasm threw rather than ffmpeg merely exiting non-zero. The instance is not to be trusted
// afterwards, so it goes rather than being left to fail every later run.
resetFfmpeg();
throw new Error(describeFfmpegFailure(err));
}
// Only on the way out of a healthy run: a dead instance has no filesystem left to tidy.
await ffmpeg.deleteFile(inputName).catch(() => {});
await ffmpeg.deleteFile(outputName).catch(() => {});
return { data };
}

/**
* Turns a crash inside the core into something a reader can act on. Emscripten's `abort()` says
* only "Aborted()" — the encode it was running is gone, and the reason for it is inside a 30 MB
* binary we did not build, so the useful part of the message is what to try instead.
*/
function describeFfmpegFailure(err: unknown): string {
const raw = (err instanceof Error ? err.message : String(err)).trim();
if (!/abort/i.test(raw)) return raw;
return (
`ffmpeg.wasm crashed part-way through (${raw}). The in-browser core is a single-threaded build, ` +
`and the slowest x264 presets ask far more of it than the faster ones; a quicker preset or a ` +
`shorter segment usually gets through. The command itself is sound — run it outside the browser ` +
`for the exact result. The encoder has been reset, so the next run starts from a fresh core.`
);
}
Loading
Loading