Render curated date-folder photos with title date and place captions
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "整理照片 / Curated Photos",
|
||||
"kind": "local",
|
||||
"location": ".",
|
||||
"recursive": true,
|
||||
"enabled": true,
|
||||
"catalog_limit": 100000,
|
||||
"organized_only": true,
|
||||
"folder_caption": true
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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){
|
||||
|
||||
@@ -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.'});
|
||||
|
||||
@@ -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,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)});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user