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

20 KiB
Raw Blame History

ТЗ #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 и тесты).