Make collage selection automatic and restore dashboard navigation order

This commit is contained in:
Pigway
2026-10-10 09:28:37 +08:00
parent cc4126d23c
commit a869f0b042
17 changed files with 452 additions and 13 deletions
+55
View File
@@ -0,0 +1,55 @@
# Automatic collage
The navigation starts with Dashboard, followed by Gallery, Photo Sources, Upload,
Templates, Layout Studio, Automation, Playlists, History, Connections and Settings.
## Web workflow / 界面操作
在「拼图」选择照片来源与数量(1–12),填写可选标题或排版要求,点击
「随机选图并生成预览」。默认使用启用的数据源;没有数据源时使用本地上传照片。
AI 只接收图片尺寸、内部资产 ID 和排版文字,不发送照片。
AI 未配置或请求失败会明确报错,不会偷偷切换引擎;取消 AI 勾选可用自动网格。
预览不会上传或切换画框,确认效果后再点「显示到画框」。
Choose a source and count, then create a preview. Only dimensions, asset IDs and
layout instructions are sent to AI. AI configuration/errors are explicit; uncheck
AI for deterministic grid layout. Preview never changes the frame. Canvas
orientation follows the target device. Manual layout is under Advanced.
「创建自动化任务」保存来源和数量,而不是固定图片列表。任务初始暂停,
请在自动化中编辑执行时间后启用。每次运行重新随机选图,但不同运行之间可能重复;
单次不重复选同一照片。照片不足时失败,不重复图片凑数。
NAS 来源继续使用人物、方向与质量筛选;一次最多检查数量的五倍候选图片。
输入图缓存与生成图在 NAS;原始 NAS 照片不会被修改或删除。
定时拼图沿用云库每任务保留最近一张成功图的策略,下一张确认成功前不删除上一张。
Create automation stores the source/count, initially paused. Edit its schedule
and enable it. Each execution selects new candidates; repeats across executions
are possible. Too few usable photos fails before upload. Existing unknown-write,
restoration and cloud-retention safeguards apply.
## Public API
`POST /api/v1/render` accepts this additive content type, returning HTTP 202 and
an operation ID. `POST /api/v1/display` uses the same content inside its existing
request envelope. Scheduler `source` accepts the same object; job previews are
read-only.
```json
{
"type": "auto_collage",
"source_id": "nas-photos",
"count": 4,
"title": "Our moments",
"orientation": "auto",
"style": "grid",
"use_ai": true,
"prompt": "Feature one photo with smaller supporting photos",
"profile": "color"
}
```
`source_id: null` chooses local uploaded assets (generated and cache images are
excluded). AI configuration is shared with existing Layout Studio. No new API key
or vendor dependency is introduced. The OpenAPI contract test records the new
content schema while keeping all existing schema fields unchanged.
+2 -2
View File
@@ -1,7 +1,7 @@
# UI layout audit
The Mock-only `scripts/ui-layout-audit.cjs` checks all 11 navigation pages,
local/cloud gallery layouts and six modal states at 1440, 768 and 390 CSS pixels,
local/cloud gallery layouts and six modal states plus expanded manual collage at 1440, 768 and 390 CSS pixels,
in Chinese/English and light/dark themes. It checks page horizontal overflow and
at least 8 pixels between visible input/select/textarea/button rectangles, and
44-pixel control heights on mobile.
@@ -32,5 +32,5 @@ node scripts/ui-layout-audit.cjs
Set `NODE_PATH` to the installed Playwright runtime if needed. Optional
`FRAME_FLOW_SCREENSHOT_DIR` saves fixture-only screenshots for visual review.
The full audit passed 217 checks with no console errors. This complements
The full audit passed 229 checks with no console errors. This complements
`ui-smoke.cjs` and `ui-integrations.cjs`; it does not replace functional tests.
+6 -3
View File
@@ -29,6 +29,7 @@ from pigway_frame_flow.models import (
Accepted,
Asset,
AssetBatch,
AutoCollageContent,
CurrentDisplay,
DeleteRequest,
Device,
@@ -485,7 +486,7 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("render:write"))],
)
def render(
payload: TemplateContent,
payload: TemplateContent | AutoCollageContent,
request: Request,
idempotency_key: str | None = Header(default=None),
):
@@ -1175,9 +1176,11 @@ def create_app(settings=None, start_worker=True):
idempotency_key: str | None = Header(default=None),
):
source = get_job(job_id)["source"]
if source["type"] != "template":
if source["type"] not in ("template", "auto_collage"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Job preview requires a template source", 422
E.UNSUPPORTED_CAPABILITY,
"Job preview requires template or auto-collage content",
422,
)
return submit(
request, "render", source, get_job(job_id)["device_id"], idempotency_key
+1 -1
View File
@@ -23,7 +23,7 @@ class CloudLibrary:
job_id = (
job["job_id"]
if job
and content.get("type") == "photo_source"
and content.get("type") in ("photo_source", "auto_collage")
and content.get("item_id") is None
else None
)
+12
View File
@@ -11,6 +11,7 @@ from pigway_frame_flow.drivers.base import DriverRegistry
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import (
AutoCollageContent,
DisplayRequest,
FutureContent,
ImageContent,
@@ -81,6 +82,11 @@ class DisplayService:
) from None
for region in plan.regions:
self.assets.path(region.asset_id)
if (
isinstance(request.content, AutoCollageContent)
and request.content.source_id
):
self.photo_sources.get(request.content.source_id)
if isinstance(request.content, PhotoSourceContent):
self.photo_sources.get(request.content.source_id)
if writing and not self.settings.mock and not self.settings.write_enabled:
@@ -191,6 +197,12 @@ class DisplayService:
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
def content_asset(self, content, device_id=None, operation_id=None):
if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare
return self.content_asset(
prepare(self, content, device_id), device_id, operation_id
)
if isinstance(content, ProviderContent):
values = {**content.data, **self.providers.get(content.provider_id)}
return self.content_asset(
@@ -0,0 +1,80 @@
"""Random selection and layout shared by preview, display and scheduled jobs."""
import random
from io import BytesIO
from PIL import Image, ImageOps
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutStudio
from pigway_frame_flow.models import TemplateContent
def prepare(service, content, device_id=None):
rng = random.SystemRandom()
ids = []
hashes = set()
if content.source_id:
from pigway_frame_flow.extensions.quality import passes, photo_quality
source = service.photo_sources.get(content.source_id)
items = service.photo_sources.catalog(content.source_id)
candidates = rng.sample(items, min(len(items), content.count * 5))
for item in candidates:
try:
data, name, _ = service.photo_sources.read(
content.source_id, item["id"]
)
quality = photo_quality(data)
if not passes(quality, source) or quality["sha256"] in hashes:
continue
if source.avoid_duplicates and quality["fingerprint"] in hashes:
continue
hashes.update([quality["sha256"], quality["fingerprint"]])
with Image.open(BytesIO(data)) as image:
photo = ImageOps.exif_transpose(image).convert("RGB")
photo.thumbnail((2400, 2400))
output = BytesIO()
photo.save(output, "JPEG", quality=92)
asset = service.assets.put(output.getvalue(), name, "cache")
if asset["asset_id"] not in ids:
ids.append(asset["asset_id"])
except (OSError, ValueError):
continue
if len(ids) == content.count:
break
else:
rows = service.db.all(
"SELECT id AS asset_id FROM assets WHERE kind='images' ORDER BY created_at DESC LIMIT 10000"
)
ids = rng.sample(
[row["asset_id"] for row in rows], min(content.count, len(rows))
)
if len(ids) != content.count:
raise FlowError(
E.INVALID_REQUEST, "Not enough distinct photos for requested count", 422
)
values = content.model_dump(exclude={"type", "source_id", "count", "profile"})
if content.orientation == "auto":
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
display = (
service.registry.get(device_id or service.settings.device_id)
.device()
.display
)
width, height = frame_canvas(display)
values["orientation"] = "landscape" if width > height else "portrait"
plan = LayoutStudio(service.renderer).plan(
LayoutInput(
asset_ids=ids,
**values,
)
)
return TemplateContent(
type="template",
template="poster" if content.style == "poster" else "collage",
data={"layout": plan.model_dump()},
profile=content.profile,
)
+14
View File
@@ -68,6 +68,19 @@ class PhotoSourceContent(StrictModel):
profile: Profile = Profile.color
class AutoCollageContent(StrictModel):
type: Literal["auto_collage"]
source_id: str | None = Field(default=None, pattern=r"^[a-z0-9-]{1,60}$")
count: int = Field(default=4, ge=1, le=12)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
orientation: Literal["auto", "portrait", "landscape"] = "auto"
style: Literal["grid", "hero", "poster"] = "grid"
use_ai: bool = True
prompt: str = Field(default="", max_length=1500)
profile: Profile = Profile.color
class ProviderContent(StrictModel):
type: Literal["provider"]
provider_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
@@ -86,6 +99,7 @@ Content = Annotated[
| URLContent
| TemplateContent
| PhotoSourceContent
| AutoCollageContent
| ProviderContent
| FutureContent,
Field(discriminator="type"),
+1 -1
View File
@@ -97,7 +97,7 @@ class Assets:
"task"
if job
else "template"
if content.get("type") == "template"
if content.get("type") in ("template", "auto_collage")
else "manual"
if operation["client_id"].startswith("user:")
else "external"
+1 -1
View File
@@ -3,7 +3,7 @@ const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
const icons={studio:'▦',connections:'↔',dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k;
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
+13
View File
@@ -3,6 +3,7 @@ const tr=(zh,en)=>state.lang==='zh'?zh:en;
async function layoutStudioPage(){
const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null;
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label>${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label>${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label>${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label>${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label>${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label>${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
await autoCollagePanel();
let rendered=null;
const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;};
$('#studio-assets').onchange=()=>{selected=new Set([...document.querySelectorAll('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();};
@@ -56,3 +57,15 @@ async function aiImagePanel(){
async function mqttPanel(){
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label>Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label>Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label>Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label>${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
}
async function autoCollagePanel(){
const sources=await api('/api/v1/photo-sources');const card=document.createElement('section');card.className='card auto-collage';
card.innerHTML=`<h2>${tr('自动拼图','Automatic collage')}</h2><p class="hint">${tr('选择照片来源和数量,系统每次随机选取不同照片,并自动排版生成一张图片。无需手动拼接。预览不会上传或切换画框。','Choose a photo source and count. Each run randomly selects distinct photos and automatically creates one composition. Preview never uploads to or changes the frame.')}</p><form id="auto-collage-form"><div class="form-grid"><div class="field"><label for="collage-source">${tr('照片来源','Photo source')}</label><select id="collage-source"><option value="">${tr('本地手动上传照片','Locally uploaded photos')}</option>${sources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="collage-count">${tr('拼图数量(1–12)','Photo count (1–12)')}</label><input id="collage-count" type="number" min="1" max="12" value="4" required></div></div><div class="field"><label for="collage-title">${tr('标题(可选)','Title (optional)')}</label><input id="collage-title" maxlength="100"></div><div class="field"><label for="collage-prompt">${tr('排版要求(可选)','Layout instructions (optional)')}</label><textarea id="collage-prompt" maxlength="1500"></textarea></div><label class="check-row"><input id="collage-ai" type="checkbox" checked>${tr('AI 自动排版','AI layout')}</label><p class="hint">${tr('AI 需先在连接中心配置,只接收尺寸与文字,不发送照片。未配置时可取消勾选,使用自动网格。','Configure AI in Connections first. Only dimensions and text are sent, never photos. Uncheck AI to use an automatic grid.')}</p><div class="actions"><button type="submit" class="primary">${tr('随机选图并生成预览','Pick photos and create preview')}</button><button id="collage-job" type="button">${tr('创建自动化任务','Create automation')}</button></div></form><div id="collage-preview"></div><div class="actions"><button id="collage-display" hidden>${t('display')}</button></div>`;
$('#main').prepend(card);if(sources.some(x=>x.enabled))$('#collage-source').value=sources.find(x=>x.enabled).id;let asset=null;
$('#auto-collage-form').oninput=()=>{asset=null;$('#collage-display').hidden=true;};
const content=()=>({type:'auto_collage',source_id:$('#collage-source').value||null,count:Number($('#collage-count').value),title:$('#collage-title').value,prompt:$('#collage-prompt').value,use_ai:$('#collage-ai').checked});
$('#auto-collage-form').onsubmit=busy(async e=>{e.preventDefault();asset=null;$('#collage-display').hidden=true;$('#collage-preview').textContent=t('previewLoading');try{const id=await accepted('/api/v1/render',content());const op=await waitOperation(id);asset=op.result;$('#collage-preview').innerHTML='<img class="preview" id="collage-image" alt="Collage">';await blobImage($('#collage-image'),asset.url);$('#collage-display').hidden=false;$('#collage-display').disabled=!liveWriteAllowed();}catch(error){$('#collage-preview').textContent=error.message;}});
bind('collage-display',()=>displayDialog({type:'image',asset_id:asset.asset_id}));
bind('collage-job',async()=>{if(!$('#auto-collage-form').reportValidity())return;await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:$('#collage-title').value||tr('自动随机拼图','Automatic random collage'),enabled:false,schedule:{type:'daily',time:'08:00'},source:content(),display_duration:0,restore_previous:false,priority:50}});await navigate('automation');toast(tr('任务已创建并暂停;编辑时间后启用。每次执行重新随机选图。','Created paused. Edit the schedule, then enable. Each run picks new photos.'));});
const manual=document.createElement('details');manual.className='card manual-collage';manual.innerHTML=`<summary>${tr('高级:手动选图与调整布局','Advanced: manual photos and layout')}</summary>`;const columns=$('#main>.columns');columns.before(manual);manual.append(columns);
}
+3 -2
View File
@@ -46,16 +46,17 @@ async function weatherSettings(){
async function enhanceHistory(){
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(current.action)}</p><p>${esc(current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
async function enhanceAutomation(){
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(job.source.type==='template'){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
}
async function jobSourceEditor(job){
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.parentElement.before(advanced);advanced.append(raw.parentElement);
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[],photoSources=[];try{[images,assets,photoSources]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets'),api('/api/v1/photo-sources')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','auto_collage','restore'].map(k=>`<option value="${k}">${k==='auto_collage'?(state.lang==='zh'?'自动随机拼图':'Automatic collage'):k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
$('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove();
function update(){raw.value=JSON.stringify(source,null,2);}
function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;}
if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`<div class="field"><label for="job-field-${k}">${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();}
else if(type==='auto_collage'){source={count:4,use_ai:true,...source,type};panel.innerHTML=`<div class="field"><label for="job-collage-source">${t('photoSources')}</label><select id="job-collage-source"><option value="">${state.lang==='zh'?'本地手动上传照片':'Locally uploaded photos'}</option>${photoSources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-collage-count">${state.lang==='zh'?'拼图数量(1–12)':'Photo count (1–12)'}</label><input id="job-collage-count" type="number" min="1" max="12" required value="${source.count}"></div><div class="field"><label for="job-collage-title">${state.lang==='zh'?'标题':'Title'}</label><input id="job-collage-title" maxlength="100" value="${esc(source.title||'')}"></div><div class="field"><label for="job-collage-prompt">${state.lang==='zh'?'排版要求':'Layout instructions'}</label><textarea id="job-collage-prompt" maxlength="1500">${esc(source.prompt||'')}</textarea></div><label class="check-row"><input id="job-collage-ai" type="checkbox" ${source.use_ai?'checked':''}>${state.lang==='zh'?'AI 自动排版(需先配置连接中心)':'AI layout (configure Connections first)'}</label><p class="hint">${state.lang==='zh'?'每次执行重新随机选图;取消 AI 使用自动网格。':'Each run picks new photos; uncheck AI for an automatic grid.'}</p>`;$('#job-collage-source').value=source.source_id||'';for(const id of ['source','count','title','prompt','ai'])$('#job-collage-'+id).oninput=()=>{source={...source,source_id:$('#job-collage-source').value||null,count:Number($('#job-collage-count').value),title:$('#job-collage-title').value,prompt:$('#job-collage-prompt').value,use_ai:$('#job-collage-ai').checked};update();};update();}
else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};}
else if(type==='photo_source'){panel.innerHTML=`<div class="field"><label for="job-photo-source">${t('photoSources')}</label><select id="job-photo-source" required><option value="">—</option>${photoSources.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select><p class="hint">${t('sourceJobHint')}</p></div>`;$('#job-photo-source').value=source.source_id||'';$('#job-photo-source').onchange=()=>{source={type:'photo_source',source_id:$('#job-photo-source').value,fit:'contain',profile:'color'};update();};}
else{panel.innerHTML=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}}
+2
View File
@@ -100,3 +100,5 @@ form>.field,form>.check-row,form>.hint,form>.notice{margin:0}
.photos-connection .badge.warning{color:var(--bad);background:transparent;border:1px solid var(--bad)}
form>.modal-actions{margin-top:8px}
@media(max-width:720px){button,input:not([type=checkbox]):not([type=radio]),select{min-height:44px}.image-card button,.row-actions button,.history-actions button{font-size:14px;padding:9px 12px}.check-row{min-height:44px;align-items:center}}
.auto-collage{margin-bottom:24px}.auto-collage h2{margin-bottom:12px}.auto-collage>p{margin-bottom:20px}.auto-collage #collage-preview:not(:empty){margin-top:20px}.auto-collage>.actions{margin-top:16px}.manual-collage>summary{cursor:pointer;font-weight:600}.manual-collage[open]>summary{margin-bottom:24px}
+14
View File
@@ -0,0 +1,14 @@
/* Isolated Mock-only automatic collage workflow. */
const assert=require('assert');const fs=require('fs');const{chromium}=require('playwright');
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);const headers={Authorization:'Bearer '+token};assert((await(await fetch(base+'/api/v1/settings',{headers})).json()).mock===true);
const form=new FormData();for(let i=0;i<4;i++)form.append('files',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')],{type:'image/png'}),'fixture-'+i+'.png');assert((await fetch(base+'/api/v1/layout/images',{method:'POST',headers,body:form})).status===201);
const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('#nav button').first().getAttribute('data-page')==='dashboard');
await page.evaluate(()=>navigate('studio'));assert(await page.locator('#studio-plan').isHidden());await page.locator('#collage-ai').uncheck();await page.locator('#collage-count').fill('3');await page.locator('#collage-title').fill('Collage fixture');await page.locator('#auto-collage-form button[type=submit]').click();await page.locator('#collage-image').waitFor({timeout:30000});assert(await page.locator('#collage-display').isVisible());
const uploads=await(await fetch(base+'/api/v1/devices/living-room/images',{headers})).json();assert(uploads.length===1,'Preview must not upload');
await page.locator('#collage-job').click();await page.locator('[data-job-edit]').last().click();await page.locator('#job-collage-count').waitFor();assert(await page.locator('#job-collage-count').inputValue()==='3');assert(await page.locator('#job-collage-ai').isChecked()===false);await page.locator('#modal-cancel').click();
const jobs=await(await fetch(base+'/api/v1/jobs',{headers})).json();const job=jobs.find(j=>j.name==='Collage fixture');assert(job&&job.source.type==='auto_collage'&&job.source.count===3&&!job.enabled);
assert.deepStrictEqual(errors,[]);console.log('Automatic collage: 8 workflow checks passed; no frame writes or console errors');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+2 -2
View File
@@ -12,13 +12,13 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
for(const view of ['studio','connections']){
await page.evaluate(view=>navigate(view),view);await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
await page.evaluate(view=>navigate(view),view);if(view==='studio')await page.locator('.manual-collage summary').click();await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth),view+' overflow '+width);checks++;
const gaps=await page.locator('.actions').evaluateAll(rows=>rows.flatMap(row=>{const nodes=[...row.querySelectorAll('button')];return nodes.slice(1).map((node,i)=>{const a=nodes[i].getBoundingClientRect(),b=node.getBoundingClientRect();return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});}));
assert(gaps.every(g=>g>=8),view+' buttons touch '+width);checks++;
}
}
await page.evaluate(()=>navigate('studio'));await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
await page.locator('#studio-assets input:checked').waitFor();await page.locator('#studio-plan').click();await page.locator('#studio-layout img').waitFor();await page.locator('#studio-render').click();await page.locator('#studio-result').waitFor({timeout:30000});assert(await page.locator('#studio-download').isEnabled());checks++;
await page.locator('#studio-title').fill('Changed');assert(await page.locator('#studio-display').isDisabled());checks++;
assert.deepStrictEqual(errors,[]);checks++;
+1 -1
View File
@@ -20,7 +20,7 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);}
await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await inspect(`Manual collage/${width}/${lang}/${theme}`);await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('playlists'));await page.locator('#new-playlist').click();await inspect(`Playlist/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
+85
View File
@@ -373,6 +373,9 @@
{
"$ref": "#/components/schemas/PhotoSourceContent"
},
{
"$ref": "#/components/schemas/AutoCollageContent"
},
{
"$ref": "#/components/schemas/ProviderContent"
},
@@ -384,6 +387,7 @@
"discriminator": {
"propertyName": "type",
"mapping": {
"auto_collage": "#/components/schemas/AutoCollageContent",
"html": "#/components/schemas/FutureContent",
"image": "#/components/schemas/ImageContent",
"image_url": "#/components/schemas/URLContent",
@@ -1337,5 +1341,86 @@
"name"
],
"title": "PhotosPerson"
},
"AutoCollageContent": {
"properties": {
"type": {
"type": "string",
"const": "auto_collage",
"title": "Type"
},
"source_id": {
"anyOf": [
{
"type": "string",
"pattern": "^[a-z0-9-]{1,60}$"
},
{
"type": "null"
}
],
"title": "Source Id"
},
"count": {
"type": "integer",
"maximum": 12.0,
"minimum": 1.0,
"title": "Count",
"default": 4
},
"title": {
"type": "string",
"maxLength": 100,
"title": "Title",
"default": ""
},
"message": {
"type": "string",
"maxLength": 500,
"title": "Message",
"default": ""
},
"orientation": {
"type": "string",
"enum": [
"auto",
"portrait",
"landscape"
],
"title": "Orientation",
"default": "auto"
},
"style": {
"type": "string",
"enum": [
"grid",
"hero",
"poster"
],
"title": "Style",
"default": "grid"
},
"use_ai": {
"type": "boolean",
"title": "Use Ai",
"default": true
},
"prompt": {
"type": "string",
"maxLength": 1500,
"title": "Prompt",
"default": ""
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type"
],
"title": "AutoCollageContent"
}
}
+160
View File
@@ -0,0 +1,160 @@
from io import BytesIO
import pytest
from PIL import Image
from pydantic import ValidationError
from pigway_frame_flow.models import AutoCollageContent
def photos(service, count=4):
for i in range(count):
data = BytesIO()
Image.new("RGB", (320 + i, 200), (i * 50, 60, 100)).save(data, "JPEG")
service.assets.put(data.getvalue(), f"photo-{i}.jpg")
def test_auto_preview_selects_exact_distinct_photos_without_frame_write(
client, service
):
photos(service)
result = client.post(
"/api/v1/render", json={"type": "auto_collage", "count": 3, "use_ai": False}
)
assert result.status_code == 202
service.process_one()
operation = service.operation(result.json()["operation_id"])
assert operation["status"] == "COMPLETED"
asset = operation["result"]["asset_id"]
import json
source = json.loads(
service.db.one("SELECT payload FROM asset_sources WHERE asset_id=?", (asset,))[
"payload"
]
)
regions = source["data"]["layout"]["regions"]
assert len({x["asset_id"] for x in regions}) == 3
assert service.registry.get("living-room").client.uploads == 0
def test_insufficient_photos_and_unconfigured_ai_fail_before_upload(service):
from pigway_frame_flow.errors import FlowError
photos(service, 1)
for content in [
AutoCollageContent(type="auto_collage", count=2, use_ai=False),
AutoCollageContent(type="auto_collage", count=1),
]:
with pytest.raises(FlowError):
service.content_asset(content)
assert service.registry.get("living-room").client.uploads == 0
@pytest.mark.parametrize("count", [0, 13])
def test_count_bounds(count):
with pytest.raises(ValidationError):
AutoCollageContent(type="auto_collage", count=count)
def test_auto_collage_job_contract_and_preview(client, service):
photos(service, 2)
job = client.post(
"/api/v1/jobs",
json={
"name": "Collage",
"device_id": "living-room",
"enabled": False,
"schedule": {"type": "daily", "time": "08:00"},
"source": {"type": "auto_collage", "count": 2, "use_ai": False},
},
)
assert job.status_code == 201
result = client.post("/api/v1/jobs/" + job.json()["id"] + "/preview")
assert result.status_code == 202
service.process_one()
assert service.operation(result.json()["operation_id"])["status"] == "COMPLETED"
assert service.registry.get("living-room").client.uploads == 0
def test_source_selection_respects_quality_and_keeps_inputs_out_of_manual_library(
service, monkeypatch
):
from types import SimpleNamespace
source = SimpleNamespace(
minimum_sharpness=0,
minimum_brightness=50,
maximum_brightness=255,
avoid_duplicates=False,
)
monkeypatch.setattr(service.photo_sources, "get", lambda _: source)
monkeypatch.setattr(
service.photo_sources, "catalog", lambda _: [{"id": str(i)} for i in range(3)]
)
def read(_, item):
data = BytesIO()
Image.new("RGB", (320, 240), (int(item) * 80,) * 3).save(data, "JPEG")
return data.getvalue(), item + ".jpg", None
monkeypatch.setattr(service.photo_sources, "read", read)
content = AutoCollageContent(
type="auto_collage", source_id="nas", count=2, use_ai=False
)
for _ in range(2):
asset = service.content_asset(content)
assert asset["source"] == "generated"
assert (
service.db.one("SELECT COUNT(*) AS n FROM assets WHERE kind='images'")["n"] == 0
)
assert (
service.db.one("SELECT COUNT(*) AS n FROM assets WHERE kind='cache'")["n"] == 4
)
def test_ai_receives_only_selected_asset_metadata(service, monkeypatch):
from pigway_frame_flow.extensions.layout import LayoutStudio, local_plan
photos(service, 3)
calls = []
def plan(_, request):
calls.append(request)
assert request.use_ai is True
assert len(set(request.asset_ids)) == 3
return local_plan(request)
monkeypatch.setattr(LayoutStudio, "plan", plan)
service.content_asset(
AutoCollageContent(type="auto_collage", count=3, title="Family")
)
assert len(calls) == 1
assert calls[0].title == "Family"
assert service.registry.get("living-room").client.uploads == 0
def test_scheduled_collage_executes_once_and_tracks_cloud_task_head(app, service):
from datetime import UTC, datetime, timedelta
from pigway_frame_flow.models import JobInput
photos(service, 3)
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Scheduled collage",
schedule={"type": "daily", "time": "08:00"},
source={"type": "auto_collage", "count": 3, "use_ai": False},
)
)
due = (datetime.now(UTC) - timedelta(seconds=2)).isoformat()
service.db.execute("UPDATE jobs SET next_run=? WHERE id=?", (due, job["id"]))
scheduler.tick()
scheduler.tick()
assert len(service.db.all("SELECT * FROM job_runs")) == 1
service.process_one()
service.sync()
assert service.registry.get("living-room").client.uploads == 1
row = service.db.one("SELECT job_id FROM managed_cloud_uploads")
assert row["job_id"] == job["id"]