A picked raster is now classified from its HEADER BYTES ONLY before anything heavy happens: src/backdrop-probe.ts parses PNG IHDR (+colour type/tRNS for alpha), JPEG SOF and WebP VP8/VP8L/VP8X at fixed offsets, never using a file field as an allocation size; hostile or truncated headers collapse to 'unknown', which warns without numbers instead of passing silently. The thresholds live in that module as the single calibration point (WARN_DECODED_BYTES 128 MiB ≈ 32 MP, HARD_DIMENSION 16384 — the browser canvas cap, DOWNSCALE_TARGET_PX 4096), derived from the desktop-Chromium matrix now committed as demo/benchmark_backdrop_decode.mjs with a conservative tablet margin documented in the spec. The shared pick flow (src/backdrop-pick.ts) feeds BOTH lazy runtimes — the editor space dialog and the onboarding first-space dialog — so the guard cannot drift between them, and nothing of it enters the eager View graph. Warn shows the real numbers and three actions; the reduced copy decodes EXIF-aware, keeps aspect and alpha (PNG stays PNG, opaque becomes JPEG q0.9) and flows through the ordinary planFile → upload path. Hard has two phases with one outcome: beyond 16384 px only Cancel; a failed or timed-out (10 s) reduce closes with a toast, clean staging and NO silent fallback to the original the user just declined. SVG never reaches the probe. The safe path swaps the manual byte-loop base64 for FileReader — half the JS-heap peak on every upload, byte-identical output (parity asserted in the smoke). Proofs: header-table units incl. a fuzz set of hostile headers and ±1 threshold bounds; smoke_backdrop_guard on the real bundle — zero decode calls before the choice, byte parity of keep-original, a real 6200 px reduce to 4096 for both alpha and opaque branches, cancel-only hard dialog, both phase-2 failures (reject and hang under the test-only timeout override), re-pick after refusal, SVG bypass; four registry mutants (probe-always-safe, alpha-dropped, hard-demoted, phase-2 silent fallback). Spec anchor corrected alongside: the server plan limit is 8 MB (MAX_PLAN_BYTES), attachments are the 50 MB path — an 8 MB JPEG is easily 80-160 MP decoded, so the client-side guard stays the primary defence. Issue: #39 User-Visible: yes
3.8 KiB
Plan image backdrop
Status: current in v1.60.0. The complete Background editor contract is in
DECOR-EDITOR.md.
Placement model
The source image is first fitted proportionally into the square plan canvas by
fitInSquare(plan_aspect, NORM_W). The optional transform is then applied:
| Field | Meaning | Default |
|---|---|---|
plan_x, plan_y |
top-left offset from the fitted rectangle, normalised by NORM_W |
0 |
plan_scale_x, plan_scale_y |
independent width/height multipliers | 1 |
plan_angle |
rotation around the transformed rectangle centre | 0° |
plan_scale |
legacy uniform fallback for both axes | 1 |
planRect() is the single reader used by the full card, static card and content
bounds. New writes remove plan_scale; Оптимизировать планы converts it
losslessly to both axis fields.
Editor behaviour
The Background editor opens on Select, never on the image tool. The image is interactive only under Plan backdrop:
- body drag moves it;
- four corner handles preserve ratio by default;
Shiftallows independent axes;- the upper handle rotates in 5° steps, or freely with
Shift; - double click opens numeric width, height and angle;
Escrestores the transform at pointer-down;- release creates one named command in the shared 50-step history;
- Reset removes all six transform fields and is itself undoable.
In the Background editor the image is at opacity 0.5 under every other tool
and cannot receive pointer events from the controller. Under Plan backdrop it
is opaque. View and the other editors always show it at opacity 1.
Move and resulting top-left coordinates are grid-bound. Width and height are quantised when resized or entered numerically. There is no positional modifier that bypasses the grid.
Rendering and content
The image is purely decorative and never changes rooms, walls, openings, devices, Glow or sun geometry. It is still a content item for fit/pan bounds. For a rotated image all four rotated corners contribute to those bounds.
Layer order, top to bottom:
devices and room labels
opening symbols / positive and zero-thickness walls / late room-hover outline
sun rays
live Glow pools
decor
room hover fill / Glow-base rooms and tunnels / data room fills and tunnels
plan image
room-shaped paper
scene background
The paper remains room-shaped; an image without rooms does not create opaque paper behind itself.
Large images (#39)
Before any decode the picked raster is classified from its header bytes only
(src/backdrop-probe.ts): PNG IHDR (+colour type/tRNS for alpha), JPEG SOF,
WebP VP8/VP8L/VP8X. Thresholds live in that module as the single calibration
point: a decoded size above WARN_DECODED_BYTES (128 MiB ≈ 32 MP) opens a
warning dialog with the real numbers and offers an aspect-preserving reduced
copy (longest side DOWNSCALE_TARGET_PX = 4096; PNG with alpha stays PNG,
opaque images become JPEG q0.9, EXIF orientation honoured); a side beyond
HARD_DIMENSION (16384, the browser canvas cap) offers only Cancel. A failed
or timed-out decode of the reduced copy shows a toast and leaves staging clean
— the original the user declined is never uploaded silently. Unreadable
headers behave like a warning without numbers. SVG is never rasterised and
skips the probe entirely. Calibration matrix and rationale:
docs/specs/039-large-backdrops.md, rerun via
demo/benchmark_backdrop_decode.mjs.
Ownership
| Concern | File |
|---|---|
| fitted/transformed rectangle and rotated bounds | src/space-geometry.ts |
| gestures, frame, numeric dialog, history/reset | src/houseplan-card.ts |
| full-card SVG image | src/houseplan-card.ts |
| static-card SVG image | src/space-render.ts |
| schema ranges | custom_components/houseplan/validation.py |
| legacy conversion | src/plan-optimizer.ts |