Commit Graph
810 Commits
Author SHA1 Message Date
Cameron CordesandClaude Opus 5.5 e029c81274 fix(persona-chat): review fixes before merge
- Thread tools are bound server-side to the chat's own thread. The model
  used to pass contact_id, so practice mode could read any conversation it
  named; a model-supplied contact_id is now only checked, never trusted, and
  the prompts no longer hand it one.
- Practice chats need no persona. They're stored under PRACTICE_PERSONA_ID
  and run with an inert stand-in, so deleting or editing personas can't hide
  or break them. persona_id is optional on create for as_contact.
- The per-thread list filters in SQL (list_persona_chats_for_contact, served
  by idx_persona_chat_anchor_contact) instead of decoding every transcript.
- fetch_mms_image checks the content type and a 20 MB cap before reading
  the body, so a video attachment isn't downloaded in full.
- get_photos_near_time unions tags over every path sharing the content, per
  the multi-library model.
- look_at_photo only opens indexed photos, so an excluded folder can't be
  read by guessing a path.
- One date parser: get_photos_near_time uses date_info::parse_center.
- Practice prompt source rewrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:38:08 -04:00
Cameron CordesandClaude Opus 5.5 185a1b561a Bump version to 1.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:00:05 -04:00
Cameron CordesandClaude Opus 5.5 ba39628a0d feat(insight-chat): allow switching a local photo chat to hybrid
A local insight's transcript carries the photo itself, so continuing on hybrid
was refused. Each hybrid turn now has the local vision model describe the
photo (cached per image), sends the description in its place, and puts the
photo back before saving, so the stored chat and later local turns keep it.
If the local description fails, the turn fails. The stored model id is only
reused on the same backend, so a llama-swap slot isn't sent to OpenRouter.

Checked end to end against a recording fake OpenRouter on all three
continuation endpoints: no image data left, the stored transcript kept the
photo, and switching back to local saw the photo again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:00:05 -04:00
Cameron CordesandClaude Opus 5.5 cc11884272 fix(openrouter): never send image data to OpenRouter
Nothing in the client stopped it: a message carrying images became image_url
parts and went out. Photos stayed local only because callers routed around it.
messages_to_openai, which every OpenRouter request passes through, now refuses
any image (IMAGES_STAY_LOCAL), and describe_image always errors; hybrid mode
describes photos with the local model. A caller bug now fails the turn instead
of uploading a photo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:00:05 -04:00
Cameron CordesandClaude Opus 5.5 edfa48d116 feat(ai): date_info tool for exact date arithmetic
Small local models get weekdays, "how long ago" and day counts between dates
wrong, confidently. Persona chats anchored on old SMS threads ask exactly
those questions. date_info answers them exactly, in the owner's display zone:
- no args: now (replaces get_current_datetime)
- date: its weekday and how long ago / until
- date + other_date: the gap in days, weeks and years/months/days, plus
  hours when both carry times
- date + add_days: the date N days after (or before)

Checked live on gemma4-26b-A4B and gemma4-31: each date question took one
date_info call and came back correct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:33:00 -04:00
Cameron CordesandClaude Opus 5.5 7215c1bdfe fix(persona-chat): don't offer describe_photo in persona chats
describe_photo describes the file chat's photo. Persona chats have no photo,
so a vision-capable model was offered a tool that could only answer "No image
available". Persona chats now build their catalog with the vision gate off;
the thread tools still cover looking at images there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:17:03 -04:00
Cameron CordesandClaude Opus 5.5 3f1bf42f1c feat(persona-chat): practice mode — play the contact
An anchored conversation now has a mode. 'about' is the existing behaviour (a
persona talks with the user about the thread). 'as_contact' has the model play
the contact so the user can rehearse the conversation: its own prompt (stay in
the contact's voice, don't invent facts, coach on 'out of character'), no
persona prompt, and only the thread tools — no memory, library, calendar or
other-thread access, and no knowledge writes.

The shared persona/file-chat loop now refuses tool calls that weren't in the
turn's catalog, so the gates hold even when a model names a tool it wasn't
offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:39:27 -04:00
Cameron CordesandClaude Opus 5.5 4ac574d4d2 feat(persona-chat): continue an SMS thread with a persona
Persona conversations can now be anchored on a message in an SMS thread.
Creating one snapshots the thread up to that message (via SMS-API) into the
row; every turn appends it to the persona prompt with guidance to talk like a
text thread rather than write an insight.

Anchored turns get four tools: get_thread_messages (page the thread),
look_at_message_image (local vision on an MMS photo), get_photos_near_time
(library photos with tags and people) and look_at_photo (local vision on a
library photo). The conversation list filters by contact_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:07:49 -04:00
cameron fdbcc75391 Merge pull request 'fix(backfill): convert exiftool-legacy rows whose only dates are zero' (#185) from fix/utc-backfill-zero-date-exiftool into master
Reviewed-on: #185
2026-10-17 16:35:11 +00:00
Cameron CordesandClaude Opus 5.5 55d2e888d6 fix(backfill): convert exiftool-legacy rows whose only dates are zero
backfill_utc_dates refused any fs_time re-resolution of a tag-dated row,
assuming exiftool had failed on the file. Files whose date tags are all
0000:00:00 (e.g. uv-prod-long.mp4) were read fine and simply have no
date, so they sat unconverted and re-ran through exiftool every tick.

resolve_dates_batch_report now also returns the paths exiftool read
without error (-Error surfaces ExifTool:Error in the JSON). The drain
lets fs_time stand for an exiftool-legacy row only when exiftool read
the file; read errors / missing entries and kamadak exif-legacy rows
keep refusing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:17:17 -04:00
cameron 783fcb8dbe Merge pull request 'refactor: compile the crate once — main.rs uses the library' (#184) from refactor/main-uses-lib into master
Reviewed-on: #184
2026-10-17 16:04:12 +00:00
Cameron CordesandClaude Opus 5.5 640cd3833a refactor: compile the crate once — main.rs uses the library
main.rs re-declared nearly every module (`mod ai; mod database; …`)
that lib.rs also declared, so the ~100k-line crate compiled twice in
every build (lib for the src/bin tools, bin for the server) and the
test suite ran most tests twice. watcher.rs even reached into the lib
copy via `image_api::` from inside the bin copy.

The six bin-only modules (backfill, handlers, hls_stats, knowledge,
reels, watcher) move into lib.rs; main.rs keeps only startup wiring
over `use image_api::…`. A few items main calls become `pub`, and the
dead_code allowances that only existed because each copy saw half the
callers are gone. CLAUDE.md says not to reintroduce `mod` in main.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:03:33 +00:00
cameron 4f35f87ea3 Merge pull request 'feat(insights): read location context from Apollo (#177)' (#179) from feat/apollo-location-context into master
Reviewed-on: #179
2026-10-17 16:03:26 +00:00
Cameron CordesandClaude Opus 5.5 dc90407afb feat(insights): read location context from Apollo (#177)
The local location_history table is a one-time Takeout import: it ends
in 2019, still has the ghost-device and coarse cell-fix samples Apollo
has since excluded, and was read loosely. The per-photo line took the
nearest sample within +/-3 h at any distance (17% of photo moments got a
position >2 km away), and get_location_history listed the first 20 rows
of a +/-14-day window sorted ascending (always ~14 days before the date).

With APOLLO_API_BASE_URL set:
- per-photo context comes from /api/location/at (30 min max gap; no
  sample -> no line). A Place stay renders as "at Home (14:02-18:30)" in
  the display zone; an unnamed sample is reverse-geocoded.
- get_location_history returns Apollo's /api/location/day timeline for
  the date (+/-1 day at most), and is offered even with an empty local
  table.

Both fall back to the local table when Apollo is unset or a call fails;
the local tool path now covers the requested local day(s) and samples
evenly instead of listing a window's first rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:03:06 +00:00
cameron 22d28ee80f Merge pull request 'fix(images): pick the decoder from the bytes, not the extension' (#183) from fix/sniff-image-format into master
Reviewed-on: #183
2026-10-17 04:24:25 +00:00
Cameron CordesandClaude Opus 5.5 b95adb4f77 fix(images): pick the decoder from the bytes, not the extension
`image::open` chooses the decoder by extension, so a PNG saved as .jpg
(Android screenshots, chat exports) failed thumbnails, previews,
perceptual hashes, insights and face crops with "Illegal start bytes:
8950". New `file_types::open_image` sniffs the header and falls back to
the extension when it's unknown; every decode site uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:24:02 +00:00
cameron bc27aea81a Merge pull request 'fix(clip): stop re-sending undecodable files every pass' (#182) from fix/clip-failure-marker into master
Reviewed-on: #182
2026-10-17 04:21:41 +00:00
Cameron CordesandClaude Opus 5.5 e75e60786d fix(clip): stop re-sending undecodable files every pass
A 422 from Apollo's /encode_image (bytes it can't decode — corrupt
files, non-images with an image extension) left clip_embedding NULL,
so the drain re-sent the file and logged the same "permanent failure
(will retry next pass)" on every scan.

Permanent rejections now record a clip_failures marker scoped to the
row's content_hash; list_clip_unencoded_candidates skips the row while
the hash still matches, so a replaced file gets another try with no
cleanup. Only 422 (new ClipError::Rejected) marks: other 4xx are
deployment problems (auth, routing) and would otherwise blacklist the
whole library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:20:00 -04:00
cameron a605ccd6f8 Merge pull request 'fix(dates): resolve IFD0 ModifyDate as a last-resort capture date' (#181) from fix/modifydate-fallback into master
Reviewed-on: #181
2026-10-17 04:10:56 +00:00
Cameron CordesandClaude Opus 5.5 5c410b09d9 fix(dates): resolve IFD0 ModifyDate as a last-resort capture date
Scans, edited exports and some downloads carry only EXIF DateTime
(exiftool: IFD0:ModifyDate). The kamadak ingest path already takes it
as the capture date, but the exiftool resolver didn't ask for it, so
the date_utc drain resolved those rows to fs_time and then (rightly)
refused to overwrite their tag-sourced legacy date. 147 prod rows sat
on the legacy convention forever, re-running exiftool every tick.

ModifyDate is now the lowest-priority tag, and only from IFD0 — XMP /
QuickTime ModifyDate is an edit or mux time, not capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:03:17 -04:00
cameron d959fc77cf Merge pull request 'fix(dates): also clear implausible true instants with a good legacy date (#160)' (#180) from fix/filename-year-gate-utc-rows into master
Reviewed-on: #180
2026-10-17 03:50:05 +00:00
Cameron CordesandClaude Opus 5.5 e6ae1727cc fix(dates): also clear implausible true instants with a good legacy date (#160)
The first #160 migration cleared rows whose legacy date_taken_source
was 'filename'. But the true-instant drain wrote only the new-contract
triple, so rows with a correct legacy date (e.g. an exiftool container
date) could still carry a filename-derived implausible year in
date_utc. Prod has three: VID_<counter> videos dated 2095 / 2229 / 8580
whose containers say 2016 / 2017.

Clear just the triple wherever date_utc is outside [1995, 2028) UTC;
backfill_utc_dates re-resolves it with the gated matcher, and the
frozen legacy pair is left alone. On prod those three are the only
rows with date_utc out of range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:47:22 -04:00
cameron 200abf9a10 Merge pull request 'feat(dates): calendar-day date filters in the owner's display zone (#152)' (#178) from fix/calendar-day-date-filters into master
Reviewed-on: #178
2026-10-17 03:25:04 +00:00
Cameron CordesandClaude Opus 5.5 139de444af docs: date_from_day / date_to_day on the photo listing endpoints
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:24:50 +00:00
Cameron CordesandClaude Opus 5.5 bfc3c1c21a feat(dates): calendar-day date filters in the owner's display zone (#152)
/photos, /photos/exif and unified search take date_from_day /
date_to_day (YYYY-MM-DD): the server turns them into the first day's
start and the last day's end in the owner's display zone, the same days
/memories groups by. Each replaces its instant counterpart. Clients stop
computing midnights in the device's zone, which drifted by the travel
offset whenever the phone's zone differed from display_tz.

e2e check: every fixture is included by its display-zone day and
excluded by the days either side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:24:50 +00:00
cameron a11d20bdcd Merge pull request 'fix(dates): year-gate the filename wall-clock patterns (#160)' (#176) from fix/filename-wall-year-gate into master
Reviewed-on: #176
2026-10-17 03:22:43 +00:00
Cameron CordesandClaude Opus 5.5 36e4af9fa6 fix(dates): year-gate the filename wall-clock patterns (#160)
match_filename_date only gated the unix-timestamp branch to years
1995..=now+1; the five wall patterns returned any NaiveDateTime. Counters
and IDs shaped like YYYYMMDD_HHMMSS (an app's VID_<counter>_<HHMMSS>,
Facebook's <8 digits>_<id>_o.jpg) became dates of record in years from
267 to 9985: 67 rows on prod, all via screenshot_compact.

Both branches now share plausible_filename_year; a wall miss falls
through to the next pattern and then the rest of the waterfall. The
filename audit no longer proposes these dates.

A data migration clears the five date columns on filename-sourced rows
dated outside [1995, 2028) UTC, so backfill_missing_date_taken
re-resolves them from EXIF / exiftool / fs time on the next ticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:06:23 -04:00
cameron f1779358b5 Merge pull request 'feat(image): add a 1024px size=medium preview tier' (#174) from feat/medium-preview-tier into master
Reviewed-on: #174
2026-10-16 20:22:53 +00:00
Cameron CordesandClaude Opus 5.5 94a566b220 feat(image): add a 1024px size=medium preview tier
Grid and memories tiles requested `size=large` (up to 2048px) for tiles
that display at ~590px (two columns) to ~1180px (one full-width column)
on a 3× phone, downloading 10–15× the pixels they show. `medium` caps
the long edge at 1024: crisp at both, a quarter of `large`'s pixels.
Cached under `_medium/` like the other tiers.

The large and xlarge generators were copies differing only in their
cap; fold them into one `generate_preview(src, dest, max_dim)` so the
third tier doesn't make a third copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:01:58 -04:00
cameron fca9a28cee Merge pull request 'fix(image): label thumbnails by their bytes, not the source's name' (#173) from fix/thumb-content-type into master
Reviewed-on: #173
2026-10-16 19:55:22 +00:00
Cameron CordesandClaude Opus 5.5 fbc9882364 fix(image): label thumbnails by their bytes, not the source's name
Legacy thumbnail and preview paths mirror the source's relative path, so
a video's JPEG thumbnail lives at `…/clip.mp4` and a RAW's at
`…/shot.arw`. NamedFile guessed the content type from that name and
served JPEG bytes as `video/mp4`. Sniff JPEG/PNG magic bytes instead.

The circular-thumbnail path had the same root cause: `image::open`
picks the decoder from the extension, so it failed on those thumbs and
quietly fell back to the square one. Guess the format from the bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:51:49 -04:00
cameron 42897990d8 Merge pull request 'chore(e2e): run the e2e dev server on native Linux' (#172) from feat/e2e-native-linux into master
Reviewed-on: #172
2026-10-16 19:26:41 +00:00
Cameron CordesandClaude Opus 5.5 a6d7b8f3a0 docs(e2e): note native Linux support and E2E_PORT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:45:01 -04:00
Cameron CordesandClaude Opus 5.5 b7943be022 chore(e2e): run the e2e dev server on native Linux
e2e.sh and gen_fixtures.py assumed WSL driving the Windows toolchain.
They now detect WSL (a microsoft kernel with cargo.exe available) and
otherwise use native tools:

- e2e.sh: cargo/curl instead of cargo.exe/curl.exe, pgrep/pkill
  instead of tasklist/taskkill, Linux paths in the generated .env, and
  binaries without the .exe suffix.
- gen_fixtures.py: picks the first ffmpeg that has the drawtext filter
  (a minimal build earlier on PATH may not; E2E_FFMPEG overrides), finds
  a monospace font with fc-match, and only translates paths with wslpath
  for .exe tools.
- Quote USER_NAME in the generated .env: the unquoted space made the
  dotenv parser reject the line.
- Mark the scripts executable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:27:47 -04:00
cameron 50dd9aa016 Merge pull request 'feat(search): explicit filters on unified search; list camera bodies' (#171) from feat/search-explicit-filters into master
Reviewed-on: #171
2026-10-16 01:13:44 +00:00
Cameron CordesandClaude Opus 5.5 015ddd6c77 feat(search): explicit filters on unified search; list camera bodies
The app's new search screen keeps tags, type, date, camera and place as
chips and sends free text to the unified endpoint. For the AI to add to
those chips instead of replacing them, the endpoint now takes them:

- /photos/search/unified accepts the /photos filters explicitly (tag_ids
  with All/Any, exclude_tag_ids, media_type, dates, camera make/model,
  gps circle) plus a recursive `path`. Explicit tags are their own
  predicate on top of the model's ANY-mode tags; single-valued filters
  replace the model's guess. `interpreted` echoes only what the model
  added, so the client doesn't show the user's own chips twice.
- interpret=false skips the LLM and uses q verbatim as the CLIP term,
  which replaces the app's separate "Semantic" mode with a visual match
  inside the user's filters.
- /image/tags/all also returns `cameras` (make + model with counts) so
  the camera picker can offer e.g. "Nexus 6" directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:09:06 -04:00
cameron ed52a07d9d Merge pull request 'fix(chat): budget chat history against the model's served context window' (#170) from fix/chat-context-budget into master
Reviewed-on: #170
2026-10-16 00:36:11 +00:00
Cameron CordesandClaude Opus 5.5 305eda5894 fix(chat): budget chat history against the model's served context window
Long insight/persona chats on llama-swap failed with "exceeds the
available context size" even after raising Context in the app:

- llama-server fixes its window at launch (-c) and ignores num_ctx, but
  the history budget trusted the requested num_ctx, so raising it kept
  MORE history than the server could hold. LlmClient::context_window()
  now reports the served window (llama-swap: /upstream/<model>/props,
  cached 10 min) and the budget uses min(requested, served).
- apply_context_budget could only drop tool-call pairs, so plain Q&A
  chats never shrank. It now also drops whole earlier exchanges (keeping
  user/assistant alternation) and, last, the current turn's tool results,
  never the in-progress question.
- The budget only ran once, before the tool loop. ContextFitter now
  fits every LLM call (on a copy; the stored transcript is untouched)
  and, on a context-overflow error, shrinks the budget from the server's
  reported token counts and retries once.

GET /insights/models reports each llama-swap model's context_window when
it's known without loading the model (cached, or currently running), for
the app's Context picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:21:04 -04:00
cameron 1d2848db05 Merge pull request 'fix(video): keep signed stream tokens out of logs and traces' (#169) from fix/redact-stream-token into master
Reviewed-on: #169
2026-10-14 02:35:23 +00:00
Cameron CordesandClaude Opus 5.5 c0e6a68ca4 fix(video): keep signed stream tokens out of logs and traces
The access log and the tracing root span's http.target both recorded the
full /video/original/stream?token=… URL, i.e. a credential for a
possibly sensitive file, in the journal and OTel exports. An outermost
middleware now moves the token into request extensions and rewrites the
URI to token=redacted before either logger sees it; the handler reads
the token from extensions and fails closed (401) without the middleware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:32:39 -04:00
cameron 40d9dcdbb6 Merge pull request 'feat(video): short-lived signed URLs for original-quality playback' (#168) from feat/signed-original-video into master
Reviewed-on: #168
2026-10-14 01:55:34 +00:00
Cameron CordesandClaude Opus 5.5 40c33c41a6 feat(video): short-lived signed URLs for original-quality playback
POST /video/original mints a URL for one video's original bytes, signed
with a key derived from SECRET_KEY (distinct from the login-token key)
and valid for 6 hours. GET /video/original/stream?token= serves it via
NamedFile, so Range/seek works, without an Authorization header — native
players can use it directly. HLS stays the default path.

Also extracts generate_video's library fallback into resolve_request_path
and fixes the stale /video/stream entry in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:42:28 -04:00
cameron 4745a945fe Merge pull request 'perf(video): decode HLS transcode sources on the GPU' (#167) from perf/hls-gpu-decode into master
Reviewed-on: #167
2026-10-13 16:12:57 +00:00
Cameron CordesandClaude Opus 5.5 be532725c8 perf(video): decode HLS transcode sources on the GPU
When NVENC is available the HLS transcode encoded on the GPU but still
decoded on the CPU, since the command had no -hwaccel. Rebuilding the
4K homemade videos ran three ffmpegs at 210-240% CPU each with the
machine at 0% idle, while NVDEC sat at 0%.

Pass -hwaccel cuda when transcoding with NVENC, as generate_preview_clip
already does. Frames return to system memory, so autorotate and the
scale filter work unchanged; NVDEC-unsupported codecs fall back to
software decode.

10 s of 4K H.264 (2025-USAF-MuseumRace.mp4): 27.3 s -> 6.1 s CPU,
9.6 s -> 3.6 s wall, identical output (1920x1080, 245 frames, 3 s
segments). Also checked a 90-degree-rotated phone clip (comes out
1080x1440 upright, 233/233 frames) and ProRes (software fallback,
correct output).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:12:08 -04:00
cameron 78e36d8a6c Merge pull request 'fix(video): stop stream-copying oversized sources into HLS' (#166) from fix/hls-copy-limits into master
Reviewed-on: #166
2026-10-13 15:09:38 +00:00
Cameron CordesandClaude Opus 5.5 84843c1c25 docs: run tests with an in-memory DATABASE_URL
The docs said tests need BASE_PATH in .env. The full suite (lib, bin and
integration tests) passes with no .env and no BASE_PATH; the only thing
it needs is DATABASE_URL, and ":memory:" works. Without it, DB-backed
tests such as reels::tests::reel_capture_preserves_owner_defaults panic
with "DATABASE_URL must be set".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:09:14 -04:00
Cameron CordesandClaude Opus 5.5 985f366bc2 chore(video): drop unused Ffmpeg::_generate_playlist, fix reels comment
_generate_playlist was a private, never-called predecessor of the HLS
path in video/actors.rs. Its leftover args (-hls_list_size 100, width
1080 scale, libx264 only) no longer described how playlists are built.
Removing it also drops the now-unused std::process::Output import.

The reels encoder comment said its NVENC settings matched the HLS
transcode; reels use preset p4, HLS uses p1. Only the quality target
(cq 23) is shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:07:05 -04:00
Cameron CordesandClaude Opus 5.5 0b87320ef7 fix(video): make NVENC honour forced HLS keyframes
-force_key_frames asks for a keyframe every 3 s, but h264_nvenc inserts
those as plain I-frames unless -forced-idr is set. The HLS muxer only
splits on IDR frames, so NVENC transcodes fell back to the encoder's
default 250-frame GOP: 8.34 s segments at 29.97 fps, ~10 s at 25 fps.
On prod ~480 playlists have a target duration above 4 s. Long segments
mean more data before playback starts and coarser seeks.

Verified on a 10 s 29.97 fps clip: segments went from 8.34 + 1.67 s to
3.003 x 3 + 1.001 s, matching the libx264 path, which was already
correct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:03:18 -04:00
Cameron CordesandClaude Opus 5.5 83b1923e25 fix(video): keep source frame timing when transcoding to HLS
Variable-frame-rate sources (phone clips that change capture rate,
screen recordings) report r_frame_rate as their 1/90000 timebase. The
HLS transcode used the mpegts muxer's default CFR mode, so ffmpeg
duplicated frames to fill 90000 fps: on prod, three 2.6-5 s phone clips
became 3-second "segments" of up to 446 MB / 250k frames. Players
download the whole thing before starting and can't seek within it.

-fps_mode vfr passes source timestamps through. Verified on
20220208_164356_01.mp4: 233 frames over 2.578 s in, 233 frames over
2.578 s out, 5.9 MB vs 221 MB. The copy path already keeps timestamps
and is unchanged. Requires ffmpeg >= 5.1 (prod runs 8.0.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:58:13 -04:00
Cameron CordesandClaude Opus 5.5 052609d604 docs: document HLS_COPY_MAX_BITRATE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:58:12 -04:00