Compare commits

...
Author SHA1 Message Date
Claude 95375e03d5 ci: перепаковка кадров документации без потерь при съёмке
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
2026-08-28 08:09:41 +03:00
2 changed files with 92 additions and 7 deletions
+42 -5
View File
@@ -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 "Это ожидаемо один раз — при переходе на канонический прогон."
+50 -2
View File
@@ -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',