diff --git a/CHANGELOG.md b/CHANGELOG.md
index 576585b..89cbe04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,33 +1,44 @@
# Changelog
+## 0.3.1
+
+#### π Enhancement
+
+- **Compare Quality** now estimates data savings: a headline figure and two bars projecting the whole file from the encoded snippet ([#18](https://github.com/brain-bbqs/encoding-helper/pull/18))
+- The projection applies the snippet's compression ratio to the source's real size, reading the sampled stretch's cost from the sample table where there is one ([#18](https://github.com/brain-bbqs/encoding-helper/pull/18))
+- The projection carries a range, drawn on its bar, that narrows as more of the file is sampled ([#18](https://github.com/brain-bbqs/encoding-helper/pull/18))
+- Added an **Estimate Detail** block with the numbers behind the headline, and the same projection to the **Full Analysis** document ([#18](https://github.com/brain-bbqs/encoding-helper/pull/18))
+
+#### π Bug Fix
+
+- A crashed ffmpeg.wasm core is replaced rather than reused, so one failed encode no longer fails every later one until reload ([#18](https://github.com/brain-bbqs/encoding-helper/pull/18))
+- A crash now reports what to try instead of only `Aborted()` ([#18](https://github.com/brain-bbqs/encoding-helper/pull/18))
+
## 0.3.0
#### π Enhancement
-- Renamed the **Report** tab to **Full Analysis** and moved it out of the tab row: it is now a single button aligned to the right of the content column, carrying a document-with-download icon. It was never a seventh place to look, it was the one control on that row that produces something, and sharing the row (and the word "report", which the footer already uses for "π Report a bug") made it read as both a view and an issue-reporting feature ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- What the button produces is now an actual document rather than a page of cards: a title block naming the file with its container, codec, resolution, frame rate, duration and size, a linked table of contents, and every section in reading order. The explainers that live behind an β on the page are written out inline, since nobody can hover a saved file ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- The document now carries the analyses the report used to leave behind: this file's container explainer, the **Video Bitrate Over Time** stats with the plot itself (including the constant-bitrate and too-few-frames cases), the GOP histogram, the seeking test's scatter plot, the chroma-subsampling and faststart notes, and the result of an in-browser reencode. Charts travel as inline SVG, so they survive into the saved file ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- The document draws the **atom map** itself. It is the same map the Atom Map tab shows, minus the parts that need a hand (no zoom, no breadcrumb, no hover readout), with its legend, and with each block's offset and size kept as a `title` so the exported HTML still reveals them on hover. Which labels fit is decided while the document is built, since nothing runs inside it to measure itself later ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- Dropped the indented text listing of the box tree that the old report printed, in the document and in the Markdown export alike. It only ever restated what the map draws, and on a fragmented recording it ran to tens of thousands of lines; the map is the whole of what this section says now ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- Added **Download HTML**: the whole document as one self-contained file with its stylesheet inlined and no external asset, script or font, so it opens the same from a downloads folder, an email attachment or an air-gapped machine ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- The panel now shows that exact file, rendered in an iframe from the same string the download writes and the print dialog prints, so there is no preview that can disagree with what you save. **Save as PDF** prints the document itself instead of the page, which replaces the old trick of hiding every other element behind `@media print` β pressing Ctrl+P anywhere in the app now prints the page you are looking at rather than a blank sheet ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- Dropped the Video Track section's **Bitrate** field from the document, now that the bitrate section below it carries the average alongside the plot, matching the split the Inspect tab already made ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- The Markdown export keeps up: explainers arrive as paragraphs and bullets rather than one run-on line, and a chart it cannot carry is named rather than dropped. Its `?tab=` value is now `analysis` ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- Renamed the **Report** tab to **Full Analysis** and moved it out of the tab row into a button beside the tabs ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- **Full Analysis** now produces a document: a title block, a linked table of contents, and every section in reading order with the β explainers written out inline ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- The document carries the container explainer, bitrate plot, atom map, GOP histogram, seeking scatter and reencode result, charts included as inline SVG ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- Added **Download HTML**, one self-contained file with no external assets, and **Save as PDF**, which prints the document rather than the page ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- Dropped the indented text listing of the box tree, and the document's Video Track bitrate field now that the bitrate section carries it ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- The Markdown export renders explainers as paragraphs and bullets, names a chart it cannot carry, and uses `?tab=analysis` ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
#### π Internal
-- Moved the explainer copy shared by the tabs and the document into `src/lib/explainers.ts`, so the page and the exported document cannot explain the same number two different ways, and extracted the GOP histogram and the seeking scatter plot from the GOP & Seeking tab so the document plots the same run rather than only tabulating it ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- Replaced the report's three section shapes with sections built from a list of blocks (`kv`, `prose`, `badge`, `code`, `table`, `figure`), so adding content to the document is a matter of listing blocks rather than teaching a renderer a new layout. Rendering lives in `src/lib/analysisDoc.ts`, state-gathering in `src/ui/analysisTab.ts` ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
-- An in-browser reencode now records its result in shared state, which the previously unused `reencodeResult` field was always meant to hold ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- Moved the explainer copy shared by the tabs and the document into `src/lib/explainers.ts`, and extracted the GOP histogram and seeking scatter so the document plots the same run ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- Rebuilt the document's sections from a list of blocks (`kv`, `prose`, `badge`, `code`, `table`, `figure`), rendered in `src/lib/analysisDoc.ts` ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
+- An in-browser reencode now records its result in shared state ([#17](https://github.com/brain-bbqs/encoding-helper/pull/17))
## 0.2.9
#### π Enhancement
-- Added a **Video Bitrate Over Time** card to the Inspect tab, plotting the video track's rate one window of playback at a time (roughly one per second, bounded so a 10-second clip and a 10-hour recording both draw a readable number of steps) against the track average. The Inspect tab could only say what the bitrate averaged to, which is the one thing a variable-bitrate encode is guaranteed not to be doing at any given moment; the plot shows where the bits actually went. It is measured straight from the container's sample table, so no decoding is involved, and hovering a window gives its rate and frame count ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
-- The plot is replaced by an explanation for a file whose rate really was held constant, since it would only ever draw a flat line. A `btrt` box declaring the same number as both the track's average and its maximum is not enough on its own: muxers routinely write the computed average into both fields whatever the encoder was doing (the bundled `mice.mp4` is a CRF encode that does exactly this), so the frame sizes have to agree before the plot is suppressed. Where the declaration and the frames disagree, the card plots the measurement and says why the declaration cannot be taken at face value ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
-- Added peak, quietest and peak Γ· average readouts beside the plot, the last being how much bandwidth headroom playback needs beyond the average, and cross-referenced the new card from the Overview's **Overall Bitrate** explainer ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
-- Dropped the **Bitrate** field from the Inspect tab's Video Track card, since the new card's **Average** is the same number measured the same way; its β explainer moved across with it. A file with too few frames to divide into windows still gets the average there, with a note in place of the plot. The Report tab continues to list the video track's bitrate, having no plot to carry it ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
+- Added a **Video Bitrate Over Time** card to the Inspect tab, plotting the video track's rate one window of playback at a time, straight from the sample table ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
+- A file whose rate really is constant gets an explanation instead of a flat line, decided by the frame sizes rather than the `btrt` box alone ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
+- Added peak, quietest and peak Γ· average readouts beside the plot ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
+- Dropped the Inspect tab's Video Track **Bitrate** field, since the new card's **Average** is the same number ([#16](https://github.com/brain-bbqs/encoding-helper/pull/16))
#### π Internal
@@ -37,93 +48,88 @@
#### π Enhancement
-- Added β and + zoom buttons to the Compare Quality panes, so zooming no longer requires a scroll wheel or a trackpad. They step the zoom by 1.5Γ around the middle of the pane, both panes staying in sync as with the wheel, and each disables itself at the end of the zoom range the wheel already clamped to ([#15](https://github.com/brain-bbqs/encoding-helper/pull/15))
-- Added a Play button, so the test segment can be watched straight through instead of only scrubbed frame by frame. Both panes are decoded per frame, so playback is paced off the wall clock and drops frames when decoding cannot keep up rather than drifting into slow motion; the button doubles as Pause, dragging the slider mid-playback moves the playhead, and playing from the end starts the segment over ([#15](https://github.com/brain-bbqs/encoding-helper/pull/15))
+- Added β and + zoom buttons to the Compare Quality panes, so zooming no longer requires a scroll wheel or trackpad ([#15](https://github.com/brain-bbqs/encoding-helper/pull/15))
+- Added a Play button, so the test segment can be watched straight through instead of only scrubbed frame by frame ([#15](https://github.com/brain-bbqs/encoding-helper/pull/15))
- Dropped the paragraph of hover/scroll/scrub instructions under the panes ([#15](https://github.com/brain-bbqs/encoding-helper/pull/15))
## 0.2.7
#### π Enhancement
-- Replaced the Atom Map's indented tree with a horizontal map: left to right across the file, each row down one level of nesting. The tree grew one row per box, which for a fragmented recording (a `moof`+`mdat` pair per fragment) meant thousands of rows; the map is a handful of rows tall whether the video runs ten seconds or ten hours ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
-- Sized each box by how many boxes its subtree holds rather than by its bytes, siblings splitting their parent's width between them. That is the split that makes the narrowest box as wide as it can be, so every box in the file is drawn and labelled at once instead of a `moov` under 1% of the file being reduced to an unreadable sliver ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
-- Made the map zoomable: clicking any box narrows the view to it and a breadcrumb walks back out. A file with more boxes than the panel has room for never drops any, only merges neighbours into a block saying how many it stands for, and a merged run is cut as soon as it is wide enough to draw β so a 20,000-fragment file striates into blocks whose density is the fragmentation, rather than becoming one anonymous bar ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
-- Colored the map by which top-level box each box belongs to (`moov`, `mdat`, `moof`, or everything else), with a legend and a readout naming the box under the cursor and giving its offset and size; `moof`, `mfra`, `free` and `skip` are named there too, where the tree had labelled only `ftyp`, `moov` and `mdat`. The palette was checked for colorblind separation and contrast against both themes' card surface ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
-- Every block is a button carrying its own offset and size, so the numbers the tree spelled out are reachable by keyboard and screen reader as well as by hovering. The Report tab still writes the whole tree out as indented text ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
+- Replaced the Atom Map's indented tree with a horizontal map, a handful of rows tall whether the video runs ten seconds or ten hours ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
+- Sized each box by how many boxes its subtree holds, so every box is drawn and labelled rather than reduced to a sliver ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
+- Made the map zoomable, with a breadcrumb back out and neighbours merged into counted blocks when a file holds more boxes than fit ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
+- Colored the map by top-level box, with a legend and a readout giving the offset and size of the box under the cursor ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
+- Made every block a button carrying its own offset and size, so those numbers are reachable by keyboard and screen reader ([#14](https://github.com/brain-bbqs/encoding-helper/pull/14))
## 0.2.6
#### π Enhancement
-- Added the BBQS corner watermark from [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader), circle-cropped and linking to [brain-bbqs.org](https://brain-bbqs.org), fixed to the top-left of the page ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
-- Added the CON watermark to the bottom-right of the footer bar, linking to [centerforopenneuroscience.org](https://centerforopenneuroscience.org), again matching bbqs-uploader ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
-- Added the Talmo Lab logo to the left of CON, linking to [talmolab.org](https://talmolab.org/), with the lab's name set beneath it since the flask carries no wordmark of its own the way the CON artwork does. The lab draws that flask with a near-black outline that vanishes against the dark theme, so each theme loads the stroke variant it can see ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
-- Below 1420px of viewport width there is no longer room for the watermarks to frame the page without overlapping content, so the BBQS mark is dropped and the footer bar flows into the document instead of staying fixed ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
-- Matched the bottom-left footer links to [brain-bbqs/clip-extractor](https://github.com/brain-bbqs/clip-extractor) and [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader): they were set at 13px against those apps' 0.92rem, which read visibly smaller. Their lengths are now taken verbatim, so the three footers are pixel-identical ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
-- Adopted those apps' indigo accent, `#4f46e5` light and `#818cf8` dark, in place of the previous `#2952cc`/`#6c9fff` blue. The footer links inherit the accent, so matching them meant matching it everywhere; buttons, tabs, links and the print stylesheet shift with it ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
+- Added the BBQS, CON and Talmo Lab watermarks framing the page, each linking to its own site ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
+- Below 1420px of viewport width the BBQS mark is dropped and the footer bar flows into the document, where framing the page would overlap content ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
+- Matched the footer links to [clip-extractor](https://github.com/brain-bbqs/clip-extractor) and [bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader), taking their lengths verbatim ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
+- Adopted those apps' indigo accent, `#4f46e5` light and `#818cf8` dark, in place of the previous blue ([#13](https://github.com/brain-bbqs/encoding-helper/pull/13))
## 0.2.5
#### π Enhancement
-- Renamed the Inspect tab's "Format" field to "Container" and gave it an β explainer covering the container-vs-codec distinction plus, per container (MP4, QuickTime, Matroska, WebM, Ogg, MP3, WAVE, FLAC, ADTS, MPEG-TS, HLS), which video and audio codecs it can carry and where it plays ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Added β explainers to the bitrate fields, including why the Overview's is labelled "overall" (whole file, every track plus container overhead, versus the per-track figures below it) ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Explained the Metadata Tags card: tag names are now shown as readable labels backed by a knowledge base of MP4/QuickTime atoms, ID3v2 frames, Vorbis comments and RIFF INFO chunks, each with an β giving the raw name and what it means. This answers what `Β©too` is (the encoding tool; the leading `Β©` is byte `0xA9`, QuickTime's text-atom marker, not a copyright statement), and recognizable encoder signatures such as `Lavf60.16.100` are decoded in place ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Gave every tab a shareable URL (`?tab=β¦`), restored on load and navigable with browser back/forward; a video loaded from a remote URL is also recorded (`?src=β¦`) and re-opened automatically, so a link carries both the file and the tab ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Added a plain-language preamble to the CLI Command Builder describing what reencoding, transcoding and remuxing are and how they differ ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Moved the GOP explainer directly under the "GOP / Keyframe Structure" heading so it reads before the numbers, matching the Atom Map tab ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Renamed the "Encode Test" tab to "Compare Quality" ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Retitled the Inspect tab's first card to "Video Container Overview" with the container explainer as a description under the heading (rather than an β popover) and the field itself relabelled "Type" ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Broke the I/P/B-frame definitions in the GOP explainer out into a bulleted list, and moved the sleap-io note to its own paragraph linking to the [`sio reencode` CLI reference](https://io.sleap.ai/latest/cli/#sio-reencode) ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Retitled "CLI Command Builder" to "FFmpeg Command Builder", linked to [ffmpeg's install page](https://ffmpeg.org/download.html), and explained why running ffmpeg natively is preferable to the in-browser engines for real work ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Moved the in-browser reencode engines out of the Reencode & CLI tab into their own "Reencode In-Browser" tab, to the right of Compare Quality. Both engines already processed the whole video and saved it to a file of your choosing; the new tab says so up front ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Renamed the Inspect tab's "Format" field to "Container", with an β covering the container-vs-codec distinction and what each container can carry ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Added β explainers to the bitrate fields, including why the Overview's is labelled "overall" ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Metadata tag names are now readable labels, each with an β giving the raw name and its meaning, and encoder signatures are decoded in place ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Gave every tab a shareable URL (`?tab=β¦`), and a video loaded from a remote URL is recorded (`?src=β¦`) and re-opened automatically ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Retitled "CLI Command Builder" to **FFmpeg Command Builder** and gave it a plain-language preamble on reencoding, transcoding and remuxing ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Renamed the "Encode Test" tab to **Compare Quality**, and moved the in-browser engines into their own **Reencode In-Browser** tab ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Retitled the Inspect tab's first card to "Video Container Overview", with the container explainer under the heading and the field relabelled "Type" ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Reflowed the GOP explainer: it reads before the numbers, the I/P/B definitions are a list, and the sleap-io note has its own paragraph ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
#### π Bug Fix
-- Styled hyperlinks with the theme accent color; they were falling back to the browser default `#0000EE`, which was nearly unreadable in dark mode (the sleap-io link on the Reencode & CLI card) ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
-- Fixed the two reencode engine descriptions rendering their markup as literal text, so "No CRF control — WebCodecsβ¦" showed the raw entity ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Styled hyperlinks with the theme accent; they were falling back to the browser default `#0000EE`, nearly unreadable in dark mode ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
+- Fixed the two reencode engine descriptions rendering their markup as literal text ([#12](https://github.com/brain-bbqs/encoding-helper/pull/12))
## 0.2.4
#### π Enhancement
-- Mirrored [brain-bbqs/clip-extractor](https://github.com/brain-bbqs/clip-extractor)'s header banner (logo pinned left, title centered on the page) and added its fixed bottom-left footer links, "π Report a bug" and "π‘ Request a feature", which open the matching issue form ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
-- Added an app version stamp to the footer that links to the source repository, styled to match [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader)'s; it uses the `__APP_VERSION__` build-time define, which was already wired up but unused ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
-- Added a light/dark theme with a sun/moon toggle in the header, mirroring [brain-bbqs/clip-extractor](https://github.com/brain-bbqs/clip-extractor) and [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader): the OS preference is the default, the toggle stores an explicit override in `localStorage`, and an inline script applies it before first paint. The whole stylesheet moved from hardcoded colors to CSS custom properties; the dark values are the app's previous palette, so dark mode is unchanged ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
-- Reworded the subtitle to "Explore video file layouts · Learn more about codec parameterizations · Compare reencoding strategies", and removed the page footer credits (the `vibes`/companion-tool links and the ffmpeg.wasm GPL note) along with the drop zone's "nothing is uploaded" hint ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
+- Mirrored [clip-extractor](https://github.com/brain-bbqs/clip-extractor)'s header banner, and added its fixed "π Report a bug" and "π‘ Request a feature" footer links ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
+- Added an app version stamp to the footer, linking to the source repository ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
+- Added a light/dark theme with a header toggle: the OS preference by default, an explicit override stored in `localStorage` ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
+- Reworded the subtitle, and removed the page footer credits and the drop zone's "nothing is uploaded" hint ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
#### π Internal
-- Added GitHub issue forms (`.github/ISSUE_TEMPLATE/`) for bug reports and feature requests, plus a `config.yml` pointing general questions at EMBER and the BBQS helpdesk, copied from [brain-bbqs/clip-extractor](https://github.com/brain-bbqs/clip-extractor) ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
+- Added GitHub issue forms for bug reports and feature requests, plus a `config.yml` pointing general questions at EMBER and the BBQS helpdesk ([#11](https://github.com/brain-bbqs/encoding-helper/pull/11))
## 0.2.3
#### π Enhancement
-- Added Google Analytics with a GDPR consent banner: tracking is only loaded after the user explicitly accepts, modeled on [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader) ([#10](https://github.com/brain-bbqs/encoding-helper/pull/10))
+- Added Google Analytics behind a GDPR consent banner, loaded only once the user accepts ([#10](https://github.com/brain-bbqs/encoding-helper/pull/10))
## 0.2.2
#### π Enhancement
-- Added logo/favicon assets (`src/assets/`) and wired them into the app: browser favicon (SVG + PNG fallbacks + `favicon.ico`), a logo next to the app title, and a logo in the README ([#8](https://github.com/brain-bbqs/encoding-helper/pull/8))
+- Added logo and favicon assets, wired into the browser tab, the app title and the README ([#8](https://github.com/brain-bbqs/encoding-helper/pull/8))
## 0.2.0
#### π Internal
-- Added Storybook (`stories/`) and Chromatic visual regression testing (both a Storybook build and a dedicated Playwright suite under `tests/chromatic/`), plus a Playwright integration test suite (`tests/integration/`), modeled on [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader) ([#5](https://github.com/brain-bbqs/encoding-helper/pull/5))
-- Added the `reuse` pre-commit hook, plus `REUSE.toml`, `LICENSES/MIT.txt`, and a root `LICENSE` file, to bring the repository into REUSE license compliance, modeled on [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader) ([#6](https://github.com/brain-bbqs/encoding-helper/pull/6))
+- Added Storybook and Chromatic visual regression testing, plus a Playwright integration suite ([#5](https://github.com/brain-bbqs/encoding-helper/pull/5))
+- Added the `reuse` pre-commit hook, `REUSE.toml`, `LICENSES/MIT.txt` and a root `LICENSE`, for REUSE compliance ([#6](https://github.com/brain-bbqs/encoding-helper/pull/6))
## 0.1.0
#### π Bug Fix
-- Fixed the "exact" ffmpeg.wasm engine failing with "failed to import ffmpeg-core.js": it was fetching the UMD core build, but Vite bundles `@ffmpeg/ffmpeg`'s worker as an ES module, which only accepts the ESM core build ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
+- Fixed the "exact" ffmpeg.wasm engine failing with "failed to import ffmpeg-core.js": it fetched the UMD core build where Vite's ES module worker needs the ESM one ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
#### π Internal
-- Refactored from a single static `index.html` file into a TypeScript/Vite app, with project infrastructure (build config, linting, testing, CI/CD) modeled on [brain-bbqs/bbqs-uploader](https://github.com/brain-bbqs/bbqs-uploader) ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
+- Refactored from a single static `index.html` into a TypeScript/Vite app, with build, lint, test and CI/CD infrastructure ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
- Deployed the app at the custom domain `encoding-helper.brain-bbqs.org` via a `public/CNAME` file ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
-- Fixed `version-check.yml` to skip cleanly on this PR's first run, since `package.json` doesn't exist yet on `main` for it to diff against ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
-- Added `configs/.codespellrc` (was referenced by `.pre-commit-config.yaml` but missing) to skip lockfiles/binary assets and allow-list `reencode`/`ans`, both legitimate (sleap-io's function name and a binary-search variable, respectively) ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
+- Fixed `version-check.yml` to skip cleanly on this PR's first run, before `package.json` existed on `main` ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
+- Added `configs/.codespellrc`, referenced by `.pre-commit-config.yaml` but missing ([#3](https://github.com/brain-bbqs/encoding-helper/pull/3))
diff --git a/CLAUDE.md b/CLAUDE.md
index c99bb86..ef07850 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index 2d3c9e4..ec5050e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 5b6b5c9..9d4e379 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "encoding-helper",
- "version": "0.3.0",
+ "version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "encoding-helper",
- "version": "0.3.0",
+ "version": "0.3.1",
"license": "MIT",
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
diff --git a/package.json b/package.json
index 87244b4..5b49eac 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/lib/explainers.ts b/src/lib/explainers.ts
index 48e180f..2dd5ba1 100644
--- a/src/lib/explainers.ts
+++ b/src/lib/explainers.ts
@@ -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. */
@@ -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 same 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
+ ? `
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.
`
+ : "";
+ return (
+ `${basis} ${difficulty}` +
+ band +
+ `Why it is still only an estimate. 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 moov index and faststart are assumed to scale ` +
+ `with length. For an exact number, encode the whole file in the Reencode & CLI tab.
`
+ );
+}
+
+/** 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 busy one, costing ${escapeHtml(d.toFixed(1))}× 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 calm one, costing ${escapeHtml(d.toFixed(2))}× 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 source 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 ÷ 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.";
diff --git a/src/lib/ffmpegEngine.ts b/src/lib/ffmpegEngine.ts
index 144dd05..2514aa6 100644
--- a/src/lib/ffmpegEngine.ts
+++ b/src/lib/ffmpegEngine.ts
@@ -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;
@@ -43,12 +79,7 @@ export async function ensureFfmpegLoaded(): Promise {
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;
}
@@ -68,15 +99,38 @@ export async function runFfmpegEncode(
outputName: string,
): Promise {
const ffmpeg = await ensureFfmpegLoaded();
- await ffmpeg.writeFile(inputName, inputData);
+ let data: Uint8Array;
try {
+ await ffmpeg.writeFile(inputName, inputData);
await ffmpeg.exec(args);
- const data = await ffmpeg.readFile(outputName);
// ffmpeg.wasm's FileData type is generically `Uint8Array | 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.`
+ );
}
diff --git a/src/lib/sizeEstimate.ts b/src/lib/sizeEstimate.ts
new file mode 100644
index 0000000..ea6d4f8
--- /dev/null
+++ b/src/lib/sizeEstimate.ts
@@ -0,0 +1,225 @@
+// Projecting what a whole file would cost at the settings the Compare Quality tab just tried.
+//
+// Compare Quality encodes a few seconds rather than the whole file, because minutes of waiting is a
+// bad way to answer "does CRF 28 still look fine?" when a snippet answers it. Size is the other half
+// of that question β quality is only ever traded against bytes β and the snippet's own byte count
+// cannot answer it alone: it says nothing about what the same stretch costs in the source, and
+// nothing about what either comes to across the full running time.
+//
+// So the projection is a *ratio* measured on the snippet, applied to the source's real size:
+//
+// projected total = original total Γ (encoded snippet Γ· the same stretch of the source)
+//
+// rather than the snippet's bytes multiplied out by the number of snippets that fit in the file.
+// Both halves of the ratio cover the same seconds of the same content, so a snippet taken from an
+// expensive stretch (a scene cut, fast motion) is divided by an equally expensive original and lands
+// near the ratio a calm stretch would have given, instead of projecting the entire file at the
+// busiest moment's rate. The multiplied-out version has no such correction and is wrong by however
+// much the chosen seconds differ from the file's average, which on a short snippet is a lot.
+//
+// What the ratio cannot correct for is the encoder behaving differently elsewhere in the file: CRF
+// spends bits per content, and a stretch this snippet never saw may compress on quite different
+// terms. That residual is what the band reports, and it is why every number here is an estimate.
+
+import { encodeTest, state } from "./state";
+import type { SampleInfo } from "./types";
+
+/** How the cost of the sampled stretch in the *source* was arrived at. */
+export type SizeEstimateBasis =
+ /** Summed out of the video sample table, so it is this stretch's real bytes, not an average. */
+ | "sample-table"
+ /** No sample table (or none covering the stretch): the file's bytes spread evenly over its time. */
+ | "proportional";
+
+export interface SizeEstimateInput {
+ /** The source file's size, container and every track included. */
+ originalTotalBytes: number;
+ /** The source's full playback duration, in seconds. */
+ totalSeconds: number;
+ /** Where the encoded stretch starts in the source, in seconds. */
+ segmentStartSeconds: number;
+ /** How many seconds of playback the encoded stretch actually covers. */
+ segmentSeconds: number;
+ /** Bytes ffmpeg produced for that stretch. */
+ encodedSegmentBytes: number;
+ /** The video track's sample table, when the container was one we could parse. */
+ samples?: SampleInfo[] | null;
+}
+
+export interface SizeEstimate {
+ basis: SizeEstimateBasis;
+ originalTotalBytes: number;
+ totalSeconds: number;
+ segmentSeconds: number;
+ /** What the source spends on the sampled stretch, on the same terms as the encoded stretch. */
+ originalSegmentBytes: number;
+ encodedSegmentBytes: number;
+ /** Encoded Γ· original over the stretch. Below 1 the encode shrank it; above 1 it grew. */
+ ratio: number;
+ /** 1 β ratio: positive when the encode saves bytes, negative when it adds them. */
+ savedFraction: number;
+ segmentSavedBytes: number;
+ projectedTotalBytes: number;
+ projectedSavedBytes: number;
+ /** A rough band around the projection, or null when the file gives nothing to derive one from. */
+ projectedRange: { low: number; high: number } | null;
+ /** The share of the file's playback the snippet covers, 0β1. */
+ sampledFraction: number;
+ /**
+ * The stretch's video bitrate Γ· the track's average: above 1 it is busier than the file at large,
+ * below 1 calmer. Null without a sample table. The projection already corrects for this; it is
+ * reported so the reader can see how representative their pick was.
+ */
+ windowDifficulty: number | null;
+}
+
+/** Below this many frames in the window, its summed bytes are noise rather than a local measurement. */
+const MIN_WINDOW_SAMPLES = 2;
+
+/** Narrower than this and a band would claim a precision a snippet cannot have. */
+const MIN_BAND = 0.03;
+
+/** Wider than this and the band spans everything, which tells the reader nothing they didn't know. */
+const MAX_BAND = 0.6;
+
+/** Fewer windows than this in the file and their spread is not a distribution worth measuring. */
+const MIN_SPREAD_WINDOWS = 3;
+
+/** Sums the bytes of every frame presented within [startSec, startSec + lengthSec). */
+function windowBytes(samples: SampleInfo[], startSec: number, lengthSec: number): { bytes: number; count: number } {
+ const end = startSec + lengthSec;
+ let bytes = 0;
+ let count = 0;
+ for (const s of samples) {
+ if (s.ctsSec >= startSec && s.ctsSec < end) {
+ bytes += s.size;
+ count++;
+ }
+ }
+ return { bytes, count };
+}
+
+/**
+ * How much the file's own equal-length windows differ from one another, as a coefficient of
+ * variation (standard deviation Γ· mean) of their video bytes.
+ *
+ * This stands in for the projection's error. It is not the same quantity β the projection already
+ * divides out how busy the sampled window was β but a file whose windows all cost about the same is
+ * one where any window predicts any other, and a file whose windows swing wildly is one where the
+ * encoder's behavior swings with them. Null when the file holds too few windows to say.
+ */
+function windowSpread(samples: SampleInfo[], totalSeconds: number, windowSeconds: number): number | null {
+ const count = Math.floor(totalSeconds / windowSeconds);
+ if (count < MIN_SPREAD_WINDOWS) return null;
+ const bytes = new Array(count).fill(0);
+ for (const s of samples) {
+ const i = Math.floor(s.ctsSec / windowSeconds);
+ if (i >= 0 && i < count) bytes[i] += s.size;
+ }
+ const mean = bytes.reduce((a, b) => a + b, 0) / count;
+ if (!(mean > 0)) return null;
+ const variance = bytes.reduce((a, b) => a + (b - mean) * (b - mean), 0) / count;
+ return Math.sqrt(variance) / mean;
+}
+
+/**
+ * Projects the full re-encoded file size from one encoded snippet. Null when the inputs cannot
+ * support a projection at all (no duration, no source size, an empty encode).
+ */
+export function estimateSizeSavings(input: SizeEstimateInput): SizeEstimate | null {
+ const { originalTotalBytes, totalSeconds, segmentSeconds, segmentStartSeconds, encodedSegmentBytes } = input;
+ if (!(originalTotalBytes > 0) || !(totalSeconds > 0) || !(segmentSeconds > 0) || !(encodedSegmentBytes > 0)) {
+ return null;
+ }
+
+ const sampledFraction = Math.min(1, segmentSeconds / totalSeconds);
+ const samples = input.samples ?? [];
+ const totalVideoBytes = samples.reduce((sum, s) => sum + s.size, 0);
+ const window = windowBytes(samples, segmentStartSeconds, segmentSeconds);
+
+ // Everything that is not video sample data β the audio track, the container's headers and sample
+ // index β has no per-frame table to sum, so it is spread evenly over the running time. The
+ // encoded snippet carries its own share of both (ffmpeg copies or drops the audio and writes a
+ // fresh container either way), so leaving them out of the original side would compare a snippet
+ // with audio against a stretch without it.
+ const nonVideoBytes = Math.max(0, originalTotalBytes - totalVideoBytes);
+ const useSampleTable = totalVideoBytes > 0 && window.count >= MIN_WINDOW_SAMPLES && window.bytes > 0;
+ const basis: SizeEstimateBasis = useSampleTable ? "sample-table" : "proportional";
+ const originalSegmentBytes = useSampleTable
+ ? window.bytes + nonVideoBytes * sampledFraction
+ : originalTotalBytes * sampledFraction;
+ if (!(originalSegmentBytes > 0)) return null;
+
+ const ratio = encodedSegmentBytes / originalSegmentBytes;
+ const projectedTotalBytes = originalTotalBytes * ratio;
+
+ const spread = useSampleTable ? windowSpread(samples, totalSeconds, segmentSeconds) : null;
+ // Sampling more of the file leaves less of it to be wrong about, and sampling all of it leaves
+ // nothing: at that point the "projection" is the measurement.
+ const band =
+ spread == null
+ ? null
+ : Math.min(MAX_BAND, Math.max(MIN_BAND, spread * Math.sqrt(Math.max(0, 1 - sampledFraction))));
+ const projectedRange =
+ band == null || sampledFraction >= 1
+ ? null
+ : { low: Math.max(0, projectedTotalBytes * (1 - band)), high: projectedTotalBytes * (1 + band) };
+
+ const windowDifficulty =
+ useSampleTable && totalVideoBytes > 0 ? window.bytes / segmentSeconds / (totalVideoBytes / totalSeconds) : null;
+
+ return {
+ basis,
+ originalTotalBytes,
+ totalSeconds,
+ segmentSeconds,
+ originalSegmentBytes,
+ encodedSegmentBytes,
+ ratio,
+ savedFraction: 1 - ratio,
+ segmentSavedBytes: originalSegmentBytes - encodedSegmentBytes,
+ projectedTotalBytes,
+ projectedSavedBytes: originalTotalBytes - projectedTotalBytes,
+ projectedRange,
+ sampledFraction,
+ windowDifficulty,
+ };
+}
+
+/** The estimate for the comparison currently loaded in the Compare Quality tab, or null before one runs. */
+export function currentSizeEstimate(): SizeEstimate | null {
+ if (encodeTest.encodedSize == null || !state.source) return null;
+ // The measured output duration, not the requested one: a trim can land a frame either side of it,
+ // and the ratio is only fair if both halves cover the same seconds.
+ const segmentSeconds = encodeTest.segDuration > 0 ? encodeTest.segDuration : encodeTest.duration;
+ return estimateSizeSavings({
+ originalTotalBytes: state.source.size,
+ totalSeconds: state.duration ?? 0,
+ segmentStartSeconds: encodeTest.startTime,
+ segmentSeconds,
+ encodedSegmentBytes: encodeTest.encodedSize,
+ samples: state.samples,
+ });
+}
+
+/** A percentage at a readable precision: tighter below 10%, where a whole point is a big relative move. */
+export function fmtPct(fraction: number): string {
+ const pct = fraction * 100;
+ return (Math.abs(pct) < 10 ? pct.toFixed(1) : pct.toFixed(0)) + "%";
+}
+
+/** Under this the two sizes are the same number as far as anyone reading it is concerned. */
+const NEGLIGIBLE = 0.005;
+
+/** e.g. "62% smaller", "8.4% larger", "about the same size". */
+export function describeSavings(estimate: SizeEstimate): string {
+ const saved = estimate.savedFraction;
+ if (Math.abs(saved) < NEGLIGIBLE) return "about the same size";
+ return saved > 0 ? `${fmtPct(saved)} smaller` : `${fmtPct(-saved)} larger`;
+}
+
+/** The change as a signed percentage, e.g. "-62%" or "+8.4%", for a numeric readout. */
+export function fmtSignedChange(estimate: SizeEstimate): string {
+ const change = estimate.ratio - 1;
+ return (change >= 0 ? "+" : "-") + fmtPct(Math.abs(change));
+}
diff --git a/src/style.css b/src/style.css
index a284683..9375f28 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1148,6 +1148,91 @@ table.data th {
line-height: 1;
}
+/* Compare Quality: the estimated data savings strip that sits between the run summary and the
+ panes. Two bars on one shared scale, so the projected file is read against the original at a
+ glance rather than by subtracting two numbers. */
+.savings {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 24px;
+ margin: 12px 0;
+ padding: 12px 14px;
+ background: var(--sunken);
+ border: 1px solid var(--border-soft);
+ border-radius: 8px;
+}
+.savings-figure {
+ min-width: 190px;
+}
+.savings-headline {
+ font-size: 22px;
+ font-weight: 700;
+ line-height: 1.2;
+ color: var(--accent);
+}
+/* Settings that grow the file are a real outcome of the tab, not an error, but they should not be
+ reported in the same color as a saving. */
+.savings-headline.grew {
+ color: var(--warn);
+}
+.savings-sub {
+ margin-top: 3px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.savings-bars {
+ flex: 1;
+ min-width: 240px;
+ display: grid;
+ gap: 6px;
+}
+.savings-row {
+ display: grid;
+ grid-template-columns: 88px 1fr 80px;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+}
+.savings-row .savings-label {
+ color: var(--muted);
+}
+.savings-row .savings-value {
+ text-align: right;
+ font-weight: 600;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+.savings-track {
+ position: relative;
+ height: 12px;
+ border-radius: 6px;
+ background: var(--btn-off-bg);
+ overflow: hidden;
+}
+.savings-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ border-radius: 6px;
+ background: var(--muted-2);
+}
+.savings-fill.projected {
+ background: var(--accent);
+}
+/* The band the projection could plausibly land in, drawn over its own bar: what shows is the part
+ reaching past the projected fill, which is the point β the estimate has a width. */
+.savings-band {
+ position: absolute;
+ top: 0;
+ height: 100%;
+ box-sizing: border-box;
+ background: color-mix(in srgb, var(--accent) 28%, transparent);
+ border-left: 1px solid var(--accent-bright);
+ border-right: 1px solid var(--accent-bright);
+}
+
/* Fixed bottom-left links, mirroring brain-bbqs/clip-extractor and brain-bbqs/bbqs-uploader. The
rem lengths through this block are lifted verbatim from those two apps rather than converted to
the px this stylesheet otherwise uses, so the three footers stay pixel-identical and a future
@@ -1315,6 +1400,11 @@ table.data th {
padding: 7px 12px;
font-size: 12px;
}
+ /* The bar rows keep all three columns on a phone; the labels give up the width. */
+ .savings-row {
+ grid-template-columns: 68px 1fr 70px;
+ gap: 6px;
+ }
}
/* body caps out at a 940px-wide content box (980px max-width minus its own 20px side padding);
diff --git a/src/ui/analysisTab.ts b/src/ui/analysisTab.ts
index ade081d..b16a1d5 100644
--- a/src/ui/analysisTab.ts
+++ b/src/ui/analysisTab.ts
@@ -30,6 +30,8 @@ import {
OVERALL_BITRATE_INFO,
PEAK_RATIO_INFO,
SEEK_TEST_INTRO,
+ SIZE_SAVINGS_INTRO,
+ sizeEstimateTeach,
TOO_FEW_FRAMES_NOTE,
VIDEO_AVERAGE_INFO,
} from "../lib/explainers";
@@ -37,6 +39,7 @@ import { fmtBits, fmtBytes, fmtDur, fmtMs, fmtRate } from "../lib/format";
import { describeMetadataTag } from "../lib/metadataTagKb";
import { declaresConstantBitrate } from "../lib/mp4boxParser";
import { downloadBlob } from "../lib/save";
+import { currentSizeEstimate, describeSavings, fmtSignedChange } from "../lib/sizeEstimate";
import { cli, currentVideoInfo, encodeTest, state } from "../lib/state";
import type { AnalysisBlock, AnalysisSection, TrackInfo } from "../lib/types";
import { renderStaticAtomMap } from "./atomsTab";
@@ -344,32 +347,41 @@ function cliCommandSection(): AnalysisSection | null {
function compareSection(): AnalysisSection | null {
if (!encodeTest.originalSink || !encodeTest.encodedSink) return null;
- return {
- title: "Compare Quality (A/B) Result",
- blocks: [
- {
- kind: "kv",
- items: [
- [
- "Segment",
- `${encodeTest.startTime.toFixed(1)}sβ${(encodeTest.startTime + encodeTest.duration).toFixed(1)}s`,
- ],
- [
- "Quality",
- cli.quality === "custom" ? `Custom (CRF ${cli.crf})` : `${cli.quality} (CRF ${CRF_MAP[cli.quality]})`,
- ],
- ["Preset", cli.preset],
- ["Encoded Segment Size", fmtBytes(encodeTest.encodedSize)],
- ],
- },
- {
- kind: "prose",
- html:
- "Only the segment above was encoded, so its size is not the whole file's β it is what that stretch of " +
- "video costs at these settings, which is what makes two settings comparable without encoding twice.",
- },
- ],
- };
+ const items: [string, string | number][] = [
+ ["Segment", `${encodeTest.startTime.toFixed(1)}sβ${(encodeTest.startTime + encodeTest.duration).toFixed(1)}s`],
+ ["Quality", cli.quality === "custom" ? `Custom (CRF ${cli.crf})` : `${cli.quality} (CRF ${CRF_MAP[cli.quality]})`],
+ ["Preset", cli.preset],
+ ["Encoded Segment Size", fmtBytes(encodeTest.encodedSize)],
+ ];
+ const blocks: AnalysisBlock[] = [
+ {
+ kind: "prose",
+ html:
+ "Only the segment above was encoded, so its size is not the whole file's β it is what that stretch of " +
+ "video costs at these settings, which is what makes two settings comparable without encoding twice.",
+ },
+ ];
+
+ const est = currentSizeEstimate();
+ if (est) {
+ items.push(
+ ["Original Segment Size", fmtBytes(est.originalSegmentBytes)],
+ ["Segment Change", fmtSignedChange(est)],
+ ["Original File Size", fmtBytes(est.originalTotalBytes)],
+ ["Projected Full File", fmtBytes(est.projectedTotalBytes)],
+ );
+ if (est.projectedRange) {
+ items.push(["Projected Range", `${fmtBytes(est.projectedRange.low)} β ${fmtBytes(est.projectedRange.high)}`]);
+ }
+ items.push([
+ "Projected Saving",
+ `${describeSavings(est)} (β ${fmtBytes(Math.abs(est.projectedSavedBytes))} ` +
+ `${est.savedFraction < 0 ? "added" : "saved"})`,
+ ]);
+ blocks.push({ kind: "prose", html: SIZE_SAVINGS_INTRO }, { kind: "prose", html: sizeEstimateTeach(est) });
+ }
+
+ return { title: "Compare Quality (A/B) Result", blocks: [{ kind: "kv", items }, ...blocks] };
}
function reencodeSection(): AnalysisSection | null {
diff --git a/src/ui/compareTab.ts b/src/ui/compareTab.ts
index ed555c2..58a2cd1 100644
--- a/src/ui/compareTab.ts
+++ b/src/ui/compareTab.ts
@@ -8,9 +8,11 @@ import { ensureMediabunny } from "../lib/mediabunny";
import { extOf } from "../lib/save";
import { cli, currentVideoInfo, encodeTest, state } from "../lib/state";
import { fmtBytes } from "../lib/format";
+import { currentSizeEstimate } from "../lib/sizeEstimate";
import type { TrackInfo, ZoomPanState } from "../lib/types";
import { syncQualityControls } from "./cliControls";
import { fieldNumber, fieldSelect, logLine } from "./formControls";
+import { renderSavingsDetail, renderSavingsStrip } from "./savingsPanel";
interface RunUi {
button: HTMLButtonElement;
@@ -212,6 +214,11 @@ function renderCompareResult(resultSec: HTMLDivElement, vt: TrackInfo): void {
);
resultSec.append(g);
+ // The size question is half of what the tab is for, so its headline goes above the panes rather
+ // than below the controls, where the detail and the caveats follow it.
+ const estimate = currentSizeEstimate();
+ if (estimate) resultSec.append(h("h3", null, "Estimated Data Savings"), renderSavingsStrip(estimate));
+
const stage = h("div", "compare-stage");
const origPane = h("div", "compare-pane");
origPane.append(h("span", "pane-label", "Original"));
@@ -265,6 +272,8 @@ function renderCompareResult(resultSec: HTMLDivElement, vt: TrackInfo): void {
controls.append(playBtn, scrub, scrubLabel, zoomBtns);
resultSec.append(controls);
+ if (estimate) resultSec.append(...renderSavingsDetail(estimate));
+
const syncZoomButtons = (scale: number): void => {
zoomOutBtn.disabled = scale <= ZOOM_MIN;
zoomInBtn.disabled = scale >= ZOOM_MAX;
diff --git a/src/ui/savingsPanel.ts b/src/ui/savingsPanel.ts
new file mode 100644
index 0000000..0a13f5d
--- /dev/null
+++ b/src/ui/savingsPanel.ts
@@ -0,0 +1,107 @@
+// The estimated-data-savings readout under Compare Quality: a headline strip that goes above the
+// panes, and the detail block that follows the comparison.
+//
+// Two bars on one shared scale rather than two numbers to subtract, because the question the tab is
+// really being asked ("is this setting worth it?") is a comparison, and a comparison is what a
+// reader takes off a pair of bars at a glance. The projection's range is drawn on the bar it belongs
+// to instead of only being spelled out beside it: an estimate from a few seconds of a long file has
+// a width, and a bar with a hard end would claim it does not.
+
+import { gridItem, h } from "../lib/dom";
+import { ORIGINAL_SEGMENT_INFO, PROJECTED_SIZE_INFO, SAMPLED_WINDOW_INFO } from "../lib/explainers";
+import { fmtBytes, fmtDur } from "../lib/format";
+import { describeSavings, fmtPct, fmtSignedChange, type SizeEstimate } from "../lib/sizeEstimate";
+
+/** Under this the two projections differ by less than the byte figures beside them would show. */
+const NEGLIGIBLE_BYTES = 1024;
+
+/** One bar on the shared scale, optionally carrying the band its value could land anywhere in. */
+export function savingsBar(
+ label: string,
+ bytes: number,
+ maxBytes: number,
+ fillClass: string,
+ band: { low: number; high: number } | null = null,
+): HTMLDivElement {
+ const row = h("div", "savings-row");
+ row.append(h("span", "savings-label", label));
+ const track = h("div", "savings-track");
+ const fill = h("div", "savings-fill " + fillClass);
+ fill.style.width = pct(bytes, maxBytes) + "%";
+ track.append(fill);
+ if (band) {
+ const bandEl = h("div", "savings-band");
+ const left = Number(pct(band.low, maxBytes));
+ const right = Number(pct(band.high, maxBytes));
+ bandEl.style.left = left.toFixed(1) + "%";
+ bandEl.style.width = Math.max(0, right - left).toFixed(1) + "%";
+ bandEl.title = `Could land anywhere from ${fmtBytes(band.low)} to ${fmtBytes(band.high)}`;
+ track.append(bandEl);
+ }
+ row.append(track, h("span", "savings-value", fmtBytes(bytes)));
+ return row;
+}
+
+/** A share of the scale, clamped so a projection larger than the original cannot overrun its track. */
+function pct(bytes: number, maxBytes: number): string {
+ return (maxBytes > 0 ? Math.min(100, Math.max(0, (bytes / maxBytes) * 100)) : 0).toFixed(1);
+}
+
+/** The headline figure and the two-bar comparison, shown above the panes. */
+export function renderSavingsStrip(est: SizeEstimate): HTMLDivElement {
+ const wrap = h("div", "savings");
+ const grew = est.savedFraction < 0;
+ const figure = h("div", "savings-figure");
+ figure.append(h("div", "savings-headline" + (grew ? " grew" : ""), describeSavings(est)));
+ const delta = Math.abs(est.projectedSavedBytes);
+ const across = `Projected across the whole ${fmtDur(est.totalSeconds)}: `;
+ figure.append(
+ h(
+ "div",
+ "savings-sub",
+ delta < NEGLIGIBLE_BYTES
+ ? across + "no meaningful change"
+ : `${across}β ${fmtBytes(delta)} ${grew ? "added" : "saved"}`,
+ ),
+ );
+
+ const bars = h("div", "savings-bars");
+ const maxBytes = Math.max(est.originalTotalBytes, est.projectedTotalBytes, est.projectedRange?.high ?? 0);
+ bars.append(
+ savingsBar("Original file", est.originalTotalBytes, maxBytes, "original"),
+ savingsBar("Projected", est.projectedTotalBytes, maxBytes, "projected", est.projectedRange),
+ );
+ wrap.append(figure, bars);
+ return wrap;
+}
+
+/**
+ * The numbers behind the headline. How the estimate is arrived at, and where it can be wrong, is
+ * left to the β on the figures it bears on rather than spelled out in prose under them; the Full
+ * Analysis document, which nobody can hover, still writes it out.
+ */
+export function renderSavingsDetail(est: SizeEstimate): HTMLElement[] {
+ const g = h("div", "grid");
+ g.append(
+ gridItem("Original Segment Size", fmtBytes(est.originalSegmentBytes), { info: ORIGINAL_SEGMENT_INFO }),
+ gridItem("Segment Change", fmtSignedChange(est)),
+ gridItem("Original File Size", fmtBytes(est.originalTotalBytes)),
+ gridItem("Projected Full File", fmtBytes(est.projectedTotalBytes), { info: PROJECTED_SIZE_INFO }),
+ );
+ if (est.projectedRange) {
+ g.append(
+ gridItem("Projected Range", `${fmtBytes(est.projectedRange.low)} β ${fmtBytes(est.projectedRange.high)}`, {
+ sm: true,
+ }),
+ );
+ }
+ g.append(
+ gridItem(
+ "Sampled",
+ `${fmtDur(est.segmentSeconds)} of ${fmtDur(est.totalSeconds)} (${fmtPct(est.sampledFraction)})`,
+ { info: SAMPLED_WINDOW_INFO, sm: true },
+ ),
+ );
+
+ return [h("h3", null, "Estimate Detail"), g];
+}
diff --git a/stories/Savings.stories.ts b/stories/Savings.stories.ts
new file mode 100644
index 0000000..b1e6c56
--- /dev/null
+++ b/stories/Savings.stories.ts
@@ -0,0 +1,76 @@
+import { estimateSizeSavings, type SizeEstimateInput } from "../src/lib/sizeEstimate";
+import type { SampleInfo } from "../src/lib/types";
+import { renderSavingsDetail, renderSavingsStrip } from "../src/ui/savingsPanel";
+
+/** A 30 fps sample table for `durationSec`, sized by a per-second cost. */
+function samples(durationSec: number, bytesAt: (second: number) => number): SampleInfo[] {
+ return Array.from({ length: Math.round(durationSec * 30) }, (_, i) => {
+ const ctsSec = i / 30;
+ return {
+ offset: 0,
+ size: bytesAt(Math.floor(ctsSec)) / 30,
+ cts: 0,
+ dts: 0,
+ ctsSec,
+ dtsSec: ctsSec,
+ is_sync: false,
+ duration: 1,
+ };
+ });
+}
+
+/** A 3-minute recording that alternates between busy and calm seconds, 3 s of it sampled. */
+const VARIABLE: SizeEstimateInput = {
+ originalTotalBytes: 200_000_000,
+ totalSeconds: 180,
+ segmentStartSeconds: 20,
+ segmentSeconds: 3,
+ encodedSegmentBytes: 1_150_000,
+ samples: samples(180, (s) => (s % 7 === 0 ? 2_000_000 : 900_000)),
+};
+
+/** No sample table (a container we cannot index), so the source's cost is a flat share of the file. */
+const FLAT: SizeEstimateInput = {
+ originalTotalBytes: 200_000_000,
+ totalSeconds: 180,
+ segmentStartSeconds: 20,
+ segmentSeconds: 3,
+ encodedSegmentBytes: 1_150_000,
+};
+
+/** Lossless on an already-compressed source: the settings cost bytes rather than saving them. */
+const GREW: SizeEstimateInput = { ...VARIABLE, encodedSegmentBytes: 5_400_000 };
+
+function renderWith(input: SizeEstimateInput, detail = true): HTMLElement {
+ const panel = document.createElement("div");
+ panel.className = "section";
+ const est = estimateSizeSavings(input);
+ if (!est) return panel;
+ panel.append(renderSavingsStrip(est));
+ if (detail) panel.append(...renderSavingsDetail(est));
+ return panel;
+}
+
+export default {
+ title: "Components/Data Savings",
+};
+
+export const Measured = {
+ name: "Sample table available β projection with a range",
+ render: () => renderWith(VARIABLE),
+};
+
+export const Proportional = {
+ name: "No sample table β flat share of the file, no range",
+ render: () => renderWith(FLAT),
+};
+
+export const Grew = {
+ name: "Settings that grow the file",
+ render: () => renderWith(GREW),
+};
+
+export const StripOnly = {
+ name: "Headline strip, as it sits above the panes",
+ render: () => renderWith(VARIABLE, false),
+};
diff --git a/tests/unit/savingsPanel.test.ts b/tests/unit/savingsPanel.test.ts
new file mode 100644
index 0000000..e065c4b
--- /dev/null
+++ b/tests/unit/savingsPanel.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from "vitest";
+import { estimateSizeSavings, type SizeEstimate } from "../../src/lib/sizeEstimate";
+import { renderSavingsDetail, renderSavingsStrip, savingsBar } from "../../src/ui/savingsPanel";
+
+/** A 1 MB, 100 s file whose sampled 10 s encoded down to `encodedSegmentBytes`. */
+function estimate(encodedSegmentBytes: number): SizeEstimate {
+ return estimateSizeSavings({
+ originalTotalBytes: 1_000_000,
+ totalSeconds: 100,
+ segmentStartSeconds: 0,
+ segmentSeconds: 10,
+ encodedSegmentBytes,
+ })!;
+}
+
+/** The rendered detail block as one element, the way the tab appends it into its section. */
+function detail(est: SizeEstimate): HTMLElement {
+ const wrap = document.createElement("div");
+ wrap.append(...renderSavingsDetail(est));
+ return wrap;
+}
+
+describe("savingsBar", () => {
+ it("sizes the fill by the value's share of the scale", () => {
+ const row = savingsBar("Projected", 250, 1000, "projected");
+ expect(row.querySelector(".savings-fill")!.style.width).toBe("25%");
+ expect(row.querySelector(".savings-label")!.textContent).toBe("Projected");
+ expect(row.querySelector(".savings-value")!.textContent).toBe("250 B");
+ expect(row.querySelector(".savings-band")).toBeNull();
+ });
+
+ it("draws the band from its low to its high", () => {
+ const row = savingsBar("Projected", 250, 1000, "projected", { low: 200, high: 400 });
+ const band = row.querySelector(".savings-band")!;
+ expect(band.style.left).toBe("20%");
+ expect(band.style.width).toBe("20%");
+ expect(band.title).toBe("Could land anywhere from 200 B to 400 B");
+ });
+
+ it("keeps a value past the top of the scale inside its track", () => {
+ const row = savingsBar("Projected", 4000, 1000, "projected", { low: 3000, high: 5000 });
+ expect(row.querySelector(".savings-fill")!.style.width).toBe("100%");
+ const band = row.querySelector(".savings-band")!;
+ expect(band.style.left).toBe("100%");
+ expect(band.style.width).toBe("0%");
+ });
+
+ it("survives a scale of zero rather than dividing by it", () => {
+ const row = savingsBar("Projected", 0, 0, "projected");
+ expect(row.querySelector(".savings-fill")!.style.width).toBe("0%");
+ });
+});
+
+describe("renderSavingsStrip", () => {
+ it("leads with the direction and size of the change", () => {
+ const strip = renderSavingsStrip(estimate(50_000));
+ expect(strip.querySelector(".savings-headline")!.textContent).toBe("50% smaller");
+ expect(strip.querySelector(".savings-headline")!.classList.contains("grew")).toBe(false);
+ expect(strip.querySelector(".savings-sub")!.textContent).toBe(
+ "Projected across the whole 1m 40.0s: β 488.3 KB saved",
+ );
+ });
+
+ it("marks settings that grew the file instead of reporting a saving", () => {
+ const strip = renderSavingsStrip(estimate(150_000));
+ expect(strip.querySelector(".savings-headline")!.textContent).toBe("50% larger");
+ expect(strip.querySelector(".savings-headline")!.classList.contains("grew")).toBe(true);
+ expect(strip.querySelector(".savings-sub")!.textContent).toContain("β 488.3 KB added");
+ });
+
+ it("says so plainly when the projection lands on the same size", () => {
+ const strip = renderSavingsStrip(estimate(100_000));
+ expect(strip.querySelector(".savings-sub")!.textContent).toBe(
+ "Projected across the whole 1m 40.0s: no meaningful change",
+ );
+ });
+
+ it("puts both bars on one scale, topped by the larger of the two", () => {
+ const strip = renderSavingsStrip(estimate(50_000));
+ const fills = strip.querySelectorAll(".savings-fill");
+ expect(fills.length).toBe(2);
+ // The original is the larger here, so it fills its track and the projection reads against it.
+ expect(fills[0].style.width).toBe("100%");
+ expect(fills[1].style.width).toBe("50%");
+ });
+
+ it("scales both bars to the projection when the encode grew the file", () => {
+ const strip = renderSavingsStrip(estimate(200_000));
+ const fills = strip.querySelectorAll(".savings-fill");
+ expect(fills[0].style.width).toBe("50%");
+ expect(fills[1].style.width).toBe("100%");
+ });
+});
+
+describe("renderSavingsDetail", () => {
+ it("lists the numbers behind the headline", () => {
+ const text = detail(estimate(50_000)).textContent ?? "";
+ expect(text).toContain("Original Segment Size");
+ expect(text).toContain("Segment Change");
+ expect(text).toContain("-50%");
+ expect(text).toContain("Original File Size");
+ expect(text).toContain("Projected Full File");
+ expect(text).toContain("976.6 KB");
+ expect(text).toContain("488.3 KB");
+ expect(text).toContain("Sampled");
+ expect(text).toContain("10.000 s of 1m 40.0s (10%)");
+ });
+
+ it("leaves the range out when the file gave nothing to derive one from", () => {
+ const est = estimate(50_000);
+ expect(est.projectedRange).toBeNull();
+ expect(detail(est).textContent).not.toContain("Projected Range");
+ });
+
+ it("shows the range when there is one", () => {
+ const samples = Array.from({ length: 300 }, (_, i) => {
+ const ctsSec = (10 * i) / 300;
+ return {
+ offset: 0,
+ size: ctsSec < 1 ? 2000 : 1000,
+ cts: 0,
+ dts: 0,
+ ctsSec,
+ dtsSec: ctsSec,
+ is_sync: false,
+ duration: 1,
+ };
+ });
+ const est = estimateSizeSavings({
+ originalTotalBytes: 330_000,
+ totalSeconds: 10,
+ segmentStartSeconds: 0,
+ segmentSeconds: 1,
+ encodedSegmentBytes: 30_000,
+ samples,
+ })!;
+ expect(detail(est).textContent).toContain("Projected Range");
+ });
+
+ it("leaves the method to the β rather than writing it out under the numbers", () => {
+ const block = detail(estimate(50_000));
+ expect(block.querySelector(".teach")).toBeNull();
+ expect(block.querySelector("details")).toBeNull();
+ // The explainers are still reachable, on the figures they bear on.
+ expect(block.querySelectorAll(".info-pop").length).toBe(3);
+ });
+});
diff --git a/tests/unit/sizeEstimate.test.ts b/tests/unit/sizeEstimate.test.ts
new file mode 100644
index 0000000..7799408
--- /dev/null
+++ b/tests/unit/sizeEstimate.test.ts
@@ -0,0 +1,214 @@
+import { describe, expect, it } from "vitest";
+import {
+ describeSavings,
+ estimateSizeSavings,
+ fmtPct,
+ fmtSignedChange,
+ type SizeEstimateInput,
+} from "../../src/lib/sizeEstimate";
+import type { SampleInfo } from "../../src/lib/types";
+
+/** One frame, presented at `ctsSec` and `size` bytes long. */
+function sample(ctsSec: number, size: number): SampleInfo {
+ return { offset: 0, size, cts: 0, dts: 0, ctsSec, dtsSec: ctsSec, is_sync: false, duration: 1 };
+}
+
+/** `count` evenly-spaced frames across `durationSec`, sized by `size(ctsSec)`. */
+function evenSamples(count: number, durationSec: number, size: (ctsSec: number) => number): SampleInfo[] {
+ return Array.from({ length: count }, (_, i) => {
+ const ctsSec = (durationSec * i) / count;
+ return sample(ctsSec, size(ctsSec));
+ });
+}
+
+const BASE: SizeEstimateInput = {
+ originalTotalBytes: 1_000_000,
+ totalSeconds: 100,
+ segmentStartSeconds: 0,
+ segmentSeconds: 10,
+ encodedSegmentBytes: 50_000,
+};
+
+describe("estimateSizeSavings", () => {
+ it("returns null when an input cannot support a projection", () => {
+ expect(estimateSizeSavings({ ...BASE, originalTotalBytes: 0 })).toBeNull();
+ expect(estimateSizeSavings({ ...BASE, totalSeconds: 0 })).toBeNull();
+ expect(estimateSizeSavings({ ...BASE, segmentSeconds: 0 })).toBeNull();
+ expect(estimateSizeSavings({ ...BASE, encodedSegmentBytes: 0 })).toBeNull();
+ expect(estimateSizeSavings({ ...BASE, totalSeconds: NaN })).toBeNull();
+ });
+
+ it("spreads the file evenly over its running time when there is no sample table", () => {
+ // 10 s of a 100 s, 1 MB file is 100 KB of source; 50 KB of encode halves it.
+ const est = estimateSizeSavings(BASE);
+ expect(est).not.toBeNull();
+ expect(est!.basis).toBe("proportional");
+ expect(est!.originalSegmentBytes).toBe(100_000);
+ expect(est!.ratio).toBe(0.5);
+ expect(est!.savedFraction).toBe(0.5);
+ expect(est!.segmentSavedBytes).toBe(50_000);
+ expect(est!.projectedTotalBytes).toBe(500_000);
+ expect(est!.projectedSavedBytes).toBe(500_000);
+ expect(est!.sampledFraction).toBe(0.1);
+ expect(est!.windowDifficulty).toBeNull();
+ // Without a sample table there are no windows to measure a spread across.
+ expect(est!.projectedRange).toBeNull();
+ });
+
+ it("reads the sampled stretch's real cost out of the sample table", () => {
+ // 10 s at 30 fps: the first second costs 2000 bytes a frame, the rest 1000.
+ const samples = evenSamples(300, 10, (t) => (t < 1 ? 2000 : 1000));
+ const est = estimateSizeSavings({
+ originalTotalBytes: 330_000,
+ totalSeconds: 10,
+ segmentStartSeconds: 0,
+ segmentSeconds: 1,
+ encodedSegmentBytes: 30_000,
+ samples,
+ });
+ expect(est).not.toBeNull();
+ expect(est!.basis).toBe("sample-table");
+ expect(est!.originalSegmentBytes).toBe(60_000);
+ expect(est!.ratio).toBe(0.5);
+ // Not 30,000 Γ 10 = 300,000: the sampled second is nearly twice as expensive as the file's
+ // average, and dividing by what the source spends on it is what takes that back out.
+ expect(est!.projectedTotalBytes).toBe(165_000);
+ expect(est!.windowDifficulty).toBeCloseTo(60_000 / 33_000, 10);
+ });
+
+ it("gives the sampled stretch its share of the audio and container bytes", () => {
+ // Same video samples as above, inside a file 100,000 bytes larger than its video track.
+ const samples = evenSamples(300, 10, (t) => (t < 1 ? 2000 : 1000));
+ const est = estimateSizeSavings({
+ originalTotalBytes: 430_000,
+ totalSeconds: 10,
+ segmentStartSeconds: 0,
+ segmentSeconds: 1,
+ encodedSegmentBytes: 30_000,
+ samples,
+ });
+ // 60,000 bytes of video for that second, plus a tenth of the 100,000 non-video bytes.
+ expect(est!.originalSegmentBytes).toBe(70_000);
+ expect(est!.basis).toBe("sample-table");
+ });
+
+ it("measures a stretch taken from the middle of the file, not from the start", () => {
+ const samples = evenSamples(300, 10, (t) => (t >= 5 && t < 6 ? 3000 : 1000));
+ const est = estimateSizeSavings({
+ originalTotalBytes: 360_000,
+ totalSeconds: 10,
+ segmentStartSeconds: 5,
+ segmentSeconds: 1,
+ encodedSegmentBytes: 45_000,
+ samples,
+ });
+ expect(est!.originalSegmentBytes).toBe(90_000);
+ expect(est!.ratio).toBe(0.5);
+ expect(est!.windowDifficulty).toBeCloseTo(90_000 / 36_000, 10);
+ });
+
+ it("falls back to the even spread when the sample table does not cover the stretch", () => {
+ const samples = evenSamples(300, 10, () => 1000);
+ const est = estimateSizeSavings({
+ originalTotalBytes: 300_000,
+ totalSeconds: 10,
+ // Past the last frame, so summing the window would give zero bytes rather than a measurement.
+ segmentStartSeconds: 20,
+ segmentSeconds: 1,
+ encodedSegmentBytes: 15_000,
+ samples,
+ });
+ expect(est!.basis).toBe("proportional");
+ expect(est!.originalSegmentBytes).toBe(30_000);
+ expect(est!.windowDifficulty).toBeNull();
+ });
+
+ it("bands the projection by how much the file's own windows differ", () => {
+ const varying = evenSamples(300, 10, (t) => (t < 1 ? 2000 : 1000));
+ const est = estimateSizeSavings({
+ originalTotalBytes: 330_000,
+ totalSeconds: 10,
+ segmentStartSeconds: 0,
+ segmentSeconds: 1,
+ encodedSegmentBytes: 30_000,
+ samples: varying,
+ })!;
+ expect(est.projectedRange).not.toBeNull();
+ expect(est.projectedRange!.low).toBeLessThan(est.projectedTotalBytes);
+ expect(est.projectedRange!.high).toBeGreaterThan(est.projectedTotalBytes);
+
+ // A file whose every window costs the same leaves almost nothing for the band to be wide about.
+ const flat = evenSamples(300, 10, () => 1000);
+ const flatEst = estimateSizeSavings({
+ originalTotalBytes: 300_000,
+ totalSeconds: 10,
+ segmentStartSeconds: 0,
+ segmentSeconds: 1,
+ encodedSegmentBytes: 15_000,
+ samples: flat,
+ })!;
+ const width = (e: typeof est): number => (e.projectedRange!.high - e.projectedRange!.low) / e.projectedTotalBytes;
+ expect(width(flatEst)).toBeLessThan(width(est));
+ });
+
+ it("drops the band once the whole file has been encoded", () => {
+ const samples = evenSamples(300, 10, (t) => (t < 1 ? 2000 : 1000));
+ const est = estimateSizeSavings({
+ originalTotalBytes: 330_000,
+ totalSeconds: 10,
+ segmentStartSeconds: 0,
+ segmentSeconds: 10,
+ encodedSegmentBytes: 165_000,
+ samples,
+ })!;
+ expect(est.sampledFraction).toBe(1);
+ expect(est.projectedRange).toBeNull();
+ expect(est.projectedTotalBytes).toBe(165_000);
+ });
+
+ it("narrows the band as more of the file is sampled", () => {
+ const samples = evenSamples(600, 20, (t) => (Math.floor(t) % 2 === 0 ? 2000 : 1000));
+ const forSegment = (segmentSeconds: number): number => {
+ const est = estimateSizeSavings({
+ originalTotalBytes: 900_000,
+ totalSeconds: 20,
+ segmentStartSeconds: 0,
+ segmentSeconds,
+ encodedSegmentBytes: 10_000 * segmentSeconds,
+ samples,
+ })!;
+ return (est.projectedRange!.high - est.projectedRange!.low) / est.projectedTotalBytes;
+ };
+ expect(forSegment(5)).toBeLessThan(forSegment(1));
+ });
+
+ it("reports settings that grew the file rather than clamping them to a saving", () => {
+ const est = estimateSizeSavings({ ...BASE, encodedSegmentBytes: 120_000 })!;
+ expect(est.ratio).toBe(1.2);
+ expect(est.savedFraction).toBeCloseTo(-0.2, 10);
+ expect(est.projectedTotalBytes).toBe(1_200_000);
+ expect(est.projectedSavedBytes).toBe(-200_000);
+ expect(describeSavings(est)).toBe("20% larger");
+ expect(fmtSignedChange(est)).toBe("+20%");
+ });
+});
+
+describe("savings wording", () => {
+ it("names the direction of the change", () => {
+ const est = estimateSizeSavings(BASE)!;
+ expect(describeSavings(est)).toBe("50% smaller");
+ expect(fmtSignedChange(est)).toBe("-50%");
+ });
+
+ it("calls a change too small to matter what it is", () => {
+ const est = estimateSizeSavings({ ...BASE, encodedSegmentBytes: 100_200 })!;
+ expect(describeSavings(est)).toBe("about the same size");
+ });
+
+ it("keeps a decimal place on percentages small enough for one to matter", () => {
+ expect(fmtPct(0.0625)).toBe("6.3%");
+ expect(fmtPct(0.62)).toBe("62%");
+ expect(fmtPct(0.005)).toBe("0.5%");
+ expect(fmtPct(1)).toBe("100%");
+ });
+});