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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "encoding-helper",
"version": "0.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",
Expand Down
257 changes: 257 additions & 0 deletions src/lib/atomLayout.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();
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 };
}
Loading
Loading