As per Adam Savage on Mythbusters S06E14: "You can, in fact, polish a poop."
Named after the Japanese art of hand-polishing dirt into a smooth, gleaming ball, Dorodango takes rough, oversized footage and works it into a clean, compact, consistent file — quality smoothed, loudness normalized, bitrate reined in.
You're the video engineer for a live event. The client delivers their final promotional video minutes before showtime. You load it in as-is. What you missed: bitrate through the roof, audio so quietly normalized it's nearly inaudible. The video stutters. The audio engineer cranks pre-amp gain +30 dB just to hear anything. The client blames you for choppy playback. The audio engineer blames you for making them crank so hard. You hope they turn that channel back down before the next video. If they don't, it fires hot — and you get blamed again.
We say, we're tired of this 💩.
A native macOS menu bar app that batch-compresses, caps bitrate, and loudness-normalizes video with ffmpeg: drag-and-drop or paste-a-URL intake, saved presets, custom ffmpeg flags, a queue with per-file progress, and a "batch done" notification. Built with SwiftUI in a persistent status-bar panel.
- macOS 13 Ventura or later
- Xcode command line tools:
xcode-select --install - ffmpeg:
brew install ffmpeg(provides bothffmpegandffprobe) - yt-dlp (optional, for URL downloads):
brew install yt-dlp
cd Dorodango
chmod +x build-app.sh
./build-app.sh # produces build/Dorodango.app
open build/Dorodango.appA circle icon appears in your menu bar — no Dock icon, it's an LSUIElement agent app. Click it to open the panel. For quick iteration during development, use swift run.
- Drop videos onto the panel or the menu bar icon, click Add files…, or paste a URL into the drop zone — items queue and process one at a time.
- The sliders button (top right) opens settings. The queue section appears once it has items, with Clear done in its header.
- Each row shows a live progress bar. Finished rows show a size delta (e.g.
120 MB → 38 MB (−68%)) and a Reveal in Finder button. - The menu bar icon spins while work is in progress. Right-click it to Quit.
Video — capped CRF by default: libx264 -crf 21 -maxrate <cap> -bufsize <1.33×cap>, -preset slow, profile main, yuv420p, Rec.709 color tags, +faststart.
Audio (auto-detected; -an when absent) — two analysis passes, then the filter chain:
ebur128 ──► integrated loudness (anchors the compressor threshold)
loudnorm ─► measured_I / TP / LRA / thresh (measured *through* the chain below)
agate ──► acompressor ──► loudnorm (linear) ──► AAC @ 48 kHz, stereo or mono
Gate before compressor so room tone isn't pumped up by the compressor; loudnorm last so it sets program level on the finished signal.
Both analysis passes decode audio only — on a 44-minute source, roughly 2 s for ebur128 and ~1 min for loudnorm, against a far longer video encode.
Why two passes: single-pass loudnorm is a dynamic normalizer. Given a quiet lead-in it winds gain up chasing the target, amplifying room tone until the talking starts and then ducking it. Measured on a 44-minute podium recording, that put the empty room 5.5 dB louder than median speech. Measuring first and normalizing linearly puts it 19.7 dB below.
Why the compressor threshold is measured, not fixed: acompressor's threshold is absolute dBFS. A hardcoded -18dB only bites on sources recorded hot — on a −27 LUFS room recording it sits above nearly all the speech and does almost nothing (0.8 dB of range reduction). Anchoring it to the measured loudness took the same file from 16.3 → 11.0 dB of speech spread.
Output is always <name>_COMP.mp4.
| Control | What it does | Default |
|---|---|---|
| Quality (CRF) | x264 quality floor, 18–28. Lower = better quality, bigger file. Disabled in hardware mode. | 21 |
| Bitrate | Peak ceiling in x264 (cap) or target in hardware (target); bufsize auto-tracks at 1.33×. |
6000k |
| Speed | Walks the x264 preset ladder best→fast; final detent switches to hardware encoder. | slow (best) |
| Channels | Stereo (-ac 2) or sum to mono (-ac 1). |
Stereo |
| Loudness | loudnorm integrated target, −24…−9 LUFS (TP −1.5, LRA 11 fixed). Two-pass linear. |
−16 LUFS |
| Noise gate | Off / Light / Medium / Heavy agate; runs ahead of the compressor. Off bypasses it. |
Off |
| Compression | Off / Light / Medium / Heavy dynamics; threshold tracks measured loudness. | Medium |
| Audio bitrate | AAC bitrate: 96 / 128 / 192 / 256k. | 128k |
| Extra flags | Raw ffmpeg flags appended after the video or audio codec block. | (empty) |
| Download quality | Caps yt-dlp fetch resolution: Source / 2160p / 1080p / 720p. | 1080p |
| Save to | Destination folder, or alongside source. | source |
| Notify | Notification Center banner when the batch finishes. | on |
Settings are organized into named presets selected from a dropdown at the top of the panel.
There's no separate encoder switch — the Speed slider is the encoder choice:
- Best → Fast walks the x264 preset ladder (
slow → medium → fast → faster → veryfast → superfast → ultrafast) at constant quality. CRF drives quality; Bitrate(cap)is a ceiling for busy scenes. Best quality-per-megabyte. - HW (the final, separated detent) switches to
h264_videotoolboxon the Mac media engine. Near-realtime, but ignores CRF — the Quality slider grays out and the Bitrate label flips to(target). Lower quality-per-megabyte; use when turnaround matters more than file size.
Bitrate semantics differ: in x264 mode the number caps peaks (quality-first); in HW mode it becomes the encoder's target bitrate.
Each level snaps to a reproducible detent. Thresholds are relative to I, the measured integrated loudness, clamped to acompressor's −60…0 dB range; if measurement fails they fall back to −18 dB.
| Level | Threshold | acompressor settings |
|---|---|---|
| Off | — | Filter bypassed |
| Light | I | ratio=2:attack=20:release=250:makeup=1.4 |
| Medium | I − 3 | ratio=4:attack=20:release=250:makeup=2 |
| Heavy | I − 6 | ratio=6:attack=10:release=200:makeup=2.5 |
makeupis a linear multiplier (1–64), not dB — ×2 is +6 dB. Earlier versions passed 3/6/8 here, which meant +9.5/+15.6/+18 dB of blind gain.
Runs before the compressor, so the compressor doesn't lift what the gate is about to remove. Thresholds are absolute dBFS.
| Level | agate settings |
|---|---|
| Off | Filter bypassed |
| Light | threshold=-45dB:ratio=2:attack=10:release=500 |
| Medium | threshold=-38dB:ratio=4:attack=5:release=300 |
| Heavy | threshold=-30dB:ratio=8:attack=2:release=200 |
A gate only acts between phrases. If the noise continues underneath the speech, no setting here will touch it — that's afftdn territory, which the app doesn't currently expose.
A preset is a named bundle of every processing setting, including custom flags. The ••• menu offers:
- Save — overwrite the selected preset with current settings
- Save as new… — store current settings under a new name
- Rename…, Delete
- Set as default — the ★-marked preset loads on launch
- Import… / Export… — single presets as
.jsonfor sharing or backup
The seeded Default preset is locked (can't be renamed or deleted) and is the initial launch default. Presets persist in UserDefaults.
Two fields under the audio section append raw flags to the command. Video flags are injected right after the video codec block; Audio flags right after the audio codec block — so each lands on the correct stream. A quote-aware parser keeps values like -metadata title="My Clip" intact.
Examples: -vf negate, -tune film (video); -af volume=0.1, -ar 44100 (audio). A bad flag causes that item to fail with ffmpeg's error shown in its row — other queued items are unaffected.
Click the drop zone and paste a URL (⌘V, then ↩), or drop a link onto the panel or menu bar icon. Remote items run a two-phase job: yt-dlp downloads to a temp folder first (with its own progress bar), then the result feeds into the normal encode pipeline. The Download quality setting caps resolution via yt-dlp's -f selector. Finished downloads land in ~/Downloads unless you've set an output folder.
yt-dlp breaks frequently as sites update their internals — when a download misbehaves, update it first: Settings → Tools → yt-dlp → Update.
Downloading is subject to each site's terms of service — use responsibly.
A foldable Tools section (chevron next to the title) at the bottom of settings lists Homebrew, ffmpeg, and yt-dlp with their installed versions and per-row Install / Update actions. Versions are re-checked each time settings opens. yt-dlp self-updates via yt-dlp -U; ffmpeg installs and updates through Homebrew.
DorodangoApp / AppDelegate status-bar item + persistent KeyablePanel (NSPanel),
│ spinning icon, right-click Quit
├─ ProcessingQueue @MainActor ObservableObject — serial work loop, owns live
│ │ settings + items, fires the completion notification
│ ├─ ProcessingSettings Codable value type bound to the settings controls
│ ├─ [QueueItem] per-file observable: file or remote URL, status,
│ │ progress, detail string
│ ├─ YtDlpRunner downloads remote items, parses yt-dlp progress
│ └─ FFmpegRunner probes duration + audio, measures loudness, builds args
│ (incl. custom flags), parses -progress output
├─ PresetStore presets list, selection, default; JSON import/export
├─ ToolManager locate / version / install / update for brew/ffmpeg/yt-dlp
└─ Notifier UserNotifications-based "batch done" banner
Views: MenuContentView · DropZone · QueueListView · QueueRowView
SettingsView · PresetsBar · ToolsView
Progress is real: ffprobe reports source duration, then ffmpeg's out_time_ms from -progress pipe:1 is divided by it. The audio analysis passes run before that and have no fraction to report, so rows show "Analyzing audio…" then "Measuring loudness…" until the encode starts.
- HDR sources: Rec.709 tags label color without converting — correct for SDR HD, but wrong for HDR/Rec.2020 sources. A "preserve source color" escape hatch is needed for HDR material.
- Faster presets produce larger files at the same CRF — with a tight bitrate cap, fast mode hits the ceiling more often and quality drops in busy scenes.
- Linear normalization can silently fall back to dynamic. When the measured true peak won't allow the full gain, ffmpeg reverts to dynamic even with
linear=true. The measured offset keeps it well-behaved, but a source that pumps wants analimiterahead ofloudnormso the peak stops capping the gain. - The analysis passes are unconditional — no "fast, skip loudness analysis" escape hatch yet. On short clips the extra decode is negligible; on a multi-hour source it's a minute or two before encoding starts.
- Conflicting custom flags (e.g. re-specifying
-c:v) cause that item to fail rather than silently winning — by design, but worth knowing.

