|
10911
|
SubBake by @reimunyancat | Stardance
|
reimunyancat
|
1
|
32.89
|
411
|
[{"ship_num": 1, "ts": "2026-0 [{"ship_num": 1, "ts": "2026-08-14T00:02:46Z", "hours": 22.0, "multiplier": 18.46, "payout": 411, "blessing": "neutral", "text": "SubBake is a desktop app that embeds subtitles into video files. Drop in a video and a subtitle file, and it muxes the subtitle in as a proper track — colors, timing, and all — without re-encoding anything. There is also a live web demo if you would rather try it in a browser. What it does: It reads SMI, SRT, ASS, SSA, VTT and SUP subtitles and writes MKV, MP4 or WebM. SMI fansubs keep their per-speaker colors, because they get converted to ASS instead of being flattened into SRT. The GUI takes drag and drop or a plain click, auto-matches videos to same-named subtitle files, and shows live per-file progress straight from FFmpeg’s output. FFmpeg comes bundled, so the whole thing is one executable with nothing to install, and the UI speaks English and Korean without a restart. The web demo runs the exact same pipeline on my home server. Upload both files, watch the bar, download the result. Everything you upload is deleted automatically after an hour. The hardest part was invisible. One subtitle cue kept showing a hollow box between two lines, and every character in those lines came back clean. The culprit turned out to be a blank line in the source file, which my converter faithfully translated into an empty subtitle line that the player rendered as a box. The fix was collapsing consecutive newlines into one. Sometimes the bug is a blank line. To test it: grab the release, drop a video and its subtitle onto the window, and hit Start SubBake. Or open reimunyancat.com/subbake and do the same thing with zero install.", "repo": "https://github.com/reimunyancat/SubBake", "demo": "https://reimunyancat.com/subbake/"}]...
|
[{"post_id": 52292, "ts": "202 [{"post_id": 52292, "ts": "2026-08-22T12:29:12Z", "hours": 8.9519, "text": "Devlog #6 Last time ended with the review finally seeing subtitles — burn mode drew them into the pixels, and the preview proved the output wasn’t empty. The review came back with a new problem, and this one was in the desktop app. The reviewer dragged a video in, then dragged its subtitle in a moment later, and nothing happened. No pair, no row, no error. Just a log line nobody reads. “I dropped the video, then the subtitle. Nothing happened.” The flow made sense to me. Any sane user would load the video and subtitles together. The app matched files by stem and folder, so the names matched — but the pair never appeared. I reproduced it immediately: drop the .mkv, 0 pairs. Drop the .srt, 0 pairs. Drop them together, 1 pair. The order wasn’t the problem — the batch was. It was by design. That was the problem. The drop handler split the incoming paths into videos and subtitles and matched them within that single batch : Both file types had to arrive in the same drop or the matcher had nothing to join. It wasn’t a bug — it worked exactly as written. My own testing had always dropped both files at once, so I never saw it. The code was correct and exactly wrong. To make it worse, that same logic was copy-pasted in four places: the drop handler, the file dialog, the drop-area click, and the folder scan. Four copies, one flaw. Deferred matching The fix was to stop matching batches and start matching state. Files now accumulate into pending queues, and every drop re-runs the matcher against everything pending. Drop the video, drop the subtitle five minutes later — the second drop finds the first and pairs them. All four entry points now route through one _ingest() method, so the deferred-matching rule can’t drift between copies again. Then I almost made it worse My first pass at leftovers had a shortcut: sort the unmatched files by name and zip them into pairs. Five videos and two subtitles? Two pairs, formed by name order, not by any actual correspondence. The names could be completely unrelated — the wrong subtitle would get muxed in silently. That’s the exact failure class I was trying to kill. So the shortcut got a guard: auto-pairing only happens when exactly one video and one subtitle are left unmatched, and everything else stays in the pending queue where the user can see it. While I was in there: dropping the same file twice no longer stacks duplicates in the queue, and the waiting notice now shows whenever anything is still pending — not just when a drop matched nothing. The log panel expands on its own so the “waiting for a matching subtitle” state is visible instead of hidden. TMI It was quite a struggle at first; I hadn’t properly watched the review video, so while testing it myself, I kept wondering, “Wait, it works fine—why do people say it doesn’t?” It turned out the issue was incredibly simple. I made a few minor quality-of-life improvements, but since they were so small, I decided to skip mentioning them. I actually wanted to do more, but I’ve already spent nine hours on this, so I’m wrapping things up now."}, {"post_id": 50793, "ts": "2026-08-20T05:49:47Z", "hours": 1.9336, "text": "Devlog #5 SubBake was released, the web demo went live, and then the review came back: “can’t see no subtitles.” That’s a bug report against a shipped product — the whole point of the app is subtitles, and the review had no way to see them. I couldn’t argue with it: the output carried no visible proof. This devlog is about hunting that bug down and making sure the subtitles can never be invisible again. The fix two There was no rendering at all, nothing to prove a subtitle existed. So the fix attacked both sides. Burn mode re-encodes the video with subtitles drawn into the frames, so the text becomes part of the picture — impossible to miss. And the preview gives real playback with a VTT attached to the <video> element, so muxed output shows its subtitles too. The web API grew a mode parameter ( mux | burn ), the burn path got progress and cancel wired through the existing worker machinery, and the GUI picked up a matching checkbox. Burn mode also rejects SUP input outright — bitmap subtitles can’t be rasterized by libass, and a silent fallback would just recreate the “nothing visible” bug. The output file was empty, and nobody noticed While testing burn with a +1s offset, the job failed with a cryptic libass error: Unable to open /tmp/... . The real cause was in the remux step. The remux command — the one that shifts timestamps with itsoffset before burning — was missing -y . The output path already existed because it came from NamedTemporaryFile , and ffmpeg refuses to overwrite without -y . Worse, it exited with returncode 0 while writing nothing, so the “did it succeed?” check passed. A zero-byte file then went into libass. Two small ordering bugs The preview VTT was extracted with a hardcoded \"ffmpeg\" while the rest of the server resolves a bundled binary via find_ffmpeg() . On a machine without ffmpeg on PATH, preview breaks exactly where the reviewer looks. Switched to the shared resolver. The <track> element was appended after video.load() . The track never registered, so even correct VTT extraction showed nothing. Order matters: attach the track, set the source, then load. Proof The bug report was about visibility, so the fix had to be verified the same way. I burned two different subtitle files into the same video and compared raw RGB. Everything above the subtitle band matched; rows 174–239 differed by ~14,000 pixels — the only difference between the two outputs was the burned text. Then an offset burn: at t=0.5s the frame matched the original (subtitle hadn’t appeared yet), at t=2.5s the text was present. Korean needed one more thing in production: the server had no CJK fonts, so Korean subtitles would burn as tofu boxes. Installed noto-fonts-cjk , re-ran the Korean burn — 12,152 pixels of band difference, and the black outline count jumped from 1385 to 1581. Real glyphs, not boxes."}, {"post_id": 46613, "ts": "2026-08-13T23:46:01Z", "hours": 7.2458, "text": "Devlog #4 Last time SubBake had a face and working hands. This time it learned to speak two languages, moved into a real server, and nearly broke me on the way in. The deployment deserves its own section, because nothing about it went right the first time. One language became two Every user-facing string had been hardcoded in English while I built the thing. Now it all funnels through one translation table — t(\"btn.start\") instead of \"Start SubBake\" — with a language combo in the corner that re-renders the whole window live, no restart. The trick that makes it work: widgets register a callback on a global listener list, and flipping the language calls every one of them. One slight downside is that the font changes when using Korean, which alters the layout; however, I was already so exhausted from dealing with the language settings that I didn’t have the energy to tweak it. I might adjust it later if I have the time, but I trust it will be fine since most people will likely be viewing it in English. Click, not just drag The drop zone now opens a file picker on click, with mixed multi-select for files and folders. Qt’s native dialog refuses to do that, so the code forces the Qt built-in dialog, switches it to directory mode, and then flips every internal list view to extended selection. It feels like a hack because it is one. It works beautifully. The web demo To ensure you can easily test and enjoy the project, I have deployed it using a domain in addition to providing the build files. SubBake now lives at a public URL — a FastAPI wrapper around the same core/ modules (imported, not copied), served from my home server VM, fronted by nginx, reached through a Cloudflare tunnel. Upload a video and a subtitle, watch the progress bar, download the result. Files vanish on a timer. So, the bottom line is that while you can download the Windows and Linux-compatible executables from GitHub to run the project, you can also test it using the URL provided here (the code is identical in both cases)."}, {"post_id": 45534, "ts": "2026-08-12T02:31:32Z", "hours": 8.4511, "text": "Devlog #3 Last time SubBake could mux subtitles with colors intact, but only through python -c one-liners. This time it got a face. It also got that face redesigned, because the first one looked like it was generated by an AI. More on that later. The shell The GUI is PySide6: a drag-and-drop area, a queue table, options row, a progress bar, and a collapsible log panel. Dropping a folder scans it recursively and pairs videos with same-named subtitles. The pairing is just (parent folder, lowercase stem) as a dictionary key, which quietly handles nested folders with identical filenames. Everything is a thread problem FFmpeg muxing is blocking, and a Qt window must never block. So every job is a QRunnable on a QThreadPool capped at 4. The catch: worker threads cannot touch widgets, at all, or the app crashes. So workers never see the UI — they emit signals ( progress , file_progress , finished , log ) and the GUI thread applies them. One trap cost me a real headache: the pool auto-deletes finished runnables by default. My cancel button keeps references to running tasks so it can flag them — which becomes a dangling pointer the moment auto-delete kicks in. The fix is one line you only learn by crashing: self.setAutoDelete(False) Cancel itself is two different operations pretending to be one button. thread_pool.clear() drops queued tasks, but anything already running needs a cancel() flag that FFmpeg checks between progress updates. The progress bar’s total also has to be recalculated mid-flight, or cancelling leaves the bar lying about how much is left. The tray icon that isn’t a file I didn’t want to ship an icon asset for a tray icon nobody looks at, so the app paints one at runtime — a 32×32 QPixmap filled with one flat color. Zero files, zero resource management, and honestly nobody can tell. Where SubBake stands A real window: drag-and-drop, queue table, live per-file progress, cancel, tray notification when done Four files mux in parallel without freezing the UI, and cancelling mid-run doesn’t corrupt anything Flat neutral theme, zero emoji, no gradients Still English-only, still launched from a terminal Next up Two things remain before this is a product: a language toggle (every string already funnels through one translation table, so it’s a refactor, not a rewrite), and PyInstaller packaging so normal humans can double-click it. Then the web demo, which is a story of its own."}, {"post_id": 43308, "ts": "2026-08-08T14:36:11Z", "hours": 3.5961, "text": "Devlog #2 Last time: a parser pipeline that couldn’t bake anything. Now SubBake actually muxes subtitles into video — and I collected three of the dumbest bugs I’ve ever fought along the way. The muxer was the easy part core/muxer.py runs FFmpeg as a subprocess with -progress pipe:1 and reads out_time_us lines off stdout for live progress. Video and audio are stream-copied, so a 24-minute episode remuxes in seconds. The sharp edges were plumbing: stderr needs its own drain thread or the pipe buffer fills and FFmpeg just stops, and the container picks your subtitle codec — MKV copies the track as-is, MP4 demands mov_text , WebM demands webvtt . One ffmpeg -i later: Stream #0:2(kor): Subtitle: subrip (default) , title “Korean”. It works. SRT ate my colors Then I actually watched the result. The original fansub had per-speaker colors; my output was all white. Of course — my SMI parser stripped every HTML tag, including <font color=\"#FF80C0\"> , and SRT has no styling anyway. So the plan changed: SMI now converts to ASS. The parser keeps font tags, and a new core/ass_converter.py writes a proper ASS document. Writing the font regex by hand nearly broke me This regex took longer than the entire muxer: _FONT_COLOR_RE = re.compile( r'<font\\s+[^>]*color\\s*=\\s*[\"\\']?([^\"\\'>\\s]+)[\"\\']?[^>]*>', re.IGNORECASE, ) The job sounds trivial: find opening <font> tags, capture the color. My first draft, <font color=\"(.*?)\"> , fell apart immediately — real fansub files write color=\"Red\" (a name, not hex), color=#FF80C0 (no quotes), and <font face=\"Arial\" color=\"...\"> (color isn’t the first attribute). The class [^\"\\'>\\s]+ exists because the value must stop at a quote, a space, or the tag end — and nesting quote escapes inside a Python raw string is its own hell. I built it the way everyone builds regexes: one character class at a time in a tester, pasting real SMI lines until nothing exploded. Nobody writes this in one shot; anyone who claims otherwise is lying. Then the colors came out wrong — red and blue swapped. ASS stores colors as &HBBGGRR& , a byte order from the 80s, so every RGB value gets flipped first. That cost me a long “why is the pink line blue” stare. The giant subtitle First ASS render: billboard-sized subtitles. Fontsize 36 sounds fine until you learn what PlayResX / PlayResY do — ASS declares a design resolution and the renderer scales it to the real video. I designed for 640×360 and played at 1080p: a 3× upscale, 108-pixel letters. Now I declare PlayRes 1920×1080 and the size holds at any resolution. I verified by burning test frames, because I no longer trust my own reasoning about this format. The mystery box Last ghost: a hollow rectangle between the pink Japanese line and the Korean translation. Every codepoint in those lines was clean, and re-rendering the same structure in isolation showed nothing. The culprit was the blank line — the SMI source separates the two languages with an empty line, my converter turned it into \\N\\N , and my player draws an empty subtitle line as a hollow box. Collapsing consecutive newlines fixed it, and as a bonus the output now looks exactly like the original presentation. Sometimes the bug is a blank line. That sentence is going on my gravestone. Where SubBake stands Muxing works end to end: stream copy, live progress, clean cancel, per-container codecs SMI→ASS with per-speaker colors, verified on a real episode Sane subtitle size at any resolution; phantom box gone Still no GUI — everything runs through python -c one-liners Next up The interface: a PySide6 shell with drag-and-drop, a queue table, and worker threads — the point where this becomes an app instead of modules I poke from a terminal. Qt will be perfectly calm and reasonable. (It will not be.)"}, {"post_id": 41919, "ts": "2026-08-06T13:11:24Z", "hours": 2.9786, "text": "Devlog #1 SubBake is a desktop app I’m building that embeds subtitle files (SMI / SRT / ASS / SSA / VTT / SUP) into MKV, MP4 and WebM as real subtitle tracks. Python + PySide6, FFmpeg bundled, GPL v3. This devlog covers the first stretch: empty repo to a working conversion pipeline. What I did Repo scaffold, GPL license, and a .gitignore that keeps the 300 MB ffmpeg/ folder out of git download_ffmpeg.py — downloads a static FFmpeg build for Windows / Linux / macOS, unpacks it, deletes the leftovers core/ffmpeg_locator.py — finds that binary later (bundled, PyInstaller temp dir, or PATH) Parsers for SMI and VTT, an SRT writer, encoding detection, and a matcher that pairs videos with their subtitle files Problems The SMI parser found 12 cues in a whole episode. I’d forgotten re.DOTALL , so any dialogue with a line break killed the match early. One flag later, hundreds of cues. Opened my test subtitle in VS Code and panicked - the entire file was mojibake. Took me a minute to realize the file is CP949 and the editor was just displaying it as UTF-8. The converter had already decoded it correctly. Editor lied, pipeline didn’t. The “throw it at the converter” test Grabbed a real SMI off the internet (a K-ON episode) and ran it through. 385 cues, first one at 00:00 31,458, Korean perfectly intact. The side-by-side screenshot says it best: left is the mojibake original, right is the clean SRT that came out. That’s the encoding fallback doing its job. Where SubBake stands FFmpeg downloads and gets located on all three OSes SMI / VTT → SRT conversion verified on real data No muxer, no GUI — it still can’t bake anything Next up core/muxer.py : run FFmpeg as a subprocess, stream the progress output, handle cancel, and pick the right subtitle codec per container (mov_text / webvtt / copy). Devlog #2 should have actual player screenshots instead of terminal text."}]...
|
Title: SubBake by @reimunyancat | Stardance
Devlo Title: SubBake by @reimunyancat | Stardance
Devlogs:
Devlog #1 SubBake is a desktop app I’m building that embeds subtitle files (SMI / SRT / ASS / SSA / VTT / SUP) into MKV, MP4 and WebM as real subtitle tracks. Python + PySide6, FFmpeg bundled, GPL v3. This devlog covers the first stretch: empty repo to a working conversion pipeline. What I did Repo scaffold, GPL license, and a .gitignore that keeps the 300 MB ffmpeg/ folder out of git download_ffmpeg.py — downloads a static FFmpeg build for Windows / Linux / macOS, unpacks it, deletes the leftovers core/ffmpeg_locator.py — finds that binary later (bundled, PyInstaller temp dir, or PATH) Parsers for SMI and VTT, an SRT writer, encoding detection, and a matcher that pairs videos with their subtitle files Problems The SMI parser found 12 cues in a whole episode. I’d forgotten re.DOTALL , so any dialogue with a line break killed the match early. One flag later, hundreds of cues. Opened my test subtitle in VS Code and panicked - the entire file was mojibake. Took me a minute to realize the file is CP949 and the editor was just displaying it as UTF-8. The converter had already decoded it correctly. Editor lied, pipeline didn’t. The “throw it at the converter” test Grabbed a real SMI off the internet (a K-ON episode) and ran it through. 385 cues, first one at 00:00 31,458, Korean perfectly intact. The side-by-side screenshot says it best: left is the mojibake original, right is the clean SRT that came out. That’s the encoding fallback doing its job. Where SubBake stands FFmpeg downloads and gets located on all three OSes SMI / VTT → SRT conversion verified on real data No muxer, no GUI — it still can’t bake anything Next up core/muxer.py : run FFmpeg as a subprocess, stream the progress output, handle cancel, and pick the right subtitle codec per container (mov_text / webvtt / copy). Devlog #2 should have actual player screenshots instead of terminal text.
Devlog #2 Last time: a parser pipeline that couldn’t bake anything. Now SubBake actually muxes subtitles into video — and I collected three of the dumbest bugs I’ve ever fought along the way. The muxer was the easy part core/muxer.py runs FFmpeg as a subprocess with -progress pipe:1 and reads out_time_us lines off stdout for live progress. Video and audio are stream-copied, so a 24-minute episode remuxes in seconds. The sharp edges were plumbing: stderr needs its own drain thread or the pipe buffer fills and FFmpeg just stops, and the container picks your subtitle codec — MKV copies the track as-is, MP4 demands mov_text , WebM demands webvtt . One ffmpeg -i later: Stream #0:2(kor): Subtitle: subrip (default) , title “Korean”. It works. SRT ate my colors Then I actually watched the result. The original fansub had per-speaker colors; my output was all white. Of course — my SMI parser stripped every HTML tag, including <font color="#FF80C0"> , and SRT has no styling anyway. So the plan changed: SMI now converts to ASS. The parser keeps font tags, and a new core/ass_converter.py writes a proper ASS document. Writing the font regex by hand nearly broke me This regex took longer than the entire muxer: _FONT_COLOR_RE = re.compile( r'<font\s+[^>]*color\s*=\s*["\']?([^"\'>\s]+)["\']?[^>]*>', re.IGNORECASE, ) The job sounds trivial: find opening <font> tags, capture the color. My first draft, <font color="(.*?)"> , fell apart immediately — real fansub files write color="Red" (a name, not hex), color=#FF80C0 (no quotes), and <font face="Arial" color="..."> (color isn’t the first attribute). The class [^"\'>\s]+ exists because the value must stop at a quote, a space, or the tag end — and nesting quote escapes inside a Python raw string is its own hell. I built it the way everyone builds regexes: one character class at a time in a tester, pasting real SMI lines until nothing exploded. Nobody writes this in one shot; anyone who claims otherwise is lying. Then the colors came out wrong — red and blue swapped. ASS stores colors as &HBBGGRR& , a byte order from the 80s, so every RGB value gets flipped first. That cost me a long “why is the pink line blue” stare. The giant subtitle First ASS render: billboard-sized subtitles. Fontsize 36 sounds fine until you learn what PlayResX / PlayResY do — ASS declares a design resolution and the renderer scales it to the real video. I designed for 640×360 and played at 1080p: a 3× upscale, 108-pixel letters. Now I declare PlayRes 1920×1080 and the size holds at any resolution. I verified by burning test frames, because I no longer trust my own reasoning about this format. The mystery box Last ghost: a hollow rectangle between the pink Japanese line and the Korean translation. Every codepoint in those lines was clean, and re-rendering the same structure in isolation showed nothing. The culprit was the blank line — the SMI source separates the two languages with an empty line, my converter turned it into \N\N , and my player draws an empty subtitle line as a hollow box. Collapsing consecutive newlines fixed it, and as a bonus the output now looks exactly like the original presentation. Sometimes the bug is a blank line. That sentence is going on my gravestone. Where SubBake stands Muxing works end to end: stream copy, live progress, clean cancel, per-container codecs SMI→ASS with per-speaker colors, verified on a real episode Sane subtitle size at any resolution; phantom box gone Still no GUI — everything runs through python -c one-liners Next up The interface: a PySide6 shell with drag-and-drop, a queue table, and worker threads — the point where this becomes an app instead of modules I poke from a terminal. Qt will be perfectly calm and reasonable. (It will not be.)
Devlog #3 Last time SubBake could mux subtitles with colors intact, but only through python -c one-liners. This time it got a face. It also got that face redesigned, because the first one looked like it was generated by an AI. More on that later. The shell The GUI is PySide6: a drag-and-drop area, a queue table, options row, a progress bar, and a collapsible log panel. Dropping a folder scans it recursively and pairs videos with same-named subtitles. The pairing is just (parent folder, lowercase stem) as a dictionary key, which quietly handles nested folders with identical filenames. Everything is a thread problem FFmpeg muxing is blocking, and a Qt window must never block. So every job is a QRunnable on a QThreadPool capped at 4. The catch: worker threads cannot touch widgets, at all, or the app crashes. So workers never see the UI — they emit signals ( progress , file_progress , finished , log ) and the GUI thread applies them. One trap cost me a real headache: the pool auto-deletes finished runnables by default. My cancel button keeps references to running tasks so it can flag them — which becomes a dangling pointer the moment auto-delete kicks in. The fix is one line you only learn by crashing: self.setAutoDelete(False) Cancel itself is two different operations pretending to be one button. thread_pool.clear() drops queued tasks, but anything already running needs a cancel() flag that FFmpeg checks between progress updates. The progress bar’s total also has to be recalculated mid-flight, or cancelling leaves the bar lying about how much is left. The tray icon that isn’t a file I didn’t want to ship an icon asset for a tray icon nobody looks at, so the app paints one at runtime — a 32×32 QPixmap filled with one flat color. Zero files, zero resource management, and honestly nobody can tell. Where SubBake stands A real window: drag-and-drop, queue table, live per-file progress, cancel, tray notification when done Four files mux in parallel without freezing the UI, and cancelling mid-run doesn’t corrupt anything Flat neutral theme, zero emoji, no gradients Still English-only, still launched from a terminal Next up Two things remain before this is a product: a language toggle (every string already funnels through one translation table, so it’s a refactor, not a rewrite), and PyInstaller packaging so normal humans can double-click it. Then the web demo, which is a story of its own.
Devlog #4 Last time SubBake had a face and working hands. This time it learned to speak two languages, moved into a real server, and nearly broke me on the way in. The deployment deserves its own section, because nothing about it went right the first time. One language became two Every user-facing string had been hardcoded in English while I built the thing. Now it all funnels through one translation table — t("btn.start") instead of "Start SubBake" — with a language combo in the corner that re-renders the whole window live, no restart. The trick that makes it work: widgets register a callback on a global listener list, and flipping the language calls every one of them. One slight downside is that the font changes when using Korean, which alters the layout; however, I was already so exhausted from dealing with the language settings that I didn’t have the energy to tweak it. I might adjust it later if I have the time, but I trust it will be fine since most people will likely be viewing it in English. Click, not just drag The drop zone now opens a file picker on click, with mixed multi-select for files and folders. Qt’s native dialog refuses to do that, so the code forces the Qt built-in dialog, switches it to directory mode, and then flips every internal list view to extended selection. It feels like a hack because it is one. It works beautifully. The web demo To ensure you can easily test and enjoy the project, I have deployed it using a domain in addition to providing the build files. SubBake now lives at a public URL — a FastAPI wrapper around the same core/ modules (imported, not copied), served from my home server VM, fronted by nginx, reached through a Cloudflare tunnel. Upload a video and a subtitle, watch the progress bar, download the result. Files vanish on a timer. So, the bottom line is that while you can download the Windows and Linux-compatible executables from GitHub to run the project, you can also test it using the URL provided here (the code is identical in both cases).
Devlog #5 SubBake was released, the web demo went live, and then the review came back: “can’t see no subtitles.” That’s a bug report against a shipped product — the whole point of the app is subtitles, and the review had no way to see them. I couldn’t argue with it: the output carried no visible proof. This devlog is about hunting that bug down and making sure the subtitles can never be invisible again. The fix two There was no rendering at all, nothing to prove a subtitle existed. So the fix attacked both sides. Burn mode re-encodes the video with subtitles drawn into the frames, so the text becomes part of the picture — impossible to miss. And the preview gives real playback with a VTT attached to the <video> element, so muxed output shows its subtitles too. The web API grew a mode parameter ( mux | burn ), the burn path got progress and cancel wired through the existing worker machinery, and the GUI picked up a matching checkbox. Burn mode also rejects SUP input outright — bitmap subtitles can’t be rasterized by libass, and a silent fallback would just recreate the “nothing visible” bug. The output file was empty, and nobody noticed While testing burn with a +1s offset, the job failed with a cryptic libass error: Unable to open /tmp/... . The real cause was in the remux step. The remux command — the one that shifts timestamps with itsoffset before burning — was missing -y . The output path already existed because it came from NamedTemporaryFile , and ffmpeg refuses to overwrite without -y . Worse, it exited with returncode 0 while writing nothing, so the “did it succeed?” check passed. A zero-byte file then went into libass. Two small ordering bugs The preview VTT was extracted with a hardcoded "ffmpeg" while the rest of the server resolves a bundled binary via find_ffmpeg() . On a machine without ffmpeg on PATH, preview breaks exactly where the reviewer looks. Switched to the shared resolver. The <track> element was appended after video.load() . The track never registered, so even correct VTT extraction showed nothing. Order matters: attach the track, set the source, then load. Proof The bug report was about visibility, so the fix had to be verified the same way. I burned two different subtitle files into the same video and compared raw RGB. Everything above the subtitle band matched; rows 174–239 differed by ~14,000 pixels — the only difference between the two outputs was the burned text. Then an offset burn: at t=0.5s the frame matched the original (subtitle hadn’t appeared yet), at t=2.5s the text was present. Korean needed one more thing in production: the server had no CJK fonts, so Korean subtitles would burn as tofu boxes. Installed noto-fonts-cjk , re-ran the Korean burn — 12,152 pixels of band difference, and the black outline count jumped from 1385 to 1581. Real glyphs, not boxes.
Devlog #6 Last time ended with the review finally seeing subtitles — burn mode drew them into the pixels, and the preview proved the output wasn’t empty. The review came back with a new problem, and this one was in the desktop app. The reviewer dragged a video in, then dragged its subtitle in a moment later, and nothing happened. No pair, no row, no error. Just a log line nobody reads. “I dropped the video, then the subtitle. Nothing happened.” The flow made sense to me. Any sane user would load the video and subtitles together. The app matched files by stem and folder, so the names matched — but the pair never appeared. I reproduced it immediately: drop the .mkv, 0 pairs. Drop the .srt, 0 pairs. Drop them together, 1 pair. The order wasn’t the problem — the batch was. It was by design. That was the problem. The drop handler split the incoming paths into videos and subtitles and matched them within that single batch : Both file types had to arrive in the same drop or the matcher had nothing to join. It wasn’t a bug — it worked exactly as written. My own testing had always dropped both files at once, so I never saw it. The code was correct and exactly wrong. To make it worse, that same logic was copy-pasted in four places: the drop handler, the file dialog, the drop-area click, and the folder scan. Four copies, one flaw. Deferred matching The fix was to stop matching batches and start matching state. Files now accumulate into pending queues, and every drop re-runs the matcher against everything pending. Drop the video, drop the subtitle five minutes later — the second drop finds the first and pairs them. All four entry points now route through one _ingest() method, so the deferred-matching rule can’t drift between copies again. Then I almost made it worse My first pass at leftovers had a shortcut: sort the unmatched files by name and zip them into pairs. Five videos and two subtitles? Two pairs, formed by name order, not by any actual correspondence. The names could be completely unrelated — the wrong subtitle would get muxed in silently. That’s the exact failure class I was trying to kill. So the shortcut got a guard: auto-pairing only happens when exactly one video and one subtitle are left unmatched, and everything else stays in the pending queue where the user can see it. While I was in there: dropping the same file twice no longer stacks duplicates in the queue, and the waiting notice now shows whenever anything is still pending — not just when a drop matched nothing. The log panel expands on its own so the “waiting for a matching subtitle” state is visible instead of hidden. TMI It was quite a struggle at first; I hadn’t properly watched the review video, so while testing it myself, I kept wondering, “Wait, it works fine—why do people say it doesn’t?” It turned out the issue was incredibly simple. I made a few minor quality-of-life improvements, but since they were so small, I decided to skip mentioning them. I actually wanted to do more, but I’ve already spent nine hours on this, so I’m wrapping things up now.
Ships:
SubBake is a desktop app that embeds subtitles into video files. Drop in a video and a subtitle file, and it muxes the subtitle in as a proper track — colors, timing, and all — without re-encoding anything. There is also a live web demo if you would rather try it in a browser. What it does: It reads SMI, SRT, ASS, SSA, VTT and SUP subtitles and writes MKV, MP4 or WebM. SMI fansubs keep their per-speaker colors, because they get converted to ASS instead of being flattened into SRT. The GUI takes drag and drop or a plain click, auto-matches videos to same-named subtitle files, and shows live per-file progress straight from FFmpeg’s output. FFmpeg comes bundled, so the whole thing is one executable with nothing to install, and the UI speaks English and Korean without a restart. The web demo runs the exact same pipeline on my home server. Upload both files, watch the bar, download the result. Everything you upload is deleted automatically after an hour. The hardest part was invisible. One subtitle cue kept showing a hollow box between two lines, and every character in those lines came back clean. The culprit turned out to be a blank line in the source file, which my converter faithfully translated into an empty subtitle line that the player rendered as a box. The fix was collapsing consecutive newlines into one. Sometimes the bug is a blank line. To test it: grab the release, drop a video and its subtitle onto the window, and hit Start SubBake. Or open reimunyancat.com/subbake and do the same thing with zero install....
|
1792878570.6959496
|
1793742570.6959496
|