diff --git a/docs/AUTOMATIC_COLLAGE.md b/docs/AUTOMATIC_COLLAGE.md
new file mode 100644
index 0000000..41d2fd0
--- /dev/null
+++ b/docs/AUTOMATIC_COLLAGE.md
@@ -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.
diff --git a/docs/UI_LAYOUT_AUDIT.md b/docs/UI_LAYOUT_AUDIT.md
index b33921c..63201de 100644
--- a/docs/UI_LAYOUT_AUDIT.md
+++ b/docs/UI_LAYOUT_AUDIT.md
@@ -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.
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index 9c16ff9..a5e91b0 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -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
diff --git a/pigway_frame_flow/display/cloud_library.py b/pigway_frame_flow/display/cloud_library.py
index 588de82..ce502a0 100644
--- a/pigway_frame_flow/display/cloud_library.py
+++ b/pigway_frame_flow/display/cloud_library.py
@@ -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
)
diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py
index 51608f2..b0be75a 100644
--- a/pigway_frame_flow/display/service.py
+++ b/pigway_frame_flow/display/service.py
@@ -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(
diff --git a/pigway_frame_flow/extensions/auto_collage.py b/pigway_frame_flow/extensions/auto_collage.py
new file mode 100644
index 0000000..183d113
--- /dev/null
+++ b/pigway_frame_flow/extensions/auto_collage.py
@@ -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,
+ )
diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py
index 1816459..c3a5308 100644
--- a/pigway_frame_flow/models.py
+++ b/pigway_frame_flow/models.py
@@ -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"),
diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py
index b76cea2..80b038b 100644
--- a/pigway_frame_flow/renderers/images.py
+++ b/pigway_frame_flow/renderers/images.py
@@ -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"
diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js
index dec928a..4b94222 100644
--- a/pigway_frame_flow/web/app.js
+++ b/pigway_frame_flow/web/app.js
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
diff --git a/pigway_frame_flow/web/extensions.js b/pigway_frame_flow/web/extensions.js
index 023ac27..8f2f099 100644
--- a/pigway_frame_flow/web/extensions.js
+++ b/pigway_frame_flow/web/extensions.js
@@ -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=`
${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.')}
${tr('选择照片','Choose photos')}
${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}
${assets.map(a=>``).join('')}
${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}
${t('preview')}
${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}
`;
+ 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=`
${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.')}
${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.')}
`;
+ $('#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='';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=`${tr('高级:手动选图与调整布局','Advanced: manual photos and layout')}`;const columns=$('#main>.columns');columns.before(manual);manual.append(columns);
+}
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index 2f31230..1966843 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -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(`
${state.lang==='zh'?'每次执行重新随机选图;取消 AI 使用自动网格。':'Each run picks new photos; uncheck AI for an automatic grid.'}
`;$('#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=``;$('#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=`