fix(images): hero e cards da home servem masters WebP — AVIF fora do otimizador

Otimizar AVIF no Next 15.4.11 decodifica via libheif (GHSA-2xp9-vwfh-vxw4) e um hang transiente envenena a chave para sempre no Batcher do ResponseCache; o patch oficial (15.5.24) bypassa AVIF. Masters WebP mantêm a entrega e o enquadramento; guards proíbem AVIF em src/ e public/.
This commit is contained in:
Francisco Solla 2026-10-02 18:28:12 -03:00
parent ac39c81765
commit d850782f76
19 changed files with 51 additions and 12 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

View file

@ -108,7 +108,7 @@ async function verticalMark(height) {
/** Foto oficial do candidato (cutout com alfa). */
async function candidatePhoto(height) {
const trim = await sharp(`${REPO}public/JOA00162.avif`)
const trim = await sharp(`${REPO}public/JOA00162.webp`)
.trim({ threshold: 5 })
.png()
.toBuffer({ resolveWithObject: true })

View file

@ -52,14 +52,14 @@ const problemItems: CampaignCarouselItem[] = [
id: 'escala-6x1',
title: 'Pelo fim da escala 6x1!',
body: 'Jornadas longas aumentam o risco de AVC e infarto. Descanso é saúde pública. A bancada de Lula derrotou a direita na Câmara: o fim da 6×1 avançou. Mas a briga continua no Senado, onde a direita tenta barrar o avanço. No dia 4 de outubro, o 1313 é o seu sim ao descanso.',
image: '/fundo.avif',
image: '/fundo.webp',
imageAlt: 'Jorge Solla defendendo direitos dos trabalhadores na Câmara dos Deputados',
},
{
id: 'sus',
title: 'Pra valorizar o SUS',
body: 'O SUS é o sistema de saúde de todo brasileiro. Com Lula, a saúde voltou a receber recursos: Nova PAC de R$ 30,5 bilhões e Mais Médicos retomado. Mas o teto de gastos da direita ainda trava o orçamento. O 1313 é seu voto em defesa do SUS no Congresso!',
image: '/53569851134_02afc18fb4_o.avif',
image: '/53569851134_02afc18fb4_o.webp',
imageAlt: 'Jorge Solla em agenda pública de defesa do SUS',
imageFrame: {
width: 1024,
@ -71,7 +71,7 @@ const problemItems: CampaignCarouselItem[] = [
id: 'mataripe',
title: 'Pra defender os baianos',
body: 'A Refinaria de Mataripe foi vendida no governo Bolsonaro e segue nas mãos de estrangeiros. Com Lula e Jerônimo, a Bahia voltou a andar pra frente; mas recomprar Mataripe exige bancada forte no Congresso. Votar 1313 é devolver à Bahia o que é seu.',
image: '/52396285023_561ffc0ff6_o.avif',
image: '/52396285023_561ffc0ff6_o.webp',
imageAlt: 'Vista da Bahia durante mobilização popular',
},
]

View file

@ -5,28 +5,28 @@ const SUPPORT_URL = 'https://apoiar.me/jorgesolla'
const allyImages = [
{
id: 'rui',
src: '/RUI - 2-2.avif',
src: '/RUI - 2-2.webp',
width: 177,
height: 265,
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'wagner',
src: '/WAGNER - 2-9 final.avif',
src: '/WAGNER - 2-9 final.webp',
width: 177,
height: 265,
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'jeronimo',
src: '/Jeronimo.avif',
src: '/Jeronimo.webp',
width: 177,
height: 265,
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'lula',
src: '/Lula.avif',
src: '/Lula.webp',
width: 216,
height: 213,
sizes:
@ -68,7 +68,7 @@ export const CampaignHero = () => (
<div className="campaign-hero-portrait absolute z-20 overflow-hidden">
<Image
src="/JOA00162.avif"
src="/JOA00162.webp"
alt="Jorge Solla, candidato a deputado federal pela Bahia"
width={495}
height={742}

View file

@ -44,6 +44,19 @@ describe('CampaignHero', () => {
}
})
it('serve masters WebP — AVIF fica fora do otimizador (libheif)', () => {
const { container } = render(<CampaignHero />)
const sources = Array.from(container.querySelectorAll('img')).map(
(image) => image.getAttribute('src') ?? '',
)
expect(sources.length).toBeGreaterThan(0)
for (const source of sources) {
expect(source.endsWith('.webp'), source).toBe(true)
}
})
it('expõe as provas e as fotos com nomes acessíveis', () => {
render(<CampaignHero />)

View file

@ -12,6 +12,12 @@ const MAX_HEIGHT = 2400
const LOCAL_IMAGE_REF = /src[=:]\s*['"](\/[^'"`]+\.(?:avif|webp|png|jpe?g))['"]/g
// `image:` covers the home card data (`CampaignCarouselItem.image`), which is
// also rendered through next/image — the dimension budget above only walks
// `src` literals on purpose (the card masters predate the budget), but the
// AVIF ban below must catch both spellings.
const LOCAL_IMAGE_LITERAL = /(?:src|image)[=:]\s*['"](\/[^'"`]+\.(?:avif|webp|png|jpe?g))['"]/g
const publicFile = (src: string) =>
fileURLToPath(new URL(`../../public${decodeURIComponent(src)}`, import.meta.url))
@ -24,14 +30,14 @@ const localHeroAssets = [
...new Set([...campaignHeroSource.matchAll(LOCAL_IMAGE_REF)].map(([, src]) => src)),
]
const collectSourceImageRefs = () => {
const collectSourceImageRefs = (pattern: RegExp) => {
const refs = new Set<string>()
const srcDir = fileURLToPath(new URL('../../src', import.meta.url))
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) continue
const code = readFileSync(join(entry.parentPath, entry.name), 'utf8')
for (const [, src] of code.matchAll(LOCAL_IMAGE_REF)) refs.add(src)
for (const [, src] of code.matchAll(pattern)) refs.add(src)
}
return refs
@ -49,7 +55,9 @@ describe('entrega das imagens do hero (perf do /_next/image)', () => {
})
it('mantém todo asset local entregue via next/image dentro do orçamento', async () => {
const refs = [...collectSourceImageRefs()].filter((src) => existsSync(publicFile(src)))
const refs = [...collectSourceImageRefs(LOCAL_IMAGE_REF)].filter((src) =>
existsSync(publicFile(src)),
)
expect(refs.length).toBeGreaterThan(0)
for (const src of refs) {
@ -59,6 +67,24 @@ describe('entrega das imagens do hero (perf do /_next/image)', () => {
}
})
it('não entrega AVIF pelo otimizador (libheif: GHSA-2xp9-vwfh-vxw4 e travamento por chave)', () => {
const refs = [...collectSourceImageRefs(LOCAL_IMAGE_LITERAL)]
expect(refs.length).toBeGreaterThan(0)
for (const src of refs) {
expect(src.endsWith('.avif'), src).toBe(false)
}
})
it('não mantém AVIF em public/ — o otimizador decodifica qualquer arquivo local por URL', () => {
const publicDir = fileURLToPath(new URL('../../public', import.meta.url))
const avifFiles = readdirSync(publicDir, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.avif'))
.map((entry) => entry.name)
expect(avifFiles).toEqual([])
})
it('configura TTL longo para os derivados do otimizador', () => {
const nextConfig = readFileSync(
fileURLToPath(new URL('../../next.config.mjs', import.meta.url)),