diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md
index 5138503..508e429 100644
--- a/docs/PHOTO_SOURCES.md
+++ b/docs/PHOTO_SOURCES.md
@@ -143,3 +143,11 @@ caption still comes from the original curated directory.
只允许 Auth login/logout、共享空间 Person.list、Item.list、Folder.get。人物照片按 500 条分页,成员路径缓存 15 分钟,源目录缓存 60 秒;显式刷新会重新查询。无后台高频轮询,不下载全图库原图。查询失败和登录失效明确报错。大型人物相册首次查询可能需要数十秒。
This optional integration reads the existing shared-space People catalog. It does not modify Photos or perform face inference. Login stores only a protected session, never the NAS password. Certificate pinning happens before credentials are sent. Select one or more people and match any/all, intersect with the existing curated folder rules, then randomize. Empty matches and expired sessions fail closed. Person membership caches for 15 minutes; explicit refresh re-queries. This improves relevance, not blur/closed-eye detection. Recognition can miss faces. Personal-space filtering and automatic photo-quality scoring are not implemented.
+
+## 照片方向 / Orientation
+
+编辑照片数据源可选:横向、竖向、自动识别。自动匹配当前渲染画布 1200×1600,因此选竖向。按 EXIF 校正后的 width/height 分类,正方形不混入横向或竖向。预览、指定图片和定时随机选图使用同一筛选结果;空结果明确失败,不退回其他方向。照片不为匹配方向强制旋转或拉伸。
+
+已有数据源 `orientation=null` 保持原来的全部方向,编辑器显示“不筛方向(原有设置)”;新建数据源默认自动。尺寸缓存持久化在 SQLite `photo_dimensions`,按路径、修改时间和大小失效。首次扫描读取本地图片尺寸和 EXIF,不批量生成全图库预览;后续刷新复用缓存。仅 NAS 本地源支持,WebDAV 不批量下载原图测尺寸。若将来改变原生画布尺寸,需要同步调整 auto 的匹配规则。
+
+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.
diff --git a/docs/openapi.json b/docs/openapi.json
index e74fee6..ddf4fc8 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -10897,6 +10897,44 @@
"type": "boolean",
"title": "Provider Preview",
"default": false
+ },
+ "orientation": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "landscape",
+ "portrait",
+ "square"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Orientation"
+ },
+ "width": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Width"
+ },
+ "height": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Height"
}
},
"additionalProperties": false,
@@ -11002,6 +11040,22 @@
"title": "Folder Caption",
"default": false
},
+ "orientation": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "auto",
+ "landscape",
+ "portrait"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Orientation"
+ },
"people_filter": {
"type": "boolean",
"title": "People Filter",
@@ -11100,6 +11154,22 @@
"title": "Folder Caption",
"default": false
},
+ "orientation": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "auto",
+ "landscape",
+ "portrait"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Orientation"
+ },
"people_filter": {
"type": "boolean",
"title": "People Filter",
diff --git a/pigway_frame_flow/providers/photo_orientation.py b/pigway_frame_flow/providers/photo_orientation.py
new file mode 100644
index 0000000..55175b1
--- /dev/null
+++ b/pigway_frame_flow/providers/photo_orientation.py
@@ -0,0 +1,38 @@
+"""EXIF-aware dimensions without decoding original photo pixels."""
+
+import os
+import stat
+from pathlib import Path
+
+from PIL import Image, UnidentifiedImageError
+
+
+def dimensions(root: Path, path: Path):
+ parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ parts = path.relative_to(root).parts
+ for part in parts[:-1]:
+ child = os.open(
+ part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd
+ )
+ os.close(parent_fd)
+ parent_fd = child
+ fd = os.open(
+ parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd
+ )
+ with os.fdopen(fd, "rb") as stream:
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
+ return None
+ with Image.open(stream) as image:
+ width, height = image.size
+ if image.getexif().get(274, 1) in (5, 6, 7, 8):
+ width, height = height, width
+ return width, height
+ except (OSError, ValueError, UnidentifiedImageError, Image.DecompressionBombError):
+ return None
+ finally:
+ os.close(parent_fd)
+
+
+def orientation(width, height):
+ return "landscape" if width > height else "portrait" if height > width else "square"
diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py
index 5e6648b..dccb8c9 100644
--- a/pigway_frame_flow/providers/photo_sources.py
+++ b/pigway_frame_flow/providers/photo_sources.py
@@ -18,6 +18,7 @@ from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.providers.photo_names import path_caption
+from pigway_frame_flow.providers.photo_orientation import dimensions, orientation
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
from pigway_frame_flow.settings import secret
from pigway_frame_flow.storage.database import encode
@@ -33,6 +34,7 @@ class PhotoSourceInput(StrictModel):
synology_previews: bool = False
organized_only: bool = False
folder_caption: bool = False
+ orientation: Literal["auto", "landscape", "portrait"] | None = None
people_filter: bool = False
person_ids: list[int] = Field(default_factory=list, max_length=50)
person_match: Literal["any", "all"] = "any"
@@ -50,6 +52,9 @@ class PhotoItem(StrictModel):
name: str
caption: dict[str, str] | None = None
provider_preview: bool = False
+ orientation: Literal["landscape", "portrait", "square"] | None = None
+ width: int | None = None
+ height: int | None = None
class PhotoCatalog(StrictModel):
@@ -96,6 +101,11 @@ class PhotoSources:
return PhotoSourceInput.model_validate_json(row["payload"])
def save(self, source_id, source):
+ if source.orientation and source.kind != "local":
+ raise FlowError(
+ E.UNSUPPORTED_CAPABILITY,
+ "Orientation filtering requires a local photo source",
+ )
if source.people_filter and source.kind != "local":
raise FlowError(
E.INVALID_REQUEST,
@@ -250,6 +260,13 @@ class PhotoSources:
else None
)
items = []
+ sizes = (
+ {r["path"]: r for r in self.db.all("SELECT * FROM photo_dimensions")}
+ if source.orientation
+ 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()
@@ -292,8 +309,45 @@ class PhotoSources:
relative = path.relative_to(root).as_posix()
if allowed is not None and relative not in allowed:
continue
+ info = {}
+ if wanted:
+ key = str(path)
+ stamp = path.stat()
+ cached_size = sizes.get(key)
+ if (
+ cached_size
+ and cached_size["mtime"] == stamp.st_mtime_ns
+ and cached_size["bytes"] == stamp.st_size
+ ):
+ width, height = (
+ cached_size["width"],
+ cached_size["height"],
+ )
+ else:
+ size = dimensions(root, path)
+ if size is None:
+ continue
+ width, height = size
+ size_updates.append(
+ (
+ key,
+ stamp.st_mtime_ns,
+ stamp.st_size,
+ width,
+ height,
+ )
+ )
+ direction = orientation(width, height)
+ if direction != wanted:
+ continue
+ info = {
+ "width": width,
+ "height": height,
+ "orientation": direction,
+ }
items.append(
{
+ **info,
"id": relative,
"name": name,
"provider_preview": preview,
@@ -358,6 +412,12 @@ class PhotoSources:
break
except ElementTree.ParseError:
raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None
+ if size_updates:
+ with self.db.transaction() as connection:
+ connection.executemany(
+ "INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
+ size_updates,
+ )
self.cache[source_id] = (time.monotonic(), items)
return items
diff --git a/pigway_frame_flow/storage/database.py b/pigway_frame_flow/storage/database.py
index 3e3fd5d..9d2fb25 100644
--- a/pigway_frame_flow/storage/database.py
+++ b/pigway_frame_flow/storage/database.py
@@ -23,6 +23,7 @@ class Database:
PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
PRAGMA synchronous=FULL;
+ CREATE TABLE IF NOT EXISTS photo_dimensions (path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS asset_sources (asset_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL);
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index 0b2f1aa..e671894 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -121,7 +121,7 @@ async function photosPeoplePicker(selected){
async function photoSourcesPanel(){
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
- section.innerHTML=`
${t('photoSources')}
${t('sourceHint')}
${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`
${esc(s.name)}
${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}
`).join(''):`
${t('noSources')}
`}
`;
+ section.innerHTML=`${t('photoSources')}
${t('sourceHint')}
${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`
${esc(s.name)}
${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}
`).join(''):`
${t('noSources')}
`}
`;
bind('add-photo-source',()=>photoSourceForm());
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');}));
@@ -130,13 +130,13 @@ async function photoSourcesPanel(){
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
}
async function photoSourceForm(source=null){
- showModal(`${t(source?'edit':'addSource')}
`);
- const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;
- $('#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,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');});
+ showModal(`${t(source?'edit':'addSource')}
`);
+ $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;
+ $('#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;
- 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.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));
}
async function previewSource(id,item_id=null){
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index 075e65e..8424dd1 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -30,3 +30,6 @@ 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.'});
diff --git a/scripts/ui-photo-sources.cjs b/scripts/ui-photo-sources.cjs
index 5b302f9..5934e3a 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-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-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);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/schema-contract.json b/tests/schema-contract.json
index 127c9d7..8693824 100644
--- a/tests/schema-contract.json
+++ b/tests/schema-contract.json
@@ -852,6 +852,22 @@
"title": "Folder Caption",
"default": false
},
+ "orientation": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "auto",
+ "landscape",
+ "portrait"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Orientation"
+ },
"people_filter": {
"type": "boolean",
"title": "People Filter",
@@ -950,6 +966,22 @@
"title": "Folder Caption",
"default": false
},
+ "orientation": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "auto",
+ "landscape",
+ "portrait"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Orientation"
+ },
"people_filter": {
"type": "boolean",
"title": "People Filter",
@@ -1027,6 +1059,44 @@
"type": "boolean",
"title": "Provider Preview",
"default": false
+ },
+ "orientation": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "landscape",
+ "portrait",
+ "square"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Orientation"
+ },
+ "width": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Width"
+ },
+ "height": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Height"
}
},
"additionalProperties": false,
diff --git a/tests/test_photo_orientation.py b/tests/test_photo_orientation.py
new file mode 100644
index 0000000..5fd7088
--- /dev/null
+++ b/tests/test_photo_orientation.py
@@ -0,0 +1,112 @@
+import pytest
+from PIL import Image
+
+from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
+
+
+@pytest.fixture
+def oriented(service, settings):
+ settings.photo_root = settings.data_dir.parent / "photos"
+ root = settings.photo_root / "20240101 Day"
+ root.mkdir(parents=True)
+ for name, size, rotation in [
+ ("wide.jpg", (200, 100), 1),
+ ("tall.jpg", (100, 200), 1),
+ ("rotated.jpg", (200, 100), 6),
+ ("square.jpg", (100, 100), 1),
+ ]:
+ exif = Image.Exif()
+ exif[274] = rotation
+ Image.new("RGB", size).save(root / name, exif=exif)
+ return service.photo_sources
+
+
+@pytest.mark.parametrize(
+ "direction,names",
+ [
+ ("landscape", {"wide.jpg"}),
+ ("portrait", {"tall.jpg", "rotated.jpg"}),
+ ("auto", {"tall.jpg", "rotated.jpg"}),
+ (None, {"wide.jpg", "tall.jpg", "rotated.jpg", "square.jpg"}),
+ ],
+)
+def test_orientation_catalog_and_exif(oriented, direction, names):
+ oriented.save(
+ "family",
+ PhotoSourceInput(
+ name="Family",
+ kind="local",
+ location=".",
+ orientation=direction,
+ organized_only=True,
+ ),
+ )
+ rows = oriented.catalog("family")
+ assert {r["name"] for r in rows} == names
+ if direction:
+ assert oriented.db.one("SELECT COUNT(*) AS n FROM photo_dimensions")["n"] == 4
+ if direction != "landscape":
+ rotated = next(r for r in rows if r["name"] == "rotated.jpg")
+ assert (rotated["width"], rotated["height"]) == (100, 200)
+
+
+def test_cached_dimensions_invalidation_and_no_fallback(
+ oriented, settings, monkeypatch
+):
+ from pigway_frame_flow.errors import FlowError
+
+ oriented.save(
+ "family",
+ PhotoSourceInput(
+ name="Family", kind="local", location=".", orientation="landscape"
+ ),
+ )
+ assert len(oriented.catalog("family")) == 1
+ monkeypatch.setattr(
+ "pigway_frame_flow.providers.photo_sources.dimensions",
+ lambda *a: pytest.fail("unchanged dimensions should be cached"),
+ )
+ assert len(oriented.catalog("family", refresh=True)) == 1
+ monkeypatch.undo()
+ Image.new("RGB", (50, 100)).save(settings.photo_root / "20240101 Day" / "wide.jpg")
+ assert oriented.catalog("family", refresh=True) == []
+ with pytest.raises(FlowError):
+ oriented.read("family")
+
+
+def test_orientation_validation_and_api(client, oriented):
+ r = client.put(
+ "/api/v1/photo-sources/family",
+ json={
+ "name": "Family",
+ "kind": "local",
+ "location": ".",
+ "orientation": "landscape",
+ },
+ )
+ assert r.status_code == 200 and r.json()["orientation"] == "landscape"
+ assert client.get("/api/v1/photo-sources/family/images").json()["total"] == 1
+ assert (
+ client.put(
+ "/api/v1/photo-sources/family",
+ json={
+ "name": "Family",
+ "kind": "local",
+ "location": ".",
+ "orientation": "sideways",
+ },
+ ).status_code
+ == 422
+ )
+ assert (
+ client.put(
+ "/api/v1/photo-sources/dav",
+ json={
+ "name": "Remote",
+ "kind": "webdav",
+ "location": "https://example.com/",
+ "orientation": "portrait",
+ },
+ ).json()["error"]["code"]
+ == "UNSUPPORTED_CAPABILITY"
+ )