diff --git a/.github/workflows/verify-images.yml b/.github/workflows/verify-images.yml new file mode 100644 index 0000000..ba1924b --- /dev/null +++ b/.github/workflows/verify-images.yml @@ -0,0 +1,83 @@ +name: Verify image delivery +on: + push: + branches: [perf/image-loading-20260910] + pull_request: + branches: [main] + paths: + - 'src/components/lora/**' + - 'src/components/projects/**' + - 'src/data/lora-visual.ts' + - 'scripts/test-image-*.mjs' + - '.github/workflows/verify-images.yml' +permissions: + contents: read +concurrency: + group: image-delivery-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: true +jobs: + measure: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + NODE_OPTIONS: --max-old-space-size=3072 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: bun install --frozen-lockfile + - run: bun run validate:sync && bun run build + - name: Check out the comparison baseline after candidate build + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha || '601e50bb63de67eb76d28fc52b7dbf0b2360a37e' }} + path: .image-baseline + persist-credentials: false + - name: Build baseline with its locked dependencies + working-directory: .image-baseline + run: bun install --frozen-lockfile && bun run validate:sync && bun run build + - name: Install isolated browser runner + run: | + npm install --prefix "$RUNNER_TEMP/image-tools" --no-save --package-lock=false playwright@1.58.2 + node "$RUNNER_TEMP/image-tools/node_modules/playwright/cli.js" install --with-deps chromium + - name: Compare cold loads on the same runner + env: + PLAYWRIGHT_MODULE: ${{ runner.temp }}/image-tools/node_modules/playwright/index.mjs + run: | + failed=0 + before_sha="$(git -C .image-baseline rev-parse HEAD)" + after_sha="$(git rev-parse HEAD)" + for sample in 1 2 3; do + IMAGE_TEST_COMMIT="$before_sha" IMAGE_TEST_LABEL="before-$sample" IMAGE_TEST_OUTPUT="$RUNNER_TEMP/image-evidence/before-$sample" node scripts/test-image-delivery.mjs .image-baseline/dist || failed=1 + IMAGE_TEST_COMMIT="$after_sha" IMAGE_TEST_LABEL="after-$sample" IMAGE_TEST_OUTPUT="$RUNNER_TEMP/image-evidence/after-$sample" node scripts/test-image-delivery.mjs dist || failed=1 + done + exit "$failed" + - name: Verify native WebP fallback and unchanged artwork + if: ${{ !cancelled() }} + env: + PLAYWRIGHT_MODULE: ${{ runner.temp }}/image-tools/node_modules/playwright/index.mjs + IMAGE_TEST_OUTPUT: ${{ runner.temp }}/image-evidence/formats + IMAGE_TEST_COMMIT: ${{ github.sha }} + run: node scripts/test-image-formats.mjs dist + - name: Preserve measurements and screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: image-delivery-evidence + path: ${{ runner.temp }}/image-evidence + retention-days: 14 + - name: Preserve production output for visual review + if: ${{ !cancelled() }} + run: tar -czf "$RUNNER_TEMP/image-production.tar.gz" -C dist . + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: image-production + path: ${{ runner.temp }}/image-production.tar.gz + retention-days: 7 diff --git a/scripts/test-image-delivery.mjs b/scripts/test-image-delivery.mjs new file mode 100644 index 0000000..d960827 --- /dev/null +++ b/scripts/test-image-delivery.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { readFile, stat, mkdir, writeFile } from 'node:fs/promises' +import { gzipSync } from 'node:zlib' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' + +const root = path.resolve(process.argv[2] || 'dist') +const out = path.resolve(process.env.IMAGE_TEST_OUTPUT || '/tmp/image-delivery') +const basePath = '/loraSys/' +const mime = {'.html':'text/html','.css':'text/css','.js':'text/javascript','.json':'application/json','.webp':'image/webp','.avif':'image/avif','.jpg':'image/jpeg','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2','.woff':'font/woff','.wasm':'application/wasm','.pdf':'application/pdf'} +await mkdir(out,{recursive:true}) +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) +let server +let site = process.env.IMAGE_TEST_URL +if (!site) { + server = createServer(async(req,res)=>{ + try { + const url = new URL(req.url,'http://localhost') + if(!['GET','HEAD'].includes(req.method)||!url.pathname.startsWith(basePath)){res.writeHead(404);res.end();return} + let file = path.resolve(root, decodeURIComponent(url.pathname.slice(basePath.length))) + if(file!==root&&!file.startsWith(root+path.sep)){res.writeHead(403);res.end();return} + if((await stat(file)).isDirectory())file=path.join(file,'index.html') + let body=await readFile(file);const ext=path.extname(file) + const headers={'Content-Type':mime[ext]||'application/octet-stream','Cache-Control':'public, max-age=600'} + if(['.html','.js','.css','.json','.svg'].includes(ext)&&req.headers['accept-encoding']?.includes('gzip')){body=gzipSync(body);headers['Content-Encoding']='gzip'} + headers['Content-Length']=String(body.length);res.writeHead(200,headers);res.end(req.method==='HEAD'?undefined:body) + }catch{res.writeHead(404);res.end()} + }) + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)) + site=`http://127.0.0.1:${server.address().port}${basePath}` +} +const report={version:1,site,commit:process.env.IMAGE_TEST_COMMIT||null,label:process.env.IMAGE_TEST_LABEL||'measurement',startedAt:new Date().toISOString(),environment:{downloadBitsPerSecond:1600000,uploadBitsPerSecond:768000,latencyMs:150,cpuSlowdown:4,cache:'disabled',reducedMotion:'reduce'},samples:[],checks:[],screenshots:[]} +const browser=await chromium.launch({headless:true,channel:'chromium'}) +report.environment.browser=browser.version() +const save=()=>writeFile(path.join(out,'report.json'),JSON.stringify(report,null,2)) +const checks=async(name,fn)=>{try{await fn();report.checks.push({name,passed:true})}catch(e){report.checks.push({name,passed:false,error:String(e.stack)});console.error(name,e.message)}await save()} +const rounds=Number(process.env.IMAGE_TEST_ROUNDS||'1') +const routes=(process.env.IMAGE_TEST_ROUTES||',en/,projects/,en/work/').split(',') +try { + for(let round=1;round<=rounds;round++)for(const width of [390,1440])for(const route of routes){ + const context=await browser.newContext({viewport:{width,height:width===390?844:1000},deviceScaleFactor:width===390?2:1,isMobile:width===390,hasTouch:width===390,reducedMotion:'reduce',serviceWorkers:'block'}) + const page=await context.newPage();page.setDefaultTimeout(15000) + const errors=[];page.on('pageerror',e=>errors.push(e.message)) + const cdp=await context.newCDPSession(page) + await cdp.send('Network.enable');await cdp.send('Network.setCacheDisabled',{cacheDisabled:true}) + await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:150,downloadThroughput:1600000/8,uploadThroughput:768000/8,connectionType:'cellular4g'}) + await cdp.send('Emulation.setCPUThrottlingRate',{rate:4}) + const net=new Map() + cdp.on('Network.requestWillBeSent',e=>net.set(e.requestId,{url:e.request.url,type:e.type,priority:e.request.initialPriority,start:e.timestamp,initiator:e.initiator.type})) + cdp.on('Network.responseReceived',e=>Object.assign(net.get(e.requestId)||{},{status:e.response.status,mime:e.response.mimeType,cache:e.response.fromDiskCache||false})) + cdp.on('Network.loadingFinished',e=>Object.assign(net.get(e.requestId)||{},{bytes:e.encodedDataLength,end:e.timestamp})) + cdp.on('Network.loadingFailed',e=>Object.assign(net.get(e.requestId)||{},{error:e.errorText})) + await page.addInitScript(()=>{ + window.__imageMetrics={lcp:0,cls:0,lcpUrl:'',lcpTag:''} + new PerformanceObserver(list=>{for(const e of list.getEntries()){window.__imageMetrics.lcp=e.startTime;window.__imageMetrics.lcpUrl=e.url||'';window.__imageMetrics.lcpTag=e.element?.tagName||''}}).observe({type:'largest-contentful-paint',buffered:true}) + new PerformanceObserver(list=>{for(const e of list.getEntries())if(!e.hadRecentInput)window.__imageMetrics.cls+=e.value}).observe({type:'layout-shift',buffered:true}) + }) + const name=`${width}-${route.replaceAll('/','-')||'home'}-${round}` + await checks(name,async()=>{ + const response=await page.goto(new URL(route,site).href,{waitUntil:'load',timeout:60000});assert.equal(response.status(),200) + await page.evaluate(()=>document.fonts.ready) + await page.waitForLoadState('networkidle',{timeout:30000}) + const info=await page.evaluate(()=>({metrics:window.__imageMetrics,elapsed:performance.now(),overflow:document.documentElement.scrollWidth-innerWidth,images:[...document.images].map(img=>{const r=img.getBoundingClientRect();return {src:img.getAttribute('src'),url:img.currentSrc,srcset:img.srcset,sizes:img.sizes,loading:img.loading,priority:img.fetchPriority,complete:img.complete,naturalWidth:img.naturalWidth,naturalHeight:img.naturalHeight,width:r.width,height:r.height,top:r.top,visible:r.width>0&&r.height>0,fold:r.width>0&&r.height>0&&r.top0,alt:img.alt}})})) + const requests=[...net.values()];const images=requests.filter(n=>n.type==='Image'||n.mime?.startsWith('image/')) + report.samples.push({name,route,width,dpr:width===390?2:1,round,documentSha256:createHash('sha256').update(await response.body()).digest('hex'),...info,imageBytes:images.reduce((s,n)=>s+(n.bytes||0),0),imageRequests:images.length,totalBytes:requests.reduce((s,n)=>s+(n.bytes||0),0),requests,errors}) + await save();console.log('MEASURED',name,images.reduce((sum,n)=>sum+(n.bytes||0),0)) + assert.ok(info.overflow<=1,`Horizontal overflow ${info.overflow}`) + assert.deepEqual(errors,[],'Uncaught browser errors') + assert.ok(info.images.filter(i=>i.fold).every(i=>i.complete&&i.naturalWidth>0),'All first-screen images must decode') + if(round===1){ + const screen=`${name}.png`;await page.screenshot({path:path.join(out,screen),animations:'disabled'});report.screenshots.push({file:screen,route,width,state:'initial'}) + const target=page.locator('[data-project-card]').first() + if(await target.count()){ + await target.scrollIntoViewIfNeeded() + const image=target.locator('img').first() + if(await image.count()){ + await image.scrollIntoViewIfNeeded() + await page.waitForFunction(img=>img.complete&&img.naturalWidth>0,await image.elementHandle(),{timeout:15000}) + await image.evaluate(img=>img.decode()) + } + const file=`${name}-project.png`;await page.screenshot({path:path.join(out,file),animations:'disabled'});report.screenshots.push({file,route,width,state:'project'}) + } + } + }) + await context.close();await save() + } +}finally{await browser.close();if(server)await new Promise(resolve=>server.close(resolve));report.completedAt=new Date().toISOString();await save()} +console.log(JSON.stringify({label:report.label,samples:report.samples.map(({name,imageBytes,imageRequests,metrics})=>({name,imageBytes,imageRequests,metrics})),failed:report.checks.filter(c=>!c.passed)},null,2)) +if(report.checks.some(c=>!c.passed))process.exitCode=1 diff --git a/scripts/test-image-formats.mjs b/scripts/test-image-formats.mjs new file mode 100644 index 0000000..a9b7615 --- /dev/null +++ b/scripts/test-image-formats.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { readFile, readdir, stat, mkdir, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const root = path.resolve(process.argv[2] || 'dist') +const output = path.resolve(process.env.IMAGE_TEST_OUTPUT || '/tmp/image-formats') +const baseline = path.resolve(process.env.IMAGE_BASELINE_ROOT || '.image-baseline') +await mkdir(output, { recursive: true }) +const mime = { '.html':'text/html', '.css':'text/css', '.js':'text/javascript', '.json':'application/json', '.avif':'image/avif', '.webp':'image/webp', '.png':'image/png', '.jpg':'image/jpeg', '.svg':'image/svg+xml', '.woff2':'font/woff2', '.woff':'font/woff', '.wasm':'application/wasm' } +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://localhost') + if (!['GET', 'HEAD'].includes(req.method) || !url.pathname.startsWith('/loraSys/')) { res.writeHead(404); res.end(); return } + let file = path.resolve(root, decodeURIComponent(url.pathname.slice('/loraSys/'.length))) + if (file !== root && !file.startsWith(root + path.sep)) { res.writeHead(403); res.end(); return } + if ((await stat(file)).isDirectory()) file = path.join(file, 'index.html') + const data = await readFile(file) + res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' }) + res.end(req.method === 'HEAD' ? undefined : data) + } catch { res.writeHead(404); res.end() } +}) +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) +const site = `http://127.0.0.1:${server.address().port}/loraSys/` +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) +const browser = await chromium.launch({ headless:true, channel:'chromium' }) +const report = { commit:process.env.IMAGE_TEST_COMMIT, browser:browser.version(), startedAt:new Date().toISOString(), checks:[], images:[], comparisons:[], screenshots:[], originalAssets:[] } +async function check(name, fn) { + try { await fn(); report.checks.push({ name, passed:true }); console.log('PASS', name) } + catch (error) { report.checks.push({ name, passed:false, error:String(error.stack) }); console.error('FAIL', name, error.message) } + await writeFile(path.join(output, 'report.json'), JSON.stringify(report, null, 2)) +} +async function inventory(directory, relative = '') { + const entries = await readdir(path.join(directory, relative), { withFileTypes:true }) + const result = [] + for (const entry of entries) { + const name = path.join(relative, entry.name) + if (entry.isDirectory()) result.push(...await inventory(directory, name)) + else result.push({ path:name, sha256:createHash('sha256').update(await readFile(path.join(directory, name))).digest('hex') }) + } + return result.sort((a,b) => a.path.localeCompare(b.path)) +} +const median = values => [...values].sort((a,b) => a-b)[Math.floor(values.length/2)] +try { + await check('Original illustration and project assets are unchanged', async () => { + for (const directory of ['src/assets/lora-visual', 'src/assets/projects']) { + const before = await inventory(path.join(baseline, directory)) + const after = await inventory(path.resolve(directory)) + assert.deepEqual(after, before, directory) + report.originalAssets.push({ directory, files:after }) + } + }) + for (const route of ['', 'en/', 'projects/', 'en/work/']) for (const width of [390,1440]) { + await check(`${width} ${route || 'home'}: no image payload or fold geometry regression`, async () => { + const runs = { before:[], after:[] } + for (const phase of ['before','after']) for (let sample=1;sample<=3;sample++) { + const data=JSON.parse(await readFile(path.join(path.dirname(output),`${phase}-${sample}`,'report.json'),'utf8')) + assert.ok(data.checks.length && data.checks.every(item=>item.passed)) + const row=data.samples.find(item=>item.route===route && item.width===width) + assert.ok(row,'Each comparison requires all three samples') + runs[phase].push(row) + } + const before=median(runs.before.map(row=>row.imageBytes)),after=median(runs.after.map(row=>row.imageBytes)) + const result={route,width,beforeImageBytes:before,afterImageBytes:after,beforeLcpMs:median(runs.before.map(row=>row.metrics.lcp)),afterLcpMs:median(runs.after.map(row=>row.metrics.lcp))} + report.comparisons.push(result) + assert.ok(after<=before*1.02,`Image payload regression: ${before} -> ${after}`) + assert.ok(runs.after.every(row=>row.metrics.cls<=0.03),'Layout shift exceeds the existing budget') + assert.equal(runs.before[0].images.length,runs.after[0].images.length,'Do not save bandwidth by deleting images') + runs.before[0].images.forEach((image,index)=>{ + const next=runs.after[0].images[index] + if(!image.fold && !next.fold)return + for(const key of ['width','height','top'])assert.ok(Math.abs(image[key]-next[key])<=1,`First-screen image ${index} changed ${key}`) + }) + }) + } + for (const format of ['avif', 'webp']) for (const width of [390,1440]) for (const lang of ['zh','en']) { + const context = await browser.newContext({ viewport:{width,height:width === 390 ? 844 : 1000}, deviceScaleFactor:width === 390 ? 2 : 1, reducedMotion:'reduce', colorScheme:format === 'webp' ? 'dark' : 'light', serviceWorkers:'block' }) + await context.route('**/*', route => ['GET','HEAD'].includes(route.request().method()) && new URL(route.request().url()).origin === new URL(site).origin ? route.continue() : route.abort()) + const page = await context.newPage() + page.setDefaultTimeout(15000) + const errors = []; page.on('pageerror', error => errors.push(error.message)) + const cdp = await context.newCDPSession(page) + if (format === 'webp') await cdp.send('Emulation.setDisabledImageTypes', { imageTypes:['avif'] }) + for (const route of lang === 'zh' ? ['', 'projects/'] : ['en/', 'en/work/']) { + const name = `${format}-${width}-${route.replaceAll('/','-') || 'home'}` + await check(name, async () => { + assert.equal((await page.goto(new URL(route, site).href, {waitUntil:'load'})).status(), 200) + await page.evaluate(() => document.fonts.ready) + const heroImages = await page.locator('.hero-scene picture img').all() + const project = page.locator('[data-project-card]').first() + const targets = [...heroImages, ...await page.locator('main img.lora-visual--projectsMap').all(), ...await project.locator('img[data-project-poster]').all()] + assert.ok(targets.length > 0, 'Changed image components must be present') + for (const image of targets) { + await image.scrollIntoViewIfNeeded() + await page.waitForFunction(img => img.complete && img.naturalWidth > 0, await image.elementHandle()) + await image.evaluate(img => img.decode()) + const data = await image.evaluate(img => ({ url:img.currentSrc, fallback:img.src, isProject:img.hasAttribute('data-project-poster'), width:img.getBoundingClientRect().width, height:img.getBoundingClientRect().height, alt:img.alt, loading:img.loading, sizes:img.sizes, avifSource:img.closest('picture')?.querySelector('source[type="image/avif"]')?.srcset, hasDimensions:Number(img.getAttribute('width')) > 0 && Number(img.getAttribute('height')) > 0 })) + const expectedFormat=data.isProject ? 'webp' : format + assert.ok(new URL(data.url).pathname.endsWith(`.${expectedFormat}`), data.url) + assert.ok(new URL(data.fallback).pathname.endsWith('.webp'), data.fallback) + assert.ok(data.isProject || data.avifSource) + assert.ok(data.hasDimensions && data.width > 0 && data.height > 0) + report.images.push({ name, ...data }) + } + assert.deepEqual(errors, []) + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)) + await project.scrollIntoViewIfNeeded() + const cardFile = `${name}-card.png` + await page.screenshot({path:path.join(output, cardFile), animations:'disabled'}) + report.screenshots.push({file:cardFile, width, lang, format, state:'project', route}) + await page.evaluate(() => window.scrollTo({ top:0, behavior:'instant' })) + await page.waitForFunction(() => window.scrollY === 0) + const file = `${name}.png` + await page.screenshot({path:path.join(output,file), animations:'disabled'}) + report.screenshots.push({file, width, lang, format, state:'top', route}) + }) + } + await context.close() + } +} finally { + await browser.close() + await new Promise(resolve => server.close(resolve)) + report.completedAt = new Date().toISOString() + await writeFile(path.join(output,'report.json'),JSON.stringify(report,null,2)) +} +if (report.checks.some(check => !check.passed)) process.exitCode = 1 diff --git a/src/components/lora/LoraVisual.astro b/src/components/lora/LoraVisual.astro index a14fa40..d365780 100644 --- a/src/components/lora/LoraVisual.astro +++ b/src/components/lora/LoraVisual.astro @@ -1,5 +1,5 @@ --- -import { Image } from 'astro:assets' +import { Picture } from 'astro:assets' import { loraVisuals, type LoraVisualKey, type VisualEntry } from '@/data/lora-visual' import { uiLocale } from '@/i18n/ui' @@ -43,17 +43,26 @@ const { asset, class: className, priority = false, alt, quality, widths } = Astr const visual: VisualEntry = loraVisuals[asset] const resolvedAlt = alt ?? (uiLocale(Astro.url.pathname) === 'en-US' ? englishAlt[asset] : visual.alt) const decorative = visual.decorative ?? resolvedAlt === '' +// Lazy images can use their laid-out width, with an explicit fallback for older browsers. +const sizes = priority ? visual.sizes : `auto, ${visual.sizes}` +const highPriority = priority && !['heroMochi', 'stampVerified', 'stampExperiment', 'stampContribution'].includes(asset) +// Keep fine pencil lines readable; the first 480px hero candidate was visibly softer. +const minimumHeroWidth = asset === 'heroMochi' ? 320 : ['heroLora', 'heroProps'].includes(asset) ? 640 : 0 +const imageWidths = widths ?? visual.widths.filter(width => width >= minimumHeroWidth) --- -{decorative diff --git a/src/components/projects/ProjectMedia.astro b/src/components/projects/ProjectMedia.astro index 92be4cd..7d95006 100644 --- a/src/components/projects/ProjectMedia.astro +++ b/src/components/projects/ProjectMedia.astro @@ -20,6 +20,9 @@ const { quality = 74, locale = uiLocale(Astro.url.pathname) } = Astro.props +// Cards stay inside the page gutters. Keep explicit desktop sizes: offscreen +// content-visibility placeholders overestimate their width with native auto sizes. +const deliverySizes = `(max-width: 639px) calc(100vw - 2rem), ${sizes}` const images = import.meta.glob<{ default: ImageMetadata }>( '/src/assets/projects/*.{avif,webp,png,jpg,jpeg}' ) @@ -48,8 +51,8 @@ const fallbackMark = project.title