Files
houseplan-card/docs/specs/039-large-backdrops.md
T
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

233 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #39 — Безопасная работа с большими подложками
- Issue: https://github.com/Matysh/houseplan-card/issues/39
- Приоритет: P3 (триаж владельца 2026-08-15), полный трек
- Ревизия: 4.1 (2026-08-29, реализация) — уточнён серверный анкер: лимит ПЛАНОВ 8 МБ (`validation.py:22 MAX_PLAN_BYTES`), 50 МБ — вложения устройств; выводы не меняются (8 МБ JPEG ≈ 80–160 МП декодированных)
## Цель
До каких-либо тяжёлых аллокаций предупреждать о растре, способном исчерпать
память планшета/WebView, и предлагать уменьшенную копию — не трогая текущий
план и оригинал файла при любой ошибке.
## Сценарий
Владелец дома настраивает план с планшета или ноутбука: в диалоге пространства
выбирает «Файл» и указывает скан поэтажного плана — обычно это фото или скан
150–600 DPI, который «как есть» весит десятки мегапикселей. Сегодня такой выбор
молча вешает вкладку на слабом планшете (полный decode + тройная конвертация в
base64 до каких-либо проверок).
**До**: выбрал большой файл → вкладка замирает или падает без объяснений;
план, который был на экране, можно потерять вместе с вкладкой.
**После**: выбрал большой файл → мгновенно появляется понятный диалог «Файл
очень большой (10000×10000, 58 МБ, потребует ~380 МБ памяти)» с кнопкой
«Загрузить уменьшенную копию»; одно нажатие — и на плане та же картинка, но
безопасного размера. Текущий план в любом исходе остаётся цел.
## Текущее состояние (анкеры кода)
`_pickPlanFile` (`houseplan-editor-runtime.ts:8243`) принимает PNG/JPEG/WebP/SVG
по MIME/расширению и БЕЗ какой-либо диагностики: (1) весь файл конвертируется в
base64 ручным循 циклом `String.fromCharCode` — пик памяти ≈3.7× размера файла
ещё до всякого предупреждения; (2) разрешение не проверяется вовсе; (3) aspect
берётся через `<img>` — то есть полный decode УЖЕ происходит на этом шаге.
Сервер режет только байты: планы — `MAX_PLAN_BYTES` 8 МБ (`validation.py:22`),
вложения — 50 МБ; квота (`plans.py:216`). 8 МБ JPEG — это спокойно 80–160 МП
декодированных, так что клиентская диагностика остаётся главной защитой. Транзакция уже честная: staging не трогает конфиг до
успешного save (комментарий `runtime:8365-8380`), Undo и явное удаление живут
отдельно — эта часть issue выполняется существующим кодом и закрепляется тестом.
## Research: бенчмарк-матрица (проведено 2026-08-29)
Desktop headless Chromium 139 (песочница CI), генерация PNG в OffscreenCanvas,
decode `createImageBitmap`, даунскейл до 4096px + JPEG q0.9:
| MP | px | файл PNG | RGBA | decode | downscale |
|---|---|---|---|---|---|
| 4 | 2000² | 3.6 МБ | 15 МБ | 39 мс | 21 мс |
| 16 | 4000² | 12.7 МБ | 61 МБ | 118 мс | 74 мс |
| 32 | 5657² | 22.9 МБ | 122 МБ | 235 мс | 102 мс |
| 32+alpha | 5657² | 25.9 МБ | 122 МБ | 247 мс | 101 мс |
| 64 | 8000² | 40.3 МБ | 244 МБ | 395 мс | 108 мс |
| 100 | 10000² | 58.1 МБ | 381 МБ | 589 мс | 144 мс |
| 165 | 12845² | 87.3 МБ | 629 МБ | 956 мс | 130 мс |
Decode линеен (~6 мс/МП), даунскейл дешёв (<150 мс из любого размера) — окно
«предупредить и уменьшить» практично. Скрипт кладётся в
`demo/benchmark_backdrop_decode.mjs` для повторной калибровки.
**Честная оговорка протокола**: reference low-end wall tablet в среде
недоступен; десктоп decode не показывает OOM вовсе (165 МП ок). Константы
берутся консервативно от известных лимитов WebView/WebKit (Android WebView
renderer OOM в сотнях МБ decoded; лимит стороны canvas Chromium/WebKit
16384px), собраны В ОДНОМ модуле и печатаются в предупреждении — полевая
рекалибровка после жалоб = правка одного файла. Пороги смягчены в сторону
«предупреждаем раньше»: цена ложного срабатывания — один необязательный диалог.
## Константы (`src/backdrop-probe.ts`, единственный источник)
- `WARN_DECODED_BYTES = 128 МиБ` (≈32 МП RGBA) — порог предупреждения;
- `HARD_DIMENSION = 16384` px по стороне — потолок канваса браузеров, дальше
только Cancel;
- `DOWNSCALE_TARGET_PX = 4096` px длинная сторона (хватает 4K-дисплею плана);
- `DOWNSCALE_JPEG_QUALITY = 0.9`.
## Диагностика: заголовки, не decode
Ключевое отличие от ревизии 1: разрешение читается **парсером заголовков**
(PNG IHDR + colour type/tRNS → alpha; JPEG SOF0/2; WebP VP8/VP8L/VP8X + флаг
alpha) — чистый модуль `backdrop-probe.ts`, без ImageBitmap, canvas и сетевых
запросов. Никакая аллокация размером с изображение не происходит до решения
пользователя. `probeBackdrop(bytes, mime)` → `{kind: 'safe'|'warn'|'hard'|
'unknown', width, height, alpha, decodedBytes}`; `unknown` (битый/усечённый
заголовок при растровом MIME) ведёт себя как `warn` без чисел разрешения —
fail-closed к диалогу, не к тихому продолжению. SVG в probe не заходит:
прежняя валидация типа/размера, растеризации нет (как сейчас).
## UX
- `safe` — прежний путь (плюс замена ручного base64-цикла на
`FileReader.readAsDataURL`: минус ~2× пикового JS-heap на любом файле).
- `warn` — диалог (`hp-dialog`, паттерн существующих подтверждений): разрешение,
размер файла, оценка decoded-памяти; действия «Загрузить уменьшенную копию»
(основное), «Оставить оригинал», «Отмена». Числа — из probe, до decode.
- `hard` — ДВЕ фазы с разным моментом наступления и одним исходом
(«ничего не изменилось»):
- **фаза 1, синхронная** (сторона > 16384 по заголовку, до какого-либо
decode): диалог `backdrop.too_large_*` с единственной кнопкой «Отмена» и
советом уменьшить файл на десктопе;
- **фаза 2, асинхронная** (пользователь выбрал «Уменьшенную копию», а decode
упал или не уложился в таймаут 10 с): warn-диалог закрывается, показывается
тост `backdrop.downscale_failed`; поле выбора файла сброшено, staging
чист — пользователь может выбрать «Оставить оригинал», повторив выбор
файла, либо уменьшить файл сам. Автоматического фолбэка на оригинал нет:
молча грузить то, от чего пользователь только что отказался, нечестно.
- Уменьшение: `createImageBitmap(file, {imageOrientation: 'from-image'})` (EXIF
учтён) → OffscreenCanvas (fallback `<canvas>`), aspect сохраняется; alpha из
probe: PNG/WebP c alpha → PNG, opaque → JPEG q0.9 (WebP-энкод не берём —
Safari не пишет). Уменьшенный Blob идёт тем же путём planFile → upload →
квота/copy-on-write, что и оригинал.
- Диалог на мобильной ширине без горизонтального скролла.
## i18n (ключи en/ru; de — перевод тех же ключей)
- `backdrop.large_title` — "Large image" / «Большое изображение»;
- `backdrop.large_body` — "This image is {w}×{h} ({fileMb} MB file) and needs
about {decodedMb} MB of memory to display. On tablets this can crash the
page." / «Изображение {w}×{h} ({fileMb} МБ), для показа потребуется около
{decodedMb} МБ памяти. На планшетах это может привести к падению страницы.»;
- `backdrop.unknown_body` — "The image dimensions could not be read — the file
may be damaged. Continuing may crash the page on tablets." / «Не удалось
прочитать размеры изображения — файл может быть повреждён. Продолжение может
привести к падению страницы на планшете.»;
- `backdrop.use_downscaled` — "Upload a reduced copy" / «Загрузить уменьшенную
копию»;
- `backdrop.keep_original` — "Keep the original" / «Оставить оригинал»;
- `backdrop.too_large_title` — "Image is too large" / «Изображение слишком
большое»;
- `backdrop.too_large_body` — "This image exceeds what browsers can display
({w}×{h}, limit {limit} px per side). Please reduce it in a desktop editor
and upload again." / «Изображение превышает возможности браузера ({w}×{h},
предел {limit} px по стороне). Уменьшите его в редакторе на компьютере и
загрузите снова.»;
- `backdrop.downscale_failed` — "Could not create the reduced copy. The
original plan was not changed." / «Не удалось создать уменьшенную копию.
Текущий план не изменён.»;
- `toast.plan_formats` — существующий, без изменений.
## Транзакция (без изменений, закрепляется тестом)
Existing backdrop/config живы до успешного upload+validation+save (текущий
контракт `runtime:8365+`); отказ на любом шаге чистит staging и не трогает
старый ref; Undo и явное удаление — как сейчас. «Оригинал до подтверждения» из
issue = оригинальный ФАЙЛ пользователя никогда не модифицируется (это upload),
а уменьшенная копия — отдельный Blob.
## AC
1. PNG 100 МП (фикстура-заголовок + маленькое тело для юнитов; настоящий файл
для смока): предупреждение с razрешением/размером/оценкой памяти показано
ДО какой-либо аллокации размером с изображение (в юните probe не создаёт
canvas/bitmap вовсе; в смоке — до клика по действию нет createImageBitmap).
2. «Уменьшенная копия»: длинная сторона 4096, aspect сохранён; PNG с alpha →
PNG (alpha жива), opaque → JPEG; результат уходит существующим upload-путём.
3. «Оставить оригинал» — прежнее поведение байт-в-байт.
4. `hard` фаза 1: сторона >16384 из заголовка — диалог «слишком большое» с
одной «Отменой», без decode; подложка/staging/конфиг не изменились.
4б. `hard` фаза 2: decode-fail или таймаут 10 с после выбора «Уменьшенной
копии» — тост `backdrop.downscale_failed`, staging чист, подложка/конфиг не
изменились, повторный выбор файла работает.
5. SVG: только прежняя валидация, растеризации нет.
6. Битые/усечённые заголовки (PNG без IHDR, JPEG без SOF, WebP-огрызок) →
`unknown` → warn-диалог без чисел; продолжение возможно только явным выбором.
7. Существующий транзакционный контракт закреплён: failed upload/save
сохраняет прежний план и файлы (тест на текущее поведение).
8. EXIF-ориентация: повёрнутый JPEG уменьшается с учётом ориентации (w/h из
SOF против imageOrientation — фикстура).
9. safe-путь через FileReader даёт тот же b64, что старый цикл (паритет-юнит).
## Тесты и мутанты
Юниты: `test/backdrop-probe.test.mjs` — таблица заголовков (валидные PNG/JPEG/
WebP всех подвидов, alpha-варианты, битые/усечённые, границы порогов ±1);
паритет FileReader-b64. Смок `demo/smoke_backdrop_guard.mjs`: warn-диалог на
100 МП PNG, уменьшение (проверка итоговых размеров загруженного), «оригинал»,
hard фаза 1, SVG-байпас, отсутствие createImageBitmap до выбора (хук на
window.createImageBitmap). **Hard фаза 2 в смоке**: перед кликом «Уменьшенную
копию» `window.createImageBitmap` подменяется на (а) reject и (б) вечный
Promise (таймаут); ассерты — тост содержит `_t('backdrop.downscale_failed')`,
`<input type=file>.value` сброшен, `_spaceDialog.planFile === null` (staging
чист), повторный выбор файла открывает диалог заново, и НИ ОДНОГО вызова
upload/planFile со старым оригиналом (счётчик на хуке).
Мутанты реестра: (1) probe всегда `safe` — смок красный; (2) выброшена ветка
alpha→PNG (всё в JPEG) — юнит/смок красный; (3) `hard` фаза 1 понижен до
warn — смок красный; (4) staging не чистится при отказе — тест транзакции
красный; (5) **автофолбэк на оригинал**: catch фазы 2 подменяется продолжением
старого пути с оригиналом — смок красный по счётчику «оригинал не грузился».
## Риски
- **Пороги не с reference-планшета**: калибровка десктопная с консервативным
запасом; ложные срабатывания дешевы (лишний диалог), пропуски дороже — при
полевых жалобах правится один модуль констант. Смягчение: числа печатаются в
диалоге, жалобу легко диагностировать по скриншоту.
- **Зоопарк заголовков** (прогрессивный JPEG, WebP VP8L, EXIF в необычном
месте): парсер обязан отвечать `unknown`, не бросать — fail-closed к
warn-диалогу; таблица юнит-фикстур покрывает все подвиды из спецификаций
форматов.
- **OffscreenCanvas недоступен** (старые WebView): фолбэк на `<canvas>` в том
же кадре; ветка покрыта юнитом через подмену глобала.
- **Regress существующего upload-пути**: safe-путь меняет только механизм
base64 (паритет-юнит AC9) — поведенчески байт-в-байт.
## Release-артефакты
- CHANGELOG.md + CHANGELOG.ru.md — одна запись (User-Visible: yes);
- docs/BACKDROP.md — раздел «Большие изображения» (пороги, поведение, совет);
- docs/USER-GUIDE.md + USER-GUIDE.ru.md — абзац в теме подложек;
- docs/TESTING.md — чек-лист сценариев (warn/уменьшение/hard×2/SVG);
- i18n en/ru/de — ключи из раздела i18n (паритет-тесты стерегут);
- golden: не задевается (диалог не входит в golden-матрицу; скриншоты
документации пересъёмка capture по общему правилу src-правок);
- performance: бенчмарк-скрипт `demo/benchmark_backdrop_decode.mjs` в repo,
в перф-джобу CI НЕ добавляется (разовая калибровка, не регресс-гейт);
- security (явное решение): новый парсер читает ТОЛЬКО фиксированные
оффсеты/длины из уже полученного ArrayBuffer, не интерпретирует контент и не
использует ни одно поле файла как размер аллокации (w×h — только арифметика
для чисел диалога, с защитой от переполнения через Number-границы и отказом
`unknown` при неправдоподобных значениях); любые исключения парсера ловятся в
`unknown`. Юнит-таблица включает fuzz-набор битых/враждебных заголовков
(нулевые/гигантские длины чанков, обрезанные VP8X, SOF без длины). SVG-путь
парсером не трогается — существующая валидация как есть.
## Вне скоупа
Автопревращение SVG в растр; серверная перекодировка; изменение
MAX_FILE_BYTES/квот; #51 (свои изображения в декоре — там свой пайплайн).
## Откат
Один revert; конфиг/схема/эндпоинты не меняются (только клиентская логика,
i18n и тесты).