mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
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
233 lines
20 KiB
Markdown
233 lines
20 KiB
Markdown
# ТЗ #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 и тесты).
|