# ТЗ #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
берётся через `` — то есть полный 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 `