- commit
- 09378f89521c2a72b0fe367b8fd4b040681ee557
- parent
- 5aa71722eb4432fb419e0cb2de63193a2e7728c5
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 2 | + |
| M | _headers | 3 | + |
| A | ela.html | 54 | ++++++++++++++++++ |
| A | ela.mjs | 92 | ++++++++++++++++++++++++++++++ |
| A | elaworker.mjs | 34 | +++++++++++ |
| M | home.mjs | 3 | +- |
| M | index.html | 7 | ++ |
| M | manifest.webmanifest | 3 | +- |
| M | scripts/ | 6 | +- |
| M | sw.js | 2 | +- |
@@ -6,6 +6,7 @@ Tools:
66 77 - `/fuji`: develops a Fujifilm RAF in the browser with LibRaw (WebAssembly) and applies any of 16 film simulation looks via 3D LUTs, then saves a JPEG. The in-camera JPEG can be viewed and saved too. See NOTICE.md for where the LUTs come from and how close they are.88 - `/social`: crop to an aspect ratio (1:1, photo 5:4 4:3 3:2, social 4:5 9:16 1.91:1, each rotatable) or pad out, drag to position, frame in white or black, vignette and grain, then save or share a JPEG at the platform size.9+- `/ela`: error level analysis. Re-saves the picture as JPEG at a chosen quality, amplifies the difference, estimates the source JPEG quality from its quantization table, and saves the map as PNG.910 - `/perspective`: straightens converging verticals and horizontals using darktable's C algorithms (LSD line detection, RANSAC, perspective fit, homography) compiled to WebAssembly.1011 1112 ## Run@@ -47,6 +48,7 @@ The config serves only `dist/`; it has no backend, secrets, storage bindings, an
4748 - `index.html` + `home.mjs`: landing grid of tools, install and about.4849 - `fuji.html` + `fuji.mjs` + `rawworker.mjs` + `raf.mjs`: the Fuji tool. `rawworker.mjs` runs LibRaw (`assets/raw.wasm`, built by `native/raw/build.sh`) and the LUTs off the main thread. `raf.mjs` parses the RAF container and the Fuji makernote. `scripts/neutral-lut.py` regenerates the neutral LUTs with darktable-cli.4950 - `social.html` + `social.mjs`: the Social tool, canvas only.51+- `ela.html` + `ela.mjs` + `elaworker.mjs`: the ELA tool; re-encoding and differencing run in a worker on an OffscreenCanvas.5052 - `perspective.html` + `app.mjs`: the perspective tool UI. `worker.mjs` runs the WebAssembly core off the main thread.5153 - `style.css`: shared styles. Dark, system font, safe-area aware; the tool page never scrolls.5254 @@ -0,0 +1,54 @@
1+2+<html lang="en">3+<head>4+ <meta charset="utf-8">5+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, interactive-widget=resizes-content">6+ <script src="./viewport.js"></script>7+ <meta name="theme-color" content="#1a1a1a">8+ <meta name="description" content="Error level analysis for JPEGs. Runs on your device.">9+ <meta name="apple-mobile-web-app-capable" content="yes">10+ <meta name="mobile-web-app-capable" content="yes">11+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">12+ <meta name="apple-mobile-web-app-title" content="gud.pics">13+ <link rel="manifest" href="./manifest.webmanifest">14+ <link rel="icon" href="./assets/icon.svg" type="image/svg+xml">15+ <link rel="apple-touch-icon" href="./assets/icon-192.png">16+ <link rel="stylesheet" href="./style.css">17+ <title>ELA</title>18+</head>19+<body class="tool-body">20+ <div class="tool" id="tool" data-state="empty">21+ <header class="nav">22+ <a class="nav-btn" href="./" aria-label="Tools"><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>23+ <h1 class="nav-title">ELA</h1>24+ <button class="nav-btn" id="save" aria-label="Save" disabled><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-5-5l5 5 5-5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>25+ </header>26+27+ <section class="stage" id="stage" aria-label="Analysis">28+ <div class="canvas-wrap"><canvas id="canvas" aria-label="Error level map"></canvas><canvas id="photo" aria-label="Original" hidden></canvas></div>29+ <button class="empty" id="open-empty" aria-label="Open photo">30+ <svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/></svg>31+ <svg class="plus" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"><path d="M14 5v18M5 14h18" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg>32+ </button>33+ <div class="hud">34+ <span class="chip" id="info"></span>35+ <button class="compare" id="compare" aria-pressed="false" aria-label="Show original" disabled><svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2v16M7 5H3v10h4M13 5h4v10h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>36+ </div>37+ <div class="toast" id="status" role="status" aria-live="polite" hidden></div>38+ <div class="busy" id="busy" hidden><span class="spinner"></span></div>39+ </section>40+41+ <section class="panel" aria-label="Analysis controls">42+ <div class="slider" id="quality-row"><label for="quality">Quality</label><output id="quality-value" for="quality">95</output><input id="quality" type="range" min="50" max="100" value="95" disabled></div>43+ <div class="slider" id="scale-row"><label for="scale">Amplify</label><output id="scale-value" for="scale">15x</output><input id="scale" type="range" min="1" max="50" value="15" disabled></div>44+ <div class="toolbar">45+ <button class="tb" id="open" aria-label="Open"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="8.5" cy="10" r="1.8" fill="currentColor"/><path d="M4 17l5-5 4 4 2.5-2.5L20 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg></button>46+ <button class="tb" id="reset" aria-label="Reset" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.3-5.7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M4 4v5h5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>47+ <button class="tb" id="share" aria-label="Share" disabled hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v13M8 7l4-4 4 4M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>48+ </div>49+ </section>50+ <input id="file" type="file" accept="image/jpeg,image/png,image/webp" hidden>51+ </div>52+ <script type="module" src="./ela.mjs"></script>53+</body>54+</html>@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later2+const $=id=>document.getElementById(id);3+const worker=new Worker(new URL('./elaworker.mjs',import.meta.url),{type:'module'});4+let nextId=0;const pending=new Map();5+worker.onmessage=({data})=>{const p=pending.get(data.id);if(!p)return;pending.delete(data.id);data.error?p.reject(Error(data.error)):p.resolve(data.result);};6+worker.onerror=()=>{for(const p of pending.values())p.reject(Error('The analysis engine stopped. Reload to try again.'));pending.clear();};7+const rpc=(type,args={},transfer=[])=>new Promise((resolve,reject)=>{const id=++nextId;pending.set(id,{resolve,reject});worker.postMessage({type,id,...args},transfer);});8+const MAX_EDGE=4096,MAX_PIXELS=16e6;9+const tool=$('tool'),canvas=$('canvas'),ctx=canvas.getContext('2d'),photo=$('photo'),stage=$('stage');10+let current=null,busy=false,toastTimer,renderId=0,renderTimer,showOriginal=false,hold=false,exportCache=null,exportTimer;11+12+function status(message,error=false){clearTimeout(toastTimer);const el=$('status');el.textContent=message;el.classList.toggle('error',error);el.hidden=!message;if(message)toastTimer=setTimeout(()=>{el.hidden=true;},error?5000:2500);}13+function sync(){14+ tool.dataset.state=current?'detected':'empty';const ready=Boolean(current)&&!busy;15+ for(const id of ['save','share','reset','quality','scale'])$(id).disabled=!ready;16+ $('open').disabled=busy;$('open-empty').disabled=busy;$('busy').hidden=!busy;$('compare').disabled=!current;17+ $('quality-value').value=$('quality').value;$('scale-value').value=$('scale').value+'x';18+ const orig=(showOriginal||hold)&¤t;canvas.hidden=Boolean(orig);photo.hidden=!orig;$('compare').setAttribute('aria-pressed',String(Boolean(orig)));19+}20+function layout(){21+ const cs=getComputedStyle(stage),w=stage.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight),h=stage.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom);22+ for(const el of [canvas,photo]){if(!el.width)continue;const s=Math.min(w/el.width,h/el.height);el.style.width=Math.max(1,Math.floor(el.width*s))+'px';el.style.height=Math.max(1,Math.floor(el.height*s))+'px';}23+}24+new ResizeObserver(layout).observe(stage);25+// JPEG quality estimate from the luminance quantization table, the inverse of the IJG scaling.26+const STD=[16,11,10,16,24,40,51,61,12,12,14,19,26,58,60,55,14,13,16,24,40,57,69,56,14,17,22,29,51,87,80,62,18,22,37,56,68,109,103,77,24,35,55,64,81,104,113,92,49,64,78,87,103,121,120,101,72,92,95,98,112,100,103,99];27+function estimateQuality(bytes){28+ try{29+ if(bytes[0]!==0xFF||bytes[1]!==0xD8)return null;30+ const dv=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);let p=2;31+ while(p+4<=bytes.length&&bytes[p]===0xFF){32+ const marker=bytes[p+1],len=dv.getUint16(p+2);33+ if(marker===0xDB){let q=p+4;const end=p+2+len;while(q<end){const pq=bytes[q]>>4,id=bytes[q]&15;q++;const table=[];for(let i=0;i<64;i++){table.push(pq?dv.getUint16(q+i*2):bytes[q+i]);}q+=pq?128:64;34+ if(id===0){let sum=0;for(let i=0;i<64;i++)sum+=table[i]/STD[i];const pct=sum/64*100;return Math.max(1,Math.min(100,Math.round(pct<=100?(200-pct)/2:5000/pct)));}}}35+ if(marker===0xDA)break;p+=2+len;36+ }37+ }catch{}38+ return null;39+}40+async function render(){41+ if(!current)return;const id=++renderId;42+ try{43+ const r=await rpc('render',{quality:Number($('quality').value),scale:Number($('scale').value)});44+ if(id!==renderId)return;45+ canvas.width=r.w;canvas.height=r.h;ctx.putImageData(new ImageData(r.data,r.w,r.h),0,0);layout();46+ exportCache=null;clearTimeout(exportTimer);exportTimer=setTimeout(async()=>{const blob=await new Promise(res=>canvas.toBlob(res,'image/png'));if(blob&&id===renderId)exportCache=blob;},800);47+ }catch(e){status(e.message,true);}48+}49+function scheduleRender(){sync();clearTimeout(renderTimer);renderTimer=setTimeout(render,120);}50+async function openFile(file){51+ if(busy)return;busy=true;sync();52+ try{53+ if(!/^image\/(jpeg|png|webp)$/.test(file.type))throw Error('Choose a JPEG, PNG or WebP.');54+ const bytes=new Uint8Array(await file.arrayBuffer());55+ let bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});56+ const scale=Math.min(1,MAX_EDGE/Math.max(bitmap.width,bitmap.height),Math.sqrt(MAX_PIXELS/(bitmap.width*bitmap.height)));57+ if(scale<1){const small=await createImageBitmap(bitmap,{resizeWidth:Math.round(bitmap.width*scale),resizeHeight:Math.round(bitmap.height*scale),resizeQuality:'high'});bitmap.close();bitmap=small;}58+ photo.width=bitmap.width;photo.height=bitmap.height;photo.getContext('2d').drawImage(bitmap,0,0);59+ const info=await rpc('load',{bitmap},[bitmap]);60+ const q=file.type==='image/jpeg'?estimateQuality(bytes):null;61+ current={name:file.name.replace(/\.[^.]+$/,''),w:info.w,h:info.h,q};62+ $('info').textContent=[q?`JPEG ${q}`:file.type.replace('image/','').toUpperCase(),`${info.w} x ${info.h}`,scale<1?'reduced':''].filter(Boolean).join(' ');$('info').hidden=false;63+ showOriginal=false;$('quality').value=95;$('scale').value=15;64+ status(scale<1?'Reduced to fit. Levels are less reliable.':'');sync();await render();65+ }catch(e){status(e.message||'Could not open this image.',true);}66+ finally{busy=false;sync();}67+}68+$('quality').oninput=scheduleRender;$('scale').oninput=scheduleRender;69+$('reset').onclick=()=>{$('quality').value=95;$('scale').value=15;showOriginal=false;scheduleRender();};70+$('compare').onclick=()=>{showOriginal=!showOriginal;sync();};71+stage.addEventListener('pointerdown',e=>{if(!current||e.target.closest('button'))return;hold=true;sync();});72+for(const t of ['pointerup','pointercancel','pointerleave'])stage.addEventListener(t,()=>{if(!hold)return;hold=false;sync();});73+$('file').onchange=()=>{const f=$('file').files[0];if(f)openFile(f);$('file').value='';};74+$('open').onclick=()=>$('file').click();$('open-empty').onclick=()=>$('file').click();75+stage.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';$('open-empty').classList.add('over');});76+stage.addEventListener('dragleave',()=>$('open-empty').classList.remove('over'));77+stage.addEventListener('drop',e=>{e.preventDefault();$('open-empty').classList.remove('over');const f=e.dataTransfer.files[0];if(f)openFile(f);});78+stage.addEventListener('contextmenu',e=>e.preventDefault());79+function download(blob,fname){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=fname;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);}80+const fname=()=>`${current.name}-ela-q${$('quality').value}-x${$('scale').value}.png`;81+async function pngBlob(){if(exportCache)return exportCache;const blob=await new Promise(res=>canvas.toBlob(res,'image/png'));if(!blob)throw Error('Could not encode the PNG.');return blob;}82+$('save').onclick=async()=>{if(!current)return;busy=true;sync();try{download(await pngBlob(),fname());status('Saved');}catch(e){status(e.message,true);}finally{busy=false;sync();}};83+if(navigator.canShare&&navigator.share)$('share').hidden=false;84+$('share').onclick=()=>{85+ if(!current)return;86+ if(!exportCache){busy=true;sync();pngBlob().then(b=>{download(b,fname());status('Saved. Share it from your photos.');}).catch(e=>status(e.message,true)).finally(()=>{busy=false;sync();});return;}87+ const file=new File([exportCache],fname(),{type:'image/png'});88+ if(!navigator.canShare({files:[file]}))return status('Sharing files is not supported here.',true);89+ navigator.share({files:[file]}).catch(e=>{if(e.name==='AbortError')return;download(exportCache,fname());status('Saved. Share it from your photos.');});90+};91+if('serviceWorker'in navigator){navigator.serviceWorker.register('./sw.js').catch(()=>{});let hadController=Boolean(navigator.serviceWorker.controller);navigator.serviceWorker.addEventListener('controllerchange',()=>{if(hadController&&!current)location.reload();hadController=true;});}92+sync();@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later2+// Error level analysis: re-encode the picture as JPEG at a chosen quality and amplify the difference.3+let w=0,h=0,original=null,diff=null,diffQuality=-1;4+async function load(bitmap){5+ w=bitmap.width;h=bitmap.height;6+ const c=new OffscreenCanvas(w,h),x=c.getContext('2d',{willReadFrequently:true});x.drawImage(bitmap,0,0);bitmap.close();7+ original=x.getImageData(0,0,w,h).data;diff=null;diffQuality=-1;8+ return {w,h};9+}10+async function computeDiff(quality){11+ if(diffQuality===quality&&diff)return;12+ const c=new OffscreenCanvas(w,h),x=c.getContext('2d',{willReadFrequently:true});13+ x.putImageData(new ImageData(original,w,h),0,0);14+ const blob=await c.convertToBlob({type:'image/jpeg',quality:quality/100});15+ const bmp=await createImageBitmap(blob);x.drawImage(bmp,0,0);bmp.close();16+ const re=x.getImageData(0,0,w,h).data,d=new Uint8Array(w*h*3);17+ for(let i=0,o=0;i<re.length;i+=4,o+=3){d[o]=Math.abs(original[i]-re[i]);d[o+1]=Math.abs(original[i+1]-re[i+1]);d[o+2]=Math.abs(original[i+2]-re[i+2]);}18+ diff=d;diffQuality=quality;19+}20+function amplify(scale){21+ const out=new Uint8ClampedArray(w*h*4);22+ for(let i=0,o=0;i<diff.length;i+=3,o+=4){out[o]=diff[i]*scale;out[o+1]=diff[i+1]*scale;out[o+2]=diff[i+2]*scale;out[o+3]=255;}23+ return out;24+}25+self.onmessage=async({data})=>{26+ try{27+ let result,transfer=[];28+ if(data.type==='load')result=await load(data.bitmap);29+ else if(data.type==='render'){if(!original)throw Error('Open a photo first.');await computeDiff(data.quality);const px=amplify(data.scale);result={w,h,data:px};transfer=[px.buffer];}30+ else if(data.type==='close'){original=diff=null;result={ok:true};}31+ else throw Error('Unknown operation.');32+ self.postMessage({id:data.id,result},transfer);33+ }catch(e){self.postMessage({id:data.id,error:e.message||'Analysis failed.'});}34+};@@ -19,7 +19,8 @@ if('serviceWorker'in navigator){
1919 const TOOLS={2020 perspective:['Perspective',['JPEG and PNG.','Straightens verticals and horizontals from detected lines. Up to 4 MP and 2560 px.','Saved copies drop EXIF and color profiles.']],2121 fuji:['Fuji',['RAF files, developed at half size with the camera\'s lens corrections.','16 film simulation looks as LUTs fitted from a Fuji body: close to the camera, not the camera\'s own rendering.','The in-camera JPEG can be saved as is.']],22- social:['Social',['JPEG, PNG and WebP.','Aspect ratios for photo and social, crop or pad, frame, vignette and grain.','Social ratios export with a 1080 short side. Photo ratios keep resolution up to 2160.']]};22+ social:['Social',['JPEG, PNG and WebP.','Aspect ratios for photo and social, crop or pad, frame, vignette and grain.','Social ratios export with a 1080 short side. Photo ratios keep resolution up to 2160.']],23+ ela:['ELA',['Error level analysis, as on FotoForensics. The picture is re-saved as JPEG at a chosen quality and the difference is amplified.','Regions edited or pasted at a different compression level stand out from the rest. Read it as a hint, not proof.','Native pixels up to 16 MP. The map saves as PNG.']]};2324 const toolSheet=$('tool-sheet');2425 for(const b of document.querySelectorAll('[data-info]'))b.onclick=e=>{e.preventDefault();const [title,paras]=TOOLS[b.dataset.info];$('tool-title').textContent=title;$('tool-text').replaceChildren(...paras.map(t=>{const p=document.createElement('p');p.textContent=t;return p;}));toolSheet.showModal();};2526 toolSheet.querySelector('[data-close]').onclick=()=>toolSheet.close();toolSheet.addEventListener('click',e=>{if(e.target===toolSheet)toolSheet.close();});@@ -42,6 +42,13 @@
4242 <h2>Social</h2>4343 </a>4444 </li>45+ <li class="tool-card">46+ <button class="tool-info" data-info="ela" aria-label="About ELA"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></button>47+ <a class="tool-link" href="./ela">48+ <span class="tool-glyph"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/></svg></span>49+ <h2>ELA</h2>50+ </a>51+ </li>4552 </ul>4653 4754 <section class="list" aria-label="App">@@ -10,6 +10,7 @@
1010 "shortcuts": [1111 {"name": "Perspective", "url": "./perspective", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]},1212 {"name": "Fuji", "url": "./fuji", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]},13- {"name": "Social", "url": "./social", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]}13+ {"name": "Social", "url": "./social", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]},14+ {"name": "ELA", "url": "./ela", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]}1415 ]1516 }@@ -14,9 +14,9 @@ if dist.exists(): shutil.rmtree(dist)
1414 1515 # Hashed files in dependency order: leaves first, so a file's hash covers its rewritten references.1616 LUTS = sorted(p.relative_to(root).as_posix() for p in (root / 'assets' / 'luts').glob('*.png'))17-HASHED = ['assets/core.wasm', 'assets/core.mjs', 'assets/raw.wasm', 'assets/raw.mjs'] + LUTS + ['geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'lens.mjs', 'rawworker.mjs', 'fuji.mjs', 'social.mjs', 'home.mjs', 'viewport.js', 'style.css']17+HASHED = ['assets/core.wasm', 'assets/core.mjs', 'assets/raw.wasm', 'assets/raw.mjs'] + LUTS + ['geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'lens.mjs', 'rawworker.mjs', 'fuji.mjs', 'social.mjs', 'elaworker.mjs', 'ela.mjs', 'home.mjs', 'viewport.js', 'style.css']1818 # Fixed-name text files that reference hashed ones.19-PAGES = ['index.html', 'perspective.html', 'fuji.html', 'social.html']19+PAGES = ['index.html', 'perspective.html', 'fuji.html', 'social.html', 'ela.html']2020 FIXED = ['manifest.webmanifest', 'NOTICE.md', 'README.md', '_headers']2121 DIRS = ['LICENSES']2222 ICONS = ['assets/icon.svg', 'assets/icon-192.png', 'assets/icon-512.png']@@ -53,7 +53,7 @@ for d in DIRS: shutil.copytree(root / d, dist / d)
5353 for src in ICONS: shutil.copyfile(root / src, dist / src)5454 5555 # Service worker: precache list and a cache name derived from everything in dist.56-precache = ['./', './perspective', './fuji', './social'] + ['./' + v for v in mapping.values()] + ['./' + f for f in ['manifest.webmanifest', 'NOTICE.md'] + ICONS] + ['./LICENSES/' + p.name for p in sorted((root / 'LICENSES').iterdir()) if p.is_file()]56+precache = ['./', './perspective', './fuji', './social', './ela'] + ['./' + v for v in mapping.values()] + ['./' + f for f in ['manifest.webmanifest', 'NOTICE.md'] + ICONS] + ['./LICENSES/' + p.name for p in sorted((root / 'LICENSES').iterdir()) if p.is_file()]5757 h = hashlib.sha256()5858 for p in sorted(dist.rglob('*')):5959 if p.is_file(): h.update(p.read_bytes())@@ -2,7 +2,7 @@
22 // The cache name and precache list are filled in by scripts/package.py.33 const CACHE='__CACHE__';44 const FILES=[__FILES__];5-const PAGES={'/':'./','/perspective':'./perspective','/fuji':'./fuji','/social':'./social'};5+const PAGES={'/':'./','/perspective':'./perspective','/fuji':'./fuji','/social':'./social','/ela':'./ela'};66 self.addEventListener('install',e=>{self.skipWaiting();e.waitUntil(caches.open(CACHE).then(c=>c.addAll(FILES)));});77 self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));88 self.addEventListener('fetch',e=>{