短影音拆解 · 本機優先
DOCS
MICRO_SHOT_SEC
(0.2s; MICRO_RUN_MIN 3) form a run. The cut detector was right about these
clips (one reel changes picture every two frames: 110 cuts in 8.9s), but
cuts/min could not tell a strobe from 110 real shots. measured now adds
segment_count, segments_per_minute, micro_shot_ratio and flash_runs,
read from the stored shot table (no new schema, no re-detection); the merge
prompt gets a “Cut Rhythm” section naming the runs and the score prompt gets
the segment figures. cuts_per_minute is unchanged. The KEYFRAME_SHOT_CAP
growth from the previous entry now counts segments: a 23-shot clip with one
4-shot strobe gets 21 frames instead of 24, and a stop-motion reel stays at
the flat cap instead of spending 30 VLM calls on near-identical frames.
Clips without runs pick exactly what they did before. 0.15/0.2/0.25 were
compared on the 288-clip library; 0.25 collapsed a genuine flash montage of
distinct images to 2 segments.style.pacing is set from the measured cut rate when there is one. The
merge LLM labelled pacing without seeing the shot metrics, and across 220
short clips its label did not follow them (84% “medium”; its “slow” clips
averaged 56.7 cuts/min against 32.4 for “medium”). That label feeds the
style_pacing column patterns, track and stats aggregate on. Measured
clips are now slow below PACING_SLOW_BELOW_CPM (8), fast from
PACING_FAST_FROM_CPM (25) — the corpus terciles — and medium between; a
disagreeing LLM label is kept as style.pacing_llm. Unmeasured clips keep
the LLM label. The score prompt now states the band and its scale. Existing
analyses are relabelled when analyze next runs the measured backfill; on
the 289-clip library that changes 172 labels.KEYFRAME_SHOT_CAP,
default 30). The flat short-form cap gave a 36-second, 23-shot montage
12 frames, so the VLM saw every other shot and four of its six locations
never reached it. When the scene pass finds more cuts than KEYFRAME_MAX,
the cap grows to cuts + first/last frame, up to KEYFRAME_SHOT_CAP. Clips
with fewer cuts, long-form, focus windows and an explicit --keyframe-max
spend exactly what they did before; KEYFRAME_SHOT_CAP=0 restores the flat
cap. Fast-cut clips cost more VLM calls (that clip: 12 → 24), and their
scores are not comparable with runs made under the flat cap.auto: mlx-whisper on Apple Silicon when the
mlx extra is installed, faster-whisper everywhere else. On 5 clips with
human Chinese subtitles mlx large-v3 matched faster-whisper large-v3
(mean CER 9.5% vs 9.4%) at RTF 0.11–0.37 vs 1.17–1.79; turbo averaged 10.2%
but swung the most per clip, so WHISPER_MODEL stays large-v3. Set
WHISPER_BACKEND=faster-whisper to keep the old engine. On Apple Silicon
without a working mlx, auto says once per run that it is on the CPU path.config check reports the Simplified → Traditional step. It lists whether
opencc is installed whenever REEL_SCOUT_ZH_CONVERT is not off; without it
Simplified transcripts are stored as received. mlx large-v3 drifted into
Simplified for 31% of one Taiwanese clip despite the Traditional seed prompt.A transcript that mixes traditional and simplified now says so after the
run ends (#114, schema v22). The detector’s finding was only printed to
stderr; it is now stored in transcripts.script_mix (JSON
{"trad", "simp", "minority"}, NULL when not mixed) and surfaced by show,
MCP show_video (transcript.script_mix + transcript.warning) and
export --format json. The migration scans existing rows once (writes only
the new column; no text is converted).
Scene and motion detection stream ffmpeg’s stderr instead of buffering it.
The detection pass held the whole showinfo log in memory before parsing
(12.2 MB for a 22-minute clip, ~98 MB worst case under the 300s cap); it now
reads pts_time: stamps line by line through Popen. The deadline is a
watchdog that kills and reaps ffmpeg, with the same fallback to interval
sampling as before.
faster-whisper transcripts silently lost speech over music/noise. The
whisper-guard filter dropped any segment with no_speech_prob > 0.8; over a
music bed faster-whisper reports 0.9–1.0 for confidently decoded speech, and
one 177 s explainer lost 20 of 173 segments. Requires whisper-guard>=0.3.2,
which keeps a ≥5 s window that is dense (≥2 chars/s) and confident
(avg_logprob > −0.5) — vulture-s/whisper-guard#4.
Silence hallucinations with a low no_speech_prob reached the transcript.
Over a noise bed faster-whisper filled 30 s windows with “Thank you.” at
no_speech_prob 0.14–0.75, under every cut. Requires whisper-guard>=0.3.3,
which drops a ≥10 s decoding window with <1 char/s whatever its metrics —
vulture-s/whisper-guard#6.
[mlx] extra pins scipy<1.15 on Python 3.10 / Apple Silicon. On macOS 27
every scipy 1.15.x cp310 wheel fails to load (dyld rejects _spropack’s
__thread_bss section), and mlx-whisper imports scipy.signal, so
WHISPER_BACKEND=mlx crashed at import. 1.13.1 and 1.14.1 load fine.
inspire’s real-name check now depends on the angle. With an angle the
variant is a different video, so a real name from the source may drop out
(a photography-angle variant need not mention the source’s GEV / ASTRA):
only a name-shaped word found in neither the analysis nor the angle fails
the answer. Without an angle the check stays strict — a missing name and an
invented one both fail. name_check.mode says which ran (angle /
strict), name_check.checks lists what counted, and in angle mode the
source names left out are listed under name_check.dropped for
information only. The re-ask in angle mode only mentions invented names.mlx Whisper backend (opt-in, issue #113). WHISPER_BACKEND=mlx /
--whisper-backend mlx / transcribe --backend mlx transcribes with
mlx-whisper on Metal (Apple Silicon; pip install "reel-scout[mlx]").
faster-whisper is CPU-only on macOS — on an M2 Max a 3h39m video ran at RTF
1.22 there vs ~0.04 for mlx large-v3-turbo. WHISPER_MODEL takes
large-v3 / large-v3-turbo or an HF repo id; an unknown short name is an
error, not a guessed repo. Same TranscriptResult shape, the same
whisper-guard probabilities and Traditional seed (now shared as
ZhSeedMixin), so save_transcript’s s2twp conversion applies unchanged;
whisper_model is stored as mlx:<repo> for A/B provenance. Differences:
no beam search (greedy + temperature fallback), vad_filter approximated by
hallucination_silence_threshold, WHISPER_MULTILINGUAL ignored with a
notice. Off Apple Silicon or without the package it falls back to
faster-whisper with a one-line stderr notice. The default is unchanged.
--whisper-backend / transcribe --backend now reject unknown names.
Simplified transcripts / subtitles are stored as Traditional, original
kept (schema v21). When save_transcript receives text with Simplified
characters (scan_script_mix) or from a zh-Hans / zh-CN / zh-SG caption
track, it stores the zh_convert result — the same REEL_SCOUT_ZH_CONVERT
(s2twp default / s2tw) and REEL_SCOUT_ZH_HOTWORDS inspire uses — in
text_full / segments_json (segment text only), and the received text in
the new text_full_original / segments_json_original; zh_conversion
records what was applied (s2twp, s2tw+hotwords). off stores Simplified as
received (hotwords included). Without the zh extra the text is stored as
received with a once-per-run stderr warning; a bad mode / hotword file costs
the conversion, never the transcript. Upgrading a v20 database adds the three
columns and converts nothing; db.restore_transcript_originals(conn, video_id=None) puts received text back. Native Traditional caption tracks are
still preferred (zh_track_rank); this only handles “what we got was
Simplified”. Whisper’s own Traditional seed prompt (WHISPER_ZH_SCRIPT) is
unchanged and still the first line of defence for the faster-whisper path.
inspire checks real names and re-asks. After the model answers, the
real names in the analysis (name-shaped Latin words such as What'Sub,
DaVinci, FX3, GEV, and 《書名號》 titles) are compared with the output.
A name that went missing, or a name-shaped word the analysis never mentions
(SubEase), triggers a re-ask that lists exactly what was wrong — at most
INSPIRE_NAME_RETRIES times (default 2; --name-retries, MCP name_retries).
The result always carries name_check.status: passed or failed. A failed
check is never passed off as success: the JSON says so, the text view prints
!! real-name check FAILED, and the CLI exits 3 with a stderr warning.
Not detected, by design of the shape rule: Title-case-only names (Subly)
and Chinese personal names.
Configurable source conversion + hotwords. REEL_SCOUT_ZH_CONVERT /
--zh-convert picks s2twp (default), s2tw (per character) or off.
REEL_SCOUT_ZH_HOTWORDS / --zh-hotwords points at a YAML/JSON
source: replacement table applied after OpenCC, longest match first, to fix
s2twp’s phrase swaps (类型 → 型別). Example: docs/zh-hotwords.example.yaml.
zh extra (pip install "reel-scout[zh]"),
not a core dependency. Its only use is the Simplified → Traditional
conversion of the analysis copy inspire sends to the LLM. Without it,
inspire warns on stderr (with the install command) and sends the analysis
unconverted. CI runs the suite both with and without the extra.The sheet is fluid on desktop instead of a fixed 1240.
clamp(1240px, 100vw - 100px, 1560px) — never narrower than before, 50px of
desk left at the sides, and it stops at 1560 because past that only the tables
keep growing: prose, the score panel and the reweight card are all already at
their own caps. Measured: at a 1512 display the sheet goes 1240 → 1412; at
1920 it reaches the 1560 ceiling.
The four differentiator cards needed a cap of their own — their column is half the sheet, so they tracked it up to 48 characters a line while the rest of the site sat at 43. Now 68ch like everything else. Orphan counts and kinsoku are unchanged from the typography pass (11 Chrome / 6 WebKit, 0 violations).
🔴 Threads is now off by default (opt-in via THREADS_USER_AGENT).
The crawler only gets a post by presenting a search-crawler User-Agent —
impersonating one — and Threads’ robots.txt opens with “collection of data
on Threads through automated means is prohibited unless you have express
written permission”, disallowing everything for agents it does not name.
Until this change the package shipped with Googlebot as the default, so
every install did that out of the box.
Now the default is empty and the crawler refuses before any request
(not even the rate limiter runs), with a message saying why and how to opt
in. docs/threads.md gains an “Off by default” section stating the terms
plainly and the limits of fair use: one hand-picked post at a time, never
program-generated URL lists, schedules, search or profile pages; at scale,
use the official API. If Threads stops serving that page, the answer is to
stop — not to find a User-Agent that looks more like a browser. The site
copy (home, how-it-works, honest list) says the same; the homepage no longer
lists Threads among “URLs it eats directly”.
The shipped default is pinned by an AST test on the source rather than the
runtime value, because config loads the project .env — a machine that has
opted in would otherwise fail the check there and pass everywhere else.
The front page now renders the four differentiators and the five-signal
reliability table. Both were written, kept in sync with the code and never
put on the page: index.astro declared them and the template never used them.
Nothing failed — the build was green, and a source-level grep for their text
found it, which is exactly why a platform-coverage gate also passed on copy
nobody could see. A test now fails the build when a page declares a content
array its template never references.
The page is wider: the sheet goes 1080 → 1240, the content column 964 →
1124. It gets its own site-only token (--col-site) rather than moving
--col-wide, which theme.py shares with the inspector — widening the site
through that token would have dragged the tool’s layout with it. Body prose is
unaffected because paragraphs carry their own measure (39–54 characters a
line); the extra room goes to the tables and the reweight panel, which were
the two things 964px was squeezing. Measured at 1440/1512/1920/1280/1024/768
/390: no horizontal scroll anywhere, and the only line over 70 characters is
the footer’s licence credit.
The site now says it eats Threads links. The crawler shipped in #153 and
the front page still read “YouTube Shorts, Instagram Reels or TikTok”, so
someone holding a Threads URL had no way to know. Stated with its shape
intact: Threads does not go through yt-dlp — there is no extractor — it reads
the post page itself, which means single posts only and no browse. A test
pins it: a registered crawler the front page never names is a build failure.
The inspector’s reweight panel now shows the weight total and the overall,
matching the site. Same reason as the site’s panel, plus one the site did
not have: the inspector blanked that line whenever the weights sat at
default — the exact state that should read “weights total 100%”. Now the sum
and the overall show unconditionally and only the comparison against the
stored score stays conditional: weights total 100% · overall 6.8 at rest,
weights total 165% → rescaled to 100% · default 6.8 · yours 6.5 (-0.3)
after a drag, weights total 0% · no overall with everything at zero.
Translated, so it reads the same in both locales. A difference that rounds to
zero no longer prints as (-0.0).
The site’s reweight panel now shows the weight total and the running
score. The four sliders are each 0–100 and do not have to add up, so the
panel was quietly rescaling to 100% and only saying so in prose above the
controls. Now the footer line states it per drag: 權重總和 100% · 總分 6.8
when the sliders already add up, 權重總和 165% → 縮放 100% · 總分 6.5 when
they do not. Showing only 100% would read as “the sliders are locked”;
showing only 165% would contradict the explanation — so both appear.
Dragging everything to zero reads 權重總和 0% · 算不出總分, because no
dimension being valued is not a score of zero.
A Threads post is stored as a post, not just its first video (schema
v20). Rule, written out in docs/threads.md: the first video is
analyzed, photos are downloaded and stored, other videos are
listed; caption, the author’s follow-up posts and engagement counts go to
post_meta (counts with the time they were fetched — they are a snapshot).
Any other video in the post can be analyzed on demand. In reel-scout view
the clip’s Post section shows every item, and a video with no analysis
has a queue analysis button. The button writes one request row and
nothing else — the viewer has no account layer, so it never runs the
pipeline; reel-scout pending --run does, on the machine that has it. Each
request becomes analyze <post url>?media=N, so the clip gets its own row,
URL and id (<code>#N), and dedupe / batch / the queue need no change.
A request is marked done only when the DB shows that row analyzed.
Found while testing live, not fixed here: a clip with no audio
stream fails transcription with tuple index out of range. It reproduces
on a local file with no Threads code involved, so it is the transcriber’s —
but every video in one real carousel was silent, so it blocks those posts
until it is fixed.
Threads posts: analyze https://www.threads.com/... now works (post
URLs and /share/ links, threads.com and threads.net). yt-dlp has no
Threads extractor, and the post code is not an Instagram shortcode in
disguise — the same ID on instagram.com/p/ is refused even with valid
cookies while a known reel in the same call succeeds.
What works is the post page itself: Threads serves browsers a login wall but serves search crawlers a server-rendered page whose JSON carries the whole post — direct mp4 URLs that download without cookies, plus caption, author, timestamp, engagement counts and the author’s own follow-up posts. Measured on five real queued posts covering all three layouts videos live in (single video, carousel, text post with an inline video): 5/5 resolved and downloaded as h264.
🔴 The whole thing rests on one assumption — which User-Agent gets the
server-rendered page — so it lives in THREADS_USER_AGENT, not in the
crawler, and a page with no post in it raises instead of returning an empty
row. The day Threads stops serving it, this fails loud.
One trap found only by running it live: the page streams a post as several
fragments sharing an id, and the follow-up posts sit in a different
fragment from the caption. Reading only the fragment with the post code
returned zero follow-ups for a post that had one, with nothing failing.
How the rest of the post (photos, other videos, caption, counts) is stored
is the entry above. batch still does not pick up Threads links.
An official site: https://vulture-s.github.io/reel-scout/. Before this the repository had no description, no homepage and no Pages — a tool that is on PyPI, documents nineteen MCP tools and ships a skill, with nowhere on GitHub that answers “how is this different from pasting the URL into an AI”.
Seven pages, thirteen routes, built with Astro and deployed by Actions. The
doc pages render docs/*.md and CHANGELOG.md directly — those files stay
the single source of truth and the site is just another place that reads
them — through an allowlist, not a glob: “anything later dropped into
docs/ goes live” is the wrong default, because the next thing dropped in
may be a private note.
One page is worth naming: 誠實清單 (the honest list). It carries only limits that have actually been hit, each with what to do about it. “Problems you might have” are left out on purpose — those turn an honest list into a disclaimer, and nobody reads a disclaimer.
Paper. The shell grows two fixed layers — an SVG feTurbulence fibre and
ruled lines — shared by the viewer, the inspector, the take-home export and
the site, so all four stay one surface. Only --paper-col differs (the tool
is wide, the site is editorial).
🔴 Two decisions here were reversed by rendering them, not by reading the
code. The ruled lines first shipped scoped to a class that only the doc pages
carried: present, invisible, effectively not done. And tables were first
masked on the theory that row dividers and ruled lines would fight — but the
library view is one full-width table, so masking it left the whole screen
bare. Measured on paper: a ruled line at 8% sits 17/255 from the sheet while
--rule-soft sits 36/255 away, so the dividers stay unambiguously louder.
The 2026-10-09 audit batch (#181–#195). Each of these failed silently or wrote wrong data rather than erroring:
crawl --cookies, browse --cookies and MCP crawl.cookies never reached
yt-dlp — the cookies path was frozen at import — so the failure then said
“need cookies?”. It is now resolved at call time. (#181)batch claimed whichever video row appeared during the run, so a row added
by another process could be exported under this entry’s label; it now claims
the id the child reports, and the heartbeat no longer depends on progress. (#182)text/plain POSTs (delete group, overwrite note, request analysis); they now
check Origin, Host and Content-Type. (#183)-- before the URL, and a “URL” starting with - is
refused; .env accepts export and inline comments. (#184)0.0 / 16.95
/ NULL; merge replies with no JSON object were stored as the summary. Both
now go through validation and retry, and raise instead of writing. (#185)ffmpeg anywhere in FFMPEG_BIN,
breaking paths like /opt/homebrew/Cellar/ffmpeg/…; only the file name is
changed now, and FFPROBE_BIN overrides it. (#187)research channel/niche averages now exclude status=invalid, like
stats/patterns/health already did. (#191)・/ー no longer count as kana. (#190)transcribed, dropping it
from every export; status now only moves forward. (#192)OCR_LANG (default chi_tra+eng) with resolved paths; CSV export
neutralises leading = + - @ and writes UTF-8 with BOM. (#195)Videos with no audio track no longer crash transcription. The audio
streams are counted first; zero means an empty transcript (model
none:no-audio-stream) and vision/scoring still run. An unmeasurable count is
not treated as silent. (#165)
Threads (and Doc-based batch) never worked on python.org macOS Python.
That installer ships without access to the system CA store until someone runs
“Install Certificates.command”, so every urllib HTTPS request failed with
CERTIFICATE_VERIFY_FAILED. Found on the maintainer’s M2 Max, where the Threads
crawler had never once succeeded — every earlier live test had run on Windows,
whose Python reads the OS store. yt-dlp handles certificates itself, which is
why IG / TikTok / YouTube never showed it.
The two internet-facing urllib calls (Threads post pages, batch Doc export) now
go through reel_scout/utils/https.py: a user-set SSL_CERT_FILE wins, else
certifi’s bundle (now a declared dependency), else the platform default.
Verified on the affected machine: same Python, same URL — plain urlopen fails,
the helper returns 200.
Chinese line-breaking: orphan last lines, and two blocks with no measure cap. Audited both engines across six pages and five widths by reconstructing every rendered line from per-character client rects. Two things came back clean and stay that way: kinsoku (no punctuation starting a line, no opening bracket ending one) is handled by the browser, 0 violations; and no Latin word or number is ever split. Two were not:
text-wrap: pretty for the orphans — a last line holding one or two
characters, 37 of them in Chrome and 40 in WebKit, almost all at phone width
(「…零金/鑰。」「…判斷留給/人。」). Now 11 and 6.
A 62ch cap on the subscribe box and the footer, which had no max-width at
all. Widening the sheet to 1240 pushed them from 68 characters a line to 80,
while capped prose stayed at 43 — the regression landed exactly on the two
blocks nobody had set a limit for, and the subscribe box is on every page.
Longest line on the site is now 44 characters, down from 90.
A clip with no audio stream crashed transcription with
IndexError: tuple index out of range. faster-whisper decodes through PyAV,
which asks for audio stream 0 whether or not one exists. Found on a real
Threads carousel whose three videos were all silent, and reproduced on a
plain local file — so it was never a Threads problem; “no audio stream” was
simply unhandled.
Now the stream count is probed first. Zero streams → an empty transcript
whose model reads none:no-audio-stream (so it is stored, not retried every
run, and says why it is empty), Whisper is not called, and the clip goes on
to keyframes, VLM and scoring — the real carousel clip now analyzes end to
end. A probe that fails is not treated as silence: it still goes to
Whisper, so an unreadable file keeps failing loudly instead of being
recorded as “no speech”.
The site said Threads could not be crawled, which stopped being true the moment the Threads crawler merged. Rewritten to what is now the real limitation: it works by reading the server-rendered page Threads serves to search crawlers, so the User-Agent is the whole load-bearing assumption. The “use the Instagram URL instead” substitute is still dead, and that part stays. A test now pins the seam — a registered crawler plus a sentence on the limits page denying it is a build failure, because nothing else was watching for claims that should come out of that page.
The craft re-weighting sliders were painted in the browser’s accent
colour — on a stock macOS install, a bright blue, on a shell whose own
canon says it spends no colour at all. The tokens were right; nothing had
told the control about them (accent-color: var(--ink)).
It had been there long enough to be in the screenshot used by both READMEs and the site’s home page — so the main piece of evidence for “this shell is monochrome” was a counter-example to it. The screenshot has been retaken, and its alt text now describes what the image actually contains rather than a bilingual pairing that is no longer visible in it.
Doc pages shipped two <h1> elements and a 404. The page supplies a
short title for the nav and each .md opens with its own long one; and the
link rewriter only understood same-directory .md, leaving everything else
“as written” — which for ../prompts/signal-reliability-cheatsheet.md meant
a guaranteed 404 rather than neutrality. Links now resolve in three classes
(published → .html, other in-repo .md → GitHub, external → untouched),
and the leading h1 is demoted in the rehype pass.
Both were found by opening the live page. Both are now executable checks:
site/test/links.test.mjs plus two build-time assertions (one h1 per page,
no unresolved .md links).
The docs advertised an MCP tool that has never existed. README.md and
README.zh.md listed get_transcript among the read-side tools;
git log -S returns nothing for it, in any version, since the MCP server was
written. It had been there for six weeks.
It was not alone. docs/commands.md said 8 tools against 19, six db
subcommands against eight, four export formats against six, and omitted five
whole CLI commands. docs/roadmap.md gave the version as v1.3.1 while the
code read 1.4.0, and stated the schema as v18 on one line and v17 on the
next — directly above a warning it had written itself about making exactly
that mistake in the version before. SKILL.md, which ships inside the
wheel under the heading “do not invent flags beyond these”, was missing
--force-keyframes: a list that omits a real flag is worse than no list,
because it makes the real one look invented.
All corrected — and, more usefully, tests/test_docs_are_current.py now
derives every one of those claims from the code and fails on a mismatch.
Prose does not rot faster than code; it rots at the same rate and nobody
notices, because there is no failing test to notice with. Verified by putting
each of the six errors back: every one turns the gate red.
Six merged changes reached 1.4.0 with no changelog entry — mark and
its clip_marks table (a whole schema step, v13), URL-based clip
identity, Apple-playable codec selection, transcripts outliving their media,
near-miss BPM reporting, and evidence coverage in stats. Written into the
1.4.0 section now, marked with the date they were recorded rather than
backdated.
The 1.4.0 note “Schema v13 → v18” describes the repo’s history, not an
upgrade: 1.3.1 on PyPI shipped SCHEMA_VERSION = 10, so a published-
version upgrade runs v10 → v18. Both were tested on real backups; the line
now says which is which.
The gate is deliberately narrow: only claims enumerable from the code are checked. Prose about intent, tradeoffs and history is not, and must not be — a gate demanding exact wording would be reformatted into uselessness within a month. Its first two versions were too broad and flagged the roadmap’s correct quotations of its own past errors; the fix was to give the current claim one authoritative line and leave the commentary around it free.
Found by an independent read-only audit of 1.4.0 (2026-09-05): R8, R9.
scores.model_used recorded the backend, not the model — while stats
grouped on it under a docstring promising the opposite. All 115 locally
scored rows in the library read ollama. The docstring on the aggregate
says grouping is “on the exact model_used string, the finest grain that is
actually correct: two local VLMs are no more comparable to each other than an
agent is to either” — and every model that ever scored under one backend was
pooled into a single yardstick, which is precisely what the column was added
to prevent. Swapping qwen2.5:14b for gpt-oss:20b moved nothing on the
page.
The gap was only in the scorer: the ingest path already stamps
agent:<model>, and vision_descriptions.vlm_model,
translations.engine/model and shot_labels.model were all checked and
are correct. New scores carry <backend>:<model> — the same shape, so a
reader splits on the first colon whoever produced the row.
⚠️ The 115 existing rows are left as they are. A bare backend name is a readable “recorded before the model was”; backfilling it would mean inventing a fact that is not written down anywhere, and a wrong provenance is worse than a missing one.
The audit also flagged 1,077 ocr_captions rows with engine='vlm' and no
model. Checked: those rows are re-read from vision_descriptions.text_in_frame
at the same keyframe, and that table does record vlm_model — so the
provenance is recoverable by join rather than lost. No column added; a second
copy of a fact is a second thing that can drift from it.
BaseLLM gained a model property, so a backend added later cannot quietly
go back to recording only itself.
Found by an independent read-only audit of 1.4.0 (2026-09-05): R7.
“This codec has no motion vectors” was reported as “this shot was too
short”. FFmpeg exports motion vectors for H.264; HEVC, VP9 and AV1 give
none, and an all-intra file has none to give. All three decoded fine, yielded
nothing, and landed in UNKNOWN — whose explanation, in the CLI and in the
Chinese UI (幀數不足), is a claim about the length of a shot. The truth is
a claim about the container, and no amount of re-running changes it.
Measured 2026-09-05 on real files: pan_hevc, pan_vp9 and an all-intra
H.264 all returned frames=0; a genuinely short H.264 shot returned
frames=5. The two are now unsupported and unknown. The library today is
118/118 H.264 so nothing was mislabelled yet — but analyze <local path>
takes any file, and an iPhone shoots HEVC by default.
A file that decodes nothing stays unknown: an unreadable stream is not
evidence about the codec.
MIN_FRAMES = 8 now says out loud that it has no measurement behind it,
unlike SPEED_FLOOR, which sits on a measured hard zero. It asserts that
eight frames are enough and seven are not, and nothing was run to separate
them. Recorded rather than quietly kept: a threshold whose provenance is “it
seemed right” is the kind that survives for years by never being questioned.
speed is not hypot(dx, dy), and the stored row now says so. They are
different statistics on purpose — dx/dy are the medians of each component
(“which way, typically”), speed is the median of the magnitudes (“how fast,
typically”). A shot that drifts left as often as right has dx ≈ 0 and a
speed well above zero, and only the second answers “is this moving”. A reader
who assumed the identity could not reproduce the classification from the row.
Found by an independent read-only audit of 1.4.0 (2026-09-05): R1, R10.
Four ways of declining to answer were read as the model saying “no”.
parse_gate_answer prefix-matched NO, so Not sure, None, Nope and
NOTE: unclear all came back False — and a False is stored as a confident
UNKNOWN carrying the current fingerprint, which means the frame is never
asked again. Under the gate’s four-token budget “Not sure” is a reply the
model actually gives. Now matched as a whole word, so those four fall
through to the classifier while no., **No** and No person, but a dog
still answer. Anchored rather than searched, deliberately: there is no
ambiguity, yes contains both words.
A run with --no-gate stamped the fingerprint of a procedure it never
ran. The hash covered both prompts, so a classification-only run claimed
the gate had asked. v17 stopped two prompts sharing a column; this stops
two procedures sharing one. --no-gate now has its own fingerprint and
both count as current — the gated hash is unchanged, so the library’s
827 existing rows stay current instead of all going stale at once.
db health prescribed a command that cannot clear the row it appears on.
Interviews are skipped by default, so shot-size <video> on a talking_head
clip returns having done nothing — and those are the clips most likely to be
holding old labels. The fix line now names --force.
A label whose keyframe no longer exists was counted as a live reading.
analyze --force-keyframes moves the timestamps a label hangs off, and
nothing ever asks about the old one again: drop_superseded_label matches
t_sec exactly. The orphan then kept its clip on the re-run list forever,
counted in analysable, and — because sizes match a shot by span — was
what the inspector showed.
🔴 The obvious fix was wrong and the repo’s own test caught it. Deleting
orphans contradicts a decision written down here: labels hang off timestamps
rather than shot ids precisely so re-analysis cannot destroy them, and
test_a_refusal_leaves_labels_at_other_timestamps_alone guards it. The first
version of this fix deleted them and turned that test red. Unreachable is
not the same as wrong. So the rows stay, and the three readers that were
treating them as live were fixed instead: stale_videos no longer prescribes
a re-run that cannot reach them, analysable counts only frames that exist,
and the inspector prefers a live label over an orphan in the same span.
db health reports them on their own row with no fix offered, because
there is nothing to run.
model is not in the uniqueness key, so a
second model under the same source replaces the first. That behaviour is
correct — one asker changing its mind is not two claims — but it is the
opposite of what was documented, and of what
test_two_models_coexist_instead_of_overwriting was named after. The test
compared two sources, never two models. Renamed, and the missing case
added.shot-size reports an orphaned count. Counted, never deleted.A model that answered nothing was deleting labels. shot-size read every
HTTP 200 from ollama as text. ollama returns 200 carrying its own error
envelope, 200 with an empty string, and 200 with nothing but reasoning the
token budget cut off before any verdict — none of which is an answer. The
caller parsed the non-answer, failed, and reported REFUSAL_UNPARSEABLE:
the model answered and broke the vocabulary. That is the one verdict
allowed to retire a stored label, so drop_superseded_label ran and the row
went. Measured 2026-09-05: qwen3-vl:8b, which wrote 2593 of this
library’s vision descriptions, returns an empty string for both the gate and
the classifier — so re-running shot-size with it after any prompt change
would have emptied the table rather than refilled it. Nothing was lost yet
only because all 827 stored labels still carry the current fingerprint.
A non-answer is now REFUSAL_INCONCLUSIVE and leaves stored rows alone,
alongside unreachable. Two boundaries came with it, because the useful
distinction is narrow and easy to overshoot in either direction:
done_reason == "length" on its own is not a failure —
a four-token budget holds MCU — and rejecting every truncated reply
would throw away good answers to avoid bad ones.EUC at temperature 0 with done_reason == "stop" is still
REFUSAL_UNPARSEABLE. The model answered; it will answer identically
forever; the retired row still goes.The vision path had already learned this in 1.4.0 (“a 200 response with no
response field … is not a generate response at all”). label_shots had
not, which is the more useful half of the finding: a lesson written into
the changelog had not crossed to the module three files away.
A keyframe whose file vanished mid-run took its label with it. missing
is checked before the model is asked, so reaching the file reader at all is a
race — and that race landed in refused, which drops. Same asymmetry, third
face: only a verdict may retire a row.
The gate and the classifier stored side by side, so a re-run never
converged. They are two questions in one asking, but they wrote under
different source values — and source is part of the unique key, so
neither replaced the other. A frame answered by the gate on one run and the
classifier on the next ended up holding both rows. Three consequences, each
reproduced:
stale_videos, because one of its two rows always
carried the retired prompt. This is precisely what #105 (“make a re-run
converge”) claimed to fix; it converges within a source and never
noticed the case across two.analysable counted one frame as two — and the caller prints that number
as %d/%d frame(s), so a library with a gate pass reported a denominator
twice its own size.gate sorts
before vlm: a stale UNKNOWN covered a fresh CU.A model row now replaces the other model source at the same frame: one
frame, one model verdict. supplied is deliberately not part of that —
a person’s correction and a model’s guess are different kinds of claim and
still sit side by side. Only the two halves of one asking collapse into each
other.
A hand-supplied size outranked the model by alphabetical accident. The
rule was in the code (or row["source"] == "supplied") and the test passed
without it: 'supplied' sorts before 'vlm', so it landed first and the
model row never overwrote it either way. 'gate' does not sort before
'supplied' — so the first time a gated UNKNOWN and a human correction
shared a frame, the UNKNOWN would have won. Precedence is now stated rather
than read off the sort order, and the test that pins it uses a gate row,
which is the only arrangement that asks the question.
A zoom was reported as a locked-off camera, with high confidence.
motion read motion_x/motion_y and took their median, which measures
translation and nothing else. The vector field of a push-in is radial and
the field of a rotation is tangential, and both have a median of exactly
(0, 0) — so a shot that ended 45% tighter came back as speed 0.00, agreement 0.93 and was classified static. Not unknown: the wrong class,
carrying a confidence number. Punch-in and Ken Burns are among the most
common moves in short-form video, so STATIC 1851/3468 (53%) could not be
read at all — nobody knew how much of it was zoom.
The module’s own docstring said it was using “positions and motions, which is what makes the two cases separable”. It had never read a position.
Each frame is now fitted with a similarity transform — translation, scale, rotation — over the block displacements. Measured on synthetic clips, through the shipped code path and a storage round-trip:
| clip | before | after |
|---|---|---|
| 45% push-in | static | camera_moves, zoom +7.7% |
| 200% push-in | still_subject_moves | camera_moves, zoom +72.6% |
| 51.6° rotation | static | camera_moves, rotation +14.2° |
| 80 px/s pan | camera_moves | camera_moves, zoom +0.0% |
| locked off | static | static |
| subject over 70% of frame | static | static |
Two decisions inside the fit, each taken against a measurement rather than a preference:
still_subject_moves, fitted a translation of 2.50 and would have been
reported as a camera move. Growing, because a zoom’s blocks disagree with
the median by construction, so seeding alone would discard the signal
being looked for.⚠️ The magnitudes are a floor, not a reading. A 45% push-in measures +7.7% and a 200% one +72.6%: the encoder quantises a sub-pixel displacement to a zero vector, and seeding costs more of it. The number answers “did the frame transform beyond translation, and roughly how much”, not “by exactly how much”. Seeding was chosen knowing it costs magnitude, because a fit that a moving subject can pull is the worse failure — it turns a still camera into a camera move, which is a claim about the filmmaker.
Schema v19: shot_motion gains zoom and rotation. Rows written
before it get NULL, not 0.0 — a zero would claim the shot was checked for
zoom and found to have none, which is a different statement from not having
looked. dx/dy keep the codec’s direction, unchanged, so v18 and v19 rows
stay comparable; only the new columns use the reader’s convention, where a
positive zoom means the frame got tighter.
frame_motion() yields six values per frame instead of four, and
classify() takes the shot’s total zoom and rotation as optional arguments.
agreement now means “the share of blocks the fit explains” rather than “the
share matching one translation” — which is why a fast push-in stops reading
as handheld.
dx/dy arrive from a least squares solve rather than a median, so they
are no longer bit-exact: 4.0 comes back as 3.999999999999997.
ZOOM_FLOOR (3%) and ROTATION_FLOOR (3°) are totals across the shot,
not per frame — half a second at the same rate changes no framing a viewer
would call a move. Both are set from four synthetic clips, two true and two
false: a thinner base than SPEED_FLOOR, which sits on a measured hard zero,
and stated so the next person can widen them rather than trust them.
shot-size reports a collapsed count, non-zero only on the first run
after this change: a silent DELETE is indistinguishable from data that was
never there. One shot-size <video> run cleans that clip’s duplicates,
since every frame it visits is rewritten.
shot-size gained an inconclusive count in its tally, reported separately
from refused and unreachable for the reason those two were split in the
first place: the operator needs to know which run is worth repeating, and
the three causes have three different answers.
Every one of these was found by a single independent read-only audit of 1.4.0 on 2026-09-05 (findings B1, B2, B3 and mutations M21, M42). Each fix ships with the mutation that proves its guard has teeth: reverting it turns the new tests red, and before the fixes the same mutations turned nothing red at all.
Six merged changes reached 1.4.0 with no entry. Found by an independent audit
(R9) and confirmed against git log --first-parent v1.3.1..v1.4.0, 60 merges.
Written now rather than left out: a changelog with holes is worse than a short
one, because a reader cannot tell which it is.
mark — moments on a clip’s timeline, shown alongside it in the
inspector (#70). Added clip_marks, schema v13. The one whose absence
mattered most: it introduced a table, and a schema step with no changelog
entry is a step nobody knows to look for.stats reports evidence coverage (#85, #87), so a newly landed
extraction layer is measured in the same round it ships: shots coverage was
3 of 116 when it was first looked at.RwyLahUuGcc) held
0.0 on all four dimensions with status='analyzed'. It was not a weak video:
keyframe extraction had produced zero frames, so the merge stage had no
visual layer, returned unparseable output, and stored an analysis whose entire
body was {"summary": "{\n", "topics": [], "error": "failed to parse JSON"}.
The scorer then ran on that carcass — its own reasoning field reads
“impossible to score the video accurately” — and the pipeline wrote that
refusal down as 0.0. Three stages each manufactured a plausible artifact from
nothing, and a NULL would have been visibly missing where a 0.0 silently
averages. One fake row in 111 was holding down the floor of every dimension in
stats: min overall read 0.0, when the corpus floor is really 3.05.
The pipeline now checks, at the single point where keyframe extraction has
definitively been attempted, for keyframes == 0 alongside a non-empty
transcript — speech proves the media decoded, so zero frames is a
contradiction rather than a hard-to-sample clip. Such a video is marked
status='invalid' with the measurements as its reason, and the run stops
before vision, merge and score can invent anything. stats and patterns
exclude invalid rows and report the excluded count rather than quietly
shrinking. scorer.score_video refuses a video already marked invalid, so the
number cannot come back by a second route. The all-zero score is deliberately
not part of the test: a genuinely terrible video still has frames, and
marking real work invalid because the score looked bad would be worse than the
defect. reel-scout db check-invalid [--apply] audits an existing library;
it reports by default, marks only when asked, and never deletes.stats averaged two rulers into one number. A craft score is only
meaningful against the model that produced it — the same clip scores 7.43
under qwen3-vl:8b and 5.5 under qwen2.5vl:7b — and ingest stamps every
agent-supplied row agent:<model> precisely so the origin survives. stats
then ignored that column entirely: a library holding agent-scored and
locally-scored videos reported a single blended mean, and the blend can land
in a gap where no video sits. Six videos scoring 8.5/8.0/8.3 (agent) and
5.0/5.5/5.2 (local) reported overall 6.8 / 5.0-8.5 (n=6) — more than a full
point away from every video in the corpus, with nothing on screen to say two
scales were in play. Score aggregates are now grouped by model_used
(score_aggregates_by_model, score_sources), and the pooled block is kept
but labelled as pooled whenever mixed_score_sources is true, in the table,
in --json, and in --csv. Grouping is on the exact model string, the finest
grain that is correct — two local VLMs are no more comparable to each other
than an agent is to either. Per-video reads and the existing score,* CSV
rows are byte-for-byte unchanged.compare put two scores side by side without saying which ruler each came
from. Same defect as above, one surface over: the comparison table is the
single place a reader is most likely to turn two numbers into “A beats B”,
and two clips scored by different models cannot support that reading at all.
The table now carries a Score source row, directly above the numbers it
qualifies — the same placement and the same reasoning as the Transcript row
added earlier. A score written before provenance existed reads as an em dash,
which is “scored, origin unrecorded” and deliberately not the same as having
no score.DATA_DIR defaults to ./data — relative to whatever cwd the process started
in — so rows written from the repo root recorded ./data/videos/x.mp4 while
rows written anywhere else recorded an absolute path. A live database holds
both. analyze checked the stored path with a bare os.path.exists(), so run
from any other directory it concluded the file was missing and downloaded it
again; resolve_video_file looked cwd-relative first and then fell back to
VIDEOS_DIR, which is itself cwd-relative, so both of its candidates missed
too. Resolution is now anchored to the data root and tries every shape a
stored path can have — portable, legacy, cwd, and basename-under-VIDEOS_DIR
for files that moved between data directories. reel-scout db normalize-paths [--dry-run] rewrites existing rows to the portable form; rows whose path
cannot be resolved are reported and deliberately left alone, because
rewriting a path you cannot verify turns a recoverable row into a confidently
wrong one.LLM_TIMEOUT, LLM_MAX_RETRIES and LLM_RETRY_BACKOFF are now environment
variables and appear in config show. Retries are for timeouts only — a
malformed request or a missing model fails identically on attempt three, so
retrying those would just delay the same error by timeout × retries./ chain in -f degrades while choosing a
format; once a format is chosen and the transfer itself dies, yt-dlp does not
walk to the next selector — it fails, and the clip never enters the library.
Seen in the wild on E8Bx9OlpmdM, where the separate video stream 403’d while
the same video downloaded fine as progressive 18. The fallback therefore has
to live at the download layer, not in the selector string: if the quality-first
attempt produces no file, a progressive format is tried before the failure is
raised. Progressive formats come from a different URL than the split streams,
which is exactly why they survive when the split ones do not. Quality-first
stays first; this is the “something beats nothing” floor, the same trade the
existing chain already makes for AV1.DB_PATH is cwd-relative, so running from the wrong directory produced a fresh
empty database that then reported zero pending work — indistinguishable from a
library that is fully up to date. Three separate incidents traced back to this.
init_db now prints a one-line notice with the absolute path when it
creates the file, which is the piece that makes the mistake visible: the path
is either the one you expected or obviously not. Creating on demand is kept
deliberately — raising instead was checked and rejected, because 40+ call sites
and first-run setup all depend on it, so the fix is to remove the silence, not
the behaviour.這 returns the first 83% and
reports nothing wrong. save_transcript now scans for script mixing and warns
with the position of the flip. It reports and does not convert — converting
requires first deciding which script the library standardises on, and it would
add a dependency the core install deliberately does not have. The detector’s
character sets exclude 後/后 and 麼/么, whose “simplified” forms are also real
traditional characters (皇后, 幺么); a detector that fires on clean text teaches
people to ignore it. Validated against the library: 18/18 mixed files found,
zero false positives.INSERT. cli.py reconfigures both streams to UTF-8, but it is
the CLI entry point — mcp/server.py never runs it, and the MCP server is
exactly how the Windows students reach this package, on the cp950/cp1252/cp437
consoles this repo has already been bitten by once. On those, the print raised
UnicodeEncodeError and the transcript was never stored: a detector that
deleted the data it was watching. Two independent fixes, because one of them
being enough is not a reason to leave the other broken. utils.stderr.warn
degrades characters the console cannot encode and never raises at its caller —
whether the operator sees a line is not worth the caller’s work. And the scan
now runs before the write while the report happens after it, so even a failure
warn deliberately does not absorb costs nothing but the message.yt_<id>.mp4.part and yt-dlp resumes partials by
default, so a first selector that picked a progressive format and died
mid-transfer left bytes that the second attempt would extend with a Range
request against a different stream. Nothing validates that the two halves
came from the same video, and the result is a corrupt file that exists — which
the caller’s os.path.exists check reads as success, the exact failure this
release is about. The fallback now passes --no-continue.response field was returned as an empty string. An
empty response is legitimate — the model produced nothing. A missing one
means the reply is not a generate response at all: an error envelope, a proxy
in front of Ollama, a schema that moved. Both arrived at the merger as an empty
result that read as a successful call. The missing case now says so. It is not
raised, because a compatible backend may legitimately differ and a hard error
would break a setup that works today.crawl/ytdlp.py
fixed this for yt-dlp, but the audio-extraction and whisper.cpp paths kept a
blind stderr[:300] / [:500]. Both print banners and progress first and the
cause last, so the head shown was reliably the part carrying no information —
an ffmpeg permission error sat past the cut behind five lines of build
configuration. Both now report error-looking lines, falling back to the tail.batch and analyze
printed an explanation and returned None, so the dispatcher’s
if code: raise SystemExit(code) never fired and a wrapper reading $? was
told the work was done. analyze also printed Batch <id> completed. on a run
where the only item had errored on the line above — “completed” on its own
reads as “worked”, and it now says completed with N/M failed., the same
distinction the MCP surface draws with completed_with_failures. A Ctrl-C is
deliberately still not counted: it leaves work undone too, but the operator
pressed it and the screen says so.fallback '<model>' unavailable. On a
run whose backend was not ollama the first condition already settled it and the
availability probe was never called — so the line named a missing model that
was installed and answering, and acting on it means installing what is already
there. Each reason now says its own name, and the “not installed” one says
which host it looked at so the claim can be checked. The probe stays last,
because it is a network call and a cheaper reason usually settles the question.main() in
cli.py called it; the MCP server did not, so a description carrying a
character the console could not encode took the server down instead of the
frame. It now runs on both, and the warning path degrades unencodable
characters rather than raising inside the warning itself..unusable, never deleted,
.vtt siblings moved with them) and success is judged on both the return code
and the file actually being there; the format fallback passes --no-continue
so it cannot resume a different format’s partial file.scene strategy,
the default). The cause was in the ffmpeg pass that detects scenes, not the
one that cuts them: -frames:v N makes ffmpeg exit after N outputs, so
detection stopped early and everything after that point was invisible to the
sampler. Measured before the change, 27 clips had a single unsampled gap larger
than half their length; measured after, across a library that had grown to 110,
one — and that one is a 22-second clip whose first 13 seconds are a single
unchanging shot, so there is nothing there to sample. Mean largest gap went
from 35.6% of clip length to 17.3%. Detection now runs unbounded and writes no
images at all (-f null -); the second pass seeks to each selected timestamp.
Selection
spreads across the time axis rather than the ordinal one, so a clip whose cuts
cluster at the front no longer spends its whole budget there. The motion
strategy carried the same defect until the entry below.motion keyframes collapsed onto the opening quarter-second, and the
timestamps written beside them were invented. Two defects, compounding.
-frames:v max_frames capped the emitting pass, and mpdecimate emits across
the whole file — measured, 405 of a 13.7-second reel’s ~411 frames survive it
— so the cap always fired inside the first second and the rest of the clip was
never decoded. Underneath that, setpts=N/FRAME_RATE/TB sat before
showinfo, so the pts_time being parsed was the surviving frame’s ordinal
over the frame rate rather than its position in the video: on a clip built as
5s frozen plus 5s moving, the old chain reported 0.00–0.27s for frames that
actually came from 0.00s and 5.00–5.20s. Every motion keyframe ever written
therefore carried a timestamp that was fiction, and anything aligned to one —
OCR spans, marks, the inspector timeline — inherited it. Same two-pass
restructure as scene: detection runs unbounded, writes no images (-f null -) and no longer rewrites the clock, then select_spread picks across the
time axis and a second pass seeks to each chosen timestamp. Measured on four
library clips at a budget of 8, the sampled span went from 0.6–2.4% of clip
length to 99.3–99.8%. The unbounded pass costs real time — a 13.7s reel went
from 0.12s to 2.28s, a 37.5s reel from 0.19s to 4.48s, and a 22-minute clip
takes 37.5s — so detection now shares scene’s duration-scaled timeout
(renamed detect_timeout), and an overrun falls through to interval sampling
instead of ending the run with nothing.text=True without encoding decodes with the locale’s
encoding, and ffmpeg echoes the input path and its metadata into the same
stderr the timestamp parser reads — so a CJK filename under a cp950 or C
console killed the extraction with a UnicodeDecodeError that named nothing
relevant. All three now decode UTF-8 explicitly and leniently: what is parsed
out of that stream is ASCII digits, so a replacement character in a banner
line is cosmetic, whereas a strict decode would turn it into a crash.done_reason: "length"
with zero characters, which downstream is indistinguishable from a frame with
nothing to say. Reasoning length varies far more than answer length — usually
around 440 tokens, with a tail well past 1200 — so raising the default further
is racing a distribution with no upper bound. A frame that hit the ceiling now
gets one retry at a larger budget; a frame that finished on its own does not,
because retrying it returns the same thing more slowly. think: false was
measured and does not work here (ollama 0.30.7 + qwen3-vl:8b keeps reasoning
and leaks a raw <think> tag). The warning that fires when even the retry came
back empty says how far it already got, so nobody raises a number twice.batch.py ran its child steps with no timeout at all, so a stuck ffmpeg or a
model that never answered held every remaining clip behind it — and the run
looked busy the whole time, which is the worst shape a stall can take. Each
step now has its own deadline and the batch has one overall: analyze at
1800s (just under the 1815s worst case a single merge can spend burning its
full retry budget — a call that reaches that number is the pathology),
export at 300s, and score derived from LLM_TIMEOUT rather than fixed,
because a subprocess kill that pre-empts the backend’s own error path throws
away the diagnosis the retry existed to produce.incomplete is now its own state, separate from completed (the list was not
finished), failed (the worker did its job) and completed_with_failures
(nothing failed). Folding it into any of those asks the reader either to
re-run URLs that are fine or to assume URLs are fine when nobody looked at
them; the payload now names which were never attempted.killpg on a process that was never detached reaches the whole tree
including the runner. The kill now resolves which group it may signal and
refuses to touch its own. (This was found by mutation testing, and the
mutation that found it took down the tool chain that was running it — the
file stayed mutated, so every later run silently tested code nobody wrote.
The runner now restores on startup as well as in finally.)video_annotations,
annotation_groups; schema v11), never as columns on videos. Everything else
in the database is derived from the source clip and is safe to regenerate;
these rows are the operator’s judgement. Re-crawling, re-analyzing and
re-scoring rewrite the pipeline’s output and cannot touch a note — there is a
test that holds that line. Deleting a group clears the filing and keeps every
note and star.POST is accepted only on the
annotation endpoints, bodies over 64 KB are refused before being read, and
everything the pipeline produced remains read-only over HTTP.Shot sizes, and a column that remembers which prompt produced them
(shot-size, schema v15/v17). Seven codes sourced from
prompts/storyboard-visualize.md, one label per shot’s representative frame.
The prompt’s fingerprint is stored beside each label because a prompt change
moved the answer ten-to-one on the same images: listing the codes with their
English names returned ECU ten times where defining each code by
subject-to-frame ratio returned it once. db health reports labels made by a
superseded prompt and names the command that refreshes them.
A gate that asks whether the question applies before asking it. A binary
“is this a single photographic shot with a person as its subject” pass runs
first; frames it rejects are stored as UNKNOWN rather than given a code.
Measured on 12 hand-judged frames: 2/12 correct without the gate, 10/12 with
it. It rejects roughly 70% of this corpus, and it adds ~13% to the pass rather
than doubling it, because everything rejected skips the classifier.
--no-gate restores the old behaviour.
analysable, reported next to the codes and never without them. Of 94
labelled clips, 40 have no frame the vocabulary applies to — they are screen
recordings, title cards and composite layouts. Showing codes alone would
present those forty as confidently as the two that scored 100%.
Camera movement per shot, read from the codec’s own motion vectors
(motion, schema v18, new [motion] extra). Four states from two numbers —
median block motion and the share of blocks that agree with it: the camera is
locked off, the camera holds while something inside the frame moves, the
camera moves coherently, or the motion is incoherent (handheld, compositing).
Across 3,468 shots: 53% static, 8% unsteady, 7% still-with-subject-motion,
2.5% coherent camera moves, the rest too short to say.
Direction is stored but deliberately not named: across 585 hand-scanned shots
only seven were coherent moves, and seven is not enough to earn a vocabulary.
A Chinese mirror for free text (translate, schema v16), and the
inspector renders both languages side by side. Translations record the hash of
the source they were made from, so an edited source shows as stale rather than
silently keeping an old translation.
db health — one command that says what is measurable, what is missing,
and which command closes each gap. Counts, never percentages; “cannot” is
separated from “not yet”; --strict fails only on gaps something on this
machine can actually close.
db backfill-shots for clips analysed before the shots table existed,
and export --format storyboard with storyboard-diff for the return
trip.
A shot-grammar block in the web inspector — per-shot size and movement with the numbers the movement class came from, plus the analysable ratio.
Keyframe extraction is a run with an identity, so re-sampling is possible
without destroying the evidence it replaces. Frames used to be tied to the
clip alone, which made “extract again with different settings” either
impossible or a deletion. A run (keyframe_runs, schema v12) owns its frames
and its descriptions; a new run supersedes the previous one instead of
overwriting it, everything downstream reads the current run, and the old frames
and their descriptions stay on disk to compare against. --force-keyframes
asks for a new run explicitly. Frames are committed only after they have landed,
so an interrupted extraction cannot leave a run pointing at files that are not
there.
retry_call — the retry helper had a decorator with no call sites and no
way to say which failures are worth retrying. It now takes a should_retry
predicate and an on_retry hook, and the decorator delegates to it. (It also
raised None when configured with zero attempts.)
Near-duplicate keyframes are dropped instead of described. frame_cap
decides how many frames a clip may spend; this decides how many it actually
needs. They fix different defects — the cap fixed “long clips sampled too
sparsely”, this fixes “consecutive samples look identical and each one still
costs a local VLM call”.
Nothing is topped up. A version that backfilled to the cap would cost exactly what it cost before, and would also hide whether the pass works at all, because the count would always equal the cap. Coming in under budget is the point.
Two guards stop this re-creating the sparse-timeline defect the cap exists to
fix. A frame is only ever dropped while the previous kept frame is within
KEYFRAME_DEDUPE_MAX_GAP_SEC (120s) — two identical-looking frames seventeen
minutes apart are information (“nothing changed for seventeen minutes”), two
seconds apart is noise. And KEYFRAME_DEDUPE_MIN (4) floors the count so a
static short clip cannot collapse to a single frame. First and last are never
dropped, and a frame ffmpeg cannot read is never dropped either.
The hash is a 64-bit dHash computed through ffmpeg, not Pillow: ffmpeg is
already a hard requirement, Pillow is only in the ocr extra, and a dedupe
pass is not worth promoting an optional dependency to a core one. One extra
ffmpeg call per frame, milliseconds each, against the seconds a VLM call
costs — removing one frame pays for the whole pass.
Measured on the real 100-video library: 1,202 → 1,148 frames, 54 VLM calls avoided (4.5%), 22 videos affected. That is deliberately modest. The default distance threshold (4 of 64 bits) only removes frames a person would call the same frame, and this library is mostly fast-cut short-form, where consecutive keyframes genuinely differ. The one long clip in the set (52 min) dropped 6 of 40. Expect the saving to grow with long static footage — lectures, interviews, streams — which is exactly where a flat cap wastes the most.
Off with KEYFRAME_DEDUPE=0; KEYFRAME_DEDUPE_DISTANCE loosens or tightens
it. reel-scout config prints all four values.
Long clips get a sampling rate, not the same twelve frames a reel gets. The
keyframe cap was one flat number, so a 9-second reel and an 82-minute interview
drew the same budget — the reel sampled about once a second, the interview once
every seven minutes. Downstream that produced a timeline whose single segment
covered 96% of the clip: technically produced, practically useless. The cap now
stays flat up to KEYFRAME_LONG_SEC (180s — short-form behaviour is unchanged,
bit for bit) and above it earns KEYFRAME_PER_MIN frames a minute up to
KEYFRAME_MAX_LONG (40). The ceiling is the point: 82 minutes asks for 164
frames at 2/min and is told 40. Each frame is still one local VLM call, so the
cost red line moved deliberately, not accidentally.
Delete a group from the page. A picker plus a button in the toolbar, rather than an X inside each row’s dropdown — a group is a library-wide object, and its destructor does not belong inside a per-video control. Each option carries its row count as the warning, and deleting still keeps every note and star.
Your own layer on the library: a note, a group, a star. The served list
(reel-scout view) is now a table, because it carries per-row controls a list
of links had nowhere to put: a star to mark what is worth coming back to,
a group dropdown you define yourself (add / rename / delete), and a free-text
note for what a clip is for. The star in the table header is the filter —
press it to show only what you marked. Notes save as you type; the group and the
star save on the spot.
The pipeline decodes what a video is; none of it knows what you intend to do with it, and that intent is the part worth typing by hand.
Same three fields from the CLI (reel-scout note <ref> --text … --group … --star,
reel-scout group list|add|rename|rm) and over MCP (annotate,
list_annotations), all going through one operations module so the rules —
group names unique case-insensitively, notes rejected rather than truncated —
are enforced once instead of three times, slightly differently.
classify_with_ollama returns (code, refusal) instead of bare None.
One value used to cover four causes — an unreadable image, ollama being
unreachable, malformed JSON, and a model answering outside the vocabulary —
and the caller counted all four as “refused”. Only the last is deterministic;
an outage is emphatically worth retrying. The split is what makes it safe to
act on a refusal at all.
Schema v13 → v18, applied by the existing migration ladder: shots (v14),
shot_labels (v15), translations (v16), shot_labels.prompt_hash (v17),
shot_motion (v18). Every one is additive.
⚠️ v13 is where master was, not where a PyPI user is. The previous
release on PyPI, 1.3.1, shipped SCHEMA_VERSION = 10, so an upgrade from
the published version runs v10 → v18. Both paths were tested on real
backups, but the range in this line describes the repo’s history rather
than anyone’s upgrade.
🔴 Shot sizes are a signal, not ground truth, and the ceiling is not the
model. Same 12 frames, same prompt: qwen2.5vl:7b 2/12, gemma3:27b 1/12,
GPT-5.5 2/12 — three tiers, the same score, wrong on the same frames. A third
of this corpus has no person in it at all, and the seven codes are defined by
where a human body is cut, so those frames have no correct answer to pick.
Spelling the exceptions out inside the seven-way prompt made it worse
(2/12 → 0/12) and three times slower; the same rules as a binary question
score 10/12. The published literature puts subject-centric models at ~88% on
curated film material and motion-vector methods at ~52%, which is why movement
reads from motion vectors and scale does not.
⚠️ Movement tolerates the codec’s frame-type rhythm rather than correcting for it: adjacent frames of one shot measured 2% and 55% moving blocks, and a median over a strictly alternating series is its mean. The thresholds were set on data carrying the same rhythm.
show_video no longer hands an agent a whole transcript timeline it did not
ask for. Measured across the 101-video library with the same serializer both
sides, one call used to return up to 288,359 tokens — a four-hour clip whose
5,808 timed segments dwarfed everything else in the payload. Three things
changed, and each one keeps what it trims reachable rather than dropping it:
segments are opt-in. They are gone from the default response;
has_segments and segment_count take their place, so an agent still knows
they exist and can pass include_segments: true when it actually needs to cut
on a timecode. The flat text_full still comes free. When segments are
returned, confidence is rounded to 3 dp — whisper emits the full float repr
(-0.1858760386370541), which is roughly twenty characters of noise per
segment for a number nobody reads past the second decimal.analysis.full is de-duplicated against its own projections. It used to
ship whole while summary / topics / hooks / style /
engagement_signals sat beside it as separate keys — the same content twice.
Only those five keys are stripped: timeline, content_type,
content_structure and measured live nowhere else and still ship. (hook
was verified byte-equal to hooks_json on all 99 analysed rows before it went
on the strip list; it is not there because the name looked similar.)keyframes is capped at 12 by default, with keyframes_total and
keyframes_truncated reporting what happened and max_keyframes: 0 lifting
the cap entirely.Result on the same library: total 1,098,903 → 428,460 tokens, p90 8,467 → 4,459, worst case 288,359 → 50,596.
⚠️ This changes a default over MCP. Anything that read
show_video(...)["transcript"]["segments"] without passing include_segments
will now find the key absent — check has_segments and ask for them.
en and zh-Hant dictionaries in the page, so EN / 中文 is an instant
client swap with nothing to fetch — a bundle stays bilingual offline. It follows
the browser’s language on first load and remembers the choice. The line held
everywhere: only interface chrome carries a data-i18n key; the model’s own
output — reasoning, transcript, decoded-structure values like educational,
OCR text — is never touched, because translating it would mean silently
re-running the model. One reel_scout/i18n.py is the single source both pages
read, so they cannot drift.overall
recomputes live against the four stored dimensions, showing your result beside
the stored default. The dimensions themselves never move — they come from the
model, and the rubric behind them is prose, not a tunable threshold — so the
honest thing to expose is the blend, and the panel says so. Weights renormalize
to sum to 100 % so the number can’t leave the 0–10 axis. The weights used to live
in three hand-kept copies; they now collapse to one config.SCORE_WEIGHTS that
both the scorer prompt and the recompute read.list_videos, show_video, get_transcript, and a keyframes
tool so an agent with no filesystem can still see the frames) and the write side
(ingest vision/score/analysis, a background batch, and inspect). reel-scout mcp install / mcp path register the server in the client’s JSON without
hand-editing it.ingest analysis — the third thing only a model can give you. merge_analysis
needs a reachable LLM; without one it fails with a connection error and the
analyses row is never written, so the 4-beat timeline, hook type and CTA type —
most of the point — are silently absent. An agent can now supply that structure in
the merge prompt’s own shape. The low-cardinality fields are validated as enums,
because they become columns stats and patterns group on and an invented value
adds a one-member category to every aggregate. Provenance rides in full_json
as _source.batch — a doc full of links, one bundle each. Point it at a Google
Doc/Sheet (or a file, or stdin) and every IG/TikTok/Shorts link in it gets
analyzed and exported. /edit URLs are rewritten to Google’s export endpoints,
so “anyone with the link” sharing is enough — no API key, no OAuth, no publish
step. --dry-run shows what was parsed before anything runs. It picks nothing
for you: a reachable VLM makes --mode full unambiguous, but without one it
stops and presents agent / transcript / full rather than quietly shipping
transcript-only bundles with the craft score missing. In agent mode it ends by
listing the videos still needing a visual layer with the ingest command for
each. Entries are paired to videos by set difference, never by URL equality —
shared links carry tracking parameters, and giving one person’s analysis to
another is worse than producing one bundle fewer.skill install — the skill now ships with the package. Measured on a clean
venv: pip install reel-scout produced a working CLI and none of SKILL.md,
commands/scout.md, prompts/ or scripts/setup.py, so an agent had nothing to
load and /scout did not exist. Those assets are vendored into the wheel and
reel-scout skill install lays them down in ~/.claude/skills/reel-scout
(--dest, --force; skill path shows the source). A clone still installs from
the working tree rather than a stale snapshot. Full install is now two commands.ingest {vision,score} — an agent can be the backend. Keyframe extraction is
ffmpeg, not a model, so the frames are on disk before the VLM stage runs. On a
machine with no oMLX/ollama, an agent that can see images now supplies the
visual layer and the craft score itself and writes them back, so the result lands
in show / view / inspect / export instead of living in a chat log. No API
key, no cloud, no local model. Rows are stamped agent:<model> because craft
scores are model-dependent (7.43 vs 5.5 on the same clip across two VLMs), and
overall is recomputed with score’s own weights rather than trusted from input.
SKILL.md documents this as tier L1 between web-only (L0) and full local (L2).show now lists keyframes and the score. Frame ids, timestamps and paths,
with * marking frames that have no description yet — the ids are how anything
outside the process addresses a specific frame.view lands on the library, inspect <id> opens one clip.theme.py carries the brand tokens (warm paper, warm-black
ink, three-step rules, mono uppercase chrome) from the brand SSOT. Deviations are
narrated in-file: a wider tool column, and no cyan (canon caps it at the tv./
wordmark, which reel-scout doesn’t carry)./font/<file> live, inlined as base64 in
exports. CJK is subset per-export from that export’s own text.export --format bundle — the take-home: one self-contained HTML per reel
(video, keyframes, waveform peaks, fonts and a CJK subset all inlined) plus an
index. Move it, rename it, email it — nothing to lose and no server to run.
Verified: a bundled page issues exactly one network request, for itself.
Reels over --max-mb (default 25) are skipped with a reason.view served requests single-threaded, so one idle browser keep-alive could
stall the whole viewer. Now ThreadingHTTPServer.— with an empty bar. (Return-value parity tests could not
catch it; it was a DOM-state bug, found by driving the real sliders.)⚠️ Craft scores are not comparable across this boundary. §4E changes how
pacingis scored — it now reasons on measured cut rhythm instead of pure LLM judgment, so the same video can score differently than it did on 1.1.0. Re-score a video before comparing it with anything scored on an older version.
shots.py measures cut rhythm (cuts/min, shot
count, avg shot length) via a dedicated full-clip ffmpeg scene pass; audio/rhythm.py
adds RMS energy + best-effort BPM (numpy-gated, no librosa). Stored in the new
shot_metrics table and folded into the analysis so the pacing craft score
rests on measured evidence, not LLM vibes. Config SHOT_METRICS_ENABLED.ocr.py collects burned-in captions with
timestamps: OCR_ENGINE=vlm (default) reuses the VLM’s text_in_frame;
tesseract is an opt-in engine (ocr extra, guarded). Stored in ocr_captions,
fed into merge as an L3.5 signal layer; new L3.5 tier in the reliability cheatsheet.patterns --channel — per-channel pattern analysis: length, hook/CTA/structure
mix, top-vs-bottom-half structural contrast, posting cadence. (3B)inspire --based-on [--angle] — generate a fresh content variant (titles,
hook script, structure outline, length) from a high-scoring video. (4B)track --my-video --views --likes — record real performance and get
deterministic structural iteration suggestions vs the top-scored corpus. (4D)patterns, inspire, research (5 → 8 tools). (4C)shot_metrics, ocr_captions, performance tables).export --format html — a self-contained single-file HTML (keyframes
base64-embedded, all CSS inline, zero external assets) that opens in any
browser, works offline, and survives being moved. Built as a take-home
artifact for people who don’t install reel-scout. --video <id|prefix>
exports one video; otherwise all analyzed videos.reel-scout view — a local read-only HTTP server rendering the library
live (index → per-video pages, keyframes served by URL). --host/--port/ --no-open.
Both show each video’s decoded structure (hook/beats/CTA), keyframes + what
the VLM saw, craft scores, and transcript. Deliberately read-only — no action
surfaces; scores are labelled a reference, not an authority.db.get_keyframes_with_descriptions (keyframes ⟕ vision_descriptions).First stable release. Completes the Batch Intelligence, Content Strategy (4A), and Tool Hygiene milestones — the tool is now installable, CI-covered, and feature-complete for cross-video/-channel analysis.
stats — corpus statistics: tag distributions (content_type,
content_structure, format, pacing, hook/cta type, emotion) + craft-score
aggregates (avg/min/max), with --channel scoping, --json, and --csv
(roadmap 3D).research --niche --channels --depth — cross-channel competitor research:
lists each channel → analyzes → aggregates per channel and niche-wide → --out
renders an LLM markdown report (common patterns / differentiation / strategy),
falling back to a deterministic data-only report when no LLM is reachable
(roadmap 4A). --json emits the aggregate; --no-analyze reuses the DB.pip install-ready (roadmap 5A).config check now covers all configured backends: yt-dlp via the
resolved binary, LLM reachability keyed off LLM_BACKEND, and the optional
audio/diarize/instagram groups when enabled (roadmap 5B).reel_scout/__init__.py via hatchling dynamic
version (fixes the prior 0.2.0/0.3.0 drift).analyze <local-path> — the analyze pipeline now accepts a local video file,
not just a URL. Registers a platform="local" row (url == file_path == abspath,
platform_id = content hash, so identical content at two paths dedups) and runs
transcribe / vision / merge unchanged. This is the platform-lockout insurance:
when a yt-dlp extractor breaks, the core pipeline still runs on files you already
have. Duration is probed independently and stays None on probe failure (no
fabricated fallback written to the DB); a missing path raises a clear
FileNotFoundError instead of the crawler’s opaque “Unsupported platform”.compare <id1> <id2> ... — cross-video comparison table (duration, format,
pacing, hook/CTA type, content type, and the craft scores). Transposed table
plus --json; accepts an exact id or a unique prefix; missing analysis/score
renders as an em dash rather than a fabricated value. Pure DB read path — no
crawler, no LLM — so it also survives a platform lockout.YTDLP_BIN config (mirrors the FFMPEG_BIN convention).python -m yt_dlp) instead of whatever yt-dlp is first on PATH — a stale
PATH build silently produced baffling extractor errors. Override with
YTDLP_BIN. All three crawlers (youtube / tiktok / instagram) routed through
the new crawl/ytdlp.py helper.ERROR: lines are kept first
(instead of a blind stderr[:500] that buried them under leading warnings),
with a fallback to the stderr tail and an update hint when the failure looks
like a broken extractor.WHISPER_LANGUAGE, WHISPER_TASK,
WHISPER_MULTILINGUAL, WHISPER_CHUNK_LENGTH.
WHISPER_MULTILINGUAL=1 WHISPER_CHUNK_LENGTH=15.large-v3 “translates”
the guest’s English into garbled Chinese. Verified on a 40-min interview:
latin-char recovery 56% -> 90%.config check now surfaces the new WHISPER_* values.tests/test_transcribe.py pins the config -> transcribe() kwargs mapping.faster-whisper floor raised >=0.10.0 -> >=1.1.0 (the multilingual
transcribe arg the fix relies on was added in 1.1).