From ed3b61bc587baa7d536dd45080558d915f824b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Fri, 23 Oct 2026 23:10:50 +0800 Subject: [PATCH] fix: resolve gallery labels from current plans and preserve render snapshots --- SHA256SUMS | 14 ++-- docs/SOURCE_LIBRARIES.md | 2 +- pigway_frame_flow/api/app.py | 4 ++ pigway_frame_flow/api/libraries.py | 12 ++-- pigway_frame_flow/display/libraries.py | 73 +++++++++++++++------ pigway_frame_flow/web/libraries.js | 9 +-- scripts/ui-libraries.cjs | 13 ++-- tests/test_source_libraries.py | 89 ++++++++++++++++++++++++++ 8 files changed, 176 insertions(+), 40 deletions(-) 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 ``+items.map(p=>``).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=`
`; 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.')}

${plan&&!plan.archived?``:''}

${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}

${page.items.length?'':`
${tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}
`}
${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}
`; $('#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=`

${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.')}

${terminal.binding?``:''}
${page.items.length?'':`
${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}
`}
${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}
`; + $('#library-content').innerHTML=`

${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.')}

${terminal.binding?``:''}
${page.items.length?'':`
${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}
`}
${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}
`; 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.')}

`); + 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.')}

`); } 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