Render curated date-folder photos with title date and place captions
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user