feat: unify source libraries, generation plans and terminal bindings
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m24s

This commit is contained in:
渡口浪人
2026-10-23 17:45:30 +08:00
parent 081d8b6ee0
commit 6277c9d563
25 changed files with 5731 additions and 45 deletions
+4
View File
@@ -8,6 +8,10 @@ Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
### Source and terminal libraries
Each photo source owns a library and generation plans. A terminal binds to one source and one plan within it; terminals may share a plan and its generated version. Source tasks select one picture and produce independent frame/watch versions. See the [library model and workflow](docs/SOURCE_LIBRARIES.md).
### Installation
Deploy with Docker Compose. The current default configuration and verified platform are AMD64:
+4
View File
@@ -8,6 +8,10 @@
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
### 图库与终端
图库分为来源图库和终端图库。每个来源有自己的生成方案;每个终端单选一个来源及其中一个方案,同一方案可以被多个终端共用。任务一次选图,分别生成相框与手表版本。参见[使用与架构说明](docs/SOURCE_LIBRARIES.md)。
### 安装
使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64:
+24 -18
View File
@@ -8,8 +8,8 @@ c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workfl
b12a3f0ff3f15db8d79b5163d0750a3dd10eb56aadc41ada20c5f8a10b46c118 Dockerfile
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
94184f76401192678ec420c2ccd23142d90e07a8ccc882a4a0d4701a0c278afc README.zh-CN.md
5bc4b12649768e753cf743da61b6608603c3080c4492def1b637fb60b78ef5eb README.md
06b316caa3e06c5cc0fbe97d9c0ef9d84ac32efcd3a52370bf887b72ec6d72a6 README.zh-CN.md
4a5de9afa11143f35fb98cb4b97bde4ac1f8b67646810522483d30aecdda16d2 RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
@@ -28,25 +28,27 @@ d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSION
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
2311d232a8394f365165d3fab8e5a5d27a88410f214919a8f3b94f2b24e02cb8 docs/SOURCE_LIBRARIES.md
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
36695835826ce75d416353938eae1c46224f3a34aa57b80b2694443604c0c1a0 docs/WATCH-THUMBNAIL.md
1ae7e53355dc27e2995c85f2a9a55ad87e53085cccb3235970236666b6485799 docs/WATCH-THUMBNAIL.md
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
f0ffb49c9832dad780bb9e7917690645076e02f2f89b7aa348bab3a5d792b284 docs/openapi.json
debddbc1ad67c51fc73891ac1855fb971ae929c582966ac2dd03d84c0229400c docs/openapi.json
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
2b68a29bc4927700c49de0693d5647134671494a3d54af76df6c0e8f06a9e377 pigway_frame_flow/api/app.py
48321bc1ebd77b09ab91a8f4b3f46ba7b852713f827a31c37a620efc05a63bcd pigway_frame_flow/api/app.py
1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
586a886999a8327aa61df622f7e9c844cc865a8220027cf8b0d57fcfbb76214c 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
@@ -54,10 +56,11 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f
2f03a04c1f0f32d95bfd3d27d37ca98f9b79daaa20453999024b16fc8f34cd5d pigway_frame_flow/core/photo_preferences.py
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
881698e97f4427ff2db025f08ee82d9d106ce36744ce8f17cae94f2e8a703e18 pigway_frame_flow/display/cloud_library.py
81d969357cd267e4047edc344db24afb88f67c1ffa176010cef384eee8d25457 pigway_frame_flow/display/libraries.py
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
a00adacca6b5bbc18c4d15dec6ff3c7bba018f8f8575c69205526228032c39b1 pigway_frame_flow/display/service.py
9fb5a61dbc73011babad2e146ed8a6215ea0a0cbee12839b7e3b66ee1dd95750 pigway_frame_flow/display/service.py
de6b53b2911837af5e8e4ee3065ae513e4523436ddabde4a11503e432c3c1351 pigway_frame_flow/display/thumbnail_gallery.py
18fc72b7f726c85d8b11bed78d88403c7867b7d71d2580d47afad6fa0242edc2 pigway_frame_flow/display/thumbnail_plans.py
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
@@ -93,7 +96,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
203cea9e4c3ed6e9c33e999ca8b7c72d1d5c3c7a5d5384bdd1ffcfff1904ce15 pigway_frame_flow/models.py
17cbaba7fe46377bf62c003dadde9e6dbbe8d230a1c2675d0541770cc413e96d pigway_frame_flow/models.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
@@ -108,12 +111,12 @@ c44bf2b4a3776bebe3689b251ae3e8c352a43088829ae8c3f6fd379b64dea05e pigway_frame_f
7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
6d7937420ad9f0f96846a6e52bcf3c867c161bc7aac48a05a4447a2e086bce6c pigway_frame_flow/renderers/images.py
b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 pigway_frame_flow/renderers/photo_caption.py
95da34371ed6a46e63fb7f01dbaad3b9a73b520cdcaec1231048b81b01a0184e pigway_frame_flow/renderers/images.py
0683436692d083c86b580aff71a9803a94e9fa07d45f384795020423b486494a pigway_frame_flow/renderers/photo_caption.py
241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py
d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/scheduler/__init__.py
ef75ef970e89569e2884afe013409e3eb4d8960d920ed363aef2a4fe8dbd2e9d pigway_frame_flow/scheduler/engine.py
d904579f2918c0bc97f05d2a84964ae5f0b3b9dfe6b5201c0866b4c4c5a1d304 pigway_frame_flow/scheduler/engine.py
d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
@@ -127,20 +130,21 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
4c97eed75273e9b7579c023dc45177c794fbc4dfcce4fd8308c0849fe00730f6 pigway_frame_flow/web/app.js
e7305a0e1870df824fd0d46663bf646f0105ce66308a0bd785baa95b6c1e99c5 pigway_frame_flow/web/app.js
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
028a9f2000f0e9787e88d7815545d02d24c9247bff473b0fac54194d58103359 pigway_frame_flow/web/features.js
1f77efa9acfb40872593e3b7c475d972b960cb9f21e9afb936add03c3f2dd542 pigway_frame_flow/web/features.js
a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js
f756a8a103cf06abdbd7c2ed6e50da49b0de166b1d7bb98a9b798bc892d07287 pigway_frame_flow/web/index.html
25f975a560aab00b57c5527a674d52bc45bd6c4b42e6169f1db406f2040f2177 pigway_frame_flow/web/index.html
be035a5088bc3f83d1cb0ff1b6996f915252b0ea392bddd6baa6e66197df0caa pigway_frame_flow/web/libraries.js
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
2ad647cb775eb3a8f665d0f2cf4520557b849bba5ed51acee75bd17cdd1a7c37 pigway_frame_flow/web/style.css
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
8bfe4c25024860cda7b83d385be9197a1b78ac4fd29ff27954f1f52d1097e346 pigway_frame_flow/web/thumbnails.js
ec7703151fe7c10e8bdeb0ee303e725376dffe148ea22df729564f72ed5759ff pigway_frame_flow/web/thumbnails.js
5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 pyproject.toml
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
@@ -150,6 +154,7 @@ b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-com
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
a39fd668383a948a9fbe1ecb00aebb23f375af52ea4dd3bc7049106925cfebb5 scripts/migrate-source-libraries.py
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
@@ -185,8 +190,8 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
c583ae686b2988a31067d9a3af348843a93f04b644e8b01ef6203fd752595383 tests/contract.json
cf57d406e1743df4697d5f8341ce3470a963880f7a7d17a5e375eafff625fd65 tests/schema-contract.json
77cdbcc0ec48125964d710ea437210d325d9aef30e02053742c506ef113ac519 tests/contract.json
9b3632a93c68c057b2fb276aa4a1b2b514f74934f0183480930d951d91704cae tests/schema-contract.json
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
d5bf4f0463a9e369557adf2f440ad401815cb8ffea84af49d106e916eee16501 tests/test_api.py
@@ -231,6 +236,7 @@ e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_ret
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
686b925646d52dcf6d9574b9b451af499a402ee2793a99f1154d4984f8458183 tests/test_source_libraries.py
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
a83390e9f580bc27a2928eb6f94d7377307ec46ab45ef71bfea1e5378cb7adb9 tests/test_thumbnail_plans.py
+1
View File
@@ -2,6 +2,7 @@
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
- [来源图库、生成方案与终端](SOURCE_LIBRARIES.md)
- [自动拼图](AUTOMATIC_COLLAGE.md)
- [构建、缓存与 CI](BUILDING.md)
- [可选连接与扩展](EXTENSIONS.md)
+41
View File
@@ -0,0 +1,41 @@
# 来源图库、生成方案与终端
平台图库分为两类:来源图库与终端图库。照片数据源的每条配置对应一个来源图库,可以建立多个。每个来源图库拥有自己的生成方案,方案不能跨来源移动。每个终端最多绑定一个来源图库及其中一个方案;同一来源、同一方案允许多个终端共用。未完成绑定的终端不参与来源统一输出。
## 使用顺序
1. 在“照片数据源”配置来源、人物、分辨率、质量、去重等选图条件。
2. 在“图库 → 来源图库”选择来源,选入照片,保存无文字叠加的源画面。
3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。也可将旧版小图方案参数复制到所选来源下。
4. 来源图库标签为“源画面”和该来源的生成方案;未生成的版本明确显示未生成,生成后可预览、下载、查看参数快照、删除。归档方案仍可查看已有版本。
5. 在“终端图库”对每个终端单选来源和方案。切换来源后必须重新选择方案,服务端检查归属和屏幕尺寸。绑定同一方案的终端共用生成文件。
6. 需要自动化时,为照片任务勾选“按来源图库统一生成并推送”。新建照片任务默认使用该模式;旧任务不在启动时自动改写,确认绑定后可在编辑任务时切换。唯一明确的单来源、单照片任务也可使用 `scripts/migrate-source-libraries.py` 预检,再加 `--apply` 迁移;脚本保留启用状态、调度配置和下次执行时间,不推送设备。若当前设备有可靠的照片来源回执,会选入该画面并仅在本地生成两个版本。生成模式不再额外选择旧版小图方案。
## 一次选图,多种版本
任务先选定一次源画面并保存到本地,再按来源的终端绑定生成版本。默认等比裁切铺满,不拉伸、不补黑边。主动选择完整画面时必要处补白;说明叠加使用来源提供的日期、标题、地点等元数据。源画面本身始终不加文字。Synology Photos 当前取得的是可用源预览,不称为原图;本地存储按画面原尺寸归一化为 JPEG 并处理 EXIF 朝向。
每份成品关联源画面、方案 ID 和生成时的完整参数快照。相同源画面和相同参数复用已有版本;修改方案后重新生成产生新版本,不覆盖历史图片。任务持久保存选图和方案快照,进程中断后继续处理该画面,不重新随机选图或重复提交同一终端输出。
相框通过现有上传队列和设备确认流程传输;“已生成”“待推送”“等待设备确认”“已确认显示”分别记录。来源任务完成代表生成和提交完成,不代表所有终端已显示;具体结果看终端图库的独立操作。手表当前只生成 410×502 成品,传输尚未接入。某个方案生成失败不撤销其他输出,源画面保留,可以单独重新生成或重新推送失败目标。
在来源图库手动点击“随机选入并按绑定生成”只生成,不自动推送。终端图库的相框推送需要明确点击并确认,受全局设备写入开关约束。更换终端绑定后,尚未执行的旧版本推送会在设备写入前重新校验,不符合当前绑定时停止。
## 保留与删除
来源文件放在 `data/source-images/`,版本放在 `data/renditions/`,不进入旧的临时缓存自动清理范围。来源图库的星标与不再选用沿用照片来源偏好,二者互斥;不再选用只影响后续自动选图。
删除本地源画面会同时删除其本地生成版本,执行前检查任务、模板和待处理操作的引用;不会删除上游照片或设备已接收的副本。删除方案采用归档,历史版本保留快照;被终端绑定的方案必须先解除或更换绑定。包含绑定、活动方案或本地源画面的来源配置禁止直接删除。
旧本地成品、旧小图及现有云端图片均保留,通过“历史本地成品”“旧版小图图库”“设备已有云端图片”查看。不会凭文件名或尺寸将旧成品猜测为源画面,不自动覆盖旧任务或推断终端绑定。
## API 与存储
- `/api/v1/source-libraries`:来源图库;`/{id}/images`、`/import`、`/run` 用于列表、选入与统一生成。
- `/api/v1/generation-plans`:按 `source_id` 查询与创建方案;`/{id}` 修改或归档。
- `/api/v1/source-images/{id}`:源画面删除;`/generate`、`/preview`、`/star`、`/exclusion` 用于版本生成、预览和来源偏好。
- `/api/v1/library-terminals`:终端;`/{id}/binding` 管理单一绑定,`/{id}/images` 查询成品,`/{id}/images/{asset_id}/send` 显式推送。
- `/api/v1/library-versions/{asset_id}`:删除本地成品版本。
- `PUT /api/v1/jobs/{job_id}/library-mode`:使用 `jobs:write` 切换模式,保留下次执行时间和启用状态。
新增 `library_plans`、`source_images`、`library_versions`、`terminal_bindings`、`terminal_deliveries` 表,复用现有 SQLite、资源鉴权、Pillow 渲染及任务队列。`terminal_bindings.terminal_id` 是主键,保证终端仅有一个绑定;服务端同步校验来源、方案、版本归属以及屏幕尺寸。读取需要 `images:read`,管理需要 `admin`,本地删除需要 `images:delete`,终端推送需要 `display:write`。旧 API 保留兼容。
+3 -1
View File
@@ -1,4 +1,6 @@
# 小图方案
# 小图方案(兼容入口)
主界面已采用[来源图库、生成方案与终端](SOURCE_LIBRARIES.md)。本文描述保留的旧版小图 API 与历史入口;新方案应在对应来源图库中建立,终端单选来源及其方案。
平台侧边栏的“小图方案”提供方案新增、查看、修改与删除。每个方案可保存名称、输出宽高(16–2048 像素)、JPEG 质量(40–95)、适配方式及水平/垂直裁切位置(0–1)。默认 410×502、质量 85、居中等比裁切铺满,不拉伸、不补黑边。也可主动选择完整画面,此时必要处补白;裁切位置仅用于铺满模式。
+3216
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -144,6 +144,9 @@ def create_app(settings=None, start_worker=True):
from pigway_frame_flow.api.thumbnails import install_thumbnails
install_thumbnails(app, service)
from pigway_frame_flow.api.libraries import install_libraries
install_libraries(app, service)
app.state.service = service
app.state.worker_enabled = start_worker
app.state.scheduler = scheduler
@@ -601,7 +604,7 @@ def create_app(settings=None, start_worker=True):
return [
assets.info(r["id"])
for r in db.all(
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind!='cache' "
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind NOT IN ('cache','source-images','renditions') "
"AND NOT EXISTS (SELECT 1 FROM asset_sources s WHERE s.asset_id=assets.id AND json_extract(s.payload,'$.type')='thumbnail') AND "
"(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) "
"AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) "
@@ -618,6 +621,13 @@ def create_app(settings=None, start_worker=True):
)
def delete_asset(asset_id: str):
path = assets.path(asset_id)
if db.one(
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')",
(asset_id,),
):
raise FlowError(
E.CONFLICT, "Delete library images through their source library", 409
)
reference = "%" + asset_id + "%"
if (
db.one(
@@ -799,7 +809,9 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("admin"))],
)
def delete_photo_source(source_id: str):
service.photo_sources.delete(source_id)
with service.lock:
service.libraries.check_source_delete(source_id)
service.photo_sources.delete(source_id)
return Response(status_code=204)
@app.get(
@@ -1391,6 +1403,7 @@ def create_app(settings=None, start_worker=True):
if filename not in [
"app.js",
"thumbnails.js",
"libraries.js",
"features.js",
"extensions.js",
"style.css",
+291
View File
@@ -0,0 +1,291 @@
from fastapi import Depends, Query, Request
from fastapi.responses import Response
from pydantic import Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.core.photo_preferences import set_flags
from pigway_frame_flow.display.libraries import GenerationPlan
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
class BindingInput(StrictModel):
source_id: str = Field(min_length=1, max_length=120)
plan_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class ImportInput(StrictModel):
item_id: str = Field(min_length=1, max_length=2048)
class RunInput(StrictModel):
item_id: str | None = Field(default=None, min_length=1, max_length=2048)
deliver: bool = False
class LibraryModeInput(StrictModel):
enabled: bool
class GenerateInput(StrictModel):
plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
def install_libraries(app, service):
libraries, db = service.libraries, service.db
@app.put(
"/api/v1/jobs/{job_id}/library-mode",
dependencies=[Depends(require("jobs:write"))],
)
def set_mode(job_id: str, payload: LibraryModeInput):
return app.state.scheduler.set_library_mode(job_id, payload.enabled)
@app.get("/api/v1/source-libraries", dependencies=[Depends(require("images:read"))])
def sources():
return [
{
**source,
"image_count": db.one(
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
(source["id"],),
)["n"],
}
for source in service.photo_sources.list()
]
@app.get(
"/api/v1/source-libraries/{source_id}/images",
dependencies=[Depends(require("images:read"))],
)
def pictures(
source_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
service.photo_sources.get(source_id)
rows = db.all(
"SELECT id FROM source_images WHERE source_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
(source_id, limit, offset),
)
return {
"items": [libraries.picture(row["id"]) for row in rows],
"offset": offset,
"limit": limit,
"total": db.one(
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
(source_id,),
)["n"],
}
@app.post(
"/api/v1/source-libraries/{source_id}/import",
dependencies=[Depends(require("admin"))],
)
def import_picture(source_id: str, payload: ImportInput):
return libraries.import_picture(source_id, payload.item_id)
@app.post(
"/api/v1/source-libraries/{source_id}/run",
dependencies=[Depends(require("admin"))],
)
def run(source_id: str, payload: RunInput, request: Request):
with service.lock:
return libraries.pipeline(
source_id,
payload.item_id,
deliver=payload.deliver,
caller=request.state.client_id,
)
@app.get("/api/v1/generation-plans", dependencies=[Depends(require("images:read"))])
def plans(source_id: str):
return libraries.plans(source_id)
@app.post("/api/v1/generation-plans", dependencies=[Depends(require("admin"))])
def create_plan(payload: GenerationPlan):
return libraries.save_plan(payload)
@app.put(
"/api/v1/generation-plans/{plan_id}", dependencies=[Depends(require("admin"))]
)
def update_plan(plan_id: str, payload: GenerationPlan):
return libraries.save_plan(payload, plan_id)
@app.delete(
"/api/v1/generation-plans/{plan_id}",
status_code=204,
dependencies=[Depends(require("admin"))],
)
def delete_plan(plan_id: str):
libraries.archive(plan_id)
@app.post(
"/api/v1/source-images/{picture_id}/generate",
dependencies=[Depends(require("admin"))],
)
def generate(picture_id: str, payload: GenerateInput):
with service.lock:
picture = libraries.picture(picture_id)
ids = (
[payload.plan_id]
if payload.plan_id
else [
p["id"]
for p in libraries.plans(picture["source_id"])
if not p["archived"]
]
)
return [libraries.generate(picture_id, plan_id) for plan_id in ids]
@app.post(
"/api/v1/source-images/{picture_id}/preview",
dependencies=[Depends(require("images:read"))],
)
def preview(picture_id: str, payload: GenerationPlan):
picture = libraries.picture(picture_id)
if picture["source_id"] != payload.source_id:
raise FlowError(
E.CONFLICT, "Picture and plan must share a source library", 409
)
data = libraries.render(
service.assets.path(picture_id).read_bytes(), picture["caption"], payload
)
return Response(
data, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
)
@app.put(
"/api/v1/source-images/{picture_id}/star",
dependencies=[Depends(require("admin"))],
)
def star(picture_id: str, payload: ImageStar):
picture = libraries.picture(picture_id)
return set_flags(
db, picture["source_id"], picture["item_id"], starred=payload.starred
)
@app.put(
"/api/v1/source-images/{picture_id}/exclusion",
dependencies=[Depends(require("admin"))],
)
def exclude(picture_id: str, payload: ImageExclusion):
picture = libraries.picture(picture_id)
return set_flags(
db, picture["source_id"], picture["item_id"], excluded=payload.excluded
)
@app.delete(
"/api/v1/source-images/{picture_id}",
status_code=204,
dependencies=[Depends(require("images:delete"))],
)
def delete_picture(picture_id: str):
libraries.delete_picture(picture_id)
@app.delete(
"/api/v1/library-versions/{asset_id}",
status_code=204,
dependencies=[Depends(require("images:delete"))],
)
def delete_version(asset_id: str):
with service.lock:
if not db.one(
"SELECT asset_id FROM library_versions WHERE asset_id=?", (asset_id,)
):
raise FlowError(E.NOT_FOUND, "Generated image not found", 404)
libraries.check_asset_references(asset_id)
libraries.remove_asset(asset_id)
@app.get(
"/api/v1/library-terminals", dependencies=[Depends(require("images:read"))]
)
def terminals():
return libraries.terminals()
@app.put(
"/api/v1/library-terminals/{terminal_id}/binding",
dependencies=[Depends(require("admin"))],
)
def bind(terminal_id: str, payload: BindingInput):
return libraries.bind(terminal_id, payload.source_id, payload.plan_id)
@app.delete(
"/api/v1/library-terminals/{terminal_id}/binding",
status_code=204,
dependencies=[Depends(require("admin"))],
)
def unbind(terminal_id: str):
with service.lock:
db.execute(
"DELETE FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
)
@app.get(
"/api/v1/library-terminals/{terminal_id}/images",
dependencies=[Depends(require("images:read"))],
)
def terminal_images(
terminal_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
if not any(t["id"] == terminal_id for t in libraries.terminals()):
raise FlowError(E.NOT_FOUND, "Terminal not found", 404)
binding = libraries.binding(terminal_id)
if not binding:
return {"items": [], "total": 0, "offset": offset, "limit": limit}
where = "FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.plan_id=? AND s.source_id=?"
args = (binding["plan_id"], binding["source_id"])
rows = db.all(
"SELECT v.asset_id,v.source_image_id "
+ where
+ " ORDER BY v.created_at DESC,v.asset_id LIMIT ? OFFSET ?",
(*args, limit, offset),
)
items = []
for row in rows:
version = next(
v
for v in libraries.versions(row["source_image_id"])
if v["asset_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"]),
)
operation = (
db.one(
"SELECT id AS operation_id,status,created_at,updated_at FROM operations WHERE id=?",
(delivery["operation_id"],),
)
if delivery and delivery["operation_id"]
else None
)
items.append(
{
**version,
"operation": operation,
"delivery_status": operation["status"]
if operation
else "GENERATED",
}
)
return {
"items": items,
"total": db.one("SELECT COUNT(*) AS n " + where, args)["n"],
"offset": offset,
"limit": limit,
}
@app.post(
"/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send",
dependencies=[Depends(require("display:write"))],
)
def send(terminal_id: str, asset_id: str, request: Request):
return {
"operation_id": libraries.send(
terminal_id, asset_id, client_id=request.state.client_id
)
}
@@ -20,6 +20,11 @@ class CloudLibrary:
job = self.db.one(
"SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,)
)
if not job and operation["client_id"] == "library-terminal":
job = self.db.one(
"SELECT job_runs.job_id FROM terminal_deliveries JOIN job_runs ON job_runs.operation_id=terminal_deliveries.parent_operation_id WHERE terminal_deliveries.operation_id=?",
(operation_id,),
)
if not job and operation["client_id"] == "scheduler-retry":
job = self.db.one(
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
+659
View File
@@ -0,0 +1,659 @@
"""Source pictures, source-scoped render plans, and single terminal bindings."""
import hashlib
import json
from io import BytesIO
from uuid import uuid4
from PIL import Image
from pydantic import Field, model_validator
from pigway_frame_flow.core.photo_preferences import flags
from pigway_frame_flow.display.thumbnail_plans import ThumbnailPlan
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import PhotoSourceContent
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS, convert
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
from pigway_frame_flow.storage.database import encode, now_iso
class GenerationPlan(ThumbnailPlan):
source_id: str = Field(min_length=1, max_length=120)
caption: bool = False
profile: str = Field(default="color", pattern="^(color|grayscale)$")
@model_validator(mode="after")
def caption_size(self):
if self.caption and min(self.width, self.height) < 128:
raise ValueError(
"Caption output requires both dimensions to be at least 128 pixels"
)
return self
class Libraries:
def __init__(self, service):
self.service, self.db = service, service.db
for kind in ("source-images", "renditions"):
(service.settings.data_dir / kind).mkdir(exist_ok=True)
for sql in [
"CREATE TABLE IF NOT EXISTS library_plans(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,payload TEXT NOT NULL,archived INTEGER NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS source_images(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,item_id TEXT NOT NULL,digest TEXT NOT NULL,caption TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_id,item_id,digest))",
"CREATE TABLE IF NOT EXISTS library_versions(asset_id TEXT PRIMARY KEY,source_image_id TEXT NOT NULL,plan_id TEXT NOT NULL,revision TEXT NOT NULL,plan TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_image_id,plan_id,revision))",
"CREATE TABLE IF NOT EXISTS terminal_bindings(terminal_id TEXT PRIMARY KEY,source_id TEXT NOT NULL,plan_id TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS terminal_deliveries(terminal_id TEXT NOT NULL,asset_id TEXT NOT NULL,operation_id TEXT PRIMARY KEY,parent_operation_id TEXT,created_at TEXT NOT NULL)",
]:
self.db.execute(sql)
service.operation_handlers["library-generate"] = self.execute
def terminals(self):
result = []
for terminal_id, driver in self.service.registry.drivers.items():
cached = getattr(driver, "cached", None)
result.append(
{
"id": terminal_id,
"name": getattr(cached, "name", terminal_id),
"kind": "frame",
"transport_supported": True,
"binding": self.binding(terminal_id),
}
)
result.append(
{
"id": "watch",
"name": "Watch",
"kind": "watch",
"width": 410,
"height": 502,
"transport_supported": False,
"binding": self.binding("watch"),
}
)
return result
def binding(self, terminal_id):
return self.db.one(
"SELECT * FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
)
def plan(self, plan_id, active=True):
row = self.db.one("SELECT * FROM library_plans WHERE id=?", (plan_id,))
if not row or (active and row["archived"]):
raise FlowError(E.NOT_FOUND, "Generation plan not found", 404)
return GenerationPlan.model_validate_json(row["payload"])
def plans(self, source_id):
self.service.photo_sources.get(source_id)
return [
{**json.loads(r["payload"]), "id": r["id"], "archived": bool(r["archived"])}
for r in self.db.all(
"SELECT * FROM library_plans WHERE source_id=? ORDER BY archived,id",
(source_id,),
)
]
def save_plan(self, plan, plan_id=None):
with self.service.lock:
self.service.photo_sources.get(plan.source_id)
if plan_id:
previous = self.plan(plan_id)
if previous.source_id != plan.source_id:
raise FlowError(
E.CONFLICT,
"A generation plan cannot move between source libraries",
409,
)
for row in self.db.all(
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?",
(plan_id,),
):
self.validate_size(row["terminal_id"], plan)
plan_id = plan_id or uuid4().hex
self.db.execute(
"INSERT INTO library_plans VALUES(?,?,?,0) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
(plan_id, plan.source_id, encode(plan.model_dump())),
)
return {"id": plan_id, **plan.model_dump(), "archived": False}
def archive(self, plan_id):
with self.service.lock:
self.plan(plan_id)
if self.db.one(
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?", (plan_id,)
):
raise FlowError(
E.CONFLICT,
"Unbind terminals before deleting this generation plan",
409,
{"reason": "libraryPlanBound"},
)
self.db.execute(
"UPDATE library_plans SET archived=1 WHERE id=?", (plan_id,)
)
def validate_size(self, terminal_id, plan):
if terminal_id == "watch":
allowed = {(410, 502)}
else:
display = self.service.registry.get(terminal_id).device().display
allowed = {(display.width, display.height), (display.height, display.width)}
if (plan.width, plan.height) not in allowed:
raise FlowError(
E.INVALID_REQUEST,
"Generation dimensions must match the terminal screen",
422,
{"reason": "librarySizeMismatch"},
)
def bind(self, terminal_id, source_id, plan_id):
with self.service.lock:
self.service.photo_sources.get(source_id)
plan = self.plan(plan_id)
if plan.source_id != source_id:
raise FlowError(
E.CONFLICT, "Generation plan belongs to another source library", 409
)
self.validate_size(terminal_id, plan)
self.db.execute(
"INSERT INTO terminal_bindings VALUES(?,?,?) ON CONFLICT(terminal_id) DO UPDATE SET source_id=excluded.source_id,plan_id=excluded.plan_id",
(terminal_id, source_id, plan_id),
)
return self.binding(terminal_id)
def ingest(self, source_id, item_id, data, name, caption):
digest = hashlib.sha256(
data + b"\0" + encode(caption or {}).encode()
).hexdigest()
row = self.db.one(
"SELECT id FROM source_images WHERE source_id=? AND item_id=? AND digest=?",
(source_id, item_id, digest),
)
if row:
return self.picture(row["id"])
# Normalize EXIF and JPEG storage without resizing or adding decorations.
try:
with Image.open(BytesIO(data)) as image:
if (
image.format not in {"JPEG", "PNG", "WEBP"}
or image.width * image.height > MAX_IMAGE_PIXELS
):
raise ValueError("Unsupported source image")
size = image.size
if image.getexif().get(274, 1) in (5, 6, 7, 8):
size = (image.height, image.width)
normalized = convert(data, "cover", size=size, quality=95)
except (OSError, ValueError, Image.DecompressionBombError):
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
asset = self.service.assets.put(normalized, name, "source-images")
self.db.execute(
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
source_id,
item_id,
digest,
encode(caption or {}),
now_iso(),
),
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_source",
"source_id": source_id,
"item_id": item_id,
"source_rendition": "preview"
if self.service.photo_sources.get(source_id).kind
== "synology-photos"
else "source",
}
),
),
)
return self.picture(asset["asset_id"])
def import_picture(self, source_id, item_id):
with self.service.lock:
data, name, caption = self.service.photo_sources.read(source_id, item_id)
return self.ingest(source_id, item_id, data, name, caption)
def picture(self, picture_id):
row = self.db.one("SELECT * FROM source_images WHERE id=?", (picture_id,))
if not row:
raise FlowError(E.NOT_FOUND, "Source picture not found", 404)
return {
**self.service.assets.info(picture_id),
**row,
"caption": json.loads(row["caption"]),
**flags(self.db, row["source_id"], row["item_id"]),
"versions": self.versions(picture_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,),
)
]
def terminal_uses(self, plan_id, asset_id):
return self.db.all(
"SELECT b.terminal_id,d.operation_id,COALESCE(o.status,'GENERATED') AS status "
"FROM terminal_bindings b LEFT JOIN terminal_deliveries d ON d.operation_id=(SELECT operation_id FROM terminal_deliveries latest "
"WHERE latest.terminal_id=b.terminal_id AND latest.asset_id=? ORDER BY latest.created_at DESC,latest.operation_id DESC LIMIT 1) "
"LEFT JOIN operations o ON o.id=d.operation_id WHERE b.plan_id=? ORDER BY b.terminal_id",
(asset_id, plan_id),
)
def render(self, data, caption, plan):
if plan.caption and caption:
return render_captioned_photo(
data,
caption,
self.service.renderer.font,
plan.fit,
plan.profile,
size=(plan.width, plan.height),
centering=(plan.center_x, plan.center_y),
quality=plan.quality,
respect_fit=True,
)
return convert(
data,
plan.fit,
plan.profile,
size=(plan.width, plan.height),
centering=(plan.center_x, plan.center_y),
quality=plan.quality,
)
def generate(self, picture_id, plan_id, snapshot=None):
with self.service.lock:
picture = self.picture(picture_id)
plan = (
GenerationPlan.model_validate(snapshot)
if snapshot
else self.plan(plan_id)
)
if picture["source_id"] != plan.source_id:
raise FlowError(
E.CONFLICT,
"Picture and generation plan must share a source library",
409,
)
revision = hashlib.sha256(encode(plan.model_dump()).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),
)
if row:
return next(
v
for v in self.versions(picture_id)
if v["asset_id"] == row["asset_id"]
)
data = self.service.assets.path(picture_id).read_bytes()
asset = self.service.assets.put(
self.render(data, picture["caption"], plan),
picture["name"].removesuffix(".jpg") + "-" + plan.name,
"renditions",
)
self.db.execute(
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
(
asset["asset_id"],
picture_id,
plan_id,
revision,
encode(plan.model_dump()),
now_iso(),
),
)
self.db.execute(
"INSERT INTO asset_sources VALUES(?,?)",
(
asset["asset_id"],
encode(
{
"type": "library_version",
"source_image_id": picture_id,
"source_id": plan.source_id,
"plan_id": plan_id,
"revision": revision,
}
),
),
)
return next(
v
for v in self.versions(picture_id)
if v["asset_id"] == asset["asset_id"]
)
def validate_asset_binding(self, terminal_id, asset_id):
if self.db.one("SELECT id FROM source_images WHERE id=?", (asset_id,)):
raise FlowError(
E.CONFLICT,
"Generate a terminal version before sending source pixels",
409,
{"reason": "libraryGenerateFirst"},
)
version = self.db.one(
"SELECT v.plan_id,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
(asset_id,),
)
if version:
binding = self.binding(terminal_id)
if (
not binding
or binding["source_id"] != version["source_id"]
or binding["plan_id"] != version["plan_id"]
):
raise FlowError(
E.CONFLICT, "Image no longer matches the terminal binding", 409
)
def send(
self,
terminal_id,
asset_id,
options=None,
client_id="admin",
parent_operation_id=None,
):
with self.service.lock:
binding = self.binding(terminal_id)
version = self.db.one(
"SELECT v.*,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
(asset_id,),
)
if (
not binding
or not version
or binding["source_id"] != version["source_id"]
or binding["plan_id"] != version["plan_id"]
):
raise FlowError(
E.CONFLICT,
"Image does not match the terminal source and plan binding",
409,
)
if terminal_id == "watch":
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Watch transfer is not connected", 422
)
request = {
"device_id": terminal_id,
"content": {"type": "image", "asset_id": asset_id},
"options": {
**(options or {}),
"cleanup_unstarred": False,
"generate_thumbnail": False,
"thumbnail_plan_id": None,
},
}
from pigway_frame_flow.models import DisplayRequest
request_model = DisplayRequest.model_validate(request)
request = request_model.model_dump(mode="json")
previous = self.db.one(
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? "
+ ("AND parent_operation_id=? " if parent_operation_id else "")
+ "ORDER BY created_at DESC,operation_id DESC LIMIT 1",
(terminal_id, asset_id, parent_operation_id)
if parent_operation_id
else (terminal_id, asset_id),
)
if previous:
op = self.db.one(
"SELECT id,status,payload FROM operations WHERE id=?",
(previous["operation_id"],),
)
if op and (
parent_operation_id
or (
op["status"]
in ("QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN")
and json.loads(op["payload"]) == request
)
):
return op["id"]
self.service.validate(request_model)
# Reuse the queue's durable idempotency receipt if a restart occurs
# between submitting a child operation and linking its delivery row.
key = (
"library-output-"
+ hashlib.sha256(
encode([parent_operation_id, terminal_id, asset_id]).encode()
).hexdigest()
if parent_operation_id
else None
)
operation = self.service.submit(
"display",
request,
terminal_id,
client_id,
"library-" + uuid4().hex,
key=key,
)
self.db.execute(
"INSERT INTO terminal_deliveries VALUES(?,?,?,?,?)",
(terminal_id, asset_id, operation, parent_operation_id, now_iso()),
)
return operation
def pipeline(
self,
source_id,
item_id=None,
options=None,
deliver=False,
operation_id=None,
caller="admin",
):
root_operation = (
(self.db.one("SELECT * FROM operations WHERE id=?", (operation_id,)) or {})
if operation_id
else {}
)
previous = (
json.loads(root_operation["result"] or "{}") if root_operation else {}
)
bindings = previous.get("binding_snapshot")
if bindings is None:
bindings = self.db.all(
"SELECT * FROM terminal_bindings WHERE source_id=? ORDER BY terminal_id",
(source_id,),
)
bindings = [
{**b, "plan_snapshot": self.plan(b["plan_id"]).model_dump()}
for b in bindings
]
if not bindings:
raise FlowError(
E.CONFLICT,
"Bind at least one terminal to this source library first",
409,
{"reason": "libraryBindFirst"},
)
if operation_id:
self.service.update(operation_id, "RENDERING")
if previous.get("source_image_id"):
selected = self.picture(previous["source_image_id"])
if selected["source_id"] != source_id:
raise FlowError(
E.CONFLICT, "Saved selection belongs to another source library", 409
)
else:
selected = self.service.content_asset(
PhotoSourceContent(
type="photo_source", source_id=source_id, item_id=item_id
),
operation_id=operation_id,
source_only=True,
source_size=max(
(
(b["plan_snapshot"]["width"], b["plan_snapshot"]["height"])
for b in bindings
),
key=lambda size: size[0] * size[1],
),
)
result = {
"source_image_id": selected["asset_id"],
"binding_snapshot": bindings,
"outputs": [],
"errors": [],
}
if operation_id:
self.service.assets.record_origin(selected["asset_id"], root_operation)
self.service.update(operation_id, "RENDERING", result)
versions = {}
for binding in bindings:
terminal_id, plan_id = binding["terminal_id"], binding["plan_id"]
try:
if plan_id not in versions:
versions[plan_id] = self.generate(
selected["asset_id"], plan_id, binding["plan_snapshot"]
)
version = versions[plan_id]
if operation_id:
self.service.assets.record_origin(
version["asset_id"], root_operation
)
output = {
"terminal_id": terminal_id,
"asset_id": version["asset_id"],
"plan_id": plan_id,
"status": "GENERATED",
}
if deliver and terminal_id != "watch":
output["operation_id"] = self.send(
terminal_id,
version["asset_id"],
options,
"library-terminal" if operation_id else caller,
parent_operation_id=operation_id,
)
output["status"] = "QUEUED"
result["outputs"].append(output)
if operation_id:
self.service.update(operation_id, "RENDERING", result)
except (FlowError, OSError) as exc:
result["errors"].append(
{
"terminal_id": terminal_id,
"plan_id": plan_id,
"message": str(exc),
}
)
if operation_id:
self.service.update(
operation_id,
"COMPLETED" if not result["errors"] else "FAILED",
result,
error={
"code": E.INVALID_REQUEST,
"message": "Some terminal outputs failed",
"request_id": root_operation["request_id"],
"details": {"reason": "libraryOutputPartial"},
}
if result["errors"]
else None,
)
return result
def execute(self, operation, payload):
result = self.pipeline(
payload["content"]["source_id"],
payload["content"].get("item_id"),
payload.get("options"),
deliver=True,
operation_id=operation["id"],
)
self.db.history(operation, "COMPLETED" if not result["errors"] else "FAILED")
def check_asset_references(self, asset_id):
reference = "%" + asset_id + "%"
if (
self.db.one(
"SELECT id FROM operations WHERE (payload LIKE ? OR result LIKE ?) AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
(reference, reference),
)
or self.db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
or self.db.one(
"SELECT template_id FROM template_config WHERE payload LIKE ?",
(reference,),
)
):
raise FlowError(
E.CONFLICT,
"Image is used by a task, template or pending operation",
409,
)
def remove_asset(self, asset_id):
self.service.assets.path(asset_id).unlink(missing_ok=True)
self.db.execute("DELETE FROM library_versions WHERE asset_id=?", (asset_id,))
self.db.execute("DELETE FROM terminal_deliveries WHERE asset_id=?", (asset_id,))
self.db.execute("DELETE FROM source_images WHERE id=?", (asset_id,))
for table, key in [
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
("assets", "id"),
]:
self.db.execute(f"DELETE FROM {table} WHERE {key}=?", (asset_id,))
self.db.execute(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
(asset_id,),
)
def delete_picture(self, picture_id):
with self.service.lock:
self.picture(picture_id)
ids = [
r["asset_id"]
for r in self.db.all(
"SELECT asset_id FROM library_versions WHERE source_image_id=?",
(picture_id,),
)
] + [picture_id]
for asset_id in ids:
self.check_asset_references(asset_id)
for asset_id in ids:
self.remove_asset(asset_id)
def check_source_delete(self, source_id):
if (
self.db.one(
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
(source_id,),
)
or self.db.one(
"SELECT id FROM library_plans WHERE source_id=? AND archived=0",
(source_id,),
)
or self.db.one(
"SELECT id FROM source_images WHERE source_id=?", (source_id,)
)
):
raise FlowError(
E.CONFLICT,
"Remove terminal bindings, generation plans and local source pictures first",
409,
)
+54 -6
View File
@@ -54,6 +54,9 @@ class DisplayService:
self.cloud_library = CloudLibrary(self)
self.recovery = RecoveryMonitor(self)
self.upload_reconciliation = UploadReconciliation(self)
from pigway_frame_flow.display.libraries import Libraries
self.libraries = Libraries(self)
self.expiration_checked: dict[str, float] = {}
self.unknown_checked: dict[str, float] = {}
# A killed write cannot be replayed safely; pre-write work can resume.
@@ -67,6 +70,14 @@ class DisplayService:
def validate(self, request, writing=True):
self.registry.get(request.device_id)
if (
writing
and isinstance(request.content, ImageContent)
and request.content.asset_id
):
self.libraries.validate_asset_binding(
request.device_id, request.content.asset_id
)
if request.options.thumbnail_plan_id:
from pigway_frame_flow.display.thumbnail_plans import get_plan
@@ -120,7 +131,14 @@ class DisplayService:
)
return existing["operation_id"]
if (
action not in ("display", "render", "render-ai", "local-upload")
action
not in (
"display",
"render",
"render-ai",
"local-upload",
"library-generate",
)
and tx.execute(
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
(device_id,),
@@ -281,6 +299,8 @@ class DisplayService:
*,
staging=False,
source_image=None,
source_only=False,
source_size=None,
):
if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare
@@ -323,10 +343,15 @@ class DisplayService:
return asset
if isinstance(content, PhotoSourceContent):
source = self.photo_sources.get(content.source_id)
display = (
self.registry.get(device_id or self.settings.device_id).device().display
)
size = frame_canvas(display, source.orientation)
if source_only:
size = source_size or (1200, 1600)
else:
display = (
self.registry.get(device_id or self.settings.device_id)
.device()
.display
)
size = frame_canvas(display, source.orientation)
import random
items = self.photo_sources.catalog(content.source_id, target_size=size)
@@ -406,6 +431,17 @@ class DisplayService:
),
),
)
if source_only:
selected = self.libraries.ingest(
content.source_id, item["id"], data, name, caption
)
attempt["status"] = "PREPARED"
if operation_id:
self.db.execute(
"UPDATE operations SET result=? WHERE id=?",
(encode({"photo_attempts": attempts}), operation_id),
)
return selected
rendered = (
render_captioned_photo(
data,
@@ -565,13 +601,20 @@ class DisplayService:
"render",
"render-ai",
"local-upload",
"library-generate",
) and self.blocked(op["device_id"]):
continue
payload = json.loads(op["payload"])
if (
op["action"] in ("display", "delete", "restore")
and op["client_id"]
in ("scheduler", "scheduler-retry", "cloud-library", "retention")
in (
"scheduler",
"scheduler-retry",
"library-terminal",
"cloud-library",
"retention",
)
and self.db.one(
"SELECT id FROM operations WHERE device_id=? AND action='display' "
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
@@ -633,6 +676,9 @@ class DisplayService:
def _execute(self, op, payload):
id, device_id, action = op["id"], op["device_id"], op["action"]
# A source run can generate watch versions without any frame driver.
if action == "library-generate":
return self.operation_handlers[action](op, payload)
driver = self.registry.get(device_id)
if action in self.operation_handlers:
return self.operation_handlers[action](op, payload)
@@ -787,6 +833,8 @@ class DisplayService:
get_plan(self, request.options.thumbnail_plan_id)
content = request.content
if isinstance(content, ImageContent) and content.asset_id:
self.libraries.validate_asset_binding(device_id, content.asset_id)
self.update(id, "RENDERING")
asset = None
source_image = {}
+13
View File
@@ -170,6 +170,10 @@ class ErrorResponse(BaseModel):
class OperationResult(BaseModel):
source_image_id: str | None = None
outputs: list[dict[str, Any]] = Field(default_factory=list)
errors: list[dict[str, Any]] = Field(default_factory=list)
wrist_thumbnail: dict[str, Any] | None = None
image_name: str | None = None
preview_url: str | None = None
cover_id: int | None = None
@@ -283,6 +287,7 @@ class RestoreContent(StrictModel):
class JobInput(StrictModel):
library_pipeline: bool = False
generate_thumbnail: bool = False
thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
cleanup_unstarred: bool = False
@@ -297,6 +302,14 @@ class JobInput(StrictModel):
@model_validator(mode="after")
def validate_options(self):
if self.library_pipeline and (
not isinstance(self.source, PhotoSourceContent)
or self.generate_thumbnail
or self.cleanup_unstarred
):
raise ValueError(
"Source-library tasks require a photo source and use terminal-bound generation plans"
)
if self.generate_thumbnail and (
not self.thumbnail_plan_id
or not isinstance(self.source, PhotoSourceContent)
+3 -1
View File
@@ -156,7 +156,9 @@ class Assets:
"job_id": meta["job_id"] if meta else None,
"job_name": meta["job_name"] if meta else None,
"asset_id": row["id"],
"source": "generated" if row["kind"] == "generated" else "uploaded",
"source": "generated"
if row["kind"] in ("generated", "renditions")
else "uploaded",
"name": row["name"],
"url": f"/api/v1/assets/{row['id']}",
"created_at": row["created_at"],
+7 -2
View File
@@ -17,6 +17,9 @@ def render_captioned_photo(
*,
size=(1200, 1600),
layout_provider=None,
centering=(0.5, 0.5),
quality=93,
respect_fit=False,
):
width, height = size
layout = (layout_provider or WatermarkLayout()).layout(size, caption)
@@ -31,9 +34,11 @@ def render_captioned_photo(
# keeping aspect ratio and cropping rather than stretching or letterboxing.
photo_data = convert(
data,
"smart" if fit == "smart" else "cover",
fit if respect_fit else ("smart" if fit == "smart" else "cover"),
profile,
size=(width, photo_height),
centering=centering,
quality=quality,
)
canvas = Image.new("RGB", size, "white")
with Image.open(BytesIO(photo_data)) as photo:
@@ -64,5 +69,5 @@ def render_captioned_photo(
anchor="rm",
)
out = BytesIO()
canvas.save(out, "JPEG", quality=93, optimize=True)
canvas.save(out, "JPEG", quality=quality, optimize=True)
return out.getvalue()
+57 -4
View File
@@ -15,6 +15,7 @@ from pigway_frame_flow.models import (
ImageContent,
JobInput,
Options,
PhotoSourceContent,
PlaylistInput,
RestoreContent,
)
@@ -86,17 +87,64 @@ class Scheduler:
row["last_result"] = operation["status"]
return rows
def save_job(self, job, id=None):
self.service.registry.get(job.device_id)
def validate_job(self, job):
if not job.library_pipeline:
self.service.registry.get(job.device_id)
if job.library_pipeline and not self.db.one(
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
(job.source.source_id,),
):
raise FlowError(
E.CONFLICT,
"Bind terminals to the source library before saving this task",
409,
{"reason": "libraryBindFirst"},
)
if job.generate_thumbnail:
from pigway_frame_flow.display.thumbnail_plans import get_plan
get_plan(self.service, job.thumbnail_plan_id)
if job.source.type != "restore":
if job.library_pipeline:
self.service.photo_sources.get(job.source.source_id)
elif job.source.type != "restore":
self.service.validate(
DisplayRequest(device_id=job.device_id, content=job.source),
writing=False,
)
def set_library_mode(self, job_id, enabled):
with self.service.lock:
row = self.db.one("SELECT payload FROM jobs WHERE id=?", (job_id,))
if not row:
raise FlowError(E.NOT_FOUND, "Job not found", 404)
values = json.loads(row["payload"])
if enabled and values.get("source", {}).get("type") != "photo_source":
raise FlowError(
E.INVALID_REQUEST,
"Only photo-source tasks support source-library mode",
422,
)
values["library_pipeline"] = enabled
if enabled:
values.update(
generate_thumbnail=False,
thumbnail_plan_id=None,
cleanup_unstarred=False,
)
job = JobInput.model_validate(values)
self.validate_job(job)
self.db.execute(
"UPDATE jobs SET payload=?,updated_at=? WHERE id=?",
(
encode(self.service.settings.sanitize(job.model_dump(mode="json"))),
now_iso(),
job_id,
),
)
return next(j for j in self.jobs() if j["id"] == job_id)
def save_job(self, job, id=None):
self.validate_job(job)
upcoming = next_run(job.schedule)
if job.enabled and upcoming is None:
raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future")
@@ -192,6 +240,8 @@ class Scheduler:
),
).model_dump(mode="json")
)
if job.library_pipeline:
action = "library-generate"
if action == "display":
payload = self.upload_retries.scheduled_payload(
row["id"], payload
@@ -199,7 +249,10 @@ class Scheduler:
operation_id = self.service.submit(
action,
payload,
job.device_id,
("source:" + job.source.source_id)
if job.library_pipeline
and isinstance(job.source, PhotoSourceContent)
else job.device_id,
"scheduler",
"schedule-" + uuid4().hex,
"job-" + row["id"] + "-" + slot,
+9 -7
View File
@@ -36,19 +36,20 @@ function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
window.addEventListener('resize',revealCurrentNavigation);
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'library-generate':['来源图库统一输出','Source-library outputs'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client==='library-terminal'?tr('来源图库输出','Source-library output'):client;}
function operationResultHTML(o){
const r=o.result||{},parts=[];
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
if(r.source_image_id)parts.push(`<div>${tr('已选定源画面','Source picture selected')}</div><small>${tr('输出:','Outputs: ')}${(r.outputs||[]).map(x=>esc(x.terminal_id==='watch'?tr('手表','Watch'):deviceNameForId(x.terminal_id))).join('、')||'—'}</small>`);
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
return parts.join('')||'—';
}
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=state.page==='gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='legacy-plans').map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=state.page==='gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
// A destination appears only when its connection and read/write adapter are available.
// Future cloud-drive destinations must implement their adapter before registration here.
const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']);
@@ -152,10 +153,10 @@ const views={
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
};
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,library_pipeline:j.library_pipeline||false,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{if($('#device').value==='@watch'){state.tab='watch';state.watchOffset=0;return navigate('gallery');}state.device=$('#device').value;if(state.page==='gallery'&&state.tab==='watch')state.tab='cloud';navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
@@ -177,15 +178,16 @@ async function jobEditor(job){
function updateScheduleField(){const type=$('#job-schedule').value;const interval=['hours','minutes'].includes(type);$('#schedule-value').min=interval?'1':'';$('#schedule-value').max=type==='hours'?'168':type==='minutes'?'10080':'';$('#schedule-value').step=interval?'1':'';$('#schedule-label').textContent=interval?tr('间隔数值','Interval'):type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=interval?'number':type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);}
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
updateScheduleField();
$('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,generate_thumbnail:$('#job-thumbnail').checked,thumbnail_plan_id:$('#job-thumbnail').checked?$('#job-thumbnail-plan').value:null,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();});
$('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,library_pipeline:$('#job-library-pipeline').checked,generate_thumbnail:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked,thumbnail_plan_id:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked?$('#job-thumbnail-plan').value:null,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();});
await jobSourceEditor(j);
await thumbnailJobFields(j);
await libraryJobFields(j,!job?.id);
}
views.automation=async epoch=>{
const jobs=await api('/api/v1/jobs');
if(epoch!==state.epoch)return;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('目标相框','Target frame'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('输出目标','Output target'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(job.library_pipeline?tr('按来源绑定终端','Source-bound terminals'):deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.library_pipeline&&job.last_result==='COMPLETED'?tr('生成与提交完成','Generated and submitted'):job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
bind('new-job',()=>jobEditor());
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
+1 -1
View File
@@ -182,7 +182,7 @@ function historyControls(){
const filters=state.historyFilters||{};
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['library-generate','display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
const wrap=$('#main .table-wrap');wrap.before(form);
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script><script src="/ui/libraries.js?v=0.1.29"></script></body></html>
+120
View File
@@ -0,0 +1,120 @@
'use strict';
// Keep older assets and APIs accessible without interpreting frame products as sources.
Object.assign(MESSAGES.zh,{'legacy-plans':'旧版小图方案',libraryOutputPartial:'部分终端输出失败,源画面与成功版本已保留,可单独重试失败目标。',libraryGenerateFirst:'请先按终端生成方案生成成品,源画面不能直接推送。',librarySourceMismatch:'方案不属于所选来源图库,请重新选择。',librarySizeMismatch:'方案尺寸与终端屏幕不匹配;相框使用设备屏幕尺寸,当前手表为 410×502。',libraryPlanBound:'此方案正在被终端使用,请先解除绑定或更换方案。',libraryBindFirst:'请先在终端图库中绑定此来源及其中的生成方案。',libraryBindingChanged:'终端绑定已改变,此版本不再匹配,已停止推送。'});Object.assign(MESSAGES.en,{'legacy-plans':'Legacy thumbnail plans',libraryOutputPartial:'Some outputs failed. Source pixels and successful versions are kept; retry failed targets individually.',libraryGenerateFirst:'Generate a terminal version before sending source pixels.',librarySourceMismatch:'The plan belongs to another source library.',librarySizeMismatch:'Plan dimensions must match the terminal screen. The current watch uses 410×502.',libraryPlanBound:'Unbind terminals or select another plan first.',libraryBindFirst:'Bind a terminal to this source and one of its plans first.',libraryBindingChanged:'The terminal binding changed; this version was not sent.'});
const legacyGalleryView=views.gallery;
const legacyPlanView=views.thumbnails;
delete icons.thumbnails;
icons['legacy-plans']='▧';
views['legacy-plans']=legacyPlanView;
const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0,legacy:false};
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 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('');}
function libraryLegacy(tab){libraryUI.legacy=true;state.tab=tab;return navigate('gallery');}
views.gallery=async function(epoch){
if(libraryUI.legacy){await legacyGalleryView(epoch);if(epoch!==state.epoch)return;const back=document.createElement('button');back.textContent=tr('返回新图库','Back to libraries');back.onclick=()=>{libraryUI.legacy=false;return navigate('gallery');};$('#main').prepend(back);return;}
const [sources,terminals]=await Promise.all([api('/api/v1/source-libraries'),api('/api/v1/library-terminals')]);
if(epoch!==state.epoch)return;
$('#main').innerHTML=`<div class="section-heading"><div class="tabs"><button id="library-source-tab" class="${libraryUI.kind==='source'?'active':''}">${tr('来源图库','Source libraries')}</button><button id="library-terminal-tab" class="${libraryUI.kind==='terminal'?'active':''}">${tr('终端图库','Terminal libraries')}</button></div><button id="library-refresh">${t('refresh')}</button></div><div id="library-content"></div>`;
bind('library-refresh',()=>navigate());
bind('library-source-tab',()=>{libraryUI.kind='source';libraryUI.offset=0;state.tab='local';return navigate();});
bind('library-terminal-tab',()=>{libraryUI.kind='terminal';libraryUI.offset=0;return navigate();});
if(libraryUI.kind==='source')await sourceLibraryPage(epoch,sources,terminals);else await terminalLibraryPage(epoch,sources,terminals);
};
async function sourceLibraryPage(epoch,sources,terminals){
const panel=$('#library-content');
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button><button id="library-legacy">${tr('查看历史本地成品','View legacy local images')}</button></section>`;bind('library-add-source',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local'));return;}
if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;}
const sourceId=libraryUI.source;
const [plans,page]=await Promise.all([api('/api/v1/generation-plans?source_id='+encodeURIComponent(sourceId)),api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/images?offset='+libraryUI.offset+'&limit=30')]);
if(epoch!==state.epoch)return;
if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null;
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"><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><button id="library-legacy">${tr('历史本地成品','Legacy local images')}</button></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><div class="tabs gallery-tabs library-plan-tabs"><button data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button 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><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p></section><div class="actions"><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 class="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>`;
}).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚未选入源画面。先从来源选入照片,再按方案生成。旧成品保留在“历史本地成品”。','No source pixels imported yet. Import a photo, then generate its versions. Older products remain under Legacy local images.')}</section>`}`;
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
document.querySelectorAll('[data-library-plan]').forEach(b=>b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();});
bind('library-import',()=>libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local'));
bind('library-new-plan',()=>generationPlanEditor(sourceId,null,page.items));bind('library-edit-plan',()=>generationPlanEditor(sourceId,plan,page.items));
bind('library-delete-plan',()=>confirmDialog(tr('删除此方案?已有生成版本保留参数快照;正在被终端使用的方案不能删除。','Delete this plan? Existing versions keep their snapshots. Bound plans cannot be deleted.'),async()=>{await api('/api/v1/generation-plans/'+plan.id,{method:'DELETE'});await navigate();}));
bind('library-run',async()=>{const result=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/run',{method:'POST',body:{deliver:false}});toast(result.errors.length?result.errors.map(e=>e.message).join('; '):tr('已从同一来源画面生成各终端版本,尚未推送','Generated terminal versions from one source picture; not sent'));libraryUI.offset=0;await navigate();});
bind('library-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('library-next',()=>{libraryUI.offset+=30;return navigate();});
const selected=i=>{const p=page.items[i];return plan?p.versions.find(v=>v.plan_id===plan.id):p;};
document.querySelectorAll('[data-library-preview]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryPreview);return openImageViewer(v.url,v.name);}));
document.querySelectorAll('[data-library-download]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryDownload);return downloadImage(v.url,v.name);}));
document.querySelectorAll('[data-library-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(selected(b.dataset.librarySettings)));
document.querySelectorAll('[data-library-generate]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/source-images/'+page.items[b.dataset.libraryGenerate].id+'/generate',{method:'POST',body:{plan_id:plan?.id||null}});await navigate();}));
document.querySelectorAll('[data-library-star]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryStar];await api('/api/v1/source-images/'+p.id+'/star',{method:'PUT',body:{starred:!p.starred}});await navigate();}));
document.querySelectorAll('[data-library-exclude]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryExclude];await api('/api/v1/source-images/'+p.id+'/exclusion',{method:'PUT',body:{excluded:!p.excluded}});await navigate();}));
document.querySelectorAll('[data-library-delete]').forEach(b=>b.onclick=()=>{const p=page.items[b.dataset.libraryDelete],v=selected(b.dataset.libraryDelete);if(plan&&!v)return;return confirmDialog(plan?tr('删除这个本地生成版本?不会删除源画面或设备上已接收的图片。','Delete this local version? Source pixels and device copies are kept.'):tr('删除本地源画面及其所有生成版本?不会删除照片数据源中的照片或设备副本。','Delete this local source picture and all generated versions? Provider photos and device copies are kept.'),async()=>{await api(plan?'/api/v1/library-versions/'+v.asset_id:'/api/v1/source-images/'+p.id,{method:'DELETE'});await navigate();});});
hydrateImages();
}
async function generationPlanEditor(sourceId,plan,pictures){
const oldPlans=plan?[]:await api('/api/v1/thumbnail-plans');
const p=plan||{name:'',width:410,height:502,fit:'cover',quality:85,center_x:0.5,center_y:0.5,caption:false,profile:'color'};
showModal(`<h2>${tr('来源图库生成方案','Source-library generation plan')}</h2><form id="generation-form">${!plan?`<div class="actions"><button type="button" data-generation-preset="frame-portrait">${tr('相框竖版','Portrait frame')}</button><button type="button" data-generation-preset="frame-landscape">${tr('相框横版','Landscape frame')}</button><button type="button" data-generation-preset="watch">${tr('手表','Watch')}</button></div>${oldPlans.length?`<div class="field"><label for="generation-legacy">${tr('从旧版小图方案复制参数(可选)','Copy legacy thumbnail settings (optional)')}</label><select id="generation-legacy">${librarySelectOptions(oldPlans,null,tr('不复制','Do not copy'))}</select></div>`:''}`:''}<div class="field"><label for="generation-name">${t('name')}</label><input id="generation-name" required maxlength="120" value="${esc(p.name)}"></div><div class="form-grid">${[['width',tr('宽度','Width'),16,2048,1],['height',tr('高度','Height'),16,2048,1],['quality',tr('JPEG 质量','JPEG quality'),40,95,1],['center_x',tr('水平裁切位置(0–1)','Crop X (0–1)'),0,1,0.01],['center_y',tr('垂直裁切位置(0–1)','Crop Y (0–1)'),0,1,0.01]].map(([key,label,min,max,step])=>`<div class="field"><label for="generation-${key}">${label}</label><input id="generation-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${p[key]}"></div>`).join('')}<div class="field"><label for="generation-fit">${tr('适配方式','Fit')}</label><select id="generation-fit"><option value="cover">${tr('等比裁切铺满','Proportional fill')}</option><option value="contain">${tr('完整画面,必要时补白','Full picture, white padding')}</option></select></div><div class="field"><label for="generation-profile">${t('profile')}</label><select id="generation-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div></div><label class="check-row"><input id="generation-caption" type="checkbox" ${p.caption?'checked':''}>${tr('叠加来源提供的日期与说明','Add date and caption supplied by the source')}</label><p class="hint">${tr('默认不拉伸、不补黑边。相框常用 1200×1600 或 1600×1200,当前手表为 410×502。绑定终端时校验尺寸。修改方案只影响后续生成,旧版本保留参数快照。','No stretching or black padding by default. Frames use 1200×1600 or 1600×1200; the current watch uses 410×502. Binding checks screen dimensions. Existing versions keep parameter snapshots.')}</p><div class="field"><label for="generation-picture">${tr('用本地源画面预览','Preview with a local source picture')}</label><select id="generation-picture">${librarySelectOptions(pictures.map(x=>({id:x.id,name:x.name})),pictures[0]?.id,tr('选择照片','Choose picture'))}</select></div><div id="generation-preview-box"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="button" id="generation-preview">${tr('预览','Preview')}</button><button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#generation-fit').value=p.fit;$('#generation-profile').value=p.profile;
const fill=values=>{for(const key of ['name','width','height','quality','center_x','center_y','fit'])$('#generation-'+key).value=values[key];$('#generation-caption').checked=values.caption||false;$('#generation-profile').value=values.profile||'color';};
document.querySelectorAll('[data-generation-preset]').forEach(b=>b.onclick=()=>{const type=b.dataset.generationPreset;fill({...p,name:type==='watch'?tr('手表','Watch'):type==='frame-portrait'?tr('相框竖版','Portrait frame'):tr('相框横版','Landscape frame'),width:type==='watch'?410:type==='frame-portrait'?1200:1600,height:type==='watch'?502:type==='frame-portrait'?1600:1200,caption:type!=='watch'});});
if($('#generation-legacy'))$('#generation-legacy').onchange=()=>{const old=oldPlans.find(p=>p.id===$('#generation-legacy').value);if(old)fill(old);};
const read=()=>({source_id:sourceId,name:$('#generation-name').value,width:Number($('#generation-width').value),height:Number($('#generation-height').value),quality:Number($('#generation-quality').value),center_x:Number($('#generation-center_x').value),center_y:Number($('#generation-center_y').value),fit:$('#generation-fit').value,profile:$('#generation-profile').value,caption:$('#generation-caption').checked});
bind('generation-preview',async()=>{if(!$('#generation-form').reportValidity())return;const id=$('#generation-picture').value;if(!id)throw new Error(tr('请先选入源画面','Import a source picture first'));const blob=await api('/api/v1/source-images/'+id+'/preview',{method:'POST',body:read(),binary:true});if(!$('#generation-preview-box'))return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#generation-preview-box').innerHTML=`<img alt="${tr('生成预览','Generation preview')}" style="max-width:100%;max-height:360px" src="${url}">`;});
$('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();});
}
async function libraryImportDialog(sourceId){
showModal(`<h2>${tr('从照片来源选入','Import from photo source')}</h2><p class="hint">${tr('保存无叠加的源画面,不会修改来源照片。','Stores undecorated source pixels without modifying provider photos.')}</p><div class="field"><label for="library-import-item">${tr('选择照片','Choose picture')}</label><select id="library-import-item"></select></div><div class="actions"><button id="library-import-prev">${tr('上一页','Previous')}</button><button id="library-import-next">${tr('下一页','Next')}</button></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="library-import-confirm" class="primary">${tr('选入本地','Import locally')}</button></div>`);
let offset=0;
async function load(){const page=await api('/api/v1/photo-sources/'+encodeURIComponent(sourceId)+'/images?limit=50&offset='+offset);if(!$('#library-import-item'))return;$('#library-import-item').innerHTML=librarySelectOptions(page.items,page.items[0]?.id,tr('请选择','Choose'));$('#library-import-prev').disabled=offset===0;$('#library-import-next').disabled=offset+50>=page.total;}
bind('library-import-prev',async()=>{offset=Math.max(0,offset-50);await load();});bind('library-import-next',async()=>{offset+=50;await load();});
bind('library-import-confirm',async()=>{const item=$('#library-import-item').value;if(!item)return;await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/import',{method:'POST',body:{item_id:item}});$('#modal').close();libraryUI.offset=0;await navigate();});await load();
}
async function terminalLibraryPage(epoch,sources,terminals){
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
const terminal=terminals.find(t=>t.id===libraryUI.terminal)||terminals[0];libraryUI.terminal=terminal.id;state.tab=terminal.id==='watch'?'watch':'cloud';if(terminal.id!=='watch')state.device=terminal.id;shell();
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="field"><label for="library-terminal">${tr('终端','Terminal')}</label><select id="library-terminal">${terminals.map(t=>`<option value="${esc(t.id)}" ${t.id===terminal.id?'selected':''}>${esc(terminalLabel(t))}</option>`).join('')}</select></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>`:''}<button id="library-old-terminal">${terminal.kind==='watch'?tr('旧版小图图库','Legacy watch gallery'):tr('设备已有云端图片','Existing device cloud images')}</button></div></section><div class="actions"><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><div class="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}" ${!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')}">${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>`}`;
$('#library-terminal').onchange=()=>{libraryUI.terminal=$('#library-terminal').value;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('解除终端绑定?已有图片保留,后续来源任务不再向此终端输出。','Unbind this terminal? 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.kind='source';libraryUI.offset=0;state.tab='local';return navigate();});
bind('library-old-terminal',()=>{if(terminal.id!=='watch')state.device=terminal.id;return libraryLegacy(terminal.id==='watch'?'watch':'cloud');});
bind('terminal-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('terminal-next',()=>{libraryUI.offset+=30;return navigate();});
document.querySelectorAll('[data-terminal-preview]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalPreview];return openImageViewer(v.url,v.name);}));
document.querySelectorAll('[data-terminal-download]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalDownload];return downloadImage(v.url,v.name);}));
document.querySelectorAll('[data-terminal-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(page.items[b.dataset.terminalSettings]));
document.querySelectorAll('[data-terminal-operation]').forEach(b=>b.onclick=()=>{state.historyFilters={q:page.items[b.dataset.terminalOperation].operation.operation_id};state.historyPage=1;return navigate('history');});
document.querySelectorAll('[data-terminal-send]').forEach(b=>b.onclick=()=>{const v=page.items[b.dataset.terminalSend];return confirmDialog(tr('将此版本推送到终端并显示?','Send and display this version on the terminal?'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images/'+v.asset_id+'/send',{method:'POST'});toast(t('accepted'));await navigate();});});hydrateImages();
}
async function terminalBindingEditor(terminal,sources){
showModal(`<h2>${esc(terminalLabel(terminal))} · ${tr('来源与生成方案','Source and generation plan')}</h2><form id="terminal-binding-form"><div class="field"><label for="binding-source">${tr('来源图库(单选)','Source library (one)')}</label><select id="binding-source" required>${librarySelectOptions(sources,terminal.binding?.source_id,tr('请选择来源','Choose source'))}</select></div><div class="field"><label for="binding-plan">${tr('该来源内的生成方案(单选)','Generation plan in this source (one)')}</label><select id="binding-plan" required></select></div><p class="hint">${tr('切换来源必须重新选择方案。方案可被多个终端共用,但尺寸必须匹配各终端屏幕。请先在来源图库中创建方案。','Changing source clears the plan selection. Terminals may share a plan when screen dimensions match. Create plans in the source library first.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);
let request=0;
async function load(selected){const source=$('#binding-source').value,ticket=++request;$('#binding-plan').innerHTML='<option value="">—</option>';if(!source)return;const plans=await api('/api/v1/generation-plans?source_id='+encodeURIComponent(source));if(ticket!==request||!$('#binding-plan'))return;$('#binding-plan').innerHTML=librarySelectOptions(plans.filter(p=>!p.archived),selected,tr('请选择生成方案','Choose generation plan'));}
$('#binding-source').onchange=busy(()=>load(null));$('#terminal-binding-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'PUT',body:{source_id:$('#binding-source').value,plan_id:$('#binding-plan').value}});$('#modal').close();await navigate();});await load(terminal.binding?.plan_id);
}
$('#device').onchange=()=>{const id=$('#device').value;if(id==='@watch'||state.page==='gallery'){libraryUI.kind='terminal';libraryUI.terminal=id==='@watch'?'watch':id;libraryUI.offset=0;libraryUI.legacy=false;state.tab=id==='@watch'?'watch':'cloud';if(id!=='@watch')state.device=id;return navigate('gallery');}state.device=id;return navigate();};
async function libraryJobFields(job,isNew){
const box=document.createElement('section');box.innerHTML=`<label class="check-row"><input id="job-library-pipeline" type="checkbox" ${job.library_pipeline||(isNew&&job.source?.type==='photo_source')?'checked':''}>${tr('按来源图库统一生成并推送','Generate and send through source-library bindings')}</label><p class="hint">${tr('选一次照片并保存源画面,按来源内的终端绑定分别生成;同一方案共用成品。相框继续走上传和显示确认,手表仅生成。此模式不再单独选择“小图方案”。旧任务默认保留原模式,勾选后切换。','Select one photo, save source pixels and generate each bound plan. Terminals sharing a plan share its version. Frames upload and confirm display; watches generate only. Legacy tasks keep their old mode until enabled.')}</p>`;
$('#job-form .modal-actions').before(box);
const kind=$('#job-content-type'),pipeline=$('#job-library-pipeline');
const update=()=>{const supported=kind.value==='photo_source';box.hidden=!supported;$('#legacy-thumbnail-job-fields').hidden=!supported||pipeline.checked;pipeline.disabled=!supported;if(!supported)pipeline.checked=false;if(pipeline.checked){$('#job-thumbnail').checked=false;$('#job-thumbnail').disabled=true;$('#job-thumbnail-plan').required=false;$('#job-thumbnail-plan').disabled=true;}else{ $('#job-thumbnail').disabled=!supported;}};
kind.addEventListener('change',()=>{if(isNew&&kind.value==='photo_source')pipeline.checked=true;update();});pipeline.onchange=update;update();
}
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>`);
}
+2 -2
View File
@@ -26,7 +26,7 @@ views.thumbnails=async function(epoch){
async function thumbnailJobFields(job){
const plans=await api('/api/v1/thumbnail-plans');if(!$('#job-form'))return;
const box=document.createElement('section');box.innerHTML=`<label class="check-row"><input type="checkbox" id="job-thumbnail" ${job.generate_thumbnail?'checked':''}>${tr('生成小图','Generate thumbnail')}</label><div class="field"><label for="job-thumbnail-plan">${tr('小图生成方案(单选)','Thumbnail plan (choose one)')}</label><select id="job-thumbnail-plan"><option value="">${tr('请选择已保存方案','Choose a saved plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}" ${job.thumbnail_plan_id===p.id?'selected':''}>${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select></div><p class="hint">${tr('仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。','For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.')}</p>`;
const box=document.createElement('section');box.id='legacy-thumbnail-job-fields';box.innerHTML=`<label class="check-row"><input type="checkbox" id="job-thumbnail" ${job.generate_thumbnail?'checked':''}>${tr('生成小图','Generate thumbnail')}</label><div class="field"><label for="job-thumbnail-plan">${tr('小图生成方案(单选)','Thumbnail plan (choose one)')}</label><select id="job-thumbnail-plan"><option value="">${tr('请选择已保存方案','Choose a saved plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}" ${job.thumbnail_plan_id===p.id?'selected':''}>${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select></div><p class="hint">${tr('仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。','For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.')}</p>`;
$('#job-form .modal-actions').before(box);
const update=()=>{const supported=$('#job-content-type')?.value==='photo_source';$('#job-thumbnail').disabled=!supported;if(!supported)$('#job-thumbnail').checked=false;$('#job-thumbnail-plan').disabled=!$('#job-thumbnail').checked;$('#job-thumbnail-plan').required=$('#job-thumbnail').checked;};
$('#job-thumbnail').onchange=update;$('#job-content-type')?.addEventListener('change',update);update();
@@ -39,7 +39,7 @@ async function watchGalleryPage(epoch,destinations){
const watch=devices.find(d=>d.id==='watch');
$('#main').innerHTML=`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button data-watch-tab="${esc(d.id)}" class="${d.id==='watch'?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><button id="watch-refresh">${t('refresh')}</button></div><section class="card"><div class="section-heading"><div><h2>${tr('手表','Watch')} · ${watch.width} × ${watch.height}</h2><p class="hint">${tr('独立小图设备 · 图片已保存在服务器;设备传输尚未接入。','Separate thumbnail device · Images are saved on the server; device transfer is not connected.')}</p></div><button id="watch-plans" class="primary">${tr('管理小图方案 / 生成图片','Manage plans / Generate images')}</button></div><div class="actions"><button id="watch-prev" ${offset===0?'disabled':''}>${tr('上一页','Previous page')}</button><button id="watch-next" ${offset+page.limit>=page.total?'disabled':''}>${tr('下一页','Next page')}</button><span class="hint">${tr('共','Total')} ${page.total} ${tr('张小图','thumbnails')}</span></div></section><div class="gallery">${page.items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-watch-preview="${i}" aria-label="${tr('预览','Preview')} · ${esc(x.name)}"><img class="thumb" data-src="${esc(x.url)}" alt="${esc(x.name)}" style="aspect-ratio:${x.thumbnail.width}/${x.thumbnail.height};object-fit:contain"></button><h3>${esc(x.name)}</h3><small>${x.thumbnail.width} × ${x.thumbnail.height} · ${esc(x.thumbnail.plan?.name||tr('默认手表方案','Default watch plan'))}</small><small>${esc(fmt(x.thumbnail.generated_at||x.created_at))}</small><div class="actions"><button data-watch-preview="${i}">${tr('预览','Preview')}</button><button data-watch-download="${i}">${tr('下载图片','Download image')}</button><button class="danger" data-watch-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty"><strong>${tr('还没有小图','No thumbnails yet')}</strong><p>${tr('在“小图方案”中生成,或为照片任务勾选生成小图。旧版手动生成的小图仍在原本地图库。','Generate one in Thumbnail plans or enable thumbnails on a photo task. Older manually generated images remain in the original local gallery.')}</p></section>`}`;
document.querySelectorAll('[data-watch-tab]').forEach(b=>b.onclick=()=>{state.tab=b.dataset.watchTab;return navigate('gallery');});
bind('watch-refresh',()=>navigate());bind('watch-plans',()=>navigate('thumbnails'));
bind('watch-refresh',()=>navigate());bind('watch-plans',()=>navigate('legacy-plans'));
bind('watch-prev',()=>{state.watchOffset=Math.max(0,offset-50);return navigate();});bind('watch-next',()=>{state.watchOffset=offset+50;return navigate();});
document.querySelectorAll('[data-watch-preview]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchPreview];return openImageViewer(x.url,x.name);}));
document.querySelectorAll('[data-watch-download]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchDownload];return downloadImage(x.url,x.name);}));
+199
View File
@@ -0,0 +1,199 @@
"""Opt-in migration of an unambiguous single photo-source task; no device writes."""
import argparse
import json
import os
import urllib.error
import urllib.request
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
from pigway_frame_flow.settings import Settings
def migrate(call, apply=False):
jobs = call("GET", "/api/v1/jobs")
candidates = [j for j in jobs if j["source"]["type"] == "photo_source"]
sources = call("GET", "/api/v1/source-libraries")
if len(candidates) != 1 or len(sources) != 1:
raise RuntimeError(
"Migration requires exactly one photo task and one source; configure ambiguous bindings in the UI"
)
job, source = candidates[0], sources[0]
if job.get("library_pipeline"):
return {"already_configured": True}
if job["source"]["source_id"] != source["id"] or job["source"].get("item_id"):
raise RuntimeError(
"Migration requires a random photo task from the sole source"
)
if job.get("next_run") and datetime.fromisoformat(job["next_run"]) <= datetime.now(
UTC
) + timedelta(minutes=2):
raise RuntimeError(
"Task is due soon; retry after it completes without changing its schedule"
)
for status in ["QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN"]:
if call("GET", "/api/v1/operation-history?status=" + status + "&page_size=1")[
"total"
]:
raise RuntimeError(
"Pending operations exist; resolve them before migrating"
)
terminals = call("GET", "/api/v1/library-terminals")
terminal_ids = {t["id"] for t in terminals}
if job["device_id"] not in terminal_ids or "watch" not in terminal_ids:
raise RuntimeError("The task frame and watch must both exist")
device = call("GET", "/api/v1/devices/" + job["device_id"])
width, height = frame_canvas(
SimpleNamespace(**device["display"]), source.get("orientation")
)
frame = {
"source_id": source["id"],
"name": "现有相框方案",
"width": width,
"height": height,
"fit": "cover"
if source["folder_caption"]
else job["source"].get("fit", "contain"),
"caption": source["folder_caption"],
"profile": job["source"].get("profile", "color"),
"quality": 93,
"center_x": 0.5,
"center_y": 0.5,
}
if frame["fit"] == "smart" or job["source"].get("fit") == "smart":
raise RuntimeError("Smart crop requires an explicit generation-plan choice")
watch = {
"source_id": source["id"],
"name": "手表默认方案",
"width": 410,
"height": 502,
"fit": "cover",
"caption": False,
"profile": "color",
"quality": 85,
"center_x": 0.5,
"center_y": 0.5,
}
if job.get("thumbnail_plan_id"):
previous = call("GET", "/api/v1/thumbnail-plans/" + job["thumbnail_plan_id"])
if (previous["width"], previous["height"]) != (410, 502):
raise RuntimeError(
"The previous thumbnail plan does not match the watch screen"
)
watch.update(
{
k: previous[k]
for k in ["width", "height", "fit", "quality", "center_x", "center_y"]
}
)
plans = call("GET", "/api/v1/generation-plans?source_id=" + source["id"])
expected_names = {frame["name"], watch["name"]}
if any(not p["archived"] and p["name"] not in expected_names for p in plans):
raise RuntimeError(
"Custom generation plans already exist; configure bindings explicitly"
)
known = {}
for target in [frame, watch]:
match = [p for p in plans if not p["archived"] and p["name"] == target["name"]]
if match and any(match[0].get(k) != v for k, v in target.items()):
raise RuntimeError(
"Existing migration plan parameters differ; configure bindings explicitly"
)
if match:
known[target["name"]] = match[0]
for terminal in terminals:
if terminal["id"] in [job["device_id"], "watch"] and terminal["binding"]:
expected = known.get(
frame["name"] if terminal["id"] == job["device_id"] else watch["name"]
)
if (
not expected
or terminal["binding"]["source_id"] != source["id"]
or terminal["binding"]["plan_id"] != expected["id"]
):
raise RuntimeError("A terminal already has another binding")
if not apply:
return {
"ready": True,
"frame_size": [width, height],
"watch_size": [410, 502],
"next_run": job["next_run"],
}
for target in [frame, watch]:
if target["name"] not in known:
known[target["name"]] = call("POST", "/api/v1/generation-plans", target)
for terminal_id, target in [(job["device_id"], frame), ("watch", watch)]:
call(
"PUT",
"/api/v1/library-terminals/" + terminal_id + "/binding",
{"source_id": source["id"], "plan_id": known[target["name"]]["id"]},
)
migrated = call(
"PUT", "/api/v1/jobs/" + job["id"] + "/library-mode", {"enabled": True}
)
if (
migrated["next_run"] != job["next_run"]
or migrated["enabled"] != job["enabled"]
or migrated["schedule"] != job["schedule"]
):
raise RuntimeError("Task scheduling changed concurrently; review the task")
seeded = False
try:
current = call(
"GET", "/api/v1/devices/" + job["device_id"] + "/current-content"
)
origin = current.get("source") or {}
if (
current.get("source_known")
and origin.get("type") == "photo_source"
and origin.get("source_id") == source["id"]
and origin.get("item_id")
):
picture = call(
"POST",
"/api/v1/source-libraries/" + source["id"] + "/import",
{"item_id": origin["item_id"]},
)
call("POST", "/api/v1/source-images/" + picture["id"] + "/generate", {})
seeded = True
except urllib.error.HTTPError:
# Current pixels may no longer be available upstream. The binding and
# schedule remain valid; never guess a substitute or write to a device.
pass
return {
"migrated": True,
"terminals_bound": 2,
"current_source_generated_locally": seeded,
"next_run": migrated["next_run"],
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
settings = Settings()
origin = os.environ.get(
"FRAME_FLOW_MIGRATION_URL", "http://127.0.0.1:18787"
).rstrip("/")
def call(method, path, payload=None):
request = urllib.request.Request(
origin + path,
method=method,
headers={
"Authorization": "Bearer " + settings.api_token,
"Content-Type": "application/json",
},
data=json.dumps(payload).encode() if payload is not None else None,
)
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response) if response.status != 204 else None
print(json.dumps(migrate(call, args.apply), ensure_ascii=False))
if __name__ == "__main__":
main()
+314
View File
@@ -1648,5 +1648,319 @@
"502",
"503"
]
},
"/api/v1/source-libraries": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/images": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/import": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-libraries/{source_id}/run": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/generation-plans": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/generation-plans/{plan_id}": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/generate": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/preview": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/star": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}/exclusion": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/source-images/{picture_id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-versions/{asset_id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals/{terminal_id}/binding": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals/{terminal_id}/images": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/jobs/{job_id}/library-mode": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
}
}
+39
View File
@@ -798,6 +798,45 @@
"type": "boolean",
"title": "Deferred",
"default": false
},
"source_image_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Image Id"
},
"outputs": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Outputs"
},
"errors": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Errors"
},
"wrist_thumbnail": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Wrist Thumbnail"
}
},
"type": "object",
+649
View File
@@ -0,0 +1,649 @@
import hashlib
from datetime import UTC, datetime
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
@pytest.fixture
def library_source(service, settings, legacy_photo_engine):
root = settings.data_dir.parent / "photos"
root.mkdir()
settings.photo_root = root
Image.new("RGB", (900, 600), (25, 100, 220)).save(root / "photo.jpg")
for source_id in ["family", "travel"]:
service.photo_sources.save(
source_id, PhotoSourceInput(name=source_id, kind="local", location=".")
)
return "family"
def create_plan(
client, source="family", name="Watch", width=410, height=502, **changes
):
response = client.post(
"/api/v1/generation-plans",
json={
"source_id": source,
"name": name,
"width": width,
"height": height,
**changes,
},
)
assert response.status_code == 200, response.text
return response.json()
def bind(client, terminal, source, plan):
response = client.put(
"/api/v1/library-terminals/" + terminal + "/binding",
json={"source_id": source, "plan_id": plan["id"]},
)
assert response.status_code == 200, response.text
def import_picture(client):
response = client.post(
"/api/v1/source-libraries/family/import", json={"item_id": "photo.jpg"}
)
assert response.status_code == 200, response.text
return response.json()
def test_single_binding_and_cross_library_guards(client, service, library_source):
first = create_plan(client)
other = create_plan(client, "travel")
bind(client, "watch", "family", first)
assert (
client.put(
"/api/v1/library-terminals/watch/binding",
json={"source_id": "travel", "plan_id": first["id"]},
).status_code
== 409
)
bind(client, "watch", "travel", other)
assert service.db.one("SELECT COUNT(*) AS n FROM terminal_bindings")["n"] == 1
assert client.delete("/api/v1/generation-plans/" + other["id"]).status_code == 409
assert client.delete("/api/v1/photo-sources/travel").status_code == 409
assert (
client.put(
"/api/v1/library-terminals/living-room/binding",
json={"source_id": "family", "plan_id": first["id"]},
).status_code
== 422
)
assert (
client.put(
"/api/v1/generation-plans/" + other["id"],
json={
k: v
for k, v in {**other, "width": 400}.items()
if k not in ("id", "archived")
},
).status_code
== 422
)
assert (
client.put(
"/api/v1/generation-plans/" + first["id"],
json={"name": "Move", "source_id": "travel"},
).status_code
== 409
)
def test_original_and_versions_share_picture_with_immutable_snapshots(
client, service, library_source
):
picture = import_picture(client)
assert import_picture(client)["id"] == picture["id"]
first = create_plan(client)
create_plan(client, name="Frame", width=1200, height=1600)
versions = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate", json={}
).json()
assert len(versions) == 2
assert all(v["source"] == "generated" for v in versions)
assert all(picture["name"].removesuffix(".jpg") in v["name"] for v in versions)
assert len({v["source_image_id"] for v in versions}) == 1
repeated = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate", json={}
).json()
assert {v["asset_id"] for v in versions} == {v["asset_id"] for v in repeated}
payload = {k: v for k, v in first.items() if k not in ("id", "archived")}
assert (
client.put(
"/api/v1/generation-plans/" + first["id"], json={**payload, "quality": 70}
).status_code
== 200
)
updated = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": first["id"]},
).json()[0]
assert updated["asset_id"] not in {v["asset_id"] for v in versions}
assert updated["plan"]["quality"] == 70
assert (
next(v for v in versions if v["plan_id"] == first["id"])["plan"]["quality"]
== 85
)
other = create_plan(client, "travel")
assert (
client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": other["id"]},
).status_code
== 409
)
assert client.get("/api/v1/assets").json() == []
assert client.delete("/api/v1/assets/" + picture["id"]).status_code == 409
assert (
client.post(
"/api/v1/devices/living-room/display",
json={"content": {"type": "image", "asset_id": picture["id"]}},
).status_code
== 409
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/star", json={"starred": True}
).status_code
== 200
)
assert (
client.put(
"/api/v1/source-images/" + picture["id"] + "/exclusion",
json={"excluded": True},
).status_code
== 409
)
assert client.delete("/api/v1/source-images/" + picture["id"]).status_code == 204
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 0
assert (service.settings.photo_root / "photo.jpg").exists()
def test_pipeline_selects_once_for_frame_and_watch_and_shared_plan(
client, service, library_source, monkeypatch
):
frame = create_plan(client, name="Frame", width=1200, height=1600, caption=True)
watch = create_plan(client)
service.registry.drivers["second-frame"] = service.registry.get("living-room")
for terminal in ["living-room", "second-frame"]:
bind(client, terminal, "family", frame)
bind(client, "watch", "family", watch)
def unavailable_frame(*args, **kwargs):
raise AssertionError("Source generation must not read a frame device")
monkeypatch.setattr(
service.registry.get("living-room"), "device", unavailable_frame
)
read = service.photo_sources.read
calls = []
def source_read(*args, **kwargs):
calls.append(1)
data, name, _ = read(*args, **kwargs)
return data, name, {"title": "Caption", "date": "2026-10-23"}
monkeypatch.setattr(service.photo_sources, "read", source_read)
result = client.post("/api/v1/source-libraries/family/run", json={}).json()
assert result["errors"] == []
assert len(calls) == 1
assert len(result["outputs"]) == 3
assert len({v["asset_id"] for v in result["outputs"]}) == 2
for terminal in ["living-room", "watch"]:
page = client.get("/api/v1/library-terminals/" + terminal + "/images").json()
assert page["total"] == 1
version = page["items"][0]
with Image.open(BytesIO(client.get(version["url"]).content)) as image:
if terminal == "watch":
assert image.size == (410, 502)
assert image.getpixel((200, 490))[2] > 200
else:
assert image.getpixel((10, 1590))[0] > 240
assert client.post(
"/api/v1/library-terminals/watch/images/"
+ result["outputs"][-1]["asset_id"]
+ "/send"
).status_code in (409, 422)
assert service.db.one("SELECT COUNT(*) AS n FROM operations")["n"] == 0
def test_scheduled_pipeline_links_independent_delivery_and_rechecks_binding(
client, app, service, library_source, monkeypatch
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
watch = create_plan(client)
bind(client, "living-room", "family", frame)
bind(client, "watch", "family", watch)
saved = client.post(
"/api/v1/jobs",
json={
"name": "Shared source",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
).json()
now = datetime.now(UTC)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved["id"])
)
app.state.scheduler.tick(now)
root = service.db.one("SELECT id FROM operations WHERE action='library-generate'")
assert root and service.process_one()
op = client.get("/api/v1/operations/" + root["id"]).json()
assert op["status"] == "COMPLETED"
assert len(op["result"]["outputs"]) == 2
child = service.db.one(
"SELECT id FROM operations WHERE client_id='library-terminal'"
)
assert child
assert (
service.db.one("SELECT parent_operation_id FROM terminal_deliveries")[
"parent_operation_id"
]
== root["id"]
)
client.delete("/api/v1/library-terminals/living-room/binding")
writes = []
monkeypatch.setattr(
service.registry.get("living-room"), "upload", lambda *args: writes.append(args)
)
assert service.process_one()
assert service.operation(child["id"])["status"] == "FAILED"
assert writes == []
assert client.get("/api/v1/library-terminals/watch/images").json()["total"] == 1
def test_preview_is_unsaved_and_references_prevent_deletion(
client, service, library_source
):
picture = import_picture(client)
plan = create_plan(client, name="Frame", width=1200, height=1600)
payload = {k: v for k, v in plan.items() if k not in ("id", "archived")}
assert (
client.post(
"/api/v1/source-images/" + picture["id"] + "/preview", json=payload
).status_code
== 200
)
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 0
bind(client, "living-room", "family", plan)
version = client.post(
"/api/v1/source-images/" + picture["id"] + "/generate",
json={"plan_id": plan["id"]},
).json()[0]
assert (
client.post(
"/api/v1/library-terminals/living-room/images/"
+ version["asset_id"]
+ "/send"
).status_code
== 200
)
assert client.delete("/api/v1/source-images/" + picture["id"]).status_code == 409
def test_resumed_source_run_reuses_saved_picture_plans_and_delivery(
client, service, library_source, monkeypatch
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", frame)
root = service.submit(
"library-generate",
{"content": {"type": "photo_source", "source_id": "family"}, "options": {}},
"living-room",
"scheduler",
"resume-test",
)
assert service.process_one()
previous = service.operation(root)["result"]
child = previous["outputs"][0]["operation_id"]
service.db.execute("UPDATE operations SET status='COMPLETED' WHERE id=?", (child,))
update = {k: v for k, v in frame.items() if k not in ("id", "archived")}
assert (
client.put(
"/api/v1/generation-plans/" + frame["id"], json={**update, "quality": 60}
).status_code
== 200
)
service.db.execute("UPDATE operations SET status='QUEUED' WHERE id=?", (root,))
def unexpected_read(*args, **kwargs):
raise AssertionError("Resumed run must not select a new photo")
monkeypatch.setattr(service.photo_sources, "read", unexpected_read)
service.settings.mock = False
service.settings.write_enabled = False
assert service.process_one()
current = service.operation(root)["result"]
assert current["source_image_id"] == previous["source_image_id"]
assert current["outputs"][0]["operation_id"] == child
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1
assert (
service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='display'")[
"n"
]
== 1
)
def test_one_plan_failure_preserves_other_output_and_source(
client, service, library_source, monkeypatch
):
from pigway_frame_flow.errors import ErrorCode, FlowError
frame = create_plan(client, name="Frame", width=1200, height=1600)
watch = create_plan(client)
bind(client, "living-room", "family", frame)
bind(client, "watch", "family", watch)
generate = service.libraries.generate
def fail_frame(picture_id, plan_id, *args, **kwargs):
if plan_id == frame["id"]:
raise FlowError(ErrorCode.INVALID_REQUEST, "Frame render failed")
return generate(picture_id, plan_id, *args, **kwargs)
monkeypatch.setattr(service.libraries, "generate", fail_frame)
root = service.submit(
"library-generate",
{"content": {"type": "photo_source", "source_id": "family"}, "options": {}},
"living-room",
"scheduler",
"partial-test",
)
assert service.process_one()
op = client.get("/api/v1/operations/" + root).json()
assert op["status"] == "FAILED"
assert len(op["result"]["errors"]) == 1 and len(op["result"]["outputs"]) == 1
assert op["result"]["outputs"][0]["terminal_id"] == "watch"
picture_id = op["result"]["source_image_id"]
monkeypatch.setattr(service.libraries, "generate", generate)
assert (
client.post(
"/api/v1/source-images/" + picture_id + "/generate",
json={"plan_id": frame["id"]},
).status_code
== 200
)
assert service.db.one("SELECT COUNT(*) AS n FROM source_images")["n"] == 1
def test_successful_scheduled_frame_delivery_retains_job_provenance(
client, app, service, library_source
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", frame)
saved = client.post(
"/api/v1/jobs",
json={
"name": "Library job",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
).json()
now = datetime.now(UTC)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved["id"])
)
app.state.scheduler.tick(now)
assert service.process_one() and service.process_one()
assert (
service.db.one("SELECT job_id FROM managed_cloud_uploads")["job_id"]
== saved["id"]
)
service.sync()
page = client.get("/api/v1/library-terminals/living-room/images").json()
assert page["items"][0]["delivery_status"] == "COMPLETED"
from pigway_frame_flow.storage.database import encode, now_iso
token = "library-reader-test"
service.db.execute(
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
(
"reader",
hashlib.sha256(token.encode()).hexdigest(),
"Reader",
encode(["images:read", "devices:read"]),
now_iso(),
None,
1,
),
)
public = client.get(
"/api/v1/library-terminals/living-room/images",
headers={"Authorization": "Bearer " + token},
)
assert public.status_code == 200
summary = public.json()["items"][0]["operation"]
assert (
"request_id" not in summary
and "client_id" not in summary
and "error" not in summary
)
assert (
client.get(
"/api/v1/operations/" + summary["operation_id"],
headers={"Authorization": "Bearer " + token},
).status_code
== 403
)
def test_caption_plan_respects_full_picture_fit(client, service, library_source):
picture = import_picture(client)
service.db.execute(
"UPDATE source_images SET caption=? WHERE id=?",
('{"title":"Caption"}', picture["id"]),
)
payload = {
"source_id": "family",
"name": "Caption",
"width": 300,
"height": 400,
"caption": True,
"fit": "contain",
}
response = client.post(
"/api/v1/source-images/" + picture["id"] + "/preview", json=payload
)
assert response.status_code == 200
with Image.open(BytesIO(response.content)) as image:
assert image.getpixel((150, 0))[0] > 240
response = client.post(
"/api/v1/source-images/" + picture["id"] + "/preview",
json={**payload, "fit": "cover"},
)
with Image.open(BytesIO(response.content)) as image:
assert image.getpixel((150, 0))[0] < 60
def test_shared_version_keeps_separate_delivery_receipts_for_each_job(
client, app, service, library_source
):
frame = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", frame)
job_ids = []
for name in ["Morning", "Evening"]:
job = client.post(
"/api/v1/jobs",
json={
"name": name,
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
).json()
job_ids.append(job["id"])
now = datetime.now(UTC)
service.db.execute("UPDATE jobs SET next_run=?", (now.isoformat(),))
app.state.scheduler.tick(now)
assert service.process_one() and service.process_one()
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1
assert service.db.one("SELECT COUNT(*) AS n FROM terminal_deliveries")["n"] == 2
assert service.process_one()
service.sync()
assert service.process_one()
service.sync()
assert {
r["job_id"] for r in service.db.all("SELECT job_id FROM managed_cloud_uploads")
} == set(job_ids)
page = client.get("/api/v1/library-terminals/living-room/images").json()
assert page["total"] == 1 and page["items"][0]["delivery_status"] == "COMPLETED"
def test_watch_only_source_task_does_not_require_a_frame_driver(
client, app, service, library_source
):
plan = create_plan(client)
bind(client, "watch", "family", plan)
service.registry.drivers.clear()
response = client.post(
"/api/v1/jobs",
json={
"name": "Watch only",
"device_id": "watch",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "photo_source", "source_id": "family"},
"library_pipeline": True,
},
)
assert response.status_code == 201
now = datetime.now(UTC)
service.db.execute("UPDATE jobs SET next_run=?", (now.isoformat(),))
app.state.scheduler.tick(now)
assert service.process_one()
operation = service.db.one(
"SELECT id,device_id,status FROM operations WHERE action='library-generate'"
)
assert (
operation["device_id"] == "source:family" and operation["status"] == "COMPLETED"
)
assert (
service.operation(operation["id"])["result"]["outputs"][0]["terminal_id"]
== "watch"
)
def test_interrupted_delivery_link_reuses_queue_idempotency(
client, service, library_source
):
plan = create_plan(client, name="Frame", width=1200, height=1600)
bind(client, "living-room", "family", plan)
root = service.submit(
"library-generate",
{"content": {"type": "photo_source", "source_id": "family"}, "options": {}},
"source:family",
"scheduler",
"receipt-gap",
)
assert service.process_one()
old = service.operation(root)["result"]
child = old["outputs"][0]["operation_id"]
service.db.execute("DELETE FROM terminal_deliveries WHERE operation_id=?", (child,))
service.db.execute("UPDATE operations SET status='QUEUED' WHERE id=?", (root,))
assert service.process_one()
assert service.operation(root)["result"]["outputs"][0]["operation_id"] == child
assert (
service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='display'")[
"n"
]
== 1
)
assert (
service.db.one("SELECT operation_id FROM terminal_deliveries")["operation_id"]
== child
)
def test_mode_switch_preserves_existing_schedule(client, service, library_source):
plan = create_plan(client)
bind(client, "watch", "family", plan)
original = client.post(
"/api/v1/jobs",
json={
"name": "Preserve timing",
"schedule": {"type": "interval", "interval_minutes": 120},
"source": {"type": "photo_source", "source_id": "family"},
},
).json()
switched = client.put(
"/api/v1/jobs/" + original["id"] + "/library-mode", json={"enabled": True}
)
assert switched.status_code == 200
current = switched.json()
assert current["library_pipeline"] is True
assert (
current["next_run"] == original["next_run"]
and current["schedule"] == original["schedule"]
and current["enabled"] == original["enabled"]
)
assert (
client.put(
"/api/v1/jobs/" + original["id"] + "/library-mode", json={"enabled": False}
).status_code
== 200
)
def test_single_source_migration_preserves_timing_and_generates_without_device_writes(
client, service, library_source, monkeypatch
):
import runpy
from pathlib import Path
migrate = runpy.run_path(
str(Path(__file__).parents[1] / "scripts/migrate-source-libraries.py")
)["migrate"]
job = client.post(
"/api/v1/jobs",
json={
"name": "Existing source task",
"schedule": {"type": "interval", "interval_minutes": 120},
"source": {"type": "photo_source", "source_id": "family"},
},
).json()
def call(method, path, payload=None):
response = client.request(method, path, json=payload)
response.raise_for_status()
return None if response.status_code == 204 else response.json()
with pytest.raises(RuntimeError):
migrate(call, apply=True)
assert client.delete("/api/v1/photo-sources/travel").status_code == 204
source_op = service.submit(
"display",
{
"content": {"type": "photo_source", "source_id": "family"},
"options": {"priority": 0},
},
"living-room",
"scheduler",
"current-photo",
)
assert service.process_one()
service.sync()
assert service.operation(source_op)["status"] == "COMPLETED"
writes = []
monkeypatch.setattr(
service.registry.get("living-room"), "upload", lambda *args: writes.append(args)
)
result = migrate(call, apply=True)
assert result["migrated"] and result["current_source_generated_locally"]
assert writes == []
migrated = client.get("/api/v1/jobs").json()[0]
assert (
migrated["library_pipeline"] is True and migrated["next_run"] == job["next_run"]
)
assert service.db.one("SELECT COUNT(*) AS n FROM terminal_bindings")["n"] == 2
assert service.db.one("SELECT COUNT(*) AS n FROM source_images")["n"] == 1
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 2
assert migrate(call, apply=True)["already_configured"]