{
    "version": "https://jsonfeed.org/version/1",
    "title": "Keiver Hernandez's Lab",
    "home_page_url": "https://keiver.dev",
    "feed_url": "https://keiver.dev/feed.json",
    "description": "Personal workspace and lab for experiments, tools, prototypes and articles",
    "icon": "https://keiver.dev/favicon-32x32.png",
    "author": {
        "name": "Keiver Hernandez",
        "url": "https://keiver.dev"
    },
    "items": [
        {
            "id": "https://keiver.dev/lab/apple-tv-scrub-thumbnails-without-trickplay",
            "content_html": "<p>Scrub thumbnails on Apple TV look like a job for Jellyfin's trickplay images, and they are not: AVPlayer has no place for them. The thumbnail above the scrub bar comes from an HLS I-frame playlist, and a stream without one scrubs blind. Tomo TV builds that playlist on the device from the file's own keyframes, so every file gets thumbnails with no server-side preparation.</p>\n<figure class=\"exhibit\"><img src=\"https://keiver.dev/lab/apple-tv-scrub-thumbnails-without-trickplay/scrub-thumbnail.webp?v=de9513f7\" alt=\"Scrubbing a film in Tomo TV on Apple TV, with a frame preview above the scrub bar\" loading=\"lazy\"><figcaption>Scrubbing on Apple TV. The preview above the bar is a keyframe from the file itself, served by the device.</figcaption></figure>\n<h2>tvOS scrubs from an I-frame playlist, not image sprites</h2>\n<p>AVKit on tvOS draws scrub thumbnails from an HLS I-frame rendition: a secondary playlist declared with <code>#EXT-X-I-FRAME-STREAM-INF</code> in the master. Each entry in the playlist points to a single-frame fMP4 fragment. As the viewer scrubs, AVKit fetches those fragments and displays each one above the timeline bar. Without the rendition, the scrub bar is blank.</p>\n<p>Jellyfin trickplay produces sprite sheets: grids of JPEG thumbnails. That format is not what <code>#EXT-X-I-FRAME-STREAM-INF</code> expects, and there is no adapter.</p>\n<h2>Why Jellyfin trickplay images do not work here</h2>\n<p>We tested both codec labels before giving up on the JPEG route. A variant with <code>CODECS=&quot;jpeg&quot;</code> was silently ignored by AVFoundation. A variant with <code>CODECS=&quot;mjpg&quot;</code> stalled the player: seeks were accepted but never completed. The trickplay path is closed on both ends.</p>\n<p>Trickplay also needs the server to generate its images before any exist. Building from the source keyframes sidesteps both problems.</p>\n<h2>Building the I-frame track from the file's keyframes</h2>\n<p>Every modern container already maintains a keyframe index. For Matroska, WebM, MP4 and MOV, the FFmpeg demuxer lists every keyframe without reading the whole file. For MPEG-TS sources, which carry no usable index, the engine falls back to one entry per segment and reads the nearest keyframe behind that offset.</p>\n<p>The engine thins keyframes closer than one second apart, matching the HLS authoring spec's assumption for I-frame tables.</p>\n<p>The diagram below shows how a keyframe in the source file ends up as a thumbnail on the scrub bar.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    A[File keyframes] --&gt; B[Keyframe index]\n    B --&gt; C[I-frame entries]\n    C --&gt; D[iframes.m3u8]\n    D --&gt; E[Master playlist]\n    E --&gt; F[AVKit scrub bar]\n</code></pre>\n<p>Each fragment is a single fMP4 segment: one <code>moof</code>, one <code>mdat</code>, one sample. Where the engine stream-copies the video, the I-frame is also stream-copied. Where it transcodes, the I-frame goes through the same encoder so codec and dimensions match the playing track.</p>\n<figure class=\"code-file\"><figcaption><a href=\"https://github.com/keiver/tomotv/blob/8d75511f63afcc20a72d6c24ed98db2be02080df/packages/tomo-engine/ios/LocalRemuxer/RemuxSession+IFrames.swift#L26-L32\" target=\"_blank\" rel=\"noopener noreferrer\">packages/tomo-engine/ios/LocalRemuxer/RemuxSession+IFrames.swift:26-32</a></figcaption><pre><code class=\"language-swift\">guard !config.isLive, planned, !codecs.isEmpty, config.durationSeconds &gt; 0 else { return &quot;&quot; }\nvar line = &quot;#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=\\(max(1, bandwidth))&quot;\nline += &quot;,CODECS=\\&quot;\\(codecs.joined(separator: &quot;,&quot;))\\&quot;&quot;\nif !config.supplementalCodecs.isEmpty { line += &quot;,SUPPLEMENTAL-CODECS=\\&quot;\\(config.supplementalCodecs)\\&quot;&quot; }\nif config.width &gt; 0 &amp;&amp; config.height &gt; 0 { line += &quot;,RESOLUTION=\\(config.width)x\\(config.height)&quot; }\nif !config.videoRange.isEmpty { line += &quot;,VIDEO-RANGE=\\(config.videoRange)&quot; }\nreturn line + &quot;,URI=\\&quot;iframes.m3u8\\&quot;\\n&quot;\n</code></pre>\n</figure><p>Live sessions and audio-only files return an empty string and get no I-frame line.</p>\n<h2>Never fail an I-frame request</h2>\n<p>The store never returns a missing response. If the engine cannot produce a frame within its time budget, it finds the nearest cached fragment. It restamps that fragment at the requested time by rewriting the <code>tfdt</code> field and returns it. On a link that carries the original the budget is 4 seconds. Under the server's smaller streams, where playback owns the link, it is 0.6 seconds.</p>\n<p>The result is that AVKit never sees a gap, and trick play works reliably even during slow reads from network sources.</p>\n<h2>Declaring an honest bandwidth</h2>\n<p>The <code>BANDWIDTH</code> attribute on the <code>#EXT-X-I-FRAME-STREAM-INF</code> line signals to AVFoundation how much capacity the rendition needs. Apple's HLS authoring spec offers a formula: bit rate times keyframe interval divided by 8. That formula under-declares real source keyframes.</p>\n<p>A keyframe carries a full independent frame, not a fraction of the average stream rate. We declare the copy track's own peak bitrate instead: a keyframe over the gap to the next can never need more than its stretch of the stream.</p>\n<hr>\n<p>The I-frame rendition is a standard part of HLS and AVKit draws from it on every Apple device. If the scrub bar is blank on tvOS, generate an I-frame playlist from the source file's keyframes. Enabling Jellyfin trickplay is not the fix. Server-side JPEG generation is never in the path.</p>\n<p>Related: <a href=\"https://keiver.dev/lab/pgs-subtitles-apple-tv-without-transcoding\">PGS subtitles on Apple TV without transcoding</a>, <a href=\"https://keiver.dev/lab/when-should-a-jellyfin-client-transcode\">When should a Jellyfin client transcode?</a>, <a href=\"https://keiver.dev/lab/tomotv\">Tomo TV</a>.</p>\n",
            "url": "https://keiver.dev/lab/apple-tv-scrub-thumbnails-without-trickplay",
            "title": "Apple TV scrub thumbnails without trickplay",
            "summary": "Tomo TV builds an HLS I-frame rendition from each file's keyframes so Apple TV draws scrub thumbnails. Jellyfin trickplay JPEG images do not fit AVPlayer.",
            "image": "https://keiver.dev/lab/apple-tv-scrub-thumbnails-without-trickplay/scrub-thumbnail.webp",
            "date_modified": "2026-10-05T00:00:00.000Z",
            "date_published": "2026-10-05T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "tomo-tv",
                "apple-tv",
                "jellyfin"
            ]
        },
        {
            "id": "https://keiver.dev/lab/dolby-vision-profile-7-apple-tv",
            "content_html": "<p>Dolby Vision on a UHD Blu-ray is Profile 7, a dual-layer format where a base HEVC track pairs with a separate enhancement layer. Apple decodes no dual-layer Dolby Vision, so those rips reached the TV as plain HDR10 no matter how the playlist was labeled. We added Profile 7 support to Tomo TV by converting each frame's RPU (Reference Processing Unit) to Profile 8.1 inside our on-device FFmpeg build. The RPU is the per-frame NAL unit carrying tone-mapping curves. The base layer is never decoded or re-encoded.</p>\n<h2>Apple TV plays one layer; Dolby Vision disc rips carry two</h2>\n<p>Two Dolby Vision profiles matter here. Profile 8 is a single HEVC layer with its Dolby Vision metadata riding inside it. Profile 7 is the UHD disc format: it carries the base layer alongside a separate enhancement layer (EL). Apple's decoders have no EL path. Without conversion, the EL is ignored and the base layer plays as HDR10.</p>\n<h2>Four metadata fields separate Dolby Vision Profile 7 from 8.1 on Apple TV</h2>\n<p>The two profiles are closer than they look. We read the actual transform from dovi_tool's committed output rather than a written description, comparing the raw bytes of dual-layer and converted RPUs. The mapping curves, MMR (Multi-Matrix Reshaping) constants and color metadata travel across untouched. Four things change. The EL resampling flag and residual flag are cleared, the NLQ (non-linear quantizer) used by the EL is zeroed out, and the profile is restated as 8 with compatibility id 1. The container's configuration record is restated as single-layer 8.1 too.</p>\n<p>The conversion path looks like this:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    A[&quot;Profile 7 input&lt;br/&gt;dual-layer&quot;] --&gt; B[&quot;EL NAL type 63&lt;br/&gt;dropped&quot;]\n    A --&gt; C[&quot;Base layer&lt;br/&gt;stream copy&quot;]\n    A --&gt; D[&quot;RPU NAL&lt;br/&gt;per frame&quot;]\n    D --&gt; E[&quot;Four fields&lt;br/&gt;rewritten&quot;]\n    C --&gt; F[&quot;Profile 8.1&lt;br/&gt;to AVPlayer&quot;]\n    E --&gt; F\n</code></pre>\n<p>The last two of the four changes, the quantizer and the profile:</p>\n<figure class=\"code-file\"><figcaption><a href=\"https://github.com/keiver/tomotv/blob/8d75511f63afcc20a72d6c24ed98db2be02080df/packages/tomo-engine/ios/LocalRemuxer/DolbyVisionConverter.swift#L170-L179\" target=\"_blank\" rel=\"noopener noreferrer\">packages/tomo-engine/ios/LocalRemuxer/DolbyVisionConverter.swift:170-179</a></figcaption><pre><code class=\"language-swift\">if let mapping = UnsafeMutablePointer(mutating: av_dovi_get_mapping(metadata)) {\n    mapping.pointee.nlq_method_idc = AV_DOVI_NLQ_NONE\n    mapping.pointee.nlq_pivots = (0, 0)\n    mapping.pointee.nlq = (AVDOVINLQParams(), AVDOVINLQParams(), AVDOVINLQParams())\n}\n\ncontext.enable = 1\ncontext.cfg.dv_profile = 8\ncontext.cfg.dv_bl_signal_compatibility_id = 1\ncontext.cfg.el_present_flag = 0\n</code></pre>\n</figure><h2>Our fixtures passed; a real disc proved the EL detection wrong</h2>\n<p>Our first cut detected the enhancement layer by its <code>nuh_layer_id</code>, dropping NAL units where the layer id was greater than zero. That rule passed every hand-built test. Twelve frames off a real 4K Profile 7 source carried 12 RPUs and 43 enhancement-layer NAL units, every one on layer id 0. The EL uses HEVC unspecified NAL type 63, not a higher layer id. The fix is one condition: drop any NAL of type 63, or any NAL with <code>nuh_layer_id &gt; 0</code>.</p>\n<p>The tests that anchor the code compare our output against dovi_tool's reference for both MEL (minimal enhancement layer, identity reshaping curves) and FEL (full enhancement layer, real MMR constants). A careless conversion that flattened FEL's mapping curves would still pass a MEL-only suite.</p>\n<h2>The in-app badge tells you nothing; Console.app does</h2>\n<p>Tomo TV shows a Dolby Vision badge on the player screen. That badge comes from the server's metadata for the file, not from the playback pipeline. The badge appeared even when Profile 7 files were playing back as HDR10. The only measurement that counts is <code>hdrMode = Dolby</code> in Console.app during playback on the device. That is what we observed on the Apple TV after shipping this conversion.</p>\n<h2>Why we did not add libdovi or re-encode</h2>\n<p>FFmpeg returns <code>AVERROR_PATCHWELCOME</code> when asked to handle Profile 7, describing enhancement layer coding as unsupported. Clearing the two flags FFmpeg's profile classifier reads does make it report Profile 8, but the output will not parse back because the mapping data is still in dual-layer form. Getting FFmpeg to do this conversion requires libdovi, a separate native library for Dolby Vision bitstream work.</p>\n<p>We chose to read dovi_tool's own committed transform and write the conversion directly in our build. The base layer never touches a decoder. The conversion adds no dependency. A source the converter declines (non-HEVC, annex B extradata, no RPU present) fails the session cleanly rather than delivering a stream that contradicts its own manifest. <a href=\"https://keiver.dev/lab/when-should-a-jellyfin-client-transcode\">When a Jellyfin client should transcode</a> covers when and why the server picks up.</p>\n<hr>\n<p>The lesson extends to any format pairing where the content does not change but the container's signalling does. When two profiles differ by a fixed set of metadata fields and the payload is otherwise identical, the rewrite costs nothing the stream copy does not already pay. The field list comes from the reference tool's committed output, not from a specification document, because the document describes intent and the committed output describes what a real player accepted. What happens to the soundtrack of the same rips is in <a href=\"https://keiver.dev/lab/dolby-atmos-truehd-apple-tv\">Dolby Atmos and TrueHD on Apple TV</a>, and the app itself is at <a href=\"https://keiver.dev/lab/tomotv\">Tomo TV</a>.</p>\n",
            "url": "https://keiver.dev/lab/dolby-vision-profile-7-apple-tv",
            "title": "Dolby Vision Profile 7 on Apple TV",
            "summary": "Tomo TV converts Dolby Vision Profile 7 UHD disc rips to single-layer Profile 8.1 on Apple TV by rewriting only the RPU, at stream-copy cost.",
            "image": "https://keiver.dev/screenshots/tomotv/tv-player-1920.webp",
            "date_modified": "2026-10-05T00:00:00.000Z",
            "date_published": "2026-10-05T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "tomo-tv",
                "apple-tv",
                "dolby-vision"
            ]
        },
        {
            "id": "https://keiver.dev/lab/jellyfin-subtitles-delay-playback",
            "content_html": "<p>Jellyfin subtitles are slow to start when the player has to wait on the server. In Tomo TV on Apple TV, picking an embedded text subtitle before pressing play produced a black screen that lasted between 5.7 and 11.8 seconds. The video itself played instantly without them. AVPlayer will not report a player item as ready until every selected media track is ready, and our subtitle track was not arriving from a fast source.</p>\n<figure class=\"exhibit\"><img src=\"https://keiver.dev/screenshots/tomotv/tv-player-1920.webp?v=0b1824ab\" alt=\"The Tomo TV player on Apple TV showing the subtitle and audio controls\" loading=\"lazy\"><figcaption>The Apple TV player. The wait appeared between pressing play and this screen when an embedded text subtitle track was selected.</figcaption></figure>\n<h2>Why Jellyfin subtitles are slow to start</h2>\n<p>Jellyfin exposes embedded text subtitles through an endpoint that returns a single WebVTT document. Before responding, Jellyfin runs FFmpeg over the whole file, demuxes the subtitle stream from beginning to end, and converts the result. That is a reasonable design for a subtitle download. In front of a play button, the player holds while the server reads the whole film. Measured across three days of server logs, the wait was 5.7 to 11.8 seconds per item. The video was not the cause and the network was not the cause.</p>\n<h2>Subtitles cut on the video's own segment grid</h2>\n<p>Tomo TV's principle is that the server is a file host and the device does the decoding work. The on-device engine reads every packet in the file as it plays, subtitle packets included, and until this change the engine discarded them. Once we stopped discarding them, a decoder collects timed cues from each subtitle packet as the engine reads. Those cues are cut into short WebVTT segments on the same time grid as the video. AVPlayer gets its first subtitle segment at the same moment it gets its first video frame. The server is never asked about subtitles for an embedded track.</p>\n<p>Before and after, the subtitle source changes like this:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    A[Play pressed] --&gt; B{subtitle source}\n    B -- before --&gt; C[&quot;Jellyfin subtitle endpoint&lt;br/&gt;FFmpeg reads whole file&quot;]\n    C --&gt; D[&quot;AVPlayer waits&lt;br/&gt;5.7 to 11.8 s&quot;]\n    B -- after --&gt; E[&quot;Engine read loop&lt;br/&gt;WebVTT segments&quot;]\n    E --&gt; F[AVPlayer starts]\n</code></pre>\n<p>SubRip, mov_text, ASS and SSA formats all reach FFmpeg as ASS dialogue internally, so a single converter handles them. Bold, italic and underline are translated into the tags WebVTT supports. Positioning, animation and drawing commands that WebVTT does not carry are dropped cleanly.</p>\n<h2>Swift reads CRLF as one Character</h2>\n<p>The synthetic tests passed. The first real ASS and SSA files from public test archives did not.</p>\n<p>ASS stores style information in a script header. Parsing the header means splitting it into lines. We were splitting on <code>&quot;\\n&quot;</code>. Swift reads a Windows line ending, <code>&quot;\\r\\n&quot;</code>, as a single <code>Character</code>. Splitting on <code>&quot;\\n&quot;</code> found no line boundaries in a CRLF file and returned the whole header as one string. The style table came out empty, and every subtitle line rendered without bold or italic.</p>\n<figure class=\"code-file\"><figcaption><a href=\"https://github.com/keiver/tomotv/blob/8d75511f63afcc20a72d6c24ed98db2be02080df/packages/tomo-engine/ios/LocalRemuxer/AssToWebVTT.swift#L220-L224\" target=\"_blank\" rel=\"noopener noreferrer\">packages/tomo-engine/ios/LocalRemuxer/AssToWebVTT.swift:220-224</a></figcaption><pre><code class=\"language-swift\">// isNewline, not &quot;\\n&quot;: Swift reads CRLF as ONE Character, so splitting a\n// CRLF script on &quot;\\n&quot; returns the whole header as a single line and the\n// table comes out empty. Aegisub writes CRLF, which is most of the ASS\n// in the world.\nfor raw in header.split(whereSeparator: \\.isNewline) {\n</code></pre>\n</figure><p><code>\\.isNewline</code> handles both <code>&quot;\\n&quot;</code> and <code>&quot;\\r\\n&quot;</code>. It matters because Aegisub, a common subtitle editor, writes CRLF.</p>\n<h2>Real files find bugs that synthetic fixtures miss</h2>\n<p>Both issues surfaced together. The CRLF split was one; the other was SSA's convention of marking an unresolved style with a leading asterisk. Our code matched that asterisk literally, so a file where every line used <code>*Default</code> found no style entry at all. Every fixture we had written was saved on a Mac with Unix line endings, and we had not written anything in that asterisk convention. Neither bug was reachable from a file we had made.</p>\n<p>Two files from public test archives, one ASS and one SSA, caught both on the same day they were added. Synthetic fixtures let you test what you already know. Files someone else made with software you did not write find the corners you did not think of.</p>\n<h2>Sidecar subtitle files use Jellyfin's URL</h2>\n<p>Not every subtitle is inside the container. A sidecar is a separate <code>.srt</code> or <code>.ass</code> file stored next to the video. It is not in the stream the engine reads, and serving it costs the server no extraction, so a sidecar keeps Jellyfin's own URL. The delay this work removed belonged to embedded tracks only. Image-format subtitles like PGS are a separate story covered in the <a href=\"https://keiver.dev/lab/pgs-subtitles-apple-tv-without-transcoding\">PGS report</a>. For tracks that do reach the server, the rule stays the same as described in <a href=\"https://keiver.dev/lab/well-behaved-jellyfin-client\">how we use Jellyfin</a>: ask it only for what the device cannot produce itself.</p>\n<hr>\n<p>If a player is waiting and the video is fine, look at the other tracks before you look at the file. And keep a few files in your test suite that someone else made with software you did not write. The whole app is at <a href=\"https://keiver.dev/lab/tomotv\">Tomo TV</a>.</p>\n",
            "url": "https://keiver.dev/lab/jellyfin-subtitles-delay-playback",
            "title": "Why Jellyfin subtitles delay playback",
            "summary": "Films with embedded subtitles waited up to 12 seconds to start in Tomo TV on Apple TV. We decode them on the device and they start with the first frame.",
            "image": "https://keiver.dev/screenshots/tomotv/tv-player-1920.webp",
            "date_modified": "2026-10-05T00:00:00.000Z",
            "date_published": "2026-10-05T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "tomo-tv",
                "apple-tv",
                "jellyfin",
                "subtitles",
                "swift",
                "hls"
            ]
        },
        {
            "id": "https://keiver.dev/lab/when-should-a-jellyfin-client-transcode",
            "content_html": "<p>Every time a Jellyfin client starts a video, it makes a call: play the original file or ask the server to transcode. Get it wrong in one direction and the viewer waits on an unnecessary encode. Get it wrong in the other and the player stalls because the link cannot carry the file. In Tomo TV, that call comes from a measurement taken on the real link, against the real file, every time.</p>\n<h2>The server is a file host</h2>\n<p>The server holds the files. On a link that can carry the original, the server sends the original. No encode runs. No quality is lost.</p>\n<p>The Tomo TV engine builds a private HLS master playlist for each session. That playlist declares the device's own stream copy alongside server-fed rungs at lower bitrates. AVPlayer picks between them on its own as the link changes. When the link is strong enough, the master lists only the stream copy. No rung appears. The server is a file host.</p>\n<p>Jellyfin server transcoding stays available for two cases: a codec the device cannot decode, or a link too thin to carry the file. Even <a href=\"https://keiver.dev/lab/dolby-vision-profile-7-apple-tv\">Dolby Vision Profile 7</a> disc rips stay on the device. The viewer has the last word too: an account the server does not let transcode never reaches the rungs, and the Server transcoding setting can rule them out on one device.</p>\n<h2>The 1.2x rule for when to transcode</h2>\n<p>The threshold is 1.2 times the file's declared bitrate. When the measured link meets or exceeds that level, the master playlist names the original stream alone and no server rung is offered. Below it, a rung leads and the original stays listed for AVPlayer to climb to as the link improves.</p>\n<figure class=\"exhibit\"><img src=\"https://keiver.dev/screenshots/tomotv/tv-quality-1920.webp?v=e96d5414\" alt=\"The Settings screen on Apple TV showing a live link reading of 3.8 Mb/s in the Streaming section.\" loading=\"lazy\"><figcaption>Live link reading in Settings. When this number is at least 1.2 times the file's bitrate, the original plays and no rung is used.</figcaption></figure>\n<h2>Measure with a real video, and make every URL unique</h2>\n<p>The probe picks a recent library video that is not already downloaded and is not a live channel. It reads the first bytes of that video's raw stream from the server with an HTTP range request, timed for ten seconds.</p>\n<p>Every probe URL gets a unique query parameter. An identical URL is eligible for the system's HTTP cache, and a cached response comes back in milliseconds. That reads as a link many times faster than the wire. The nonce forces a real network read each time.</p>\n<p>A read cut off before nine of its ten seconds is not kept; the previous reading stands.</p>\n<h2>Follow drops at once, and rises once they hold</h2>\n<p>One measurement is not enough. TCP delivers in bursts, and a single window can land inside a burst and report a rate the wire cannot sustain. The engine keeps two exponential moving averages of probe readings, weighted by the seconds each one measured.</p>\n<p>The link follows the bandwidth model of hls.js and Shaka Player: the estimate is the lower of a fast average and a slow one. The half-lives are 3 and 9 seconds, as in hls.js.</p>\n<figure class=\"code-file\"><figcaption><a href=\"https://github.com/keiver/tomotv/blob/8d75511f63afcc20a72d6c24ed98db2be02080df/packages/tomo-engine/ios/LocalRemuxer/RemuxSession+LinkProbe.swift#L61-L68\" target=\"_blank\" rel=\"noopener noreferrer\">packages/tomo-engine/ios/LocalRemuxer/RemuxSession+LinkProbe.swift:61-68</a></figcaption><pre><code class=\"language-swift\">mutating func add(_ bps: Double, seconds: Double) {\n    guard bps.isFinite, bps &gt; 0, seconds &gt; 0 else { return }\n    fast.add(bps, seconds: seconds)\n    slow.add(bps, seconds: seconds)\n    self.seconds += seconds\n}\n\nvar estimate: Double? { seconds &gt; 0 ? min(fast.value, slow.value) : nil }\n</code></pre>\n</figure><p>A drop hits the fast average and becomes the lower value at once. A rise moves the slow average slowly, so the estimate stays low until the improvement holds. The routing decision follows the same curve.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    A[Link measured] --&gt; B{&quot;At least 1.2x&lt;br/&gt;the file rate?&quot;}\n    B -- Yes --&gt; C[&quot;Original only&lt;br/&gt;no server rung&quot;]\n    B -- No --&gt; D[&quot;Server rung leads&lt;br/&gt;original stays listed&quot;]\n</code></pre>\n<h2>What it fixed</h2>\n<p>The first probe was a 500 KB read, and it trusted the result. A read that small cannot outrun TCP slow start. A 30 Mb/s link measured 3.5 Mb/s in one cold sample, and the routing gate treated the file as too large for direct play. The ten-second read of a real video replaced it.</p>\n<p>The two-average layer fixed a separate problem: a single probe that caught a network stall moved the estimate far enough that the player dropped to a rung it did not need. With the slow average holding the floor, one bad sample has limited weight.</p>\n<hr>\n<p>The rule is plain: measure the real link against the real file, add a margin, and decide. The complexity is in measuring correctly. A cached response gives no real answer. A single sample gives the wrong answer when TCP is still ramping up. And a too-wide margin puts the server to work on links that could have played the original. The two-average model is what keeps the routing stable. The same principle applies to <a href=\"https://keiver.dev/lab/never-log-users-out-on-network-error\">network errors</a>: one bad signal does not reset state. The <a href=\"https://keiver.dev/lab/tomotv\">Tomo TV</a> page has more on the engine.</p>\n",
            "url": "https://keiver.dev/lab/when-should-a-jellyfin-client-transcode",
            "title": "When should a Jellyfin client transcode?",
            "summary": "Tomo TV decides when Jellyfin should transcode by measuring the live link against the file rate. The device plays the original when the link keeps up.",
            "image": "https://keiver.dev/screenshots/tomotv/tv-quality-1920.webp",
            "date_modified": "2026-10-05T00:00:00.000Z",
            "date_published": "2026-10-05T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "tomo-tv",
                "apple-tv",
                "jellyfin",
                "streaming"
            ]
        },
        {
            "id": "https://keiver.dev/lab/image-tiler-mcp-server",
            "url": "https://keiver.dev/lab/image-tiler-mcp-server",
            "title": "image-tiler-mcp-server - Vision-Optimized Image Tiling MCP Server",
            "summary": "MCP server that splits large images into optimally-sized tiles for LLM vision models. Prevents automatic downscaling by Claude, GPT-4o, and Gemini.",
            "image": "https://keiver.dev/app-icons/circled/image-tiler.svg",
            "date_modified": "2026-02-26T00:00:00.000Z",
            "date_published": "2026-02-26T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "mcp",
                "vision",
                "image-tiling",
                "claude",
                "openai",
                "gemini"
            ]
        },
        {
            "id": "https://keiver.dev/lab/lazywebp",
            "url": "https://keiver.dev/lab/lazywebp",
            "title": "lazywebp - Batch Image to WebP Converter",
            "summary": "CLI tool and native macOS app to batch convert images to WebP format. Drag-and-drop or command-line with concurrent processing, atomic writes, and mtime-based skip logic.",
            "image": "https://keiver.dev/app-icons/circled/lazywebp-poster.svg",
            "date_modified": "2026-02-10T00:00:00.000Z",
            "date_published": "2026-02-10T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "webp",
                "image-converter",
                "cli",
                "macos",
                "sharp",
                "batch"
            ]
        },
        {
            "id": "https://keiver.dev/lab/tvos-assets",
            "url": "https://keiver.dev/lab/tvos-assets",
            "title": "tvos-assets - tvOS & iOS Asset Bundle Generator",
            "summary": "Generates complete tvOS and iOS Images.xcassets bundles from an icon and a background image: parallax app icons, iOS 18+ light/dark/tinted variants, Top Shelf images, splash assets, and an interactive preview.html. Expo config plugin, CLI, and programmatic API.",
            "image": "https://keiver.dev/app-icons/circled/tvos-assets.svg",
            "date_modified": "2026-02-01T00:00:00.000Z",
            "date_published": "2026-02-01T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "tvos",
                "ios",
                "xcassets",
                "expo-config-plugin",
                "cli",
                "apple-tv",
                "xcode"
            ]
        },
        {
            "id": "https://keiver.dev/lab/expo-tvos-search",
            "url": "https://keiver.dev/lab/expo-tvos-search",
            "title": "expo-tvos-search - Native tvOS Search for Expo Apps",
            "summary": "Native tvOS search integration for React Native Expo apps via .searchable Swift modifier. Easily add Spotlight-style search functionality to your Apple TV apps built with Expo.",
            "image": "https://keiver.dev/app-icons/circled/expo-tvos-search.svg",
            "date_modified": "2026-01-21T00:00:00.000Z",
            "date_published": "2026-01-21T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "tvos",
                "react-native-tvos",
                "expo",
                "swiftui",
                "search",
                "apple-tv"
            ]
        },
        {
            "id": "https://keiver.dev/lab/poster-generator",
            "url": "https://keiver.dev/lab/poster-generator",
            "title": "generative-poster-maker",
            "summary": "Create unique procedural posters with 50+ color palettes and 19 artistic styles. Free online tool to generate and download custom SVG, PNG, or JPG images for social media, blogs, and more.",
            "image": "https://keiver.dev/app-icons/circled/poster-generator.svg",
            "date_modified": "2026-01-14T00:00:00.000Z",
            "date_published": "2026-01-14T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "generative-art",
                "svg",
                "poster",
                "design",
                "procedural",
                "social-preview"
            ]
        },
        {
            "id": "https://keiver.dev/lab/image-metadata-remover",
            "url": "https://keiver.dev/lab/image-metadata-remover",
            "title": "image-metadata-remover",
            "summary": "Remove EXIF data, GPS location, camera info, and timestamps from photos. 100% client-side processing - your images never leave your device. Free, private, no upload required.",
            "image": "https://keiver.dev/app-icons/circled/metadata-icon-zorro-v2-circled.svg",
            "date_modified": "2026-01-04T00:00:00.000Z",
            "date_published": "2026-01-04T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "privacy",
                "exif",
                "gps",
                "metadata",
                "photos",
                "images",
                "webp"
            ]
        },
        {
            "id": "https://keiver.dev/lab/tomotv",
            "url": "https://keiver.dev/lab/tomotv",
            "title": "Tomo TV - Jellyfin Media Client",
            "summary": "A Jellyfin client for Apple TV, iPhone and iPad: movies, live TV, music and books in Apple's own player. An on-device engine handles containers and legacy codecs. Original-quality playback supports Dolby Atmos and lossless surround; smaller server streams cover weak connections. Free, open source, no subscription.",
            "image": "https://keiver.dev/app-icons/circled/tomotv.svg",
            "date_modified": "2025-11-22T00:00:00.000Z",
            "date_published": "2025-11-22T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "jellyfin",
                "apple-tv",
                "media-server",
                "tvos",
                "streaming",
                "transcoding"
            ]
        },
        {
            "id": "https://keiver.dev/lab/araname",
            "url": "https://keiver.dev/lab/araname",
            "title": "Araname - Web Resources Inspector",
            "summary": "A simple developer tool for inspecting, analyzing, and testing media resources on websites. Identify optimization opportunities and verify content implementation.",
            "image": "https://keiver.dev/app-icons/circled/araname.svg",
            "date_modified": "2025-06-23T00:00:00.000Z",
            "date_published": "2025-06-23T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "web-dev",
                "media-inspector",
                "optimization",
                "analysis",
                "testing"
            ]
        },
        {
            "id": "https://keiver.dev/lab/cubita",
            "url": "https://keiver.dev/lab/cubita",
            "title": "Cubita - Forest Runner Arcade Game",
            "summary": "Swipe or tap your way through a vibrant forest, dodging trees and wildlife while the pace keeps picking up. How far can you run?",
            "image": "https://keiver.dev/app-icons/circled/cubita.svg",
            "date_modified": "2025-05-07T00:00:00.000Z",
            "date_published": "2025-05-07T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "game",
                "arcade",
                "runner",
                "ios",
                "android"
            ]
        },
        {
            "id": "https://keiver.dev/lab/mojigrid",
            "url": "https://keiver.dev/lab/mojigrid",
            "title": "Moji Grid - Japanese Kana Wallpaper Creator",
            "summary": "Create beautiful, customizable Japanese character grids for learning and teaching. Arrange hiragana, katakana, and kanji with complete style control and easy export capabilities.",
            "image": "https://keiver.dev/app-icons/circled/mojigrid.svg",
            "date_modified": "2025-03-17T00:00:00.000Z",
            "date_published": "2025-03-17T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "japanese",
                "hiragana",
                "katakana",
                "ios",
                "android"
            ]
        },
        {
            "id": "https://keiver.dev/lab/symbolmind",
            "url": "https://keiver.dev/lab/symbolmind",
            "title": "Symbol Mind - Egyptian Hieroglyphs Memory Game",
            "summary": "Challenge your memory with this engaging puzzle game featuring authentic Egyptian hieroglyphs. Progressive difficulty levels and special cards make each game unique!",
            "image": "https://keiver.dev/app-icons/circled/symbolmind.svg",
            "date_modified": "2025-03-10T00:00:00.000Z",
            "date_published": "2025-03-10T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "memory",
                "game",
                "egyptian",
                "hieroglyphs",
                "puzzle"
            ]
        },
        {
            "id": "https://keiver.dev/lab/facel",
            "url": "https://keiver.dev/lab/facel",
            "title": "Facel - Face Detection and Tagging App",
            "summary": "Tag and label faces in your photos privately and securely. No cloud processing, no data collection - just simple, effective photo organization.",
            "image": "https://keiver.dev/app-icons/circled/facel.svg",
            "date_modified": "2025-02-24T00:00:00.000Z",
            "date_published": "2025-02-24T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "ai",
                "face-detection",
                "photo-tagging",
                "ios",
                "android"
            ]
        },
        {
            "id": "https://keiver.dev/lab/full-unicode-viewer-with-favorite-blocks",
            "url": "https://keiver.dev/lab/full-unicode-viewer-with-favorite-blocks",
            "title": "unicode-blocks-explorer",
            "summary": "Search specific unicode characters by name or description. Use an easy slider to scrub through the unicode blocks and bookmark favorites",
            "image": "https://keiver.dev/app-icons/unicode.svg",
            "date_modified": "2025-02-08T00:00:00.000Z",
            "date_published": "2025-02-08T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "unicode",
                "javascript",
                "emoji",
                "utf-8",
                "kana"
            ]
        },
        {
            "id": "https://keiver.dev/lab/barlog",
            "url": "https://keiver.dev/lab/barlog",
            "title": "Barlog - Barbell Plate Calculator App",
            "summary": "Barlog, barbell minimum plates calculator, iOS app, Android app, with Figma design, React Native code, and SVG images.",
            "image": "https://keiver.dev/app-icons/circled/barlog.svg",
            "date_modified": "2024-12-12T00:00:00.000Z",
            "date_published": "2024-12-12T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "react-native",
                "typescript",
                "ios",
                "android",
                "fitness"
            ]
        },
        {
            "id": "https://keiver.dev/lab/canvas",
            "url": "https://keiver.dev/lab/canvas",
            "title": "CircleSpace Canvas Game with Highscore",
            "summary": "CircleSpace, canvas animation experiment, game with levels and highscore, with controls and local storage where you need to avoid circle intersections and accumulate points.",
            "image": "https://keiver.dev/app-icons/ship.svg",
            "date_modified": "2024-04-08T00:00:00.000Z",
            "date_published": "2024-04-08T00:00:00.000Z",
            "author": {
                "name": "Keiver Hernandez",
                "url": "https://keiver.dev"
            },
            "tags": [
                "canvas",
                "animation",
                "html",
                "javascript",
                "game"
            ]
        }
    ]
}