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
20 KiB
ТЗ #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 = 16384px по стороне — потолок канваса браузеров, дальше только Cancel;DOWNSCALE_TARGET_PX = 4096px длинная сторона (хватает 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 чист — пользователь может выбрать «Оставить оригинал», повторив выбор файла, либо уменьшить файл сам. Автоматического фолбэка на оригинал нет: молча грузить то, от чего пользователь только что отказался, нечестно.
- фаза 1, синхронная (сторона > 16384 по заголовку, до какого-либо
decode): диалог
- Уменьшение:
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
- PNG 100 МП (фикстура-заголовок + маленькое тело для юнитов; настоящий файл для смока): предупреждение с razрешением/размером/оценкой памяти показано ДО какой-либо аллокации размером с изображение (в юните probe не создаёт canvas/bitmap вовсе; в смоке — до клика по действию нет createImageBitmap).
- «Уменьшенная копия»: длинная сторона 4096, aspect сохранён; PNG с alpha → PNG (alpha жива), opaque → JPEG; результат уходит существующим upload-путём.
- «Оставить оригинал» — прежнее поведение байт-в-байт.
hardфаза 1: сторона >16384 из заголовка — диалог «слишком большое» с одной «Отменой», без decode; подложка/staging/конфиг не изменились. 4б.hardфаза 2: decode-fail или таймаут 10 с после выбора «Уменьшенной копии» — тостbackdrop.downscale_failed, staging чист, подложка/конфиг не изменились, повторный выбор файла работает.- SVG: только прежняя валидация, растеризации нет.
- Битые/усечённые заголовки (PNG без IHDR, JPEG без SOF, WebP-огрызок) →
unknown→ warn-диалог без чисел; продолжение возможно только явным выбором. - Существующий транзакционный контракт закреплён: failed upload/save сохраняет прежний план и файлы (тест на текущее поведение).
- EXIF-ориентация: повёрнутый JPEG уменьшается с учётом ориентации (w/h из SOF против imageOrientation — фикстура).
- 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 и тесты).