Read-only inspection tool for AAF (Advanced Authoring Format) files. Exposes both layers of an AAF — the raw CFB (Microsoft Compound File Binary) storage tree and the AAF object graph (Mobs, Slots, Components, Descriptors, WeakRefs into the dictionary, etc.) — so a developer can answer questions like "is this metadata actually present in the file or am I being lied to by some intermediate tool?"
Download the latest AAF-Browser-vX.Y.Z-arm64.dmg from the
GitHub Releases
page, mount it, and drag AAF Browser to /Applications. Launch
from Spotlight or the Applications folder. The app opens in a real
macOS window — title bar, dock icon, native menu bar with Cmd-Q to
quit. No browser tab.
Apple Silicon (M1+) only. Files are never modified — the app uses the same read-only pyaaf2 path as the CLI. The window's content is the same web GUI that pip-installed users see; the difference is that it renders inside an embedded WKWebView instead of in Safari/Chrome.
The .dmg is signed + notarized + stapled, so the first launch proceeds without a Gatekeeper warning.
For the command-line tools (aafbrowser tree, dump, cfb,
inspect, find, walk, web):
python -m venv .venv
source .venv/bin/activate
pip install -e .This registers the aafbrowser console script.
Every command opens the input AAF with aaf2.open(path, 'r') and refuses to
proceed if pyaaf2 returns a writeable handle. The test suite hashes the
input before and after every command and asserts equality.
Indented tree of the AAF object graph rooted at f.content. Strong-ref
children recurse, WeakRefs render as WeakRef -> ClassName "name" (no
recursion into the dictionary).
aafbrowser tree session.aafFull structural dump of both layers (AAF object graph + CFB tree).
# Human form, both layers
aafbrowser dump session.aaf
# Machine-readable JSON (round-trip safe — every typed value carries _type)
aafbrowser dump session.aaf --json > session.json
# Skip a layer
aafbrowser dump session.aaf --object-only
aafbrowser dump session.aaf --cfb-only
# Include full base64 of binary stream payloads (default truncates to 1 KB)
aafbrowser dump session.aaf --json --full-bytes > full.jsonJSON shape:
{
"file": "/abs/path/to/session.aaf",
"sha256": "<hex of input>",
"aaf": { "_type": "aaf_object", ... },
"cfb": { "_type": "cfb_storage", ... }
}Raw CFB storage tree. The MetaDictionary-1 subtree is filtered by default
(it contains thousands of schema entries that drown out anything
interesting); a single summary line records its size.
# Default — readable
aafbrowser cfb session.aaf
# Include the full MetaDictionary subtree
aafbrowser cfb session.aaf --include-metadict
# Hex view of a single stream (lazy — only reads the requested range)
aafbrowser cfb session.aaf --show-bytes "/MasterMob-1234/properties" \
--offset 0 --length 256
# JSON tree
aafbrowser cfb session.aaf --json > cfb.jsonDump a single object with one level of reference resolution. Cross-references to dictionary entries display the target's class and name without recursing further.
# By MobID — accepts URN, dotted-hex, or plain-hex
aafbrowser inspect session.aaf --mob-id "urn:smpte:umid:060a2b34..."
aafbrowser inspect session.aaf --mob-id "060a2b340101..."
# By property path from f.content
aafbrowser inspect session.aaf --path "Mobs/<mob-id>/Slots/0/Segment"
# JSON
aafbrowser inspect session.aaf --mob-id <id> --jsonStarts a local browser-based GUI on 127.0.0.1:5173 (override with
--host and --port). If a file path is given, the server opens it
before serving so the page lands on the file already loaded.
# Open a file and launch the GUI in the system browser
aafbrowser web /path/to/session.aaf
# Custom host/port, no auto-open
aafbrowser web --host 0.0.0.0 --port 8080 --no-browser session.aafWhat the GUI does:
- Tracks is the default landing tab — a post-production-sound-operator
view of the file. Three columns: ordered list of audio + video tracks
on the topmost CompositionMob (left); clips on the selected track,
each labeled with its recovered recorder/mic identity from the chain
walk (center); shared inspector with operator-summary header on top
of the existing object dump (right). Each clip row shows timeline
position, length, the source MasterMob, and — when present — the
named recorder SourceMob (e.g.
PW_310_ISO1_B). Clip rows expand to reveal the underlying AAF object structure. - All Mobs and CFB tabs sit alongside Tracks for the geek-view paths: a flat Mob list grouped by class with name filter (All Mobs), and the raw CFB storage tree (CFB). The inspector is shared across all four views — selecting something in Tracks and switching to another tab leaves the inspector showing what you last clicked.
- Sources tab is the cross-track pull list: a deduplicated roster of every recorder source mob in the file, sorted most-used-first, with use-count, format (sample rate · bit depth · channels), and online/offline indicator dots when Locator URLs are present. Selecting a source shows a "Used by" panel that jumps directly to the corresponding clip in the Tracks view. First load takes a few seconds (the server walks every clip to compute use counts); subsequent visits are instant.
- Each clip row in the Tracks view also surfaces, in addition to the recovered mic identity, its head/tail handle (frames + seconds), per-clip audio format, and an offline indicator when its source file path doesn't exist on disk. Multi-input combiner clips (e.g. an Avid mix-down of two mics) expand into one sub-clip per input, each with its own recovered identity.
- Format-aware recovery: the inspector exposes a
recovery_statusper clip — recoverable for normal Avid chain-walks, Premiere stereo splits (Mono Audio Pan) and combiners whose inputs all resolve; ambiguous for partial-information cases; unrecoverable for Premiere multichannel polywav imports (channel index destroyed at import) and broken/non-source terminals. Unrecoverable clips get a faint red border-left in the tree so the operator sees at a glance that the mic name shouldn't be trusted. Premiere stereo-split tracks get anL/Rpill next to the track name. - Click a MobID badge anywhere in the inspector to jump to that Mob in the list (works across StrongRefVector members and scalar MobID values).
- Click a WeakRef to expand it in place — dictionary entries aren't surfaced as Mobs, so they show their identifying triple (target_class / target_name / target_auid) without recursing.
- CFB streams switch the inspector to a hex viewer with offset/length
controls. Storage rows show the metadict-decoded class name (e.g.
[MasterMob]) where pyaaf2 has it registered. - Search box at the top of the page runs a regex against names, values, or both, on the AAF graph, the CFB tree, or both. Results render in a collapsible bottom panel; clicking a row navigates to the Mob (AAF layer) or expands the CFB ancestors and selects the entry (CFB layer).
- Open uses a native file picker. In the bundled .app it's a real
NSOpenPanelvia pywebview's JS bridge (with.aaffiltering); inaafbrowser webfrom a pip install it's anosascript-driven picker. Non-macOS platforms fall back to a paste-the-path dialog. - Quit link in the topbar stops the local server cleanly. In a
browser tab, closing the tab also stops it (via
navigator.sendBeacon). In the bundled .app, closing the window or Cmd-Q from the menu bar shuts everything down. - The window/tab title shows the open file's basename
(
AAF Browser — PWD_310_LC_10-07-2025.aaf).
The GUI is a thin frontend over the same aafbrowser.core library the
CLI uses. Read-only invariants are enforced server-side; pyaaf2 access
is serialized through a single threading.Lock. The bundled .app
ships an embedded WKWebView via pywebview, so the window is native
even though the content is the same Flask + JS frontend.
The bundled .app declares a .aaf file association via
CFBundleDocumentTypes. Right-click an .aaf in Finder → Open With
→ AAF Browser preloads the file when the app starts cold. Once the
app is already running, double-clicking another .aaf in Finder won't
preload it (would require an in-process AppKit handler not present
in v1) — use the in-app Open button instead.
Regex search across the AAF object graph and/or CFB tree. Each match prints the full property path, classname, field, and a truncated value.
# Find anything that mentions "channel" — case-insensitive
aafbrowser find session.aaf --pattern '(?i)channel'
# Limit to property names (skip stringified scalar values)
aafbrowser find session.aaf --pattern 'MobID' --in names
# Limit to the CFB layer (storage / stream names + class_ids)
aafbrowser find session.aaf --pattern 'MasterMob' --layer cfb
# Restrict the AAF-layer walk to one or more Mob classes (repeatable)
aafbrowser find session.aaf --pattern KEKE --class CompositionMob
aafbrowser find session.aaf --pattern host --class CompositionMob --class MasterMob
# JSON output (one match per line)
aafbrowser find session.aaf --pattern '(?i)channel' --jsonThe --class filter materially speeds up search on large sessions: on a
4,648-Mob iso AAF, --class CompositionMob cuts a 7 s search to 3.5 s
(50% off); --class MasterMob cuts it to 1.7 s (76% off).
This is the validation command for questions like "does this AAF contain any property or stream that distinguishes physical recorder channels?". Example pattern targeting iso-recorder identity:
aafbrowser find samples/password.aaf \
--pattern '(?i)channel|chan_?id|physicaltrack|isolat|cam(era)?_?\d|mic_?\d'Walk the SourceClip chain hop-by-hop, returning the per-hop
(mob_class, mob_name, slot_id, segment_class, physical_track_number, edit_rate, terminal, terminal_reason) tuple. The deepest-named-mob in
the chain is typically the original capture (the WAV file, the camera
clip), which answers the iso-channel question for any clip.
# From a Mob (slot defaults to the first; override with --slot)
aafbrowser walk session.aaf --mob-id "urn:smpte:umid:..."
aafbrowser walk session.aaf --mob-id "urn:smpte:umid:..." --slot 2
# From a property path (e.g. land directly on a SourceClip)
aafbrowser walk session.aaf --path "Mobs/<urn>/Slots/0/Segment"
# Bound the walk depth (default 64; clipped chains terminate
# with reason "max_hops_reached")
aafbrowser walk session.aaf --mob-id "..." --max-hops 4
# Machine-readable
aafbrowser walk session.aaf --mob-id "..." --jsonSample output (3-hop chain — MasterMob → SourceMob → SourceMob):
[0] MasterMob 'PW VO 310 B Round 1.wav.new.05' slot=1 segment=SourceClip edit=30000/1001
[1] SourceMob slot=1 segment=SourceClip edit=30000/1001
* [2] SourceMob 'PW VO 310 B Round 1.wav' slot=1 segment=SourceClip edit=30000/1001 -- terminal: no_source_id
Terminal reasons cover every non-chainable shape: essence,
no_source_id, broken_ref, cycle, filler, timecode,
essence_group, pulldown, operation_group,
multi_segment_sequence, empty_sequence, max_hops_reached,
invalid_slot, non_clip_segment:<class>. The walker is cycle-safe
(visited (mob_id, slot_id) set).
Headline summary of the file: composition, track + clip + mob counts,
audio specs, timecode, authoring (with detected NLE kind), duration.
Mirrors the GUI's session bar; --json matches /api/session.
aafbrowser session session.aaf
aafbrowser session session.aaf --json | jq .session.timecodeAudio + video tracks on the topmost CompositionMob, with PT-style
A1/V1 ordinals and clip counts. Premiere stereo-split tracks show
[L]/[R] next to the name.
aafbrowser tracks session.aaf
aafbrowser tracks session.aaf --jsonClips on a single track: timeline timecode, length, recovered mic identity from the chain-walk, recovery_status (format-aware classification), and sub-clip fan-out for multi-input combiners.
aafbrowser clips session.aaf --slot 3
aafbrowser clips session.aaf --slot 3 --json | jq '.clips[] | select(.recovery_status == "unrecoverable")'Cross-track deduplicated source-mob pull list with per-mob use
counts. Defaults to "used" sources (use_count > 0); --unused
includes everything in the file. Mirrors the GUI's Sources tab.
aafbrowser sources session.aaf
aafbrowser sources session.aaf --json | jq '.sources[] | {name, use_count}'Every JSON output preserves type information via a _type discriminator so
that values which don't round-trip cleanly through plain JSON (datetimes,
rationals, AUIDs, MobIDs, bytes) can be reconstructed by a consumer:
{ "_type": "rational", "num": 48000, "den": 1001, "value": 47.952 }
{ "_type": "datetime", "value": "2026-04-27T12:34:56" }
{ "_type": "auid", "value": "01030202-0200-0000-060e-2b3404010101" }
{ "_type": "mobid", "value": "urn:smpte:umid:060a2b34..." }
{ "_type": "bytes", "length": 4096, "base64": "...", "truncated": false }
{ "_type": "weakref", "target_class": "DataDef",
"target_name": "DataDef_Sound",
"target_auid": "..." }pip install -e '.[test]'
pytestThe test suite covers the serializer, the AAF walker (with cycle detection), the CFB walker (with default MetaDictionary filtering and run collapsing), the resolver (URN/hex MobIDs, slash-separated paths), every CLI command, and the read-only invariant.
aafbrowser/
├── core/ # Pure library — CLI and web both consume this
│ ├── cfb.py # CFB walker (uses pyaaf2's f.cfb, NOT olefile)
│ ├── aaf.py # AAF object graph walker with cycle detection
│ ├── serialize.py # Property values -> JSON-friendly with type tags
│ ├── resolver.py # MobID / path / regex search (with mob_class filter)
│ └── chain.py # Multi-hop SourceClip chain walker
├── cli/ # Click-based CLI (`aafbrowser` console script)
└── web/ # Flask app + vanilla JS GUI
├── app.py # Routes under /api
├── state.py # Process-global open file + threading.Lock
└── static/ # index.html, styles.css, app.js
aafbrowser.core is pure: it has no dependency on Click, Flask, or terminal
formatting. CLI and web are thin adapters.