* perf: cortar 157 KiB do caminho crítico e medir o LCP de forma reprodutível (MAN-109) A auditoria do PR #34 mediu LCP acima da meta de 2,5 s da SPEC §6.6 em todas as páginas no mobile, mas ficou registrada apenas como tabela num comentário do Linear — `storage/app/lighthouse` é gitignored, então não havia artefato para comparar depois. Esta entrega mede de novo, encontra a causa dominante e corta o que dava para cortar. ## Fontes servidas em dobro (88 KiB) Bunny entrega cada peso de EB Garamond em woff2 e woff, e o plugin de fontes emitia uma regra `@font-face` para cada, woff2 primeiro e woff depois. Duas regras com a mesma família, peso, estilo e unicode-range fazem a última vencer: o navegador renderizava a partir dos woff e descartava os woff2 pré-carregados. O log de rede da home prova: 3 woff em prioridade VeryHigh (88 KiB), a mais alta da página e à frente do elemento de LCP, mais 3 woff2 em High (74 KiB) baixados só por causa do `<link rel="preload">`. 162 KiB de tráfego para 74 KiB de fonte útil. woff2 é suportado por todo navegador que este site atende desde 2016, então as regras woff eram peso morto, não fallback. O plugin `amare:fonts-woff2-only` remove as regras do CSS e do manifest e tira os arquivos do bundle. É o que derruba o FCP de 1,51 s para 0,91 s em todas as páginas. ## Ativos de marca reencodados (102 KiB) — absorve MAN-122 O logotipo era servido a 512 px de largura para renderizar em 48 px (lockup, cabeçalho e rodapé de toda página) e 32 px (mark, home), com `loading="eager"`. Reencodados a 3× do maior render: lockup 149×144 (84 → 14 KiB) e mark 191×96 (42 → 10 KiB), mesmos nomes de arquivo para não invalidar cache. As variantes `on-dark` foram reencodadas junto por consistência; nenhuma view as usa hoje. Os 16 baselines visuais foram regenerados no runner Linux e o diff é imperceptível a 2× de zoom — mesma forma, mesma cor, menos bytes. ## Efeito medido Mobile, mediana de 3 execuções por página: home 4,58 → 3,39 s; portfolio 3,98 → 1,58 s; portfolio-detalhe 3,98 → 3,01 s; servicos 3,68 → 2,63 s; sobre 3,01 → 1,58 s; contato 2,55 → 1,51 s. Desktop passa com folga em todas (máximo 0,79 s). Acessibilidade, boas práticas e SEO seguem 100, CLS 0,000 e TBT 0 ms. `contato`, `portfolio` e `sobre` entraram na meta. `servicos`, `portfolio-detalhe` e `home` continuam fora, e o que falta está identificado: o elemento de LCP da home é a imagem do hero em JPEG q82 (143 KiB), e `Improve image delivery` estima 0,90 s de ganho restante. O lever é variante WebP em ResponsiveImage com `<picture>` — fora deste escopo porque mexe no pipeline de mídia que o CMS usa para upload. ## Ferramental - `scripts/perf/lighthouse.sh` ganha `/portfolio/{slug}`, passada desktop, mediana de 3 execuções e uma guarda de HTTP 200 antes de auditar. Uma rota em 404 produz relatório com score alto: a rota mais pesada do site apareceria como ótima e ninguém notaria. - `scripts/perf/summarize-lighthouse.mjs` extrai do JSON o elemento de LCP, a decomposição em fases e as requests até o LCP. - `docker/ci-runner.Dockerfile` e `scripts/test/visual-update-ci.sh` versionam a receita de regeneração de baselines, que existia só como checklist. - `CLAUDE.md` corrigido: Pest Browser serve a aplicação de um servidor Amp in-process, não do FrankenPHP. A paridade que importa nos baselines é Linux vs macOS, não o runtime HTTP. - Sem gate de performance no CI (§14.1 e §22). No lugar, orçamento de bytes para os ativos de marca e asserção de que o build só emite woff2. Staging não foi medido: a origem responde 303 para blocked.teams.cloudflare.com a partir da rede corporativa, inclusive em `/up`. Duas hipóteses seguem não validadas por dependerem de `FILESYSTEM_DISK=r2` — os ~4 round trips a R2 por imagem que `x-media.image` faz sem cache, e a falta de `preconnect` para a origem cross-origin de mídia. Ambas documentadas em docs/evidence/lighthouse/README.md. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * perf: servir imagens de conteúdo em webp e descrever sizes corretamente (MAN-109) Fecha a meta de LCP ≤ 2,5 s da SPEC §6.6 em todas as páginas, nos dois presets. Continuação direta do commit anterior: com fontes e marca resolvidas, o elemento de LCP de toda página no mobile passou a ser a imagem do hero, e o Load Delay de 1,88 s era contenção de banda pura. ## Variantes webp `ResponsiveImage::generate()` escreve uma variante `.webp` ao lado de cada variante no formato original, e `x-media.image` a oferece num `<source type="image/webp">`. O `<img>` continua apontando para o formato original: o `<source>` é preferência, não substituição, então nada quebra em quem não decodifica webp, e mídia antiga sem irmãos webp renderiza `<img>` puro como antes. O `<picture>` recebe `display: contents` porque os chamadores estilizam o `<img>` com classes como `h-full w-full object-cover` que resolvem contra o pai grid ou flex — um wrapper inline quebraria isso. O nome do arquivo acrescenta a extensão em vez de trocá-la (`photo-720.jpg.webp`): uploads usam UUID, então colisão já era improvável, mas `photo-720.webp` colidiria com a variante webp de um `photo.png`. ## sizes que descreve a realidade Nenhuma imagem do site ocupa a viewport inteira — todas ficam dentro de `container-amare`, que reserva 1,5rem de padding de cada lado. Declarar `100vw` fazia uma viewport de 412 px em DPR 1,75 pedir 721 px e pular para a variante de 960 para desenhar uma caixa de 637 px. Errar por um pixel custava um terço a mais de bytes em toda página. Com `calc(100vw - 3rem)` a home passa a usar a variante de 720: 74 KiB, contra 143 KiB no início da investigação. Foi também por isso que 720 entrou em `ResponsiveImage::WIDTHS` — sem ela o salto de 480 para 960 é grande demais para a viewport mobile mais comum. ## Efeito medido Mobile, mediana de 3 execuções: home 3,39 → 2,49 s; portfolio-detalhe 3,01 → 2,18 s; servicos 2,63 → 2,03 s; portfolio 1,58 s; sobre 1,58 s; contato 1,50 s. Desktop no máximo 0,65 s. Peso total da home 798 → 347 KiB. Contra o início da investigação (`2e43fde`): home 4,58 → 2,49 s com score de performance 83 → 98. A home passa **em cima da linha** — a pior das três execuções deu 2,57 s. Está documentado como aprovada por margem, não com folga, e os levers restantes estão listados em docs/evidence/lighthouse/README.md em ordem de custo. Os 16 baselines visuais não mudaram: nenhum seeder gera variantes e `media:generate-variants` não roda no runner visual, então naquele ambiente `availableVariants()` volta vazio e o componente renderiza `<img>` puro. A cobertura do caminho com variantes fica em MediaImageComponentTest e ResponsiveImageTest, não nos baselines — anotado como lacuna conhecida. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * docs: registrar a dependência do deploy e corrigir os commits nas evidências Três correções de honestidade nos artefatos de medição. O campo `Commit:` era gravado com o HEAD do momento da coleta, que é sempre anterior ao commit que contém as mudanças medidas — as duas passadas pós-correção diziam `2e43fde` e `65919d4` quando as árvores medidas viraram `65919d4` e `9ab2beb`. Corrigido à mão nos dois arquivos, e o script passa a marcar `+alterações não commitadas` quando a árvore está suja, para o artefato não voltar a afirmar o que não é. O ganho das imagens não aparece em staging nem em produção antes de `media:generate-variants` rodar: lá o disco é `r2` e as variantes webp e a largura 720 ainda não existem para a mídia publicada. O serviço `migrate` do docker-compose.deploy.yml roda o comando sem condição a cada deploy e ele regenera mesmo quando já há variantes, então o primeiro deploy resolve — mas estava implícito e agora está escrito. E o custo que as variantes webp introduzem no lado servidor está registrado com o número certo: `x-media.image` faz 8 `exists()` por imagem no lugar de 3, o que em `r2` são ~9 round trips remotos por imagem e ~36 na home. Isso agrava a hipótese não validada de TTFB em staging em vez de melhorá-la, e promove o cache desses metadados a item mais urgente da lista. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * fix: cache responsive image metadata for R2 * test: atualizar baselines visuais Linux --------- Co-authored-by: manoel.neto <manoel.neto@creditas.com>
312 lines
9.5 KiB
PHP
312 lines
9.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Intervention\Image\Drivers\Gd\Driver;
|
|
use Intervention\Image\ImageManager;
|
|
use Throwable;
|
|
|
|
final class ResponsiveImage
|
|
{
|
|
/**
|
|
* 720 sits between the original 480 and 960 because that is where the common
|
|
* mobile viewport lands: 412 CSS px at a 1.75 device pixel ratio asks for
|
|
* ~721 px, so a full-width image used to jump straight to the 960 variant and
|
|
* pay for a third more pixels than it drew. Measured on the hero (MAN-109):
|
|
* 143 KiB at 960 in jpeg against 75 KiB at 720 in webp.
|
|
*
|
|
* @var list<int>
|
|
*/
|
|
public const WIDTHS = [480, 720, 960, 1440];
|
|
|
|
public static function generate(string $path, ?string $disk = null): void
|
|
{
|
|
self::forgetMetadata($path, $disk);
|
|
$filesystem = self::filesystem($disk);
|
|
|
|
try {
|
|
if (! $filesystem->exists($path)) {
|
|
return;
|
|
}
|
|
|
|
$manager = new ImageManager(new Driver);
|
|
$contents = $filesystem->get($path);
|
|
|
|
if ($contents === null) {
|
|
return;
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
|
|
|
foreach (self::WIDTHS as $width) {
|
|
$variantPath = self::variantPath($path, $width);
|
|
$variant = $manager->read($contents);
|
|
|
|
if ($variant->width() > $width) {
|
|
$variant->scale(width: $width);
|
|
}
|
|
|
|
$encoded = match ($extension) {
|
|
'png' => $variant->toPng(),
|
|
'webp' => $variant->toWebp(quality: 82),
|
|
default => $variant->toJpeg(quality: 82),
|
|
};
|
|
|
|
$filesystem->put($variantPath, (string) $encoded);
|
|
|
|
// A webp sibling for every variant. The MAN-109 audit measured the
|
|
// hero as the LCP element on every mobile page, at 143 KiB for a
|
|
// 960 px jpeg — webp carries the same picture for roughly a third of
|
|
// that. `x-media.image` offers these through a <source> so a browser
|
|
// that cannot decode webp still gets the original format.
|
|
if ($extension === 'webp') {
|
|
continue;
|
|
}
|
|
|
|
$webp = $manager->read($contents);
|
|
|
|
if ($webp->width() > $width) {
|
|
$webp->scale(width: $width);
|
|
}
|
|
|
|
$filesystem->put(
|
|
self::webpVariantPath($path, $width),
|
|
(string) $webp->toWebp(quality: 80)
|
|
);
|
|
}
|
|
} finally {
|
|
self::forgetMetadata($path, $disk);
|
|
}
|
|
|
|
self::metadata($path, $disk);
|
|
}
|
|
|
|
public static function deleteVariants(string $path, ?string $disk = null): void
|
|
{
|
|
self::forgetMetadata($path, $disk);
|
|
$filesystem = self::filesystem($disk);
|
|
|
|
try {
|
|
foreach (self::WIDTHS as $width) {
|
|
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
|
|
if ($filesystem->exists($variantPath)) {
|
|
$filesystem->delete($variantPath);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
self::forgetMetadata($path, $disk);
|
|
}
|
|
}
|
|
|
|
public static function delete(string $path, ?string $disk = null): void
|
|
{
|
|
self::forgetMetadata($path, $disk);
|
|
$filesystem = self::filesystem($disk);
|
|
|
|
try {
|
|
self::deleteVariants($path, $disk);
|
|
|
|
if ($filesystem->exists($path)) {
|
|
$filesystem->delete($path);
|
|
}
|
|
} finally {
|
|
self::forgetMetadata($path, $disk);
|
|
}
|
|
}
|
|
|
|
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
|
|
{
|
|
self::forgetMetadata($previousPath, $disk);
|
|
self::forgetMetadata($newPath, $disk);
|
|
|
|
try {
|
|
if ($previousPath !== '' && $previousPath !== $newPath) {
|
|
self::delete($previousPath, $disk);
|
|
}
|
|
|
|
self::generate($newPath, $disk);
|
|
} finally {
|
|
self::forgetMetadata($previousPath, $disk);
|
|
self::forgetMetadata($newPath, $disk);
|
|
}
|
|
|
|
self::metadata($newPath, $disk);
|
|
}
|
|
|
|
public static function variantPath(string $path, int $width): string
|
|
{
|
|
$directory = trim(dirname($path), '.');
|
|
$filename = pathinfo($path, PATHINFO_FILENAME);
|
|
$extension = pathinfo($path, PATHINFO_EXTENSION);
|
|
$variantName = $filename.'-'.$width.($extension !== '' ? '.'.$extension : '');
|
|
|
|
return $directory === '' ? $variantName : $directory.'/'.$variantName;
|
|
}
|
|
|
|
/**
|
|
* The webp sibling of a variant, named by appending rather than replacing the
|
|
* extension. Uploads are stored under a UUID so a collision is already
|
|
* unlikely, but `photo-480.jpg.webp` cannot collide with the webp variant of
|
|
* a `photo.png` the way `photo-480.webp` would.
|
|
*/
|
|
public static function webpVariantPath(string $path, int $width): string
|
|
{
|
|
return self::variantPath($path, $width).'.webp';
|
|
}
|
|
|
|
/**
|
|
* @return list<array{path: string, width: int}>
|
|
*/
|
|
public static function availableVariants(string $path, ?string $disk = null): array
|
|
{
|
|
return self::metadata($path, $disk)['variants'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* The webp variants that exist for a path. Empty when the media predates
|
|
* `media:generate-variants` running with webp support, which is why
|
|
* `x-media.image` treats the <source> as optional rather than assuming it.
|
|
*
|
|
* @return list<array{path: string, width: int}>
|
|
*/
|
|
public static function availableWebpVariants(string $path, ?string $disk = null): array
|
|
{
|
|
return self::metadata($path, $disk)['webp_variants'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* @return array{width: int, height: int}|null
|
|
*/
|
|
public static function dimensions(string $path, ?string $disk = null): ?array
|
|
{
|
|
$metadata = self::metadata($path, $disk);
|
|
|
|
return $metadata === null ? null : [
|
|
'width' => $metadata['width'],
|
|
'height' => $metadata['height'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* width: int,
|
|
* height: int,
|
|
* variants: list<array{path: string, width: int}>,
|
|
* webp_variants: list<array{path: string, width: int}>
|
|
* }|null
|
|
*/
|
|
public static function metadata(string $path, ?string $disk = null): ?array
|
|
{
|
|
$key = self::metadataCacheKey($path, $disk);
|
|
$cached = self::cachedMetadata($key);
|
|
|
|
if (is_array($cached)) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$filesystem = self::filesystem($disk);
|
|
|
|
if (! $filesystem->exists($path)) {
|
|
return null;
|
|
}
|
|
|
|
$contents = $filesystem->get($path);
|
|
|
|
if ($contents === null) {
|
|
return null;
|
|
}
|
|
|
|
$image = (new ImageManager(new Driver))->read($contents);
|
|
$metadata = [
|
|
'width' => $image->width(),
|
|
'height' => $image->height(),
|
|
'variants' => self::existingVariants($filesystem, $path, false),
|
|
'webp_variants' => self::existingVariants($filesystem, $path, true),
|
|
];
|
|
|
|
self::storeMetadata($key, $metadata);
|
|
|
|
return $metadata;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public static function forgetMetadata(string $path, ?string $disk = null): void
|
|
{
|
|
try {
|
|
Cache::forget(self::metadataCacheKey($path, $disk));
|
|
} catch (Throwable) {
|
|
// Cache availability must not block public media rendering or cleanup.
|
|
}
|
|
}
|
|
|
|
private static function filesystem(?string $disk): Filesystem
|
|
{
|
|
return Storage::disk(self::diskName($disk));
|
|
}
|
|
|
|
/**
|
|
* @return list<array{path: string, width: int}>
|
|
*/
|
|
private static function existingVariants(Filesystem $filesystem, string $path, bool $webp): array
|
|
{
|
|
$variants = [];
|
|
|
|
foreach (self::WIDTHS as $width) {
|
|
$variantPath = $webp
|
|
? self::webpVariantPath($path, $width)
|
|
: self::variantPath($path, $width);
|
|
|
|
if ($filesystem->exists($variantPath)) {
|
|
$variants[] = ['path' => $variantPath, 'width' => $width];
|
|
}
|
|
}
|
|
|
|
return $variants;
|
|
}
|
|
|
|
private static function metadataCacheKey(string $path, ?string $disk): string
|
|
{
|
|
return 'responsive-image:metadata:'.sha1(self::diskName($disk).'|'.$path);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private static function cachedMetadata(string $key): ?array
|
|
{
|
|
try {
|
|
$cached = Cache::get($key);
|
|
|
|
return is_array($cached) ? $cached : null;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $metadata
|
|
*/
|
|
private static function storeMetadata(string $key, array $metadata): void
|
|
{
|
|
try {
|
|
Cache::forever($key, $metadata);
|
|
} catch (Throwable) {
|
|
// The uncached result remains safe to use for this request.
|
|
}
|
|
}
|
|
|
|
private static function diskName(?string $disk): string
|
|
{
|
|
return $disk ?? PublicImageUploadRules::disk();
|
|
}
|
|
}
|