diff --git a/README.md b/README.md index 21997c3..387ab91 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/README.zh-CN.md b/README.zh-CN.md index b0c787e..e26ce9f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,6 +8,10 @@ 轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。 +### 图库与终端 + +图库分为来源图库和终端图库。每个来源有自己的生成方案;每个终端单选一个来源及其中一个方案,同一方案可以被多个终端共用。任务一次选图,分别生成相框与手表版本。参见[使用与架构说明](docs/SOURCE_LIBRARIES.md)。 + ### 安装 使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64: diff --git a/SHA256SUMS b/SHA256SUMS index 76b52fc..2d5ad92 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -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 diff --git a/docs/README.md b/docs/README.md index 0cd8d21..67b91a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,7 @@ 安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。 +- [来源图库、生成方案与终端](SOURCE_LIBRARIES.md) - [自动拼图](AUTOMATIC_COLLAGE.md) - [构建、缓存与 CI](BUILDING.md) - [可选连接与扩展](EXTENSIONS.md) diff --git a/docs/SOURCE_LIBRARIES.md b/docs/SOURCE_LIBRARIES.md new file mode 100644 index 0000000..db6687e --- /dev/null +++ b/docs/SOURCE_LIBRARIES.md @@ -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 保留兼容。 diff --git a/docs/WATCH-THUMBNAIL.md b/docs/WATCH-THUMBNAIL.md index 8d688bb..cb3fcb7 100644 --- a/docs/WATCH-THUMBNAIL.md +++ b/docs/WATCH-THUMBNAIL.md @@ -1,4 +1,6 @@ -# 小图方案 +# 小图方案(兼容入口) + +主界面已采用[来源图库、生成方案与终端](SOURCE_LIBRARIES.md)。本文描述保留的旧版小图 API 与历史入口;新方案应在对应来源图库中建立,终端单选来源及其方案。 平台侧边栏的“小图方案”提供方案新增、查看、修改与删除。每个方案可保存名称、输出宽高(16–2048 像素)、JPEG 质量(40–95)、适配方式及水平/垂直裁切位置(0–1)。默认 410×502、质量 85、居中等比裁切铺满,不拉伸、不补黑边。也可主动选择完整画面,此时必要处补白;裁切位置仅用于铺满模式。 diff --git a/docs/openapi.json b/docs/openapi.json index 0ee9375..a1cac47 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -16137,6 +16137,3002 @@ } } } + }, + "/api/v1/source-libraries": { + "get": { + "summary": "Sources", + "operationId": "sources_api_v1_source_libraries_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "413": { + "description": "Content Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ] + } + }, + "/api/v1/source-libraries/{source_id}/images": { + "get": { + "summary": "Pictures", + "operationId": "pictures_api_v1_source_libraries__source_id__images_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-libraries/{source_id}/import": { + "post": { + "summary": "Import Picture", + "operationId": "import_picture_api_v1_source_libraries__source_id__import_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-libraries/{source_id}/run": { + "post": { + "summary": "Run", + "operationId": "run_api_v1_source_libraries__source_id__run_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/generation-plans": { + "get": { + "summary": "Plans", + "operationId": "plans_api_v1_generation_plans_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + }, + "post": { + "summary": "Create Plan", + "operationId": "create_plan_api_v1_generation_plans_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationPlan" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/generation-plans/{plan_id}": { + "put": { + "summary": "Update Plan", + "operationId": "update_plan_api_v1_generation_plans__plan_id__put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "plan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Plan Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationPlan" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + }, + "delete": { + "summary": "Delete Plan", + "operationId": "delete_plan_api_v1_generation_plans__plan_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "plan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Plan Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-images/{picture_id}/generate": { + "post": { + "summary": "Generate", + "operationId": "generate_api_v1_source_images__picture_id__generate_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "picture_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Picture Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-images/{picture_id}/preview": { + "post": { + "summary": "Preview", + "operationId": "preview_api_v1_source_images__picture_id__preview_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "picture_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Picture Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationPlan" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-images/{picture_id}/star": { + "put": { + "summary": "Star", + "operationId": "star_api_v1_source_images__picture_id__star_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "picture_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Picture Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageStar" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-images/{picture_id}/exclusion": { + "put": { + "summary": "Exclude", + "operationId": "exclude_api_v1_source_images__picture_id__exclusion_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "picture_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Picture Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageExclusion" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/source-images/{picture_id}": { + "delete": { + "summary": "Delete Picture", + "operationId": "delete_picture_api_v1_source_images__picture_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "picture_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Picture Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/library-versions/{asset_id}": { + "delete": { + "summary": "Delete Version", + "operationId": "delete_version_api_v1_library_versions__asset_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Asset Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/library-terminals": { + "get": { + "summary": "Terminals", + "operationId": "terminals_api_v1_library_terminals_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "413": { + "description": "Content Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ] + } + }, + "/api/v1/library-terminals/{terminal_id}/binding": { + "put": { + "summary": "Bind", + "operationId": "bind_api_v1_library_terminals__terminal_id__binding_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "terminal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Terminal Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindingInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + }, + "delete": { + "summary": "Unbind", + "operationId": "unbind_api_v1_library_terminals__terminal_id__binding_delete", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "terminal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Terminal Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/library-terminals/{terminal_id}/images": { + "get": { + "summary": "Terminal Images", + "operationId": "terminal_images_api_v1_library_terminals__terminal_id__images_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "terminal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Terminal Id" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send": { + "post": { + "summary": "Send", + "operationId": "send_api_v1_library_terminals__terminal_id__images__asset_id__send_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "terminal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Terminal Id" + } + }, + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Asset Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/jobs/{job_id}/library-mode": { + "put": { + "summary": "Set Mode", + "operationId": "set_mode_api_v1_jobs__job_id__library_mode_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryModeInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } } }, "components": { @@ -17625,6 +20621,11 @@ } ], "title": "Thumbnail Plan Id" + }, + "library_pipeline": { + "type": "boolean", + "title": "Library Pipeline", + "default": false } }, "additionalProperties": false, @@ -17746,6 +20747,11 @@ } ], "title": "Thumbnail Plan Id" + }, + "library_pipeline": { + "type": "boolean", + "title": "Library Pipeline", + "default": false } }, "additionalProperties": false, @@ -18188,6 +21194,45 @@ } ], "title": "Preview Url" + }, + "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", @@ -20041,6 +23086,177 @@ "plan" ], "title": "ThumbnailRequest" + }, + "BindingInput": { + "properties": { + "source_id": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Source Id" + }, + "plan_id": { + "type": "string", + "pattern": "^[a-f0-9]{32}$", + "title": "Plan Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "source_id", + "plan_id" + ], + "title": "BindingInput" + }, + "GenerateInput": { + "properties": { + "plan_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + { + "type": "null" + } + ], + "title": "Plan Id" + } + }, + "additionalProperties": false, + "type": "object", + "title": "GenerateInput" + }, + "GenerationPlan": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "width": { + "type": "integer", + "maximum": 2048.0, + "minimum": 16.0, + "title": "Width", + "default": 410 + }, + "height": { + "type": "integer", + "maximum": 2048.0, + "minimum": 16.0, + "title": "Height", + "default": 502 + }, + "fit": { + "type": "string", + "pattern": "^(cover|contain)$", + "title": "Fit", + "default": "cover" + }, + "center_x": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Center X", + "default": 0.5 + }, + "center_y": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Center Y", + "default": 0.5 + }, + "quality": { + "type": "integer", + "maximum": 95.0, + "minimum": 40.0, + "title": "Quality", + "default": 85 + }, + "source_id": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Source Id" + }, + "caption": { + "type": "boolean", + "title": "Caption", + "default": false + }, + "profile": { + "type": "string", + "pattern": "^(color|grayscale)$", + "title": "Profile", + "default": "color" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "source_id" + ], + "title": "GenerationPlan" + }, + "ImportInput": { + "properties": { + "item_id": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "title": "Item Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item_id" + ], + "title": "ImportInput" + }, + "RunInput": { + "properties": { + "item_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Item Id" + }, + "deliver": { + "type": "boolean", + "title": "Deliver", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "title": "RunInput" + }, + "LibraryModeInput": { + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "enabled" + ], + "title": "LibraryModeInput" } }, "securitySchemes": { diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 70405e2..05a5544 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -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", diff --git a/pigway_frame_flow/api/libraries.py b/pigway_frame_flow/api/libraries.py new file mode 100644 index 0000000..845afd4 --- /dev/null +++ b/pigway_frame_flow/api/libraries.py @@ -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 + ) + } diff --git a/pigway_frame_flow/display/cloud_library.py b/pigway_frame_flow/display/cloud_library.py index 768230e..0cdc67f 100644 --- a/pigway_frame_flow/display/cloud_library.py +++ b/pigway_frame_flow/display/cloud_library.py @@ -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", diff --git a/pigway_frame_flow/display/libraries.py b/pigway_frame_flow/display/libraries.py new file mode 100644 index 0000000..437b03d --- /dev/null +++ b/pigway_frame_flow/display/libraries.py @@ -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, + ) diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 7c28f8a..269b6ad 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -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 = {} diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 255c6c9..35eceba 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -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) diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index f707358..3883daa 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -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"], diff --git a/pigway_frame_flow/renderers/photo_caption.py b/pigway_frame_flow/renderers/photo_caption.py index 6922f77..0f78df0 100644 --- a/pigway_frame_flow/renderers/photo_caption.py +++ b/pigway_frame_flow/renderers/photo_caption.py @@ -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() diff --git a/pigway_frame_flow/scheduler/engine.py b/pigway_frame_flow/scheduler/engine.py index 1e65274..de7f1d2 100644 --- a/pigway_frame_flow/scheduler/engine.py +++ b/pigway_frame_flow/scheduler/engine.py @@ -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, diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index ad0db6e..cc28594 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -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(`
${t('uploadHint')}
${t('previewHint')}
${t('templateSteps')}
${t('templateGuideTitle')}
${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}
${['weather','daily-dashboard'].includes(selected.id)?`${t('templateWeatherHelp')}
`:''}${t('helpProfile')}
${t('templateActionHelp')}
${t('templateAutomationHelp')}
${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}
${esc(p.image_ids.join(' → '))}
| ${t(x)} | `).join('')}|||||
|---|---|---|---|---|---|
| ${fmt(o.created_at)} | ${esc(operationActionLabel(o.action))} ${esc(deviceNameForId(o.device_id))} | ${t(o.status)}${o.status==='UNKNOWN'?``:''} | ${operationResultHTML(o)} | ${esc(operationClientLabel(o.client_id))} | ${esc(o.request_id)} |
${t('current')}: ${esc(r.current_image_id)}
${t('result')}: ${esc(r.expected_image_id)}
${t('unknown')}
| ${t(x)} | `).join('')}|||||
|---|---|---|---|---|---|
| ${fmt(o.created_at)} | ${esc(operationActionLabel(o.action))} ${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))} | ${t(o.status)}${o.status==='UNKNOWN'?``:''} | ${operationResultHTML(o)} | ${esc(operationClientLabel(o.client_id))} | ${esc(o.request_id)} |
${t('current')}: ${esc(r.current_image_id)}
${t('result')}: ${esc(r.expected_image_id)}
${t('unknown')}
${t('tokenHint')}
${buttons('logout','logout')}${t('refreshInterval')}
${t('apiDocs')} →GET /openapi.json · /api/v1
${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}
${buttons('new-job','newJob',true)}| ${key==='targetFrame'?tr('目标相框','Target frame'):t(key)} | `).join('')}|||||||
|---|---|---|---|---|---|---|---|
| ${esc(job.name)} | ${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))} | ${esc(scheduleSummary(job.schedule))} | ${t(job.enabled?'enabled':'disabled')} | ${fmt(job.next_run)} | ${fmt(job.last_run)} | ${esc(job.last_result)} |
${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}
${buttons('new-job','newJob',true)}| ${key==='targetFrame'?tr('输出目标','Output target'):t(key)} | `).join('')}|||||||
|---|---|---|---|---|---|---|---|
| ${esc(job.name)} | ${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}))} | ${esc(scheduleSummary(job.schedule))} | ${t(job.enabled?'enabled':'disabled')} | ${fmt(job.next_run)} | ${fmt(job.last_run)} | ${esc(job.library_pipeline&&job.last_result==='COMPLETED'?tr('生成与提交完成','Generated and submitted'):job.last_result)} |