fix: resolve gallery labels from current plans and preserve render snapshots
This commit is contained in:
+7
-7
@@ -31,7 +31,7 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI
|
||||
10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md
|
||||
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||
604e86417063600682392c03e0993eae9c48519c25c1f94a208b4b373f8b2965 docs/SOURCE_LIBRARIES.md
|
||||
5ba13866ad96cb931c9dcb5fcc141c60fc44103a969fe912636a44afefb6854f docs/SOURCE_LIBRARIES.md
|
||||
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
||||
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
|
||||
@@ -45,9 +45,9 @@ ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
||||
37f907c13f5747a37252d9e3e8a882b106fbd9158e8727d7dc05cb827f63173c pigway_frame_flow/api/app.py
|
||||
ea7b720fa34ebe4039398eb34ac2d9457aafa46d14e273e6a01b257b2acdbb0e pigway_frame_flow/api/app.py
|
||||
1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
|
||||
cb8b2bfe0f9044b82766e0817ad6e7ac67b7b480dbd4edbd4906f36a4bb60c60 pigway_frame_flow/api/libraries.py
|
||||
a6f7336da1e4b90ec41a6f7cb2467e4b1bfa78551bf71b14b64e82d8ea08498f pigway_frame_flow/api/libraries.py
|
||||
3dda509a34daca61836913976dba35e59c2135c48bf04bc7617e923cc5cffdc1 pigway_frame_flow/api/thumbnails.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
|
||||
@@ -56,7 +56,7 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f
|
||||
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
||||
881698e97f4427ff2db025f08ee82d9d106ce36744ce8f17cae94f2e8a703e18 pigway_frame_flow/display/cloud_library.py
|
||||
81d969357cd267e4047edc344db24afb88f67c1ffa176010cef384eee8d25457 pigway_frame_flow/display/libraries.py
|
||||
94ac69244669b7ccc816ebac3e62f0bf44a0a076e1d7b8bd7bab332dad779c79 pigway_frame_flow/display/libraries.py
|
||||
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||
52f1e4a1adb3dca8d47647e14e55479fe18664bfac63f220da76ff90e0a358b1 pigway_frame_flow/display/service.py
|
||||
@@ -138,7 +138,7 @@ d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_f
|
||||
9eec4fe71cf1750a55bf9d8ebc9a24434c3951e8659dd40267b5184b20dcb37e pigway_frame_flow/web/features.js
|
||||
a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js
|
||||
6385ec8b0383fa9bdbb23c4c685c8ef1afd7926fd74c8032811e647808548255 pigway_frame_flow/web/index.html
|
||||
943e71c859a8711e8e718fe79bda1849cd43280ff1c898587f8412ffe9792bbb pigway_frame_flow/web/libraries.js
|
||||
3d3078b5eb74f3c48369deb1cc654e064dea97689cddff65fd2a44f362164e00 pigway_frame_flow/web/libraries.js
|
||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
|
||||
9dcf0419d0292efce8ead7309dc70b4afd27dd24fe82cf9e673682342650f4da pigway_frame_flow/web/source-browser.js
|
||||
d1e42232c50e3ed23912504997d776e70127f089daba944f514f68c90364c332 pigway_frame_flow/web/style.css
|
||||
@@ -173,7 +173,7 @@ b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/produc
|
||||
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
|
||||
70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs
|
||||
39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs
|
||||
21c432c0b9337844e555e4e54235a6b128d872374072c6db5e57c99a61ab9ab8 scripts/ui-libraries.cjs
|
||||
7c9fc679880d39ef3af262dd43d58835740fdeee45112b848303ee88c15448de scripts/ui-libraries.cjs
|
||||
b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs
|
||||
8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs
|
||||
98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs
|
||||
@@ -232,7 +232,7 @@ ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_sch
|
||||
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
|
||||
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
|
||||
a821fbe5096b2fa5b781423e66673b8d5659b60fc37c5c10470afa2a818659c8 tests/test_source_browser.py
|
||||
e1e9b81ba3fd3b08c7d07fcf09dc2393493434dbd766a747d345ec3c16d56b5b tests/test_source_libraries.py
|
||||
d019a765860f82ee0249d34174c73dc56ca0073765d98e2891938178e80fbc3f tests/test_source_libraries.py
|
||||
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
||||
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||
a83390e9f580bc27a2928eb6f94d7377307ec46ab45ef71bfea1e5378cb7adb9 tests/test_thumbnail_plans.py
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
任务先选定一次源画面并保存到本地,再按来源的终端绑定生成版本。默认等比裁切铺满,不拉伸、不补黑边。主动选择完整画面时必要处补白;说明叠加使用来源提供的日期、标题、地点等元数据。源画面本身始终不加文字。Synology Photos 当前取得的是可用源预览,不称为原图;本地存储按画面原尺寸归一化为 JPEG 并处理 EXIF 朝向。
|
||||
|
||||
每份成品关联源画面、方案 ID 和生成时的完整参数快照。相同源画面和相同参数复用已有版本;修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。
|
||||
每份成品关联源画面、方案 ID 和生成时的完整参数快照。来源标签、终端绑定、成品卡片及下载名称均通过方案 ID 读取当前名称;生成参数窗口单独标示生成时的名称和原始文件名。仅改名不影响像素,不重复生成,也不改写历史快照和操作回执。方案渲染参数变更后,旧成品显示旧参数提示;重新生成才产生新版本。相同源画面和相同渲染参数复用已有版本(名称及来源名称不计入渲染版本);修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。
|
||||
|
||||
相框通过现有上传队列和设备确认流程传输;“已生成”“待推送”“等待设备确认”“已确认显示”分别记录。来源任务完成代表生成和提交完成,不代表所有终端已显示;具体结果看终端图库的独立操作。终端当前画面已同步确认且关联到该成品时,推送按钮显示“已在终端显示”并禁用;仅有历史推送成功记录不足以认定当前仍在显示,离线或同步状态未知时也不作该判断。手表当前只生成 410×502 成品,传输尚未接入。某个方案生成失败不撤销其他输出,源画面保留,可以单独重新生成或重新推送失败目标。
|
||||
|
||||
|
||||
@@ -812,6 +812,10 @@ def create_app(settings=None, start_worker=True):
|
||||
with service.lock:
|
||||
service.libraries.check_source_delete(source_id)
|
||||
service.photo_sources.delete(source_id)
|
||||
db.execute(
|
||||
"DELETE FROM library_plans WHERE source_id=? AND archived=1",
|
||||
(source_id,),
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get(
|
||||
|
||||
@@ -284,12 +284,14 @@ def install_libraries(app, service):
|
||||
# Offline devices must not hide locally generated versions.
|
||||
pass
|
||||
items = []
|
||||
versions_by_picture = {}
|
||||
for row in rows:
|
||||
version = next(
|
||||
v
|
||||
for v in libraries.versions(row["source_image_id"])
|
||||
if v["asset_id"] == row["asset_id"]
|
||||
)
|
||||
picture_id = row["source_image_id"]
|
||||
if picture_id not in versions_by_picture:
|
||||
versions_by_picture[picture_id] = {
|
||||
v["asset_id"]: v for v in libraries.versions(picture_id)
|
||||
}
|
||||
version = versions_by_picture[picture_id][row["asset_id"]]
|
||||
delivery = db.one(
|
||||
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? ORDER BY created_at DESC,operation_id DESC LIMIT 1",
|
||||
(terminal_id, row["asset_id"]),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image
|
||||
@@ -234,21 +235,46 @@ class Libraries:
|
||||
"versions": self.versions(picture_id),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def render_parameters(plan):
|
||||
return plan.model_dump(exclude={"name", "source_id"})
|
||||
|
||||
def versions(self, picture_id):
|
||||
return [
|
||||
{
|
||||
**self.service.assets.info(r["asset_id"]),
|
||||
"source_image_id": r["source_image_id"],
|
||||
"plan_id": r["plan_id"],
|
||||
"revision": r["revision"],
|
||||
"plan": json.loads(r["plan"]),
|
||||
"terminal_uses": self.terminal_uses(r["plan_id"], r["asset_id"]),
|
||||
}
|
||||
for r in self.db.all(
|
||||
"SELECT * FROM library_versions WHERE source_image_id=? ORDER BY created_at DESC,asset_id",
|
||||
result = []
|
||||
rows = self.db.all(
|
||||
"SELECT v.*,p.payload AS current_plan,p.archived,a.name AS source_name "
|
||||
"FROM library_versions v JOIN library_plans p ON p.id=v.plan_id "
|
||||
"JOIN source_images s ON s.id=v.source_image_id JOIN assets a ON a.id=s.id "
|
||||
"WHERE v.source_image_id=? ORDER BY v.created_at DESC,v.asset_id DESC",
|
||||
(picture_id,),
|
||||
)
|
||||
]
|
||||
for row in rows:
|
||||
current = GenerationPlan.model_validate_json(row["current_plan"])
|
||||
snapshot = GenerationPlan.model_validate_json(row["plan"])
|
||||
asset = self.service.assets.info(row["asset_id"])
|
||||
display_name = (
|
||||
Path(Path(row["source_name"]).stem + "-" + current.name).name[:110]
|
||||
+ ".jpg"
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
**asset,
|
||||
"name": display_name,
|
||||
"stored_name": asset["name"],
|
||||
"source_image_id": row["source_image_id"],
|
||||
"plan_id": row["plan_id"],
|
||||
"plan_name": current.name,
|
||||
"plan_archived": bool(row["archived"]),
|
||||
"matches_current_plan": self.render_parameters(current)
|
||||
== self.render_parameters(snapshot),
|
||||
"revision": row["revision"],
|
||||
"plan": json.loads(row["plan"]),
|
||||
"terminal_uses": self.terminal_uses(
|
||||
row["plan_id"], row["asset_id"]
|
||||
),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def terminal_uses(self, plan_id, asset_id):
|
||||
return self.db.all(
|
||||
@@ -295,17 +321,26 @@ class Libraries:
|
||||
"Picture and generation plan must share a source library",
|
||||
409,
|
||||
)
|
||||
revision = hashlib.sha256(encode(plan.model_dump()).encode()).hexdigest()
|
||||
parameters = self.render_parameters(plan)
|
||||
revision = hashlib.sha256(encode(parameters).encode()).hexdigest()
|
||||
row = self.db.one(
|
||||
"SELECT asset_id FROM library_versions WHERE source_image_id=? AND plan_id=? AND revision=?",
|
||||
(picture_id, plan_id, revision),
|
||||
)
|
||||
existing = self.versions(picture_id)
|
||||
if row:
|
||||
return next(
|
||||
v
|
||||
for v in self.versions(picture_id)
|
||||
if v["asset_id"] == row["asset_id"]
|
||||
return next(v for v in existing if v["asset_id"] == row["asset_id"])
|
||||
# Older revisions hashed the display name too. Reuse matching pixels'
|
||||
# settings without rewriting historical snapshots or receipt references.
|
||||
for version in existing:
|
||||
if (
|
||||
version["plan_id"] == plan_id
|
||||
and self.render_parameters(
|
||||
GenerationPlan.model_validate(version["plan"])
|
||||
)
|
||||
== parameters
|
||||
):
|
||||
return version
|
||||
data = self.service.assets.path(picture_id).read_bytes()
|
||||
asset = self.service.assets.put(
|
||||
self.render(data, picture["caption"], plan),
|
||||
|
||||
@@ -6,7 +6,7 @@ Object.assign(MESSAGES.en,{gallery:'Source library','terminal-gallery':'Terminal
|
||||
const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0};
|
||||
|
||||
function libraryStatus(status){return ({GENERATED:tr('已生成','Generated'),QUEUED:tr('待推送','Queued'),RENDERING:tr('准备推送','Preparing'),UPLOADING:tr('上传中','Uploading'),SYNCING:tr('等待设备确认','Awaiting confirmation'),COMPLETED:tr('已确认显示','Display confirmed'),FAILED:tr('推送失败','Failed'),UNKNOWN:tr('结果待核对','Outcome unknown')})[status]||status;}
|
||||
function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceNameForId(terminal.id);}
|
||||
function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceDisplayName(terminal.name?terminal:state.devices.find(d=>d.id===terminal.id)||terminal);}
|
||||
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.name)}</option>`).join('');}
|
||||
views.gallery=epoch=>libraryPage(epoch,'source');
|
||||
views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal');
|
||||
@@ -15,6 +15,7 @@ async function libraryPage(epoch,kind){
|
||||
libraryUI.kind=kind;
|
||||
const [sources,terminals]=await Promise.all([api('/api/v1/source-libraries'),api('/api/v1/library-terminals')]);
|
||||
if(epoch!==state.epoch)return;
|
||||
state.devices=state.devices.map(device=>{const terminal=terminals.find(t=>t.id===device.id&&t.name&&t.name!==t.id);return terminal?{...device,name:terminal.name}:device;});shell();
|
||||
$('#main').innerHTML=`<div class="section-heading"><span></span><button id="library-refresh">${t('refresh')}</button></div><div id="library-content"></div>`;
|
||||
bind('library-refresh',()=>navigate());
|
||||
if(kind==='source')await sourceLibraryPage(epoch,sources,terminals);else await terminalLibraryPage(epoch,sources,terminals);
|
||||
@@ -31,7 +32,7 @@ async function sourceLibraryPage(epoch,sources,terminals){
|
||||
const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id));
|
||||
panel.innerHTML=`<section class="card library-source-controls"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button><button id="library-source-settings">${tr('来源设置','Source settings')}</button></div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label="${tr('源画面与生成方案','Source pixels and generation plans')}"><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected="${!plan}" tabindex="${!plan?0:-1}" data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button id="library-plan-tab-${esc(p.id)}" role="tab" aria-controls="library-plan-content" aria-selected="${p.id===plan?.id}" tabindex="${p.id===plan?.id?0:-1}" data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab-${plan?esc(plan.id):'source'}"><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
|
||||
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
|
||||
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan.name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
|
||||
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${plan&&version?.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
|
||||
}).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div></div></section>`;
|
||||
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
|
||||
document.querySelectorAll('[data-library-plan]').forEach(b=>{
|
||||
@@ -73,7 +74,7 @@ async function terminalLibraryPage(epoch,sources,terminals){
|
||||
const [page,plans]=await Promise.all([api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images?limit=30&offset='+libraryUI.offset),terminal.binding?api('/api/v1/generation-plans?source_id='+encodeURIComponent(terminal.binding.source_id)):Promise.resolve([])]);
|
||||
if(epoch!==state.epoch)return;
|
||||
const source=sources.find(s=>s.id===terminal.binding?.source_id),plan=plans.find(p=>p.id===terminal.binding?.plan_id);
|
||||
$('#library-content').innerHTML=`<section class="card"><div class="tabs gallery-tabs terminal-choice" role="group" aria-label="${tr('终端','Terminal')}">${terminals.map(t=>`<button type="button" data-library-terminal="${esc(t.id)}" class="${t.id===terminal.id?'active':''}" aria-pressed="${t.id===terminal.id}">${esc(terminalLabel(t))}</button>`).join('')}</div><h2>${esc(terminalLabel(terminal))}</h2><p>${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}</p><p class="hint">${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}</p><div class="actions"><button id="library-binding" class="primary">${tr('设置来源与方案(各单选)','Bind one source and one plan')}</button>${terminal.binding?`<button id="library-unbind">${tr('解除绑定','Unbind')}</button><button id="library-to-source">${tr('查看来源与生成版本','View source and versions')}</button>`:''}</div></section><div class="gallery terminal-library-gallery">${page.items.map((v,i)=>`<article class="card image-card"><button class="image-preview" data-terminal-preview="${i}"><img class="thumb" alt="${esc(v.name)}" data-src="${esc(v.url)}" style="object-fit:contain;aspect-ratio:${v.plan.width}/${v.plan.height}"></button><h3>${esc(v.name)}</h3><small>${v.plan.width} × ${v.plan.height} · ${esc(v.plan.name)}</small><small>${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))}</small><div class="actions"><button data-terminal-preview="${i}">${tr('预览','Preview')}</button><button data-terminal-download="${i}">${tr('下载图片','Download image')}</button><button data-terminal-settings="${i}">${tr('生成参数','Generation settings')}</button>${v.operation?`<button data-terminal-operation="${i}">${tr('推送记录','Delivery history')}</button>`:''}<button data-terminal-send="${i}" ${v.current||!terminal.transport_supported||!liveWriteAllowed()||['QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN'].includes(v.delivery_status)?'disabled':''} title="${terminal.transport_supported?(liveWriteAllowed()?'':esc(t('readOnlyWarning'))):tr('手表传输尚未接入','Watch transfer is not connected')}">${v.current?tr('已在终端显示','Already displayed on terminal'):tr('推送到终端','Send to terminal')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}</section>`}<div class="actions library-pagination"><button id="terminal-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="terminal-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}</span></div>`;
|
||||
$('#library-content').innerHTML=`<section class="card"><div class="tabs gallery-tabs terminal-choice" role="group" aria-label="${tr('终端','Terminal')}">${terminals.map(t=>`<button type="button" data-library-terminal="${esc(t.id)}" class="${t.id===terminal.id?'active':''}" aria-pressed="${t.id===terminal.id}">${esc(terminalLabel(t))}</button>`).join('')}</div><h2>${esc(terminalLabel(terminal))}</h2><p>${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}</p><p class="hint">${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}</p><div class="actions"><button id="library-binding" class="primary">${tr('设置来源与方案(各单选)','Bind one source and one plan')}</button>${terminal.binding?`<button id="library-unbind">${tr('解除绑定','Unbind')}</button><button id="library-to-source">${tr('查看来源与生成版本','View source and versions')}</button>`:''}</div></section><div class="gallery terminal-library-gallery">${page.items.map((v,i)=>`<article class="card image-card"><button class="image-preview" data-terminal-preview="${i}"><img class="thumb" alt="${esc(v.name)}" data-src="${esc(v.url)}" style="object-fit:contain;aspect-ratio:${v.plan.width}/${v.plan.height}"></button><h3>${esc(v.name)}</h3><small>${v.plan.width} × ${v.plan.height} · ${esc(v.plan_name)}</small><small>${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))}</small>${v.matches_current_plan===false?`<small class="hint">${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')}</small>`:''}<div class="actions"><button data-terminal-preview="${i}">${tr('预览','Preview')}</button><button data-terminal-download="${i}">${tr('下载图片','Download image')}</button><button data-terminal-settings="${i}">${tr('生成参数','Generation settings')}</button>${v.operation?`<button data-terminal-operation="${i}">${tr('推送记录','Delivery history')}</button>`:''}<button data-terminal-send="${i}" ${v.current||!terminal.transport_supported||!liveWriteAllowed()||['QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN'].includes(v.delivery_status)?'disabled':''} title="${terminal.transport_supported?(liveWriteAllowed()?'':esc(t('readOnlyWarning'))):tr('手表传输尚未接入','Watch transfer is not connected')}">${v.current?tr('已在终端显示','Already displayed on terminal'):tr('推送到终端','Send to terminal')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}</section>`}<div class="actions library-pagination"><button id="terminal-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="terminal-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}</span></div>`;
|
||||
document.querySelectorAll('[data-library-terminal]').forEach(b=>b.onclick=()=>{if(b.dataset.libraryTerminal===libraryUI.terminal)return;libraryUI.terminal=b.dataset.libraryTerminal;libraryUI.offset=0;state.tab=libraryUI.terminal==='watch'?'watch':'cloud';if(libraryUI.terminal!=='watch')state.device=libraryUI.terminal;return navigate();});
|
||||
bind('library-binding',()=>terminalBindingEditor(terminal,sources));bind('library-unbind',()=>confirmDialog(tr(`确认解除“${terminalLabel(terminal)}”与来源图库及生成方案的绑定?已有图片保留,后续来源任务不再向此终端输出。`,`Unbind “${terminalLabel(terminal)}” from its source library and generation plan? Images are kept; future source tasks will not output to it.`),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'DELETE'});await navigate();}));
|
||||
bind('library-to-source',()=>{libraryUI.source=terminal.binding.source_id;libraryUI.plan=terminal.binding.plan_id;libraryUI.offset=0;state.tab='local';return navigate('gallery');});
|
||||
@@ -106,5 +107,5 @@ if(state.settings&&state.page==='gallery')void navigate('gallery');else shell();
|
||||
|
||||
function generationVersionDetails(version){
|
||||
const p=version.plan;
|
||||
showModal(`<h2>${esc(p.name)}</h2><div class="detail-grid">${detail(tr('输出尺寸','Output size'),p.width+' × '+p.height)}${detail(tr('JPEG 质量','JPEG quality'),p.quality)}${detail(tr('适配方式','Fit'),p.fit==='cover'?tr('等比铺满','Proportional fill'):tr('完整画面','Full picture'))}${detail(tr('裁切位置','Crop position'),p.center_x+' / '+p.center_y)}${detail(tr('叠加说明','Caption'),p.caption?tr('开启','Enabled'):tr('关闭','Disabled'))}${detail(tr('生成时间','Generated at'),fmt(version.created_at))}</div><p class="hint">${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);
|
||||
showModal(`<h2>${esc(version.plan_name)}</h2><div class="detail-grid">${detail(tr('生成时方案名称','Plan name at generation'),p.name)}${detail(tr('原始生成文件名','Stored filename'),version.stored_name)}${detail(tr('输出尺寸','Output size'),p.width+' × '+p.height)}${detail(tr('JPEG 质量','JPEG quality'),p.quality)}${detail(tr('适配方式','Fit'),p.fit==='cover'?tr('等比铺满','Proportional fill'):tr('完整画面','Full picture'))}${detail(tr('裁切位置','Crop position'),p.center_x+' / '+p.center_y)}${detail(tr('叠加说明','Caption'),p.caption?tr('开启','Enabled'):tr('关闭','Disabled'))}${detail(tr('生成时间','Generated at'),fmt(version.created_at))}</div><p class="hint">${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,20 @@ const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright')
|
||||
await page.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));
|
||||
const sources=await(await fetch(base+'/api/v1/source-libraries',{headers:{Authorization:'Bearer '+token}})).json();assert(sources.length);
|
||||
const sourceId=sources[0].id,planId='a'.repeat(32);let bound=true,deletions=0,current=true;
|
||||
await page.route('**/api/v1/library-terminals',r=>r.fulfill({json:[{id:'living-room',kind:'frame',transport_supported:true,binding:bound?{source_id:sourceId,plan_id:planId}:null},{id:'watch',kind:'watch',transport_supported:false}]}));
|
||||
await page.route('**/api/v1/generation-plans?*',r=>r.fulfill({json:[{id:planId,name:'Fixture frame',source_id:sourceId,width:1600,height:1200}]}));
|
||||
await page.route('**/api/v1/library-terminals/*/images?*',r=>r.fulfill({json:{total:1,items:[{asset_id:'fixture-version',name:'Fixture.jpg',url:'/ui/logo-symbol.svg',created_at:'2026-10-23',plan:{width:1600,height:1200,name:'Fixture plan'},delivery_status:'COMPLETED',current:current&&!r.request().url().includes('/watch/')}]}}));
|
||||
let plan={id:planId,name:'Fixture frame',source_id:sourceId,width:1600,height:1200,quality:85,fit:'cover',center_x:0.5,center_y:0.5,caption:true,profile:'color'};
|
||||
const version=()=>({asset_id:'fixture-version',source_image_id:'fixture-source',plan_id:planId,name:'Fixture-'+plan.name+'.jpg',stored_name:'Fixture-original.jpg',plan_name:plan.name,matches_current_plan:true,url:'/ui/logo-symbol.svg',created_at:'2026-10-23',plan:{...plan,name:'Original snapshot'},terminal_uses:[],delivery_status:'COMPLETED'});
|
||||
await page.route('**/api/v1/library-terminals',r=>r.fulfill({json:[{id:'living-room',name:'Authoritative frame',kind:'frame',transport_supported:true,binding:bound?{source_id:sourceId,plan_id:planId}:null},{id:'watch',kind:'watch',transport_supported:false}]}));
|
||||
await page.route('**/api/v1/generation-plans?*',r=>r.fulfill({json:[plan]}));
|
||||
await page.route('**/api/v1/generation-plans/'+planId,r=>{assert.equal(r.request().method(),'PUT');plan={...plan,...r.request().postDataJSON()};return r.fulfill({json:plan});});
|
||||
await page.route('**/api/v1/source-libraries/'+sourceId+'/images?*',r=>r.fulfill({json:{total:1,items:[{id:'fixture-source',name:'Fixture.jpg',url:'/ui/logo-symbol.svg',created_at:'2026-10-23',versions:[version()]}]}}));
|
||||
await page.route('**/api/v1/library-terminals/*/images?*',r=>r.fulfill({json:{total:1,items:[{...version(),current:current&&!r.request().url().includes('/watch/')}]}}));
|
||||
await page.route('**/api/v1/library-terminals/living-room/binding',r=>{assert.equal(r.request().method(),'DELETE');deletions++;bound=false;return r.fulfill({json:{}});});
|
||||
await page.route('**/api/v1/source-libraries/*/browse?*',r=>r.fulfill({json:{items:[],next_offset:null,scanned:0,paged_provider:true}}));
|
||||
await page.goto(base+'/#gallery');await page.locator('#library-new-plan').waitFor();
|
||||
assert.equal(await page.locator('#library-legacy,#library-old-terminal,[data-page="legacy-gallery"],[data-page="legacy-plans"]').count(),0);
|
||||
await page.locator('#library-new-plan').click();await page.locator('#generation-form').waitFor();assert.equal(await page.locator('#generation-legacy').count(),0);await page.locator('[data-generation-preset="watch"]').click();assert.equal(await page.locator('#generation-width').inputValue(),'410');await page.locator('#modal-cancel').click();
|
||||
await page.locator('[data-page="terminal-gallery"]').click();await page.locator('#library-unbind').waitFor();
|
||||
await page.locator('[data-library-plan="'+planId+'"]').click();await page.locator('#library-edit-plan').click();await page.locator('#generation-name').fill('Renamed frame');await page.locator('#generation-form button[type=submit]').click();await page.locator('[data-library-plan="'+planId+'"]').filter({hasText:'Renamed frame'}).waitFor();assert((await page.locator('.source-library-gallery').innerText()).includes('Renamed frame'));
|
||||
await page.locator('[data-page="terminal-gallery"]').click();await page.locator('#library-unbind').waitFor();assert.equal(await page.locator('[data-library-terminal="living-room"]').innerText(),'Authoritative frame');assert.equal(await page.locator('#device option:checked').innerText(),'Authoritative frame');assert.equal(await page.locator('.terminal-library-gallery h3').innerText(),'Fixture-Renamed frame.jpg');assert((await page.locator('.terminal-library-gallery').innerText()).includes('Renamed frame'));await page.locator('[data-terminal-settings="0"]').click();assert.equal(await page.locator('#modal-content h2').innerText(),'Renamed frame');assert((await page.locator('#modal-content').innerText()).includes('Original snapshot'));await page.locator('#modal-cancel').click();
|
||||
const send=page.locator('[data-terminal-send="0"]');assert(await send.isDisabled());assert.equal(await send.innerText(),'已在终端显示');
|
||||
current=false;await page.locator('#library-refresh').click();await page.waitForFunction(()=>document.querySelector('[data-terminal-send="0"]')?.disabled===false);assert.equal(await send.innerText(),'推送到终端');
|
||||
await page.locator('#library-unbind').click();await page.locator('dialog[open]').waitFor();assert.equal(deletions,0);assert((await page.locator('#modal-content').innerText()).includes('来源图库'));await page.locator('#modal-cancel').click();assert.equal(deletions,0);
|
||||
|
||||
@@ -692,3 +692,92 @@ def test_terminal_current_picture_uses_synced_live_asset(
|
||||
),
|
||||
)
|
||||
assert client.get(endpoint).json()["items"][0]["current"] is False
|
||||
|
||||
|
||||
def test_plan_rename_uses_current_labels_without_rerendering_legacy_versions(
|
||||
client, service, library_source
|
||||
):
|
||||
import json
|
||||
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
plan = create_plan(client, name="Original", width=1200, height=1600)
|
||||
bind(client, "living-room", "family", plan)
|
||||
picture = import_picture(client)
|
||||
endpoint = "/api/v1/source-images/" + picture["id"] + "/generate"
|
||||
original = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
|
||||
snapshot = original["plan"]
|
||||
original_bytes = service.assets.path(original["asset_id"]).read_bytes()
|
||||
# Existing deployed versions used the entire plan, including its old name.
|
||||
legacy_revision = hashlib.sha256(encode(snapshot).encode()).hexdigest()
|
||||
service.db.execute(
|
||||
"UPDATE library_versions SET revision=? WHERE asset_id=?",
|
||||
(legacy_revision, original["asset_id"]),
|
||||
)
|
||||
provenance = json.loads(
|
||||
service.db.one(
|
||||
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
||||
(original["asset_id"],),
|
||||
)["payload"]
|
||||
)
|
||||
provenance["revision"] = legacy_revision
|
||||
service.db.execute(
|
||||
"UPDATE asset_sources SET payload=? WHERE asset_id=?",
|
||||
(encode(provenance), original["asset_id"]),
|
||||
)
|
||||
payload = {k: v for k, v in plan.items() if k not in ("id", "archived")}
|
||||
renamed = {**payload, "name": "Frame v2.1"}
|
||||
assert (
|
||||
client.put("/api/v1/generation-plans/" + plan["id"], json=renamed).status_code
|
||||
== 200
|
||||
)
|
||||
repeated = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
|
||||
assert repeated["asset_id"] == original["asset_id"]
|
||||
assert repeated["revision"] == legacy_revision
|
||||
assert repeated["plan"] == snapshot
|
||||
assert repeated["plan_name"] == "Frame v2.1"
|
||||
assert repeated["name"] == "photo-Frame v2.1.jpg"
|
||||
assert repeated["stored_name"] == original["stored_name"]
|
||||
assert repeated["matches_current_plan"] is True
|
||||
assert service.assets.path(original["asset_id"]).read_bytes() == original_bytes
|
||||
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1
|
||||
source_version = client.get("/api/v1/source-libraries/family/images").json()[
|
||||
"items"
|
||||
][0]["versions"][0]
|
||||
terminal_version = client.get(
|
||||
"/api/v1/library-terminals/living-room/images"
|
||||
).json()["items"][0]
|
||||
assert source_version["plan_name"] == terminal_version["plan_name"] == "Frame v2.1"
|
||||
assert source_version["name"] == terminal_version["name"]
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/generation-plans/" + plan["id"], json={**renamed, "quality": 70}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
old = client.get("/api/v1/library-terminals/living-room/images").json()["items"][0]
|
||||
assert old["matches_current_plan"] is False and old["plan"]["quality"] == 85
|
||||
updated = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
|
||||
assert (
|
||||
updated["asset_id"] != original["asset_id"]
|
||||
and updated["matches_current_plan"] is True
|
||||
)
|
||||
assert updated["plan"]["quality"] == 70
|
||||
client.delete("/api/v1/library-terminals/living-room/binding")
|
||||
assert client.delete("/api/v1/generation-plans/" + plan["id"]).status_code == 204
|
||||
versions = client.get("/api/v1/source-libraries/family/images").json()["items"][0][
|
||||
"versions"
|
||||
]
|
||||
assert len(versions) == 2 and all(v["plan_archived"] for v in versions)
|
||||
|
||||
|
||||
def test_source_deletion_removes_unreferenced_archived_plans(
|
||||
client, service, library_source
|
||||
):
|
||||
plan = create_plan(client)
|
||||
assert client.delete("/api/v1/generation-plans/" + plan["id"]).status_code == 204
|
||||
assert client.delete("/api/v1/photo-sources/family").status_code == 204
|
||||
assert (
|
||||
service.db.one("SELECT id FROM library_plans WHERE source_id='family'") is None
|
||||
)
|
||||
assert client.get("/api/v1/generation-plans?source_id=family").status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user