Compare commits

...
13 Commits
21 changed files with 456 additions and 23 deletions
+2
View File
@@ -69,6 +69,8 @@ Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKon
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](../pigway-console-adapter-frame-flow/README.md)。
### 存储与安全
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
+1 -1
View File
@@ -29,4 +29,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0
ARG BUILD_COMMIT
ARG BUILD_TIME
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.16" org.opencontainers.image.revision=$BUILD_COMMIT
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.17" org.opencontainers.image.revision=$BUILD_COMMIT
+76
View File
@@ -14203,6 +14203,17 @@
},
"Asset": {
"properties": {
"source_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Path"
},
"starred": {
"type": "boolean",
"title": "Starred",
@@ -16227,6 +16238,17 @@
],
"title": "Caption"
},
"source_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Path"
},
"provider_preview": {
"type": "boolean",
"title": "Provider Preview",
@@ -16346,6 +16368,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -16394,6 +16429,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
@@ -16499,6 +16548,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -16547,6 +16609,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
+11
View File
@@ -0,0 +1,11 @@
# v0.1.17
- 总览预览改用原图,避免放大低分辨率缩略图。
- 云端图库缩略图升级为最大 860×860,等比缩放、JPEG 质量 88;原位替换旧缓存,仍只保留一档。点击放大继续读取原图。
- 照片源新增最小短边、最小长边参数,同时检查源照片和实际取得的预览尺寸,默认 0 不限制。
- 本地图库显示 NAS 原照片完整路径并支持换行,保留原文件名大小写和后缀;历史四张图片已按照片 ID 核实并补齐来源。
- 自动化新增“目标相框”列;历史记录的状态、结果、调用方及操作按钮垂直居中。
- 左上角统一显示 PIGWay Frame Flow,移除图标和重复品牌前缀。
- 设置与鉴权保持左右并排、卡片等高,鉴权内部单列。图片预览弹窗保留原有布局。
不修改 NAS 原照片,不主动切换真实画框。清理仅针对项目临时构建物及过期镜像,保留持久数据和近期回滚资源。
+14 -7
View File
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI(
title="PIGWay Frame Flow API",
docs_url=None,
version="0.1.16",
version="0.1.17",
lifespan=lifespan,
responses=error_responses,
)
@@ -421,12 +421,19 @@ def create_app(settings=None, start_worker=True):
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
if thumbnail:
thumb = cached.with_name(cached.stem + "-thumb.jpg")
if not thumb.exists():
from PIL import Image
from PIL import Image
with Image.open(cached) as img:
img.thumbnail((300, 400))
img.save(thumb, "JPEG")
with Image.open(cached) as img:
# The 430px cards need up to 860px on 2x displays. Replace the
# existing cache in place so old 300px previews are upgraded.
expected_edge = min(860, max(img.size))
regenerate = not thumb.exists()
if not regenerate:
with Image.open(thumb) as existing:
regenerate = max(existing.size) != expected_edge
if regenerate:
img.thumbnail((860, 860), Image.Resampling.LANCZOS)
img.convert("RGB").save(thumb, "JPEG", quality=88, optimize=True)
return FileResponse(thumb, media_type="image/jpeg")
from PIL import Image
@@ -1033,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True,
),
],
version="0.1.16",
version="0.1.17",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
+18 -1
View File
@@ -392,7 +392,24 @@ class DisplayService:
"UPDATE operations SET result=? WHERE id=?",
(encode({"photo_attempts": attempts}), operation_id),
)
return self.assets.put(rendered, name, "cache" if staging else "images")
asset = self.assets.put(
rendered, name, "cache" if staging else "images"
)
self.db.execute(
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "photo_source",
"source_id": content.source_id,
"item_id": item["id"],
"source_path": item.get("source_path"),
}
),
),
)
return self.assets.info(asset["asset_id"])
raise FlowError(
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
)
+3 -2
View File
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.16"
version: str = "0.1.17"
class Readiness(BaseModel):
@@ -375,6 +375,7 @@ class Readiness(BaseModel):
class Asset(BaseModel):
source_path: str | None = None
starred: bool = False
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
job_id: str | None = None
@@ -396,7 +397,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "PIGWay Frame Flow"
version: str = "0.1.16"
version: str = "0.1.17"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
@@ -32,6 +32,7 @@ class PhotoSourceInput(StrictModel):
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
]
location: str = Field(min_length=1, max_length=2048)
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
recursive: bool = True
enabled: bool = True
catalog_limit: int = Field(default=10000, ge=1, le=100000)
@@ -39,6 +40,8 @@ class PhotoSourceInput(StrictModel):
organized_only: bool = False
folder_caption: bool = False
orientation: Literal["auto", "landscape", "portrait"] | None = None
minimum_short_edge: int = Field(default=0, ge=0, le=50000)
minimum_long_edge: int = Field(default=0, ge=0, le=50000)
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
minimum_brightness: float = Field(default=0, ge=0, le=255)
maximum_brightness: float = Field(default=255, ge=0, le=255)
@@ -60,6 +63,7 @@ class PhotoItem(StrictModel):
id: str
name: str
caption: dict[str, str] | None = None
source_path: str | None = None
provider_preview: bool = False
orientation: Literal["landscape", "portrait", "square"] | None = None
width: int | None = None
@@ -412,6 +416,8 @@ class PhotoSources:
height,
)
)
if not self.resolution_matches(source, width, height):
continue
direction = orientation(width, height)
if wanted and direction != wanted:
continue
@@ -478,6 +484,8 @@ class PhotoSources:
rotation = row.get("additional", {}).get("orientation", 1)
if rotation in (5, 6, 7, 8):
width, height = height, width
if not self.resolution_matches(source, width, height):
continue
direction = orientation(width, height)
if wanted and (not width or not height or direction != wanted):
continue
@@ -486,6 +494,13 @@ class PhotoSources:
"id": str(row["id"]),
"name": row["filename"],
"provider_preview": True,
"source_path": (
source.nas_root_path.rstrip("/")
+ "/"
+ relative.lstrip("/")
if source.nas_root_path
else "/" + relative.lstrip("/")
),
"caption": caption if source.folder_caption else None,
"width": width,
"height": height,
@@ -560,6 +575,11 @@ class PhotoSources:
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
return items
@staticmethod
def resolution_matches(source, width, height):
short, long = sorted((width, height))
return short >= source.minimum_short_edge and long >= source.minimum_long_edge
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
source = self.get(source_id)
items = self.catalog(source_id, target_size=target_size)
@@ -642,4 +662,15 @@ 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")
if source.minimum_short_edge or source.minimum_long_edge:
from io import BytesIO
from PIL import Image
with Image.open(BytesIO(data)) as image:
if not self.resolution_matches(source, *image.size):
raise FlowError(
E.INVALID_REQUEST,
"Photo is below the configured minimum resolution",
)
return data, item["name"], item.get("caption")
+6
View File
@@ -134,7 +134,13 @@ class Assets:
if not row:
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
meta = self.db.one("SELECT * FROM asset_metadata WHERE asset_id=?", (asset_id,))
provenance = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
)
return {
"source_path": json.loads(provenance["payload"]).get("source_path")
if provenance
else None,
"starred": bool(meta and meta["starred"]),
"origin": meta["origin"] if meta else "unknown",
"job_id": meta["job_id"] if meta else None,
+2 -2
View File
@@ -128,7 +128,7 @@ const views={
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 class="${state.tab==='local'?'source-path':''}" title="${esc(x.source_path||x.name)}">${esc(state.tab==='local'?(x.source_path||x.name):x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
render(cached||[],!cached);
try{
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
@@ -175,7 +175,7 @@ async function jobEditor(job){
views.automation=async epoch=>{
const jobs=await api('/api/v1/jobs');
if(epoch!==state.epoch)return;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong><br><small>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</small></td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('目标相框','Target frame'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
bind('new-job',()=>jobEditor());
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
+1 -1
View File
@@ -49,7 +49,7 @@ async function officialWidgetControls(info,data){
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
}
function addPhotoQualityFields(source){
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid">${[['short','minimum_short_edge',tr('最小短边(像素)','Minimum short edge (px)')],['long','minimum_long_edge',tr('最小长边(像素)','Minimum long edge (px)')]].map(([id,key,label])=>`<div class="field"><label for="source-min-${id}">${label}</label><input id="source-min-${id}" type="number" min="0" max="50000" step="1" value="${source?.[key]||0}"></div>`).join('')}</div><p class="hint">${tr('0 表示不限制;按源照片和实际取得的预览图像素筛选,横竖图通用。可从短边 1200、长边 1600 开始设置;分辨率不代表对焦清晰度。','0 disables. Checks source and downloaded preview dimensions regardless of orientation. Start with short edge 1200 and long edge 1600; pixel count does not measure focus.')}</p><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
}
async function aiImagePanel(){
+4 -4
View File
@@ -14,7 +14,7 @@ function editTemplateSource(source){state.selectedTemplate=source.template;state
async function enhanceDashboard(){
const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel);
const current=await currentPanel(panel);if(epoch!==state.epoch)return;
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),current.preview_url);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
if(current.preview_url){frame.innerHTML=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),original.pathname+original.search);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#main').append(box);
}
async function enhanceTemplates(){
@@ -150,9 +150,9 @@ async function photoSourcesPanel(){
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
}
async function photoSourceForm(source=null){
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><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" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</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 for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</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||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></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-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);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:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><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" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</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" id="source-nas-root-field"><label for="source-nas-root">${tr('NAS 原照片根目录(仅用于显示路径)','NAS original root (display only)')}</label><input id="source-nas-root" pattern="/.*" maxlength="2048" placeholder="/volume1/photo" value="${esc(source?.nas_root_path||'')}"><p class="hint">${tr('共享空间通常为 /volume1/photo,请填写实际 NAS 根目录;留空时显示 Photos 空间内路径。','Enter the actual NAS root; shared space is commonly /volume1/photo. Blank shows the path within Photos.')}</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 for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</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||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></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-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-nas-root-field').hidden=!photosAPI;$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,nas_root_path:$('#source-nas-root').value||null,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').value),minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
}
async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>PIGWay · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.16</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=gallery-layout-v0116"></script><script src="/ui/features.js?v=gallery-layout-v0116"></script><script src="/ui/extensions.js?v=gallery-layout-v0116"></script><script src="/ui/app.js?v=gallery-layout-v0116"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.17</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
+8 -1
View File
@@ -187,7 +187,7 @@ body.login-mode header,body.login-mode footer{flex-shrink:0}
.history-status{color:var(--accent)}
.history-status.bad{color:var(--bad)}
.settings-columns{align-items:start}
.settings-columns{align-items:stretch}
.settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0}
#password-form>button{align-self:flex-start}
form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end}
@@ -220,3 +220,10 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
@media(max-width:720px){#client-form>.field{grid-column:auto}}
.settings-columns>.card:nth-child(2) .detail-grid{grid-template-columns:minmax(0,1fr)}
#storage-estimate:empty,#hook-secret:empty{display:none}
.automation-table{min-width:1200px}
.automation-table td:is(:nth-child(2),:nth-child(5),:nth-child(6)){white-space:nowrap}
.automation-table .row-actions{flex-wrap:nowrap}
.history-table td:is(:nth-child(3),:nth-child(4),:nth-child(5)),.history-table .history-actions{vertical-align:middle}
.image-card h3.source-path{white-space:normal;overflow-wrap:anywhere}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pigway-frame-flow"
version = "0.1.16"
version = "0.1.17"
requires-python = ">=3.12"
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
[project.optional-dependencies]
+1 -1
View File
@@ -151,7 +151,7 @@ def release(lock):
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
base = "pigway-frame-flow:release-base-" + commit[:12]
run("docker", "image", "tag", before["Image"], base)
image = "pigway-frame-flow:0.1.16-" + commit[:12]
image = "pigway-frame-flow:0.1.17-" + commit[:12]
try:
run(
"docker",
+1 -1
View File
@@ -1,6 +1,6 @@
/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */
const assert=require('assert'),{chromium}=require('playwright');
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'}));await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]}));
try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>{return r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="WIDTH" height="900"><rect width="1200" height="900" fill="#6c9c83"/><circle cx="600" cy="450" r="220" fill="#e9d9af"/></svg>'.replace('WIDTH',new URL(r.request().url()).searchParams.has('thumbnail')?'300':'1200')});});await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]}));
await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock));
for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-original="0"]').click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)});
+65
View File
@@ -883,6 +883,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -931,6 +944,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
@@ -1036,6 +1063,19 @@
"minLength": 1,
"title": "Location"
},
"nas_root_path": {
"anyOf": [
{
"type": "string",
"maxLength": 2048,
"pattern": "^/"
},
{
"type": "null"
}
],
"title": "Nas Root Path"
},
"recursive": {
"type": "boolean",
"title": "Recursive",
@@ -1084,6 +1124,20 @@
],
"title": "Orientation"
},
"minimum_short_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Short Edge",
"default": 0
},
"minimum_long_edge": {
"type": "integer",
"maximum": 50000.0,
"minimum": 0.0,
"title": "Minimum Long Edge",
"default": 0
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
@@ -1192,6 +1246,17 @@
],
"title": "Caption"
},
"source_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Path"
},
"provider_preview": {
"type": "boolean",
"title": "Provider Preview",
+28
View File
@@ -0,0 +1,28 @@
import io
from PIL import Image
def test_gallery_thumbnail_upgrades_existing_cache_and_reuses_it(client, settings):
cache = settings.data_dir / "cache"
original = cache / "living-room-1.original"
thumb = cache / "living-room-1-thumb.jpg"
Image.new("RGB", (1600, 1200), "blue").save(original, "JPEG")
Image.new("RGB", (300, 225), "blue").save(thumb, "JPEG")
url = "/api/v1/devices/living-room/images/1/file?thumbnail=true"
result = client.get(url)
assert result.status_code == 200
assert Image.open(io.BytesIO(result.content)).size == (860, 645)
modified = thumb.stat().st_mtime_ns
assert client.get(url).content == result.content
assert thumb.stat().st_mtime_ns == modified
assert len(list(cache.glob("living-room-1-thumb*"))) == 1
assert Image.open(original).size == (1600, 1200)
def test_gallery_thumbnail_does_not_upscale_small_source(client, settings):
original = settings.data_dir / "cache" / "living-room-1.original"
Image.new("RGBA", (200, 400), (255, 0, 0, 255)).save(original, "PNG")
result = client.get("/api/v1/devices/living-room/images/1/file?thumbnail=true")
assert result.status_code == 200
assert Image.open(io.BytesIO(result.content)).size == (200, 400)
+59
View File
@@ -0,0 +1,59 @@
import io
from PIL import Image
from pigway_frame_flow.models import PhotoSourceContent
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def test_photo_path_survives_render_and_asset_listing(client, service, monkeypatch):
photos = service.photo_sources
photos.save(
"family",
PhotoSourceInput(
name="Family",
kind="synology-photos",
location=".",
nas_root_path="/volume1/photo",
),
)
monkeypatch.setattr(photos.synology, "sid", lambda: "fixture")
monkeypatch.setattr(
photos.synology,
"items",
lambda *a, **kw: [
{
"id": 42,
"filename": "original.PNG",
"additional": {
"folder": "/家庭/20190130",
"resolution": {"width": 1600, "height": 1200},
},
}
],
)
out = io.BytesIO()
Image.new("RGB", (1600, 1200)).save(out, "PNG")
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: out.getvalue())
assert (
photos.catalog("family")[0]["source_path"]
== "/volume1/photo/家庭/20190130/original.PNG"
)
asset = service.content_asset(
PhotoSourceContent(type="photo_source", source_id="family", item_id="42")
)
assert asset["source_path"] == "/volume1/photo/家庭/20190130/original.PNG"
assert client.get("/api/v1/assets").json()[0]["source_path"] == asset["source_path"]
assert (
client.get("/api/v1/assets/" + asset["asset_id"] + "/source").json()["source"][
"item_id"
]
== "42"
)
def test_missing_photo_path_is_not_invented(service):
out = io.BytesIO()
Image.new("RGB", (20, 20)).save(out, "JPEG")
asset = service.assets.put(out.getvalue(), "manual.jpg")
assert asset["source_path"] is None
+123
View File
@@ -0,0 +1,123 @@
import io
import pytest
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def test_minimum_resolution_filters_catalog_and_persists(
client, service, settings, legacy_photo_engine
):
root = settings.data_dir.parent / "photos"
root.mkdir()
settings.photo_root = root
for name, size in [
("small.jpg", (160, 120)),
("wide.jpg", (800, 600)),
("tall.jpg", (600, 800)),
]:
Image.new("RGB", size).save(root / name)
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "local",
"location": ".",
"minimum_short_edge": 600,
"minimum_long_edge": 800,
},
)
assert response.status_code == 200
assert response.json()["minimum_short_edge"] == 600
assert service.photo_sources.get("family").minimum_long_edge == 800
assert {x["name"] for x in service.photo_sources.catalog("family")} == {
"wide.jpg",
"tall.jpg",
}
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "local",
"location": ".",
},
)
assert response.status_code == 200
assert len(service.photo_sources.catalog("family")) == 3
def test_resolution_rejects_small_synology_preview(service, monkeypatch):
photos = service.photo_sources
photos.save(
"family",
PhotoSourceInput(
name="Family",
kind="synology-photos",
location=".",
minimum_short_edge=600,
minimum_long_edge=800,
),
)
row = {"id": "1", "name": "photo.jpg"}
monkeypatch.setattr(photos, "catalog", lambda *args, **kwargs: [row])
photos.api_items["family"] = {"1": {}}
monkeypatch.setattr(photos.synology, "item_people", lambda *a, **kw: [])
def image(size):
out = io.BytesIO()
Image.new("RGB", size).save(out, "JPEG")
return out.getvalue()
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((300, 400)))
with pytest.raises(FlowError, match="minimum resolution"):
photos.read("family", "1")
monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((800, 600)))
assert photos.read("family", "1")[1] == "photo.jpg"
def test_minimum_resolution_rejects_negative_value(client):
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "synology-photos",
"location": ".",
"minimum_short_edge": -1,
},
)
assert response.status_code == 422
def test_synology_metadata_resolution_filters_before_catalog_limit(
client, service, monkeypatch
):
photos = service.photo_sources
monkeypatch.setattr(photos.synology, "sid", lambda: "fixture")
rows = [
{
"id": i,
"filename": f"{i}.jpg",
"additional": {
"folder": "/family",
"resolution": {"width": w, "height": h},
},
}
for i, w, h in [(1, 300, 400), (2, 800, 600), (3, 0, 0)]
]
monkeypatch.setattr(photos.synology, "items", lambda *a, **kw: rows)
response = client.put(
"/api/v1/photo-sources/family",
json={
"name": "Family",
"kind": "synology-photos",
"location": ".",
"minimum_short_edge": 600,
"minimum_long_edge": 800,
"catalog_limit": 1,
},
)
assert response.status_code == 200
assert response.json()["minimum_long_edge"] == 800
assert [x["id"] for x in photos.catalog("family")] == ["2"]