From bf349c6ca8973fb751cf458f35fe5f935f6f6a5b Mon Sep 17 00:00:00 2001 From: Pigway Date: Mon, 5 Oct 2026 10:38:20 +0800 Subject: [PATCH] Filter NAS photos by EXIF-aware orientation with persistent dimension cache --- docs/PHOTO_SOURCES.md | 8 ++ docs/openapi.json | 70 +++++++++++ .../providers/photo_orientation.py | 38 ++++++ pigway_frame_flow/providers/photo_sources.py | 60 ++++++++++ pigway_frame_flow/storage/database.py | 1 + pigway_frame_flow/web/features.js | 10 +- pigway_frame_flow/web/i18n.js | 3 + scripts/ui-photo-sources.cjs | 2 +- tests/schema-contract.json | 70 +++++++++++ tests/test_photo_orientation.py | 112 ++++++++++++++++++ 10 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 pigway_frame_flow/providers/photo_orientation.py create mode 100644 tests/test_photo_orientation.py 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')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

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

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); + $('#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" + )