Files
houseplan-card/docs/BACKDROP.md
Codex 7c31725ac9 feat: warn about huge backdrops and offer a safe reduced copy (#39)
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
2026-08-29 10:13:09 +03:00

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;
  • Shift allows independent axes;
  • the upper handle rotates in 5° steps, or freely with Shift;
  • double click opens numeric width, height and angle;
  • Esc restores 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