diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml index 3a021777..b076cf01 100644 --- a/.github/workflows/docs-screenshots.yml +++ b/.github/workflows/docs-screenshots.yml @@ -46,6 +46,29 @@ jobs: - name: Install pinned Chromium if: steps.pw.outputs.cache-hit != 'true' run: npx playwright install chromium + # oxipng без потерь снимает с набора ~19% (замер в #345 на pyoxipng 9.1.1; + # у 10.2.0 пресеты уровней перебалансированы, точная доля может отличаться, + # но кадры остаются пиксельно идентичными в любой версии — это перепаковка). + # + # Пин версии и контрольной суммы намеренно, а не `apt-get install oxipng`: + # пакет из образа раннера может пропасть или переехать, а падение шага + # съёмки стоит целого цикла приёмки. Тот же урок, что с azure-зеркалом + # Playwright (#175, #206). + - name: Установить oxipng + env: + OXIPNG_VERSION: 10.2.0 + OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e + run: | + set -euo pipefail + asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + curl -fsSL -o "$asset" \ + "https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}" + echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c - + mkdir -p "$HOME/.local/bin" + tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \ + "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/oxipng" --version - name: Build the bundle the screenshots must come from run: npm run build - name: Capture @@ -58,16 +81,30 @@ jobs: # разницу рендеринга. - name: Вердикт run: | + # Поле манифеста «до» — из закоммиченного состояния, «после» — из + # свежего. Читается одинаково для браузера и для упаковщика: оба + # переписывают все кадры сразу, и различить их причины обязан вердикт, + # а не человек по памяти (#345). + field() { + node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).$1||'')}catch{console.log('')}})" + } git status --porcelain docs/images changed=$(git diff --name-only docs/images | grep -c png || true) - before=$(git show HEAD:docs/images/screenshots.json | node -e \ - "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})") + before=$(git show HEAD:docs/images/screenshots.json | field chromium) after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)") + packer_before=$(git show HEAD:docs/images/screenshots.json | field oxipng) + packer_after=$(node -e "console.log(require('./docs/images/screenshots.json').oxipng || '')") echo "--- изменившихся PNG: $changed" echo "--- Chromium: было «${before:-не записан}», стало «$after»" - if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then - echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт." - echo "Смотрите на кадры: если изменение ожидаемое, принимайте." + echo "--- oxipng: было «${packer_before:-не записан}», стало «${packer_after:-нет}»" + if [ "$packer_before" != "$packer_after" ] && [ "$changed" -gt 0 ]; then + echo "ВЕРДИКТ: изменился упаковщик, поэтому переписаны все кадры сразу." + echo "Это перепаковка без потерь: пиксели те же, размер меньше на ~19%." + echo "Ожидаемо один раз — при включении oxipng либо при смене его версии." + echo "Проверить можно сравнением декодированных кадров, а не байтов." + elif [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then + echo "ВЕРДИКТ: тот же браузер и тот же упаковщик, а картинки изменились —" + echo "изменился продукт. Смотрите на кадры: если изменение ожидаемое, принимайте." elif [ "$before" != "$after" ]; then echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу." echo "Это ожидаемо один раз — при переходе на канонический прогон." diff --git a/demo/docs/capture.mjs b/demo/docs/capture.mjs index da8762eb..30259ec8 100644 --- a/demo/docs/capture.mjs +++ b/demo/docs/capture.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -17,6 +18,47 @@ const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/h const SCRIPT = fileURLToPath(import.meta.url); const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +/** + * Перепаковка кадра без потерь (#345). + * + * Замер на этом наборе: 2096 КБ превращаются в 1689 КБ, минус 19.4%, и все + * десять кадров остаются ПИКСЕЛЬНО идентичными — декодированные RGBA совпадают + * по sha256. Это выбор фильтров строки и уровня сжатия, а не квантование: + * визуального решения здесь нет вовсе. + * + * Почему внутри съёмки, а не отдельным проходом по закоммиченным файлам. + * Манифест хранит `imageSha256` каждого кадра, поэтому оптимизировать файлы в + * репозитории руками нельзя — `check-docs` покраснеет; а если жать после + * подсчёта хешей, следующая же съёмка вернёт неоптимизированные байты. + * + * Отсутствие инструмента не ошибка: локальная съёмка и без него полезна для + * глаз, а приёмка всё равно идёт только из артефакта CI, где `oxipng` стоит + * пином (`.github/workflows/docs-screenshots.yml`). Но молчать об этом нельзя — + * байты кадра зависят от того, был ли инструмент, поэтому его версия попадает + * в манифест рядом с версией браузера, по той же причине. + */ +const oxipngVersion = (() => { + const probe = spawnSync('oxipng', ['--version'], { encoding: 'utf8' }); + if (probe.status !== 0) { + console.log('oxipng не найден: кадры пишутся как есть, без перепаковки'); + return null; + } + return String(probe.stdout || '').trim().split('\n')[0]; +})(); + +/** Пожать файл на месте и вернуть его новые байты. */ +const shrinkPng = (path, before) => { + if (!oxipngVersion) return before; + const run = spawnSync('oxipng', ['-o', '4', '--strip', 'safe', '--quiet', path]); + if (run.status !== 0) { + throw new Error(`oxipng не смог обработать ${path}: код ${run.status}` + + `${run.stderr ? ` · ${run.stderr}` : ''}`); + } + const after = readFileSync(path); + console.log(` ${(before.length / 1024).toFixed(0)} КБ -> ${(after.length / 1024).toFixed(0)} КБ`); + return after; +}; + const roomCardClip = (page) => page.evaluate(() => { const card = window.__goldenCard; @@ -129,14 +171,18 @@ try { const image = await page.screenshot({ ...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css', }); - writeFileSync(resolve(OUTPUT, scenario.file), image); + const path = resolve(OUTPUT, scenario.file); + writeFileSync(path, image); + // Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты, + // которые лежат на диске, иначе приёмка отвергнет свой же кандидат. + const stored = shrinkPng(path, image); scenarios[scenario.id] = { file: scenario.file, viewport: scenario.viewport, theme: scenario.theme, language: scenario.language, sourceSha256: fingerprint, - imageSha256: sha256(image), + imageSha256: sha256(stored), }; console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`); } @@ -147,6 +193,8 @@ try { // Кто снимал. Смена браузера переписывает все картинки без содержательных // изменений (#246), поэтому окружение съёмки — часть доказательства. chromium: browser.version(), + // Чем жали — тоже часть доказательства: без инструмента байты другие. + oxipng: oxipngVersion, sourceFingerprint: fingerprint, captureScriptSha256: sha256(readFileSync(SCRIPT)), command: 'npm run build && node demo/docs/capture.mjs',