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
+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)}