mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
docs: actualize #39 large-backdrops spec (revision 2, benchmark-calibrated)
Issue: #39 User-Visible: no
This commit is contained in:
@@ -1,58 +1,137 @@
|
||||
# ТЗ #39 — Безопасная работа с большими подложками
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/39
|
||||
- Приоритет: P2
|
||||
- Статус ТЗ: research-first, thresholds утверждаются по benchmark
|
||||
- Приоритет: P3 (триаж владельца 2026-08-15), полный трек
|
||||
- Ревизия: 2 (2026-08-29) — research-фаза проведена, константы предложены по бенчмарку
|
||||
|
||||
## Цель
|
||||
|
||||
До upload предупреждать о raster, способном исчерпать память tablet/WebView,
|
||||
и предлагать уменьшенную копию без потери текущего плана при ошибке.
|
||||
До каких-либо тяжёлых аллокаций предупреждать о растре, способном исчерпать
|
||||
память планшета/WebView, и предлагать уменьшенную копию — не трогая текущий
|
||||
план и оригинал файла при любой ошибке.
|
||||
|
||||
## Локальная диагностика
|
||||
## Текущее состояние (анкеры кода)
|
||||
|
||||
Для PNG/JPEG/WebP до сетевого запроса:
|
||||
`_pickPlanFile` (`houseplan-editor-runtime.ts:8243`) принимает PNG/JPEG/WebP/SVG
|
||||
по MIME/расширению и БЕЗ какой-либо диагностики: (1) весь файл конвертируется в
|
||||
base64 ручным循 циклом `String.fromCharCode` — пик памяти ≈3.7× размера файла
|
||||
ещё до всякого предупреждения; (2) разрешение не проверяется вовсе; (3) aspect
|
||||
берётся через `<img>` — то есть полный decode УЖЕ происходит на этом шаге.
|
||||
Сервер режет только байты: `MAX_FILE_BYTES` 50 МБ (`http_api.py:252`) и квота
|
||||
(`plans.py:216`). Транзакция уже честная: staging не трогает конфиг до
|
||||
успешного save (комментарий `runtime:8365-8380`), Undo и явное удаление живут
|
||||
отдельно — эта часть issue выполняется существующим кодом и закрепляется тестом.
|
||||
|
||||
- MIME/signature и file bytes;
|
||||
- decoded width×height через `createImageBitmap`, fallback isolated `<img>`;
|
||||
- megapixels и minimum decoded RGBA memory `w*h*4`;
|
||||
- estimated working peak: bitmap + canvas + encoded output (conservative 2.5×);
|
||||
- рекомендуемый target dimensions.
|
||||
## Research: бенчмарк-матрица (проведено 2026-08-29)
|
||||
|
||||
Object URLs всегда revoke; decode timeout/error не заменяет текущий backdrop.
|
||||
SVG получает file-size/security validation, но не raster memory/downscale.
|
||||
Desktop headless Chromium 139 (песочница CI), генерация PNG в OffscreenCanvas,
|
||||
decode `createImageBitmap`, даунскейл до 4096px + JPEG q0.9:
|
||||
|
||||
## Threshold protocol
|
||||
| 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 мс |
|
||||
|
||||
Перед code default провести matrix на reference low-end wall tablet WebView и
|
||||
desktop Chromium: 4/8/16/32/64 MP, transparency, JPEG/WebP. Ship constants
|
||||
документируются как `WARN_DECODED_BYTES` и `MAX_SAFE_DIMENSION`. Рекомендуемый
|
||||
начальный guard для исследования: warning при >32 MP либо estimated peak
|
||||
>128 MiB; это не становится product constant без результатов.
|
||||
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: обычный upload.
|
||||
- Warning: dialog показывает resolution, file size, estimated memory и действия
|
||||
«Загрузить уменьшенную копию», «Оставить оригинал», Cancel.
|
||||
- Hard browser limit/decode failure: только Cancel и инструкция уменьшить файл
|
||||
desktop tool; нельзя продолжать blind.
|
||||
- Downscale сохраняет aspect; longest side до benchmark target. PNG с alpha
|
||||
остаётся PNG, opaque photo — JPEG/WebP с documented quality; metadata не
|
||||
требуется.
|
||||
- `safe` — прежний путь (плюс замена ручного base64-цикла на
|
||||
`FileReader.readAsDataURL`: минус ~2× пикового JS-heap на любом файле).
|
||||
- `warn` — диалог (`hp-dialog`, паттерн существующих подтверждений): разрешение,
|
||||
размер файла, оценка decoded-памяти; действия «Загрузить уменьшенную копию»
|
||||
(основное), «Оставить оригинал», «Отмена». Числа — из probe, до decode.
|
||||
- `hard` (сторона > 16384, decode-ошибка или таймаут 10 с на этапе
|
||||
уменьшения) — только «Отмена» + совет уменьшить файл на десктопе; текущая
|
||||
подложка и 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, что и оригинал.
|
||||
- Тексты en/ru/de; диалог на мобильной ширине без горизонтального скролла.
|
||||
|
||||
## Transaction
|
||||
## Транзакция (без изменений, закрепляется тестом)
|
||||
|
||||
Existing backdrop/config остаются до успешного upload+validation+config save.
|
||||
Downscaled Blob проходит тот же quota/copy-on-write backend path. При upload,
|
||||
save или decode failure staging очищается, старый ref не удаляется. Undo и
|
||||
explicit plan deletion работают как сейчас.
|
||||
Existing backdrop/config живы до успешного upload+validation+save (текущий
|
||||
контракт `runtime:8365+`); отказ на любом шаге чистит staging и не трогает
|
||||
старый ref; Undo и явное удаление — как сейчас. «Оригинал до подтверждения» из
|
||||
issue = оригинальный ФАЙЛ пользователя никогда не модифицируется (это upload),
|
||||
а уменьшенная копия — отдельный Blob.
|
||||
|
||||
## Проверки и приёмка
|
||||
## AC
|
||||
|
||||
- header fixtures без выделения гигантского canvas до warning;
|
||||
- alpha/aspect/orientation (EXIF), corrupt/truncated, decode timeout;
|
||||
- failed upload/save preserves previous plan and files;
|
||||
- memory benchmark report + documented thresholds;
|
||||
- mobile dialog без horizontal scroll;
|
||||
- SVG никогда автоматически не растеризуется.
|
||||
1. PNG 100 МП (фикстура-заголовок + маленькое тело для юнитов; настоящий файл
|
||||
для смока): предупреждение с razрешением/размером/оценкой памяти показано
|
||||
ДО какой-либо аллокации размером с изображение (в юните probe не создаёт
|
||||
canvas/bitmap вовсе; в смоке — до клика по действию нет createImageBitmap).
|
||||
2. «Уменьшенная копия»: длинная сторона 4096, aspect сохранён; PNG с alpha →
|
||||
PNG (alpha жива), opaque → JPEG; результат уходит существующим upload-путём.
|
||||
3. «Оставить оригинал» — прежнее поведение байт-в-байт.
|
||||
4. `hard`: сторона >16384 либо decode-fail/таймаут — только Отмена; текущая
|
||||
подложка, 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-путь, SVG-байпас, отсутствие createImageBitmap до выбора (хук на
|
||||
window.createImageBitmap). Мутанты реестра: (1) probe всегда `safe` — смок
|
||||
красный; (2) выброшена ветка alpha→PNG (всё в JPEG) — юнит/смок красный; (3)
|
||||
`hard` понижен до warn — смок красный; (4) staging не чистится при отказе —
|
||||
тест транзакции красный.
|
||||
|
||||
## Вне скоупа
|
||||
|
||||
Автопревращение SVG в растр; серверная перекодировка; изменение
|
||||
MAX_FILE_BYTES/квот; #51 (свои изображения в декоре — там свой пайплайн).
|
||||
|
||||
## Откат
|
||||
|
||||
Один revert; конфиг/схема/эндпоинты не меняются (только клиентская логика,
|
||||
i18n и тесты).
|
||||
|
||||
Reference in New Issue
Block a user