Status: alpha. The format layer is well tested; the desktop UI is usable but still gaining features. Expect API changes before 1.0.
An edit-list and conform toolkit for film/TV sound post-production: read and write EDLs, inspect and export AAF, plan a re-conform against a new picture cut, and assemble a timeline from location recordings.
Built from public format standards (SMPTE 12M, CMX 3600, AMWA AAF 2.0, EBU Tech 3285, [MS-CFB], SMF) and validated against production material.
- No runtime dependencies for the core. PDF import and binary AAF export are opt-in extras.
- Edit lists round-trip byte-stable across four dialects, including the
parts of real-world EDLs that textbooks omit: dissolve pairs, zero-length
anchors, CDL annotations,
REMblocks. - Any file can be identified from its content, not its extension.
- AAF-XML v1.1 is written in the AAF SDK's XML stored format and validated against its reference schema; binary AAF is written with a real AAF library, and the container is readable without one.
- Desktop UI with a dense, keyboard-friendly list view and a fully modular command layer.
- 482 tests, including validation against production edit lists and a third-party parser.
git clone https://github.com/hug0-l/conformkit
cd conformkit
pip install -e .
# optional extras
pip install -e ".[pdf]" # PDF script import
pip install -e ".[aaf]" # binary AAF export
pip install -e ".[cross]" # cross-validation against other parsers
pip install -e ".[dev]" # pytestPython 3.10+. The core installs nothing.
python -m pip install -e ".[aaf,pdf,dev]"
python -m pytest tests -qfixtures/ and work/ are gitignored, so tests that need production
material skip on a clean checkout. The public fixture corpus can be fetched
with bash scripts/fetch_fixtures.sh.
conformkit identify mystery_file # content-based identification
conformkit info show_r1.edl # summarise an edit list
conformkit convert in.edl out.aafx # EDL <-> AAFX
conformkit compare old.edl new.edl # diff two cuts
conformkit conform session.edl new.edl # plan a re-conform
conformkit assemble ./wavs --out a.edl # build from location WAVs
conformkit wavinfo take_01.wav # dump BWF metadata
conformkit ptx session.ptx --tracks 20 # inspect a DAW session
conformkit aafinfo cut.aaf --names 20 # inspect an AAF container
conformkit gui show_r1.edl # desktop windowAll commands take --rate and --encoding, and name the offending item on
failure (line 42: event 17 source duration 25 != record duration 24).
conformkit gui # or: conformkit-gui
+-------------------------------------------------------------------+
| File Edit List View Help |
+-------------------------------------------------------------------+
| Open List | Save List | Import▾ | Append▾ | Clear Events | |
| Display Options | Clean Events | Compare Lists | |
| WAV File List | Assemble WAVs | Update Data | PT Re-conform | |
| Export▾ | User Guide Frame Count [25 v] |
+-------------------------------------------------------------------+
| Sequence 01 (3 warnings) test_25.edl |
| Track [All v] Roll [ ] Search [ ] Clear Filters |
+-------------------------------------------------------------------+
| EvNo | + | RollName | Source In | Source Out | Destination In |...|
+-------------------------------------------------------------------+
| Events 13 Rolls 2 Frame Count 25 |
+-------------------------------------------------------------------+
The command set is declarative — the toolbar and menus reference command ids,
so adding an action is one method plus one registry line. See
docs/GUI.md.
from conformkit.edl import parse_edl, write_edl
from conformkit.timecode import rate_for
rate = rate_for("25")
edit_list = parse_edl(open("cut.edl").read(), rate)
print(len(edit_list), "events")
print(edit_list.events[0].rec_in, edit_list.events[0].clip_name)
open("out.edl", "w").write(write_edl(edit_list, "CMX3600"))from conformkit.compare import compare_lists
from conformkit.conform import check_reconformable, plan_conform
changes = compare_lists(old, new)
print(changes.counts()) # {'moved': 12, 'trimmed': 3}
problems = check_reconformable(session, new_cut)
if not problems:
plan = plan_conform(session, new_cut)
for op in plan.ops:
print(op.kind.value, op.delta, op.detail)from conformkit.midi import MidiSettings, write_midi
settings = MidiSettings(
text_cue_track=True,
text_cue_advance_seconds=2.0,
add_pull_down=True,
)
write_midi("cues.mid", edit_list, rate, settings)Type-1 SMF with the conductor track first and 960 ticks per quarter note. Markers, notes, punches, streamers and text cues each get their own track.
- Text cues are standard SMF text events (
FF 01) at each event's record-in position, not markers. They carry the clip name, prefixed with the three-digit event number whenmarker_name_indexis set. text_cue_advance_secondsmoves text cues earlier, clamped at tick 0. Only the text-cue track is affected; negative values are rejected.add_pull_downinterprets exported times at 1000/1001, so 24/30 fps material lands on the 23.976/29.97 grid. Every generated track uses that scale consistently. It is an export-time interpretation: the edit list is not modified.
from conformkit.aafwrite import write_binary_aaf
summary = write_binary_aaf("out.aaf", edit_list, rate, title="Episode 4")
print(summary) # {'events': 128, 'tracks': 6, 'size': 1421312, ...}from conformkit.ptx import identify, read_session
fmt = identify(open("session.ptx", "rb").read(), "session.ptx")
print(fmt.kind, fmt.pronom) # PTX fmt/1727
s = read_session("session.ptx")
print(s.session_name, s.sample_rate, s.block_count)
print(s.track_names[:5], s.bus_names[:5], s.plugins[:5])
for r in s.regions[:5]:
# sample_offset and length are validated against the real source file
print(f"{r.name[:40]:<40} src={r.source_index} "
f"{r.duration_seconds:7.1f}s at offset {r.sample_offset:,}")
# Where a region sits on the timeline. One region can be placed more than
# once (multichannel tracks), so these are records, not a Region field.
for p in s.playlist_placements[:5]:
print(f"{p.track_name:<24} {p.region_name[:32]:<32} "
f"start={p.timeline_start} {p.unit}")
for f in s.audio_files[:5]:
print(f"{f.name:<44} {f.length:>10,} samples "
f"@ {f.sample_rate} Hz / {f.channels} ch")Session writing is deliberately narrow: only same-length name replacement on a copy, verified by re-walking the block tree afterwards.
from conformkit.ptwrite import write_clone
report = write_clone("in.ptx", "out.ptx", {"Old Name": "New Name"})
print(report.summary()) # "5 renamed, verified"The layout below is established from the files and checked against real audio, not taken on trust. Two independent sessions are used: a public demo that ships its own WAVs, and a 34 MB production Atmos session.
Decryption. The first 0x14 bytes are plaintext. Byte 0x12 selects the
key schedule, byte 0x13 seeds it:
delta = the i in 0..255 with (i * mul) & 0xFF == xor_value
mul = 53, positive, for type 0x01
mul = 11, negative, for type 0x05
key[i] = (i * delta) & 0xFF
key index = off & 0xFF for type 0x01
= (off >> 12) & 0xFF for type 0x05
Blocks. Z (0x5A) at pos, u16 type at +1, u32 size at +3, u16 content
type at +7. size counts from the content-type field, so a block ends at
pos + 7 + size. Children sit inside the parent payload.
Audio files. Names come from content type 0x103A; the record with the
length is 0x1001 nested under 0x1003, in the same order.
0x1001 : +0 ct, +2 u32 sample rate, +6 u8 channels, +7 u8 bits,
+8 u64 length in samples
Names. 0x1014 is the audio track name list and 0x251A the MIDI track
name list, so both feed PtxSession.track_names. 0x1021 is the I/O channel
list and 0x2602 the I/O route list, so their names feed bus_names. The
legacy .pts files have no 0x251A; their tracks come from 0x1014 only.
Regions. Content type 0x2629 (or 0x1008) inside 0x262A (or 0x100B).
Name length is a u32 at payload+11, name bytes at payload+15. A packed
triple follows: field widths are the high nibbles of the three bytes at
base+1..+3, and the values are little-endian starting at base+5, in the
order sample_offset, length, start.
The endianness is not a style choice — it is provable. Reading the triple big-endian leaves only 22% of regions inside their source file; little-endian gives 100%. That assertion is in the test suite.
Timeline placement. The region records say what a region is; a separate map says where it is placed:
0x1054 full region->track map
0x1052 entry; track name is a u32-length string at +2
0x1050 one placement
0x104f region index u32 at +2
position u40 at +7
unit byte at +14: bit 6 clear = samples, set = ticks
This layout is established for little-endian PTX v10+ sessions. The u32 at
+2 indexes the region list in 0x2629 order: on the demo all 286 ids
resolve and the resolved names match the clips on the track. Sample positions
are plausible and checkable: every one is below the session's audio extent, and
for split regions from one source placed on one track, timeline order follows
source order in 97% of pairs and the exact source gap survives in 86% (demo
session). A region may appear more than once — for example once per channel of
a multichannel track — so placements are returned as PlaylistPlacement
records rather than a single Region.timeline_start.
Tick positions are the raw 40-bit value minus ZERO_TICKS (0xE8D4A51000).
Turning ticks into samples needs the session tempo map; that is not
implemented, so timeline_start stays None for those records and only
timeline_ticks is set. Do not read them as samples.
| Format | Read | Write | Notes |
|---|---|---|---|
| CMX 3600 EDL | yes | yes | plus File16 / File32 / File129 |
| AAF-XML v1.1 | yes | yes | AAF SDK XML stored format, schema-validated; not a DAW interchange format |
| Binary AAF (OLE2) | container + names | yes (via pyaaf2) | objects keyed by SMPTE UL |
FCP7 XML (xmeml) |
partial | no | sequence / clipitem / file |
| Pro Tools session (PTX/PTF/PTS) | identification, decryption, block tree, tracks, buses, regions, timeline placements, audio files, embedded XML | name replacement only | AES-free XOR with a derived 256-byte key |
| BWF / WAV | yes | no | fmt , data, bext, iXML |
| MIDI (SMF type 1) | no | yes | markers, cues, punches, streamers, text cues |
| PDF / text script | yes | n/a | PDF needs pypdf |
| Delimited text | yes | yes | tab and CSV |
conformkit/
├── timecode.py SMPTE timecode, drop-frame, all rates
├── model.py Event, EditList, TrackSet, Transition, Marker
├── edl.py EDL parse / serialise, four dialects
├── bwf.py WAV / BWF / iXML metadata
├── transforms.py column text transform pipeline
├── compare.py change list
├── match.py roll / filename matching
├── aaf.py AAF-XML v1.1 read/write, binary container identification
├── aafwrite.py binary AAF via pyaaf2 (optional)
├── cfb.py MS-CFB (OLE2) reader
├── conform.py re-conform planning
├── assembly.py timeline from location WAVs
├── script.py shooting-script import
├── midi.py SMF type 1 writer
├── update.py versioned change list
├── ptx.py session identification and metadata
├── ptdecrypt.py session body decryption
├── ptblocks.py session block tree
├── ptregions.py session audio files and regions
├── ptwrite.py safe same-length session edits
├── guimodel.py Tk-free view model
├── gui/ desktop UI (app, panels, commands, widgets, dialogs)
└── cli.py command line
Each module is a pure function library over plain dataclasses. Nothing touches the network, the registry, or the filesystem except the explicit loaders.
| Add | Where |
|---|---|
| a document format in the UI | conformkit/gui/panels.py — register_panel() |
| a user action | conformkit/gui/operations.py — one method, one registry row |
| a toolbar button or menu item | conformkit/gui/commands.py — TOOLBAR / MENUS |
| a dialog | conformkit/gui/dialogs.py |
| a CLI subcommand | conformkit/cli.py — build_parser() |
python -m pytest tests -q458 passed, 1 skipped
What the suite actually proves:
- Standard timecode vectors that can be checked independently of any product.
- Real-world EDL validation against production lists, including dissolve pairs, CDL/FRMC annotations, and generator output.
- Cross-validation against an independent parser: edit counts, source identifiers, timecodes, clip names and transitions must agree edit-for-edit.
- Round-trip stability: parse → write → parse is byte-identical.
- Negative tests: every malformed input produces a distinct error and no partial list.
- Container integrity: AAF output is a well-formed compound file, read back by our own independent CFB implementation.
- Ground-truth validation against real audio: a public demo session ships
its own WAVs, so every region's
sample_offset + lengthis checked against the actual file length, and every file length in the session is checked against the WAV header. Both must match exactly. - Endianness pinned by evidence: the same test asserts that reading the packed triple big-endian breaks the bound wholesale, so the little-endian choice is proven rather than assumed.
- Structural verification for decryption: a wrong key yields no block chain at all, so the test asserts that blocks tile the file without gaps.
- Transactional updates: a mid-way failure leaves the list untouched.
- Safe session writes: block count and file size unchanged, source byte-identical.
AAF-XML output can be checked against the AAF SDK's reference schema. The
validator downloads the schema at run time (it is not vendored) and needs
lxml; the test suite itself needs neither:
python scripts/validate_aafxml.py out.aafxFixtures from third parties are not vendored:
bash scripts/fetch_fixtures.shPrivate production fixtures live in fixtures/production/ and are gitignored;
tests using them skip automatically.
Measured on a 34 MB Dolby Atmos session (48 kHz, 118,950 blocks):
| Stage | Time |
|---|---|
| decrypt 34 MB | 2.3 s |
| parse block tree | 2.9 s |
| extract string table | 1.6 s |
| extract 3,491 XML documents | 1.2 s |
full read_session |
~7 s |
Everything is off the UI thread with progress reporting.
- Binary session writing is name replacement only; sessions are never synthesized.
- Binary AAF export needs
pyaaf2; AAF-XML is always available. The AAF-XML writer implements the AAF SDK's XML stored format (http://www.aafassociation.org/aafx/v1.1/20050628, root<AAF version="1.1">) and is checked against the SDK reference schema byscripts/validate_aafxml.py. It is not claimed to be importable by Pro Tools, Nuendo or Resolve. AAF-XML is the SDK's XML representation, not the interchange file those applications consume; binary AAF remains the interoperable route. - Transitions are not serialised into AAF-XML. AAF
Transitionobjects require operation definitions that are file extensions rather than baseline dictionary entries, so the writer does not fabricate them. Clips keep their record and source timing, andread_aafxreturns cuts. - FCP7 XML import is partial; export is not implemented.
- No DAW automation: re-conform produces a plan and a new list, and leaves applying it to the operator.
- Session region timing is decoded and validated: every region's
sample_offset + lengthis checked against the real source-file length, and holds for 100% of records in both test sessions.start(the third packed value) is reported but its meaning is not established, so nothing uses it. Timeline placement is decoded separately from the0x1054map; sample-unit positions are validated against the session extent and source order, while tick-unit positions are reported raw without a tempo-map conversion. - Session writing is name replacement only; sessions are never synthesized.
MIT. See LICENSE.
This project interoperates with file formats. It contains no copy-protection circumvention and implements no vendor's licensing, activation or DRM.
Third-party credit: the session decryption scheme and block layout were established by libptformat (LGPL-2.1+). This is an independent Python re-derivation, not a port of that code.