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()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}${pending?t('galleryListLoading'):''}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${``}${!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')}

`);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()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}${pending?t('galleryListLoading'):''}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${``}${!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')}

`);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)