feat(dates): container video dates, filesystem branch, manual edit (#115)

The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:

- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
  UTC-rendered creation_time fields -> true instants (utc_direct).
  Running them through the camera wall-clock cascade was the
  "videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
  the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
  the offset cascade (OffsetTime* pairing + GPS evidence pulled in
  the same exiftool pass).

The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.

Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.

ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
This commit is contained in:
Cameron Cordes committed 2026-09-25 15:26:26 -04:00
1 parent 6f020f4201
commit d2263be6ac
13 files changed
+866 -178

No files matched your search

+22 -1
View File
@@ -22,7 +22,11 @@ actual moment). The wall clock the camera showed is a **display value**, recover
instant plus a per-row **camera offset** resolved at ingest by a cascade (explicit EXIF
offset tag → GPS position → server zone, the last recorded as an assumption). Day-level
grouping uses the owner's **display zone** (an IANA zone name). Manual edits enter as wall
clocks in the display zone and are stored as instants.
clocks in the display zone (rendered naive-as-UTC by the client) and are
stored as instants; the legacy column keeps the sent value verbatim and
revert restores a snapshot of both the legacy pair and the new-contract
triple (`original_date_taken*` / `original_date_utc` /
`original_camera_offset_minutes` / `original_date_taken_tz_source`).
Consequences of the decision:
@@ -59,3 +63,20 @@ Consequences of the decision:
- **Re-dating across a day boundary invalidates derived day data** (daily summaries, reel
pre-gen), which regenerates on its normal schedule; the backfill drain detects the day
change per row.
- **Container dates are true instants, not wall clocks.** QuickTime/MP4/MOV
`creation_time` fields are seconds-since-1904 in UTC; exiftool renders them as
UTC-naive strings. Running a naive container string through the camera wall-clock
cascade reinterprets a UTC instant in the server zone and shifts every video by the
server's UTC offset (the "videos off by 4–5 hours" bug, ticket #115). Verified against
production data: a mirrorless video lands inside the same-moment photo burst, and the
delta across all sampled videos is the uniform server EDT/EST offset. So the exiftool
branch classifies the *group* of each tag: zoned strings are instants outright,
container-group naives are instants (`utc_direct`), and only photo-EXIF-group naives
run the cascade. The legacy column for those rows stays byte-identical to the old
`-d %s` server-local interpretation; the new-contract columns carry the true instant.
- **The legacy column is reproduced in Rust, not by re-invoking exiftool.** The old
`-d %s` path interpreted exiftool's rendered naive string in the *server's* local zone
(DST-aware, ignoring the file's own `OffsetTime*` tags — verified). `date_taken`
for exiftool rows must keep producing exactly that value, so the resolver parses the
raw string and applies the server-zone interpretation itself (`utc_direct` rows skip
the cascade but still get the server-local legacy value).