Skip to content

Commit 9c36cfd

Browse files
CodyCBakerPhDclaudepre-commit-ci[bot]
authored
Added horizontal byte map view to Atom Map with zoom (#14)
* Added a horizontal orientation to the Atom Map The indented tree grew one row per box, so a long recording — especially a fragmented one, where a moof+mdat pair repeats per fragment — ran to thousands of rows. The new horizontal view draws the boxes onto the file itself: left to right is byte offset, one lane per nesting level, so the map is a handful of rows tall however long the video is. Clicking a block zooms the view to its byte range, with a breadcrumb back out. Boxes too narrow to draw at the current zoom are merged into one block saying how many it stands for rather than dropped, so a 20,000-fragment file is a few blocks that open up as you zoom instead of an empty map. Color says which top-level box a byte belongs to; the palette was checked for colorblind separation and contrast against both themes. A Horizontal/Vertical toggle picks between the new view and the original tree, and the choice is remembered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0191Yrv8pTa7ujjv9iC9WiZE * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Added a structure view so the Atom Map shows every box The byte-proportional map could not give a moov under 1% of the file room for its 24 children, so it summarised the whole subtree into one block and most of what the indented tree shows went missing at the default zoom. The Atom Map now offers two horizontal views, chosen alongside the tree from one control. Structure, the new default, sizes each box by how many boxes its subtree holds rather than by bytes, so every box in the file is drawn and labelled at once — the tree turned on its side. Bytes is the previous proportional map, kept because it is the one that shows a 99%-mdat file and whether moov comes before mdat. Merged runs are also cut as soon as they are wide enough to draw. A 20,000 fragment file collapsed into a single anonymous bar before; it now striates into blocks whose density is the fragmentation, each carrying the color of whatever fills most of it, so a fragmented recording reads as the sample data it mostly is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0191Yrv8pTa7ujjv9iC9WiZE * Removed the byte-proportional Atom Map view The structure view is the one worth having, and keeping a second axis meant carrying a scale parameter through the placement, the layout, the readout and the byte-offset ruler for a view nobody wanted. Dropping it also removes a rule that was working against structure. A box narrower than 2% was not opened up, because on the byte axis descending into a sub-1% moov drew lane after lane of identical slivers. On the structure axis a box's width is its subtree size, so that rule only hid children that would have fit. Gone, the view now draws everything the panel has room for and nothing less. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0191Yrv8pTa7ujjv9iC9WiZE * Removed the Atom Map's tree view and its view selector The map is the view; a selector between it and the indented tree was one button row standing between the tab and the thing worth looking at. Nothing the tree carried is lost. Every block is already a button whose aria-label is its type, role, offset and size, so those 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 — the integration test now counts the map's blocks against that listing, so the two cannot drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0191Yrv8pTa7ujjv9iC9WiZE --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent c9bc6e5 commit 9c36cfd

10 files changed

Lines changed: 937 additions & 46 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.2.7
4+
5+
#### 🚀 Enhancement
6+
7+
- 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))
8+
- 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))
9+
- 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))
10+
- 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))
11+
- 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))
12+
313
## 0.2.6
414

515
#### 🚀 Enhancement

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Companion to [Video Info Tool](https://vibes.tlab.sh/video-info-tool/) and [Fram
1313
## Features
1414

1515
- **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
16+
- **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
1617
- **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
1718
- **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
1819
- **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

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "encoding-helper",
3-
"version": "0.2.6",
3+
"version": "0.2.7",
44
"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.",
55
"type": "module",
66
"license": "MIT",

‎src/lib/atomLayout.ts‎

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Geometry for the Atom Map's horizontal view.
2+
//
3+
// The indented tree grows one row per atom, so a long recording — especially a fragmented one,
4+
// where a moof+mdat pair repeats per fragment — runs to thousands of rows. This lays the same tree
5+
// out along an axis instead: one lane per nesting depth, each atom drawn across its slice. Height
6+
// is then bounded by how deeply the file nests (a handful of lanes) no matter how long the video
7+
// is, and width is always the width of the panel.
8+
//
9+
// The axis is one slot per atom, not file bytes. Siblings split their parent's slice in proportion
10+
// to how many atoms each subtree holds, which is the split that makes the narrowest atom as wide as
11+
// it can be: every atom gets at least one slot's share of the panel. So the whole file is drawn and
12+
// labelled at once, and what the view says nothing about is size. It is the indented tree, turned
13+
// on its side.
14+
//
15+
// Atoms are contained by their parent and siblings never overlap, so an atom is never wider than
16+
// its parent. That makes visibility monotonic: if an atom is drawn, every one of its ancestors was
17+
// too, and zooming into an ancestor is always the way to reach something too small to see on its
18+
// own.
19+
20+
import type { BoxNode } from "./types";
21+
22+
/** A half-open range on the atom-slot axis. */
23+
export interface AxisRange {
24+
start: number;
25+
end: number;
26+
}
27+
28+
/** An atom with the slice of the axis it was given, and its children placed inside that slice. */
29+
export interface Placement {
30+
box: BoxNode;
31+
from: number;
32+
to: number;
33+
children: Placement[];
34+
}
35+
36+
/** One drawn rectangle: either a single atom, or a run of neighbours collapsed into one block. */
37+
export interface AtomRect {
38+
kind: "box" | "group";
39+
/** Nesting depth, which is also the lane the rect is drawn in. */
40+
depth: number;
41+
/** Zoom target, on the atom-slot axis. */
42+
from: number;
43+
to: number;
44+
/** The bytes this rect covers, which is what the readout reports. */
45+
byteStart: number;
46+
byteEnd: number;
47+
/** Left edge and width as fractions (0..1) of the visible range, clipped to it. */
48+
x: number;
49+
w: number;
50+
/** The atom itself on a "box" rect; null on a "group". */
51+
box: BoxNode | null;
52+
/** Type of the top-level atom this one descends from ("moov", "mdat", …). On a "group" holding a
53+
* mix, it is whichever family covers most of the block — so a fragmented file's collapsed runs
54+
* still read as the sample data they mostly are. Null only when there is nothing to name. */
55+
family: string | null;
56+
/** Atoms represented: 1 for a box (its children are drawn separately), the whole collapsed count
57+
* — subtrees included — for a group. */
58+
count: number;
59+
}
60+
61+
export interface AtomLayout {
62+
rects: AtomRect[];
63+
/** Number of lanes to draw, i.e. deepest depth reached + 1. */
64+
laneCount: number;
65+
/** True when MAX_RECTS was reached and the rest of the tree was left undrawn. */
66+
truncated: boolean;
67+
}
68+
69+
/**
70+
* Narrower than this fraction of the visible range, an atom is collapsed into a group block. Set so
71+
* that a block at the threshold is still a few real pixels wide at any sensible panel width, since
72+
* anything thinner would be widened by the stylesheet's minimum and start overlapping its neighbour.
73+
* It is also what bounds "every atom is drawn": past roughly 1/MIN_RECT_WIDTH atoms in view there is
74+
* no room left to give each its own block, and zooming is how you get it back.
75+
*/
76+
export const MIN_RECT_WIDTH = 0.005;
77+
78+
/**
79+
* Up to this many top-level atoms are all drawn, however narrow they are, so the shape of the file
80+
* is never summarised away at the outermost level. A progressive file has only a handful; a
81+
* fragmented one has thousands, well past this, so its runs collapse like any other lane's.
82+
*/
83+
const MAX_UNCOLLAPSED_TOP_LEVEL = 24;
84+
85+
/** Backstop for pathological files; a real one settles far below this once groups collapse runs. */
86+
const MAX_RECTS = 4000;
87+
88+
/** Atoms in a subtree, counting the box itself. */
89+
export function countAtoms(box: BoxNode): number {
90+
return box.children.reduce((n, c) => n + countAtoms(c), 1);
91+
}
92+
93+
function place(boxes: BoxNode[], from: number, to: number): Placement[] {
94+
const weights = boxes.map(countAtoms);
95+
const total = weights.reduce((a, b) => a + b, 0);
96+
if (total === 0) return [];
97+
const span = to - from;
98+
let cursor = from;
99+
return boxes.map((box, i) => {
100+
const next = cursor + (span * weights[i]) / total;
101+
const placement: Placement = { box, from: cursor, to: next, children: [] };
102+
placement.children = place(box.children, cursor, next);
103+
cursor = next;
104+
return placement;
105+
});
106+
}
107+
108+
/** Assigns every atom its slice of the axis, the tree as a whole spanning one slot per atom. */
109+
export function placeAtoms(boxes: BoxNode[]): Placement[] {
110+
return place(
111+
boxes,
112+
0,
113+
boxes.reduce((n, b) => n + countAtoms(b), 0),
114+
);
115+
}
116+
117+
/** The range the placed tree spans. */
118+
export function placementRange(placements: Placement[]): AxisRange {
119+
if (placements.length === 0) return { start: 0, end: 0 };
120+
let start = Infinity;
121+
let end = -Infinity;
122+
for (const placement of placements) {
123+
start = Math.min(start, placement.from);
124+
end = Math.max(end, placement.to);
125+
}
126+
return { start, end };
127+
}
128+
129+
interface Clipped {
130+
placement: Placement;
131+
from: number;
132+
to: number;
133+
}
134+
135+
/** Whichever top-level family covers most of a collapsed run. */
136+
function dominantFamily(run: Clipped[]): string | null {
137+
const widths = new Map<string, number>();
138+
for (const c of run) {
139+
const type = c.placement.box.type;
140+
widths.set(type, (widths.get(type) ?? 0) + (c.to - c.from));
141+
}
142+
let winner: string | null = null;
143+
let best = -1;
144+
// Insertion order is axis order, and the comparison is strict, so ties go to the leftmost.
145+
for (const [type, width] of widths) {
146+
if (width > best) {
147+
best = width;
148+
winner = type;
149+
}
150+
}
151+
return winner;
152+
}
153+
154+
/** The parts of `siblings` that fall inside `view`, in axis order. */
155+
function clipToView(siblings: Placement[], view: AxisRange): Clipped[] {
156+
return siblings
157+
.map((placement) => ({
158+
placement,
159+
from: Math.max(placement.from, view.start),
160+
to: Math.min(placement.to, view.end),
161+
}))
162+
.filter((c) => c.to > c.from)
163+
.sort((a, b) => a.from - b.from);
164+
}
165+
166+
/**
167+
* Places every atom visible in `view`. Anything too narrow to draw becomes a group block saying how
168+
* many atoms it stands for, and zooming to that block's range is what pulls them apart — so nothing
169+
* is ever silently dropped.
170+
*/
171+
export function layoutAtoms(placements: Placement[], view: AxisRange, minWidth: number = MIN_RECT_WIDTH): AtomLayout {
172+
const span = view.end - view.start;
173+
const rects: AtomRect[] = [];
174+
if (span <= 0) return { rects, laneCount: 0, truncated: false };
175+
let laneCount = 0;
176+
let truncated = false;
177+
178+
const pushGroup = (depth: number, run: Clipped[], inherited: string | null): void => {
179+
if (run.length === 0) return;
180+
laneCount = Math.max(laneCount, depth + 1);
181+
const from = run[0].from;
182+
const to = run[run.length - 1].to;
183+
// Folded rather than spread: a fragmented file's run can hold tens of thousands of atoms, well
184+
// past the argument limit Math.min(...xs) would hit.
185+
let byteStart = Infinity;
186+
let byteEnd = -Infinity;
187+
for (const c of run) {
188+
byteStart = Math.min(byteStart, c.placement.box.start);
189+
byteEnd = Math.max(byteEnd, c.placement.box.start + c.placement.box.size);
190+
}
191+
rects.push({
192+
kind: "group",
193+
depth,
194+
from,
195+
to,
196+
byteStart,
197+
byteEnd,
198+
x: (from - view.start) / span,
199+
w: (to - from) / span,
200+
box: null,
201+
// Below the top level every member descends from the same top-level box already.
202+
family: inherited ?? dominantFamily(run),
203+
count: run.reduce((n, c) => n + countAtoms(c.placement.box), 0),
204+
});
205+
};
206+
207+
const walk = (siblings: Placement[], depth: number, inherited: string | null): void => {
208+
const visible = clipToView(siblings, view);
209+
const floor = depth === 0 && visible.length <= MAX_UNCOLLAPSED_TOP_LEVEL ? 0 : minWidth;
210+
let i = 0;
211+
while (i < visible.length) {
212+
if (rects.length >= MAX_RECTS) {
213+
truncated = true;
214+
return;
215+
}
216+
const current = visible[i];
217+
const width = (current.to - current.from) / span;
218+
if (width < floor) {
219+
// A run of neighbours that are each too narrow to draw shares one block, and the run is cut
220+
// as soon as it is itself wide enough to draw. Without that cut, a file whose every atom is
221+
// too narrow — a long fragmented recording, where all of them are — would merge into one
222+
// block spanning the whole map, saying only how many boxes are somewhere inside it. Cut
223+
// this way it striates instead: a run of blocks whose density is the fragmentation.
224+
let end = i;
225+
while (end < visible.length && (visible[end].to - visible[end].from) / span < floor) {
226+
end++;
227+
if ((visible[end - 1].to - visible[i].from) / span >= floor) break;
228+
}
229+
pushGroup(depth, visible.slice(i, end), inherited);
230+
i = end;
231+
continue;
232+
}
233+
laneCount = Math.max(laneCount, depth + 1);
234+
const box = current.placement.box;
235+
// Top-level atoms name their own family; everything below inherits the one it descends from.
236+
const family = inherited ?? box.type;
237+
rects.push({
238+
kind: "box",
239+
depth,
240+
from: current.placement.from,
241+
to: current.placement.to,
242+
byteStart: box.start,
243+
byteEnd: box.start + box.size,
244+
x: (current.from - view.start) / span,
245+
w: width,
246+
box,
247+
family,
248+
count: 1,
249+
});
250+
walk(current.placement.children, depth + 1, family);
251+
i++;
252+
}
253+
};
254+
255+
walk(placements, 0, null);
256+
return { rects, laneCount, truncated };
257+
}

0 commit comments

Comments
 (0)