diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bec57c..3dea738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 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)) + ## 0.2.6 #### ๐Ÿš€ Enhancement diff --git a/README.md b/README.md index 340c8e2..13d860a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Companion to [Video Info Tool](https://vibes.tlab.sh/video-info-tool/) and [Fram ## Features - **Inspect** - rich metadata plus a visual MP4 **atom map** (`ftyp`/`moov`/`mdat`/`moof`, byte offsets & sizes, moov-before-mdat "faststart" detection) and per-frame GOP/I-frame/B-frame structure +- **Atom Map** - the box tree on its side: left to right across the file, one lane per nesting level, so it stays the same height whether the video runs ten seconds or ten hours. Siblings split their parent's width by how many boxes each subtree holds, so every box is drawn and labelled at once; click any box to zoom into it, and on a file with more boxes than fit, neighbours merge into counted blocks rather than being dropped. Hover or tab to a box for its offset and byte count - **Identify the codec** - infers the codec family from the container (H.264/AVC, H.265/HEVC, VP8/VP9, AV1, AAC, Opus, FLAC, MP3, AC-3/E-AC-3, PCM) and decodes its embedded profile/level/tier straight out of the RFC 6381 codec string, with a short explainer on what that codec actually is and why you'd (not) choose it - **Teach** - interactive explanations tied to the loaded file: CRF vs. bitrate, x264 presets, GOP/keyframe interval, I/P/B frames, `yuv420p` chroma subsampling, even-dimension requirements, and the moov-atom/faststart tradeoff - **Measure** - empirical seeking tests (nearest-keyframe distance per timestamp, decode wall-clock, keyframe-interval histogram) with a scatter plot of distance vs. decode time, plus before/after compression stats diff --git a/package-lock.json b/package-lock.json index 8ff8e2e..68259aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "encoding-helper", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "encoding-helper", - "version": "0.2.6", + "version": "0.2.7", "license": "MIT", "dependencies": { "@ffmpeg/ffmpeg": "^0.12.15", diff --git a/package.json b/package.json index 196fd1c..8c1a604 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "encoding-helper", - "version": "0.2.6", + "version": "0.2.7", "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/atomLayout.ts b/src/lib/atomLayout.ts new file mode 100644 index 0000000..c61fc62 --- /dev/null +++ b/src/lib/atomLayout.ts @@ -0,0 +1,257 @@ +// Geometry for the Atom Map's horizontal view. +// +// The indented tree grows one row per atom, so a long recording โ€” especially a fragmented one, +// where a moof+mdat pair repeats per fragment โ€” runs to thousands of rows. This lays the same tree +// out along an axis instead: one lane per nesting depth, each atom drawn across its slice. Height +// is then bounded by how deeply the file nests (a handful of lanes) no matter how long the video +// is, and width is always the width of the panel. +// +// The axis is one slot per atom, not file bytes. Siblings split their parent's slice in proportion +// to how many atoms each subtree holds, which is the split that makes the narrowest atom as wide as +// it can be: every atom gets at least one slot's share of the panel. So the whole file is drawn and +// labelled at once, and what the view says nothing about is size. It is the indented tree, turned +// on its side. +// +// Atoms are contained by their parent and siblings never overlap, so an atom is never wider than +// its parent. That makes visibility monotonic: if an atom is drawn, every one of its ancestors was +// too, and zooming into an ancestor is always the way to reach something too small to see on its +// own. + +import type { BoxNode } from "./types"; + +/** A half-open range on the atom-slot axis. */ +export interface AxisRange { + start: number; + end: number; +} + +/** An atom with the slice of the axis it was given, and its children placed inside that slice. */ +export interface Placement { + box: BoxNode; + from: number; + to: number; + children: Placement[]; +} + +/** One drawn rectangle: either a single atom, or a run of neighbours collapsed into one block. */ +export interface AtomRect { + kind: "box" | "group"; + /** Nesting depth, which is also the lane the rect is drawn in. */ + depth: number; + /** Zoom target, on the atom-slot axis. */ + from: number; + to: number; + /** The bytes this rect covers, which is what the readout reports. */ + byteStart: number; + byteEnd: number; + /** Left edge and width as fractions (0..1) of the visible range, clipped to it. */ + x: number; + w: number; + /** The atom itself on a "box" rect; null on a "group". */ + box: BoxNode | null; + /** Type of the top-level atom this one descends from ("moov", "mdat", โ€ฆ). On a "group" holding a + * mix, it is whichever family covers most of the block โ€” so a fragmented file's collapsed runs + * still read as the sample data they mostly are. Null only when there is nothing to name. */ + family: string | null; + /** Atoms represented: 1 for a box (its children are drawn separately), the whole collapsed count + * โ€” subtrees included โ€” for a group. */ + count: number; +} + +export interface AtomLayout { + rects: AtomRect[]; + /** Number of lanes to draw, i.e. deepest depth reached + 1. */ + laneCount: number; + /** True when MAX_RECTS was reached and the rest of the tree was left undrawn. */ + truncated: boolean; +} + +/** + * Narrower than this fraction of the visible range, an atom is collapsed into a group block. Set so + * that a block at the threshold is still a few real pixels wide at any sensible panel width, since + * anything thinner would be widened by the stylesheet's minimum and start overlapping its neighbour. + * It is also what bounds "every atom is drawn": past roughly 1/MIN_RECT_WIDTH atoms in view there is + * no room left to give each its own block, and zooming is how you get it back. + */ +export const MIN_RECT_WIDTH = 0.005; + +/** + * Up to this many top-level atoms are all drawn, however narrow they are, so the shape of the file + * is never summarised away at the outermost level. A progressive file has only a handful; a + * fragmented one has thousands, well past this, so its runs collapse like any other lane's. + */ +const MAX_UNCOLLAPSED_TOP_LEVEL = 24; + +/** Backstop for pathological files; a real one settles far below this once groups collapse runs. */ +const MAX_RECTS = 4000; + +/** Atoms in a subtree, counting the box itself. */ +export function countAtoms(box: BoxNode): number { + return box.children.reduce((n, c) => n + countAtoms(c), 1); +} + +function place(boxes: BoxNode[], from: number, to: number): Placement[] { + const weights = boxes.map(countAtoms); + const total = weights.reduce((a, b) => a + b, 0); + if (total === 0) return []; + const span = to - from; + let cursor = from; + return boxes.map((box, i) => { + const next = cursor + (span * weights[i]) / total; + const placement: Placement = { box, from: cursor, to: next, children: [] }; + placement.children = place(box.children, cursor, next); + cursor = next; + return placement; + }); +} + +/** Assigns every atom its slice of the axis, the tree as a whole spanning one slot per atom. */ +export function placeAtoms(boxes: BoxNode[]): Placement[] { + return place( + boxes, + 0, + boxes.reduce((n, b) => n + countAtoms(b), 0), + ); +} + +/** The range the placed tree spans. */ +export function placementRange(placements: Placement[]): AxisRange { + if (placements.length === 0) return { start: 0, end: 0 }; + let start = Infinity; + let end = -Infinity; + for (const placement of placements) { + start = Math.min(start, placement.from); + end = Math.max(end, placement.to); + } + return { start, end }; +} + +interface Clipped { + placement: Placement; + from: number; + to: number; +} + +/** Whichever top-level family covers most of a collapsed run. */ +function dominantFamily(run: Clipped[]): string | null { + const widths = new Map(); + for (const c of run) { + const type = c.placement.box.type; + widths.set(type, (widths.get(type) ?? 0) + (c.to - c.from)); + } + let winner: string | null = null; + let best = -1; + // Insertion order is axis order, and the comparison is strict, so ties go to the leftmost. + for (const [type, width] of widths) { + if (width > best) { + best = width; + winner = type; + } + } + return winner; +} + +/** The parts of `siblings` that fall inside `view`, in axis order. */ +function clipToView(siblings: Placement[], view: AxisRange): Clipped[] { + return siblings + .map((placement) => ({ + placement, + from: Math.max(placement.from, view.start), + to: Math.min(placement.to, view.end), + })) + .filter((c) => c.to > c.from) + .sort((a, b) => a.from - b.from); +} + +/** + * Places every atom visible in `view`. Anything too narrow to draw becomes a group block saying how + * many atoms it stands for, and zooming to that block's range is what pulls them apart โ€” so nothing + * is ever silently dropped. + */ +export function layoutAtoms(placements: Placement[], view: AxisRange, minWidth: number = MIN_RECT_WIDTH): AtomLayout { + const span = view.end - view.start; + const rects: AtomRect[] = []; + if (span <= 0) return { rects, laneCount: 0, truncated: false }; + let laneCount = 0; + let truncated = false; + + const pushGroup = (depth: number, run: Clipped[], inherited: string | null): void => { + if (run.length === 0) return; + laneCount = Math.max(laneCount, depth + 1); + const from = run[0].from; + const to = run[run.length - 1].to; + // Folded rather than spread: a fragmented file's run can hold tens of thousands of atoms, well + // past the argument limit Math.min(...xs) would hit. + let byteStart = Infinity; + let byteEnd = -Infinity; + for (const c of run) { + byteStart = Math.min(byteStart, c.placement.box.start); + byteEnd = Math.max(byteEnd, c.placement.box.start + c.placement.box.size); + } + rects.push({ + kind: "group", + depth, + from, + to, + byteStart, + byteEnd, + x: (from - view.start) / span, + w: (to - from) / span, + box: null, + // Below the top level every member descends from the same top-level box already. + family: inherited ?? dominantFamily(run), + count: run.reduce((n, c) => n + countAtoms(c.placement.box), 0), + }); + }; + + const walk = (siblings: Placement[], depth: number, inherited: string | null): void => { + const visible = clipToView(siblings, view); + const floor = depth === 0 && visible.length <= MAX_UNCOLLAPSED_TOP_LEVEL ? 0 : minWidth; + let i = 0; + while (i < visible.length) { + if (rects.length >= MAX_RECTS) { + truncated = true; + return; + } + const current = visible[i]; + const width = (current.to - current.from) / span; + if (width < floor) { + // A run of neighbours that are each too narrow to draw shares one block, and the run is cut + // as soon as it is itself wide enough to draw. Without that cut, a file whose every atom is + // too narrow โ€” a long fragmented recording, where all of them are โ€” would merge into one + // block spanning the whole map, saying only how many boxes are somewhere inside it. Cut + // this way it striates instead: a run of blocks whose density is the fragmentation. + let end = i; + while (end < visible.length && (visible[end].to - visible[end].from) / span < floor) { + end++; + if ((visible[end - 1].to - visible[i].from) / span >= floor) break; + } + pushGroup(depth, visible.slice(i, end), inherited); + i = end; + continue; + } + laneCount = Math.max(laneCount, depth + 1); + const box = current.placement.box; + // Top-level atoms name their own family; everything below inherits the one it descends from. + const family = inherited ?? box.type; + rects.push({ + kind: "box", + depth, + from: current.placement.from, + to: current.placement.to, + byteStart: box.start, + byteEnd: box.start + box.size, + x: (current.from - view.start) / span, + w: width, + box, + family, + count: 1, + }); + walk(current.placement.children, depth + 1, family); + i++; + } + }; + + walk(placements, 0, null); + return { rects, laneCount, truncated }; +} diff --git a/src/style.css b/src/style.css index 021743d..10d0dd6 100644 --- a/src/style.css +++ b/src/style.css @@ -39,6 +39,18 @@ --teach-text: #333a4d; --teach-strong: #111827; --chart-grid: #e4e8f2; + /* Atom Map families. A categorical trio (moov / mdat / moof) plus a neutral for everything else, + each with the ink that reads on it; both modes were validated as a set for colorblind + separation and contrast against the card surface, so re-step them together rather than nudging + one. moov keeps the light accent so the map ties back to the rest of the page. */ + --atom-moov: #4f46e5; + --atom-moov-ink: #ffffff; + --atom-mdat: #eb6834; + --atom-mdat-ink: #2b1000; + --atom-moof: #1baf7a; + --atom-moof-ink: #04241a; + --atom-other: #5b6478; + --atom-other-ink: #ffffff; --ok: #1a7d42; --ok-tint: #e6f9ee; --warn: #92620a; @@ -83,6 +95,14 @@ --teach-text: #bbbbbb; --teach-strong: #dddddd; --chart-grid: #333333; + --atom-moov: #6366f1; + --atom-moov-ink: #ffffff; + --atom-mdat: #d95926; + --atom-mdat-ink: #1b0a00; + --atom-moof: #199e70; + --atom-moof-ink: #03211a; + --atom-other: #8f94ad; + --atom-other-ink: #17172a; --ok: #4ade80; --ok-tint: rgb(74 222 128 / 0.15); --warn: #fbbf24; @@ -126,6 +146,14 @@ --teach-text: #bbbbbb; --teach-strong: #dddddd; --chart-grid: #333333; + --atom-moov: #6366f1; + --atom-moov-ink: #ffffff; + --atom-mdat: #d95926; + --atom-mdat-ink: #1b0a00; + --atom-moof: #199e70; + --atom-moof-ink: #03211a; + --atom-other: #8f94ad; + --atom-other-ink: #17172a; --ok: #4ade80; --ok-tint: rgb(74 222 128 / 0.15); --warn: #fbbf24; @@ -650,40 +678,152 @@ details.teach-more summary { display: block; } -/* Atom tree */ -.atom-tree { - font-family: Monaco, Consolas, monospace; - font-size: 12.5px; -} -.atom-row { +/* Atom Map, horizontal: the box tree left to right, one lane per nesting depth. */ +.atom-crumbs { display: flex; - gap: 10px; - padding: 3px 6px; - border-radius: 4px; + flex-wrap: wrap; align-items: baseline; + gap: 4px; + margin-bottom: 10px; + font-size: 12px; } -.atom-row:hover { +.crumb { + background: none; + border: none; + padding: 2px 5px; + border-radius: 4px; + cursor: pointer; + font-family: Monaco, Consolas, monospace; + font-size: 12px; + color: var(--accent); +} +.crumb:hover { background: var(--hover); } -.atom-row .type { - color: var(--accent); +.crumb.on { + color: var(--text-strong); font-weight: 700; - min-width: 4.2em; } -.atom-row .off { +.crumb-sep { + color: var(--faint); +} +.crumb-range { + margin-left: auto; color: var(--muted); - min-width: 9em; + font-size: 11px; + font-family: Monaco, Consolas, monospace; } -.atom-row .sz { - color: var(--muted-2); - min-width: 9em; +.atom-map { + display: flex; + flex-direction: column; + gap: 2px; } -.atom-row .tag { - font-size: 10px; - padding: 1px 6px; - border-radius: 8px; - background: var(--chip-bg); - color: var(--chip-text); +.atom-lane { + position: relative; + height: 22px; +} +.f-moov { + --fam: var(--atom-moov); + --fam-ink: var(--atom-moov-ink); +} +.f-mdat { + --fam: var(--atom-mdat); + --fam-ink: var(--atom-mdat-ink); +} +.f-moof { + --fam: var(--atom-moof); + --fam-ink: var(--atom-moof-ink); +} +.f-other { + --fam: var(--atom-other); + --fam-ink: var(--atom-other-ink); +} +.atom-block { + position: absolute; + top: 0; + bottom: 0; + margin: 0; + padding: 0; + border: none; + border-radius: 3px; + overflow: hidden; + cursor: pointer; + text-align: left; + font-family: Monaco, Consolas, monospace; + font-size: 11px; + line-height: 22px; + background: var(--fam); + color: var(--fam-ink); + /* A block that would round to nothing still has to be clickable, and the inset keeps a surface + gap between neighbours so two same-colored siblings never read as one. */ + min-width: 3px; + box-shadow: inset -2px 0 0 var(--card); +} +/* Runs of boxes too narrow to draw individually keep the color of whichever family fills most of + the run, and carry a hatch over it so the count never reads as one real box. */ +.atom-block.grouped, +.swatch.grouped { + background-image: repeating-linear-gradient(45deg, rgb(255 255 255 / 0.3) 0 3px, transparent 3px 6px); +} +.atom-block:hover { + filter: brightness(1.12); + z-index: 1; +} +.atom-block:focus-visible { + outline: 2px solid var(--text-strong); + outline-offset: -2px; + z-index: 1; +} +/* Set from the measured panel width: a label is drawn only where it actually fits, in the larger + size when there is room and a tighter one when that is the difference between naming the box + and leaving it a bare sliver. */ +.atom-block .lbl { + display: none; + padding: 0 5px; + white-space: nowrap; +} +.atom-block.wide .lbl, +.atom-block.snug .lbl { + display: block; +} +.atom-block.snug .lbl { + padding: 0 2px; + font-size: 9.5px; +} +.atom-readout { + margin-top: 8px; + min-height: 1.4em; + font-family: Monaco, Consolas, monospace; + font-size: 11.5px; + color: var(--muted); +} +.atom-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + margin-top: 12px; + font-size: 11px; + color: var(--muted); +} +.legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} +.swatch { + width: 12px; + height: 12px; + flex: none; + border-radius: 3px; + background: var(--fam); +} +.legend-label { + font-family: Monaco, Consolas, monospace; + color: var(--text); +} +.legend-note { + flex-basis: 100%; + color: var(--faint); } /* Histogram bars */ diff --git a/src/ui/atomsTab.ts b/src/ui/atomsTab.ts index a89dde7..3700655 100644 --- a/src/ui/atomsTab.ts +++ b/src/ui/atomsTab.ts @@ -1,21 +1,52 @@ -// Tab: Atom Map โ€” MP4 box/atom tree with byte offsets and sizes. +// Tab: Atom Map โ€” the MP4 box tree laid on its side. +// +// Left to right across the file, each row down one level of nesting, so the map stays the same +// handful of lanes tall whether the video runs ten seconds or ten hours. Siblings split their +// parent's width by how many boxes each subtree holds, so every box gets room and nothing in the +// file goes unshown; see atomLayout.ts for why that particular split. Every block is a button +// carrying its own offset and size, so the numbers are reachable by keyboard and screen reader as +// well as by hovering, and the Report tab still writes the whole tree out as indented text. import { h, teachBox } from "../lib/dom"; +import { layoutAtoms, placeAtoms, placementRange, type AtomRect, type AxisRange } from "../lib/atomLayout"; import { fmtBytes } from "../lib/format"; import { state } from "../lib/state"; -import type { BoxNode } from "../lib/types"; -function renderNode(tree: HTMLElement, box: BoxNode, depth: number): void { - const row = h("div", "atom-row"); - row.style.paddingLeft = depth * 18 + "px"; - row.append(h("span", "type", box.type)); - row.append(h("span", "off", "offset " + box.start.toLocaleString())); - row.append(h("span", "sz", fmtBytes(box.size) + " (" + box.size.toLocaleString() + " B)")); - if (box.type === "moov") row.append(h("span", "tag", "index")); - if (box.type === "mdat") row.append(h("span", "tag", "sample data")); - if (box.type === "ftyp") row.append(h("span", "tag", "brand")); - tree.append(row); - box.children.forEach((c) => renderNode(tree, c, depth + 1)); +/** What the boxes worth calling out actually hold, named in the readout. */ +const BOX_ROLES: Record = { + ftyp: "brand", + moov: "index", + mdat: "sample data", + moof: "fragment index", + mfra: "fragment random access", + free: "padding", + skip: "padding", +}; + +/** The three box families the map colors; everything else shares the neutral slot. */ +const FAMILIES: [string, string][] = [ + ["moov", "index & metadata"], + ["mdat", "sample data"], + ["moof", "fragment index"], +]; + +const READOUT_HINT = "Hover a block for its offset and size; click one to zoom into it."; + +/** + * Roughly what a label costs in the two sizes the blocks draw it at: per character, plus the + * block's own padding. Measured against the map's real width, so a box still gets named in the + * narrow slot a file with a big moov can afford it. + */ +const LABEL_COST = { + wide: { perChar: 6.7, padding: 10 }, + snug: { perChar: 5.8, padding: 4 }, +}; + +// One observer at a time: each redraw builds a fresh map element and re-points the observer at it. +let labelObserver: ResizeObserver | null = null; + +function familyClass(family: string | null): string { + return family !== null && FAMILIES.some(([key]) => key === family) ? "f-" + family : "f-other"; } export function renderAtomMap(panel: HTMLElement): void { @@ -28,14 +59,176 @@ export function renderAtomMap(panel: HTMLElement): void { `An MP4 file is a tree of boxes (also called “atoms”): ftyp declares the ` + `brand/compatibility, moov holds all metadata & the sample index (offsets, sizes, ` + `timestamps, keyframe flags), and mdat holds the raw encoded frame bytes it points to. ` + - `Fragmented MP4s repeat moof+mdat pairs instead of one big mdat.`, + `Fragmented MP4s repeat moof+mdat pairs instead of one big mdat.` + + `

The map below is that tree on its side: left to right across the file, each row down one level of ` + + `nesting. Siblings split their parent's width by how many boxes each subtree holds, so every box gets ` + + `room and the whole file is on screen at once however long the video is. Width says nothing about ` + + `size โ€” hover a box for its offset and byte count, or click to zoom into it. The Report tab ` + + `writes the same tree out as indented text, every number spelled out.

`, ), ); - const tree = h("div", "atom-tree"); - state.boxes.forEach((b) => renderNode(tree, b, 0)); - const treeScroll = h("div", "scroll-x"); - treeScroll.append(tree); - sec.append(treeScroll); + const placements = placeAtoms(state.boxes); + const zoom: { label: string; range: AxisRange }[] = []; + const body = h("div"); + + const draw = (): void => { + body.innerHTML = ""; + if (state.boxes.length === 0) { + body.append(h("div", "progress-label", "No boxes were parsed for this file.")); + return; + } + renderMap(body, placements, zoom, draw); + }; + + sec.append(body); panel.append(sec); + draw(); +} + +function renderMap( + host: HTMLElement, + placements: ReturnType, + zoom: { label: string; range: AxisRange }[], + redraw: () => void, +): void { + const fullRange = placementRange(placements); + const view = zoom.length > 0 ? zoom[zoom.length - 1].range : fullRange; + const layout = layoutAtoms(placements, view); + const shown = layout.rects.reduce((n, r) => n + r.count, 0); + + host.append(renderCrumbs(shown, zoom, redraw)); + + const map = h("div", "atom-map"); + const lanes: HTMLDivElement[] = []; + for (let depth = 0; depth < layout.laneCount; depth++) { + const lane = h("div", "atom-lane"); + lanes.push(lane); + map.append(lane); + } + const readout = h("div", "atom-readout", READOUT_HINT); + layout.rects.forEach((rect) => lanes[rect.depth].append(blockEl(rect, zoom, redraw, readout))); + host.append(map, readout); + + if (layout.truncated) { + host.append(h("div", "progress-label", "This file nests further than the map draws; zoom in to see the rest.")); + } + host.append(renderLegend()); + bindLabelSizing(map); +} + +function renderCrumbs(shown: number, zoom: { label: string; range: AxisRange }[], redraw: () => void): HTMLDivElement { + const crumbs = h("div", "atom-crumbs"); + crumbs.setAttribute("aria-label", "Zoom path"); + // Each crumb pops the zoom path back to the depth it names; the last one is where you already are. + const jump = (depth: number, label: string): HTMLButtonElement => { + const btn = h("button", "crumb" + (depth === zoom.length ? " on" : ""), label); + btn.type = "button"; + btn.addEventListener("click", () => { + zoom.length = depth; + redraw(); + }); + return btn; + }; + crumbs.append(jump(0, "Whole file")); + zoom.forEach((step, i) => crumbs.append(h("span", "crumb-sep", "โ€บ"), jump(i + 1, step.label))); + crumbs.append(h("span", "crumb-range", `${boxCount(shown)} shown`)); + return crumbs; +} + +function boxCount(n: number): string { + return `${n.toLocaleString()} box${n === 1 ? "" : "es"}`; +} + +/** Rounded size with the exact byte count behind it, except under 1 KB where they are the same. */ +function fmtSize(bytes: number): string { + const rounded = fmtBytes(bytes); + const exact = bytes.toLocaleString() + " B"; + return rounded === exact ? exact : `${rounded} (${exact})`; +} + +function rectLabel(rect: AtomRect): string { + return rect.box !== null ? rect.box.type : boxCount(rect.count); +} + +function rectDetail(rect: AtomRect): string { + if (rect.box !== null) { + const role = BOX_ROLES[rect.box.type]; + const size = fmtSize(rect.box.size); + return `${rect.box.type}${role ? " โ€” " + role : ""} ยท offset ${rect.box.start.toLocaleString()} ยท ${size}`; + } + return ( + `${boxCount(rect.count)} in ${fmtSize(rect.byteEnd - rect.byteStart)} at offset ` + + `${rect.byteStart.toLocaleString()}, too many to draw one by one ยท click to zoom in on them` + ); +} + +function blockEl( + rect: AtomRect, + zoom: { label: string; range: AxisRange }[], + redraw: () => void, + readout: HTMLElement, +): HTMLButtonElement { + const label = rectLabel(rect); + const detail = rectDetail(rect); + const el = h("button", `atom-block ${familyClass(rect.family)}${rect.kind === "group" ? " grouped" : ""}`); + el.type = "button"; + el.style.left = rect.x * 100 + "%"; + el.style.width = rect.w * 100 + "%"; + el.dataset.frac = String(rect.w); + el.dataset.chars = String(label.length); + el.setAttribute("aria-label", detail); + el.append(h("span", "lbl", label)); + + const show = (): void => { + readout.textContent = detail; + }; + const clear = (): void => { + readout.textContent = READOUT_HINT; + }; + el.addEventListener("mouseenter", show); + el.addEventListener("focus", show); + el.addEventListener("mouseleave", clear); + el.addEventListener("blur", clear); + el.addEventListener("click", () => { + zoom.push({ label, range: { start: rect.from, end: rect.to } }); + redraw(); + }); + return el; +} + +function renderLegend(): HTMLDivElement { + const legend = h("div", "atom-legend"); + const item = (cls: string, name: string, desc: string): HTMLSpanElement => { + const wrap = h("span", "legend-item"); + wrap.append(h("span", "swatch " + cls), h("span", "legend-label", name), h("span", "legend-desc", desc)); + return wrap; + }; + FAMILIES.forEach(([key, desc]) => legend.append(item("f-" + key, key, desc))); + legend.append(item("f-other", "other", "brand, padding, user data")); + legend.append(item("f-other grouped", "N boxes", "too many to draw, in the color of most of them")); + legend.append( + h("span", "legend-note", "A box's color is the top-level box it belongs to; each row down is one level in."), + ); + return legend; +} + +// Labels fit or they don't, and that depends on the panel's real width โ€” which is zero while the +// tab is hidden. Observing the map covers both the first reveal and later window resizes. +function bindLabelSizing(map: HTMLElement): void { + const sizeLabels = (): void => { + const width = map.clientWidth; + map.querySelectorAll(".atom-block").forEach((el) => { + const px = Number(el.dataset.frac) * width; + const chars = Number(el.dataset.chars); + const wide = px >= chars * LABEL_COST.wide.perChar + LABEL_COST.wide.padding; + el.classList.toggle("wide", wide); + el.classList.toggle("snug", !wide && px >= chars * LABEL_COST.snug.perChar + LABEL_COST.snug.padding); + }); + }; + sizeLabels(); + labelObserver?.disconnect(); + if (typeof ResizeObserver === "undefined") return; + labelObserver = new ResizeObserver(sizeLabels); + labelObserver.observe(map); } diff --git a/stories/AtomMap.stories.ts b/stories/AtomMap.stories.ts new file mode 100644 index 0000000..fb1336b --- /dev/null +++ b/stories/AtomMap.stories.ts @@ -0,0 +1,51 @@ +import { state } from "../src/lib/state"; +import type { BoxNode } from "../src/lib/types"; +import { renderAtomMap } from "../src/ui/atomsTab"; + +function box(type: string, start: number, size: number, children: BoxNode[] = []): BoxNode { + return { type, start, size, hdrSize: 8, children }; +} + +/** A progressive file: ftyp, a moov holding the index, padding, then the sample payload. */ +const PROGRESSIVE: BoxNode[] = [ + box("ftyp", 0, 32), + box("moov", 32, 17531, [ + box("mvhd", 40, 108), + box("trak", 148, 17317, [box("tkhd", 156, 92), box("mdia", 284, 17181, [box("hdlr", 292, 45)])]), + box("udta", 17465, 98), + ]), + box("free", 17563, 8), + box("mdat", 17571, 2119170), +]; + +/** A fragmented file: the shape a long recording takes, and what the indented tree cannot show. */ +const FRAGMENTED: BoxNode[] = (() => { + const boxes: BoxNode[] = [box("ftyp", 0, 32), box("moov", 32, 1200, [box("mvhd", 40, 108)])]; + for (let i = 0; i < 400; i++) { + const at = 1232 + i * 24000; + boxes.push(box("moof", at, 320, [box("mfhd", at + 8, 16)])); + boxes.push(box("mdat", at + 320, 23680)); + } + return boxes; +})(); + +function renderWith(boxes: BoxNode[]): HTMLElement { + state.boxes = boxes; + const panel = document.createElement("div"); + renderAtomMap(panel); + return panel; +} + +export default { + title: "Components/Atom Map", +}; + +export const Progressive = { + name: "Progressive file โ€” every box drawn", + render: () => renderWith(PROGRESSIVE), +}; + +export const Fragmented = { + name: "Fragmented file โ€” collapsed into counted blocks", + render: () => renderWith(FRAGMENTED), +}; diff --git a/tests/integration/smoke.spec.ts b/tests/integration/smoke.spec.ts index e0b43de..29da83e 100644 --- a/tests/integration/smoke.spec.ts +++ b/tests/integration/smoke.spec.ts @@ -60,6 +60,41 @@ test.describe("Encoding Helper shell", () => { await expect(page.locator(".talmo-brand-logo.on-light")).toBeHidden(); }); + test("draws every box the Report tab lists, with none summarised away", async ({ page }) => { + await page.goto("/?tab=atoms"); + await page.locator("#loadSampleBtn").click(); + const panel = page.locator("#panel-atoms"); + await expect(panel.locator(".atom-map")).toBeVisible(); + await expect(panel.locator(".atom-block.grouped")).toHaveCount(0); + const drawn = await panel.locator(".atom-block").count(); + + // The Report tab writes the same tree out as indented text from the same parse, so its line + // count is an independent check that the map is not quietly leaving boxes out. + await page.locator('.tab[data-tab="report"]').click(); + const listing = page.locator("#panel-report .section", { hasText: "MP4 Atom Map" }).locator("pre.cmd"); + const lines = ((await listing.textContent()) ?? "").trim().split("\n"); + expect(drawn).toBe(lines.length); + expect(lines[0]).toContain("ftyp"); + }); + + test("zooms into a box on click and walks back out with the breadcrumb", async ({ page }) => { + await page.goto("/?tab=atoms"); + await page.locator("#loadSampleBtn").click(); + const panel = page.locator("#panel-atoms"); + await expect(panel.locator(".atom-map")).toBeVisible(); + await expect(panel.locator(".crumb")).toHaveText(["Whole file"]); + + await panel.locator(".atom-block.f-moov").first().click(); + await expect(panel.locator(".crumb")).toHaveText(["Whole file", "moov"]); + // Zoomed to moov, its own subtree is all that is left and it fills the width. + await expect(panel.locator(".atom-block.f-mdat")).toHaveCount(0); + await expect(panel.locator(".atom-block").first()).toHaveAttribute("style", /width: 100%/); + + await panel.locator(".crumb").first().click(); + await expect(panel.locator(".crumb")).toHaveText(["Whole file"]); + await expect(panel.locator(".atom-block.f-mdat")).toHaveCount(1); + }); + test("drops the fixed watermarks once the viewport is too narrow to frame the page", async ({ page }) => { await page.setViewportSize({ width: 1200, height: 900 }); await page.goto("/"); diff --git a/tests/unit/atomLayout.test.ts b/tests/unit/atomLayout.test.ts new file mode 100644 index 0000000..b8370c6 --- /dev/null +++ b/tests/unit/atomLayout.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { countAtoms, layoutAtoms, MIN_RECT_WIDTH, placeAtoms, placementRange } from "../../src/lib/atomLayout"; +import type { BoxNode } from "../../src/lib/types"; + +function box(type: string, start: number, size: number, children: BoxNode[] = []): BoxNode { + return { type, start, size, hdrSize: 8, children }; +} + +/** A plain, unfragmented file: ftyp, then a moov holding one trak, then the sample payload. */ +function progressiveFile(): BoxNode[] { + return [ + box("ftyp", 0, 32), + box("moov", 32, 2000, [box("mvhd", 40, 108), box("trak", 148, 1800, [box("tkhd", 156, 92)])]), + box("mdat", 2032, 97968), + ]; +} + +/** `pairs` moof+mdat fragments, the shape a long recording actually takes. */ +function fragmentedFile(pairs: number, fragmentSize: number): BoxNode[] { + const boxes: BoxNode[] = []; + for (let i = 0; i < pairs; i++) { + const at = i * fragmentSize; + boxes.push(box("moof", at, 200, [box("mfhd", at + 8, 16)])); + boxes.push(box("mdat", at + 200, fragmentSize - 200)); + } + return boxes; +} + +/** Lays a whole file out, the way the tab does before any zooming. */ +function layOut(boxes: BoxNode[]) { + const placements = placeAtoms(boxes); + return layoutAtoms(placements, placementRange(placements)); +} + +function drawnTypes(boxes: BoxNode[]): (string | undefined)[] { + return layOut(boxes) + .rects.filter((r) => r.kind === "box") + .map((r) => r.box?.type); +} + +describe("countAtoms", () => { + it("counts the box itself plus every descendant", () => { + expect(countAtoms(progressiveFile()[1])).toBe(4); + expect(countAtoms(box("mdat", 0, 10))).toBe(1); + }); +}); + +describe("placeAtoms", () => { + it("splits the axis between siblings by how many boxes each subtree holds", () => { + // Six atoms: ftyp and mdat take one slot each, and moov's subtree takes the other four. + const placements = placeAtoms(progressiveFile()); + const [ftyp, moov, mdat] = placements; + expect(placementRange(placements)).toEqual({ start: 0, end: 6 }); + expect(ftyp).toMatchObject({ from: 0, to: 1 }); + expect(moov).toMatchObject({ from: 1, to: 5 }); + expect(mdat).toMatchObject({ from: 5, to: 6 }); + // mvhd is one atom of the three under moov; trak's subtree is the other two. + expect(moov.children[0]).toMatchObject({ from: 1, to: 1 + 4 / 3 }); + expect(moov.children[1].children[0]).toMatchObject({ to: 5 }); + }); + + it("keeps children inside their parent", () => { + const [, moov] = placeAtoms(progressiveFile()); + for (const child of moov.children) { + expect(child.from).toBeGreaterThanOrEqual(moov.from); + expect(child.to).toBeLessThanOrEqual(moov.to); + } + }); + + it("is unmoved by how big the boxes are, only by how many there are", () => { + const tiny = [box("moov", 0, 100, [box("mvhd", 8, 90)]), box("mdat", 100, 200)]; + const huge = [box("moov", 0, 100, [box("mvhd", 8, 90)]), box("mdat", 100, 4e9)]; + expect(placeAtoms(tiny).map((p) => [p.from, p.to])).toEqual(placeAtoms(huge).map((p) => [p.from, p.to])); + }); +}); + +describe("layoutAtoms", () => { + it("draws every box in the file, each labelled with its own lane and slice", () => { + const { rects, laneCount } = layOut(progressiveFile()); + expect(drawnTypes(progressiveFile())).toEqual(["ftyp", "moov", "mvhd", "trak", "tkhd", "mdat"]); + expect(rects.every((r) => r.kind === "box")).toBe(true); + expect(laneCount).toBe(3); + }); + + it("gives a box width from its subtree size rather than its bytes", () => { + const { rects } = layOut(progressiveFile()); + // mdat is 98% of the file but one box of six, so it gets a sixth of the width. + expect(rects.find((r) => r.box?.type === "mdat")?.w).toBeCloseTo(1 / 6); + expect(rects.find((r) => r.box?.type === "ftyp")?.w).toBeCloseTo(1 / 6); + expect(rects.find((r) => r.box?.type === "moov")?.w).toBeCloseTo(4 / 6); + }); + + it("leaves nothing collapsed for a deep, lopsided tree", () => { + const deep = [ + box("ftyp", 0, 32), + box("moov", 32, 3000, [ + box("mvhd", 40, 108), + box("trak", 148, 2000, [box("tkhd", 156, 92), box("mdia", 248, 1800, [box("hdlr", 256, 45)])]), + box("udta", 2148, 800, [box("meta", 2156, 700)]), + ]), + box("mdat", 3032, 500000000), + ]; + expect(drawnTypes(deep)).toEqual(["ftyp", "moov", "mvhd", "trak", "tkhd", "mdia", "hdlr", "udta", "meta", "mdat"]); + expect(layOut(deep).laneCount).toBe(4); + }); + + it("puts children in the lane below their parent", () => { + const { rects } = layOut(progressiveFile()); + expect(rects.find((r) => r.box?.type === "moov")?.depth).toBe(0); + expect(rects.find((r) => r.box?.type === "trak")?.depth).toBe(1); + expect(rects.find((r) => r.box?.type === "tkhd")?.depth).toBe(2); + }); + + it("labels every descendant with the top-level box it belongs to", () => { + const { rects } = layOut(progressiveFile()); + expect(rects.find((r) => r.box?.type === "tkhd")?.family).toBe("moov"); + expect(rects.find((r) => r.box?.type === "mdat")?.family).toBe("mdat"); + }); + + it("reports real byte offsets, which is what the readout shows", () => { + const mdat = layOut(progressiveFile()).rects.find((r) => r.box?.type === "mdat"); + expect(mdat).toMatchObject({ byteStart: 2032, byteEnd: 100000 }); + }); + + it("keeps every top-level box, however many boxes its siblings' subtrees hold", () => { + // ftyp and mdat are one box each against a moov subtree of 200; the file's outermost shape is + // never summarised away. + const crowded: BoxNode[] = [ + box("ftyp", 0, 32), + box( + "moov", + 32, + 2000, + Array.from({ length: 199 }, (_, i) => box("trak", 40 + i, 1)), + ), + box("mdat", 2032, 97968), + ]; + const top = layOut(crowded).rects.filter((r) => r.depth === 0); + expect(top.map((r) => r.box?.type)).toEqual(["ftyp", "moov", "mdat"]); + }); +}); + +describe("layoutAtoms, files with more boxes than fit", () => { + it("collapses what will not fit into counted blocks rather than dropping it", () => { + const boxes = fragmentedFile(20000, 100000); + const layout = layOut(boxes); + expect(boxes.reduce((n, b) => n + countAtoms(b), 0)).toBe(60000); + // Bounded by how many blocks fit at the minimum width, not by how many boxes the file has. + expect(layout.laneCount).toBeLessThanOrEqual(2); + expect(layout.rects.length).toBeLessThanOrEqual(Math.ceil(1 / MIN_RECT_WIDTH)); + expect(layout.rects.reduce((n, r) => n + r.count, 0)).toBe(60000); + }); + + it("cuts a collapsed run once it is wide enough to draw, so the map striates", () => { + // Every box of a heavily fragmented file is too narrow to draw. Merged into a single run they + // would be one block spanning the whole map, which says nothing; cut into blocks the map shows + // the fragmentation itself. + const layout = layOut(fragmentedFile(20000, 100000)); + expect(layout.rects.length).toBeGreaterThan(100); + expect(layout.rects.every((r) => r.w >= MIN_RECT_WIDTH * 0.999)).toBe(true); + // Each block is drawn in the color of whatever fills most of it rather than an anonymous grey. + // Width here is box count, and each fragment is two moof boxes to one mdat, so moof it is. + expect(layout.rects.every((r) => r.family === "moof")).toBe(true); + }); + + it("pulls the collapsed boxes apart once the view zooms in on them", () => { + const placements = placeAtoms(fragmentedFile(20000, 100000)); + let layout = layoutAtoms(placements, placementRange(placements)); + expect(layout.rects.every((r) => r.kind === "group")).toBe(true); + + // Zooming into the leftmost block repeatedly always reaches real boxes, and quickly: each step + // divides the boxes in view by the number of blocks the last one was cut into. + let steps = 0; + while (!layout.rects.some((r) => r.kind === "box") && steps < 5) { + layout = layoutAtoms(placements, { start: layout.rects[0].from, end: layout.rects[0].to }); + steps++; + } + expect(steps).toBe(1); + expect(layout.rects.filter((r) => r.kind === "box").map((r) => r.box?.type)).toContain("moof"); + }); + + it("keeps a group's byte range and count, so a collapsed block still says what it holds", () => { + const group = layOut(fragmentedFile(2000, 100000)).rects[0]; + expect(group.kind).toBe("group"); + expect(group.count).toBeGreaterThan(1); + expect(group.byteStart).toBe(0); + expect(group.byteEnd).toBeGreaterThan(group.byteStart); + }); +}); + +describe("layoutAtoms, degenerate input", () => { + it("draws nothing for an empty tree or a collapsed range", () => { + expect(layOut([])).toEqual({ rects: [], laneCount: 0, truncated: false }); + expect(layoutAtoms(placeAtoms(progressiveFile()), { start: 3, end: 3 })).toEqual({ + rects: [], + laneCount: 0, + truncated: false, + }); + }); + + it("draws zero-byte boxes, which have a slot like any other", () => { + expect(drawnTypes([box("free", 0, 0), box("mdat", 0, 1000)])).toEqual(["free", "mdat"]); + }); +});