fix: resize gallery thumbnails for current high density cards
This commit is contained in:
@@ -421,12 +421,19 @@ def create_app(settings=None, start_worker=True):
|
|||||||
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
||||||
if thumbnail:
|
if thumbnail:
|
||||||
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
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:
|
with Image.open(cached) as img:
|
||||||
img.thumbnail((300, 400))
|
# The 430px cards need up to 860px on 2x displays. Replace the
|
||||||
img.save(thumb, "JPEG")
|
# 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")
|
return FileResponse(thumb, media_type="image/jpeg")
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const views={
|
|||||||
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
|
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 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 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()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url)}"></button><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;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(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);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()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;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(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);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);
|
render(cached||[],!cached);
|
||||||
try{
|
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):''));
|
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):''));
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user