ADFA-5552: Optimize every media file in documentation.db (in place) - #33
Conversation
A single, self-contained script that shrinks every image stored in a documentation.db in place - same Content.path, extension and contentTypeID, so every page reference keeps resolving. Reuses the optimization approach from the Kotlin website pipeline (pngquant + 500px downscale for PNG, JPEG re-encode, animated-GIF-aware resize, WebP re-encode, Scour for SVG) and applies it across all doc sets. Optimizes in place rather than converting formats: non-Kotlin pages reference media by absolute URL with no literal link to the stored path, so renaming (e.g. to WebP) can't be rewritten safely - keeping path and extension fixed avoids the problem entirely. Handles both plain-Brotli and shared-dictionary (CompressionDictionary) databases, reassembles chunked (>1MB) media before optimizing and re-chunks on write, and only ever rewrites a row when the result is smaller. Backs up first, runs in one transaction, VACUUMs, and supports --dry-run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0d43357 to
a4e5516
Compare
Declare the Python dependencies (Pillow, scour, brotli) inline with PEP 723 script metadata so `uv run scripts/optimize_db_media.py <db>` installs them on the fly - no requirements.txt or --with flags needed. Add `from __future__ import annotations` so the `X | None` hints run on Python 3.9+, and update the usage docstring. (pngquant and brotli remain system CLIs the user installs.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
optimize_db_media.py gains a --webp mode that converts stored media to WEBP
under a new path/extension and deletes the old row, alongside the existing
in-place mode. update_media_references.py then repairs the links that
conversion leaves dangling, across every doc set.
The reference updater matches on filename rather than stored path: conversion
changes only the extension, and each doc set writes references differently
(Android "media/static_..._foo.gif", IntelliJ "./images/foo.gif", Kotlin a
JSON-escaped "/k/html/images/..."), with no single form that maps onto
Content.path. Filenames are unambiguous here - no two images share a basename
within a doc set, and no converted name collides with one left alone. The
rename map is a diff against the pre-conversion copy (--before) rather than
inferred from the converted database: inferring "every stored X.webp implies a
converted X.png" produced 4530 candidate names where only 745 files had really
converted, each spurious entry rewriting links to unrelated external assets.
Fixes from the /code-review pass:
- A file was renamed whenever its extension differed from its type's
canonical one, so "photo.jpeg" became "photo.jpg" even in the default mode
documented as never renaming. optimize_media now returns the content type
it produced, so only a real format change renames anything.
- Inserting a "<path>-N" chunk row could hit an unrelated row of that name
(content_chunking notes those are legal as separate pages) and abort the
whole transaction on UNIQUE. clear_fragment_slots frees the slots first,
logging what it removes.
- target_path split the extension off the whole path, so a dotted directory
with an extensionless file ("a/v1.2/logo") targeted "a/v1.webp".
- Writes hardcoded templateId 0, against populate_db's convention that
fragments reuse the base row's languageID/contentTypeID/templateId.
- --before is validated as the same database lineage before anything is
rewritten; a mistyped backup no longer silently rewrites 29k rows.
- Reference matching is anchored to a URL-ish delimiter, so a filename in
prose ("save it as copy.png") is no longer rewritten.
- Images were decoded twice on the default path; the open image is now
handed to the encoders.
- Animated WEBP/APNG skips are counted separately from unhandled types,
backup names take the next free suffix instead of failing on a same-second
rerun, and flags that do nothing under --webp now warn.
Measured on a 249MB database: 745 files converted, image bytes 130.2MB ->
60.6MB (-53.5%), whole file 249.1MB -> 178.4MB (-28.4%), then 1699 references
rewritten across 578 rows. integrity_check/foreign_key_check pass, all 1192
images decode, no row links to a deleted filename, and templateId is preserved
(3507 non-zero, unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update: WebP conversion + reference updating, and a review passThis PR now carries two scripts. What changed
Why it matches on filenameConversion changes only the extension, and each doc set writes references differently — Android Worth recording: Android's The rename map is a diff, not an inference
Review fixes (
|
| Before | After | |
|---|---|---|
| Database file | 249.1 MB | 178.4 MB (−28.4%) |
| Optimizable image bytes | 130.2 MB | 60.6 MB (−53.5%) |
| image/png | 733 rows, 69.31 MB | 20 rows, 0.02 MB |
| image/jpeg | 13 rows, 0.28 MB | 0 |
| image/gif | 139 rows, 55.27 MB | 114 rows, 49.53 MB |
| image/webp | 161 rows, 2.18 MB | 906 rows, 8.14 MB |
| image/svg+xml | 183 rows, 3.20 MB | 183 rows, 2.90 MB |
| video (mp4/quicktime) | 16.61 MB | 16.61 MB (untouched) |
745 files converted; 1699 references rewritten across 578 rows.
Verification: integrity_check and foreign_key_check pass; all 1192 images decode with 0 extension/format mismatches; 0 rows still link to a deleted filename; templateId preserved exactly (3507 non-zero, unchanged); the reference pass is idempotent (a second run finds 0). The conversion output was byte-identical to the pre-fix run — the fixes close latent holes this database doesn't happen to contain — and clear_fragment_slots reported 0 collisions. Tightening the reference anchor produced the identical 1699 references, so removing the prose risk cost nothing.
Notes
- Animated GIFs are now ~82% of remaining media (49.5 MB), untouched by design. Biggest remaining opportunity.
- 5 large SVGs fall back to minified-SVG instead of rasterizing when native
cairoisn't installed (graceful, logged). ~2.9 MB of SVG remains, mostly one file. - Both scripts declare their dependencies inline (PEP 723), so
uv run scripts/<name>.py …works with no setup.pngquantandbrotliare system CLIs — under--webppngquant isn't needed at all.
TEXT_TYPES listed seven content types on the theory that any of them could hold a link. Measured against a full production database, every one of the 578 rewritten rows was text/html (541), text/javascript (29) or text/css (8); text/plain, application/json, text/markdown and application/xml rewrote nothing at all. text/plain was the plausible one - this schema files ~61 "j/html/api/*/module-graph.svg" under it, and SVG can reference a raster via <image href="...">, but none of those module graphs does. Its only measurable contribution was cost: it also files 16 binary .ogv/.webm videos as text, and those are the sole reason the UnicodeDecodeError branch had anything to catch. The one text/plain row that even resembles a reference is a false positive - "javax.imageio.plugins.jpeg", a Java package name in j/html/api/element-list, which is exactly the class of match the delimiter anchor already rejects. Narrowing the list is behavior-neutral and verified so: re-run against the same pre-rewrite database it still finds 1699 references across 578 rows, scanning 28982 rows instead of 29069 (the 87 dropped rows are the 80 text/plain, 5 markdown and 2 json) and skipping 0 binary rows instead of 16. The decode guard stays as insurance, since this schema has been seen filing binary under a text content type. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…xt/plain Narrowing TEXT_TYPES to html/javascript/css went too far. Those three are the only ones that rewrote anything in the database measured, but JSON, markdown and XML are real carriers in this schema, not hypotheticals: Kotlin's nav row is application/json holding literal "/k/html/images/<name>" links - the row insert_optimized_media.py exists to rewrite - markdown embeds images as , and XML does so in an attribute. A database populated by a different pipeline would put references in exactly those rows, and scanning them costs one decode of a handful of rows (7 here). text/plain stays out, because its exclusion rests on what it holds rather than on it having scored zero: ~61 "j/html/api/*/module-graph.svg" that reference no rasters, and 16 binary .ogv/.webm videos filed as text - the only rows that ever hit the decode guard. Its one reference-shaped string is a false positive, "javax.imageio.plugins.jpeg" in j/html/api/element-list. Verified against the same pre-rewrite database: still 1699 references across 578 rows, scanning 28989 rows (vs 29069 with text/plain included and 28982 without JSON/markdown/XML) and skipping 0 binary rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This database is handed to authors who cannot run these scripts. Running the
tool again over their edits must not re-encode what it already encoded - lossy
output re-encoded from lossy input loses quality every time - but must still
pick up whatever they changed. Nothing in the database distinguishes the two,
so record it: --manifest-out writes a text file listing every media file this
tool manages, and --manifest-in reads it back.
<sha256 of stored bytes>\t<stored bytes>\t<path>\t<source path or ->
The size is what makes the file skimmable; the digest is what makes the
decision safe, since an edited image can easily land on its predecessor's byte
count. The provenance column is what makes the third author scenario work:
* A file they ADDED is absent from the manifest, so it is optimized.
* A file they OVERWROTE no longer matches its digest, so it is optimized
again.
* A source image they RE-INSERTED over one converted earlier (a .png whose
.webp this tool produced) is recognised through the provenance column and
converted back over that same .webp, replacing it. Without provenance the
new .png would convert to a de-conflicted name like foo-png.webp that
nothing links to, while the stale foo.webp every page points at stayed.
Rows that errored are left out of the manifest, so a later run retries them.
The replace path also needs a guard the rest of the loop did not: `rows` is a
snapshot read before the loop, so a path replaced as some other row's target
is still sitting in it with pre-run bytes. Reaching it later recorded those
stale bytes in the manifest - caught in testing, gamma.webp logged as 426
bytes while the database held 450 - and, for a row that did not match the
manifest, would have re-optimized the old bytes and written them back over the
replacement, silently undoing the author's update. Such paths are now skipped.
Verified end to end on a database exercising all three scenarios: the manifest
comes out matching the database exactly, a re-run with no edits touches
nothing and emits a byte-identical manifest, and the same re-run WITHOUT the
manifest re-encodes an already-optimized WEBP from 450 to 448 bytes - the
quality loss this exists to prevent, which the never-grow guard cannot catch
because the second encode came out marginally smaller.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documentation Database — Media Optimization Report
Ticket: ADFA-5552 · Branch:
fix/ADFA-5552Summary
Every image in
documentation.dbwas optimized in place, reducing the database file by 26% with no change to how any page references its media and no loss of any image.Integrity verified afterward:
PRAGMA integrity_checkandforeign_key_checkboth pass, and all 1,192 image records reassemble and decode correctly.Scope
Optimization was applied to every media file in the database, across all documentation sets (Android
a/, IntelliJi/, Kotlink/,p/, Javaj/) — not just the Kotlin website. It reuses the optimization pipeline from PR #24 (the Kotlin website'soptimize_media.py) and applies it to the images already stored in the database.Videos (
mp4,quicktime) and favicons (x-icon) were intentionally left untouched — the pipeline does not re-encode video.Savings by media type
PNG downscaling is essentially the entire win. A further 109 images were already minimal and left as-is, and 14 were left untouched because re-encoding them would have increased their size (see "never enlarge" below).
Methods — exact settings
All raster images are downscaled with a Lanczos filter to a maximum width of 500px, preserving aspect ratio, and never upscaled (an image already ≤500px wide keeps its dimensions). Then, per format:
--quality 65-95,--speed 4, metadata stripped). pngquant is run at full resolution first (so its palette selection sees the original color detail), then the image is downscaled, then quantized again at the delivered size.Key design decision: optimize in place, no format changes
The Kotlin website pipeline (PR #24) converts images to WebP, which renames files (e.g.
foo.png→foo.webp), and then rewrites the image references embedded in Kotlin's stored pages. That reference-rewriting step only understands Kotlin's page format.The other documentation sets don't share that format. For example, an Android page references its media by absolute URL (
https://developer.android.com/images/…), which has no literal link to the path the file is stored under (a/devsite/media/…) that a text substitution could follow. Renaming media outside Kotlin therefore can't be done safely by this tooling.The solution is to optimize each image in place — same path, same file extension, same content type — so every reference, however it's written, keeps resolving to the same, now-smaller file. This is why the process never converts to WebP and never rasterizes an SVG. It trades a little additional savings (WebP would shrink things further) for correctness and uniform coverage across all doc sets.
Correctness and safety properties
documentation.db.backup-YYYYMMDD-HHMMSS) before any change.VACUUMed to reclaim freed space. A--dry-runmode does all the work and reports savings without writing anything.CompressionDictionarytable). The tool detects which and, for dictionary databases, round-trips those rows through thebrotliCLI using the dictionary read from the database itself. The decompress→optimize→recompress→decompress round-trip was confirmed lossless before any write.Reproducing / running it
Add
--dry-runto preview savings without modifying the database. Other tunables:--jpeg-quality,--webp-quality,--pngquant-speed,--svg-precision,--verbose. Requires thepngquantandbrotlicommand-line tools onPATH.Code: a single self-contained file —
scripts/optimize_db_media.py. The database chunking protocol is inlined into it (rather than imported), so this PR adds exactly one file tomainwith no other in-repo dependencies.A note on image width (worth a group decision)
A 500px max width was used uniformly, matching the Kotlin website's setting. For Android technical diagrams and screenshots this is fairly aggressive — most were originally wider. If preserving more detail in specific doc sets matters, the process can be re-run from the backup with a larger
--max-width(globally or per doc set) at the cost of some of the savings above.