diff --git a/SHA256SUMS b/SHA256SUMS
index efe3cf0..a8f99a0 100644
--- a/SHA256SUMS
+++ b/SHA256SUMS
@@ -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
diff --git a/docs/SOURCE_LIBRARIES.md b/docs/SOURCE_LIBRARIES.md
index f4a6893..ad969a1 100644
--- a/docs/SOURCE_LIBRARIES.md
+++ b/docs/SOURCE_LIBRARIES.md
@@ -23,7 +23,7 @@
任务先选定一次源画面并保存到本地,再按来源的终端绑定生成版本。默认等比裁切铺满,不拉伸、不补黑边。主动选择完整画面时必要处补白;说明叠加使用来源提供的日期、标题、地点等元数据。源画面本身始终不加文字。Synology Photos 当前取得的是可用源预览,不称为原图;本地存储按画面原尺寸归一化为 JPEG 并处理 EXIF 朝向。
-每份成品关联源画面、方案 ID 和生成时的完整参数快照。相同源画面和相同参数复用已有版本;修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。
+每份成品关联源画面、方案 ID 和生成时的完整参数快照。来源标签、终端绑定、成品卡片及下载名称均通过方案 ID 读取当前名称;生成参数窗口单独标示生成时的名称和原始文件名。仅改名不影响像素,不重复生成,也不改写历史快照和操作回执。方案渲染参数变更后,旧成品显示旧参数提示;重新生成才产生新版本。相同源画面和相同渲染参数复用已有版本(名称及来源名称不计入渲染版本);修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。
相框通过现有上传队列和设备确认流程传输;“已生成”“待推送”“等待设备确认”“已确认显示”分别记录。来源任务完成代表生成和提交完成,不代表所有终端已显示;具体结果看终端图库的独立操作。终端当前画面已同步确认且关联到该成品时,推送按钮显示“已在终端显示”并禁用;仅有历史推送成功记录不足以认定当前仍在显示,离线或同步状态未知时也不作该判断。手表当前只生成 410×502 成品,传输尚未接入。某个方案生成失败不撤销其他输出,源画面保留,可以单独重新生成或重新推送失败目标。
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index 1e43661..92dd6e4 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -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(
diff --git a/pigway_frame_flow/api/libraries.py b/pigway_frame_flow/api/libraries.py
index 2ba93e1..f23b842 100644
--- a/pigway_frame_flow/api/libraries.py
+++ b/pigway_frame_flow/api/libraries.py
@@ -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"]),
diff --git a/pigway_frame_flow/display/libraries.py b/pigway_frame_flow/display/libraries.py
index 437b03d..1a38624 100644
--- a/pigway_frame_flow/display/libraries.py
+++ b/pigway_frame_flow/display/libraries.py
@@ -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",
- (picture_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),
diff --git a/pigway_frame_flow/web/libraries.js b/pigway_frame_flow/web/libraries.js
index 5cf7a1d..b0deb6c 100644
--- a/pigway_frame_flow/web/libraries.js
+++ b/pigway_frame_flow/web/libraries.js
@@ -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 `${label} `+items.map(p=>`${esc(p.name)} `).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=`
${t('refresh')}
`;
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=`${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.')}
${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}
${page.items.map((picture,i)=>{
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
- return `
${version?` `:`${tr('此方案尚未生成','Not generated for this plan')}
`}${esc(picture.name)} ${plan?version?`${esc(version.plan.name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')} ${esc(fmt(version?.created_at||picture.created_at))} ${version?.terminal_uses?.length?`${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')} `:''}${version?`${tr('预览','Preview')} ${tr('下载图片','Download image')} ${plan?`${tr('生成参数','Generation settings')} `:''}`:''}${!plan?.archived?`${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')} `:''}${!plan?`${t(picture.starred?'unstar':'star')} ${t(picture.excluded?'includePhoto':'excludePhoto')} `:''}${t('delete')}
`;
+ return `
${version?` `:`${tr('此方案尚未生成','Not generated for this plan')}
`}${esc(picture.name)} ${plan?version?`${esc(version.plan_name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')} ${esc(fmt(version?.created_at||picture.created_at))} ${plan&&version?.matches_current_plan===false?`${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')} `:''}${version?.terminal_uses?.length?`${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')} `:''}${version?`${tr('预览','Preview')} ${tr('下载图片','Download image')} ${plan?`${tr('生成参数','Generation settings')} `:''}`:''}${!plan?.archived?`${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')} `:''}${!plan?`${t(picture.starred?'unstar':'star')} ${t(picture.excluded?'includePhoto':'excludePhoto')} `:''}${t('delete')}
`;
}).join('')}
${page.items.length?'':`
${tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')} `}
`;
$('#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=`${terminals.map(t=>`${esc(terminalLabel(t))} `).join('')}
${esc(terminalLabel(terminal))} ${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}
${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.')}
${tr('设置来源与方案(各单选)','Bind one source and one plan')} ${terminal.binding?`${tr('解除绑定','Unbind')} ${tr('查看来源与生成版本','View source and versions')} `:''}
${page.items.map((v,i)=>`
${esc(v.name)} ${v.plan.width} × ${v.plan.height} · ${esc(v.plan.name)} ${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))} ${tr('预览','Preview')} ${tr('下载图片','Download image')} ${tr('生成参数','Generation settings')} ${v.operation?`${tr('推送记录','Delivery history')} `:''}${v.current?tr('已在终端显示','Already displayed on terminal'):tr('推送到终端','Send to terminal')}
`).join('')}
${page.items.length?'':`${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')} `}`;
+ $('#library-content').innerHTML=`${terminals.map(t=>`${esc(terminalLabel(t))} `).join('')}
${esc(terminalLabel(terminal))} ${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}
${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.')}
${tr('设置来源与方案(各单选)','Bind one source and one plan')} ${terminal.binding?`${tr('解除绑定','Unbind')} ${tr('查看来源与生成版本','View source and versions')} `:''}
${page.items.map((v,i)=>`
${esc(v.name)} ${v.plan.width} × ${v.plan.height} · ${esc(v.plan_name)} ${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))} ${v.matches_current_plan===false?`${tr('方案参数已更新,此图片为旧参数版本','Plan settings changed; this image uses earlier settings')} `:''}${tr('预览','Preview')} ${tr('下载图片','Download image')} ${tr('生成参数','Generation settings')} ${v.operation?`${tr('推送记录','Delivery history')} `:''}${v.current?tr('已在终端显示','Already displayed on terminal'):tr('推送到终端','Send to terminal')}
`).join('')}
${page.items.length?'':`${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')} `}`;
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(`${esc(p.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))}
${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}
${buttons('modal-cancel','cancel')}
`);
+ showModal(`${esc(version.plan_name)} ${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))}
${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}
${buttons('modal-cancel','cancel')}
`);
}
diff --git a/scripts/ui-libraries.cjs b/scripts/ui-libraries.cjs
index e484a6d..e80654e 100644
--- a/scripts/ui-libraries.cjs
+++ b/scripts/ui-libraries.cjs
@@ -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);
diff --git a/tests/test_source_libraries.py b/tests/test_source_libraries.py
index 6b02f57..b864779 100644
--- a/tests/test_source_libraries.py
+++ b/tests/test_source_libraries.py
@@ -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