Make collage selection automatic and restore dashboard navigation order
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}):'—';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();};}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)});
|
||||
@@ -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++;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user