diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index 692608f..7deb069 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -421,12 +421,19 @@ def create_app(settings=None, start_worker=True):
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
if thumbnail:
thumb = cached.with_name(cached.stem + "-thumb.jpg")
- if not thumb.exists():
- from PIL import Image
+ from PIL import Image
- with Image.open(cached) as img:
- img.thumbnail((300, 400))
- img.save(thumb, "JPEG")
+ with Image.open(cached) as img:
+ # The 430px cards need up to 860px on 2x displays. Replace the
+ # existing cache in place so old 300px previews are upgraded.
+ expected_edge = min(860, max(img.size))
+ regenerate = not thumb.exists()
+ if not regenerate:
+ with Image.open(thumb) as existing:
+ regenerate = max(existing.size) != expected_edge
+ if regenerate:
+ img.thumbnail((860, 860), Image.Resampling.LANCZOS)
+ img.convert("RGB").save(thumb, "JPEG", quality=88, optimize=True)
return FileResponse(thumb, media_type="image/jpeg")
from PIL import Image
diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js
index 48e92ae..20df0cd 100644
--- a/pigway_frame_flow/web/app.js
+++ b/pigway_frame_flow/web/app.js
@@ -128,7 +128,7 @@ const views={
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
- const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${destinations.map(d=>``).join('')}
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}${pending?t('galleryListLoading'):''}
${state.tab==='local'?`${t('uploadNotice')}
`:''}${`${items.map((x,i)=>`
${esc(x.name)}
${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}`).join('')}
`}${!pending&&!items.length?`${t('noImages')}${t('emptyHint')}
`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`${t('displayByName')}
${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}
`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
+ const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`${destinations.map(d=>``).join('')}
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}${pending?t('galleryListLoading'):''}
${state.tab==='local'?`${t('uploadNotice')}
`:''}${`${items.map((x,i)=>`
${esc(x.name)}
${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}`).join('')}
`}${!pending&&!items.length?`${t('noImages')}${t('emptyHint')}
`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`${t('displayByName')}
${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}
`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
render(cached||[],!cached);
try{
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
diff --git a/tests/test_gallery_thumbnail.py b/tests/test_gallery_thumbnail.py
new file mode 100644
index 0000000..7ba68fe
--- /dev/null
+++ b/tests/test_gallery_thumbnail.py
@@ -0,0 +1,28 @@
+import io
+
+from PIL import Image
+
+
+def test_gallery_thumbnail_upgrades_existing_cache_and_reuses_it(client, settings):
+ cache = settings.data_dir / "cache"
+ original = cache / "living-room-1.original"
+ thumb = cache / "living-room-1-thumb.jpg"
+ Image.new("RGB", (1600, 1200), "blue").save(original, "JPEG")
+ Image.new("RGB", (300, 225), "blue").save(thumb, "JPEG")
+ url = "/api/v1/devices/living-room/images/1/file?thumbnail=true"
+ result = client.get(url)
+ assert result.status_code == 200
+ assert Image.open(io.BytesIO(result.content)).size == (860, 645)
+ modified = thumb.stat().st_mtime_ns
+ assert client.get(url).content == result.content
+ assert thumb.stat().st_mtime_ns == modified
+ assert len(list(cache.glob("living-room-1-thumb*"))) == 1
+ assert Image.open(original).size == (1600, 1200)
+
+
+def test_gallery_thumbnail_does_not_upscale_small_source(client, settings):
+ original = settings.data_dir / "cache" / "living-room-1.original"
+ Image.new("RGBA", (200, 400), (255, 0, 0, 255)).save(original, "PNG")
+ result = client.get("/api/v1/devices/living-room/images/1/file?thumbnail=true")
+ assert result.status_code == 200
+ assert Image.open(io.BytesIO(result.content)).size == (200, 400)