From 95375e03d5513ca11aab9e177767028a7fcd1056 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:09:41 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=B0=D0=B4=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D0=BF=D1=80=D0=B8=20=D1=81=D1=8A=D1=91?= =?UTF-8?q?=D0=BC=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oxipng снимает с набора 19.4%: 2096 КБ становятся 1689 КБ, и все десять кадров остаются пиксельно идентичными — декодированные RGBA совпадают по sha256. Это выбор фильтров строки и уровня сжатия, а не квантование: визуального решения здесь нет. Внутри съёмки, а не отдельным проходом по закоммиченным файлам: манифест хранит imageSha256 каждого кадра, поэтому жать их в репозитории руками нельзя — check-docs покраснеет; а если жать после подсчёта хешей, следующая съёмка вернёт неоптимизированные байты. Хеш считается после перепаковки. Версия oxipng попадает в манифест рядом с версией браузера и по той же причине: байты кадра зависят от того, чем жали. Отсюда же правка шага «Вердикт» — иначе он объявил бы «тот же браузер, а картинки изменились — изменился продукт», хотя изменился упаковщик. Теперь эта причина названа отдельной ветвью. Пин версии и контрольной суммы вместо apt-get: пакет из образа раннера может пропасть, а падение шага съёмки стоит целого цикла приёмки. Тот же урок, что с azure-зеркалом Playwright (#175, #206). Отсутствие инструмента не ошибка: локальная съёмка полезна и без него, приёмка идёт только из артефакта CI. Но молчать нельзя, поэтому в лог пишется строка. Проверено исполнением: съёмка прогнана целиком с подставным oxipng на pyoxipng, десять кадров, 2096 -> 1689 КБ, хеши манифеста совпали с файлами на диске, check-docs зелёный. Issue: #345 User-Visible: no --- .github/workflows/docs-screenshots.yml | 47 ++++++++++++++++++++--- demo/docs/capture.mjs | 52 +++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 7 deletions(-) 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',