Match photo selection and rendered canvas to device rotation
This commit is contained in:
@@ -69,6 +69,7 @@ from pigway_frame_flow.providers.synology_photos import (
|
||||
PhotosPerson,
|
||||
)
|
||||
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
|
||||
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
|
||||
from pigway_frame_flow.renderers.images import Assets, convert
|
||||
from pigway_frame_flow.renderers.templates import TemplateRenderer
|
||||
from pigway_frame_flow.scheduler.engine import Scheduler
|
||||
@@ -629,8 +630,17 @@ def create_app(settings=None, start_worker=True):
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
refresh: bool = False,
|
||||
device_id: str | None = None,
|
||||
):
|
||||
items = service.photo_sources.catalog(source_id, refresh=refresh)
|
||||
source = service.photo_sources.get(source_id)
|
||||
size = (1200, 1600)
|
||||
if source.orientation == "auto":
|
||||
size = frame_canvas(
|
||||
registry.get(device_id or settings.device_id).device().display
|
||||
)
|
||||
items = service.photo_sources.catalog(
|
||||
source_id, refresh=refresh, target_size=size
|
||||
)
|
||||
return {
|
||||
"items": items[offset : offset + limit],
|
||||
"total": len(items),
|
||||
|
||||
@@ -17,6 +17,7 @@ from pigway_frame_flow.models import (
|
||||
URLContent,
|
||||
)
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSources
|
||||
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
|
||||
from pigway_frame_flow.renderers.images import convert
|
||||
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
@@ -157,7 +158,7 @@ class DisplayService:
|
||||
)
|
||||
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
|
||||
|
||||
def content_asset(self, content):
|
||||
def content_asset(self, content, device_id=None):
|
||||
if isinstance(content, TemplateContent):
|
||||
values = self.renderer.resolve(content.template, content.data)
|
||||
data = self.renderer.render(content.template, values, content.profile)
|
||||
@@ -174,15 +175,25 @@ class DisplayService:
|
||||
)
|
||||
return asset
|
||||
if isinstance(content, PhotoSourceContent):
|
||||
source = self.photo_sources.get(content.source_id)
|
||||
display = (
|
||||
self.registry.get(device_id or self.settings.device_id).device().display
|
||||
)
|
||||
size = frame_canvas(display, source.orientation)
|
||||
data, name, caption = self.photo_sources.read(
|
||||
content.source_id, content.item_id
|
||||
content.source_id, content.item_id, target_size=size
|
||||
)
|
||||
rendered = (
|
||||
render_captioned_photo(
|
||||
data, caption, self.renderer.font, content.fit, content.profile
|
||||
data,
|
||||
caption,
|
||||
self.renderer.font,
|
||||
content.fit,
|
||||
content.profile,
|
||||
size=size,
|
||||
)
|
||||
if caption
|
||||
else convert(data, content.fit, content.profile)
|
||||
else convert(data, content.fit, content.profile, size=size)
|
||||
)
|
||||
return self.assets.put(rendered, name)
|
||||
if isinstance(content, URLContent):
|
||||
@@ -310,7 +321,9 @@ class DisplayService:
|
||||
|
||||
from pigway_frame_flow.models import Content
|
||||
|
||||
asset = self.content_asset(TypeAdapter(Content).validate_python(payload))
|
||||
asset = self.content_asset(
|
||||
TypeAdapter(Content).validate_python(payload), device_id
|
||||
)
|
||||
if action == "local-upload":
|
||||
asset = self.assets.promote(asset["asset_id"])
|
||||
self.update(
|
||||
@@ -389,7 +402,7 @@ class DisplayService:
|
||||
)
|
||||
image_id = matches[0].image_id
|
||||
else:
|
||||
asset = self.content_asset(content)
|
||||
asset = self.content_asset(content, device_id)
|
||||
image_id = None
|
||||
self.db.execute(
|
||||
"UPDATE device_state SET previous_image_id=? WHERE device_id=?",
|
||||
|
||||
@@ -247,12 +247,22 @@ class PhotoSources:
|
||||
pass
|
||||
return None
|
||||
|
||||
def catalog(self, source_id, refresh=False):
|
||||
def catalog(self, source_id, refresh=False, *, target_size=(1200, 1600)):
|
||||
source = self.get(source_id)
|
||||
if not source.enabled:
|
||||
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
||||
wanted = (
|
||||
orientation(*target_size)
|
||||
if source.orientation == "auto"
|
||||
else source.orientation
|
||||
)
|
||||
cached = self.cache.get(source_id)
|
||||
if cached and not refresh and time.monotonic() - cached[0] < 60:
|
||||
if (
|
||||
cached
|
||||
and not refresh
|
||||
and time.monotonic() - cached[0] < 60
|
||||
and cached[2] == wanted
|
||||
):
|
||||
return cached[1]
|
||||
allowed = (
|
||||
self.synology.paths(source.person_ids, source.person_match, refresh=refresh)
|
||||
@@ -266,7 +276,6 @@ class PhotoSources:
|
||||
else {}
|
||||
)
|
||||
size_updates = []
|
||||
wanted = "portrait" if source.orientation == "auto" else source.orientation
|
||||
if source.kind == "local":
|
||||
directory = self.local_directory(source.location)
|
||||
root = self.settings.photo_root.resolve()
|
||||
@@ -418,12 +427,12 @@ class PhotoSources:
|
||||
"INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
|
||||
size_updates,
|
||||
)
|
||||
self.cache[source_id] = (time.monotonic(), items)
|
||||
self.cache[source_id] = (time.monotonic(), items, wanted)
|
||||
return items
|
||||
|
||||
def read(self, source_id, item_id=None):
|
||||
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
||||
source = self.get(source_id)
|
||||
items = self.catalog(source_id)
|
||||
items = self.catalog(source_id, target_size=target_size)
|
||||
if not items:
|
||||
raise FlowError(
|
||||
E.NOT_FOUND, "Photo source contains no supported images", 404
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Resolve a device's visual canvas without coupling rendering to its driver."""
|
||||
|
||||
|
||||
def frame_canvas(display, orientation=None):
|
||||
width, height = display.width, display.height
|
||||
if orientation == "landscape":
|
||||
return max(width, height), min(width, height)
|
||||
if orientation == "portrait":
|
||||
return min(width, height), max(width, height)
|
||||
return (height, width) if display.rotation % 180 == 90 else (width, height)
|
||||
@@ -7,23 +7,40 @@ from PIL import Image, ImageDraw
|
||||
from pigway_frame_flow.renderers.images import convert
|
||||
|
||||
|
||||
def render_captioned_photo(data, caption, font_loader, fit="contain", profile="color"):
|
||||
photo_data = convert(data, fit, profile, size=(1200, 1280))
|
||||
canvas = Image.new("RGB", (1200, 1600), "white")
|
||||
def render_captioned_photo(
|
||||
data, caption, font_loader, fit="contain", profile="color", *, size=(1200, 1600)
|
||||
):
|
||||
width, height = size
|
||||
landscape = width > height
|
||||
caption_height = 240 if landscape else 320
|
||||
photo_height = height - caption_height
|
||||
photo_data = convert(data, fit, profile, size=(width, photo_height))
|
||||
canvas = Image.new("RGB", size, "white")
|
||||
with Image.open(BytesIO(photo_data)) as photo:
|
||||
canvas.paste(photo, (0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
lines = [(caption["title"], 1320, 52), (caption["date"], 1410, 40)]
|
||||
if caption.get("place"):
|
||||
lines.append((caption["place"], 1480, 38))
|
||||
for text, y, size in lines:
|
||||
font = font_loader(size)
|
||||
while size > 24 and draw.textlength(text, font=font) > 1080:
|
||||
size -= 2
|
||||
font = font_loader(size)
|
||||
while draw.textlength(text, font=font) > 1080 and text:
|
||||
rows = (
|
||||
[(24, 44), (88, 36), (140, 34)]
|
||||
if landscape
|
||||
else [(40, 52), (130, 40), (200, 38)]
|
||||
)
|
||||
values = [caption["title"], caption["date"], caption.get("place", "")]
|
||||
for text, (offset, font_size) in zip(values, rows, strict=True):
|
||||
if not text:
|
||||
continue
|
||||
font = font_loader(font_size)
|
||||
while font_size > 24 and draw.textlength(text, font=font) > width - 120:
|
||||
font_size -= 2
|
||||
font = font_loader(font_size)
|
||||
while draw.textlength(text, font=font) > width - 120 and text:
|
||||
text = text[:-2] + "…"
|
||||
draw.text((600, y), text, font=font, fill="black", anchor="mt")
|
||||
draw.text(
|
||||
(width / 2, photo_height + offset),
|
||||
text,
|
||||
font=font,
|
||||
fill="black",
|
||||
anchor="mt",
|
||||
)
|
||||
out = BytesIO()
|
||||
canvas.save(out, "JPEG", quality=93, optimize=True)
|
||||
return out.getvalue()
|
||||
|
||||
@@ -135,7 +135,7 @@ async function photoSourceForm(source=null){
|
||||
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:!dav&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('photo-sources');});
|
||||
}
|
||||
async function browseSource(id,offset=0,refresh=false){
|
||||
const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
||||
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
||||
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.title,x.caption.date,x.caption.place].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSources')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
|
||||
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
|
||||
}
|
||||
|
||||
@@ -31,5 +31,5 @@ Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopl
|
||||
Object.assign(MESSAGES.zh,{'photo-sources':'照片数据源'});
|
||||
Object.assign(MESSAGES.en,{'photo-sources':'Photo sources'});
|
||||
|
||||
Object.assign(MESSAGES.zh,{photoOrientation:'照片方向',orientationAuto:'自动识别 · 匹配画布方向',orientationLandscape:'横向照片',orientationPortrait:'竖向照片',orientationLegacy:'不筛方向(原有设置)',orientationHint:'自动按当前 1200×1600 画布选择竖向照片;横向/竖向按照片 EXIF 校正后的尺寸筛选,正方形不匹配。仅支持 NAS 本地数据源,不旋转或拉伸照片。'});
|
||||
Object.assign(MESSAGES.en,{photoOrientation:'Photo orientation',orientationAuto:'Auto · match canvas orientation',orientationLandscape:'Landscape photos',orientationPortrait:'Portrait photos',orientationLegacy:'Any orientation (existing setting)',orientationHint:'Auto selects portrait photos for the current 1200×1600 canvas. Orientation uses EXIF-corrected dimensions; square photos do not match. Local NAS sources only; photos are not rotated or stretched to fit.'});
|
||||
Object.assign(MESSAGES.zh,{photoOrientation:'照片方向',orientationAuto:'自动识别 · 匹配画布方向',orientationLandscape:'横向照片',orientationPortrait:'竖向照片',orientationLegacy:'不筛方向(原有设置)',orientationHint:'自动按设备尺寸和旋转方向选择照片并生成画布(90°/270° 会交换宽高)。横向/竖向同时控制选图和出图方向;按 EXIF 校正尺寸筛选,正方形不匹配。不拉伸照片。'});
|
||||
Object.assign(MESSAGES.en,{photoOrientation:'Photo orientation',orientationAuto:'Auto · match canvas orientation',orientationLandscape:'Landscape photos',orientationPortrait:'Portrait photos',orientationLegacy:'Any orientation (existing setting)',orientationHint:'Auto follows device dimensions and rotation, swapping width/height at 90°/270°. Landscape/portrait control both photo selection and output canvas. EXIF-aware; square photos do not match. Photos are not stretched.'});
|
||||
|
||||
Reference in New Issue
Block a user