Skip to content

Keep blank frames and new cameras off the home page's mosaic - #382

Merged
widgetii merged 2 commits into
masterfrom
wall-showcase
Oct 3, 2026
Merged

widgetii merged 2 commits into
masterfrom
wall-showcase

Conversation

@widgetii

@widgetii widgetii commented Oct 3, 2026

Copy link
Copy Markdown
Member

The mosaic on openipc.org's front page shows whatever a camera sent last. On 2026-10-03 that included a flat grey frame with only the camera's clock on it (280228abf9b1e84c5ee8) and a white one (878a7b98ed37538b9b91). And any camera, new that day, could put a picture of its choosing there within fifteen minutes.

Change

  • Brightness, measured once. Each frame's 5th / 50th / 95th brightness percentiles over a 64×36 greyscale copy, taken from the decode it is already checked with: Go's JPEG decode, or ffmpeg's for a HEIF keyframe (which now writes the greyscale copy instead of a framemd5). Stored on the row — snapshots.luma_p5/p50/p95, migration 022. Nothing a camera sends is re-encoded.

  • Store.Showcase for /api/v1/wall/mosaic.json: the newest measured frame per camera, leaving out

    • a flat frame: spread (p95 − p5) < 16,
    • a blown-out one: median ≥ 245,
    • a black one: p95 < 20,
    • any camera first seen less than 30 days ago.

    The gallery (/open-wall, page/N.json) is unchanged.

  • cameras (mac_key, first_seen), written by a trigger on every upload and never purged (snapshots are kept two days), and backfilled from the snapshots present when the migration runs.

Calibration on the 4,123 frames on the wall: 309 fall under the rule — all flat grey or white, a night frame with no light, or one garden camera blown out to white. Frames with a spread of 24 or more were real scenes. The Go measurement, run over every frame on the production wall, agreed with an independent one (Pillow) on 4,121 of 4,123; the other two sit exactly on a threshold.

Tests: TestShowcase (a scene shown; grey-with-clock, white, black and a month-old camera that went flat left out; an unmeasured newest frame falls back to the measured one before it; a new camera ages in), TestCamerasFirstSeen (written once, any MAC spelling, survives the purge), TestLumaOfJPEG (a flat frame with a clock overlay measures flat; a gradient does not), TestCheckDecodes (testsrc through ffmpeg measures as a picture).

After merge — not part of the code:

  1. Seed first_seen for 17 cameras from the off-site backups older than 30 days (MAC and date only, LEAST with what the migration wrote).
  2. Fill in brightness for the frames already on the wall, so the mosaic is right before the next upload slot.

Preview from today's data: of 21 cameras active, 11 qualify; the 10 left out are 9 first seen this week (several of them probably older, but absent from the three two-day backups) and "The shelf", whose newest frame is the grey one above.

The mosaic on openipc.org's front page showed whatever a camera sent last:
on 2026-10-03 a flat grey frame with a clock on it (280228abf9b1e84c5ee8)
and a white one (878a7b98ed37538b9b91). And any camera, new that day, could
put a picture of its choosing there within fifteen minutes.

- Each frame's brightness percentiles (5th, median, 95th of a 64x36
  greyscale copy) are measured from the decode it is already checked with:
  Go's JPEG decode, or ffmpeg's for a HEIF keyframe, which now writes the
  greyscale copy instead of a framemd5. Stored on the row (migration 022).
- The mosaic reads Store.Showcase: the newest measured frame per camera,
  leaving out a flat one (spread < 16), a blown-out one (median >= 245), a
  black one (95th < 20), and any camera first seen less than 30 days ago.
  The gallery is unchanged.
- cameras(mac_key, first_seen), written by a trigger on every upload and
  never purged, since snapshots are kept two days.

Calibrated on the 4,123 frames on the wall: 309 fall under the rule, every
one of them flat grey or white, a night frame with no light, or a garden
camera blown out to white; frames with a spread of 24 or more were real
scenes. The Go measurement agreed with an independent one (Pillow) on 4,121
of them; the other two sit on a threshold.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Keep blank frames and new cameras off the home page mosaic

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Measure frame brightness during existing decode checks without re-encoding uploads.
• Show only eligible frames from cameras first seen at least 30 days ago in the home page mosaic.
• Preserve the unfiltered gallery and test brightness, camera age, and mosaic selection.
Diagram

graph TD
  Upload["Camera upload"] --> Variants["Variant processor"] --> Decode["Frame decode"]
  Variants --> Snapshots[("Snapshots DB")] --> Showcase["Showcase query"] --> Mosaic["Mosaic API"]
  Snapshots --> Cameras[("Cameras DB")] --> Showcase
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Curated camera allowlist
  • ➕ Provides direct control over which cameras can appear on the front page.
  • ➖ Requires ongoing moderation and does not automatically remove blank frames from approved cameras.
2. Filter frames at ingestion
  • ➕ Simplifies the mosaic read query.
  • ➖ Makes threshold changes harder and risks changing the unfiltered gallery's behavior.

Recommendation: Keep the PR's read-time showcase filter: it preserves the gallery, allows threshold adjustments without remeasuring frames, and uses decode work already performed. An allowlist is worth considering separately if established-camera abuse becomes a concern; camera age alone cannot prevent it.

Files changed (13) +389 / -64

Enhancement (4) +109 / -44
check.goMeasure brightness during HEIF decode checks +14/-13

Measure brightness during HEIF decode checks

• Changes ffmpeg output from framemd5 to a 64×36 greyscale frame. The decode check now returns brightness percentiles and rejects output without exactly one measured picture.

service/internal/keyframe/check.go

jpeg.goMeasure decoded JPEG brightness +11/-10

Measure decoded JPEG brightness

• Returns brightness percentiles from the JPEG decode already used to validate stripped frames. Published JPEG pixels remain unchanged.

service/internal/keyframe/jpeg.go

luma.goAdd shared brightness percentile measurement +61/-0

Add shared brightness percentile measurement

• Defines the 64×36 measurement size and 5th, 50th, and 95th percentile result. Downsamples decoded JPEG images and computes percentiles from greyscale pixels.

service/internal/keyframe/luma.go

variants.goCarry measurements through frame publishing +23/-21

Carry measurements through frame publishing

• Propagates brightness from JPEG or HEIF validation to the generated-snapshot update while continuing to publish frames without re-encoding.

service/internal/variants/variants.go

Bug fix (2) +55 / -6
store.goSelect eligible mosaic frames +51/-5

Select eligible mosaic frames

• Adds Showcase to select each established camera's newest measured frame from the last day, then exclude flat, blown-out, or black frames. MarkGenerated now persists brightness alongside dimensions.

service/internal/snapshots/store.go

api.goUse Showcase for the mosaic endpoint +4/-1

Use Showcase for the mosaic endpoint

• Routes mosaic tile selection through Showcase. Gallery pagination continues to use the unfiltered latest-per-camera query.

service/internal/wall/api.go

Tests (5) +174 / -13
keyframe_test.goAdapt decode tests to measured results +17/-10

Adapt decode tests to measured results

• Updates callers for the new decode signatures and verifies that ffmpeg measures a test pattern as a non-flat picture.

service/internal/keyframe/keyframe_test.go

luma_test.goTest flat-frame and scene measurements +75/-0

Test flat-frame and scene measurements

• Tests percentile calculation and JPEG measurements for grey-with-clock, white, and gradient images.

service/internal/keyframe/luma_test.go

snapshots_test.goTest showcase selection and camera retention +75/-0

Test showcase selection and camera retention

• Covers blank and dark frame exclusion, newest-measured-frame fallback, camera aging, normalized MAC addresses, and first-seen retention after snapshot deletion.

service/internal/snapshots/snapshots_test.go

variants_test.goUpdate variant store test double +1/-1

Update variant store test double

• Adapts the fake store to the additional brightness argument passed when a frame is marked generated.

service/internal/variants/variants_test.go

wall_test.goKeep wall endpoint fixtures showcase-eligible +6/-2

Keep wall endpoint fixtures showcase-eligible

• Adds brightness values to seeded snapshots and ages their cameras so existing mosaic endpoint assertions exercise the new selection path.

service/internal/wall/wall_test.go

Documentation (1) +5 / -1
CLAUDE.mdDocument mosaic eligibility +5/-1

Document mosaic eligibility

• Explains brightness measurement, blank-frame exclusion, and the 30-day camera requirement in the wall architecture notes.

CLAUDE.md

Other (1) +46 / -0
022_wall_showcase.sqlPersist brightness and camera first-seen dates +46/-0

Persist brightness and camera first-seen dates

• Adds nullable brightness percentile columns and a persistent cameras table. An insert trigger records first sightings, while the migration backfills cameras from snapshots still present.

service/internal/db/migrations/022_wall_showcase.sql

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. The mosaic conformance test fails ✓ Resolved
Description
Store.Showcase requires a camera to have been seen for 30 days and its frame to have measured
brightness, but the mosaic conformance test still uploads frames under a fresh MAC and immediately
expects a tile. That test runs in the required conformance suite, so its new camera cannot satisfy
the age condition even if frame processing finishes before the request.
Code

service/internal/snapshots/store.go[R231-232]

+			WHERE s.created_at > now() - interval '1 day' AND s.luma_p50 IS NOT NULL
+			  AND c.first_seen <= now() - make_interval(secs => $1)
Evidence
The test creates a fresh MAC, uploads two frames and requires at least one mosaic tile; the new
query excludes that MAC by age. The CI test job runs the conformance suite.

service/conformance/wall_api_test.go[80-111]
service/conformance/conformance_test.go[250-257]
service/internal/snapshots/store.go[227-240]
.github/workflows/build.yml[64-68]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The mosaic conformance test expects a newly uploaded camera to appear, but Showcase excludes cameras first seen less than 30 days ago and frames not yet measured.
## Fix Focus Areas
- service/conformance/wall_api_test.go[80-111]
- service/internal/snapshots/store.go[227-240]
## Recommended Fix
After uploading the test frames, wait for the newest frame's variant generation and backdate that test camera's first_seen in the scratch database before asserting the mosaic response. Keep a separate assertion for the new-camera exclusion if appropriate.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. New uploaders can bypass the age gate 🐞 Bug ⛨ Security
Description
Showcase uses cameras.first_seen for the submitted MAC as its only age check, while the upload
handler accepts a MAC supplied by the caller and validates only its format. Someone who knows an
eligible older camera's MAC can submit a qualifying image under that identity; once the ordinary
upload interval permits it, the new frame passes the age check.
Code

service/internal/snapshots/store.go[R230-232]

+			JOIN cameras c ON c.mac_key = s.mac_key
+			WHERE s.created_at > now() - interval '1 day' AND s.luma_p50 IS NOT NULL
+			  AND c.first_seen <= now() - make_interval(secs => $1)
Evidence
The request reads mac_address from caller-controlled form fields, validation checks its shape rather
than possession, and the insert stores it. The migration retains age by normalized MAC, which
Showcase joins to the newly submitted frame without further identity verification.

service/internal/snapshots/upload.go[155-166]
service/internal/snapshots/upload.go[186-204]
service/internal/snapshots/validate.go[12-14]
service/internal/snapshots/validate.go[44-64]
service/internal/snapshots/store.go[137-171]
service/internal/db/migrations/022_wall_showcase.sql[25-38]
service/internal/snapshots/store.go[227-235]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new age gate relies on an unauthenticated, caller-supplied MAC, so an uploader can claim the age of an existing camera.
## Fix Focus Areas
- service/internal/snapshots/upload.go[155-166]
- service/internal/snapshots/store.go[227-235]
- service/internal/db/migrations/022_wall_showcase.sql[25-38]
## Recommended Fix
Do not grant mosaic eligibility based solely on a submitted MAC. Bind eligible uploads to a server-verified camera identity, or use a review or enrollment mechanism that an arbitrary uploader cannot inherit by supplying an older MAC.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. One upload a month ago earns a front-page spot ✓ Resolved
Description
The cameras_seen trigger records only the first upload, and Showcase checks only `c.first_seen
<= now() - 30 days`, so a camera that uploaded once and then went silent for a month qualifies with
its next upload. The MAC is whatever the uploader sends and the trigger fires on every inserted row
before the frame is checked, so many invented MACs, or a refused garbage upload, start the 30-day
clock today and can all reach the mosaic together a month later.
Code

service/internal/snapshots/store.go[R230-232]

+			JOIN cameras c ON c.mac_key = s.mac_key
+			WHERE s.created_at > now() - interval '1 day' AND s.luma_p50 IS NOT NULL
+			  AND c.first_seen <= now() - make_interval(secs => $1)
Evidence
The trigger inserts first_seen with ON CONFLICT DO NOTHING and nothing else records activity;
Showcase filters only on first_seen. The insert happens when the upload is stored, before the
variants processor decides whether to refuse the frame, and the MAC comes from the uploader.

service/internal/db/migrations/022_wall_showcase.sql[30-38]
service/internal/snapshots/store.go[227-236]
service/internal/snapshots/store.go[167-177]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The 30-day rule only checks `cameras.first_seen`, which is written by an AFTER INSERT trigger on snapshots before the frame is checked. One upload, then a month of silence, is enough to put a camera on the front page.
## Fix Focus Areas
- service/internal/db/migrations/022_wall_showcase.sql[25-38]
- service/internal/snapshots/store.go[227-245]
- service/internal/snapshots/store.go[320-330]
## Recommended Fix
- Write `cameras` from `MarkGenerated` (only frames that were checked and accepted), not from the AFTER INSERT trigger.
- Store activity next to `first_seen`: a `last_seen` column, or a count of distinct upload days.
- In `Showcase`, require uploads on at least N separate days within the 30-day window, not just a `first_seen` older than 30 days.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Small frames are dropped as black ✓ Resolved
Description
lumaOfImage returns 0/0/0 for JPEGs smaller than 64×36 even though StripJPEG accepts dimensions
down to 16 pixels, and MarkGenerated stores those values as a measurement. When such a frame is
the camera’s newest, Showcase selects it as measured and rejects its zero brightness, removing the
camera from the mosaic instead of falling back to an earlier measured frame.
Code

service/internal/keyframe/luma.go[R37-39]

+	if b.Dx() < LumaW || b.Dy() < LumaH {
+		return Luma{}
+	}
Evidence
JPEG validation accepts dimensions down to 16 pixels, but lumaOfImage returns an all-zero Luma
below 64×36. MarkGenerated stores those values unconditionally, so Showcase treats the row’s
non-NULL luma_p50 as measured and rejects its zero brightness and spread.

service/internal/keyframe/luma.go[35-39]
service/internal/snapshots/store.go[323-326]
service/internal/snapshots/store.go[231-235]
service/internal/keyframe/jpeg.go[29-38]
service/internal/keyframe/jpeg.go[54-57]
service/internal/keyframe/keyframe.go[215-229]
service/internal/keyframe/luma.go[9-12]
service/internal/variants/variants.go[278-290]
service/internal/snapshots/store.go[227-240]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Valid JPEGs smaller than 64×36 receive zero brightness values that are stored as measurements, causing `Showcase` to reject them regardless of their contents. Empty input also produces zeros that should not be stored as a measurement.
## Fix Focus Areas
- service/internal/keyframe/luma.go[22-60]
- service/internal/keyframe/luma_test.go[33-75]
- service/internal/snapshots/store.go[323-326]
## Recommended Fix
Make grayscale sampling measure valid images smaller than 64×36, for example by upsampling or clamping source coordinates, and add a test using a small non-flat JPEG. Have `lumaOfImage` and `lumaOf` signal when a frame cannot be measured, such as for empty input, rather than returning a zero `Luma`; update `MarkGenerated` to write NULL to `luma_p5`, `luma_p50`, and `luma_p95` in that case.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread service/internal/snapshots/store.go Outdated
Comment thread service/internal/snapshots/store.go Outdated
Comment thread service/internal/snapshots/store.go Outdated
Comment thread service/internal/keyframe/luma.go Outdated
Review of #382 (Qodo):

- One upload a month ago, or a MAC invented then, was enough: first_seen
  came from a trigger on every inserted row, refused uploads included.
  cameras now keeps the first accepted frame and the number of UTC days
  with one, written by MarkGenerated in the same statement that marks the
  frame; Showcase also requires ShowcaseMinDays (20) days.
- A JPEG smaller than the 64x36 copy measured 0/0/0 and was dropped as
  black; it is now sampled, each cell reading at least one pixel.
- The conformance mosaic test uploaded the default JPEG -- a header and
  padding the wall refuses -- under a new MAC and expected a tile. It now
  uploads a real picture, requires it absent while the camera is new, and
  present once the database says the camera is established; the suite's
  cleanup removes its cameras rows.
@widgetii

widgetii commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Review addressed in e3e095d:

  1. Conformance mosaic test — fixed. It uploaded the suite's default JPEG (a header and padding, which the wall refuses) under a new MAC. It now uploads a real picture, waits for it to be measured, requires it absent while the camera is new, then makes the camera established in the scratch database and requires it present. Cleanup removes the test's cameras rows.
  2. One upload a month ago earns a spot — fixed. The trigger is gone; cameras is written by MarkGenerated (accepted frames only) in the same statement that marks the frame, and keeps first_seen, last_day and days (UTC days with an accepted frame). Showcase requires 30 days since first seen and ShowcaseMinDays = 20 days of uploads. TestCameraHistory covers refused uploads, two spellings of one MAC, a late-processed frame and the purge.
  3. Small frames dropped as black — fixed: a picture smaller than 64×36 is sampled (each cell reads at least one pixel); TestLumaOfASmallJPEG.
  4. Claiming an old camera's MAC — not changed, deliberately. The wall never publishes a MAC: pages and JSON carry camera_token, an HMAC of it under a server key, so an attacker would need a specific camera's exact MAC from somewhere else. The upload contract is frozen and has no other camera identity to bind to; an impostor's frames would also interleave with the real camera's under the 15-minute interval. If MACs ever leak, the answer is an enrolment step, which is a larger change than this PR.

Deployment note: the 17 cameras seeded from the off-site backups (seen 2026-08-21…29 and still uploading) will be seeded with days = 20: the backups hold two days each, so a day count from them would understate cameras that have uploaded every quarter hour for six weeks. That is an operator decision for those 17 only; every other camera earns its days.

@widgetii
widgetii merged commit 53de767 into master Oct 3, 2026
4 checks passed
@widgetii
widgetii deleted the wall-showcase branch October 3, 2026 14:48
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.

1 participant