Skip to content

feat(crop): content-trim crop stage (smart-crop phase 1) - #2

Merged
kelchm merged 3 commits into
mainfrom
feat/smart-crop-phase1
Jul 16, 2026
Merged

feat(crop): content-trim crop stage (smart-crop phase 1)#2
kelchm merged 3 commits into
mainfrom
feat/smart-crop-phase1

Conversation

@kelchm

@kelchm kelchm commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Smart crop, phase 1: content-trim

Trims each served page to its content bounds before framing — the safe first rung of the smart-crop track. Established as the shippable floor by a field evaluation of 7 crop approaches against a hand-verified corpus (content-trim was the only approach with 0% over-crops — it can never cut real content).

What it does

  • New internal/crop package: a normalized Box + Detector seam and the content-trim detector — bounding-box-of-ink trim.
  • Top-bleed skip: steps over printer's marks (registration / CMYK bars, plate-ident codes like the NYT's C M Y K … Nxxx,…,Bs-4C,E1) — but only a band that's provably junk: thin, faint, in the extreme-top bleed, and separated from the body by a clear whitespace gap. Every guard is conservative enough that real content is never cut.
  • Wired into the engine serve path: resolve a crop plan (a stored crop_overrides box wins, else auto) → apply to the decoded master before compose → fold the crop identity into the ETag so a re-crop invalidates caches.
  • On by default; BROADSHEET_CROP=off serves full pages. Applied box echoed in X-Broadsheet-Crop.

What it deliberately does NOT do

Remove ad/promo skyboxes above the masthead — that's a semantic call needing a text-layer or learned detector (a later phase). The seam is built for it: the crop's top edge is the pluggable axis; content-trim owns the sides, bottom, and a safe fallback top.

Behavior change

Existing deployments start serving cropped pages on upgrade. Safe (whitespace and printer's-marks only); BROADSHEET_CROP=off restores full pages.

Verification

  • Unit: content-trim tests (trims margins; skips bleed strip; keeps the strip on each of the 4 guard violations; blank no-op; height-relative/scale-independent). Serve-path test (crop on trims, off serves whole, ETags differ).
  • A/B regression harness over the 97-edition eval corpus: 12 NYT registration-mark fixes, 0 regressions (it flagged and I tightened a pa-pn dateline near-miss before shipping).
  • Real app: served ny-nyt/20260702 through the server — X-Broadsheet-Crop: 0.02,0.04,0.96,0.92, registration marks gone; crop-off unchanged.
  • go build/vet/test ./... and golangci-lint all clean.

Docs

Architecture (new Cropping section + config/headers/package-layout), README config table, and CHANGELOG (with the behavior-change note).

🤖 Generated with Claude Code

Trim each served page to its content bounds before framing. A new
internal/crop package provides a normalized Box + Detector seam and the
Phase 1 content-trim detector: a bounding-box-of-ink trim that also steps
over top-bleed printer's marks (registration / CMYK bars, plate-ident
codes) under strict guards, so it can never cut real content.

Wire it into the engine serve path: resolve a crop plan (a stored
crop_overrides box wins, else the auto detector), apply it to the decoded
master before compose, and fold the crop identity into the ETag so a
re-crop invalidates caches. On by default; BROADSHEET_CROP=off serves full
pages. The applied box is echoed in the X-Broadsheet-Crop header.

The crop's top edge is the pluggable axis for a future masthead/skybox
detector; content-trim owns the sides, bottom, and a safe fallback top. It
does not remove ad/promo skyboxes yet.

Verified end to end on a real NYT edition (registration marks removed) and
against the eval-corpus A/B harness (12 fixes, 0 regressions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013G3Wpmfq9mxWMc5Lsfjdvo
Copilot AI review requested due to automatic review settings July 14, 2026 13:13
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kelchm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c5a0e440-9c57-4583-9310-43b4d086fdd4

📥 Commits

Reviewing files that changed from the base of the PR and between b0eabc8 and e23a092.

📒 Files selected for processing (2)
  • internal/crop/crop.go
  • internal/crop/trim_test.go
📝 Walkthrough

Walkthrough

Adds default-on content-aware cropping before page framing, with detector heuristics, stored overrides, cache invalidation, configuration controls, response headers, tests, and documentation.

Changes

Content-aware page cropping

Layer / File(s) Summary
Crop geometry and content detection
internal/crop/*, internal/crop/trim_test.go
Defines normalized crop boxes and a content-trim detector with bleed-strip handling, blank-page safeguards, cancellation support, and focused tests.
Stored overrides and crop planning
internal/store/store.go, pkg/broadsheet/broadsheet.go
Loads per-source crop overrides and resolves disabled, stored, or automatically detected crop plans with stable tokens.
Serving, cache identity, and response metadata
pkg/broadsheet/broadsheet.go, cmd/broadsheet-server/main.go, pkg/broadsheet/broadsheet_test.go
Applies crops before framing, includes crop identity in ETags, wires BROADSHEET_CROP, emits X-Broadsheet-Crop, and tests cropped versus uncropped rendering.
Configuration and upgrade documentation
README.md, docs/architecture.md, CHANGELOG.md
Documents cropping configuration, detector behavior, response metadata, package layout, and the paperboy-to-broadsheet rename.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant broadsheet-server
  participant broadsheet.Engine
  participant Store
  participant crop.ContentTrim
  Client->>broadsheet-server: request page
  broadsheet-server->>broadsheet.Engine: render page
  broadsheet.Engine->>Store: load crop override
  broadsheet.Engine->>crop.ContentTrim: detect crop if needed
  broadsheet.Engine->>broadsheet.Engine: apply crop before framing and compute ETag
  broadsheet.Engine-->>broadsheet-server: rendered result and crop box
  broadsheet-server-->>Client: image with ETag and X-Broadsheet-Crop
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding the content-trim crop stage for smart-crop phase 1.
Description check ✅ Passed The description is detailed and directly matches the crop-stage, caching, config, and docs changes in the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI 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.

Pull request overview

Adds a new “smart crop” stage (phase 1: content-trim) that trims served pages to content bounds (including conservative skipping of top-bleed printer’s marks), wires it into the serve/render path with cache-aware ETag identity, and exposes configuration + headers/docs for operators.

Changes:

  • Introduce internal/crop with normalized Box/Detector seam and ContentTrim detector (bounding-box-of-ink with top-bleed skip).
  • Apply crop (override-first, else auto) to decoded masters before framing; fold crop identity into ETag and expose applied crop via X-Broadsheet-Crop.
  • Add BROADSHEET_CROP/DisableCrop configuration plus tests and documentation updates.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
README.md Documents BROADSHEET_CROP configuration.
pkg/broadsheet/broadsheet.go Wires crop planning/detection into serve path, adds DisableCrop, includes crop token in ETag, returns applied crop in results.
pkg/broadsheet/broadsheet_test.go Adds an end-to-end serve-path test verifying crop on/off behavior and ETag differences.
internal/store/store.go Adds CropOverride model + GetCropOverride lookup used by the engine.
internal/crop/trim.go Implements the ContentTrim detector.
internal/crop/trim_test.go Adds unit tests for trimming and top-bleed strip skipping/guard conditions.
internal/crop/crop.go Adds core crop types (Box, Detector, Hints) and application logic.
docs/architecture.md Documents cropping architecture, behavior, headers, and package layout.
cmd/broadsheet-server/main.go Adds BROADSHEET_CROP env handling and emits X-Broadsheet-Crop header.
CHANGELOG.md Notes new default cropping behavior and operator controls.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/crop/trim.go Outdated
Comment thread internal/crop/trim_test.go Outdated
Comment thread internal/crop/trim_test.go
Comment thread internal/crop/crop.go

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/broadsheet/broadsheet.go (1)

746-797: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Crop resolution (DB lookup + auto-detect) is opts-independent but recomputed per opts-specific variant, undercutting the variant cache's purpose.

resolveCrop runs before the variant-cache lookup on every request, so p.store.GetCropOverride is hit on every serve() call whenever crop is enabled (the default) — even for what would otherwise be a fully-cached thumbnail. The comment at Lines 752-754 explicitly frames the variant cache as avoiding "re-decoding and re-cropping the master per image" for thumbnail-heavy pages, but the DB round-trip (and, on cache misses, the O(w·h) cropper.Detect scan at Lines 787-791) is keyed only by the full opts-specific ETag, not by (source, edition, render) — so a grid page requesting many distinct thumbnail sizes of the same edition pays the DB query and, on first load of each size, a full redundant detection scan, for content that's identical across all those requests.

Consider resolving/caching the crop plan (DB lookup result and/or detected box) once per (sourceID, render mtime) — independent of opts — e.g. a small TTL/invalidated cache alongside p.variants, so repeated requests for the same source/edition don't re-hit the store or re-scan the master.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/broadsheet/broadsheet.go` around lines 746 - 797, Decouple crop-plan
resolution from opts-specific variant caching in the serve flow around
resolveCrop and the cropper.Detect call. Cache the stored override and
auto-detected crop box by source identity and render version (such as SourceID
plus render mtime), with appropriate TTL or invalidation, so repeated thumbnail
variants reuse the same plan without repeated store lookups or detection scans
while preserving override precedence and no-op behavior.
🧹 Nitpick comments (1)
internal/crop/crop.go (1)

54-83: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider a minimum-size floor for sanity, not just positivity.

A box like {X:0.999, Y:0, W:0.001, H:1} passes W<=0||H<=0 and clamps to itself unchanged — it's a "valid" rectangle but crops away virtually the whole page, which is presumably never an intended outcome for any real override. Clamp currently only guards against degenerate/out-of-bounds boxes, not against nonsensical-but-valid tiny ones.
[optional_nitpick]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/crop/crop.go` around lines 54 - 83, Update Box.Clamp to enforce a
defined minimum width and height after all boundary adjustments, rejecting boxes
smaller than that floor by returning Full(). Apply the floor consistently to the
initial validation and final clamped dimensions, while preserving existing
handling for out-of-bounds and non-positive boxes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Line 153: Update the README’s “Not done yet” section to remove the stale claim
that smart cropping is unwritten and pages are served whole, keeping the
documented BROADSHEET_CROP behavior consistent with the current implementation.

---

Outside diff comments:
In `@pkg/broadsheet/broadsheet.go`:
- Around line 746-797: Decouple crop-plan resolution from opts-specific variant
caching in the serve flow around resolveCrop and the cropper.Detect call. Cache
the stored override and auto-detected crop box by source identity and render
version (such as SourceID plus render mtime), with appropriate TTL or
invalidation, so repeated thumbnail variants reuse the same plan without
repeated store lookups or detection scans while preserving override precedence
and no-op behavior.

---

Nitpick comments:
In `@internal/crop/crop.go`:
- Around line 54-83: Update Box.Clamp to enforce a defined minimum width and
height after all boundary adjustments, rejecting boxes smaller than that floor
by returning Full(). Apply the floor consistently to the initial validation and
final clamped dimensions, while preserving existing handling for out-of-bounds
and non-positive boxes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9dc40149-c4f5-4daf-b21b-84dee63c4179

📥 Commits

Reviewing files that changed from the base of the PR and between 41f99c8 and 76267f8.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • README.md
  • cmd/broadsheet-server/main.go
  • docs/architecture.md
  • internal/crop/crop.go
  • internal/crop/trim.go
  • internal/crop/trim_test.go
  • internal/store/store.go
  • pkg/broadsheet/broadsheet.go
  • pkg/broadsheet/broadsheet_test.go

Comment thread README.md
kelchm and others added 2 commits July 14, 2026 21:34
- crop.Box.Clamp: reject non-finite (NaN/Inf) coordinates up front, so a
  corrupted override can't slip past the bounds checks (Copilot).
- ContentTrim.Detect doc: it does error on ctx cancellation; say so (Copilot).
- trim tests: correct the "four guards" wording (three here, one separate) and
  tighten the tall-page scale assertion to near(185) instead of a loose bound
  (Copilot).
- README "Not done yet": pages are now content-trimmed; the open item is skybox
  removal, not cropping wholesale (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013G3Wpmfq9mxWMc5Lsfjdvo
A tiny-but-positive override (e.g. W=0.001) slipped past Clamp's <=0 checks and
Apply would emit a 1px sliver crop. Enforce a minSpan (2%) floor on both the
initial and final dimension checks, collapsing anything smaller to Full() —
same fail-safe-to-uncropped philosophy as the non-finite guard. Adds a Clamp
guard test (NaN/Inf/below-floor/negative/legit).

Addresses CodeRabbit nitpick on internal/crop/crop.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013G3Wpmfq9mxWMc5Lsfjdvo
@kelchm
kelchm merged commit 83262ea into main Jul 16, 2026
5 checks passed
@kelchm
kelchm deleted the feat/smart-crop-phase1 branch July 16, 2026 00:24
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.

2 participants