Releases: chr1shaefn3r/grow-up
Release list
v1.3.0
Rejecting a photo now hands its week to the runner-up, and the contact sheet shows you that happening before you commit to it.
The problem
rejects.json was applied at encode, against the already-selected frame list. That made a rejection a deletion: reject the photo that won a week, and the video lost the week. The one thing you actually wanted — the second-best photo of that week, which was sitting right there — was the one thing you could not get.
Worse, you were deciding blind. Nothing on the page said what would take over, so the loop was: reject, grow-up run, open the sheet, discover the replacement was no better, repeat. One decision per round trip, on a stage that takes a warp pass.
The fix
Rejections are applied at select, not at encode. select is the only stage that can reconsider a bucket, so a rejected pick simply loses to the runner-up and the timeline keeps its shape. Reject every candidate for a week and the week does disappear, which is presumably what you meant.
The contact sheet is now one card per bucket, and it promotes live. Each card carries its bucket's next two candidates as thumbnails. Reject the pick and the card immediately shows the next one, labelled promoted #2:
2026-03-02 #14 [reject] promoted #2
So the grid always shows the video as it would be, and a whole pass of rejections is settled in the page before anything is re-run. Clicking a thumbnail rejects that runner-up directly, for the alternate you can already see is bad. Reject all of them and the card says bucket empty.
The alternates are warped, not raw. Judging a replacement means seeing it framed the way the video will frame it — an unaligned thumbnail answers a different question.
[select]
alternates = 2 # runner-ups per bucket to prepare; 0 turns it offWhat it costs
This is the one deliberate change to an existing setup. alternates defaults to 2, so align warps three candidates per bucket instead of one — roughly 3× the warp time and 3× the frames directory. On a 150-frame timelapse that is seconds becoming a couple of minutes. Set alternates = 0 for exactly the old cost and the old page.
Nothing else about the run changes, and nothing extra is downloaded or analyzed: alternates are chosen from photos already scored.
The part that could have gone wrong
Alternates get frames rows like any other warped image. The join to selection on alternate = 0 in stage_encode is the only thing keeping them out of the render — without it a week appears three times, in a video that looks entirely plausible. That join has its own test, and the invariant is written down in CLAUDE.md alongside the other traps that produce believable-looking wrong output.
The promotion rule is driven under node against the shipped page code, for the same reason the threshold tuner is: a page that quietly stops agreeing with the pipeline still looks fine.
Nothing about an existing setup breaks
- A
config.tomlwith no[select] alternateskeeps working; the setting arrives with a default. - Databases from 1.2.0 upgrade in place.
selectiongains analternatecolumn throughADDED_COLUMNS, and existing rows default to 0 — a stored selection is a pick, not a runner-up. - An existing
rejects.jsonis honoured, and the contact sheet is seeded from it, so decisions made under 1.2.0 survive. The page's download replaces the whole file, so an unseeded page would silently discard every earlier decision.
After editing rejections, the re-run is grow-up select && grow-up align && grow-up encode — a warp pass, unchanged from 1.2.0.
630 tests, up from 586. 605 with 25 skipped where opencv and Pillow are absent, which is the shape CI runs, on Python 3.11, 3.12 and 3.13.
Notes
This project was written entirely by Claude (Anthropic's AI assistant, running as Claude Code) and supervised by the repository owner. The model has never had access to the Immich instance or its API keys; all verification against real photographs was done by the owner — including whether two alternates is the right number to show, which is a judgement about real faces and cannot be made from the test suite.
v1.2.0
The timelapse can now caption itself: the capture date and the subject's age, drawn from the birth date Immich already stores.
Why
A face timelapse spans years. Without a caption a viewer cannot tell whether two frames are a week apart or a decade — and the faces themselves are the one thing you cannot date by eye, because that is the whole point of the video.
Immich already knows the answer. It stores a person's birth date when you supply one, and shows their age in the web UI. This release puts it on the frames.
The footer
Opt-in. Capture date bottom left, age bottom right.
[encode.annotate]
enabled = true
age = "year_months" # days | months | year_months | off
language = "de" # en | de | fr | es | it
date_format = "DD.MM.YYYY"27.08.2023 3 Jahre, 5 Monate
Both videos are written when it is on — the plain timelapse.mp4 and timelapse-annotated.mp4. A date format you end up disliking should never cost you the clean render.
Readable by construction, not by luck. A translucent band darkens the bottom strip and the text carries an outline, so white snow and a night shot both work. Nothing above the band is touched.
Details that are deliberate
year_monthsreads the way a person speaks.3 years, 5 months, dropping a zero part — and under one month old it falls back to days, because0 monthsis wrong on exactly the frames where a baby changes fastest.- Month arithmetic uses a day-of-month correction. Someone born on the 31st is not a month old on the 28th of February; the obvious implementation says they are.
- Translations are built in, not taken from the system locale. That module depends on which locales a machine has generated, so the same config would render a different video on a Mac and on a Linux desktop. It also gets French wrong:
moisis invariable, so "plural = add an s" produces3 moiss. Plurals, month names and thousands separators (1,261/1.261/1 261) all come from a table. - Date patterns are tokens, not strftime:
YYYY MM DD, plusMMMMandMMMfor month names.D MMMM YYYYgives5 August 2026or5 août 2026depending onlanguage.
Birth dates
Read during index into a new people table and cached, so encode never touches the network. With two accounts configured, whichever one filled the field in wins — they describe the same person.
If age is enabled and Immich has no birth date, the run says so and draws the date alone rather than failing:
! encode.annotate.age is set but Immich has no birth date for this person;
annotating with the date only.
Nothing about an existing setup changes
- A
config.tomlwith no[encode.annotate]block produces exactly the video it did before — one file, no footer, nothing read. - Databases from 1.1.0 upgrade in place. The
peopletable arrives throughCREATE TABLE IF NOT EXISTS, so there is no migration to run and none to get wrong. - Pillow is imported only to draw, so the age arithmetic and the language tables stay testable in a bare environment.
586 tests, up from 514 — and 572 with 14 skipped where Pillow is absent, which is the shape CI runs.
Notes
This project was written entirely by Claude (Anthropic's AI assistant, running as Claude Code) and supervised by the repository owner. The model has never had access to the Immich instance or its API keys; all verification against real photographs was done by the owner.
v1.1.0
Photos of one person, pulled from more than one Immich account.
The problem
Immich scopes face recognition per account. The link between a detected face and a person belongs to whoever owns it, so a search with your person id never returns photos sitting in a partner's account — even when the same child is tagged in both libraries. If half the photos of your kid were taken on someone else's phone, half of them were invisible to grow-up, and nothing in the output said so.
The fix
A source is now an account: a URL, an API key, and the person id within that account.
[[immich.sources]]
name = "me"
person_id = "…" # your account's record for the subject
[[immich.sources]]
name = "partner"
person_id = "…" # *their* account's record for the same person
key_env = "IMMICH_API_KEY_PARTNER"export IMMICH_API_KEY=… # yours
export IMMICH_API_KEY_PARTNER=… # theirs, from their own Account Settings → API KeysTheir key needs the same four permissions as yours. url_env can be set per source too, if the two accounts are on different servers.
Indexing, face lookup and download run once per account; filtering, alignment and encoding then see one merged pool, so the timelapse interleaves both libraries by date. Each account keeps its own sync watermark, so they stay incremental independently — grow-up status shows a block per account.
New --source NAME on index, faces, fetch, run, trial and doctor works on one account at a time.
Two failure modes made loud
- Two sources sharing one API key is rejected at load. It is the copy-paste mistake, and it would index the same account twice — leaving the partner's photos still missing, with nothing on screen to explain why. Exactly the symptom this release exists to remove.
- Every key is checked before any account starts work, and one failure aborts the run. A video quietly missing one account's photos looks completely fine, which makes it the worst kind of failure. Preflight messages now name the account they are about.
Nothing about an existing setup changes
- A
config.tomlwith no[[immich.sources]]keeps working untouched —[immich] person_name/person_idon the plainIMMICH_URL/IMMICH_API_KEYis simply the one-account case. - Databases from 1.0.0 upgrade in place.
assetsgains asourcecolumn, and the first configured source adopts the existing rows: everything in such a database came from the one account there was. - A single-account run's output is byte-for-byte what it was, down to the progress labels and the preflight wording.
From 1.1.0 on, an existing config will not be broken by a release. New settings arrive with a default and a fallback for their absence.
Under the hood
sync_state and runs were already keyed on person id, so two accounts keep two independent watermarks with no new bookkeeping. assets.source exists because only the owning account's key can download an asset or answer a face lookup for it — asking the wrong one returns 404 on exactly the other account's half of the library.
514 tests, up from 473. The compatibility promise is written down as TestTheReleasedConfigStillWorks in tests/test_sources.py.
Notes
This project was written entirely by Claude (Anthropic's AI assistant, running as Claude Code) and supervised by the repository owner. The model has never had access to the Immich instance or its API keys; all verification against real libraries was done by the owner — including the two-account setup this release is about.
v1.0.0
Builds an eye-aligned face timelapse from the photos of one person in a private Immich library. It picks the frames where the subject is looking at the camera and warps every one of them onto the same eye positions, so the years pass without the face jittering around the frame.
What's in it
The pipeline — eight stages, each independently runnable and resumable from a SQLite manifest: index, faces, fetch, analyze, select, align, review, encode. Interrupting any of them costs only the item in flight.
No face recognition. Immich already labels which detected face belongs to whom, so even group photos yield the right bounding box. The pipeline reads that association rather than reproducing it.
Filtering on five signals from one MediaPipe FaceLandmarker pass: head pose from the transformation matrix, gaze measured geometrically from where each iris sits between its own eye corners, blink blendshapes, landmarks falling outside the frame, plus sharpness and exposure. Gaze is separate from pose deliberately — a face can point straight at the lens while the eyes look elsewhere.
Exact alignment, no smoothing. Each frame is solved independently for a similarity transform that lands both eyes on the canonical positions. Scale is normalised by interocular distance, so growing up reads as changing facial proportions rather than a head that inflates.
An interactive threshold tuner. out/rejects.html carries a slider per filter threshold and shows exactly which photos a change would add or drop. The page and the Python filter interpret the same serialised rule table, and the test suite runs the page's own filter under node to prove they agree.
Trial runs. grow-up trial -n 100 is a partial real run — it goes through to a video, banks everything it downloads, and projects the full set from measured per-stage timings.
A time/accuracy dial. analyze.effort trades speed for detection recall and landmark precision; grow-up trial --compare measures all three levels over the same sample so the choice is a number rather than a guess.
Incremental by default. A bare grow-up run picks up where the last successful one finished, using updatedAfter rather than takenAfter so back-dated imports are not lost, plus asset-count drift detection for the case updatedAfter cannot see — someone tagged in an old photo.
Setup
pip install -e ".[dev]"
cp config.example.toml config.toml # then edit person_name
grow-up fetch-model
export IMMICH_URL=https://immich.example.com
export IMMICH_API_KEY=… # needs person.read, asset.read, face.read, asset.download
grow-up index && grow-up trial -n 50Credentials come from the environment only, never from config.toml. Every setting in config.example.toml carries a comment explaining what turning it does.
Notes
This project was written entirely by Claude (Anthropic's AI assistant, running as Claude Code) and supervised by the repository owner. The model never had access to the Immich instance or its API key, and all verification against real photographs was done by the owner. See the authorship notice in the README, and CLAUDE.md for the invariants and the mistakes already made.
473 tests, running on Python 3.11, 3.12 and 3.13 — no network, no Immich instance, no model download and no ffmpeg required.