From 95039dc5f8c62491562e7d0476bfeaf33b8a7bde Mon Sep 17 00:00:00 2001 From: lora <3526039967@qq.com> Date: Thu, 10 Sep 2026 11:59:01 +0800 Subject: [PATCH 1/5] test(images): measure cold image transfers and capture unchanged pages --- scripts/test-image-delivery.mjs | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 scripts/test-image-delivery.mjs diff --git a/scripts/test-image-delivery.mjs b/scripts/test-image-delivery.mjs new file mode 100644 index 0000000..69ecac4 --- /dev/null +++ b/scripts/test-image-delivery.mjs @@ -0,0 +1,90 @@ +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 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)})}} +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) + // Wait for actual transfer settlement, rather than hiding a timeout with a fixed sleep. + 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}) + 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'}) + for(const selector of ['#work','[data-project-card]','.personal-showcase']){ + const target=page.locator(selector).first();if(!await target.count())continue + await target.scrollIntoViewIfNeeded() + for(const image of await target.locator('img').all()){ + if(!await image.isVisible())continue + await image.scrollIntoViewIfNeeded() + await image.evaluate(async img=>{if(!img.complete)await new Promise((resolve,reject)=>{img.addEventListener('load',resolve,{once:true});img.addEventListener('error',reject,{once:true})});await img.decode()}) + } + await target.scrollIntoViewIfNeeded();const file=`${name}-${selector.replace(/[^a-zA-Z]/g,'')}.png` + await page.screenshot({path:path.join(out,file),animations:'disabled'});report.screenshots.push({file,route,width,state:selector}) + } + } + }) + await context.close();await writeFile(path.join(out,'report.json'),JSON.stringify(report,null,2)) + } +}finally{await browser.close();if(server)await new Promise(resolve=>server.close(resolve));report.completedAt=new Date().toISOString();await writeFile(path.join(out,'report.json'),JSON.stringify(report,null,2))} +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 From 60b8cb2f6c59e2d6af8b7ac29288c19ebf13412b Mon Sep 17 00:00:00 2001 From: lora <3526039967@qq.com> Date: Thu, 10 Sep 2026 11:59:24 +0800 Subject: [PATCH 2/5] ci(images): record a read-only baseline before image optimization --- .github/workflows/verify-images.yml | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/verify-images.yml diff --git a/.github/workflows/verify-images.yml b/.github/workflows/verify-images.yml new file mode 100644 index 0000000..3248412 --- /dev/null +++ b/.github/workflows/verify-images.yml @@ -0,0 +1,71 @@ +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-delivery.mjs' + - '.github/workflows/verify-images.yml' +permissions: + contents: read +concurrency: + group: image-delivery-${{ github.ref }} + cancel-in-progress: true +jobs: + measure: + runs-on: ubuntu-latest + timeout-minutes: 25 + 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: 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: Measure current production build + env: + PLAYWRIGHT_MODULE: ${{ runner.temp }}/image-tools/node_modules/playwright/index.mjs + IMAGE_TEST_OUTPUT: ${{ runner.temp }}/image-evidence/baseline + IMAGE_TEST_LABEL: baseline + IMAGE_TEST_COMMIT: ${{ github.sha }} + run: node scripts/test-image-delivery.mjs dist + - name: Measure published site without changing it + if: ${{ !cancelled() }} + env: + PLAYWRIGHT_MODULE: ${{ runner.temp }}/image-tools/node_modules/playwright/index.mjs + IMAGE_TEST_OUTPUT: ${{ runner.temp }}/image-evidence/published + IMAGE_TEST_LABEL: published + IMAGE_TEST_URL: https://lora-sys.github.io/loraSys/ + IMAGE_TEST_ROUTES: ',projects/' + run: node scripts/test-image-delivery.mjs + - 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 From dd1b289b4f911f3fe48edfe58811913f04fbd41f Mon Sep 17 00:00:00 2001 From: lora <3526039967@qq.com> Date: Thu, 10 Sep 2026 12:02:59 +0800 Subject: [PATCH 3/5] test(images): bound lazy-image waits and save each cold measurement immediately --- scripts/test-image-delivery.mjs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/scripts/test-image-delivery.mjs b/scripts/test-image-delivery.mjs index 69ecac4..d960827 100644 --- a/scripts/test-image-delivery.mjs +++ b/scripts/test-image-delivery.mjs @@ -34,7 +34,8 @@ if (!site) { 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 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)})}} +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 { @@ -60,31 +61,31 @@ try { 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) - // Wait for actual transfer settlement, rather than hiding a timeout with a fixed sleep. 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'}) - for(const selector of ['#work','[data-project-card]','.personal-showcase']){ - const target=page.locator(selector).first();if(!await target.count())continue + const target=page.locator('[data-project-card]').first() + if(await target.count()){ await target.scrollIntoViewIfNeeded() - for(const image of await target.locator('img').all()){ - if(!await image.isVisible())continue + const image=target.locator('img').first() + if(await image.count()){ await image.scrollIntoViewIfNeeded() - await image.evaluate(async img=>{if(!img.complete)await new Promise((resolve,reject)=>{img.addEventListener('load',resolve,{once:true});img.addEventListener('error',reject,{once:true})});await img.decode()}) + await page.waitForFunction(img=>img.complete&&img.naturalWidth>0,await image.elementHandle(),{timeout:15000}) + await image.evaluate(img=>img.decode()) } - await target.scrollIntoViewIfNeeded();const file=`${name}-${selector.replace(/[^a-zA-Z]/g,'')}.png` - await page.screenshot({path:path.join(out,file),animations:'disabled'});report.screenshots.push({file,route,width,state:selector}) + 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 writeFile(path.join(out,'report.json'),JSON.stringify(report,null,2)) + await context.close();await save() } -}finally{await browser.close();if(server)await new Promise(resolve=>server.close(resolve));report.completedAt=new Date().toISOString();await writeFile(path.join(out,'report.json'),JSON.stringify(report,null,2))} +}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 From a9a915cd61593a96e9581be0faccb8730dc339a7 Mon Sep 17 00:00:00 2001 From: lora <3526039967@qq.com> Date: Thu, 10 Sep 2026 12:09:08 +0800 Subject: [PATCH 4/5] perf(images): serve responsive AVIF with WebP fallback without changing artwork --- .github/workflows/verify-images.yml | 34 ++++++++++++---------- src/components/lora/LoraVisual.astro | 14 ++++++--- src/components/projects/ProjectMedia.astro | 10 ++++--- src/data/lora-visual.ts | 6 ++-- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/.github/workflows/verify-images.yml b/.github/workflows/verify-images.yml index 3248412..c2cb2a5 100644 --- a/.github/workflows/verify-images.yml +++ b/.github/workflows/verify-images.yml @@ -13,12 +13,12 @@ on: permissions: contents: read concurrency: - group: image-delivery-${{ github.ref }} + group: image-delivery-${{ github.event.pull_request.head.ref || github.ref_name }} cancel-in-progress: true jobs: measure: runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 30 env: NODE_OPTIONS: --max-old-space-size=3072 steps: @@ -33,26 +33,30 @@ jobs: 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: Measure current production build + - name: Compare cold loads on the same runner env: PLAYWRIGHT_MODULE: ${{ runner.temp }}/image-tools/node_modules/playwright/index.mjs - IMAGE_TEST_OUTPUT: ${{ runner.temp }}/image-evidence/baseline - IMAGE_TEST_LABEL: baseline IMAGE_TEST_COMMIT: ${{ github.sha }} - run: node scripts/test-image-delivery.mjs dist - - name: Measure published site without changing it - if: ${{ !cancelled() }} - env: - PLAYWRIGHT_MODULE: ${{ runner.temp }}/image-tools/node_modules/playwright/index.mjs - IMAGE_TEST_OUTPUT: ${{ runner.temp }}/image-evidence/published - IMAGE_TEST_LABEL: published - IMAGE_TEST_URL: https://lora-sys.github.io/loraSys/ - IMAGE_TEST_ROUTES: ',projects/' - run: node scripts/test-image-delivery.mjs + run: | + failed=0 + for sample in 1 2 3; do + 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_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: Preserve measurements and screenshots if: always() uses: actions/upload-artifact@v4 diff --git a/src/components/lora/LoraVisual.astro b/src/components/lora/LoraVisual.astro index a14fa40..7877e6c 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,23 @@ 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 actual laid-out width. Older browsers retain the explicit fallback. +const sizes = priority ? visual.sizes : `auto, ${visual.sizes}` +const highPriority = priority && !['heroMochi', 'stampVerified', 'stampExperiment', 'stampContribution'].includes(asset) --- -{decorative diff --git a/src/components/projects/ProjectMedia.astro b/src/components/projects/ProjectMedia.astro index 92be4cd..92fdfad 100644 --- a/src/components/projects/ProjectMedia.astro +++ b/src/components/projects/ProjectMedia.astro @@ -1,5 +1,5 @@ --- -import { Image } from 'astro:assets' +import { Picture } from 'astro:assets' import { uiLocale, text } from '@/i18n/ui' import type { Project } from '@/data/projects' @@ -45,11 +45,13 @@ const fallbackMark = project.title { source && ( - {text(locale, Date: Thu, 10 Sep 2026 12:30:37 +0800 Subject: [PATCH 5/5] perf(images): retain efficient WebP covers and preserve hero detail after measurement --- .github/workflows/verify-images.yml | 16 ++- scripts/test-image-formats.mjs | 128 +++++++++++++++++++++ src/components/lora/LoraVisual.astro | 7 +- src/components/projects/ProjectMedia.astro | 11 +- 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 scripts/test-image-formats.mjs diff --git a/.github/workflows/verify-images.yml b/.github/workflows/verify-images.yml index c2cb2a5..ba1924b 100644 --- a/.github/workflows/verify-images.yml +++ b/.github/workflows/verify-images.yml @@ -8,7 +8,7 @@ on: - 'src/components/lora/**' - 'src/components/projects/**' - 'src/data/lora-visual.ts' - - 'scripts/test-image-delivery.mjs' + - 'scripts/test-image-*.mjs' - '.github/workflows/verify-images.yml' permissions: contents: read @@ -49,14 +49,22 @@ jobs: - name: Compare cold loads on the same runner env: PLAYWRIGHT_MODULE: ${{ runner.temp }}/image-tools/node_modules/playwright/index.mjs - IMAGE_TEST_COMMIT: ${{ github.sha }} 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_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_LABEL="after-$sample" IMAGE_TEST_OUTPUT="$RUNNER_TEMP/image-evidence/after-$sample" node scripts/test-image-delivery.mjs dist || failed=1 + 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 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 7877e6c..d365780 100644 --- a/src/components/lora/LoraVisual.astro +++ b/src/components/lora/LoraVisual.astro @@ -43,9 +43,12 @@ 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 actual laid-out width. Older browsers retain the explicit fallback. +// 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) --- ( '/src/assets/projects/*.{avif,webp,png,jpg,jpeg}' ) @@ -45,13 +48,11 @@ const fallbackMark = project.title { source && ( -