Skip to content

feat(cli): capture reports why a referenced asset is not in the folder - #3598

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/capture-drop-counts
Sep 3, 2026
Merged

feat(cli): capture reports why a referenced asset is not in the folder#3598
miguel-heygen merged 1 commit into
mainfrom
fix/capture-drop-counts

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

The problem

capture drops assets for four reasons and reports none of them.

Every drop site is a bare continue, break, return null, or an empty catch. The result is
that a capture of a spare page and a capture that a limit truncated produce the same summary:

  Assets: 30

There is no way, from the output, to tell whether the page had thirty images or three hundred. The
only existing signals were two hand-written warning strings that fire when the budget is already
gone before a download pass is called — which is the one case where the pass itself could not
have said how much it lost.

What changed

downloadAssets and downloadAndRewriteFonts return an AssetDropCounts tally beside their
result, incremented at the single line that performs each drop. Nothing re-derives a count from
anything else.

capture --json carries it as dropped. The human summary prints a Dropped: line when it is
non-zero:

◇  Captured Example → capture

  Screenshots: 12
  Assets: 232
  Dropped: 91 (39 size-floor, 20 cap-reached, 32 unavailable)
  Sections: 15

Four reasons — three of them decisions this code made, one a failure it hit, which is the split a
reader actually needs:

Reason Meaning Owning line
size-floor Fetched, then judged too small to be a real asset rather than a spacer or tracking pixel inline-SVG length gate, the 200/10 000 byte raster floor, the og-image floor
budget-exhausted The post-navigation clock ran out before this one was reached the favicon, image-batch, and font loop breaks
cap-reached A per-run or per-family limit was already met 30 inline SVGs, 30 fonts, 6 faces per family
unavailable The request or the write failed: network error, timeout, refused address, bad status, disk every fetchBuffer miss and every catch around a write

A break counts everything it did not reach, not the one it stopped on. "How many did we lose"
is the question, and one is never the answer.

The two warnings are gone, not duplicated

Both existed only to cover the case where the budget ran out before a pass was called. So both
passes are now called unconditionally: with a zero budget each loop breaks on its first item and
records budget-exhausted for the rest. That costs no network — the break happens before any
fetch — and produces a real number where the string could only say "some".

One warning remains, and it is derived from the tally rather than written alongside it, so the
prose and the count cannot drift apart the way two separately-authored strings could.

What I measured

A live capture of a large marketing site, same URL, same command, two budgets:

Run Kept Dropped
default budget 232 91 — 39 size-floor, 20 cap-reached, 32 unavailable
--capture-budget 15000 30 299 — 279 budget-exhausted, 20 cap-reached

Until now both runs described themselves identically. The 20 cap-reached in both is the same
fact seen twice: that page carries 50 inline SVGs and the per-run cap is 30.

Tests: 7 new cases through the public API of the two exported functions, expected values read off
the fixture (35 declared with a cap of 30 gives 5; 10 rules in one family with a per-family cap of
6 gives 4), never recomputed the way the code computes them. One of them is a control that asserts
an all-zero tally on a page whose fonts all downloaded, because that is the case the whole
field exists to make readable.

Each counter was proven non-vacuous by mutation: neutering the increments fails 5 of the 7, and
neutering the per-family increment alone fails exactly the per-family test.

bun run lint, oxfmt --check, and the pre-commit typecheck are all green.

What I did NOT exercise

  • Only the reasons above. I did not add counts for the classification filters earlier in
    downloadAssets (type !== Image, the pixel/beacon/analytics substring skip,
    !hasGoodContext). Those reject things that are not assets, so counting them would inflate the
    number the field exists to make trustworthy. Say the word if you want them under a fifth reason.
  • Three test files in src/capture/ fail to collect in a fresh worktree until
    @hyperframes/parsers is built. That is unrelated to this change and reproduces on origin/main
    (143 passing there, 150 here, same three files failing to collect both times).
  • Windows and the Lambda/Cloud Run paths: not run locally.

Capture drops assets for four reasons and reported none of them, so a folder
with thirty images and a folder truncated to thirty images were the same
object. Every drop site was a bare `continue`, `break`, `return null` or an
empty `catch`, and the only signals downstream were two hand-written warning
strings that fired when the budget was already gone before a download pass
started, which is the one case where the pass could not say how much it lost.

`downloadAssets` and `downloadAndRewriteFonts` now return an `AssetDropCounts`
tally beside their result, incremented at the single line that performs each
drop. `capture --json` carries it as `dropped`; the human summary prints a
`Dropped:` line when it is non-zero.

Four reasons, three decisions and one failure:

  size-floor        fetched, then judged too small to be a real asset
  budget-exhausted  the post-navigation clock ran out before this one
  cap-reached       30 inline SVGs, 30 fonts, or 6 faces per family
  unavailable       the request or the write failed

A break now counts everything it did not reach rather than the one it stopped
on, because "how many did we lose" is the question and one is never the answer.

The two budget warnings are gone. Both existed only to cover the case where
the budget ran out before a pass was called, so both passes are now called
unconditionally: a zero budget makes each loop break on its first item and
record `budget-exhausted` for the rest, which costs no network and produces a
real number instead of the word "some". The single remaining warning is derived
from the tally, so the prose and the count cannot disagree.

Measured on a live capture of a large marketing site:

  default budget    232 kept, 91 dropped (39 size-floor, 20 cap-reached,
                    32 unavailable)
  15s budget         30 kept, 299 dropped (279 budget-exhausted, 20 cap-reached)

Same page, same command, and until now both runs described themselves the same
way.
@mintlify

mintlify Bot commented Sep 2, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hyperframes 🟢 Ready View Preview Sep 2, 2026, 3:18 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

} else if (buffer.length <= 5000) {
drops["size-floor"]++;
} else {
writeFileSync(join(outputDir, localPath), buffer);

@jerrai-bot-heygen jerrai-bot-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at 460dba3117e. Verified first-hand against the head:

  • Every drop site in downloadAssets and downloadAndRewriteFonts increments exactly one reason at the line that performs the drop: SVG cap (pre-loop arithmetic) and size floor; favicon budget (remaining count), fetch failure and write failure; image batch budget (remaining count), null fetch, size floor, rejected promise, write failure; OG image budget / null / size; font budget and total cap (remaining counts), per-family cap, fetch and write failure. The fulfilled-null path is counted inside the mapper and deliberately not again in the results loop, so no double count.
  • Exits that are not counted are not drops of a distinct referenced asset: URL dedup (downloadedUrls), a favicon link with no href, the first-favicon-wins break, and the catalog's junk filter (pixel/beacon/analytics//favicon). Nit, non-blocking: a tracking pixel caught by that URL heuristic is uncounted while the same pixel caught by byte size is size-floor; if the heuristic ever needs to be visible, size-floor is the honest bucket.
  • Both passes now run with a spent budget so their loops can count budget-exhausted per item; the inline-SVG pass does no network, so a zero-budget run still writes up to 30 inline SVGs where before it wrote none. Intended per the comment, and cheap.
  • --json is additive: dropped always present with all four keys (zeros when clean), text line printed only when non-zero, reason strings and shape match docs/packages/cli.mdx exactly. CaptureResult.dropped is required, and Typecheck/Build are green so every constructor supplies it.
  • Tests reference noDrops/drop reasons that do not exist on main, so each fails there.
  • CodeQL js/http-to-file-access at assetDownloader.ts:291 is the OG-image write that existed on main (the block was restructured, not introduced); same class as the open alerts 806/599 on this file. Pre-existing by design for a downloader.

Stamp only; merge is yours (repo needs last-push approval).

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read assetDownloader.ts in full at 460dba3117e1c83079bf5c7662d3f38de97e5afa. The premise is right and the live numbers make it - default budget 232 kept / 91 dropped against 15s budget 30 kept / 299 dropped, two captures that described themselves identically until now, is exactly the case a bare continue cannot answer.

The taxonomy is the good part: three decisions and one failure, which is the split a reader actually needs. Deleting the two hand-written budget warnings and deriving the remaining one from the tally is the right follow-through - a warning computed separately from the thing it warns about is the next disagreement.

The counting is careful where it is easy to get wrong. Splitting rejected from a fulfilled null in the batch loop, with the comment saying why counting both would double it, is the specific bug this shape invites and it is handled. drops["budget-exhausted"] += toDownload.length - i on the batch break is right, and so is the ogImage rewrite from a collapsed boolean expression into three named outcomes.

The one count that is wrong: the favicon block is a search, not a download list

for (const [index, icon] of icons.entries()) {
  const remainingMs = options.remainingMs?.() ?? 10_000;
  if (remainingMs <= 0) { drops["budget-exhausted"] += icons.length - index; break; }
  if (!icon.href) continue;
  try {
    const buffer = await fetchBuffer(icon.href, Math.min(10_000, remainingMs));
    if (buffer) { writeFileSync(...); assets.push(...); break; }
    drops.unavailable++;
  } catch { drops.unavailable++; }
}

This loop breaks on the FIRST success, so the entries are alternate candidates for one favicon, not a list of assets to fetch. Both counters read them as the latter:

  • A page declaring favicon.ico, apple-touch-icon.png and icon-192.png where the first two 404 and the third works reports unavailable: 2 with the favicon successfully captured. Nothing was dropped; two candidates were tried.
  • The budget break counts every remaining candidate as a separate loss, when at most one favicon was ever going to land.

The docblock's rule - "every member is counted at the single line that performs the drop, so a count can never disagree with the branch it describes" - is what makes this stand out: that line is not performing a drop, it is advancing a search, and it is the one place in the file where the rule does not hold. On a header that exists to say how much is missing, an over-count is the same class of wrong as the silence it replaces, just quieter.

The shape that matches the rest of the file is to decide the favicon's outcome once after the loop: if one landed, no drop; if none did, exactly one drop, attributed to why the last candidate failed (or budget-exhausted if the clock is what ended it).

Smaller, same block: if (!icon.href) continue; is the one drop site in the function that counts nothing at all. Harmless under the fix above, since a hrefless candidate stops being an asset the moment the favicon is one outcome rather than N.

One judgement call worth naming rather than changing

drops["cap-reached"] += Math.max(0, tokens.svgs.length - MAX_INLINE_SVGS) attributes the whole overflow to the cap, including svgs that would have failed the 50-character floor had they been examined. That is the right attribution - the cap is genuinely why they were never looked at - and the alternative would mean examining what the cap exists to avoid examining. Worth a half-line in the comment so a later reader does not "fix" it into a floor count.

Approving. The mechanism is sound everywhere else, the tally is a real improvement over four kinds of silent continue, and the favicon count is a contained fix rather than a rethink - but it is in the feature's own subject matter, so I would take it before merging rather than after.

Review by Rames

@jerrai-bot-heygen

Copy link
Copy Markdown

Non-blocking, after the stamp: the docs define dropped as "how many assets the page referenced that are not in the folder", and one referenced-and-absent case is uncounted by design: favicon alternates after the first successful icon (assetDownloader.ts:153 break). On a page declaring several rel=icon sizes the tally undercounts by that many. Either count the remainder as cap-reached (a per-run limit of one favicon is what it is) or narrow the docs sentence to "referenced and attempted". Same family as the junk-URL filter I mentioned above.

— Jerrai

@jerrai-bot-heygen

Copy link
Copy Markdown

Concur with Rames, and this supersedes my note above: the favicon loop counts each failed candidate as unavailable before the one that succeeds, so favicon.ico 404 + apple-touch-icon.png 404 + icon-192.png landed reports unavailable: 2 with the favicon in the folder. That is an overcount of the feature's own subject, not the undercount I described. Decide the favicon's outcome once after the loop: landed → no drop; none landed → one drop, by the last candidate's reason (budget-break likewise → one budget-exhausted, not the remainder). Worth taking before merge.

— Jerrai

@miguel-heygen
miguel-heygen merged commit ae7e530 into main Sep 3, 2026
51 checks passed
@miguel-heygen
miguel-heygen deleted the fix/capture-drop-counts branch September 3, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants