Render curated date-folder photos with title date and place captions

This commit is contained in:
Pigway
2026-10-05 01:36:05 +08:00
parent f5ab1ade77
commit 011339d511
15 changed files with 386 additions and 18 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"name": "整理照片 / Curated Photos",
"kind": "local",
"location": ".",
"recursive": true,
"enabled": true,
"catalog_limit": 100000,
"organized_only": true,
"folder_caption": true
}
+29
View File
@@ -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.
+48
View File
@@ -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": [
{
+1 -1
View File
@@ -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(
+12 -2
View File
@@ -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:
@@ -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<year>\d{4})(?:[-._年]?)(?P<month>\d{2})(?:[-._月]?)(?P<day>\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
+31 -7
View File
@@ -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")
+8 -4
View File
@@ -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")
@@ -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()
+3 -3
View File
@@ -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(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#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=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}</button>`).join('')||t('noSources')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.caption?`<small>${esc([x.caption.title,x.caption.date,x.caption.place].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSources')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
}
async function previewSource(id,item_id=null){
+3
View File
@@ -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.'});
+2
View File
@@ -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)}
+1 -1
View File
@@ -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)});
+48
View File
@@ -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,
+127
View File
@@ -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