You are browsing as a guest. Sign up (or log in) to start making projects!

reimunyancat

@reimunyancat

Joined June 7th, 2026

  • 44Devlogs
  • 10Projects
  • 9Ships
  • 90Votes
I am a 17-year-old developer from South Korea. I am trying out various things, but I mainly work in web and programming. I write all the devlogs manually, but running them through a translator might make them look like they were written by AI. I tried to address this, but I apologize as my English skills are not very good.
Open comments for this post

8h 49m 14s logged

Devlog #4

Spent this round splitting the app into actual pages, fixing a permissions wall I didn’t know existed, and making the galaxy feel less like a static diorama.

New page

The demo mode search input lived in a corner of the galaxy view, which turned out to be a fight I couldn’t win: clicking the canvas locks your pointer for flight controls, so trying to type in an input that shares the screen meant the cursor kept getting stolen mid-keystroke.

Instead of negotiating between the two, I split them. / is now a landing page and the galaxy moved to /g/[user]. The URL now decides whose galaxy you’re looking at, which also means every galaxy is a shareable link for free. I had planned a whole /u/username share system for later and then realized the router had already handed it to me.

Permission issue

Demo mode looked done until I actually clicked into someone else’s repo and got nothing. My server authenticated every tree and file request with the GitHub App’s installation token, and installation tokens can only read repos the app is installed on. Works for my account, 404 for literally anyone else’s.

The fix was boring: try the token first, and if the response isn’t a success, retry anonymously. Public repos don’t need auth anyway. I briefly felt clever for the fallback, then remembered the clever thing would have been thinking about scope the first time.

Stars that burn when you’re active

Two visual features this time. First, activity flares: each repo’s pushed_at becomes a score that decays linearly to zero over 30 days, and that score drives a slow pulse on the star’s point light. Recently pushed repos visibly breathe; abandoned ones sit cold. The idea being you can see what someone is working on lately without reading anything.

Second, search and warp. Type part of a repo name, hit Enter, and the camera lerps itself to a viewpoint just off that star. One detail that mattered: the warp calls document.exitPointerLock() first, because warping while the pointer is locked leaves you staring at a star with no cursor to click anything.

Next up

Deployment, probably. It’s currently a localhost demo, and “check out my galaxy at localhost:3000” impresses no one.

0
0
61
Open comments for this post

6h 31m 53s logged

Devlog #11

Last devlog ended with me calling statistics-only scoring the last fake thing in the cracker, and promising the real attack. This is that

The actual attack

A crib is a guessed fragment of plaintext — operators were creatures of habit, and words like “Meldung” (report) show up in everything. With a crib, the cracker stops scoring vibes entirely: every candidate setting decrypts the text, and the crib either appears in it letter-for-letter or it doesn’t. Six exact letters out of 17,576 start positions leaves one setting standing. The five short intercepts that statistics couldn’t touch now each carry a crib — FUNKEN, MORGEN, STEINECKE (the Germans really did sign Steinecke twice) — and all of them break in under a second.

One historical filter made it in: a letter never encrypts to itself, so a crib can only sit where no letter collides with the ciphertext. Fixed-offset guesses get rejected on that rule before the search even runs.

The answer that was wrong and right

The fun started in testing. One intercept’s documented start position is SWV, but my cracker’s top hit came back RVV — and RVV decrypts the message perfectly. Letter for letter, not just the crib window. I was convinced my engine was broken, so I compiled the reference C++ natively and compared: identical output on both. It’s not a bug. This key’s middle rotor sits exactly on its notch, so the famous double-step kicks the left rotor forward on the very first keypress — (R,V,V) and (S,W,V) converge to the same machine state before a single letter is encrypted. Two daily keys, one message. The codebreakers knew this class of equivalent settings; I rediscovered it by being confused for an hour.

Next up

The guided demo — the machine types, X-rays its own wiring, and cracks its own message while a first-time visitor watches.

0
0
5
Open comments for this post

8h 50m 52s logged

Devlog #10

This one is two small things instead of one big thing — share links, and a challenge mode built from real 1941 intercepts — plus an embarrassing amount of time spent not writing code. Most of it still isn’t properly wired in, but the hours happened, so here’s the honest checkpoint.

Share links

The whole machine state — rotors, reflector, rings, start position, plugboard — plus a message now fits in the URL hash, like #I.II.III.B:TOU:HOU:ABCDXZ:HELLOWORLD. Opening the link configures the machine and runs the message through it, so the person on the other end watches their lampboard spell out the plaintext. “I encrypted this, try reading it” finally works as a link.

My first version of the format forgot the reflector. Anyone opening those links would have gotten confident garbage, and I only noticed while writing the parser. It costs four characters in the URL.

Real intercepts, verified

I didn’t want to invent fake historical messages, so I went looking for real ones — and that hunt ate the first chunk of the day. Ended up at Ostwald and Weierud’s Cryptologia paper (2017)(https://cryptocellar.org), which lists 13 authentic Wehrmacht messages from 1941, transcribed from the actual message forms, with keys recovered by modern cryptanalysis. I picked six — from a three-word “morning report canceled” up to an SS tank division complaining that two other divisions are blocking its road.

Each form starts with a 5-letter group that looks like ciphertext but isn’t — it’s the Kenngruppe, a network discriminant. The paper warns you to skip it before decrypting, and for once I read the instructions first. All six decrypt on the site’s engine to exactly the plaintext on the forms.

The cracker only spoke English

The crack mode scored decryptions by English letter frequencies. These messages are German. Added a German frequency table and scored by whichever language fits better.

Still not enough for the short ones. I ran a parameter sweep over the scoring weights and the 18-to-76-letter intercepts never rank first under any combination. That’s not a bug, it’s information theory — and it’s exactly why Bletchley Park needed cribs instead of brute force. So the panel is honest about it: only the 214-letter intercept gets a “Crack positions” button (it recovers the real start position, SDV, in about half a second), and the short ones say why they’re decrypt-only.

Where the hours actually went

Reading a cryptology paper instead of writing components. Rewriting the challenge card layout more than once. Re-running the sweep every time I changed my mind about the scoring. The code diff is small; the day was not. The remaining wiring is still in progress.

Next up

Crib-based cracking, the known-plaintext attack. That’s what actually breaks the short ones, and it’s the historically accurate method

0
0
6
Open comments for this post

9h 0m 33s logged

Devlog #3

This one is three small things instead of one big thing. Controls, a code viewer, and a demo mode.

The flight controls were bad, so I fixed them

The first version had WASD and a speed of 12. That was it. Flying across the galaxy felt like wading through water, vertical movement was on Space/Shift which is apparently not what anyone expects, and the camera started and stopped instantly, which felt robotic.

New setup: base speed is 30, Shift is sprint (3.5x), E goes up and Q goes down, and scrolling the wheel multiplies your speed up or down so you can be precise inside a solar system and fast between constellations. Movement also has acceleration and deceleration now via lerping the velocity vector. This alone made it feel like an actual spaceship instead of a camera on rails.

Clicking a file moon opens the code

The moons in a solar system were just decoration, so now clicking one fetches the file through the server and opens a side panel with syntax highlighting via highlight.js. The trick on the server side is sending Accept: application/vnd.github.raw to the contents API, which hands back the raw file instead of a base64 blob wrapped in JSON. Saved me a decode step and a headache.

One small thing I got wrong first: I put a key on the <Canvas> to reset the camera when switching views, then forgot that clicking things while pointer lock is active is its own mess. Tooltips and clicks work fine through drei’s event system, but I did waste ten minutes confused about why my onClick wasn’t firing before realizing I had the handler on the wrong component.

Demo mode

There was no way to look at anyone’s galaxy except mine, which defeats the entire point of sharing a link. Now there’s a username box in the corner; type any GitHub account and it builds their galaxy from public repos. The server just hits /users/:user/repos anonymously, since public data doesn’t need the app token. Anonymous calls are rate limited to 60/hour, so this will need caching or token routing later, but it works for now.

Next up

Private repos and the public share link, probably. That means real sessions instead of my hardcoded username, which I’ve been putting off because I know it will be annoying.

0
0
43
Open comments for this post

9h 22m 16s logged

Improvements made based on feedback

English + Korean support has been applied. English is the default. There was a point raised in the feedback that the translator did not work properly, making it difficult to read. I apologize for not being considerate of you all.

A guide panel has been added. It was added because feedback suggested that it would be helpful to have text explanations of how it works. A brief explanation of the Enigma and its principles has been added using a translator.

A performance mode has been added. I received feedback that the browser was lagging, so I was struggling to figure out how to handle it. Depending on the mode, I lowered the pixel ratio or optimized the wiring calculations.

The logo engraving has been changed. The black background plate has been removed, and a transparent texture with a dark groove and a bright rim has been created using a dual pass to make it look like it was carved into wood. To be honest, it still hasn’t reached the level I wanted, but I have wasted so much time here that I am honestly exhausted and plan to postpone it until next time.

Rotation lock and camera presets have been added. I simply did exactly what was requested in the feedback, so there isn’t much to say about it.

The crack mode was created simply because I was caught up in the strange idea that if there is an original cipher, there should also be a decryption function. It was the absolute hell; I will never do it again.

Web workers use the same WASM engine as the simulator to [set] initial positions Scoring through brute force is difficult, involving the Interval of Concordance (IoC) as the primary axis plus the chi-square auxiliary for character frequency, plaintext previews for the top 5, and a button to apply directly to the machine…

Trial and error: When I scored only the first 48 characters of each candidate in the full scope, a completely wrong rotor order happened to get a higher IoC and beat the correct answer (0.0677 vs 0.0651). However, when I selected the top 8 from a 64-character sample and re-scored and reordered the entire sentence, the correct answer won by a landslide (0.058 vs 0.012).

Complete reconstruction of X-ray wiring. Honestly, I left the existing wiring as it was because it was a hassle, but it was tough working on it this time. I roughly standardized the thickness and connected the wires properly. Honestly, I still need to tidy it up a bit more, but I’m exhausted.

Lastly, since some people claim my devlog was written by AI, I intentionally didn’t write a title or use Markdown this time. However, I’m not sure what the best approach would be. Please give me lots of feedback in the comments.

3
0
20
Open comments for this post

8h 57m 7s logged

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.

0
0
9
Open comments for this post

6h 9m 42s logged

Devlog #2

Last time ended with a working pipeline: GitHub App auth on a Rust server, my repo list coming back as JSON. This time the goal was simple to say and fun to do — turn that JSON into an actual galaxy, and make stars clickable so each repo opens up into its own little solar system.

Trimming 300 fields down to 7

GitHub’s repo API returns an absurd amount of data per repository. Hundreds of fields, most of which I’ll never touch. So the server got a new /api/galaxy endpoint that maps each repo down to what the galaxy actually needs: name, language, stars, fork/archived flags, last push, description. The frontend never sees the raw GitHub payload, which also keeps the design honest — tokens and GitHub-shaped data stay server-side, the browser only gets our own format.

Placing stars without a physics engine

The interesting problem was layout. Repos need positions in 3D space, they shouldn’t overlap, and related repos should cluster. I went with constellations: a small heuristic buckets each repo into a group (Web, Systems, Games, AI, Tools) based on language plus keywords in the name and description. Each constellation gets a wedge of the galaxy, and inside the wedge, repos spiral outward from the group center.

Star size is log-scaled from star count, because linear scaling either flattens the difference or explodes it. Color comes from GitHub’s own language colors — a Rust repo burns orange-brown, TypeScript blue. Forks and archived repos are hidden by default; a portfolio galaxy shouldn’t be full of other people’s work.

Tooltips came from drei’s <Html> component, which pins actual DOM elements to 3D coordinates. Hover a star, get name, language, stars, constellation. Felt like cheating, in a good way.

Clicking into a solar system

Clicking a star now fetches that repo’s full file tree — one git/trees/HEAD?recursive=1 call through the server proxy — and lays out the top level as a system: directories become planets on an inner orbit, files become moons on an outer one, file size sets moon size, extension sets color. Two thin ring meshes mark the orbits so the structure reads at a glance.

One trap I set for myself: switching views without resetting the camera left me stranded inside the old galaxy coordinates staring at nothing. Fixed by keying the <Canvas> on the selected repo so the camera respawns with the view. The back button is a plain HTML button floating over the canvas — sometimes the boring solution is the right one.

The bug that wasn’t mine

Finished the session with a scary-looking hydration error from Next.js. The diff showed an attribute trancy-version="7.9.1" on the <html> tag that definitely isn’t in my code — because it isn’t. A translation browser extension injects it before React loads, and React correctly reports that server HTML and client HTML differ. Nothing to fix; I just learned to read hydration diffs before panicking. Any attribute that isn’t mine means an extension did it.

Next up

Moons that actually open files — a code viewer panel with syntax highlighting. And at some point the recursive tree needs real LOD handling, because a big monorepo would currently try to render everything at once.

0
0
5
Open comments for this post

1h 56m 1s logged

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.

0
0
6
Open comments for this post

9h 2m 5s logged

Devlog #1

Uranometria is the idea of turning a GitHub account into an explorable 3D galaxy repos become star systems, folders become planets, files become moons. This session was about getting the two ends of the stack alive: a Three.js scene you can actually fly around in, and a Rust server that talks to GitHub as a proper GitHub App. As a high school senior in Korea, I had to juggle studying for exams alongside this, so I ended up making quite a few silly mistakes.

Flying between spheres

Step zero was a playground: Next.js + react-three-fiber, a sun in the middle, a few planets around it, pointer-lock mouse look, and WASD flight. The flight part is a small FlyControls component that reads keys into a ref and moves the camera along camera.getWorldDirection() inside useFrame, so W always means “wherever I’m looking.” Simple, but it immediately felt like a spaceship, which is the whole point. (To be honest, I reused a lot of code from the previous project. I also know the current design is pretty bland, but I just couldn’t be bothered to change it. I’ll fix it later.)

The scene itself took ten minutes. The ecosystem took the rest of the day.

Talking to GitHub as an App

The actual goal of the session: an axum server that authenticates as a GitHub App — sign a short-lived RS256 JWT with the app’s private key, trade it for an installation token, then call /installation/repositories. Read-only Contents permission, tokens never leaving the server, exactly like the design doc says.

Where Uranometria stands

  • Next.js + R3F scene: sun, planets, stars, pointer-lock + WASD flight working
  • Rust (axum) server: GitHub App JWT auth, installation token flow, /api/repos proxy returning real data
  • Three version-ecosystem traps documented so future-me never pays for them again

Next up

Rendering the actual galaxy — mapping the repo list onto star systems, with language picking the star color and stars deciding the corona. The fun part, finally.

0
0
6
Open comments for this post

7h 38m 43s logged

Devlog #1

LiftOff is a Godot 4 remake of an old Korean block-coding idle rocket game I used to play. Fuel charges over time, you launch a 3-stage rocket, altitude turns into money, money buys upgrades. This is my first Godot project, and this log covers up to the first actual flight.

the brain

I started with global.gd as an autoload before any scene existed. It holds credits, gems, upgrade levels, rebirth multipliers, highscores, and save/load. The 24 planet milestones are ported from the original game’s actual numbers, Mercury at 10K up to the Black Hole at 350e21. Upgrade cost is base × 1.8^level and there’s a formatter that goes K, M, B, T then aa, ab.

Almost shipped a save-eater here. I wrote next_plant instead of next_planet in the load function. Saving worked, loading silently didn’t, so planet progress reset every launch and unlock gems would have duplicated forever. No error anywhere. Caught it while re-reading the code before moving on.

assets

I couldn’t find the asset I used previously, so I recreated it.

fighting the editor

My Godot is in Korean and I kept being told to click menus that didn’t exist. Worst one: I spent way too long right-clicking Title.tscn in the FileSystem dock looking for “attach script”. It was never going to be there — scripts attach to nodes, and nodes live in the Scene panel. Different panel, different menu. I also dragged the script file onto the node and it just asked where to move the file.

Then the save dialog. I named the root node Title so the save dialog suggested Title.tscn, capital T, at the project root. The code wants scenes/title.tscn lowercase. Linux cares about that difference. Deleted it, rebuilt, saved properly.

the rocket moves 9 pixels

The main loop works. Fuel charges, LAUNCH burns three stages, touchdown pays out. But the rocket wasn’t moving, which scared me until I did the math: the visual offset is altitude × 0.0004, and a whole first flight tops out around 22.3K meters, so the rocket moves about nine pixels. Total. It’s fine. The altitude number going up is the game right now; actual motion comes from scrolling backgrounds in the art pass.

next up

Three minigames: Ignition Timing (from the original’s launch gauge), Starhop (slingshot between islands), Meteor Intercept (shoot down falling rocks). Hopefully with fewer wrong menus.

0
0
8
Ship

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.

  • 4 devlogs
  • 22h
  • 18.46x multiplier
  • 411 Stardust
Try project → See source code →
Open comments for this post

7h 14m 45s logged

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).

0
0
10
Open comments for this post

8h 27m 4s logged

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.

1
0
11
Open comments for this post

4h 13m 54s logged

Since I was installing the module myself, the fit wasn’t quite right, so I had to make adjustments while going back and forth between the PCB and the case. It was truly difficult, but… I managed to pull it off.

0
0
14
Ship

I made Apogee, a mission-control-style new tab page. There’s a live clock, a command bar with quick jumps (g for Google, yt for YouTube, gh owner/repo goes straight to a repo), the ISS’s live position on a little map, and NASA’s Astronomy Picture of the Day as the background.

To try it: type “help” in the command bar, or “gh reimunyancat/Apogee”. Watch the ISS dot, it really is moving, every 5 seconds.

Try project → See source code →
Open comments for this post

54m 57s logged

Devlog #3

What I did
• NASA APOD is in — the astronomy picture of the day as the page background, with its title and date in the widget
• README, GitHub Pages, ship

Where Apogee stands
• Done and deployed. Clock, greeting, command bar, live ISS, daily NASA background.
• It quietly became the page I actually open my browser to.

Next up
Nothing on the list. Maybe a settings panel someday, maybe I’ll just enjoy the view.

0
0
15
Open comments for this post

1h 8m 25s logged

Devlog #2

What I did
• The command bar is alive: g, yt, gh, w, npm jumps, plain URLs open directly, anything else falls through to Google
gh got a special case — gh owner/repo skips the search page and goes straight to the repo
• History chips under the input: last 6 commands, stored in localStorage, deduped
• Live ISS tracking: the wheretheiss.at API polled every 5 seconds, with latitude / longitude / altitude / velocity readouts and a dot gliding across a mini map

Where Apogee stands
• Clock, greeting, command bar, history, and a satellite that actually moves
• The APOD widget still says “Loading…” because nothing is loading it yet

Next up
NASA APOD: fetch the astronomy picture of the day, set it as the page background, and cache it in localStorage so I don’t burn through the DEMO_KEY rate limit.

0
0
8
Open comments for this post

3h 35m 46s logged

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.)

0
0
15
Open comments for this post

1h 49m 12s logged

Devlog #1

What I did
• Repo scaffold: index.html, style.css, app.js, README
index.html — the console markup: clock, command bar, and the ISS / APOD widget skeletons
style.css — dark console theme. Rewrote it three times because every draft kept looking AI-generated (glassmorphism, neon glow, the whole checklist). Final version is deliberately plain: system monospace, translucent black boxes, white 1px borders, lime accent
app.js first chunk — live clock plus a greeting that changes with the time of day

Problems
• The clock sat on --:--: -- for a full second after every load. setInterval doesn’t fire until the first interval passes, so tick() now runs once immediately before the interval starts.
9:5:3 is not a time of day. padStart(2, '0') on each unit fixed the padding.

Where Apogee stands
• Clock ticks and the greeting follows the hour
• Command bar, ISS dot and APOD background are still just decoration

Next up
The command bar: search-engine jumps (g, yt, gh, w, npm), direct URL passthrough, and a localStorage history under the input.

0
0
11
Open comments for this post

2h 58m 43s logged

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.

0
0
41
Loading more…

Followers

Loading…