# Changelog of Video → LLM Analyzer (`grizzlygriff/video-llm-analyzer`) Actor

- **URL**: https://apify.com/grizzlygriff/video-llm-analyzer/changelog.md
- **Full Actor documentation**: https://apify.com/grizzlygriff/video-llm-analyzer.md

## Changelog

### 2.8.25 — Wrong-language transcripts, cut-off answers, frame cap (2026-10-05)

- **New "Spoken language" field.** Whisper guesses the language from the first
  30 seconds. On a 26-minute English demo it heard one 9-minute part as Czech
  and returned a transcript that repeated one phrase, on a run marked
  successful. Set **Spoken language** (`transcriptLanguage`, e.g. `en`; `en-US`
  also works) to skip the guess. The output shows the language Whisper used in
  `transcriptLanguageDetected` (a name, e.g. `english`).
- **A looping transcript is flagged** in `softErrors.whisper` when the same line
  comes back 5+ times in a row. Checked on the real failing transcript (91%
  unique overall, one line 7 times in a row) and on two clean ones.
- **A cut-off answer is flagged** in `softErrors.outputTruncated` when the model
  stops at the 8,000-token limit, including on cached replays. A 26-minute
  breakdown stopped at minute 11 with no notice.
- **The frame cap is in the output.** Asking for more than 12 frames still runs
  with 12, and `softErrors.framesCapped` says so. Before, only the run log did.
- **Warnings lead the status message** ("Done, but the transcript is missing or
  unreliable…") so they show in the Console run list instead of being cut off.
- Setting the language changes the cache key, so a re-run to fix a bad
  transcript doesn't replay it. Runs without it keep their existing keys
  (pinned in tests).

### 2.8.24 — Real video timestamps, whole-video coverage, fast frames (2026-10-02)

- **Times in the analysis are positions in the video.** The Whisper transcript now
  carries `[m:ss]` on every line and each frame is labeled with its time. Before,
  the model had no times at all and fell back to clocks visible on screen.
- **Long videos are covered start to end.** The transcript limit rose from 20,000
  to 80,000 characters and the answer limit from 2,000 to 8,000 tokens. On 30-minute
  videos both limits cut the breakdown off partway. The worst-case cost estimate
  rises with the answer limit (8,000 output tokens on Opus 5.5 is $0.16).
- **Frames take seconds, not 40 minutes.** Each frame is read by jumping to its
  time instead of decoding the whole video, and audio is pulled in its own pass.
  12 frames of a 10-minute 1080p file: 1.1 s locally.
- **Long answers no longer time out on the hosted route.** Each model call may take
  up to 5 minutes (was 45 s), since an 8,000-token answer takes about 100 s.
- **Re-runs don't replay older, shorter analyses.** Cache keys now carry an analysis
  format version, so results from before this release are not reused.
- **Silent videos get a frames-only analysis** instead of failing at extraction.
- **The cost check scales with video length,** so short videos with a tight cap
  aren't refused for an 80,000-character transcript they don't have.

### 2.8.23 — Opus 4 replaced by Opus 5.5 (2026-10-02)

- **"Claude Opus 4" is now "Claude Opus 5.5".** OpenRouter stopped serving Opus 4
  ("No endpoints found"), so every Opus run failed after the video work. Saved
  Tasks that still say Opus 4 run on Opus 5.5 and log a warning. Opus 5.5 costs
  $4 / $20 per million tokens, against $15 / $75 for Opus 4.
- **The model is checked before any video work.** On the hosted route, the Actor
  reads OpenRouter's public model list first. A model it no longer serves now
  fails in seconds, not after 41 minutes of frames and transcription.

### 2.8.22 — Fail fast on Anthropic key problems (2026-10-02)

- **A pasted Anthropic key is checked before any video work.** A 1-token test
  request (about $0.0002, billed to your Anthropic key) catches a rejected key,
  an unavailable model, or an empty credit balance in seconds. Before, a
  30-minute video spent 38 minutes on frames and transcription first.
- **Anthropic's own reason is shown** when it rejects a request, instead of only
  `BadRequestError`.

### 2.8.21 — Destination connection fixes (2026-10-02)

- **"Just give me the analysis" ignores the Destination field.** A connection
  left in Destination failed the run with "unexpected ID format", even though
  nothing gets written when the analysis is returned in the dataset.
- **Connections saved by name are accepted.** The Console's connection picker
  now stores `~connection-name` instead of a 17-character ID. The ID check
  rejected the `~`.

### 2.8.20 — README sweep fixes (2026-09-14)

22 live runs across every platform the README names found the platforms working
and a dozen gaps between what the README promises and what runs did. Ten are fixed
here; a Whisper pre-flight that catches an out-of-credit OpenAI key is not.

- **Portrait videos no longer fail with HTTP 413 on default settings.** Frames are
  now shrunk on their longer side, so tall (portrait) videos no longer exceed the
  LLM gateway's size limit at 12 frames. A 413 now gets its own message ("lower
  Frames to extract") and is marked permanent.
- **`maxChargeUsd` is a ceiling for every model.** Per-model prices and per-frame
  token budgets (`my_actor/pricing.py`) replace one shared constant; GPT-4o-mini
  bills a frame at roughly six times Sonnet and was under-estimated 2.4×.
- **Failed runs write an OUTPUT record**, matching the row, as documented.
- **Deleted or private TikTok posts** are reported as gone (permanent), not as a
  retryable helper glitch.
- **Nested Notion writes report the child page URL.** Notion now returns
  `app.notion.com` links, which the Actor did not recognize as the new page, so
  it reported the parent page instead.
- **Cache replays report `succeeded_idempotent_replay`** on analysis-only runs, and
  the log no longer claims the video fetch was skipped (it still runs).
- **`provider` is `gemini`**, not `google`, matching the input enum and README.
- **A rejected API key, out-of-credit account, or invalid model is `errorRecoverable: false`.**
- **Whisper is skipped when captions already cover the audio**, and the status line
  says when the audio wasn't transcribed (details stay in `softErrors.whisper`).
- Caps under a cent display as `$0.0010`, not `$0.00`, and every cap refusal now says
  how to override it: raise the cap or set **Maximum cost for this run** to 0.

### 2.8.12 — Restricted storage access no longer breaks runs (2026-09-14)

TikTok, other URL, and uploaded-file runs no longer fail with "The run hit an
unexpected error" when your Apify account has **General resource access** set
to **Restricted** (the default for new accounts). Every video the Actor
processes is first stored in your account — the file you upload, or the file a
download helper saves — and the Actor read it back without your token, so
restricted accounts got 403.

- **Reads from your Apify account are now signed with the run's own token.**
  Only requests to `api.apify.com` carry it; redirects to other hosts never do.
- **A failed file fetch now says what happened.** Instead of "unexpected
  error" you get the cause, whether a retry makes sense, and the "no LLM cost
  was incurred" line. Agent callers get `download_failed_infra` (retryable,
  with `errorRetryAfterSec`) or `download_video_gone` (permanent).
- **Concurrent-run limit named.** When the hosted OpenRouter helper can't
  start because your account is at its concurrent-run limit, the message says
  so instead of "OpenRouter returned a server error (HTTP 500)".

### 2.8.10 — Long-video opt-in (2026-09-01)

New input `allowLongVideos` (default `false`) lifts the 10-minute duration cap
for users who accept the higher, harder-to-estimate cost. Off, nothing changes.

- **New Advanced toggle: "Process videos longer than 10 minutes (costs more)".**
  The schema copy is explicit that cost scales with length and is hard to
  estimate up front, and points at `maxChargeUsd` — which still works: the
  post-ingest worst-case estimate uses the real duration, so a cap aborts
  before the LLM call exactly as before.
- **File pipeline:** videos over 10 minutes no longer hard-fail when the toggle
  is on; instead the run logs and shows a long-video cost warning in the status
  line (including whether a cost cap is set). The refusal message when the
  toggle is off now names the toggle.
- **YouTube visual path:** a visual prompt on a >10-min video downloads real
  frames when the toggle is on, instead of falling back to transcript +
  thumbnail. Fallback messages now mention the toggle.
- **Downloader timeout:** sub-actor download timeout goes 6 → 20 minutes on
  long-video runs so 1-hour downloads aren't killed mid-flight.
- **Whisper 25 MB guard:** audio over Whisper's API file limit (~69 minutes at
  our 48 kbps mono encoding) skips transcription with a clear
  `softErrors.whisper` message instead of burning a doomed API upload. The
  constant is decimal 25,000,000 bytes so the "~69 minutes" copy is exact and
  the guard is conservative (review catch: 25 MiB would shift the boundary to
  \~73 min and could let a doomed upload through).
- **Early budget gate (review blocker fix):** the pre-LLM cost gate used to fire
  *after* Whisper had billed — on long videos, the cost that scales. The file
  pipeline now checks the cap right after reading the video's duration, before
  frame extraction and transcription, assuming the transcript hits its
  truncation cap. Cap-selection and publisher-fee math are shared with the
  refined gate via new `resolve_charge_cap()` / `publisher_fees_estimate_usd()`
  helpers, and the refined gate's abort message now names any Whisper spend
  that already happened.
- **`allowLongVideos` joins the idempotency key (review blocker fix):** the
  fallback copy tells users to flip the flag and re-run; without the flag in
  the key that exact re-run replayed the stale transcript-only result.
  Existing idempotency entries miss once and recompute; the analysis cache
  still absorbs the LLM cost where the resolved mode matches.
- **Whisper empty-result fix (pre-existing, amplified by long videos):** a
  silent video no longer sets `whisper_used` (which added a phantom per-minute
  term to cost estimates) or clobbers a preserved caption transcript; it now
  soft-errors "no audio track".
- **Limits that do not lift:** 1 GB file size, 12-frame cap, 20,000-character
  transcript truncation. README documents all of this under *Long videos*.
- **Deferred from review, with reasoning:** (a) the downloader's one infra retry
  keeps the same 20-min timeout on long runs (worst case ~40 min parent wait) —
  acceptable for an explicit opt-in, revisit if real runs hit it; (b) passing a
  cost cap down to the sub-actor call — needs SDK signature verification first;
  (c) surfacing transcript truncation in `softErrors` — output-schema addition,
  separate change.

### 2.8.9 — YouTube ingest fixes (2026-08-03)

Fixes the three bugs found in the 2026-08-03 demo-review runs (`fCaYYYdXwRagdgqiE`,
`TuRJhQp0HXRnbsugw`). No input schema changes; one new `analysisMode` value.

- **YouTube transcripts extract again.** `streamers/youtube-scraper` returns
  captions as one complete raw SRT per `subtitles` entry (`{language, srt}`), not
  the `{text, start}` segment shape the extractor expected — so runs recorded
  `transcriptChars: 0` while a 50 KB SRT sat in the response. Added an SRT
  flattener (strips indices, timestamps, and the rolling-caption duplicates
  auto-generated tracks emit). The demo video now yields 12,050 transcript chars.
- **No more silent caption failures.** `softErrors.youtubeTranscript` used to stay
  null unless BOTH transcript and title were missing. It now reports the
  metadata-present-but-no-captions case, with the recovery step in the message.
- **Thumbnail-only runs stop pretending.** A YouTube run with no usable transcript
  was labeled `transcript+thumbnail` and the LLM was told to lean on a transcript
  that wasn't there — which produced confident fabricated reviews. Those runs are
  now labeled `analysisMode: "thumbnail"` (new value, documented in the README),
  and the prompt tells the model it has one still image, forbids inventing
  content, and redirects it to what the thumbnail and metadata do show.
- **Poisoned-cache guard.** Pre-fix cached analyses were computed without the
  transcript this build now extracts; replaying them would resurface the
  fabricated answers. A cache hit is refused when the cached run had no
  transcript but the current run does, and `thumbnail` runs refuse mode-less
  pre-2.7 cache entries.
- **Downloader retry.** `download_via_actor` retries once after a 10 s pause on
  infra failures (launch timeouts), with a log line for the wait. Deterministic
  failures are not retried. This does not fix a persistently-down downloader —
  a second fallback is a known follow-up, not in this build.
- 13 new tests (SRT edge cases pinned to the verbatim scraper payload, retry
  control flow, and an `analysisMode` contract test that fails if the code and
  README enum drift).

### 2.8.8 — crash reliability (2026-06-24)

Build reproducibility and memory rightsizing. No input/output schema changes.

- **Reproducible builds (lockfile).** Added `requirements.lock`, a fully pinned,
  hash-verified resolution compiled from `requirements.txt`. The Docker build now
  installs from it with `pip install --require-hashes -r requirements.lock` instead
  of from the loose ranges. This pins `apify-shared` (currently `2.2.0`) — the
  transitive dependency behind the v2.x crash — so a rebuild can't silently pull a
  changed version. `requirements.txt` keeps the human-readable ranges and is the
  source the lock is compiled from. Regenerate the lock after editing
  `requirements.txt` with:
  `uv pip compile requirements.txt -o requirements.lock --generate-hashes --no-header --python-version 3.14`
  (`--python-version 3.14` matches the `apify/actor-python:3.14` base image;
  pre-releases are excluded by default). The lock in this commit was generated this
  way on a connected machine, not hand-written.
- **Memory rightsizing.** Lowered `.actor/actor.json` from min 2048 / max 4096 MB to
  min 1024 / max 2048 MB. Peak observed Python RSS was ~114 MB; the 2 GB max keeps
  headroom for the ffmpeg subprocess (whose RSS is not in that 114 MB number) while
  halving the per-GB actor-start charge.
  - **Follow-up (not yet done):** run an on-platform headroom test on a long /
    high-resolution video to confirm 2 GB is enough for ffmpeg before considering a
    further drop to 1024 MB max. Watch the run for OOM kills / ffmpeg exit 137. Do
    not lower the max below 2048 MB until that test passes.

### v2.8 — 2026-06-12

Error-handling hardening. No input/output schema changes; existing Tasks keep working.

- Added a top-level exception net in `main()`. An unhandled error now fails the
  run with a `failed_internal` dataset row (`errorCategory: internal_error`,
  `errorRecoverable: false`) and a human status message, instead of exiting with
  a bare `exit_code 1` and no explanation.
- Split the LLM-call handler. Known `RuntimeError`s from the LLM paths
  (transport, 4xx/5xx, out-of-credit, rate limit) stay user-friendly and
  recoverable. Any other exception is treated as an internal error (permanent),
  so a bug isn't mislabeled as a retryable LLM condition and looped on by agents.
- Both failure messages state "No LLM analysis charge was incurred" where that's
  provably true, and keep raw exception types out of the lead of the message.

First release built from the GitHub repo (private) rather than uploaded source files.

### Earlier

For 2.7 and earlier, see the Apify Console build history and the project notes.
