<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Vikram Bhaskaran on Medium]]></title>
        <description><![CDATA[Stories by Vikram Bhaskaran on Medium]]></description>
        <link>https://medium.com/@vbasky?source=rss-5d48085a669c------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*756cNZoU0WyC5emZCILoag.png</url>
            <title>Stories by Vikram Bhaskaran on Medium</title>
            <link>https://medium.com/@vbasky?source=rss-5d48085a669c------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 04:13:40 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@vbasky/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Packaging the World’s Video in Pure Rust]]></title>
            <link>https://medium.com/@vbasky/packaging-the-worlds-video-in-pure-rust-ff1f6b884fec?source=rss-5d48085a669c------2</link>
            <guid isPermaLink="false">https://medium.com/p/ff1f6b884fec</guid>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[videos]]></category>
            <category><![CDATA[streaming]]></category>
            <dc:creator><![CDATA[Vikram Bhaskaran]]></dc:creator>
            <pubDate>Fri, 03 Jul 2026 08:14:53 GMT</pubDate>
            <atom:updated>2026-07-03T08:14:53.270Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nAR24JbgodxXZUGNzXADkA.png" /></figure><h3>Building sheathe, an HLS/DASH/CMAF media packager, validated byte-for-byte against Shaka Packager</h3><p>Every video you stream on YouTube, Netflix, or an OTT sports app arrives in pieces. A two-hour movie is not one file served in order — it is a manifest listing hundreds of small segments, each containing a few seconds of video and audio, joined by a description that tells the player how to stitch them back together. The tool that makes those pieces is called a <em>packager</em>, and production streaming has relied on exactly one open-source implementation for a decade: Google’s Shaka Packager.</p><p>This is the story of building a second one — in Rust, with no C or C++ anywhere in the dependency tree, validated against the original byte-for-byte.</p><h3>What a Packager Actually Does</h3><p>A packager takes a finished media file — say, a 1080p MP4 with H.264 video and AAC audio — and produces what the web needs to stream it:</p><ul><li><strong>CMAF segments.</strong> Common Media Application Format fragments, the ISO-BMFF-based chunks that every modern player understands. Each segment is a self-contained fMP4 file with an initialization header plus a sequence of moof+mdat fragments covering ~6 seconds.</li><li><strong>A DASH manifest.</strong> An MPD (Media Presentation Description) XML document that lists every segment URL, its duration, its resolution, and its codec. A player reads the MPD, figures out which rendition matches the current network, and fetches segments.</li><li><strong>An HLS playlist.</strong> A master .m3u8 listing available renditions plus per-track media playlists, with the same segment references. Older players and Apple devices use this path.</li></ul><p>If the content is encrypted — and in production it always is — the packager also applies Common Encryption (CENC), embeds the DRM signalling, and writes the per-sample encryption metadata that a player’s Content Decryption Module needs to decrypt each frame.</p><p>That is the job. The reference implementation — Shaka Packager — does it in C++ with ~250,000 lines of code, covering a dozen input formats, four encryption schemes, live streaming, trick-play, and HLS packed-audio output. The goal of sheathe is functional parity on the subset that production VOD (Video on Demand) actually uses, done in pure Rust and differential-tested against Shaka at every step.</p><h3>Why Build Another Packager?</h3><p>There are excellent Rust projects that parse DASH manifests and HLS playlists. There was no mature Rust project that <em>produces</em> segments and manifests from video files — the write side is the hard side, and the ecosystem had a gap in the Delivery lane: probe the source, build the ladder, segment into CMAF, write the manifests.</p><p>sheathe fills that gap. It also fills a subtler one: the standard packager is a native binary written in C++, carrying all the memory-safety risk of parsing untrusted media containers at scale. A packager that does the same work in unsafe-free Rust turns a class of buffer-overflow CVEs into compile-time impossibilities. That matters the moment you point it at a file you didn&#39;t create.</p><h3>Architecture: Nine Crates, Four Layers</h3><p>sheathe is nine crates arranged in four layers, each crate with one responsibility:</p><pre>Foundation   sheathe-core      Media model: streams, samples, timing, errors<br>             sheathe-mp4        ISO-BMFF box reader/writer, CMAF fragments<br>             sheathe-crypto     CENC encryption (all four schemes + DRM pssh)<br>             sheathe-ts         MPEG-2 transport stream demux (PAT/PMT/PES)<br>             sheathe-es         Raw elementary stream demux (Annex B, ADTS)</pre><pre>Pipeline     sheathe-dash       DASH MPD generation<br>             sheathe-hls        HLS master + media playlist generation</pre><pre>Application  sheathe-cli        The `sheathe` binary<br>             sheathe            CLI crate (thin wrapper over sheathe-cli)</pre><p>The dependency graph points strictly down. A manifest crate composes foundation crates but never reaches sideways into another manifest crate. This is where Rust’s crate boundary earns its keep: the MP4 writer cannot accidentally call into the DASH MPD generator, because the compiler won’t let it. Nine shallow crates, each small enough to hold in your head and test in isolation.</p><p>The whole workspace is ~6,000 lines of Rust across 41 source files, with 34 tests that verify everything from AES-128-CTR NIST vectors to AVC codec-string derivation — and an integration harness that diffs output against Shaka Packager itself.</p><h3>The Read Side: Demuxing MP4, MPEG-TS, and Elementary Streams</h3><p>Before sheathe can write CMAF segments, it has to read the source. Three input paths are wired:</p><p><strong>MP4 demux (</strong><strong>sheathe-mp4).</strong> A dependency-free, cursor-based box reader traverses the ISO-BMFF hierarchy: moov → trak → mdia → minf → stbl. From the sample table (stts/ctts/stsc/stsz/stco/stss) it reconstructs every sample&#39;s decode time, composition offset, size, offset, and keyframe flag — the exact information the fragmenter needs to slice the stream into segments. The box reader is a simple state machine over a &amp;[u8] reference; there is no allocation beyond the BoxIter stack, and no unsafe.</p><pre>// Top-level box traversal: find every box at the current nesting level,<br>// yielding (type, body, next_offset) for each.<br>pub fn top_level(data: &amp;[u8]) -&gt; impl Iterator&lt;Item = std::io::Result&lt;Mp4Box&lt;&#39;_&gt;&gt;&gt; {<br>    // ... purely a Cursor over the byte slice<br>}</pre><p><strong>MPEG-TS demux (</strong><strong>sheathe-ts).</strong> Transport streams are a different beast entirely — 188-byte packets carrying program-specific information (PAT/PMT) and packetized elementary stream (PES) payloads. sheathe-ts parses PAT to find the program, PMT to find the streams, PES headers to reassemble access units, and Annex B / ADTS parsers to extract H.264, HEVC, and AAC samples. It synthesizes avcC/hvcC/mp4a sample entries from the elementary stream headers, so the output segment&#39;s moov carries the decoder configuration the player needs — all without an external probe tool.</p><p><strong>Elementary stream demux (</strong><strong>sheathe-es).</strong> For raw .h264, .hevc, and .aac files, sheathe detects the format from the extension and content signature, splits the stream into access units (via Annex B start-code scanning for video, ADTS frame sync for audio), and feeds them to the same fragmenter. No container, no problem.</p><h3>The Write Side: CMAF Segments from a Stream of Samples</h3><p>Once the demuxer has produced a stream of Sample structs (data, decode timestamp, composition offset, duration, keyframe flag), the fragmenter decides where to cut. The policy is the same one Shaka uses: wait until the accumulated samples exceed the target segment duration (default 6 seconds), then cut at the next keyframe — because a segment that starts on a non-keyframe cannot be decoded independently, and DASH/HLS switching depends on clean boundaries.</p><pre>pub fn push(&amp;mut self, sample: Sample) -&gt; Result&lt;()&gt; {<br>    let elapsed = sample.dts.saturating_sub(self.current_start);<br>    let may_cut = !self.policy.keyframes_only || sample.is_segment_boundary();<br>    if !self.current.is_empty() &amp;&amp; may_cut &amp;&amp; elapsed &gt;= self.target_ticks {<br>        self.cut(sample.dts);<br>    }<br>    if self.current.is_empty() {<br>        self.current_start = sample.dts;<br>    }<br>    self.current.push(sample);<br>    Ok(())<br>}</pre><p>Each segment is written as an fMP4 file: a styp box (segment type), a sidx box (segment index for seeking), a moof box (movie fragment — the sample table for this segment), and an mdat box (the actual sample data). The init segment carries ftyp + moov + mvex, describing the codec configuration once, at the start.</p><p>The box writer is a 75-line nestable builder with automatic size backpatching — no pre-computed table of contents. begin(&quot;moof&quot;), write child boxes, end() — and the 32-bit size in the header is patched to the correct value. This is the same pattern Shaka uses; sheathe just does it in 1/10th the code.</p><h3>The Hard Part: Common Encryption Done Correctly</h3><p>Encryption is where most packagers get complex. CENC (ISO/IEC 23001–7) defines four protection schemes, and sheathe implements all of them:</p><p>SchemeCipherModeVideo PatternAudiocencAES-128-CTRFull-regionNAL-aware subsamplesWhole-samplecensAES-128-CTRPattern (1:9)Crypt 1 block, skip 9Whole-samplecbc1AES-128-CBCFull-regionPer-sample IV, block-alignedWhole-samplecbcsAES-128-CBCPattern (1:9)Crypt 1, skip 9, constant IVWhole-sample</p><p>The sheathe-crypto crate is a 457-line module built on the RustCrypto aes crate. The AES-128-CTR path runs a continuous keystream across protected byte ranges, skipping clear bytes and pattern-skipped blocks without advancing the counter. The AES-128-CBC path chains ciphertext blocks, resetting to the constant IV at each subsample boundary under pattern encryption. Both are validated against NIST SP 800-38A test vectors, and against Shaka Packager&#39;s output on real media.</p><p>Every scheme is ffmpeg decrypt+decode verified. That is: sheathe encrypts a sample, produces an fMP4 segment, and then ffmpeg -decryption_key ... -i segment.mp4 -f framemd5 confirms every video frame and every audio frame decodes to the same MD5 as the clear original. No &quot;probably encrypted&quot; — provably correct.</p><h3>Multi-DRM pssh</h3><p>Encrypting the bytes is half the job; the other half is telling the player’s DRM module <em>how</em> to decrypt them. Each DRM system gets a pssh box in the init segment, generated directly from the raw key (no key server required):</p><ul><li><strong>Common</strong> — a version-1 box listing the KID, used by W3C clear-key and urn:mpeg:dash:mp4protection.</li><li><strong>Widevine</strong> — a version-0 box carrying a WidevinePsshData protobuf with the KID and protection-scheme fourcc.</li><li><strong>PlayReady</strong> — a version-0 box wrapping a PlayReady Object with a WRMHEADER 4.0.0.0 (KID, key length, ALGID, and an AES-ECB checksum proving key possession).</li></ul><p>Each box byte-matches Shaka Packager’s --protection_systems output. Not approximately — identically.</p><h3>Key Rotation</h3><p>For long content, rotating keys reduces the exposure window of any single key. sheathe supports --crypto-period-duration &lt;seconds&gt;: each period derives a new content key by left-rotating the base key (Shaka&#39;s naive raw-key scheme), signalled per segment via seig sample groups (sbgp/sgpd), a zero-KID init tenc, and a per-period pssh in each moof. Every segment decrypts to the clear baseline under its derived key.</p><h3>Manifest Generation: DASH and HLS</h3><p>With segments written, the packager generates the manifests that players consume.</p><p><strong>DASH.</strong> The MPD is an XML document in the static (type=&quot;static&quot;), on-demand profile, using SegmentTemplate + SegmentTimeline — the same profile Shaka produces for VOD. Each representation gets a &lt;Representation&gt; element with its codec string (RFC 6381), resolution, bandwidth, and a &lt;SegmentTimeline&gt; that lists each segment&#39;s exact duration. Equal-duration runs are collapsed with r= (repeat count), so a 2-hour movie doesn&#39;t produce 1,200 &lt;S&gt; elements.</p><p>The codec strings themselves are derived from raw box data — avcC → avc1.640028, hvcC → hvc1.1.6.L93.90, av1C → av01.0.04M.08, esds → mp4a.40.2 — in a pure-Rust, zero-dependency codec-string module with tests pinned against known outputs.</p><pre>// avc1.PPCCLL from avcC<br>fn avc_string(fourcc: &amp;[u8; 4], avcc: &amp;[u8]) -&gt; Option&lt;String&gt; {<br>    let (profile, compat, level) = (*avcc.get(1)?, *avcc.get(2)?, *avcc.get(3)?);<br>    let prefix = std::str::from_utf8(fourcc).ok()?;<br>    Some(format!(&quot;{prefix}.{profile:02x}{compat:02x}{level:02x}&quot;))<br>}</pre><p><strong>HLS.</strong> The master playlist lists video variants as #EXT-X-STREAM-INF entries with combined bandwidth and codecs (video + audio folded together, per the spec), bound to an #EXT-X-MEDIA audio rendition group. Media playlists carry #EXTINF per-segment durations, #EXT-X-MAP pointing to the init segment, and #EXT-X-KEY encryption signalling (SAMPLE-AES for cbcs, SAMPLE-AES-CTR for cenc) — the full HLS encrypted-fMP4 contract.</p><h3>The Oracle: Differential Testing Against Shaka Packager</h3><p>sheathe adopts the “revelo method”: implement in pure Rust, then differential-test against a reference oracle. For a packager, the oracle is Shaka Packager running the same input file through the same output configuration.</p><p>The oracle harness (just oracle input.mp4) runs both packagers on the same source, then diffs:</p><ol><li><strong>Segment counts.</strong> Do sheathe and Shaka produce the same number of media segments?</li><li><strong>Canonical MPD.</strong> XML canonicalization (xmllint --c14n) strips formatting to compare structure — same representations, same timelines, same codec strings.</li><li><strong>ffmpeg decode.</strong> Both outputs are fed through ffmpeg -f framemd5 to confirm the video renders identically.</li></ol><p>This is the same methodology that keeps revelo’s 180+ parsers byte-identical to MediaInfoLib and viser’s convex hulls correct to the numerical method. Nothing ships until its bytes are verified against the oracle. If Shaka and sheathe disagree, the bug is in sheathe until proven otherwise.</p><h3>What Makes This Genuinely Hard</h3><p>Rust makes it safe; the domain makes it hard. The genuinely difficult parts of building a packager are worth naming:</p><p><strong>NAL-aware subsample encryption is format-specific.</strong> AVC and HEVC samples cannot be encrypted blindly — the NAL unit headers and slice headers must stay clear for a player to parse the stream. The senc box carries a table of (clear_bytes, encrypted_bytes) per sample, and getting the boundary exactly right requires knowing the NAL unit length size from the avcC/hvcC configuration. Get it wrong, and the player produces a green screen or a crash.</p><p><strong>The full CENC scheme matrix has subtle interactions.</strong> cbcs uses a constant IV across all samples; the other three derive per-sample IVs. cbc1 chains CBC across subsamples; cbcs resets to the constant IV at each subsample. Pattern encryption applies to video only — audio under cens/cbcs is whole-sample encrypted with Pattern::NONE. DASH-IF conformance requires saiz/saio auxiliary-information boxes with the saio offset backpatched to point at the senc data. Every one of these interactions earned its own differential test.</p><p><strong>PlayReady pssh is deeply non-obvious.</strong> The PlayReady Object wraps a WRMHEADER with a KID that uses swapped GUID byte order (the first three fields of the UUID are written little-endian, not network order), and an AES-ECB checksum of the object. Building this from the raw key requires implementing a subset of the PlayReady binary format that no RFC describes — the oracle is the only spec.</p><p><strong>The box model rewards getting it right the first time.</strong> ISO-BMFF boxes nest arbitrarily: moov contains trak, which contains mdia, which contains minf, which contains stbl, which contains stsd, which contains the actual sample entry with its decoder configuration. A missing box, a wrong offset, or an incorrect size computation produces a file that looks valid to a file browser and silently fails to play. The reader and writer together form a round-trip that is verified on every test asset.</p><h3>The Result</h3><p>sheathe is a pure-Rust HLS/DASH/CMAF media packager at ~6,000 lines, validated against Shaka Packager as the reference oracle. It demuxes MP4, MPEG-TS, and raw elementary streams; writes CMAF init and media segments with correct box structure and codec strings; applies all four CENC encryption schemes with multi-DRM pssh (Widevine, PlayReady, Common) and key rotation; and emits DASH MPDs and HLS playlists that match Shaka’s output.</p><pre># Package an MP4 into 6s CMAF segments with both DASH and HLS manifests.<br>sheathe package input.mp4 --out site/ --segment-duration 6 --dash --hls</pre><pre># Encrypt with CENC cbcs (AES-CBC pattern) and Widevine+PlayReady DRM.<br>sheathe package input.mp4 --out site/ --dash --hls \<br>  --enc-scheme cbcs \<br>  --enc-key abcd1234...:deadbeef... \<br>  --protection-systems common,widevine,playready</pre><pre># Probe an MPEG-TS file — no ffprobe required.<br>sheathe probe input.ts</pre><pre># Install the binary from crates.io.<br>cargo install sheathe</pre><p>sheathe doesn’t yet do live streaming, trick-play, or WebM input. Those are tracked on the roadmap, each gated behind an oracle validation checkpoint. What ships today is the complete VOD pipeline — the subset that covers ~90% of production packaging work — built in unsafe-free Rust, with no C dependencies, and every output differential-tested against the best open-source packager available.</p><h3>One More Thing: The Pure-Rust Probe</h3><p>A packager needs to know what it’s packaging. Resolution, frame rate, codec, bitrate — the numbers that determine how segments get sized and manifests get filled. The default way to get those numbers is to shell out to ffprobe.</p><p>sheathe doesn’t need to. Its MP4 demuxer reads sample-table metadata directly from the box hierarchy; its MPEG-TS demuxer reconstructs stream descriptions from PAT/PMT/PES headers; its elementary-stream path synthesizes sample entries from the Annex B/ADTS byte stream itself. The sheathe probe command returns the same information ffprobe does, with no external binary on the path.</p><p>For the full metadata picture — the kind that covers 177+ media formats with byte-identical output to MediaInfoLib — there’s <a href="https://github.com/vbasky/revelo">revelo</a>, another pure-Rust project that integrates as a drop-in probe engine. But for the subset a packager actually needs, sheathe is self-contained: the tool that segments video is also the tool that reads it. One binary, one dependency chain, one cargo install.</p><h3>What I’d Want You to Take From This</h3><p>If there’s a lesson worth carrying out of this project, it’s simpler than the topic implies:</p><ol><li><strong>Choose an oracle you can diff against.</strong> Shaka Packager is open source, battle-tested on billions of streams, and emits deterministic output. Every feature sheathe ships was validated by running both tools on the same input and comparing the result. Byte-identical output is the only claim worth making.</li><li><strong>The crate boundary is a design tool.</strong> Nine crates, each with exactly one concern, separated by the compiler. The MP4 writer cannot reach into the DASH generator; the crypto crate knows nothing about the container format. Wide and flat, not deep and tangled.</li><li><strong>The hardest bugs are interactions, not algorithms.</strong> AES-CTR is 40 lines. Getting the saiz offset to backpatch correctly after the senc is written — that&#39;s where the hours go. The differential harness catches these; no amount of code review does.</li><li><strong>Packaging is infrastructure, not magic.</strong> A packager is a program that reads video, cuts it into pieces, and writes a description of the pieces. Doing it correctly is a lot of work; doing it in pure Rust is less work than you’d think, and the result is safer, simpler, and faster to install than the C++ alternative.</li></ol><p>sheathe is open source at <a href="https://github.com/vbasky/sheathe">github.com/vbasky/sheathe</a>, under MIT OR Apache-2.0.</p><p><em>The author is also building </em><a href="https://github.com/vbasky/revelo"><em>revelo</em></a><em> (a pure-Rust MediaInfoLib port) and </em><a href="https://github.com/vbasky/viser"><em>viser</em></a><em> (a content-adaptive video encoding optimizer). The three projects form a suite: probe → ladder → package — the Delivery lane of a pure-Rust media ecosystem, end to end.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ff1f6b884fec" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Packaging the World’s Video in Pure Rust]]></title>
            <link>https://medium.com/@vbasky/packaging-the-worlds-video-in-pure-rust-57ab58aadabe?source=rss-5d48085a669c------2</link>
            <guid isPermaLink="false">https://medium.com/p/57ab58aadabe</guid>
            <category><![CDATA[videos]]></category>
            <category><![CDATA[live-streaming]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Vikram Bhaskaran]]></dc:creator>
            <pubDate>Sat, 27 Jun 2026 14:52:28 GMT</pubDate>
            <atom:updated>2026-06-27T14:52:28.702Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nAR24JbgodxXZUGNzXADkA.png" /></figure><h3>Building sheathe, an HLS/DASH/CMAF media packager, validated byte-for-byte against Shaka Packager</h3><p>Every video you stream on YouTube, Netflix, or an OTT sports app arrives in pieces. A two-hour movie is not one file served in order — it is a manifest listing hundreds of small segments, each containing a few seconds of video and audio, joined by a description that tells the player how to stitch them back together. The tool that makes those pieces is called a <em>packager</em>, and production streaming has relied on exactly one open-source implementation for a decade: Google’s Shaka Packager.</p><p>This is the story of building a second one — in Rust, with no C or C++ anywhere in the dependency tree, validated against the original byte-for-byte.</p><h3>What a Packager Actually Does</h3><p>A packager takes a finished media file — say, a 1080p MP4 with H.264 video and AAC audio — and produces what the web needs to stream it:</p><ul><li><strong>CMAF segments.</strong> Common Media Application Format fragments, the ISO-BMFF-based chunks that every modern player understands. Each segment is a self-contained fMP4 file with an initialization header plus a sequence of moof+mdat fragments covering ~6 seconds.</li><li><strong>A DASH manifest.</strong> An MPD (Media Presentation Description) XML document that lists every segment URL, its duration, its resolution, and its codec. A player reads the MPD, figures out which rendition matches the current network, and fetches segments.</li><li><strong>An HLS playlist.</strong> A master .m3u8 listing available renditions plus per-track media playlists, with the same segment references. Older players and Apple devices use this path.</li></ul><p>If the content is encrypted — and in production it always is — the packager also applies Common Encryption (CENC), embeds the DRM signalling, and writes the per-sample encryption metadata that a player’s Content Decryption Module needs to decrypt each frame.</p><p>That is the job. The reference implementation — Shaka Packager — does it in C++ with ~250,000 lines of code, covering a dozen input formats, four encryption schemes, live streaming, trick-play, and HLS packed-audio output. The goal of sheathe is functional parity on the subset that production VOD (Video on Demand) actually uses, done in pure Rust and differential-tested against Shaka at every step.</p><h3>Why Build Another Packager?</h3><p>There are excellent Rust projects that parse DASH manifests and HLS playlists. There was no mature Rust project that <em>produces</em> segments and manifests from video files — the write side is the hard side, and the ecosystem had a gap in the Delivery lane: probe the source, build the ladder, segment into CMAF, write the manifests.</p><p>sheathe fills that gap. It also fills a subtler one: the standard packager is a native binary written in C++, carrying all the memory-safety risk of parsing untrusted media containers at scale. A packager that does the same work in unsafe-free Rust turns a class of buffer-overflow CVEs into compile-time impossibilities. That matters the moment you point it at a file you didn&#39;t create.</p><h3>Architecture: Nine Crates, Four Layers</h3><p>sheathe is nine crates arranged in four layers, each crate with one responsibility:</p><pre>Foundation   sheathe-core      Media model: streams, samples, timing, errors<br>             sheathe-mp4        ISO-BMFF box reader/writer, CMAF fragments<br>             sheathe-crypto     CENC encryption (all four schemes + DRM pssh)<br>             sheathe-ts         MPEG-2 transport stream demux (PAT/PMT/PES)<br>             sheathe-es         Raw elementary stream demux (Annex B, ADTS)</pre><pre>Pipeline     sheathe-dash       DASH MPD generation<br>             sheathe-hls        HLS master + media playlist generation</pre><pre>Application  sheathe-cli        The `sheathe` binary<br>             sheathe            CLI crate (thin wrapper over sheathe-cli)</pre><p>The dependency graph points strictly down. A manifest crate composes foundation crates but never reaches sideways into another manifest crate. This is where Rust’s crate boundary earns its keep: the MP4 writer cannot accidentally call into the DASH MPD generator, because the compiler won’t let it. Nine shallow crates, each small enough to hold in your head and test in isolation.</p><p>The whole workspace is ~6,000 lines of Rust across 41 source files, with 34 tests that verify everything from AES-128-CTR NIST vectors to AVC codec-string derivation — and an integration harness that diffs output against Shaka Packager itself.</p><h3>The Read Side: Demuxing MP4, MPEG-TS, and Elementary Streams</h3><p>Before sheathe can write CMAF segments, it has to read the source. Three input paths are wired:</p><p><strong>MP4 demux (</strong><strong>sheathe-mp4).</strong> A dependency-free, cursor-based box reader traverses the ISO-BMFF hierarchy: moov → trak → mdia → minf → stbl. From the sample table (stts/ctts/stsc/stsz/stco/stss) it reconstructs every sample&#39;s decode time, composition offset, size, offset, and keyframe flag — the exact information the fragmenter needs to slice the stream into segments. The box reader is a simple state machine over a &amp;[u8] reference; there is no allocation beyond the BoxIterstack, and no unsafe.</p><pre>// Top-level box traversal: find every box at the current nesting level,<br>// yielding (type, body, next_offset) for each.<br>pub fn top_level(data: &amp;[u8]) -&gt; impl Iterator&lt;Item = std::io::Result&lt;Mp4Box&lt;&#39;_&gt;&gt;&gt; {<br>    // ... purely a Cursor over the byte slice<br>}</pre><p><strong>MPEG-TS demux (</strong><strong>sheathe-ts).</strong> Transport streams are a different beast entirely — 188-byte packets carrying program-specific information (PAT/PMT) and packetized elementary stream (PES) payloads. sheathe-ts parses PAT to find the program, PMT to find the streams, PES headers to reassemble access units, and Annex B / ADTS parsers to extract H.264, HEVC, and AAC samples. It synthesizes avcC/hvcC/mp4a sample entries from the elementary stream headers, so the output segment&#39;s moov carries the decoder configuration the player needs — all without an external probe tool.</p><p><strong>Elementary stream demux (</strong><strong>sheathe-es).</strong> For raw .h264, .hevc, and .aac files, sheathe detects the format from the extension and content signature, splits the stream into access units (via Annex B start-code scanning for video, ADTS frame sync for audio), and feeds them to the same fragmenter. No container, no problem.</p><h3>The Write Side: CMAF Segments from a Stream of Samples</h3><p>Once the demuxer has produced a stream of Sample structs (data, decode timestamp, composition offset, duration, keyframe flag), the fragmenter decides where to cut. The policy is the same one Shaka uses: wait until the accumulated samples exceed the target segment duration (default 6 seconds), then cut at the next keyframe — because a segment that starts on a non-keyframe cannot be decoded independently, and DASH/HLS switching depends on clean boundaries.</p><pre>pub fn push(&amp;mut self, sample: Sample) -&gt; Result&lt;()&gt; {<br>    let elapsed = sample.dts.saturating_sub(self.current_start);<br>    let may_cut = !self.policy.keyframes_only || sample.is_segment_boundary();<br>    if !self.current.is_empty() &amp;&amp; may_cut &amp;&amp; elapsed &gt;= self.target_ticks {<br>        self.cut(sample.dts);<br>    }<br>    if self.current.is_empty() {<br>        self.current_start = sample.dts;<br>    }<br>    self.current.push(sample);<br>    Ok(())<br>}</pre><p>Each segment is written as an fMP4 file: a styp box (segment type), a sidx box (segment index for seeking), a moof box (movie fragment — the sample table for this segment), and an mdat box (the actual sample data). The init segment carries ftyp + moov + mvex, describing the codec configuration once, at the start.</p><p>The box writer is a 75-line nestable builder with automatic size backpatching — no pre-computed table of contents. begin(&quot;moof&quot;), write child boxes, end() — and the 32-bit size in the header is patched to the correct value. This is the same pattern Shaka uses; sheathe just does it in 1/10th the code.</p><h3>The Hard Part: Common Encryption Done Correctly</h3><p>Encryption is where most packagers get complex. CENC (ISO/IEC 23001–7) defines four protection schemes, and sheathe implements all of them:</p><p>SchemeCipherModeVideo PatternAudiocencAES-128-CTRFull-regionNAL-aware subsamplesWhole-samplecensAES-128-CTRPattern (1:9)Crypt 1 block, skip 9Whole-samplecbc1AES-128-CBCFull-regionPer-sample IV, block-alignedWhole-samplecbcsAES-128-CBCPattern (1:9)Crypt 1, skip 9, constant IVWhole-sample</p><p>The sheathe-crypto crate is a 457-line module built on the RustCrypto aes crate. The AES-128-CTR path runs a continuous keystream across protected byte ranges, skipping clear bytes and pattern-skipped blocks without advancing the counter. The AES-128-CBC path chains ciphertext blocks, resetting to the constant IV at each subsample boundary under pattern encryption. Both are validated against NIST SP 800-38A test vectors, and against Shaka Packager&#39;s output on real media.</p><p>Every scheme is ffmpeg decrypt+decode verified. That is: sheathe encrypts a sample, produces an fMP4 segment, and then ffmpeg -decryption_key ... -i segment.mp4 -f framemd5 confirms every video frame and every audio frame decodes to the same MD5 as the clear original. No &quot;probably encrypted&quot; — provably correct.</p><h3>Multi-DRM pssh</h3><p>Encrypting the bytes is half the job; the other half is telling the player’s DRM module <em>how</em> to decrypt them. Each DRM system gets a pssh box in the init segment, generated directly from the raw key (no key server required):</p><ul><li><strong>Common</strong> — a version-1 box listing the KID, used by W3C clear-key and urn:mpeg:dash:mp4protection.</li><li><strong>Widevine</strong> — a version-0 box carrying a WidevinePsshData protobuf with the KID and protection-scheme fourcc.</li><li><strong>PlayReady</strong> — a version-0 box wrapping a PlayReady Object with a WRMHEADER 4.0.0.0 (KID, key length, ALGID, and an AES-ECB checksum proving key possession).</li></ul><p>Each box byte-matches Shaka Packager’s --protection_systems output. Not approximately — identically.</p><h3>Key Rotation</h3><p>For long content, rotating keys reduces the exposure window of any single key. sheathe supports --crypto-period-duration &lt;seconds&gt;: each period derives a new content key by left-rotating the base key (Shaka&#39;s naive raw-key scheme), signalled per segment via seig sample groups (sbgp/sgpd), a zero-KID init tenc, and a per-period pssh in each moof. Every segment decrypts to the clear baseline under its derived key.</p><h3>Manifest Generation: DASH and HLS</h3><p>With segments written, the packager generates the manifests that players consume.</p><p><strong>DASH.</strong> The MPD is an XML document in the static (type=&quot;static&quot;), on-demand profile, using SegmentTemplate + SegmentTimeline — the same profile Shaka produces for VOD. Each representation gets a &lt;Representation&gt; element with its codec string (RFC 6381), resolution, bandwidth, and a &lt;SegmentTimeline&gt; that lists each segment&#39;s exact duration. Equal-duration runs are collapsed with r= (repeat count), so a 2-hour movie doesn&#39;t produce 1,200 &lt;S&gt; elements.</p><p>The codec strings themselves are derived from raw box data — avcC → avc1.640028, hvcC → hvc1.1.6.L93.90, av1C → av01.0.04M.08, esds → mp4a.40.2 — in a pure-Rust, zero-dependency codec-string module with tests pinned against known outputs.</p><pre>// avc1.PPCCLL from avcC<br>fn avc_string(fourcc: &amp;[u8; 4], avcc: &amp;[u8]) -&gt; Option&lt;String&gt; {<br>    let (profile, compat, level) = (*avcc.get(1)?, *avcc.get(2)?, *avcc.get(3)?);<br>    let prefix = std::str::from_utf8(fourcc).ok()?;<br>    Some(format!(&quot;{prefix}.{profile:02x}{compat:02x}{level:02x}&quot;))<br>}</pre><p><strong>HLS.</strong> The master playlist lists video variants as #EXT-X-STREAM-INF entries with combined bandwidth and codecs (video + audio folded together, per the spec), bound to an #EXT-X-MEDIA audio rendition group. Media playlists carry #EXTINF per-segment durations, #EXT-X-MAP pointing to the init segment, and #EXT-X-KEY encryption signalling (SAMPLE-AES for cbcs, SAMPLE-AES-CTR for cenc) — the full HLS encrypted-fMP4 contract.</p><h3>The Oracle: Differential Testing Against Shaka Packager</h3><p>sheathe adopts the “revelo method”: implement in pure Rust, then differential-test against a reference oracle. For a packager, the oracle is Shaka Packager running the same input file through the same output configuration.</p><p>The oracle harness (just oracle input.mp4) runs both packagers on the same source, then diffs:</p><ol><li><strong>Segment counts.</strong> Do sheathe and Shaka produce the same number of media segments?</li><li><strong>Canonical MPD.</strong> XML canonicalization (xmllint --c14n) strips formatting to compare structure — same representations, same timelines, same codec strings.</li><li><strong>ffmpeg decode.</strong> Both outputs are fed through ffmpeg -f framemd5 to confirm the video renders identically.</li></ol><p>This is the same methodology that keeps revelo’s 180+ parsers byte-identical to MediaInfoLib and viser’s convex hulls correct to the numerical method. Nothing ships until its bytes are verified against the oracle. If Shaka and sheathe disagree, the bug is in sheathe until proven otherwise.</p><h3>What Makes This Genuinely Hard</h3><p>Rust makes it safe; the domain makes it hard. The genuinely difficult parts of building a packager are worth naming:</p><p><strong>NAL-aware subsample encryption is format-specific.</strong> AVC and HEVC samples cannot be encrypted blindly — the NAL unit headers and slice headers must stay clear for a player to parse the stream. The senc box carries a table of (clear_bytes, encrypted_bytes) per sample, and getting the boundary exactly right requires knowing the NAL unit length size from the avcC/hvcC configuration. Get it wrong, and the player produces a green screen or a crash.</p><p><strong>The full CENC scheme matrix has subtle interactions.</strong> cbcs uses a constant IV across all samples; the other three derive per-sample IVs. cbc1 chains CBC across subsamples; cbcs resets to the constant IV at each subsample. Pattern encryption applies to video only — audio under cens/cbcs is whole-sample encrypted with Pattern::NONE. DASH-IF conformance requires saiz/saio auxiliary-information boxes with the saio offset backpatched to point at the senc data. Every one of these interactions earned its own differential test.</p><p><strong>PlayReady pssh is deeply non-obvious.</strong> The PlayReady Object wraps a WRMHEADER with a KID that uses swapped GUID byte order (the first three fields of the UUID are written little-endian, not network order), and an AES-ECB checksum of the object. Building this from the raw key requires implementing a subset of the PlayReady binary format that no RFC describes — the oracle is the only spec.</p><p><strong>The box model rewards getting it right the first time.</strong> ISO-BMFF boxes nest arbitrarily: moov contains trak, which contains mdia, which contains minf, which contains stbl, which contains stsd, which contains the actual sample entry with its decoder configuration. A missing box, a wrong offset, or an incorrect size computation produces a file that looks valid to a file browser and silently fails to play. The reader and writer together form a round-trip that is verified on every test asset.</p><h3>The Result</h3><p>sheathe is a pure-Rust HLS/DASH/CMAF media packager at ~6,000 lines, validated against Shaka Packager as the reference oracle. It demuxes MP4, MPEG-TS, and raw elementary streams; writes CMAF init and media segments with correct box structure and codec strings; applies all four CENC encryption schemes with multi-DRM pssh (Widevine, PlayReady, Common) and key rotation; and emits DASH MPDs and HLS playlists that match Shaka’s output.</p><pre># Package an MP4 into 6s CMAF segments with both DASH and HLS manifests.<br>sheathe package input.mp4 --out site/ --segment-duration 6 --dash --hls</pre><pre># Encrypt with CENC cbcs (AES-CBC pattern) and Widevine+PlayReady DRM.<br>sheathe package input.mp4 --out site/ --dash --hls \<br>  --enc-scheme cbcs \<br>  --enc-key abcd1234...:deadbeef... \<br>  --protection-systems common,widevine,playready</pre><pre># Probe an MPEG-TS file — no ffprobe required.<br>sheathe probe input.ts</pre><p>cargo install sheathe</p><p>sheathe doesn’t yet do live streaming, trick-play, or WebM input. Those are tracked on the roadmap, each gated behind an oracle validation checkpoint. What ships today is the complete VOD pipeline — the subset that covers ~90% of production packaging work — built in unsafe-free Rust, with no C dependencies, and every output differential-tested against the best open-source packager available.</p><h3>One More Thing: The Pure-Rust Probe</h3><p>A packager needs to know what it’s packaging. Resolution, frame rate, codec, bitrate — the numbers that determine how segments get sized and manifests get filled. The default way to get those numbers is to shell out to ffprobe.</p><p>sheathe doesn’t need to. Its MP4 demuxer reads sample-table metadata directly from the box hierarchy; its MPEG-TS demuxer reconstructs stream descriptions from PAT/PMT/PES headers; its elementary-stream path synthesizes sample entries from the Annex B/ADTS byte stream itself. The sheathe probe command returns the same information ffprobe does, with no external binary on the path.</p><p>For the full metadata picture — the kind that covers 177+ media formats with byte-identical output to MediaInfoLib — there’s <a href="https://github.com/vbasky/revelo">revelo</a>, another pure-Rust project that integrates as a drop-in probe engine. But for the subset a packager actually needs, sheathe is self-contained: the tool that segments video is also the tool that reads it. One binary, one dependency chain, one cargo install.</p><h3>What I’d Want You to Take From This</h3><p>If there’s a lesson worth carrying out of this project, it’s simpler than the topic implies:</p><ol><li><strong>Choose an oracle you can diff against.</strong> Shaka Packager is open source, battle-tested on billions of streams, and emits deterministic output. Every feature sheathe ships was validated by running both tools on the same input and comparing the result. Byte-identical output is the only claim worth making.</li><li><strong>The crate boundary is a design tool.</strong> Nine crates, each with exactly one concern, separated by the compiler. The MP4 writer cannot reach into the DASH generator; the crypto crate knows nothing about the container format. Wide and flat, not deep and tangled.</li><li><strong>The hardest bugs are interactions, not algorithms.</strong> AES-CTR is 40 lines. Getting the saiz offset to backpatch correctly after the senc is written — that&#39;s where the hours go. The differential harness catches these; no amount of code review does.</li><li><strong>Packaging is infrastructure, not magic.</strong> A packager is a program that reads video, cuts it into pieces, and writes a description of the pieces. Doing it correctly is a lot of work; doing it in pure Rust is less work than you’d think, and the result is safer, simpler, and faster to install than the C++ alternative.</li></ol><p>sheathe is open source at <a href="https://github.com/vbasky/sheathe">github.com/vbasky/sheathe</a>, under MIT OR Apache-2.0.</p><p><em>The author is also building </em><a href="https://github.com/vbasky/revelo"><em>revelo</em></a><em> (a pure-Rust MediaInfoLib port) and </em><a href="https://github.com/vbasky/viser"><em>viser</em></a><em> (a content-adaptive video encoding optimizer). The three projects form a suite: probe → ladder → package — the Delivery lane of a pure-Rust media ecosystem, end to end.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=57ab58aadabe" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Catharsis for Noisy Audio: A Pure-Rust Restoration Toolkit with No ffmpeg and No Black Boxes]]></title>
            <link>https://medium.com/@vbasky/catharsis-for-noisy-audio-a-pure-rust-restoration-toolkit-with-no-ffmpeg-and-no-black-boxes-a6c5c38e4c14?source=rss-5d48085a669c------2</link>
            <guid isPermaLink="false">https://medium.com/p/a6c5c38e4c14</guid>
            <category><![CDATA[audio]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Vikram Bhaskaran]]></dc:creator>
            <pubDate>Thu, 18 Jun 2026 08:06:11 GMT</pubDate>
            <atom:updated>2026-10-04T07:30:43.040Z</atom:updated>
            <content:encoded><![CDATA[<p><em>How I built cathar — denoise, de-hum, de-click, de-clip, de-reverb, de-ess, voice isolation, bandwidth extension and loudness, every stage a classic inspectable algorithm, in a single static Rust binary.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PN6acnsNimPDRzQ5d13QHA.png" /></figure><p>A language model helped draft this article</p><p>Every recording arrives a little broken. A camera mic picks up air-conditioning hiss. A guitar DI hums at 50 Hz. A lapel mic clips on a loud laugh. A click track bleeds into a take. A Zoom call reverberates off bare walls. A phone recording has no top end above 8 kHz.</p><p>The usual fix is a pile of tools you don’t control. You shell out to ffmpeg, route through a chain of VST plugins you can’t script, or hand the whole thing to a neural denoiser that either fixes it or smears it into a watery, artifacted mess — and you can’t tell which until you’ve already listened to the output.</p><p>I wanted the opposite: a toolkit where every stage is a named, documented algorithm I can read end to end; a pipeline that never quantises between steps; and a binary with <strong>no system dependencies whatsoever</strong> — no ffmpeg, no libsndfile, no pkg-config. That&#39;s <strong>cathar</strong>.</p><p>The name is from Greek <em>katharós</em> (καθαρός) — “pure, clean” — the same root as <em>catharsis</em>, a cleansing. That’s the whole job: take a noisy recording and give back clean audio.</p><h3>What it is</h3><p>Cathar is an audio toolkit for any recording, written in pure Rust. It works on a standalone audio file (WAV, MP3, FLAC, OGG, M4A) just as readily as the audio track <em>inside</em> a video (MP4, MKV) — video is never required, the audio is pulled straight out of the container. It does three classes of work and writes a clean 32-bit float WAV:</p><ul><li><strong>Restore</strong> — denoise, de-hum, de-click, de-clip, de-reverb.</li><li><strong>Enhance</strong> — de-ess, breath removal, voice isolation, bandwidth extension.</li><li><strong>Level</strong> — loudness (LUFS) and peak normalisation for delivery.</li></ul><pre>cathar denoise interview.mp4 --out clean.wav   # noisy interview off a camera -&gt; clean dialogue</pre><pre>cathar noiseprint room_tone.wav --out room.np.json                       # learn the room tone from a silent clip<br>cathar denoise interview.mp4 --noiseprint room.np.json --out clean.wav   # then denoise with it<br>cathar dehum     recording.wav --freq 60     # kill 60 Hz mains buzz<br>cathar declick   recording.wav               # interpolate impulse clicks<br>cathar declip    recording.wav               # rebuild clipped peaks<br>cathar normalize recording.wav --target -16  # to -16 LUFS (podcast)</pre><p>Decoding is <a href="https://crates.io/crates/symphonia">symphonia</a>, the FFT is <a href="https://crates.io/crates/realfft">realfft</a>/<a href="https://crates.io/crates/rustfft">rustfft</a>, WAV writing is <a href="https://crates.io/crates/hound">hound</a>. A single cargo build gives you a self-contained binary that runs anywhere — a container, a CI job, a Lambda — with no install step.</p><h3>The design stance</h3><p>Five principles drive every decision, and they’re worth stating up front because they explain <em>why</em> the rest of the article looks the way it does:</p><ul><li><strong>Pure Rust</strong> — no ffmpeg, no C/C++ FFI, no pkg-config; one cargo build produces a self-contained binary.</li><li><strong>Lossless float pipeline</strong> — decode → f32 → process → 32-bit float WAV; nothing is quantised between stages.</li><li><strong>Composable</strong> — every effect is a plain fn(&amp;[f32], …) -&gt; Vec&lt;f32&gt;; chain them in any order, in the CLI or as a library.</li><li><strong>Inspectable DSP</strong> — classic, documented algorithms (STFT subtraction, Wiener, IIR notches, cubic interpolation), not opaque models.</li><li><strong>Deterministic</strong> — deterministic per-file output; batch processes files concurrently.</li></ul><p>The one I care most about is <em>inspectable</em>. When a denoiser leaves a metallic “musical noise” artifact, I want to know it came from too-aggressive spectral subtraction and turn a specific knob — not re-roll a model and pray. Every algorithm below is one you can look up, reason about, and predict.</p><h3>The backbone: an STFT loop</h3><p>Cathar decodes to interleaved f32 PCM — one Vec&lt;f32&gt; per channel, at the file&#39;s native sample rate — and most reduction stages run as the same loop: a <strong>short-time Fourier transform → modify the spectrum → inverse STFT</strong>. The denoiser uses a 2048-point FFT with a 512-sample hop (75% overlap) and a Hann window on both analysis and synthesis, reconstructed by overlap-add:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*jccToTRy0Xszhgat.png" /></figure><p>The 75% overlap and the Hann-window-on-both-ends is what makes the reconstruction seamless: with constant overlap-add the windows sum to unity, so untouched frames come back bit-for-bit and modified frames don’t click at their boundaries. Phase is never altered — only the magnitude spectrum is reshaped — which is exactly what keeps processed speech sounding natural rather than phasey and underwater.</p><p>Everything that follows is a variation on “what do we do to the magnitudes.”</p><h3>Reducing noise: reshaping the spectrum</h3><p><strong>Denoise</strong> is the headline. Two flavours share the frame loop above:</p><ul><li><strong>Spectral subtraction</strong> (default) estimates the noise magnitude in each frequency bin and subtracts α × it, held above a spectral floor β·mag. That floor is the artifact knob: subtract too hard and you carve isolated spectral peaks out of the noise floor that ring as &quot;musical noise&quot;; the β·mag floor leaves a thin, natural-sounding bed instead. α from 1→6 takes you gentle→aggressive.</li><li><strong>Wiener filter</strong> (--wiener) instead applies the statistically optimal per-bin gain S / (S + N) derived from the estimated signal and noise power. It&#39;s smoother on stationary, broadband noise like tape hiss or fan rumble, because the gain rolls off continuously rather than thresholding.</li></ul><p>Both need a noise estimate, and that comes from one of two places. <strong>Minimum-statistics</strong> assumes the quietest ~15% of frames are essentially noise and builds the profile from them — zero configuration, works on anything. But if you can spare two seconds of true room tone, <strong>noiseprint</strong> does better: it captures the exact per-bin magnitude spectrum of a dedicated silent segment, serialises it to JSON, and feeds it back as a precise subtraction target. Learn it once, reuse it across every take from the same session.</p><p><strong>De-hum</strong> is a different problem and gets a different tool. Mains hum is not broadband — it’s a fundamental at 50 or 60 Hz plus a stack of harmonics. Spectral subtraction would be the wrong hammer; instead dehum builds a cascade of <strong>second-order IIR notch biquads</strong> (Q = 30) tuned to the base frequency and each harmonic up to Nyquist. A high-Q notch removes a razor-thin slice at each hum frequency and leaves everything around it untouched, so the voice keeps its body while the buzz disappears.</p><p><strong>De-reverb</strong> tackles the smear of a live room. It runs a <strong>two-pass spectral-decay gate</strong>: it tracks each frequency bin’s envelope with an 8 ms attack and a 50 ms release, then gates bins that are sitting near their own reverb-decay floor — i.e. energy that is decaying rather than being freshly driven. Direct sound passes; the ringing tail gets pulled down.</p><p><strong>Voice isolation</strong> keeps speech and gates everything else. An energy-based voice-activity detector runs on 20 ms frames, with gap-filling (bridge silences under 120 ms so words don’t get chopped) and minimum-segment dropping (discard blips under 50 ms that are clearly not speech). Non-speech regions are then spectrally gated — and if you hand it a noiseprint, the gate tightens around the actual noise spectrum.</p><p><strong>De-ess</strong> chases sibilance — the harsh “sss” and “tss” that lavs and condensers exaggerate. It runs a tighter STFT (2048/256) and, wherever the high-frequency power <em>ratio</em> above a crossover frequency exceeds a threshold, applies frequency-dependent compression to just that band. Because it keys on the ratio, it only acts on actual sibilant frames and leaves the rest of the vocal alone.</p><p><strong>Breath removal</strong> is subtle on purpose. It flags the frames immediately before a speech onset (≤ 150 ms), high-passes them at 200 Hz, and mixes the result back 40/60 dry/wet — so breaths are softened, not surgically deleted, which is what keeps the delivery sounding human instead of gated and robotic.</p><h3>Repairing damage: reconstructing samples</h3><p>Noise reduction reshapes a spectrum. The repair tools do something more aggressive — they decide some samples are <em>wrong</em> and rebuild them.</p><p><strong>De-click</strong> targets impulse damage: vinyl ticks, digital dropouts, mouth clicks. It runs a sliding window, computes the local RMS, and flags any sample that spikes past threshold × RMS as a click. Flagged runs are then replaced by <strong>cubic-Hermite interpolation</strong> across the gap — fitting a smooth curve through the good samples on either side so the repair is inaudible rather than a hole.</p><p><strong>De-clip</strong> handles the opposite failure: a signal recorded too hot, with its peaks flattened against the ceiling. It detects runs sitting at or above the clip threshold, extends the shoulders ±4 samples to catch the full flattened region, and reconstructs the missing peak with the same cubic-Hermite interpolation — inferring the curve the waveform <em>would</em> have taken if the converter hadn&#39;t run out of headroom.</p><p>Both are honest reconstructions of small, localised damage — not magic — and because they only touch flagged samples, undamaged audio passes through untouched.</p><h3>Enhancing and levelling</h3><p><strong>Enhance</strong> is bandwidth extension for dull, band-limited sources — old phone recordings, low-bitrate exports. It windowed-sinc resamples up to the target rate, then uses <strong>spectral band replication</strong>: it folds the existing top band of the signal up into the empty highs with a tiled rolloff, synthesising plausible high-frequency detail where the source simply had none. It’s the only stage that resamples; everything else runs at the source rate.</p><p><strong>Normalize</strong> is delivery. Peak mode scales so the loudest sample hits a dBFS target; loudness mode scales by RMS to approximate a LUFS target. The rough targets: -23 for broadcast (EBU R128), -16 for podcast, -14 for streaming.</p><h3>Run them however you like</h3><p>Every command reads any supported format and writes a 32-bit float WAV, so you can run them in any order, chain them, or batch a directory:</p><pre>cathar batch --indir ./raw --outdir ./clean --dehum 60 --normalize -16</pre><p>And because each stage is independent and frame-synchronous, the same input always produces the same output — no seeds, no nondeterminism, which matters the moment you put this in a reproducible pipeline.</p><h3>It’s a library first, a CLI second</h3><p>The CLI is a thin clap wrapper over the cathar crate. Because every effect is a plain function over &amp;[f32], the same engine drops straight into a Rust program:</p><pre>use cathar::{AudioData, SpectralDenoiser, Denoiser, dehum, normalize_loudness};</pre><pre>let audio = AudioData::from_file(&quot;interview.mp4&quot;)?;   // symphonia decode → f32<br>let sr = audio.sample_rate;<br>let clean = SpectralDenoiser::default()<br>    .denoise(&amp;audio)?<br>    .map_channels(|ch| dehum(ch, sr, 60.0, 5))<br>    .map_channels(|ch| normalize_loudness(ch, -16.0));<br>clean.to_file(&quot;clean.wav&quot;)?;   // 32-bit float WAV via hound</pre><p>AudioData is just { sample_rate, channels: Vec&lt;Vec&lt;f32&gt;&gt; }. map_channels applies any &amp;[f32] -&gt; Vec&lt;f32&gt; effect to every channel independently. Learn a noise print once and reuse it for a tighter subtraction:</p><pre>use cathar::{AudioData, SpectralDenoiser, Denoiser, learn_noise_print};</pre><pre>let print = learn_noise_print(&amp;AudioData::from_file(&quot;room_tone.wav&quot;)?)?;<br>let audio = AudioData::from_file(&quot;interview.mp4&quot;)?;<br>let clean = SpectralDenoiser::with_noise_print(print, /* alpha */ 3.0, /* beta */ 0.01)<br>    .denoise(&amp;audio)?;<br>clean.to_file(&quot;clean.wav&quot;)?;</pre><p>The whole public surface is a handful of free functions — dehum, declick, declip, dereverb, voice_isolate, deesser, breath_remove, bandwidth_extend, normalize_peak, normalize_loudness, generate_wave — plus the Denoiser trait, SpectralDenoiser, and NoisePrint. Nothing hidden, nothing you can&#39;t call directly.</p><h3>The lossless float pipeline</h3><p>One detail that’s easy to miss but matters: cathar decodes to 32-bit float, processes in float, and writes 32-bit float WAV. Nothing is quantised back to 16-bit between stages. When you chain denoise → dehum → declip → normalize, each stage hands the next full-precision floats, so quantisation error never accumulates across a five-stage chain. The cost is bigger intermediate files; the payoff is that a long restoration chain doesn&#39;t slowly add its own noise floor.</p><h3>The architecture</h3><p>A deliberately small two-crate workspace — a library and the binary that drives it:</p><pre>cathar/<br>├─ crates/<br>│  ├─ cathar/        # the engine: decode (symphonia) · DSP · encode (hound)<br>│  └─ cathar-cli/    # the `cathar` binary — clap subcommands over the engine<br>└─ docs/             # banner + assets</pre><p>symphonia (built with features = [&quot;all&quot;]) decodes every supported container and codec to f32; realfft/rustfft sit behind every STFT stage; hound writes the float WAV; serde/serde_json serialise the NoisePrint. The optional ml feature wires in <a href="https://crates.io/crates/candle-core">candle</a> as scaffolding for a future learned denoiser.</p><h3>Honest about the edges</h3><p>Cathar is 0.1.x, and I&#39;d rather tell you what it <em>doesn&#39;t</em> do yet than oversell:</p><ul><li><strong>Loudness is an RMS-based LUFS approximation</strong>, not true K-weighted gated EBU R128. Good enough to land a podcast in the ballpark; not a broadcast-compliance tool.</li><li><strong>The learned denoiser isn’t built.</strong> The ml feature wires in candle, but the neural model is scaffolding. The classic DSP chain is what&#39;s implemented and unit-tested.</li><li><strong>Batch</strong> fans out across the rayon thread pool — files process in parallel, and a bad file is reported and skipped rather than aborting the run.</li><li><strong>Only </strong><strong>enhance resamples</strong>; mixed-rate inputs aren&#39;t yet handled uniformly across every stage.</li></ul><h3>Try it</h3><pre>cargo install --path crates/cathar-cli<br>cathar wave --out test.wav --duration 3 --freq 440 --noise 0.15  # make a noisy tone<br>cathar denoise test.wav --out clean.wav                          # clean it</pre><p>The code is on GitHub: <a href="https://github.com/vbasky/cathar"><strong>github.com/vbasky/cathar</strong></a>. If you’ve ever wanted a denoiser whose source you can actually read — and predict — this one’s for you.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a6c5c38e4c14" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Somnia — A Type-Safe ORM for SurrealDB]]></title>
            <link>https://medium.com/@vbasky/somnia-a-type-safe-orm-for-surrealdb-that-feels-like-diesel-7341d49bd5c4?source=rss-5d48085a669c------2</link>
            <guid isPermaLink="false">https://medium.com/p/7341d49bd5c4</guid>
            <category><![CDATA[rust-programming-language]]></category>
            <category><![CDATA[orm]]></category>
            <category><![CDATA[surrealdb]]></category>
            <dc:creator><![CDATA[Vikram Bhaskaran]]></dc:creator>
            <pubDate>Sun, 14 Jun 2026 15:10:18 GMT</pubDate>
            <atom:updated>2026-06-18T07:22:30.560Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*x_pYZegkz3sNNHnBvZUXvg.png" /></figure><h3>Stop Hand-Writing SurrealQL Strings in Rust</h3><p>Every Rust developer who has worked with SurrealDB knows the sinking feeling. You write a query. It compiles. It runs. And then, at 3 AM, production throws a parse error because a record ID got escaped wrong, or a column name had a typo, or a UUID got treated as an arithmetic expression inside a string interpolation.</p><p>You’re not alone. I built <strong>Somnia</strong> because I was tired of debugging query strings at runtime when Rust’s type system was sitting right there, ready to help.</p><h3>The Stringly-Typed Nightmare</h3><p>Let me paint a picture. Here’s what a typical SurrealDB query looks like in a Rust codebase using the raw surrealdb crate:</p><pre>let query = format!(<br>    &quot;SELECT id, title, body, published_at FROM post \<br>     WHERE title = &#39;{}&#39; AND published_at &lt; d&#39;{}&#39; \<br>     ORDER BY published_at DESC \<br>     LIMIT {}&quot;,<br>    title, cutoff, limit<br>);</pre><pre>let result: Vec&lt;Post&gt; = db.query(query).await?.take(0)?;</pre><p>What could possibly go wrong? Let me count the ways:</p><ol><li><strong>That</strong> <strong>‘title’ field in the string</strong> — rename title to headline in your struct, and the compiler won’t catch that the query still references the old column name.</li><li><strong>That</strong> <strong>d’{}’ datetime literal</strong> — forget the d prefix and SurrealDB silently interprets your timestamp as a string. Type mismatch bugs that surface days later.</li><li><strong>That unescaped</strong> <strong>title value</strong> — what if the title contains a single quote? Oops.</li><li><strong>The</strong> <strong>id field</strong> — SurrealDB record IDs have subtle parsing rules. A UUID with dashes gets interpreted as type::thing — 550e8400 — e29b — 41d4 — a716–446655440000 (an arithmetic expression) unless wrapped in backticks.</li><li><strong>The</strong> <strong>take(0)</strong> — hope you remembered the right index. Deserialization targets a positional result set; mismatch it and you’re deserializing the wrong thing.</li></ol><p>Every one of these is a runtime error. Some are silent. Some crash. None are caught by the compiler. And when you’re maintaining dozens of queries across a growing codebase, the cognitive load adds up fast.</p><h3>Enter Somnia: Schema as Code, Queries as Types</h3><p>Somnia flips the model. Instead of writing queries and hoping they match your types, you define your types and <strong>generate</strong> the queries. The Rust struct becomes the single source of truth for both your application model and your database schema.</p><pre>use somnia::SurrealRecord;<br>use serde::{Deserialize, Serialize};</pre><pre>#[derive(Debug, Clone, Serialize, Deserialize, SurrealRecord)]<br>#[table(&quot;post&quot;)]<br>struct Post {<br>    #[field(thing)]<br>    id: Thing&lt;Post&gt;,<br>    title: String,<br>    body: String,<br>    published_at: Option&lt;String&gt;,<br>}</pre><p>That #[derive(SurrealRecord)] annotation does heavy lifting. It generates:</p><ul><li>Typed column accessors — Post::title() returns a Column&lt;Post, String&gt;</li><li>A table() method to enter the query builder</li><li>The SurrealSchema trait for DDL generation (more on that later)</li><li>Post::all() — a column set for SELECT *</li></ul><p>Now queries look like this:</p><pre>use somnia::query::ident;</pre><pre>let query = Post::table()<br>    .select(Post::all())<br>    .filter(Post::title().eq(&quot;Hello, world&quot;))<br>    .filter(ident(&quot;published_at&quot;).less_than(&quot;2025-01-01&quot;))<br>    .order_desc(ident(&quot;published_at&quot;))<br>    .limit(10)<br>    .to_surrealql();</pre><p>Run this and to_surrealql() produces:</p><pre>SELECT * FROM post<br>WHERE title = &#39;Hello, world&#39; AND published_at &lt; &#39;2025-01-01&#39;<br>ORDER BY published_at DESC LIMIT 10</pre><p>Notice what happened behind the scenes:</p><ol><li>Post::title() is a typed accessor. If you rename the field in your struct, the code won’t compile.</li><li>The table name comes from the #[table(“post”)] attribute — define it once, never mistype it again.</li><li>Values are automatically escaped with the right SurrealQL literal syntax (strings in single quotes, datetimes with the d prefix, UUIDs with the u prefix).</li></ol><p>The entire query is a builder chain. The compiler type-checks every expression, column reference, and operator. You can’t accidentally use a string column in an arithmetic comparison. You can’t filter on a field that doesn’t exist.</p><h3>Beyond SELECT: Mutations That Make Sense</h3><p>Somnia handles the full CRUD lifecycle with the same type-safe builder pattern.</p><h3>CREATE</h3><pre>let query = Post::table()<br>    .create()<br>    .record(&quot;post:hello-world&quot;)<br>    .set_lit(&quot;title&quot;, &quot;Hello, world&quot;)<br>    .set_lit(&quot;body&quot;, &quot;This is a post about SurrealDB.&quot;)<br>    .set_lit(&quot;published_at&quot;, &quot;2025-06-15&quot;)<br>    .returning(Returning::After)<br>    .to_surrealql();</pre><p>Generates:</p><pre>CREATE post:hello-world SET<br>    title = &#39;Hello, world&#39;,<br>    body = &#39;This is a post about SurrealDB.&#39;,<br>    published_at = &#39;2025-06-15&#39;<br>RETURN AFTER</pre><p>Returning::After returns the newly created record. Use Returning::Before for the pre-update snapshot, Returning::Diff to see only changed fields, or Returning::None to suppress the return value.</p><p>You can also use the CONTENT pattern if you already have a fully-formed struct:</p><pre>Post::table()<br>    .create()<br>    .content(post_obj)  // post_obj: &amp;Post<br>    .returning(Returning::After)<br>    .to_surrealql()</pre><h3>UPDATE with SET, MERGE, and CONTENT</h3><p>Somnia supports all three SurrealDB update strategies:</p><pre>// SET — assign specific fields<br>Post::table()<br>    .update(&quot;post:hello-world&quot;)<br>    .set_lit(&quot;title&quot;, &quot;Updated title&quot;)<br>    .set_lit(&quot;body&quot;, &quot;New body content&quot;)<br>    .to_surrealql();</pre><pre>// MERGE - deep-merge a JSON object<br>Post::table()<br>    .update(&quot;post:hello-world&quot;)<br>    .merge(json!({&quot;title&quot;: &quot;Merged title&quot;}))<br>    .to_surrealql();<br><br>// CONTENT - replace the entire record<br>Post::table()<br>    .update(&quot;post:hello-world&quot;)<br>    .content(&amp;updated_post)<br>    .to_surrealql();</pre><h3>UPSERT</h3><p>New in 0.4.1, UPSERT gives you insert-or-update with a single call:</p><pre>Post::table()<br>    .upsert(&quot;post:maybe-exists&quot;)<br>    .set_lit(&quot;title&quot;, &quot;Exists or not, here it is&quot;)<br>    .returning(Returning::After)<br>    .to_surrealql();</pre><h3>DELETE with Safety</h3><pre>Post::table()<br>    .delete(&quot;post:obsolete&quot;)<br>    .returning(Returning::Before)  // get the record before removing it<br>    .to_surrealql();</pre><h3>The “Mutate Then Reselect” Pattern</h3><p>A common workflow: you create a record, and you need the fully-hydrated version (with computed fields, timestamps, defaults) immediately. Somnia’s then_select() chains a mutation with a follow-up SELECT in a single round-trip:</p><pre>let query = Post::table()<br>    .create()<br>    .content(&amp;new_post)<br>    .returning(Returning::After)<br>    .then_select(<br>        Post::table()<br>            .project(vec![<br>                Post::title(),<br>                Post::published_at(),<br>            ])<br>            .limit(1)<br>    );</pre><p>The generated query joins two statements with ; — SurrealDB executes them as a unit and returns both result sets.</p><h3>Complex Filtering: ANDs, ORs, and Everything in Between</h3><p>Real queries rarely stop at a single WHERE clause. Somnia’s expression tree composes naturally:</p><pre>use somnia::expr::Expr;</pre><pre>let is_published = ident(&quot;published_at&quot;).less_than_or_equal(&quot;2025-06-15&quot;);<br>let is_featured = Post::title().contains(&quot;featured&quot;);<br>let author_is_vikram = Post::title().eq(&quot;Vikram&quot;);<br>let query = Post::table()<br>    .select(Post::all())<br>    .filter(is_published.and(is_featured.or(author_is_vikram)))<br>    .limit(50)<br>    .to_surrealql();</pre><p>Generates:</p><pre>SELECT * FROM post<br>WHERE (published_at &lt;= &#39;2025-06-15&#39; AND (title CONTAINS &#39;featured&#39; OR title = &#39;Vikram&#39;))<br>LIMIT 50</pre><p>Parentheses are automatically inserted for correct precedence. The expression tree handles arbitrary nesting — chain as many .and() and .or() combinators as you need.</p><p>You can also compare columns against columns, not just literals:</p><pre>use somnia::expr::Expr as _;  // for .gt(), .lt(), .eq()</pre><pre>let query = Post::table()<br>    .select(Post::all())<br>    .filter(ident(&quot;updated_at&quot;).greater_than(ident(&quot;created_at&quot;)))<br>    .to_surrealql();</pre><h3>Schema as Code: Your Struct IS Your DDL</h3><p>This is where Somnia truly shines. Every SurrealRecord automatically generates schema DDL. Call Post::up() and you get:</p><pre>DEFINE TABLE IF NOT EXISTS post SCHEMAFULL PERMISSIONS FULL;<br>DEFINE FIELD IF NOT EXISTS title ON TABLE post TYPE string;<br>DEFINE FIELD IF NOT EXISTS body ON TABLE post TYPE string;<br>DEFINE FIELD IF NOT EXISTS published_at ON TABLE post TYPE option&lt;string&gt;;</pre><p>The Post::id field — marked with #[field(thing)] — is skipped from the generated DDL because SurrealDB handles the id field automatically.</p><p>Call Post::down() and you get:</p><pre>REMOVE TABLE IF EXISTS post;</pre><p>You can customize the table definition with attributes:</p><pre>#[derive(SurrealRecord)]<br>#[table(&quot;post&quot;, permissions = &quot;NONE&quot;)]<br>struct Post { ... }<br>// DEFINE TABLE IF NOT EXISTS post SCHEMAFULL PERMISSIONS NONE;</pre><pre>#[derive(SurrealRecord)]<br>#[table(&quot;event_log&quot;, schemaless)]<br>struct EventLog { ... }<br>// DEFINE TABLE IF NOT EXISTS event_log SCHEMALESS PERMISSIONS FULL;</pre><p>This is powerful because it eliminates the drift between your application types and your database schema. When a field is added, renamed, or retyped, the generated DDL changes automatically. Your CI pipeline can compare the generated DDL against the production schema to surface discrepancies before they ship.</p><h3>Migrations: Diesel-Style, SurrealDB-Ready</h3><p>If you’ve used Diesel, Somnia’s migration system will feel immediately familiar. Migrations are timestamped folders containing up.surql and down.surql files:</p><pre>migrations/<br>├── 2025-06-15-142300_create_posts/<br>│   ├── up.surql<br>│   └── down.surql<br>├── 2025-06-15-142500_seed_categories/<br>│   ├── up.surql<br>│   └── down.surql<br>└── 2025-06-15-142700_add_tags/<br>    ├── up.surql<br>    └── down.surql</pre><p>Each up.surql might look like:</p><pre>-- up.surql<br>DEFINE TABLE IF NOT EXISTS tag SCHEMAFULL PERMISSIONS FULL;<br>DEFINE FIELD IF NOT EXISTS name ON TABLE tag TYPE string;<br>DEFINE INDEX IF NOT EXISTS idx_tag_name ON TABLE tag COLUMNS name UNIQUE;</pre><p>And the corresponding down.surql:</p><pre>-- down.surql<br>REMOVE TABLE IF EXISTS tag;</pre><p>Migrations are applied in timestamp order, tracked in a special _somnia_migrations table (auto-created on first run). Use them from code:</p><pre>let client = SomniaClient::connect(<br>    &quot;ws://localhost:8000&quot;,<br>    &quot;root&quot;, &quot;root&quot;,<br>    &quot;myns&quot;, &quot;mydb&quot;<br>).await?;</pre><pre>let migrator = client.migrator(&quot;migrations&quot;);<br>// Apply all pending migrations<br>migrator.run().await?;<br>// Check status<br>for m in migrator.status().await? {<br>    let icon = if m.applied { &quot;✓&quot; } else { &quot; &quot; };<br>    println!(&quot;{} {}&quot;, icon, m.name);<br>}<br>// Revert the most recent migration<br>migrator.revert_last().await?;<br>// Revert everything<br>migrator.revert_all().await?;</pre><p>Or use the CLI:</p><pre>somnia migration generate add_tags  # generate a new migration scaffold<br>somnia migration run                # apply pending migrations<br>somnia migration revert             # revert the most recent<br>somnia migration redo               # revert and re-apply the latest (useful during development)<br>somnia migration list               # see what&#39;s applied and what&#39;s pending</pre><p>The CLI connects via flags or environment variables:</p><pre>somnia migration run \<br>  --endpoint ws://localhost:8000 \<br>  --user root --pass root \<br>  --ns myns --db mydb</pre><h3>Graph Relations: Typed Edges in SurrealDB</h3><p>SurrealDB’s graph model is one of its killer features. Somnia supports typed RELATE queries:</p><pre>// Define edge and node types<br>#[derive(SurrealRecord)]<br>#[table(&quot;authored&quot;)]<br>struct Authored {<br>    #[field(thing)]<br>    id: Thing&lt;Authored&gt;,<br>    since: String,<br>}</pre><pre>#[derive(SurrealRecord)]<br>#[table(&quot;author&quot;)]<br>struct Author {<br>    #[field(thing)]<br>    id: Thing&lt;Author&gt;,<br>    name: String,<br>}<br>// Relate with a SET body<br>let relate = Authored::table()<br>    .relate(Author::table(), &quot;author:vikram&quot;, Post::table(), &quot;post:hello-world&quot;)<br>    .set_lit(&quot;since&quot;, &quot;2025-01-01&quot;)<br>    .to_surrealql();</pre><p>Generates:</p><pre>RELATE author:vikram -&gt; authored -&gt; post:hello-world SET since = &#39;2025-01-01&#39;</pre><p>The type parameters track which node types the edge connects. The compiler won’t let you accidentally relate a Post to a Comment through an Authored edge if those types don’t match the edge’s intended schema.</p><h3>Geometry Types: Latitude, Longitude, and Type Safety</h3><p>If you’re building location-aware applications, SurrealDB’s geometry support is important. Somnia v0.3.0 added first-class geometry types with proper GeoJSON serialization:</p><pre>use somnia::types::{Point, LineString, Polygon};</pre><pre>#[derive(SurrealRecord)]<br>#[table(&quot;venue&quot;)]<br>struct Venue {<br>    #[field(thing)]<br>    id: Thing&lt;Venue&gt;,<br>    name: String,<br>    location: Point,<br>    service_area: Polygon,<br>}<br>// The coordinates serialize as proper GeoJSON<br>let venue = Venue {<br>    id: Thing::new(&quot;venue&quot;, &quot;central-park&quot;),<br>    name: &quot;Central Park&quot;.into(),<br>    location: Point::new(-73.9654, 40.7829),<br>    service_area: Polygon::new(vec![<br>        (-74.0, 40.7),<br>        (-73.9, 40.7),<br>        (-73.9, 40.8),<br>        (-74.0, 40.8),<br>        (-74.0, 40.7),<br>    ]),<br>};</pre><p>When stored in SurrealDB, these serialize as {“type”: “Point”, “coordinates”: [-73.9654, 40.7829]} — the format SurrealDB expects for its geometry type. Without this, SurrealDB would store coordinates as a plain array, losing the ability to use geospatial indexes and queries.</p><p>Before v0.3.0, geometry was stored as bare arrays, which silently broke SurrealDB’s geospatial features. The fix was a breaking change, but it was necessary — correctness over convenience.</p><h3>Record ID Escaping: The Bug You Didn’t Know You Had</h3><p>SurrealDB’s record ID parser has non-obvious rules. Consider these keys:</p><ul><li>hello — simple, works fine as post:hello</li><li>U+ID — contains +, which is an operator, so it needs escaping</li><li>550e8400-e29b-41d4-a716–446655440000 — a UUID without backticks gets parsed as type::thing — 550e8400 — e29b — …, an arithmetic expression</li></ul><p>Somnia v0.3.0 added proper key escaping. The Key enum auto-detects the escaping strategy:</p><ul><li>Bare integers render as post:42 (no quotes needed)</li><li>Simple ASCII identifiers render as post:hello (no backticks needed)</li><li>UUIDs and strings with special characters render as post:⟨550e8400-e29b-41d4-a716–446655440000⟩ (backtick-wrapped)</li></ul><p>(Replace ⟨⟩ with actual backticks above — Medium’s rendering can be tricky with backticks inside code blocks.)</p><p>This is the kind of detail that silently corrupts data at scale. You might not notice it in development with a handful of records. But when your UUID-based key scheme hits production and queries start returning zero results because the IDs were parsed as arithmetic expressions? That’s a bad day.</p><p>Somnia handles it transparently.</p><h3>The Expression Tree: How It All Works Under the Hood</h3><p>The query builder isn’t just string concatenation. Somnia constructs an intermediate <strong>expression tree</strong> that models SurrealQL’s grammar as Rust types.</p><p>Every expression node implements two traits:</p><pre>/// Type-erased rendering. Every node knows how to write itself to a buffer.<br>trait DynExpr {<br>    fn render_dyn(&amp;self, buf: &amp;mut String);<br>}</pre><pre>/// Typed expression with a SurrealQL type hint.<br>trait Expr: DynExpr {<br>    fn ty_hint() -&gt; &amp;&#39;static str;  // e.g., &quot;string&quot;, &quot;int&quot;, &quot;datetime&quot;<br>}</pre><p>The expression node types form a tree:</p><ul><li><strong>Literal</strong> — a typed literal value (string, int, float, bool, datetime, uuid, geometry, JSON)</li><li><strong>Column&lt;T, V&gt;</strong> — a typed column reference (generated by Post::title())</li><li><strong>Ident</strong> — a raw SurrealQL identifier (for expressions the type system doesn’t yet model)</li><li><strong>Func</strong> — a function call like count(), array::len(), type::thing()</li><li><strong>RecordLink</strong> — a typed pointer to another record (Thing references)</li><li><strong>Raw</strong> — an escape hatch for arbitrary SurrealQL fragments</li><li><strong>Binary operators</strong> — EqExpr, GtExpr, OrExpr, AndExpr, ContainsExpr, NotExpr</li><li><strong>Grouped</strong> — parenthesized sub-expressions (for precedence)</li></ul><p>When you write .filter(Post::title().eq(“Hello”)), you’re constructing an EqExpr node wrapping a Column&lt;Post, String&gt; and a Literal. When you call .and(…), you’re wrapping that in an AndExpr. When to_surrealql() is called, the tree recursively renders each node into the output buffer.</p><p>This architecture has two big advantages:</p><ol><li><strong>Composability.</strong> You can build filter expressions independently, store them in variables, combine them, and reuse them across queries. The Rust borrow checker ensures lifetimes are correct.</li><li><strong>Extensibility.</strong> Adding a new operator or expression type means implementing DynExpr (and optionally Expr) for a new struct. The rendering pipeline handles the rest.</li></ol><h3>Connection and Execution: The SomniaClient</h3><p>Somnia wraps the raw Surreal connection from the surrealdb crate with a thin, typed client:</p><pre>use somnia::SomniaClient;</pre><pre>#[tokio::main]<br>async fn main() -&gt; somnia::Result&lt;()&gt; {<br>    let client = SomniaClient::connect(<br>        &quot;ws://localhost:8000&quot;, // endpoint<br>        &quot;root&quot;,                // username<br>        &quot;root&quot;,                // password<br>        &quot;mynamespace&quot;,         // namespace<br>        &quot;mydatabase&quot;,          // database<br>    ).await?;<br>    // Typed query - deserializes into Vec&lt;T&gt;<br>    let posts: Vec&lt;Post&gt; = client<br>        .query(<br>            &amp;Post::table()<br>                .select(Post::all())<br>                .filter(Post::title().contains(&quot;SurrealDB&quot;))<br>                .limit(10)<br>        )<br>        .await?;<br>    // Typed insert - uses $data bind parameters internally<br>    let new_post = Post {<br>        id: Thing::new(&quot;post&quot;, &quot;hello-world&quot;),<br>        title: &quot;Hello&quot;.into(),<br>        body: &quot;World&quot;.into(),<br>        published_at: Some(&quot;2025-06-15&quot;.into()),<br>    };<br>    client.insert(&amp;Post::table().insert(vec![&amp;new_post])).await?;<br>    // Raw escape hatch - returns Vec&lt;serde_json::Value&gt;<br>    let raw: Vec&lt;serde_json::Value&gt; = client<br>        .raw(&quot;SELECT count() FROM post GROUP ALL&quot;)<br>        .await?;<br>    Ok(())<br>}</pre><p>The query() method renders the builder to a string, executes it against SurrealDB, and deserializes the first result set into Vec. The insert() method uses SurrealDB’s bind-parameter feature ($data) for safe record insertion.</p><p>The raw() method is your escape hatch. When you need to run a query that Somnia’s builder doesn’t yet support (window functions, advanced graph traversals, or anything niche), raw() gives you a Vec&lt;serde_json::Value&gt; with no ceremony.</p><h3>The Derive Macro: Anatomy of a SurrealRecord</h3><p>The #[derive(SurrealRecord)] proc macro is the heart of Somnia’s developer experience. Here’s what it really generates for a struct:</p><pre>#[derive(SurrealRecord)]<br>#[table(&quot;post&quot;)]<br>struct Post {<br>    #[field(thing)]<br>    id: Thing&lt;Post&gt;,<br>    title: String,<br>    published_at: Option&lt;String&gt;,<br>}</pre><p>This expands (conceptually) to:</p><pre>impl SurrealRecord for Post {<br>    fn table_name(&amp;self) -&gt; &amp;&#39;static str { &quot;post&quot; }<br>    fn primary_key(&amp;self) -&gt; Column&lt;Post, Thing&lt;Post&gt;&gt; { Self::id() }<br>    fn table() -&gt; Table&lt;Post&gt; { Table::new(&quot;post&quot;) }<br>}</pre><pre>impl Post {<br>    pub fn id() -&gt; Column&lt;Post, Thing&lt;Post&gt;&gt; {<br>        Column::new(&quot;post&quot;, &quot;id&quot;)<br>    }<br>    pub fn title() -&gt; Column&lt;Post, String&gt; {<br>        Column::new(&quot;post&quot;, &quot;title&quot;)<br>    }<br>    pub fn published_at() -&gt; Column&lt;Post, Option&lt;String&gt;&gt; {<br>        Column::new(&quot;post&quot;, &quot;published_at&quot;)<br>    }<br>    pub fn all() -&gt; ColumnSet&lt;Post&gt; {<br>        ColumnSet::new(vec![<br>            Self::id().erase(),<br>            Self::title().erase(),<br>            Self::published_at().erase(),<br>        ])<br>    }<br>}<br>impl SurrealSchema for Post {<br>    fn up() -&gt; String {<br>        // DEFINE TABLE IF NOT EXISTS post SCHEMAFULL PERMISSIONS FULL;<br>        // DEFINE FIELD IF NOT EXISTS title ON TABLE post TYPE string;<br>        // DEFINE FIELD IF NOT EXISTS published_at ON TABLE post TYPE option&lt;string&gt;;<br>    }<br>    fn down() -&gt; String {<br>        // REMOVE TABLE IF EXISTS post;<br>    }<br>}</pre><p>The #[field(thing)] annotation tells the macro that id is the SurrealDB record ID (which gets special treatment — it’s omitted from DDL since SurrealDB manages it automatically, and primary_key() returns it).</p><p>Other field attributes:</p><p>AttributeEffect#[field(thing)]Mark as SurrealDB record ID#[field(record = “other_table”)]Typed record link#[field(name = “different_name”)]Override the SurrealDB field name#[field(default = “expr”)]Set a DEFAULT expression in DDL#[field(value = “expr”)]Set a VALUE expression (computed field)#[field(flexible)]Allow flexible typing#[field(skip)]Omit from schema generation#[field(ty = “custom_type”)]Override the inferred SurrealQL type</p><p>The type inference is currently string-based — the macro inspects the Rust type string for keywords like “String”, “i64”, “Option” and maps them to SurrealQL types. This is pragmatic and works for the common cases, but richer type analysis (using syn to walk the actual type tree) is on the roadmap.</p><h3>Real-World Patterns</h3><h3>Pattern 1: Repository Layer</h3><p>Wrap Somnia in a repository that encapsulates query logic:</p><pre>struct PostRepository {<br>    client: SomniaClient,<br>}</pre><pre>impl PostRepository {<br>    pub async fn find_by_title(&amp;self, title: &amp;str) -&gt; Result&lt;Vec&lt;Post&gt;&gt; {<br>        self.client.query(<br>            &amp;Post::table()<br>                .select(Post::all())<br>                .filter(Post::title().eq(title))<br>                .limit(50)<br>        ).await<br>    }<br>    pub async fn find_recent(&amp;self, since: &amp;str) -&gt; Result&lt;Vec&lt;Post&gt;&gt; {<br>        self.client.query(<br>            &amp;Post::table()<br>                .select(Post::all())<br>                .filter(ident(&quot;published_at&quot;).greater_than(since))<br>                .order_desc(ident(&quot;published_at&quot;))<br>                .limit(20)<br>        ).await<br>    }<br>    pub async fn create(&amp;self, post: &amp;Post) -&gt; Result&lt;Vec&lt;Post&gt;&gt; {<br>        self.client.query(<br>            &amp;Post::table()<br>                .create()<br>                .content(post)<br>                .returning(Returning::After)<br>        ).await<br>    }<br>}</pre><h3>Pattern 2: Schema Verification in CI</h3><p>Generate the DDL during CI and compare it against production:</p><pre>#[test]<br>fn schema_ddl_matches_expected() {<br>    // This test fails if someone changes a struct without updating the snapshot<br>    insta::assert_snapshot!(Post::up());<br>}</pre><p>Or, more ambitiously, connect to a staging SurrealDB instance, run the generated DDL, and verify no errors:</p><pre>#[tokio::test]<br>async fn schema_up_is_valid_surrealql() {<br>    let client = SomniaClient::connect(&quot;mem://&quot;, &quot;&quot;, &quot;&quot;, &quot;&quot;, &quot;&quot;).await.unwrap();<br>    client.raw(&amp;Post::up()).await.expect(&quot;Schema DDL should be valid&quot;);<br>    client.raw(&amp;Post::down()).await.expect(&quot;Schema teardown should be valid&quot;);<br>}</pre><h3>Pattern 3: Composable Filter Functions</h3><p>Build reusable filter predicates:</p><pre>fn published_since(cutoff: &amp;str) -&gt; impl Expr {<br>    ident(&quot;published_at&quot;).greater_than(cutoff)<br>}</pre><pre>fn title_contains(phrase: &amp;str) -&gt; impl Expr {<br>    Post::title().contains(phrase.to_string())<br>}<br>fn authored_by(author: &amp;str) -&gt; impl Expr {<br>    Post::title().eq(author.to_string())<br>}<br>// Compose them<br>let filter = published_since(&quot;2025-01-01&quot;)<br>    .and(title_contains(&quot;SurrealDB&quot;).or(authored_by(&quot;Vikram&quot;)));<br>let query = Post::table()<br>    .select(Post::all())<br>    .filter(filter)<br>    .limit(25)<br>    .to_surrealql();</pre><h3>Pattern 4: Batch Operations</h3><p>Group multiple statements into a single round-trip:</p><pre>use somnia::query::Batch;</pre><pre>let batch = Batch::new()<br>    .statement(<br>        Post::table()<br>            .create()<br>            .content(&amp;post1)<br>            .returning(Returning::After)<br>    )<br>    .statement(<br>        Post::table()<br>            .create()<br>            .content(&amp;post2)<br>            .returning(Returning::After)<br>    )<br>    .statement(<br>        Post::table()<br>            .select(Post::all())<br>            .filter(Post::title().eq(&quot;batch test&quot;))<br>    );<br>// Execute as one network call; results come back in order<br>let results = client.query(&amp;batch).await?;</pre><h3>The CLI: Project Tooling Beyond the Library</h3><p>The somnia CLI binary is modeled on diesel-cli. Install it with:</p><pre>cargo install somnia-cli                       # from crates.io<br>brew tap vbasky/somnia &amp;&amp; brew install somnia  # or via Homebrew</pre><p>The workflow for a new migration:</p><p><strong>1. Scaffold a migration directory.</strong> Run somnia migration generate create_posts_table, which creates:</p><pre>migrations/2025-06-15-142300_create_posts_table/<br>  ├── up.surql<br>  └── down.surql</pre><p><strong>2. Edit </strong><strong>up.surql</strong> with your DDL:</p><pre>DEFINE TABLE IF NOT EXISTS post SCHEMAFULL;<br>DEFINE FIELD IF NOT EXISTS title ON TABLE post TYPE string;<br>...</pre><p><strong>3. Edit </strong><strong>down.surql</strong> with the reverse:</p><pre>REMOVE TABLE IF EXISTS post;</pre><p><strong>4. Apply it.</strong> Run somnia migration run.</p><p><strong>5. Check what’s applied.</strong> Run somnia migration list — it prints ✓ 2025-06-15-142300_create_posts_table.</p><p><strong>6. Iterate.</strong> somnia migration redo reverts and re-applies (good for development); somnia migration revert reverts the most recent.</p><p>The CLI connects to the same SurrealDB instances your application uses, tracking migration state in the _somnia_migrations table. Multiple developers can run migrations against the same database safely — the tracking table ensures each migration is applied exactly once.</p><h3>What Somnia Isn’t (Yet)</h3><p>I want to be upfront about the current limitations. Somnia is at version 0.4.1, and there are things it doesn’t do:</p><p><strong>No ODM / Active Record.</strong> Somnia is a query builder and thin client, not an object-document mapper. There’s no change tracking, no dirty-checking, no post.save(). You write queries, they execute, you get results. A persistence ergonomics layer could be built on top, but that’s not Somnia’s job.</p><p><strong>No Graph Traversal Helpers.</strong> SurrealDB supports path expressions like person-&gt;authored-&gt;post, but Somnia doesn’t yet have typed graph traversal in SELECTs. This is P1 on the roadmap.</p><p><strong>No Live Queries.</strong> SurrealDB’s LIVE SELECT for real-time updates isn’t modeled in the builder yet. P3.</p><p><strong>No Transaction Wrappers.</strong> BEGIN TRANSACTION / COMMIT / CANCEL aren’t wrapped. You can use raw() for this. P2.</p><p><strong>String-Based Type Inference.</strong> The derive macro does simple string matching on Rust type names to infer SurrealQL types. Complex generics, custom types, and newtype wrappers may need explicit #[field(ty = “…”)] annotations. Richer type analysis is P1.</p><p><strong>Not Production-Battle-Tested at Scale.</strong> The covered features work correctly, but this is a young project (0.4.x). Comprehensive CI validates every commit, but you should test thoroughly for your use case.</p><h3>Getting Started in 5 Minutes</h3><p>Add Somnia to your project:</p><pre>[dependencies]<br>somnia = &quot;0.4&quot;<br>serde = { version = &quot;1&quot;, features = [&quot;derive&quot;] }<br>tokio = { version = &quot;1&quot;, features = [&quot;full&quot;] }<br>surrealdb = &quot;2&quot;  # required for the SurrealDB client</pre><p>Define a record and connect:</p><pre>use somnia::prelude::*;<br>use serde::{Deserialize, Serialize};</pre><pre>#[derive(Debug, Clone, Serialize, Deserialize, SurrealRecord)]<br>#[table(&quot;user&quot;)]<br>struct User {<br>    #[field(thing)]<br>    id: Thing&lt;User&gt;,<br>    name: String,<br>    email: String,<br>}<br>#[tokio::main]<br>async fn main() -&gt; somnia::Result&lt;()&gt; {<br>    // Connect to SurrealDB (in-memory for testing, ws:// for real)<br>    let client = SomniaClient::connect(<br>        &amp;std::env::var(&quot;SURREAL_ENDPOINT&quot;)<br>            .unwrap_or_else(|_| &quot;mem://&quot;.into()),<br>        &amp;std::env::var(&quot;SURREAL_USER&quot;).unwrap_or_else(|_| &quot;root&quot;.into()),<br>        &amp;std::env::var(&quot;SURREAL_PASS&quot;).unwrap_or_else(|_| &quot;root&quot;.into()),<br>        &amp;std::env::var(&quot;SURREAL_NS&quot;).unwrap_or_else(|_| &quot;test&quot;.into()),<br>        &amp;std::env::var(&quot;SURREAL_DB&quot;).unwrap_or_else(|_| &quot;test&quot;.into()),<br>    ).await?;<br>    // Set up the schema<br>    client.raw(&amp;User::up()).await?;<br>    // Insert a user<br>    let user = User {<br>        id: Thing::new(&quot;user&quot;, &quot;1&quot;),<br>        name: &quot;Ada Lovelace&quot;.into(),<br>        email: &quot;ada@computing.org&quot;.into(),<br>    };<br>    client.insert(&amp;User::table().insert(vec![&amp;user])).await?;<br>    // Query it back<br>    let users: Vec&lt;User&gt; = client.query(<br>        &amp;User::table()<br>            .select(User::all())<br>            .filter(User::name().eq(&quot;Ada Lovelace&quot;))<br>    ).await?;<br>    assert_eq!(users.len(), 1);<br>    println!(&quot;Found user: {:?}&quot;, users[0]);<br>    // Clean up<br>    client.raw(&amp;User::down()).await?;<br>    Ok(())<br>}</pre><h3>Why I Built This</h3><p>I’ve spent too many late nights debugging SurrealQL strings. A missing backtick here, a wrong quote character there, a UUID that got parsed as math — these are the bugs that slip past code review because they look correct at a glance. They only surface when data hits a specific shape, or when a user types a special character, or when you upgrade SurrealDB and the parser gets stricter.</p><p>Somnia is my answer to that frustration. It moves SurrealDB interaction errors from runtime to compile time. It makes the Rust compiler your first line of defense against broken queries. And it treats the database schema as a first-class citizen of your codebase — derivable, testable, and version-controlled.</p><p>The project is Apache-2.0 licensed, actively maintained, and tested against SurrealDB 3.x. If you’re building Rust applications on SurrealDB, I’d love for you to try it, open issues, and contribute.</p><h3>Links</h3><ul><li><strong>GitHub:</strong> <a href="https://github.com/vbasky/somnia">github.com/vbasky/somnia</a></li><li><strong>Crates.io:</strong> <a href="https://crates.io/crates/somnia">crates.io/crates/somnia</a></li><li><strong>Documentation:</strong> <a href="https://docs.rs/somnia">docs.rs/somnia</a></li><li><strong>Homebrew:</strong> brew tap vbasky/somnia &amp;&amp; brew install somnia</li></ul><p><em>Have you hit query-string bugs in production? What’s your approach to type-safe database access in Rust? Drop a comment — I’d love to hear your war stories and ideas for what Somnia should tackle next.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7341d49bd5c4" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Measure, Don’t Guess: Building viser, a Content-Adaptive Video Encoding Optimizer in Rust]]></title>
            <link>https://medium.com/@vbasky/measure-dont-guess-building-viser-a-content-adaptive-video-encoding-optimizer-in-rust-7675edd6943a?source=rss-5d48085a669c------2</link>
            <guid isPermaLink="false">https://medium.com/p/7675edd6943a</guid>
            <category><![CDATA[video-encoding]]></category>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[algorithms]]></category>
            <dc:creator><![CDATA[Vikram Bhaskaran]]></dc:creator>
            <pubDate>Fri, 05 Jun 2026 04:48:02 GMT</pubDate>
            <atom:updated>2026-07-05T11:10:07.754Z</atom:updated>
            <content:encoded><![CDATA[<blockquote>Most video on the internet is encoded with a one-size-fits-all bitrate ladder that wastes bandwidth on simple content and starves complex content. viser is a bet that we can do better — by measuring every video instead of guessing.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7p47XNWv-PtYUQbn0xbGVA.png" /></figure><p>There is a small act of quiet waste happening millions of times a day. Somewhere a streaming service takes a video — a news anchor reading the headlines, a fast-cut action sequence, a grainy noir thriller — and runs all of them through the <em>same</em> table of bitrates. The anchor gets far more bits than her flat, static frame could ever use. The thriller gets nowhere near enough, and its shadows dissolve into blocky mud. The table doesn’t care. It was written once, in 2010, and it has been copied ever since.</p><p>viser is built on a simple conviction: <strong>every video deserves to be looked at.</strong> Not guessed at from a lookup table, not predicted from a thumbnail — actually encoded, actually measured, and given the bitrate ladder that wrings the most perceptual quality out of every single bit. The name blends <em>vision</em> and <em>optimizer</em> (and nods to French <em>viser</em>, “to aim”). It is fifteen Rust crates, roughly 8,000 lines, and a belief that the right answer is worth the work of finding it.</p><p>This is the story of how it works, why the hard parts are hard, and what it took to make a tool whose every numerical claim you can reproduce yourself.</p><h3>The Problem: One Ladder Does Not Fit All</h3><p>Every streaming service faces the same decision, over and over: at what resolutions and bitrates do I store this video so a player can adaptively switch between rungs as the network changes?</p><p>The easy answer is a <strong>fixed bitrate ladder</strong> — one table of (resolution, bitrate) rungs applied to everything. Apple’s original 2010 HLS recommendation defined ten rungs from 64 kbps up to 8564 kbps at 1080p, and most of the industry copied it. It is simple, and it is wasteful, because content does not have uniform complexity:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1008/1*KuhG_00gym5FvQbueQKnzg.png" /></figure><p>A fixed ladder <em>simultaneously</em> overspends on the anchor and starves the thriller. The talking head reaches transparent quality at 1.5 Mbps and 720p; the action sequence needs 8 Mbps and 1080p to reach the <em>same</em> perceived quality. Netflix proved the fix in 2015 — <strong>per-title encoding</strong> — and demonstrated 20–30% bitrate savings. The catch is that doing it correctly is a measurement problem, and measurement problems are where good intentions quietly go wrong.</p><p>viser exists to do that measurement correctly, at four levels of granularity, for three codecs, with results you can reproduce.</p><h3>Four Granularities, One Framework</h3><p>“Content-adaptive encoding” is not one algorithm. It is a family, distinguished by <em>how finely</em> you adapt:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b5xEGSmxxeWWbfKOwlvC4w.png" /></figure><p>All four sit on the same foundation, and all four had to be right not just in <em>shape</em> but in <em>number</em>. A convex hull that is “roughly right” produces a ladder that is roughly right, which means bandwidth you can measure on a bill. The whole craft of the project is in turning “roughly” into “exactly.”</p><h3>When There’s No Single Right Answer to Diff Against</h3><p>Here is what makes this kind of tool intimidating to build: the output of a per-title analysis is a <em>judgment</em> — a set of rungs — and reasonable engineers disagree about the last rung. There is no canonical perfect-ladder binary you can run alongside yours and demand identical output. So what do you validate against?</p><p>The breakthrough is to realize that <strong>the oracle is the published science, decomposed into pieces that each have a knowable right answer:</strong></p><ol><li><strong>The convex hull</strong> has a correct answer. Given a set of (bitrate, VMAF) points, the upper Pareto frontier is a deterministic geometric object. Andrew’s monotone chain produces it in O(n log n), exactly. You can pin it against hand-computed hulls: empty input, a single point, all-collinear points, interior points that <em>must</em> be removed, unsorted input.</li><li><strong>BD-Rate</strong> — the Bjøntegaard delta that says “codec A needs X% less bitrate than codec B for equal quality” — is a published numerical method with reference implementations and known outputs. Feed in the canonical test vectors; demand the same percentage back.</li><li><strong>The reference ladders</strong> are public. Netflix and Apple’s fixed ladders are documented rung-for-rung. viser carries them as comparison baselines, and a test asserts they are reproduced exactly — because a tool that can’t even echo a <em>fixed</em> ladder has no business computing an adaptive one.</li><li><strong>The encode and measurement</strong> bottom out at ffmpeg and libvmaf. viser orchestrates them rather than reimplementing them, so the oracle for “did this encode hit the target bitrate” is ffmpeg’s own output, probed back and checked.</li></ol><p>This decomposition is the heart of the whole thing. You cannot diff a <em>judgment</em> — but you can diff every <em>input</em> to the judgment. Get the hull exactly right, get BD-Rate exactly right, get the bitrate measurement exactly right, and the judgment that rides on top of them is correct by construction. Every module in viser had to answer one question: <em>what could I be objectively wrong about?</em> — and then a test was written to pin it. That discipline is what turns an intimidating problem into a tractable one.</p><h3>Architecture: Fifteen Crates in Four Layers</h3><p>viser is fifteen crates arranged in four strict layers, each crate with a single responsibility:</p><pre>Foundation   viser-ffmpeg     FFmpeg/FFprobe wrapper — encode, probe, cache<br>             viser-quality    VMAF / PSNR / SSIM / SSIMULACRA2 / Butteraugli<br>             viser-encoding   Shared config, preset mapping, temp cleanup<br>             viser-checkpoint SHA-256 resumable state<br><br>Core         viser-hull       Convex hull (Pareto frontier) + BD-Rate<br>             viser-ladder     Rung selection with crossover enforcement<br>             viser-shot       Shot/scene detection (FFmpeg scdet)<br>             viser-complexity Spatial / temporal / DCT analysis<br>Pipelines    viser-pertitle   Whole-video ladder<br>             viser-pershot    Per-shot + Trellis bit allocation<br>             viser-persegment Segment-level CRF adaptation<br>             viser-contextaware Device-specific ladders<br>Application  viser-cli        The clap binary<br>             viser-compare    Browser comparison player<br>             viser-chart      R-D curve charts (plotters)</pre><p>The dependency graph only ever points <em>down</em>. A pipeline crate composes foundation and core crates; it never reaches sideways into another pipeline. viser-pershot is allowed to depend on viser-pertitle (a shot is just a short title), but viser-pertitle knows nothing about shots.</p><p>This is where Rust’s crate model earns its keep. Every cross-module call is an explicit, typed, public API — there is no accidental reach-around between optimization logic and ffmpeg-shelling logic, because the crate boundary makes it impossible to write one by accident. Fifteen shallow crates, not a deep inheritance tree. Wide and flat, where each piece is small enough to hold in your head and test in isolation.</p><h3>Race the Encodes, Walk the Budget</h3><p>A full per-title search is 5 resolutions × 9 CRF values × 3 codecs = 135 trial encodes. Serially, that&#39;s an afternoon. All at once, it&#39;s a fork bomb that thrashes the machine into swap and produces <em>slower</em> wall-clock time than a careful subset. The art is in racing the encodes while walking the resource budget — and viser models that bound explicitly:</p><pre>// Bound concurrent encodes to num_cpus/2 — encoders are already<br>// internally threaded, so oversubscribing thrashes the cache.<br>let permits = Arc::new(Semaphore::new(num_cpus::get() / 2));</pre><pre>let trials = trial_matrix.into_iter().map(|trial| {<br>    let permit = permits.clone();<br>    async move {<br>        let _slot = permit.acquire().await?;   // backpressure, not a fork bomb<br>        let encoded = encode(&amp;trial).await?;<br>        let vmaf = measure_vmaf(&amp;source, &amp;encoded).await?;<br>        Ok(RdPoint::new(trial, encoded.bitrate, vmaf))<br>    }<br>});</pre><p>The semaphore is the entire concurrency story, and it is <em>visible</em>. There is no hidden task that outlives its parent, no channel that deadlocks if a consumer panics. Tokio’s structured concurrency means a failed trial propagates and cancels the rest rather than orphaning them.</p><p>And because every long analysis is checkpointed — each completed trial hashed by its (resolution, codec, CRF, preset) config under a SHA-256 key — a crash three hours into a 135-trial sweep resumes from trial 130, not trial zero. The same hash <em>is</em> the cache invalidation: change the preset, the hash changes, stale results are silently ignored. Long, expensive work that survives a crash is not a luxury; it&#39;s what makes the whole &quot;measure everything&quot; philosophy practical instead of fragile.</p><h3>What Makes This Genuinely Hard</h3><p>It would be dishonest to pretend the language makes this correct. Rust makes it <em>safe</em>; the domain makes it hard, and no language helps with the domain. The genuinely difficult parts are worth naming, because they are also the parts that make the project interesting:</p><p><strong>Resolution crossovers are content-dependent.</strong> Each resolution traces an R-D curve that flattens at a quality ceiling — 480p tops out around VMAF 98 because downsampling destroys high-frequency detail that no number of bits can recover; 1080p reaches 99.7. The bitrate at which 1080p <em>overtakes</em> 720p on the hull is not a constant. For a talking head it happens low; for film grain it happens high, because the lower resolution hasn’t hit its ceiling yet. The hull finds the crossover automatically — but only if you compute it across <em>all</em> resolutions jointly, a subtlety that quietly produces a plausible-but-wrong ladder if you get it backwards.</p><p><strong>VMAF is expensive, and the cost compounds.</strong> Every one of those 135 trials needs a VMAF measurement, and VMAF runs on the CPU through libvmaf. The honest accelerations — n_subsample=5 to measure every fifth frame, fast presets for the search and slow presets only for final delivery, eliminating dominated resolutions after a single probe CRF — are all <em>approximations</em>, each trading accuracy for time. So viser exposes them as flags rather than baking in a guess. The trade is the user&#39;s to make, with eyes open.</p><p><strong>Per-shot allocation is a global optimization, not a local one.</strong> You cannot optimize each shot independently — that spends your whole budget on the first complex shot. The correct method is Trellis/Lagrangian allocation: find the single quality-per-bit slope λ at which the <em>sum</em> of per-shot bitrates hits your global target, then assign every shot the operating point at that slope. It’s a one-dimensional search over λ with a convex-hull lookup per shot inside the loop, and every failure mode (empty hull, single shot, identical shots, duration weighting) earned its own test.</p><p><strong>HDR breaks the metric.</strong> libvmaf is SDR-centric. An HDR source measured with an SDR model produces a number that <em>looks</em> fine and <em>means</em> nothing. viser detects HDR from probe metadata and refuses it by default, gated behind an explicit --allow-hdr for people who know they&#39;re doing something best-effort. Honesty as a feature, refusal as a kindness.</p><p>None of these are language problems. They are video problems that any serious implementation has to get right — which is exactly why the 170+ tests exist: the compiler cannot catch a wrong crossover bitrate, so a human writes the assertion that does.</p><h3>The Heart: Convex Hull and BD-Rate</h3><p>The core of the whole project is about 540 lines in viser-hull, and it is the part I trust most, because it is the part with the least ambiguity. The upper hull is textbook Andrew&#39;s monotone chain, specialized to (bitrate, quality) space:</p><pre>/// Upper convex hull of R-D points: the Pareto frontier where no other<br>/// configuration achieves higher quality at the same or lower bitrate.<br>fn upper_hull(mut points: Vec&lt;RdPoint&gt;) -&gt; Vec&lt;RdPoint&gt; {<br>    points.sort_by(|a, b| a.bitrate.total_cmp(&amp;b.bitrate));<br>    let mut hull: Vec&lt;RdPoint&gt; = Vec::new();<br>    for p in points {<br>        // Pop while the last turn is clockwise or collinear — those<br>        // middle points are dominated and must leave the frontier.<br>        while hull.len() &gt;= 2 &amp;&amp; cross(hull[hull.len() - 2], hull[hull.len() - 1], p) &gt;= 0.0 {<br>            hull.pop();<br>        }<br>        hull.push(p);<br>    }<br>    hull<br>}</pre><p>total_cmp instead of partial_cmp is a small thing that matters: a stray NaN from a failed measurement must not silently corrupt the sort, and Rust forces you to confront float ordering rather than let &lt; quietly do the wrong thing. The crate is #![forbid(unsafe_code)]; nothing in hull geometry needs raw pointers.</p><p>BD-Rate sits beside it — the cubic fit over log(bitrate) versus quality, integrated over the overlapping quality range, returned as a percentage. It has the most published reference outputs, and therefore the most assertive tests: minimum-point handling, negative efficiency (when codec A is <em>worse</em>), non-overlapping ranges, and the near-singular matrices the cubic fit produces on pathological inputs.</p><h3>Validation: Reproduce the Known Before Trusting the Novel</h3><p>The test suite is 170+ tests across the workspace, and its philosophy is “pin everything that has a knowable answer”:</p><pre>cargo test -p viser-hull        # convex hull, BD-rate — 24 tests<br>cargo test -p viser-ladder      # rung selection, crossover, savings — 19 tests<br>cargo test -p viser-pershot     # Trellis Lagrangian allocation — 12 tests<br>cargo test -p viser-complexity  # complexity + screen-content detection — 21 tests<br>cargo test -p viser-ffmpeg      # probe, encode args, metadata adapter — 26 tests</pre><p>The coverage maps directly onto “what could each module be wrong about”:</p><ul><li><strong>Hull:</strong> empty, single point, interior-point removal, unsorted input, per-codec separation.</li><li><strong>BD-Rate:</strong> minimum points, negative efficiency, non-overlap, singular matrices, cubic fit.</li><li><strong>Trellis:</strong> empty, single shot, duration weighting, identical shots, empty-hull fallback, λ-search bounds.</li><li><strong>Ladder:</strong> empty, zero rungs, bitrate/VMAF filters, max-VMAF cap, sorted output, <strong>the Netflix and Apple reference ladders reproduced exactly</strong>, savings computation.</li><li><strong>Screen content:</strong> slides classified 90%, natural video 0%, code-capture 70%, empty input.</li></ul><p>The whole workspace runs in about half a second, which means the tests run on every save, which means the math stays pinned. Fast tests are honest tests — the ones too slow to run get run never, and the modules they protect drift. The single most valuable test in the suite is the cheapest one: the assertion that viser reproduces the Netflix and Apple <em>fixed</em> ladders rung-for-rung. Reproduce the known before you trust the novel.</p><h3>Safety and Honesty</h3><p>The pure-math and orchestration crates are unsafe-free, the workspace denies the usual footgun lints, and the whole thing is edition = &quot;2024&quot; on a pinned MSRV of 1.88. There is no unsafe in hull geometry, ladder selection, or Trellis allocation, because none of those domains need it — they are arithmetic and Vecs.</p><p>What Rust really buys here isn’t raw speed — the bottleneck is ffmpeg, not the optimizer. It’s the <em>elimination of a class of silent wrongness</em>. A missing bitrate can’t quietly default to 0 and distort a frontier; Option, exhaustive match, and the absence of an implicit zero-value force every &quot;what if this is missing&quot; to become a decision the compiler makes you write down. In a tool whose entire value is the trustworthiness of a number, that guarantee is the foundation everything else stands on.</p><h3>The Frontier: Beyond the Obvious</h3><p>The four methods are the spine, but the most exciting part of viser is where it goes past the textbook:</p><ul><li><strong>Two extra perceptual metrics</strong> — SSIMULACRA2 and Butteraugli run in viser-quality alongside VMAF, PSNR, and SSIM. VMAF is excellent, but it is one model; cross-checking against a second perceptual metric catches the cases where VMAF is over-confident.</li><li><strong>Screen-content detection.</strong> Slides, code screencasts, and UI captures behave nothing like camera footage — static frames, razor-sharp edges, DCT energy concentrated completely differently. viser-complexity classifies content from spatial/temporal/DCT heuristics so the pipeline can choose a strategy instead of treating a slide deck like a football game.</li><li><strong>A pure-Rust probe engine — revelo.</strong> By default viser reads metadata with ffprobe, but an optional engine called <em>revelo</em> (--features revelo) extracts resolution, frame rate, codec, color space, and dynamic range with no external binary on the path at all. More on what that unlocks below.</li><li><strong>Audio-aware ladder budgets.</strong> per-title analyze detects the source audio bitrate and reserves it, so a &quot;3 Mbps&quot; rung is 3 Mbps of <em>total</em> stream, not 3 Mbps of video plus whatever audio happens to cost.</li><li><strong>A browser comparison player</strong> that shows reference and encoded side-by-side with a per-frame VMAF timeline — because at some point a human has to <em>look</em>, and a number on a chart is not the same as seeing the artifact disappear.</li></ul><h3>What I’d Want You to Take From This</h3><p>If there’s a lesson worth carrying out of this project, it’s smaller and more hopeful than “rewrite it in Rust”:</p><ol><li><strong>When you can’t diff the answer, diff the inputs to the answer.</strong> A judgment isn’t testable; the geometry, the numerical method, and the measurement underneath it all are. Decompose until every piece has a knowable right answer, and the judgment becomes correct by construction. This turns intimidating, “expert-only” problems into ones an ordinary careful engineer can ship.</li><li><strong>Reproduce the known before trusting the novel.</strong> The cheapest test can be the most valuable one. If your tool can’t echo a published reference, it has no authority to compute something new.</li><li><strong>Make your approximations into flags, not assumptions.</strong> Every speed/accuracy trade you hide is a guess you’re making on someone else’s behalf. Expose it, and the trade becomes their informed choice.</li><li><strong>Measure, don’t guess.</strong> The whole project is one sentence long: look at the actual video, run the actual encodes, trust the number you can reproduce. It is more work than a lookup table. It is also just <em>better</em>, and the difference shows up on a bandwidth bill and in a viewer’s eyes.</li></ol><h3>The Result</h3><p>viser is three things at once, depending on how you reach for it:</p><pre># A CLI: compute an optimal ladder across codecs and resolutions<br>viser per-title analyze -i video.mp4 \<br>  --codecs libx264,libsvtav1 \<br>  --resolutions 480p,720p,1080p \<br>  --preset veryfast --parallel 4 -o analysis.json</pre><pre># Then deliver the selected rungs as final encodes<br>viser per-title deliver --analysis analysis.json \<br>  --output-dir delivery --mode capped-crf --manifest delivery/manifest.json</pre><pre># A probe — with no ffprobe required<br>viser inspect probe video.mp4 --probe-engine revelo</pre><pre># And a set of Rust crates you can compose yourself</pre><p>Four optimization methods, three codecs, five quality metrics, fifteen crates, about 8,000 lines of unsafe-free Rust, under a permissive BSD-2-Clause license — and every numerical claim pinned to a test you can run in half a second.</p><pre>cargo install viser-cli --features revelo</pre><p>Most video on the internet is still encoded by a table written before the iPhone had a Retina display. It doesn’t have to be. Every video deserves to be looked at — and now there’s a tool that does exactly that.</p><p><em>viser is open source at </em><a href="https://github.com/vbasky/viser"><em>github.com/vbasky/viser</em></a><em>.</em></p><h3>One More Thing: How revelo Changes the Equation</h3><p>Everything above quietly leans on a dependency I’ve barely mentioned: to set up its trial matrix, viser has to <em>read</em> the video first — resolution, frame rate, codec, color space, dynamic range. The default way to do that is to shell out to ffprobe, which means a native binary has to be installed, found on PATH, version-matched, and trusted to parse a container the same way every time. For a tool whose whole promise is <em>reproducibility</em>, leaning on an external parser is the one soft spot in the foundation.</p><p><strong>revelo</strong> is the answer to that — a pure-Rust probe engine built directly into viser. Enable it with --features revelo, ask for it with --probe-engine revelo, and viser reads container and stream metadata itself, in process, with nothing external on the path:</p><pre>cargo install viser-cli --features revelo<br>viser inspect probe video.mp4 --probe-engine revelo   # no ffprobe anywhere</pre><p>That single change rewrites the equation in a few ways:</p><ul><li><strong>One fewer native dependency.</strong> The metadata path becomes pure Rust. Distribution gets dramatically simpler — closer to “download one binary and run it” than “install this toolchain first.”</li><li><strong>Reproducibility all the way down.</strong> The probe is no longer a black box whose behavior depends on which ffprobe build happens to be installed. The same input yields the same parse, pinned by the same fast tests as the rest of the workspace (codec mapping, color transfer, pixel format, frame-rate formatting all have their own assertions).</li><li><strong>Memory safety on untrusted input.</strong> Parsing arbitrary media containers is exactly the kind of byte-wrangling that has produced a decade of CVEs in C parsers. Doing it in unsafe-free Rust turns a whole class of buffer bugs into compile-time impossibilities — which matters the moment you point the tool at a file you didn&#39;t create.</li></ul><p>ffprobe stays the friendly default, because it is battle-tested and ubiquitous. But revelo is the direction of travel: a video optimizer that, end to end, depends on nothing but itself — and answers you can reproduce on any machine, with no toolchain to install first. The measurement philosophy, finally, reaches all the way down to the first byte.</p><h3>Additional Reading</h3><p>If this piece sparked your curiosity, the science docs go a layer deeper:</p><ul><li><a href="https://github.com/vbasky/viser/blob/main/docs/per-title-encoding.md">Per-Title Encoding</a> — convex hulls, rate-distortion optimization, and how a ladder gets selected</li><li><a href="https://github.com/vbasky/viser/blob/main/docs/per-shot-encoding.md">Per-Shot Encoding</a> — shot detection and Trellis constant-slope bit allocation</li><li><a href="https://github.com/vbasky/viser/blob/main/docs/segment-level-adaptation.md">Segment-Level CRF</a> — closed-loop CRF tuning with complexity analysis</li><li><a href="https://github.com/vbasky/viser/blob/main/docs/quality-metrics.md">Quality Metrics</a> — VMAF, PSNR, SSIM, and BD-Rate, demystified</li><li><a href="https://netflixtechblog.com/per-title-encode-optimization-7e99442b62a2">Netflix: Per-Title Encode Optimization</a> — the 2015 post that started it all</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7675edd6943a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Porting 300,000 Lines of C++ and Perl to Rust: A Dual-Oracle Media Metadata Engine]]></title>
            <link>https://medium.com/@vbasky/porting-200-000-lines-of-c-to-rust-building-a-byte-identical-mediainfo-replacement-8e9b587d469a?source=rss-5d48085a669c------2</link>
            <guid isPermaLink="false">https://medium.com/p/8e9b587d469a</guid>
            <category><![CDATA[video-codec]]></category>
            <category><![CDATA[mediainfo]]></category>
            <category><![CDATA[audio-codec]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Vikram Bhaskaran]]></dc:creator>
            <pubDate>Fri, 29 May 2026 03:36:40 GMT</pubDate>
            <atom:updated>2026-06-27T14:55:38.553Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zNPKQsyDKl1xrq6-QBMUpQ.png" /></figure><p>How I replaced both a 25-year-old C++ media analyzer and a 30-year-old Perl EXIF tool with a pure Rust library — matching their output byte-for-byte and tag-for-tag, while making it faster, safer, and installable with cargo install.</p><h3>The Problem</h3><p>Every video and photo file carries hidden metadata. Codec profiles, color spaces, HDR luminance values, frame rates, audio channel layouts, bitrates, encoder settings, GPS coordinates, lens specifications, maker-note camera settings. For decades, two tools have been the undisputed standards:</p><ul><li><strong>MediaInfoLib</strong> (C++): extracts container/codec metadata from ~200 media formats</li><li><strong>ExifTool</strong> (Perl): extracts EXIF/maker-note metadata from thousands of camera models</li></ul><p>Both work. Both are painful to integrate.</p><h3>MediaInfoLib</h3><p>Try adding it to a modern project. First you build it — ./Configure &amp;&amp; make &amp;&amp; make install, which pulls in 10+ system library dependencies (zlib, libcurl, libmms, libzen, libtinyxml2, and more). Each has its own version constraints. If you&#39;re on macOS, Homebrew helps. On Windows, you&#39;re in for a bad afternoon. In CI, every container image needs the full toolchain. Cross-compile? Good luck.</p><p>Integration options:</p><ul><li><strong>Shell out</strong> as a subprocess, capture XML, parse it back. Lose type safety, pay the serialization round-trip, error handling is “parse the stderr string.”</li><li><strong>Link via C ABI</strong>. Manage a native dependency across platforms. The C ABI was designed for C++ consumption — char* output buffers, manually-managed handles, integer stream kind discriminants you must get exactly right.</li></ul><h3>ExifTool</h3><p>Written in Perl. Amazing tool — 30,000+ tags, 50+ camera makes. But embedding it means shipping a Perl runtime, managing CPAN dependencies, and accepting that your parser lives in a scripting language with no memory safety guarantees.</p><p>Both codebases touch attacker-controlled binary data. 200,000 lines of C++ and Perl parsing crafted MP4 boxes and EXIF IFDs. Every file is a potential exploit vector.</p><p>This is exactly the problem Rust was built to solve.</p><h3>The Challenge: Not One Oracle, But Two</h3><p>Let me be clear about what “port” means here. This is not a wrapper. It’s a complete from-scratch reimplementation in Rust that produces:</p><ul><li><strong>Byte-identical XML output to MediaInfoLib</strong> — every field name, decimal separator, &lt;extra&gt; bucket, field ordering identical.</li><li><strong>95–100% tag parity with ExifTool</strong> — for 14 camera vendors, validation against the exiftool binary shows exact tag name and value matches on real camera files.</li></ul><p>Two reference tools’ worth of metadata, in one pure-Rust crate.</p><h3>Why Both?</h3><p>They solve different halves of the same problem:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Rl6fVnZ8mAFxjiWFRbxCCg.png" /></figure><h3>Architecture: From Virtual Classes to Function Pointers</h3><p>The most visible design change: instead of a C++ class hierarchy, revelo uses a flat table of 180 function pointers:</p><pre>pub fn table() -&gt; [fn(&amp;mut MediaFile) -&gt; bool; 180] {<br>    [<br>        parse_wav,       // WAV<br>        parse_mp4,       // MP4/MOV<br>        parse_mkv,       // Matroska<br>        parse_hevc,      // HEVC<br>        parse_jpeg,      // JPEG + EXIF<br>        parse_exif,      // EXIF IFD walker<br>        // ... 174 more<br>    ]<br>}</pre><p>Every parser is fn(&amp;mut MediaFile) -&gt; bool. No traits, no vtables, no subclassing. Each parser is standalone, independently testable, and trivially parallelizable.</p><h3>Race + Walk</h3><p>All 180 parsers race across CPU cores using rayon’s work-stealing thread pool:</p><pre>pub fn detect(bytes: &amp;[u8]) -&gt; Option&lt;fn(&amp;mut MediaFile) -&gt; bool&gt; {<br>    table()<br>        .par_iter()<br>        .find_first(|&amp;&amp;parser| {<br>            let mut fa = MediaFile::new(bytes);<br>            parser(&amp;mut fa)  // cheap detection: peek magic bytes<br>        })<br>        .copied()<br>}</pre><p>Detection is just checking magic bytes — WAV checks for RIFF, MP4 checks ftyp box, JPEG checks 0xFFD8. Each candidate finishes in microseconds. On an 8-core machine, the entire 180-format race completes in under a millisecond.</p><p>Once detected, the winner runs again for full extraction. Two passes: parallel sprint, then single-threaded deep parse.</p><h3>What Makes This Hard: Media Parsing Is Inconsistent by Design</h3><p>Media files aren’t cleanly-specified formats read by well-behaved parsers. They’re the product of 30 years of competing standards committees, vendor extensions, and “it compiles, ship it” engineering.</p><p><strong>MP4/MOV</strong> is nominally a tree of boxes, each with a 4-byte size and 4-byte type. But some boxes use 0 as a size meaning &quot;rest of file.&quot; Others use 1 as a flag meaning &quot;the real size is in the next 8 bytes&quot; (64-bit extended size). The stsd sample description box is a container whose children vary by codec type — avcC for H.264 stores an SPS/PPS blob, hvcC for H.265 stores a VPS/SPS/PPS combo with completely different bit-packing, dvcC for Dolby Vision has its own versioning, and esds for AAC contains a recursive ISO 14496-1 descriptor hierarchy (DecoderConfigDescriptor → DecoderSpecificInfo → SLConfigDescriptor) that must be parsed depth-first.</p><p><strong>Matroska/WebM</strong> uses EBML elements with variable-length integer-encoded sizes. The size encoding is elegant — the number of leading zeros in the first byte tells you how many bytes follow — but decoding it requires bit-level manipulation. A single Segment element can span terabytes with nested seek indexes, track definitions, chapters, tags, attachments, and CueSeekPositions all as sibling elements.</p><p><strong>MPEG-TS</strong> is a flat stream of 188-byte packets. Each packet has a PID identifying its stream, but the PAT (PID 0) and PMTs must be parsed to know which PID carries video, which audio, and which subtitles. The PMT itself contains nested descriptors — some simple (4 bytes, language code), some complex (AC-3 descriptor with bitstream mode and channel configuration). You can’t rely on the PMT appearing at a fixed position; you have to scan continuously.</p><p><strong>RIFF (AVI/WAV)</strong> uses little-endian chunk sizes, which is fine until you encounter a WAV file with an odd-sized fmt chunk and need to handle the word-alignment padding byte exactly as the C++ code does. Or a Broadcast Wave bext chunk whose specification includes a field that is literally &quot;372 bytes of reserved space, must be zeroed.&quot;</p><p><strong>Bitstream-level parsers</strong> are the worst. HEVC SPS parsing requires extracting log2_max_pic_order_cnt_lsb_minus4 from a ue(v) (unsigned exponential Golomb-coded integer), which itself must be decoded bit-by-bit: count leading zeros, read that many data bits, add 1, subtract 1. H.264 has a similar scheme with completely different parameter names and semantics. AV1 uses a different OBU (Open Bitstream Unit) structure entirely. Each codec has its own bit-packing conventions, and none of them align with byte boundaries.</p><p>Each container format requires a fundamentally different walking strategy. I chose not to abstract over them — every container walks its native on-disk structure directly. The output model is the same StreamCollection — they all write into the same data structure — but the walk code maps directly to the format specification.</p><h3>The Porting Process</h3><p>I started every parser as a transliteration of the C++ source, then iteratively refined through differential testing:</p><ol><li><strong>Find sample files</strong> — real-world media, sometimes generated with specific encoder settings for obscure formats.</li><li><strong>Run the oracle</strong> — mediainfo --Output=XML sample.file. Save ground truth.</li><li><strong>Read the C++ source</strong> — File_* class, Header_Parse() and Data_Parse() flow, transliterate field-by-field into Rust.</li><li><strong>Compare output</strong> — cargo run -p revelo-diff -- sample.file.</li><li><strong>Fix discrepancies</strong> — mismatched field names, wrong byte order, wrong bit offsets, wrong value formatting. Every mismatch is a bug.</li><li><strong>Commit</strong> — once --strict reports byte-equal.</li></ol><p>Some parsers came together in hours. The MP4 container parser — with dozens of sub-box types — took weeks. The hardest bugs were the ones where output looked right but the bytes didn’t match: a field rendered as &quot;48000&quot; instead of &quot;48 000&quot;, or appearing in the wrong XML element order because the C++ code Fill()-s fields in a different sequence than alphabetical.</p><h3>The FileAnalyze Engine</h3><p>The engine wraps the raw buffer and provides the read API every parser uses. I kept MediaInfoLib’s C++ API naming — get_b1 through get_b8 for big-endian, get_l1 through get_l8 for little-endian, get_c4 for four-character codes, bs_begin()/bs_end() for bitstream mode — so I could compare the Rust port against the C++ original side-by-side without mental translation.</p><p>The read API is comprehensive — everything from single-byte peeks to 80-bit IEEE 754 extended precision floats (AIFF format uses these):</p><ul><li><strong>Big-endian</strong>: get_b1 through get_b8, get_b16, peek_b*, skip_b*</li><li><strong>Little-endian</strong>: get_l1 through get_l8, get_l16, with peek/skip variants</li><li><strong>Floats</strong>: get_bf4, get_bf8, get_bf10 (80-bit extended precision)</li><li><strong>Bitstream</strong>: bs_begin()/bs_end() bracket bit-aligned reads with get_s1 through get_s8 (MSB-first)</li><li><strong>4CC</strong>: get_c4 reads 4 bytes as big-endian u32, traces as human-readable ASCII</li><li><strong>Raw</strong>: read_raw(n), peek_raw(n), peek_raw_at(at, n) for absolute reads</li></ul><p>Underrun behavior follows the C++ semantics exactly: any read exceeding the buffer sets a truncated flag, advances the cursor to EOF, and returns zero. No panics, no crashes. Real media files are often truncated, and a parser that panics is useless in production.</p><h3>The ExifTool Layer: 14-Camera Maker-Note Depth</h3><p>This is where revelo diverges from being “just a MediaInfoLib port.”</p><p>The core crate is <strong>BSD-2-Clause</strong> and uses hand-written clean-room maker-note tables for standard EXIF/GPS/Interop tags. For camera-maker-note depth on par with ExifTool, an opt-in exiftool-tables feature pulls in tag tables <strong>generated from</strong> ExifTool&#39;s source:</p><pre>cargo install revelo-cli --features exiftool-tables</pre><p>Because those tables are derived from ExifTool, the revelo-exiftool-tables crate is <strong>GPL/Artistic</strong>, not BSD. The licensing is deliberately isolated: the default build stays BSD-2-Clause; only a build that opts in takes on the copyleft.</p><h3>Camera Coverage</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hQPr3xcghV3jWCGcuFy7gA.png" /></figure><p>Validated by revelo-exif-diff, a harness that diffs revelo&#39;s output against the exiftool binary tag-for-tag — the same way revelo-diff validates against mediainfo.</p><h3>EXIF Architecture</h3><p>EXIF parsing in revelo has two layers:</p><ol><li><strong>The IFD walker</strong> (tags.rs): full IFD chain traversal (IFD0 → ExifIFD → GPS IFD → InteropIFD → IFD1), 170+ standard tag names, GPS coordinates computed as decimal degrees, LensSpec formatting from multi-field combinations.</li><li><strong>The format detector</strong> (jpeg.rs): a curated MediaInfo-style parser that extracts a streamlined set of EXIF fields using MediaInfoLib-compatible names (Recorded_Date, Encoded_Hardware_CompanyName). Both layers write to the same Exif stream with non-conflicting keys, so consumers can query whichever naming convention they prefer.</li></ol><h3>Byte-for-Byte Validation</h3><p>My validation standard: exact — not “close enough.” Two differential test harnesses:</p><pre># MediaInfo oracle<br>$ cargo run -p revelo-diff -- --strict video.mp4<br>Oracle: 312 lines, Rust: 312 lines<br>Only in oracle: 0   Only in rust: 0   Common: 312<br>✅ Byte-equal!</pre><pre># ExifTool oracle<br>$ cargo run -p revelo-exif-diff Canon_40D.jpg<br>TOTAL: 45/45 exiftool EXIF/maker-note tags matched (100%)</pre><p>The MediaInfo harness uses a full LCS (Longest Common Subsequence) O(n×m) table diff in --strict mode. When it reports zero differences, the outputs are byte-equal — every character, every space, every angle bracket identical.</p><p>The ExifTool harness runs exiftool -G1 -s against the same file, collects all EXIF/maker-note tag names, and counts how many revelo also produces (matched by tag name, case-insensitive). On 19 of 20 sample files across 20 camera models and brands, revelo achieves 100% tag parity.</p><h3>Safety and Correctness</h3><p>Zero unsafe blocks. #![deny(unsafe_code)] across all 17 crates.</p><p>This isn’t philosophical. Media files are attacker-controlled inputs. A parsing library will encounter maliciously crafted files:</p><ul><li><strong>Heap overflows</strong> from crafted box sizes</li><li><strong>Out-of-bounds reads</strong> in bitstream parsing</li><li><strong>Use-after-free</strong> in box tree manipulation</li></ul><p>In Rust, the type system and borrow checker eliminate these classes of bugs entirely. If it compiles, it doesn’t have a buffer overflow. Vec::resize with a user-supplied size will either succeed or panic — no silent heap corruption. Bounds-checked indexing means every read is validated. The Option type eliminates null pointer dereferences.</p><h3>The HDR Frontier</h3><p>Beyond porting, I extended revelo with HDR format detection that even MediaInfoLib v26.05 doesn’t cover:</p><ul><li><strong>HDR10+</strong> (SMPTE ST 2094–40): parsed from HEVC SEI payload type 4 and AV1 metadata OBUs</li><li><strong>Dolby Vision RPU</strong>: NAL unit type 62 parsing with L1/L5/L8 metadata layers</li><li><strong>SL-HDR1</strong> (ETSI TS 103 433): three SEI payload types</li><li><strong>HLG/PQ container signalling</strong>: CICP transfer_characteristics detection</li><li><strong>Dolby Atmos</strong>: E-AC-3 dependent substreams and TrueHD MAT frames</li><li><strong>Eclipsa Audio (IAMF)</strong>: full OBU-level parsing with scalable channel layouts</li></ul><p>HDR classification needs data from both the container layer (MP4 colr boxes, MKV Colour elements) and the elementary bitstream (HEVC/AV1/AVC SPS VUI colour info). revelo emits colour_primaries, transfer_characteristics, matrix_coefficients, HDR_Format, MasteringDisplay_Luminance, and MasteringDisplay_ColorPrimaries from both layers.</p><ul><li><strong>Dolby AC-4</strong>: Frame header parsing with CBR/VBR signaling, substream table (up to 16 substreams with independent sample rates and channel modes), IMS (Immersive Stereo) bit, JOC (Joint Object Coding) object count, and dialnorm/loudness extraction.</li></ul><h3>Lessons Learned</h3><p><strong>1. Flat is better than deep.</strong> The C++ virtual class hierarchy made sense for the original developers — adding a format meant subclassing File__Analyze and overriding Header_Parse() and Data_Parse(). But that design carries costs: virtual dispatch overhead on every call, complex initialization ordering, state sprawl from inherited member variables, and testing isolation difficulty. My flat function table is simpler, faster (no vtables), parallelizable, and each parser is a pure function from MediaFile to bool with no shared mutable state.</p><p><strong>2. Parallel detection is a win that falls out of the architecture.</strong> I didn’t set out to parallelize format detection. It became trivial once I had a flat table of independent functions. The C++ sequential if-else chain is O(n) in the number of formats, and n keeps growing. With rayon, it’s one parallel iteration — essentially constant time on any multi-core machine.</p><p><strong>3. Byte-level correctness requires byte-level testing.</strong> You cannot reason your way to bit-exact output compatibility. The differential harness catches bugs that are invisible to functional testing: field ordering differences (the C++ code Fill()-s fields in one order while my code does another, breaking schema validation), whitespace differences in XML attribute ordering, value formatting differences (integer rendered as &quot;48000&quot; vs &quot;48 000&quot;).</p><p><strong>4. Container-native is the right call.</strong> Tempting as it is to build a unified “media stream” abstraction, each container stores metadata differently — boxes (MP4), EBML elements (MKV), RIFF chunks (AVI), PES packets (MPEG-TS). Forcing them into a common model would lose information or require constant exceptions. My approach — walk each container through its own structure, write to the same stream model — strikes the right balance. The walk code maps directly to the format spec; the output is unified.</p><p><strong>5. Two oracles beat one.</strong> Adding ExifTool validation alongside MediaInfo validation exposed a different class of bugs — tag name mismatches, missing maker-note sub-IFDs, GPS coordinate sign errors. Each oracle validates a different dimension of the output, and passing both gives confidence that neither silent brokenness remains.</p><h3>The Result</h3><p>Revelo ships as 17 workspace crates with zero native dependencies. The cdylib crate is a drop-in replacement for libmediainfo.so/.dylib/.dll — same C ABI, same output, completely different implementation underneath. The exiftool-tables optional crate brings ExifTool-derived maker-note depth for 14 camera vendors, validated at 95–100% tag parity.</p><pre># Drop-in for libmediainfo:<br>MediaInfo_Handle handle = MediaInfo_New();<br>MediaInfo_Open(handle, &quot;video.mp4&quot;);<br>const char* xml = MediaInfo_Inform(handle, 0);  // byte-identical output</pre><p>The project compiles with a single cargo build from a clean checkout, runs on any platform with a Rust toolchain (including wasm32-unknown-unknown), and distributes via cargo install. No ./Configure, no system library dependencies, no Perl runtime.</p><p>Repository: <a href="https://github.com/vbasky/revelo">github.com/vbasky/revelo</a> — BSD-2-Clause (core) · GPL/Artistic (optional exiftool-tables).</p><pre>cargo install revelo-cli<br>revelo --xml video.mp4                    # MediaInfoLib-compatible XML<br>revelo photo.jpg --json                   # JSON with EXIF/maker-note tags<br>revelo --json --features exiftool-tables  # Full ExifTool-grade maker-note depth</pre><p>Or as a Rust library:</p><pre>// One-liner: detect, parse, extract tags<br>let meta = revelo::Metadata::from_file(&quot;photo.jpg&quot;).unwrap();</pre><pre>for (key, value) in meta.general() {<br>    println!(&quot;{key} = {value}&quot;);<br>}<br>for (key, value) in meta.exif() {<br>    println!(&quot;{key} = {value}&quot;);<br>}</pre><p>No native dependencies. No system libraries. No Perl runtime. cargo build from a clean checkout.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8e9b587d469a" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>