diff --git a/deploy/organized-photos-source.json b/deploy/organized-photos-source.json new file mode 100644 index 0000000..e1ee95e --- /dev/null +++ b/deploy/organized-photos-source.json @@ -0,0 +1,10 @@ +{ + "name": "整理照片 / Curated Photos", + "kind": "local", + "location": ".", + "recursive": true, + "enabled": true, + "catalog_limit": 100000, + "organized_only": true, + "folder_caption": true +} diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index 0470161..19a599b 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -83,3 +83,32 @@ protected asynchronous device display. Schemas are in `/openapi.json` and `/docs Native Aliyun Drive/Baidu Drive/Google Drive adapters are not implemented. A compatible WebDAV gateway or an independently managed NAS sync folder can be used. Frame Flow does not install a gateway or receive vendor API credentials automatically. + +## Date-folder captions / 整理照片文字 + +Enable **Only photos in date-prefixed folders** and **Show folder caption below +the photo** when editing a source. Curated sources created by the UI increase the +catalog limit to 100,000 photos; other sources default to 10,000. The API exposes +`catalog_limit` explicitly. It remains a bounded catalog; reaching the limit is +shown in the UI. + +Example folder name: `20261005 家庭出游@示例地点`. +The image contains the photo and three separate caption lines: + +```text +家庭出游 +2026-10-05 +示例地点 +``` + +Without `@`, the location line is omitted. Folder names support `YYYYMMDD`, +`YYYY-MM-DD`, `YYYY.MM.DD`, and `YYYY年MM月DD日`, with calendar-valid dates. +Subfolders inherit their nearest dated ancestor; image filenames and EXIF do not +replace this curated date. Synology `#recycle`, hidden folders, and `@eaDir` are +excluded. Extremely long captions shrink and then truncate visually; full values +remain available in catalog metadata. + +Output is 1200×1600: a 1200×1280 photo area plus a white caption area. Original +files remain unchanged. Contain/cover/smart and color/grayscale profiles still apply. +A prepared source definition is in `deploy/organized-photos-source.json`; use it +with `PUT /api/v1/photo-sources/organized-photos` after mounting the library. diff --git a/docs/openapi.json b/docs/openapi.json index 75736a2..63ba710 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10312,6 +10312,20 @@ "name": { "type": "string", "title": "Name" + }, + "caption": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Caption" } }, "additionalProperties": false, @@ -10395,6 +10409,23 @@ "title": "Enabled", "default": true }, + "catalog_limit": { + "type": "integer", + "maximum": 100000.0, + "minimum": 1.0, + "title": "Catalog Limit", + "default": 10000 + }, + "organized_only": { + "type": "boolean", + "title": "Organized Only", + "default": false + }, + "folder_caption": { + "type": "boolean", + "title": "Folder Caption", + "default": false + }, "credential_env": { "anyOf": [ { @@ -10449,6 +10480,23 @@ "title": "Enabled", "default": true }, + "catalog_limit": { + "type": "integer", + "maximum": 100000.0, + "minimum": 1.0, + "title": "Catalog Limit", + "default": 10000 + }, + "organized_only": { + "type": "boolean", + "title": "Organized Only", + "default": false + }, + "folder_caption": { + "type": "boolean", + "title": "Folder Caption", + "default": false + }, "credential_env": { "anyOf": [ { diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index d54d5d7..198970d 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -591,7 +591,7 @@ def create_app(settings=None, start_worker=True): "total": len(items), "offset": offset, "limit": limit, - "catalog_limit": 10000, + "catalog_limit": service.photo_sources.get(source_id).catalog_limit, } @app.delete( diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 313258c..6f07287 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -18,6 +18,7 @@ from pigway_frame_flow.models import ( ) from pigway_frame_flow.providers.photo_sources import PhotoSources 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 @@ -173,8 +174,17 @@ class DisplayService: ) return asset if isinstance(content, PhotoSourceContent): - data, name = self.photo_sources.read(content.source_id, content.item_id) - return self.assets.put(convert(data, content.fit, content.profile), name) + data, name, caption = self.photo_sources.read( + content.source_id, content.item_id + ) + rendered = ( + render_captioned_photo( + data, caption, self.renderer.font, content.fit, content.profile + ) + if caption + else convert(data, content.fit, content.profile) + ) + return self.assets.put(rendered, name) if isinstance(content, URLContent): data = self.assets.fetch_url(content.url) elif isinstance(content, ImageContent) and content.asset_id: diff --git a/pigway_frame_flow/providers/photo_names.py b/pigway_frame_flow/providers/photo_names.py new file mode 100644 index 0000000..e726b9b --- /dev/null +++ b/pigway_frame_flow/providers/photo_names.py @@ -0,0 +1,34 @@ +"""Interpret user-curated date folders without guessing dates from image files.""" + +import re +from datetime import date +from pathlib import PurePosixPath + +_DATE = r"(?P\d{4})(?:[-._年]?)(?P\d{2})(?:[-._月]?)(?P\d{2})(?:日)?(?!\d)" +_PREFIX = re.compile(r"^" + _DATE) + + +def folder_caption(name: str) -> dict[str, str] | None: + match = _PREFIX.match(name.strip()) + if not match: + return None + try: + captured = date(int(match["year"]), int(match["month"]), int(match["day"])) + except ValueError: + return None + remainder = name.strip()[match.end() :].strip(" _-—–::") + title, separator, place = remainder.replace("@", "@").partition("@") + return { + "title": " ".join(title.split()), + "date": captured.isoformat(), + "place": " ".join(place.split()) if separator else "", + } + + +def path_caption(path: str) -> dict[str, str] | None: + # Children inherit their nearest date folder, rather than their filename. + for component in reversed(PurePosixPath(path).parent.parts): + caption = folder_caption(component) + if caption is not None: + return caption + return None diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 13a2a26..3d31440 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -17,6 +17,7 @@ from pydantic import Field 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.settings import secret from pigway_frame_flow.storage.database import encode @@ -27,6 +28,9 @@ class PhotoSourceInput(StrictModel): location: str = Field(min_length=1, max_length=2048) recursive: bool = True enabled: bool = True + catalog_limit: int = Field(default=10000, ge=1, le=100000) + organized_only: bool = False + folder_caption: bool = False credential_env: str | None = Field( default=None, pattern=r"^FRAME_FLOW_SOURCE_[A-Z0-9_]+$" ) @@ -39,6 +43,7 @@ class PhotoSourceView(PhotoSourceInput): class PhotoItem(StrictModel): id: str name: str + caption: dict[str, str] | None = None class PhotoCatalog(StrictModel): @@ -204,13 +209,19 @@ class PhotoSources: sorted( d for d in dirs - if not d.startswith(".") + if d != "#recycle" + and not d.startswith(".") and not d.startswith("@") and not Path(folder, d).is_symlink() ) if source.recursive else [] ) + caption = path_caption( + Path(folder).relative_to(root).as_posix() + "/__photo__.jpg" + ) + if source.organized_only and caption is None: + continue for name in sorted(files): path = Path(folder, name) if ( @@ -223,12 +234,18 @@ class PhotoSources: if path.stat().st_size > self.settings.max_bytes: continue relative = path.relative_to(root).as_posix() - items.append({"id": relative, "name": name}) + items.append( + { + "id": relative, + "name": name, + "caption": caption if source.folder_caption else None, + } + ) except OSError: continue - if len(items) >= 10000: + if len(items) >= source.catalog_limit: break - if len(items) >= 10000: + if len(items) >= source.catalog_limit: break else: data = self.assets.fetch_url( @@ -266,12 +283,19 @@ class PhotoSources: or parsed.fragment ): continue + caption = path_caption(unquote(parsed.path)) + if source.organized_only and caption is None: + continue if Path(parsed.path).suffix.lower() in self.extensions: seen.add(url) items.append( - {"id": url, "name": unquote(Path(parsed.path).name)} + { + "id": url, + "name": unquote(Path(parsed.path).name), + "caption": caption if source.folder_caption else None, + } ) - if len(items) >= 10000: + if len(items) >= source.catalog_limit: break except ElementTree.ParseError: raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None @@ -332,4 +356,4 @@ class PhotoSources: data = self.assets.fetch_url(item["id"], headers=self.headers(source)) if len(data) > self.settings.max_bytes: raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit") - return data, item["name"] + return data, item["name"], item.get("caption") diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index c5627df..268d066 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -14,7 +14,7 @@ from pigway_frame_flow.storage.database import now_iso Image.MAX_IMAGE_PIXELS = 25_000_000 -def convert(data, fit="contain", profile="color"): +def convert(data, fit="contain", profile="color", *, size=(1200, 1600)): try: with Image.open(BytesIO(data)) as source: if source.format not in {"JPEG", "PNG", "WEBP"}: @@ -27,7 +27,7 @@ def convert(data, fit="contain", profile="color"): img = canvas.convert("RGB") if fit == "contain": result = ImageOps.pad( - img, (1200, 1600), method=Image.Resampling.LANCZOS, color="white" + img, size, method=Image.Resampling.LANCZOS, color="white" ) else: center = (0.5, 0.5) @@ -38,7 +38,11 @@ def convert(data, fit="contain", profile="color"): thumb.thumbnail((240, 240)) edges = thumb.convert("L").filter(ImageFilter.FIND_EDGES) w, h = edges.size - cw, ch = (int(h * 0.75), h) if w / h > 0.75 else (w, int(w / 0.75)) + cw, ch = ( + (int(h * size[0] / size[1]), h) + if w / h > size[0] / size[1] + else (w, int(w * size[1] / size[0])) + ) choices = [] for step in range(11): x, y = int((w - cw) * step / 10), int((h - ch) * step / 10) @@ -53,7 +57,7 @@ def convert(data, fit="contain", profile="color"): (y / (h - ch)) if h != ch else 0.5, ) result = ImageOps.fit( - img, (1200, 1600), method=Image.Resampling.LANCZOS, centering=center + img, size, method=Image.Resampling.LANCZOS, centering=center ) if profile == "grayscale": result = ImageOps.grayscale(result).convert("RGB") diff --git a/pigway_frame_flow/renderers/photo_caption.py b/pigway_frame_flow/renderers/photo_caption.py new file mode 100644 index 0000000..832b2cd --- /dev/null +++ b/pigway_frame_flow/renderers/photo_caption.py @@ -0,0 +1,29 @@ +"""Native frame image with a separate, high-contrast caption area.""" + +from io import BytesIO + +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") + 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: + text = text[:-2] + "…" + draw.text((600, y), 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 b2374c4..01ed999 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -117,13 +117,13 @@ async function photoSourcesPanel(){ section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); } function photoSourceForm(source=null){ - showModal(`

${t(source?'edit':'addSource')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

`); + showModal(`

${t(source?'edit':'addSource')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('folderCaptionHint')}

`); $('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').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,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');}); + $('#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,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');}); } 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 96b2ed3..19be271 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -19,3 +19,6 @@ Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and su Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'从只读 NAS 目录或 WebDAV 选图;预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',nasDirectory:'NAS 本地目录',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'本地填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV 填写 HTTPS 目录 URL,并先在设置中允许该域名。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录(本地目录)',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'}); Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photos from a read-only NAS folder or WebDAV. Preview does not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',nasDirectory:'NAS local folder',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Local: relative folder under the mounted photo root, e.g. . or family. WebDAV: HTTPS directory URL; allow its hostname in Settings first.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders (local)',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'}); + +Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'第一行:去掉日期和地点后的主题;第二行:日期;第三行:@ 后的地点(没有地点则省略)。子目录沿用上级日期文件夹。'}); +Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'Line 1: title without date/place. Line 2: date. Line 3: place after @, omitted when absent. Subfolders inherit their nearest dated folder.'}); diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 83202ad..4b6ecff 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -50,3 +50,5 @@ input[type="file"]::file-selector-button{margin-inline-end:12px} .history-table{min-width:920px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:100px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:100px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:middle;white-space:nowrap}.history-actions button{white-space:nowrap} .photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px} + +.source-items button small{display:block;margin-top:8px;color:var(--muted)} diff --git a/scripts/ui-photo-sources.cjs b/scripts/ui-photo-sources.cjs index 3a21998..cba9296 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="gallery"]').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('#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="gallery"]').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);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="gallery"]').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('#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="gallery"]').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());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 eb82ca8..b9f7269 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -830,6 +830,23 @@ "title": "Enabled", "default": true }, + "catalog_limit": { + "type": "integer", + "maximum": 100000.0, + "minimum": 1.0, + "title": "Catalog Limit", + "default": 10000 + }, + "organized_only": { + "type": "boolean", + "title": "Organized Only", + "default": false + }, + "folder_caption": { + "type": "boolean", + "title": "Folder Caption", + "default": false + }, "credential_env": { "anyOf": [ { @@ -884,6 +901,23 @@ "title": "Enabled", "default": true }, + "catalog_limit": { + "type": "integer", + "maximum": 100000.0, + "minimum": 1.0, + "title": "Catalog Limit", + "default": 10000 + }, + "organized_only": { + "type": "boolean", + "title": "Organized Only", + "default": false + }, + "folder_caption": { + "type": "boolean", + "title": "Folder Caption", + "default": false + }, "credential_env": { "anyOf": [ { @@ -920,6 +954,20 @@ "name": { "type": "string", "title": "Name" + }, + "caption": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Caption" } }, "additionalProperties": false, diff --git a/tests/test_photo_captions.py b/tests/test_photo_captions.py new file mode 100644 index 0000000..9910216 --- /dev/null +++ b/tests/test_photo_captions.py @@ -0,0 +1,127 @@ +from io import BytesIO + +import pytest +from PIL import Image + +from pigway_frame_flow.providers.photo_names import folder_caption, path_caption +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput +from pigway_frame_flow.renderers.photo_caption import render_captioned_photo + + +@pytest.mark.parametrize( + "name,title,day,place", + [ + ("20141207 婚礼@示例酒店", "婚礼", "2014-12-07", "示例酒店"), + ("2026-10-05 家庭出游 @ 示例地点", "家庭出游", "2026-10-05", "示例地点"), + ("2026.10.05_周末@家", "周末", "2026-10-05", "家"), + ("2026年10月05日 国庆假期", "国庆假期", "2026-10-05", ""), + ("20041015 军训合影", "军训合影", "2004-10-15", ""), + ("20241005 主题@地点@补充说明", "主题", "2024-10-05", "地点@补充说明"), + ], +) +def test_folder_three_line_caption(name, title, day, place): + assert folder_caption(name) == {"title": title, "date": day, "place": place} + + +@pytest.mark.parametrize( + "name", + [ + "MobileBackup", + "20260230 无效日期", + "20261301 无效月份", + "20261005123 数字不是日期", + ], +) +def test_non_date_folders_are_not_curated(name): + assert folder_caption(name) is None + + +def test_child_folder_inherits_date_and_location(): + assert path_caption("20141207 婚礼@示例酒店/精选/IMG_001.jpg") == { + "title": "婚礼", + "date": "2014-12-07", + "place": "示例酒店", + } + assert path_caption("MobileBackup/20260101.jpg") is None + + +@pytest.mark.parametrize("profile", ["color", "grayscale"]) +def test_native_caption_layout_has_three_separate_lines(service, profile): + out = BytesIO() + Image.new("RGB", (1600, 900), "blue").save(out, "JPEG") + result = render_captioned_photo( + out.getvalue(), + folder_caption("20141207 婚礼@示例酒店"), + service.renderer.font, + profile=profile, + ) + with Image.open(BytesIO(result)) as image: + assert image.size == (1200, 1600) + for y in [1320, 1410, 1480]: + darkest = image.crop((50, y, 1150, y + 65)).convert("L").getextrema()[0] + assert darkest < 50, "Caption row is missing" + pixel = image.getpixel((600, 640)) + assert ( + len(set(pixel)) <= 2 + if profile == "grayscale" + else pixel[2] > pixel[0] + 100 + ) + + +def test_source_filters_unorganized_and_reports_caption(client, service, settings): + root = settings.data_dir.parent / "photos" + settings.photo_root = root + for folder in ["20261005 秋游@金山/精选", "MobileBackup", "20260230 无效日期"]: + path = root / folder / "photo.jpg" + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (100, 100), "red").save(path) + source = PhotoSourceInput( + name="Curated", + kind="local", + location=".", + organized_only=True, + folder_caption=True, + catalog_limit=100000, + ) + service.photo_sources.save("curated", source) + r = client.get("/api/v1/photo-sources/curated/images") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 1 and data["catalog_limit"] == 100000 + assert data["items"][0]["caption"] == { + "title": "秋游", + "date": "2026-10-05", + "place": "金山", + } + accepted = client.post( + "/api/v1/devices/living-room/upload", + json={"type": "photo_source", "source_id": "curated"}, + ) + 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) + + +def test_curated_catalog_includes_photos_beyond_old_ten_thousand_limit( + service, settings +): + root = settings.data_dir.parent / "many-photos" + settings.photo_root = root + folder = root / "20261005 旅行@示例地点" + folder.mkdir(parents=True) + for index in range(10001): + (folder / f"{index:05}.jpg").write_bytes(b"catalog metadata fixture") + service.photo_sources.save( + "all", + PhotoSourceInput( + name="All curated", + kind="local", + location=".", + organized_only=True, + folder_caption=True, + catalog_limit=100000, + ), + ) + assert len(service.photo_sources.catalog("all")) == 10001