diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md
index 508e429..08e89cd 100644
--- a/docs/PHOTO_SOURCES.md
+++ b/docs/PHOTO_SOURCES.md
@@ -146,8 +146,8 @@ This optional integration reads the existing shared-space People catalog. It doe
## 照片方向 / Orientation
-编辑照片数据源可选:横向、竖向、自动识别。自动匹配当前渲染画布 1200×1600,因此选竖向。按 EXIF 校正后的 width/height 分类,正方形不混入横向或竖向。预览、指定图片和定时随机选图使用同一筛选结果;空结果明确失败,不退回其他方向。照片不为匹配方向强制旋转或拉伸。
+编辑照片数据源可选:横向、竖向、自动识别。自动根据目标设备尺寸和旋转方向计算画布;例如 1200×1600、rotation=270 输出 1600×1200 并选择横向。手动横向输出 1600×1200,手动竖向输出 1200×1600(以当前设备为例)。照片区和底部三行文字均随画布重排。按 EXIF 校正后的 width/height 分类,正方形不混入横向或竖向。预览、指定图片和定时随机选图使用同一筛选结果;空结果明确失败,不退回其他方向。照片不为匹配方向强制旋转或拉伸。
-已有数据源 `orientation=null` 保持原来的全部方向,编辑器显示“不筛方向(原有设置)”;新建数据源默认自动。尺寸缓存持久化在 SQLite `photo_dimensions`,按路径、修改时间和大小失效。首次扫描读取本地图片尺寸和 EXIF,不批量生成全图库预览;后续刷新复用缓存。仅 NAS 本地源支持,WebDAV 不批量下载原图测尺寸。若将来改变原生画布尺寸,需要同步调整 auto 的匹配规则。
+已有数据源 `orientation=null` 保持原来的全部方向,编辑器显示“不筛方向(原有设置)”;新建数据源默认自动。尺寸缓存持久化在 SQLite `photo_dimensions`,按路径、修改时间和大小失效。首次扫描读取本地图片尺寸和 EXIF,不批量生成全图库预览;后续刷新复用缓存。仅 NAS 本地源支持,WebDAV 不批量下载原图测尺寸。自动模式读取统一 Device Model,不依赖 KoKonna 私有字段;90°/270° 交换宽高。
-Orientation options: landscape, portrait, auto. Auto matches the current portrait rendering canvas. Classification honors EXIF orientation; square photos are excluded from directional matches. Existing sources remain unfiltered (`null`) until changed. Dimensions persist in SQLite and refresh when the file changes. Local NAS sources only; no bulk original downloads from WebDAV. Empty matches fail closed. This controls selection, not photo rotation or stretching.
+Orientation options: landscape, portrait, auto. Auto follows the target device dimensions and rotation (90°/270° swap width and height). Manual direction controls both selection and rendering. Photo and caption layouts support landscape and portrait. Classification honors EXIF orientation; square photos are excluded from directional matches. Existing sources remain unfiltered (`null`) until changed. Dimensions persist in SQLite and refresh when the file changes. Local NAS sources only; no bulk original downloads from WebDAV. Empty matches fail closed. This controls selection, not photo rotation or stretching.
diff --git a/docs/openapi.json b/docs/openapi.json
index ddf4fc8..2f18848 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -4128,6 +4128,22 @@
"default": false,
"title": "Refresh"
}
+ },
+ {
+ "name": "device_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Device Id"
+ }
}
],
"responses": {
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index ff19cd8..b24cba3 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -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),
diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py
index 6f07287..798cfa1 100644
--- a/pigway_frame_flow/display/service.py
+++ b/pigway_frame_flow/display/service.py
@@ -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=?",
diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py
index dccb8c9..3c6f8aa 100644
--- a/pigway_frame_flow/providers/photo_sources.py
+++ b/pigway_frame_flow/providers/photo_sources.py
@@ -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
diff --git a/pigway_frame_flow/renderers/frame_geometry.py b/pigway_frame_flow/renderers/frame_geometry.py
new file mode 100644
index 0000000..e7f7571
--- /dev/null
+++ b/pigway_frame_flow/renderers/frame_geometry.py
@@ -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)
diff --git a/pigway_frame_flow/renderers/photo_caption.py b/pigway_frame_flow/renderers/photo_caption.py
index 832b2cd..18a89c4 100644
--- a/pigway_frame_flow/renderers/photo_caption.py
+++ b/pigway_frame_flow/renderers/photo_caption.py
@@ -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()
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index e671894..821c3bc 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -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=`
${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}
${t('sourceBrowseHint')}
${result.items.map((x,i)=>``).join('')||t('noSources')}
${buttons('source-refresh','refresh')}
`;
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));
}
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index 8424dd1..ce678fe 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -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.'});
diff --git a/scripts/ui-photo-sources.cjs b/scripts/ui-photo-sources.cjs
index 5934e3a..9913c03 100644
--- a/scripts/ui-photo-sources.cjs
+++ b/scripts/ui-photo-sources.cjs
@@ -1,3 +1,3 @@
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
const assert=require('assert'),{chromium}=require('playwright');
-(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/tests/test_external_photo_sources.py b/tests/test_external_photo_sources.py
index b6d73d7..3ec65e1 100644
--- a/tests/test_external_photo_sources.py
+++ b/tests/test_external_photo_sources.py
@@ -74,7 +74,7 @@ def test_random_preview_is_local_and_display_uses_same_asset(client, service, ph
assert service.registry.get("living-room").get_current_image() == before
image = client.get(op["result"]["url"])
assert image.status_code == 200
- assert Image.open(BytesIO(image.content)).size == (1200, 1600)
+ assert Image.open(BytesIO(image.content)).size == (1600, 1200)
r = client.post(
"/api/v1/display",
json={"content": {"type": "image", "asset_id": op["result"]["asset_id"]}},
diff --git a/tests/test_photo_canvas.py b/tests/test_photo_canvas.py
new file mode 100644
index 0000000..e87541c
--- /dev/null
+++ b/tests/test_photo_canvas.py
@@ -0,0 +1,139 @@
+from io import BytesIO
+
+import pytest
+from PIL import Image
+
+from pigway_frame_flow.models import DisplayGeometry
+from pigway_frame_flow.providers.photo_names import folder_caption
+from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
+from pigway_frame_flow.renderers.frame_geometry import frame_canvas
+from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
+
+
+@pytest.mark.parametrize("rotation", [0, 90, 180, 270])
+@pytest.mark.parametrize("mode", [None, "auto", "portrait", "landscape"])
+def test_canvas_respects_rotation_and_explicit_direction(rotation, mode):
+ display = DisplayGeometry(
+ width=1200, height=1600, rotation=rotation, color_mode="color"
+ )
+ expected = (
+ (1600, 1200)
+ if mode == "landscape" or (mode in (None, "auto") and rotation in (90, 270))
+ else (1200, 1600)
+ )
+ assert frame_canvas(display, mode) == expected
+
+
+@pytest.mark.parametrize("profile", ["color", "grayscale"])
+def test_landscape_caption_keeps_three_rows(service, profile):
+ out = BytesIO()
+ Image.new("RGB", (1600, 900), "blue").save(out, "JPEG")
+ data = render_captioned_photo(
+ out.getvalue(),
+ folder_caption("20261005 家庭出游@示例地点"),
+ service.renderer.font,
+ profile=profile,
+ size=(1600, 1200),
+ )
+ with Image.open(BytesIO(data)) as image:
+ assert image.size == (1600, 1200)
+ for y in [984, 1048, 1100]:
+ assert image.crop((60, y, 1540, y + 50)).convert("L").getextrema()[0] < 50
+ pixel = image.getpixel((800, 480))
+ assert (
+ len(set(pixel)) <= 2
+ if profile == "grayscale"
+ else pixel[2] > pixel[0] + 100
+ )
+
+
+@pytest.mark.parametrize(
+ "mode,expected",
+ [("auto", (1600, 1200)), ("landscape", (1600, 1200)), ("portrait", (1200, 1600))],
+)
+def test_select_and_render_use_same_direction(
+ client, service, settings, mode, expected
+):
+ root = settings.data_dir.parent / "photos"
+ settings.photo_root = root
+ folder = root / "20261005 出游@金山"
+ folder.mkdir(parents=True)
+ for name, size in [("wide.jpg", (300, 200)), ("tall.jpg", (200, 300))]:
+ Image.new("RGB", size, "blue").save(folder / name)
+ service.photo_sources.save(
+ "family",
+ PhotoSourceInput(
+ name="Family",
+ kind="local",
+ location=".",
+ folder_caption=True,
+ orientation=mode,
+ ),
+ )
+ rows = client.get("/api/v1/photo-sources/family/images").json()["items"]
+ assert len(rows) == 1 and rows[0]["name"] == (
+ "wide.jpg" if expected[0] > expected[1] else "tall.jpg"
+ )
+ before = service.registry.get("living-room").get_current_image()
+ accepted = client.post(
+ "/api/v1/devices/living-room/upload",
+ json={"type": "photo_source", "source_id": "family"},
+ )
+ assert accepted.status_code == 202 and service.process_one()
+ op = client.get("/api/v1/operations/" + accepted.json()["operation_id"]).json()
+ assert op["status"] == "COMPLETED"
+ with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image:
+ assert image.size == expected
+ assert service.registry.get("living-room").get_current_image() == before
+
+
+def test_auto_catalog_cache_is_not_shared_between_opposite_canvases(service, settings):
+ root = settings.data_dir.parent / "photos"
+ settings.photo_root = root
+ root.mkdir()
+ for name, size in [("wide.jpg", (300, 200)), ("tall.jpg", (200, 300))]:
+ Image.new("RGB", size).save(root / name)
+ p = service.photo_sources
+ p.save(
+ "family",
+ PhotoSourceInput(name="Family", kind="local", location=".", orientation="auto"),
+ )
+ assert p.catalog("family", target_size=(1600, 1200))[0]["name"] == "wide.jpg"
+ assert p.catalog("family", target_size=(1200, 1600))[0]["name"] == "tall.jpg"
+
+
+def test_auto_preview_uses_requested_device_not_default(
+ client, service, settings, monkeypatch
+):
+ import copy
+
+ driver = service.registry.get("living-room")
+ other = copy.copy(driver)
+ model = driver.device().model_copy(
+ update={
+ "id": "bedroom",
+ "display": driver.device().display.model_copy(update={"rotation": 0}),
+ }
+ )
+ monkeypatch.setattr(other, "device", lambda: model)
+ monkeypatch.setitem(service.registry.drivers, "bedroom", other)
+ root = settings.data_dir.parent / "photos"
+ root.mkdir()
+ settings.photo_root = root
+ for name, size in [("wide.jpg", (300, 200)), ("tall.jpg", (200, 300))]:
+ Image.new("RGB", size).save(root / name)
+ service.photo_sources.save(
+ "family",
+ PhotoSourceInput(name="Family", kind="local", location=".", orientation="auto"),
+ )
+ r = client.get("/api/v1/photo-sources/family/images?device_id=bedroom")
+ assert r.status_code == 200 and r.json()["items"][0]["name"] == "tall.jpg"
+ accepted = client.post(
+ "/api/v1/devices/bedroom/upload",
+ json={"type": "photo_source", "source_id": "family"},
+ )
+ assert accepted.status_code == 202 and service.process_one()
+ op = client.get("/api/v1/operations/" + accepted.json()["operation_id"]).json()
+ assert op["status"] == "COMPLETED"
+ with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image:
+ assert image.size == (1200, 1600)
diff --git a/tests/test_photo_captions.py b/tests/test_photo_captions.py
index e552136..bbbdb03 100644
--- a/tests/test_photo_captions.py
+++ b/tests/test_photo_captions.py
@@ -101,7 +101,7 @@ def test_source_filters_unorganized_and_reports_caption(client, service, setting
op = client.get("/api/v1/operations/" + accepted.json()["operation_id"]).json()
assert op["status"] == "COMPLETED"
with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image:
- assert image.size == (1200, 1600)
+ assert image.size == (1600, 1200)
def test_curated_catalog_includes_photos_beyond_old_ten_thousand_limit(