diff --git a/SHA256SUMS b/SHA256SUMS index 28ee28d..312c6a6 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -32,21 +32,22 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI 5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md -eeb127adac67c18be1fd25b7fba3bd9e41624e844105b2f27488ecd3a1bd0c10 docs/WATCH-THUMBNAIL.md +00602929b1a4a2ab5f0e1b5746e2bede71e5568e2c423e11bf44ec9397c8679f 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 -349d7acb49ad5b5f1df169c6ffe99063f11a55ca8764302ac383663161f1c6fb docs/openapi.json +d81f11dda188117b5dbf84983ca54309f6d09cd81d9a342c446bb46b28241e8e 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 -0d9582321b07b570adeab0e6f4c7fddfd45b9ae8703c1ff75edb53ec5cde2e09 pigway_frame_flow/api/app.py +5df5fa4e4baeaac6a68e5007cf1097131d93ec9659f15e65c239df099eddc8df pigway_frame_flow/api/app.py 1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py +512fbf679913d29ad8bfbed496faa9a29d0e2385501a81c0ee4a54659b8f270f pigway_frame_flow/api/thumbnails.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py 0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py 818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py @@ -56,9 +57,10 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f 81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py 5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py -14bb319a5e81c6d95d2a2038073b1db616b3cce8bb7f715376c54ddf4ab02f5a pigway_frame_flow/display/service.py +a00adacca6b5bbc18c4d15dec6ff3c7bba018f8f8575c69205526228032c39b1 pigway_frame_flow/display/service.py +18fc72b7f726c85d8b11bed78d88403c7867b7d71d2580d47afad6fa0242edc2 pigway_frame_flow/display/thumbnail_plans.py 0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py -fcedbac7b8fe57a906882d6fdb54bd570fe2ae32d828cc6dcc98339fb9e77745 pigway_frame_flow/display/wrist_thumbnail.py +9de31405afb22dd5e26ec0cfe023a33c4d5c65b8326c26a8df3378764056023a pigway_frame_flow/display/wrist_thumbnail.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py 9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py 54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py @@ -90,7 +92,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 -c9afccb6336bcb312e738a3c46a402bb693f961dd1cfffb4fff7011f9c3c1395 pigway_frame_flow/models.py +203cea9e4c3ed6e9c33e999ca8b7c72d1d5c3c7a5d5384bdd1ffcfff1904ce15 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 @@ -105,12 +107,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 -e80029d9edc781e54e523570940110c948a0b0e564dcacf64f2fe1425f4509ea pigway_frame_flow/renderers/images.py +6d7937420ad9f0f96846a6e52bcf3c867c161bc7aac48a05a4447a2e086bce6c pigway_frame_flow/renderers/images.py b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 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 -ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_flow/scheduler/engine.py +ef75ef970e89569e2884afe013409e3eb4d8960d920ed363aef2a4fe8dbd2e9d 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 @@ -124,19 +126,20 @@ 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 -a300dfc1083bc45058416759a795fcee0d5efa85148a88f7c2e22aed464c1cc8 pigway_frame_flow/web/app.js +62e898e118823965f1ab90e8386a604512a028b1b9e8bf41a6e82062ac1e99e0 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 a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js -da655fc633ec69e5dfdfbb5c67ae8a878a2df35200a1231035f59edb640c1cf1 pigway_frame_flow/web/index.html +f756a8a103cf06abdbd7c2ed6e50da49b0de166b1d7bb98a9b798bc892d07287 pigway_frame_flow/web/index.html 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 +af2d71e493dab0ecc2c0ebf93abde752344a1e54f63dc7b3908bee534b507d5a pigway_frame_flow/web/thumbnails.js 5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 pyproject.toml d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock 008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh @@ -181,8 +184,8 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py -964a29221c6f7a4185067cb686e1ea003b99ce4609d6fb4ee4bacb70c9fe5ce8 tests/contract.json -86aba7819e70b6ea9372eecc17ab5dfee3aa433e38417ebe81785d3450b4ad0d tests/schema-contract.json +5108885a53feb7119c9b1933432744ef794b0146febef9a33f021e7655198aaa tests/contract.json +cf57d406e1743df4697d5f8341ce3470a963880f7a7d17a5e375eafff625fd65 tests/schema-contract.json 23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py @@ -229,6 +232,7 @@ ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_sch a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py 2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py +e623f7c5796586e4ad47ebd38a49f8d03c08d63a46c539fcb3bd16b2182ac0b4 tests/test_thumbnail_plans.py 33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py 8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py diff --git a/docs/WATCH-THUMBNAIL.md b/docs/WATCH-THUMBNAIL.md index 6cee7cf..4a058a9 100644 --- a/docs/WATCH-THUMBNAIL.md +++ b/docs/WATCH-THUMBNAIL.md @@ -1,9 +1,15 @@ -# 手表小图 +# 小图方案 -照片数据源图片成功上传到本地图库或画框云端后,生成当前手表屏幕尺寸的 410×502 RGB JPEG。默认等比缩放、居中裁切铺满屏幕,不拉伸、不补边、不叠加日期、文字或边框;源文件不变。自定义裁切尚未实现。 +平台侧边栏的“小图方案”提供方案新增、查看、修改与删除。每个方案可保存名称、输出宽高(16–2048 像素)、JPEG 质量(40–95)、适配方式及水平/垂直裁切位置(0–1)。默认 410×502、质量 85、居中等比裁切铺满,不拉伸、不补黑边。也可主动选择完整画面,此时必要处补白;裁切位置仅用于铺满模式。 -小图使用添加画框说明前的源画面;Synology Photos 当前只能提供源预览,结果中的 `source_rendition` 标记为 `preview`,不称为原图。没有可验证源画面的模板和旧图库成品暂不生成。 +选择照片数据源及源图片后,可以使用当前参数预览或“生成并保存到图库”。预览不保存文件,正式生成沿用同一渲染器,结果在本地图库按既有方式查看、下载与删除。修改参数后须重新预览;保存方案与保存生成图片是独立操作。 -成功操作结果的 `wrist_thumbnail` 包含鉴权资源 URL、尺寸、字节数、SHA-256、生成时间、服务器配置时区及当地日期。`app_config` 的 `wrist-thumbnail:` 保存该设备最后一次成功生成的记录;日期用于判断是否属于当天,跨日不将旧图冒充当天图片。资源沿用 `/api/v1/assets/{asset_id}` 的鉴权规则,不开放公开下载或手表传输接口。 +自动化任务提供“生成小图”勾选,默认关闭。勾选后必须单选一个已保存方案,仅支持照片数据源任务。画框上传成功后,按所选方案生成小图;计划执行时使用该方案最新保存的参数。正在被任务或待处理操作引用的方案不能删除,请先取消任务勾选或更换方案。已有任务默认保持不生成小图;手动照片上传沿用原有默认小图行为。 -上传失败不更新小图。辅助小图生成失败保留已有记录并记录日志,不重放已经成功的画框上传。最新小图受现有本地清理策略保护;更早小图作为 cache 沿用现有清理策略。本阶段未接入 BLE、手表端下载或实机显示验证。 +小图始终使用添加画框说明前的源画面,不叠加日期、文字或边框,不修改源文件。Synology Photos 当前只能提供源预览,不称为原图。没有可验证源画面的模板和旧图库成品不生成。 + +成功操作结果的 `wrist_thumbnail` 包含鉴权资源 URL、方案 ID 及参数、尺寸、字节数、SHA-256、生成时间、服务器配置时区及当地日期。`app_config` 的 `wrist-thumbnail:` 保存该设备最后一次成功生成的记录;跨日不将旧图冒充当天图片。资源沿用 `/api/v1/assets/{asset_id}` 的鉴权规则。 + +方案接口为 `/api/v1/thumbnail-plans` 和 `/{plan_id}`;预览及生成分别使用 `/api/v1/thumbnails/preview` 与 `/generate`。读取和预览需要 `images:read`,写入需要 `admin`。参数由服务端校验,任务提交也校验方案存在性与来源类型。 + +上传失败不更新小图。辅助小图生成失败保留已有记录并记录日志,不重放已成功的画框上传。最新小图受现有本地清理策略保护,较早小图沿用 cache 清理策略。本阶段未接入 BLE、手表端下载或实机显示验证。 diff --git a/docs/openapi.json b/docs/openapi.json index ca63116..343cd1b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -14844,6 +14844,1006 @@ } } } + }, + "/api/v1/thumbnail-plans": { + "get": { + "summary": "Plans", + "operationId": "plans_api_v1_thumbnail_plans_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": [] + } + ] + }, + "post": { + "summary": "Create", + "operationId": "create_api_v1_thumbnail_plans_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThumbnailPlan" + } + } + }, + "required": true + }, + "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/thumbnail-plans/{plan_id}": { + "get": { + "summary": "Detail", + "operationId": "detail_api_v1_thumbnail_plans__plan_id__get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "plan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Plan 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" + } + } + }, + "put": { + "summary": "Update", + "operationId": "update_api_v1_thumbnail_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/ThumbnailPlan" + } + } + } + }, + "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", + "operationId": "delete_api_v1_thumbnail_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/thumbnails/preview": { + "post": { + "summary": "Preview", + "operationId": "preview_api_v1_thumbnails_preview_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThumbnailRequest" + } + } + }, + "required": true + }, + "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/thumbnails/generate": { + "post": { + "summary": "Generate", + "operationId": "generate_api_v1_thumbnails_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThumbnailRequest" + } + } + }, + "required": true + }, + "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": [] + } + ] + } } }, "components": { @@ -15960,7 +16960,7 @@ "version": { "type": "string", "title": "Version", - "default": "0.1.23" + "default": "0.1.29" } }, "type": "object", @@ -16315,6 +17315,23 @@ } ], "title": "Next Run" + }, + "generate_thumbnail": { + "type": "boolean", + "title": "Generate Thumbnail", + "default": false + }, + "thumbnail_plan_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + { + "type": "null" + } + ], + "title": "Thumbnail Plan Id" } }, "additionalProperties": false, @@ -16419,6 +17436,23 @@ "minimum": 0.0, "title": "Priority", "default": 50 + }, + "generate_thumbnail": { + "type": "boolean", + "title": "Generate Thumbnail", + "default": false + }, + "thumbnail_plan_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + { + "type": "null" + } + ], + "title": "Thumbnail Plan Id" } }, "additionalProperties": false, @@ -16839,6 +17873,28 @@ "type": "boolean", "title": "Deferred", "default": false + }, + "image_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image Name" + }, + "preview_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Preview Url" } }, "type": "object", @@ -16882,6 +17938,23 @@ "minimum": 0.0, "title": "Priority", "default": 50 + }, + "generate_thumbnail": { + "type": "boolean", + "title": "Generate Thumbnail", + "default": true + }, + "thumbnail_plan_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + { + "type": "null" + } + ], + "title": "Thumbnail Plan Id" } }, "additionalProperties": false, @@ -17908,7 +18981,8 @@ "weekdays", "weekends", "date", - "temporary" + "temporary", + "interval" ], "title": "Type" }, @@ -17946,6 +19020,19 @@ } ], "title": "At" + }, + "interval_minutes": { + "anyOf": [ + { + "type": "integer", + "maximum": 10080.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Interval Minutes" } }, "additionalProperties": false, @@ -18233,7 +19320,7 @@ "version": { "type": "string", "title": "Version", - "default": "0.1.23" + "default": "0.1.29" }, "api_version": { "type": "string", @@ -18577,6 +19664,90 @@ "item_id" ], "title": "PhotoPreference" + }, + "ThumbnailPlan": { + "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 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ], + "title": "ThumbnailPlan" + }, + "ThumbnailRequest": { + "properties": { + "source_id": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Source Id" + }, + "item_id": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "title": "Item Id" + }, + "plan": { + "$ref": "#/components/schemas/ThumbnailPlan" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "source_id", + "item_id", + "plan" + ], + "title": "ThumbnailRequest" } }, "securitySchemes": { diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 7ce7474..23d5c51 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -141,6 +141,9 @@ def create_app(settings=None, start_worker=True): install_extensions(app, service) install_accounts(app, db) install_history(app, service) + from pigway_frame_flow.api.thumbnails import install_thumbnails + + install_thumbnails(app, service) app.state.service = service app.state.worker_enabled = start_worker app.state.scheduler = scheduler @@ -986,7 +989,8 @@ def create_app(settings=None, start_worker=True): dependencies=[Depends(require("jobs:write"))], ) def create_job(payload: JobInput): - return scheduler.save_job(payload) + with service.lock: + return scheduler.save_job(payload) @app.put( "/api/v1/jobs/{job_id}", @@ -997,7 +1001,8 @@ def create_app(settings=None, start_worker=True): def update_job(job_id: str, payload: JobInput): if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)): raise FlowError(E.NOT_FOUND, "Job not found", 404) - return scheduler.save_job(payload, job_id) + with service.lock: + return scheduler.save_job(payload, job_id) @app.delete( "/api/v1/jobs/{job_id}", @@ -1366,6 +1371,7 @@ def create_app(settings=None, start_worker=True): def static(filename: str): if filename not in [ "app.js", + "thumbnails.js", "features.js", "extensions.js", "style.css", diff --git a/pigway_frame_flow/api/thumbnails.py b/pigway_frame_flow/api/thumbnails.py new file mode 100644 index 0000000..14ab82e --- /dev/null +++ b/pigway_frame_flow/api/thumbnails.py @@ -0,0 +1,118 @@ +import json +from uuid import uuid4 + +from fastapi import Depends +from fastapi.responses import Response +from pydantic import Field + +from pigway_frame_flow.core.auth import require +from pigway_frame_flow.display.thumbnail_plans import ( + ThumbnailPlan, + get_plan, + render, + save_plan, +) +from pigway_frame_flow.errors import ErrorCode as E +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.models import StrictModel + + +class ThumbnailRequest(StrictModel): + source_id: str = Field(min_length=1, max_length=120) + item_id: str = Field(min_length=1, max_length=2048) + plan: ThumbnailPlan + + +def install_thumbnails(app, service): + @app.get("/api/v1/thumbnail-plans", dependencies=[Depends(require("images:read"))]) + def plans(): + return [ + dict( + id=r["key"].removeprefix("thumbnail-plan:"), **json.loads(r["payload"]) + ) + for r in service.db.all( + "SELECT key,payload FROM app_config WHERE key LIKE 'thumbnail-plan:%' ORDER BY key" + ) + ] + + @app.post("/api/v1/thumbnail-plans", dependencies=[Depends(require("admin"))]) + def create(plan: ThumbnailPlan): + plan_id = uuid4().hex + save_plan(service, plan_id, plan) + return dict(id=plan_id, **plan.model_dump()) + + @app.get( + "/api/v1/thumbnail-plans/{plan_id}", + dependencies=[Depends(require("images:read"))], + ) + def detail(plan_id: str): + return dict(id=plan_id, **get_plan(service, plan_id).model_dump()) + + @app.put( + "/api/v1/thumbnail-plans/{plan_id}", dependencies=[Depends(require("admin"))] + ) + def update(plan_id: str, plan: ThumbnailPlan): + with service.lock: + get_plan(service, plan_id) + save_plan(service, plan_id, plan) + return dict(id=plan_id, **plan.model_dump()) + + @app.delete( + "/api/v1/thumbnail-plans/{plan_id}", + dependencies=[Depends(require("admin"))], + status_code=204, + ) + def delete(plan_id: str): + with service.lock: + get_plan(service, plan_id) + for row in service.db.all("SELECT payload FROM jobs"): + job = json.loads(row["payload"]) + if job.get("thumbnail_plan_id") == plan_id: + raise FlowError( + E.CONFLICT, + "Thumbnail plan is used by a task", + 409, + {"reason": "thumbnailPlanInUse"}, + ) + for row in service.db.all( + "SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','UNKNOWN')" + ): + if ( + json.loads(row["payload"]) + .get("options", {}) + .get("thumbnail_plan_id") + == plan_id + ): + raise FlowError( + E.CONFLICT, + "Thumbnail plan is used by a pending operation", + 409, + {"reason": "thumbnailPlanInUse"}, + ) + service.db.execute( + "DELETE FROM app_config WHERE key=?", ("thumbnail-plan:" + plan_id,) + ) + + def image(request): + data, _, _ = service.photo_sources.read( + request.source_id, + request.item_id, + target_size=(request.plan.width, request.plan.height), + ) + return render(data, request.plan) + + @app.post( + "/api/v1/thumbnails/preview", dependencies=[Depends(require("images:read"))] + ) + def preview(request: ThumbnailRequest): + return Response( + image(request), + media_type="image/jpeg", + headers={"Cache-Control": "no-store"}, + ) + + @app.post("/api/v1/thumbnails/generate", dependencies=[Depends(require("admin"))]) + def generate(request: ThumbnailRequest): + return service.assets.put( + image(request), request.plan.name + ".jpg", "generated" + ) diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index a69bd79..7c28f8a 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -67,6 +67,10 @@ class DisplayService: def validate(self, request, writing=True): self.registry.get(request.device_id) + if request.options.thumbnail_plan_id: + from pigway_frame_flow.display.thumbnail_plans import get_plan + + get_plan(self, request.options.thumbnail_plan_id) if isinstance(request.content, FutureContent): raise FlowError( E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422 @@ -778,6 +782,10 @@ class DisplayService: ): self.reconcile(pending["id"], acknowledge=True) request = DisplayRequest.model_validate(payload) + if request.options.thumbnail_plan_id: + from pigway_frame_flow.display.thumbnail_plans import get_plan + + get_plan(self, request.options.thumbnail_plan_id) content = request.content self.update(id, "RENDERING") asset = None @@ -830,11 +838,15 @@ class DisplayService: else: driver.display(image_id) result = {"image_id": image_id} - if asset: + if asset and request.options.generate_thumbnail: from pigway_frame_flow.display.wrist_thumbnail import publish thumbnail = publish( - self, device_id, asset["asset_id"], source_image.get("data") + self, + device_id, + asset["asset_id"], + source_image.get("data"), + plan_id=request.options.thumbnail_plan_id, ) if thumbnail: result["wrist_thumbnail"] = thumbnail diff --git a/pigway_frame_flow/display/thumbnail_plans.py b/pigway_frame_flow/display/thumbnail_plans.py new file mode 100644 index 0000000..7a474bc --- /dev/null +++ b/pigway_frame_flow/display/thumbnail_plans.py @@ -0,0 +1,45 @@ +"""Saved thumbnail rendering parameters; source pixels are never decorated.""" + +from pydantic import Field + +from pigway_frame_flow.errors import ErrorCode as E +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.models import StrictModel +from pigway_frame_flow.renderers.images import convert +from pigway_frame_flow.storage.database import encode + + +class ThumbnailPlan(StrictModel): + name: str = Field(min_length=1, max_length=120) + width: int = Field(default=410, ge=16, le=2048) + height: int = Field(default=502, ge=16, le=2048) + fit: str = Field(default="cover", pattern="^(cover|contain)$") + center_x: float = Field(default=0.5, ge=0, le=1, allow_inf_nan=False) + center_y: float = Field(default=0.5, ge=0, le=1, allow_inf_nan=False) + quality: int = Field(default=85, ge=40, le=95) + + +def get_plan(service, plan_id): + row = service.db.one( + "SELECT payload FROM app_config WHERE key=?", ("thumbnail-plan:" + plan_id,) + ) + if not row: + raise FlowError(E.NOT_FOUND, "Thumbnail plan not found", 404) + return ThumbnailPlan.model_validate_json(row["payload"]) + + +def render(data, plan): + return convert( + data, + plan.fit, + size=(plan.width, plan.height), + centering=(plan.center_x, plan.center_y), + quality=plan.quality, + ) + + +def save_plan(service, plan_id, plan): + service.db.execute( + "INSERT OR REPLACE INTO app_config VALUES(?,?)", + ("thumbnail-plan:" + plan_id, encode(plan.model_dump())), + ) diff --git a/pigway_frame_flow/display/wrist_thumbnail.py b/pigway_frame_flow/display/wrist_thumbnail.py index 1822b88..b3faa96 100644 --- a/pigway_frame_flow/display/wrist_thumbnail.py +++ b/pigway_frame_flow/display/wrist_thumbnail.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) SIZE = (410, 502) -def publish(service, device_id, asset_id, data=None): +def publish(service, device_id, asset_id, data=None, plan_id=None): # Templates and old gallery assets have no verified undecorated source. row = service.db.one( "SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,) @@ -23,25 +23,33 @@ def publish(service, device_id, asset_id, data=None): if source.get("type") != "photo_source": return None try: + from pigway_frame_flow.display.thumbnail_plans import get_plan, render + + plan = get_plan(service, plan_id) if plan_id else None + size = (plan.width, plan.height) if plan else SIZE if data is None: data, _, _ = service.photo_sources.read( - source["source_id"], source["item_id"], target_size=SIZE + source["source_id"], source["item_id"], target_size=size ) - rendered = convert(data, "cover", "color", size=SIZE) + rendered = ( + render(data, plan) if plan else convert(data, "cover", "color", size=SIZE) + ) asset = service.assets.put(rendered, "watch.jpg", "cache") now = datetime.now(ZoneInfo(service.settings.timezone)) result = { "asset_id": asset["asset_id"], "url": asset["url"], - "width": SIZE[0], - "height": SIZE[1], + "width": size[0], + "height": size[1], "date": now.date().isoformat(), "timezone": service.settings.timezone, "generated_at": now.isoformat(), "sha256": hashlib.sha256(rendered).hexdigest(), "bytes": len(rendered), "source": source, - "fit": "cover", + "fit": plan.fit if plan else "cover", + "plan_id": plan_id, + "plan": plan.model_dump() if plan else None, "source_rendition": "preview" if service.photo_sources.get(source["source_id"]).kind == "synology-photos" else "source", diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 6c1de17..255c6c9 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -107,6 +107,8 @@ Content = Annotated[ class Options(StrictModel): + generate_thumbnail: bool = True + thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") cleanup_unstarred: bool = False duration: int = Field(default=0, ge=0, le=604800) restore_previous: bool = False @@ -281,6 +283,8 @@ class RestoreContent(StrictModel): class JobInput(StrictModel): + generate_thumbnail: bool = False + thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") cleanup_unstarred: bool = False enabled: bool = True device_id: str = "living-room" @@ -293,6 +297,15 @@ class JobInput(StrictModel): @model_validator(mode="after") def validate_options(self): + if self.generate_thumbnail and ( + not self.thumbnail_plan_id + or not isinstance(self.source, PhotoSourceContent) + ): + raise ValueError( + "Thumbnail generation requires a photo source and one saved plan" + ) + if not self.generate_thumbnail and self.thumbnail_plan_id: + raise ValueError("Enable thumbnail generation before selecting a plan") if self.cleanup_unstarred and not ( isinstance(self.source, PhotoSourceContent) and self.source.item_id is None ): diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index afefc15..f707358 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -17,7 +17,15 @@ MAX_IMAGE_PIXELS = 25_000_000 Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS -def convert(data, fit="contain", profile="color", *, size=(1200, 1600)): +def convert( + data, + fit="contain", + profile="color", + *, + size=(1200, 1600), + centering=(0.5, 0.5), + quality=93, +): try: with Image.open(BytesIO(data)) as source: if source.format not in {"JPEG", "PNG", "WEBP"}: @@ -33,7 +41,7 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)): img, size, method=Image.Resampling.LANCZOS, color="white" ) else: - center = (0.5, 0.5) + center = centering if fit == "smart": # Lightweight edge saliency: slide an aspect-correct window # over a thumbnail; retain the region with most detail. @@ -65,7 +73,7 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)): if profile == "grayscale": result = ImageOps.grayscale(result).convert("RGB") out = BytesIO() - result.save(out, "JPEG", quality=93, optimize=True) + result.save(out, "JPEG", quality=quality, optimize=True) return out.getvalue() except (OSError, ValueError, Image.DecompressionBombError): raise FlowError(E.INVALID_REQUEST, "Unsupported or invalid image") from None diff --git a/pigway_frame_flow/scheduler/engine.py b/pigway_frame_flow/scheduler/engine.py index b8c0705..1e65274 100644 --- a/pigway_frame_flow/scheduler/engine.py +++ b/pigway_frame_flow/scheduler/engine.py @@ -88,6 +88,10 @@ class Scheduler: def save_job(self, job, id=None): self.service.registry.get(job.device_id) + 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": self.service.validate( DisplayRequest(device_id=job.device_id, content=job.source), @@ -183,6 +187,8 @@ class Scheduler: restore_previous=job.restore_previous, priority=job.priority, cleanup_unstarred=job.cleanup_unstarred, + generate_thumbnail=job.generate_thumbnail, + thumbnail_plan_id=job.thumbnail_plan_id, ), ).model_dump(mode="json") ) diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index a4b940b..9598950 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -8,7 +8,7 @@ async function loadConnection(){ state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device; if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);}); } -const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; +const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',thumbnails:'▧',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; const t=k=>window.MESSAGES[state.lang][k]||k; function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));} const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); @@ -154,7 +154,7 @@ const views={ async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`

${t('history')}

${buttons('history-refresh','refresh')}
${ops.some(x=>x.status==='UNKNOWN')?`
${t('unknown')}
`:''}
${ops.length?`${['created','action','status','result','client','requestId'].map(x=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${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)}
${esc(o.operation_id)}
`:`
${t('noHistory')}
`}
`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`

${t('reconcile')}

${t('current')}: ${esc(r.current_image_id)}

${t('result')}: ${esc(r.expected_image_id)}

${t('unknown')}

`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));}, async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`

${t('settings')}

${t('tokenHint')}

${buttons('logout','logout')}

${t('auth')}

${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}

${t('refreshInterval')}

${t('apiDocs')} →

GET /openapi.json · /api/v1

`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();} }; -function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};} +function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};} async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`

${t('playlists')}

`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);} $('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme); applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`PIGWay Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{}); @@ -176,8 +176,9 @@ async function jobEditor(job){ function updateScheduleField(){const type=$('#job-schedule').value;const interval=['hours','minutes'].includes(type);$('#schedule-value').min=interval?'1':'';$('#schedule-value').max=type==='hours'?'168':type==='minutes'?'10080':'';$('#schedule-value').step=interval?'1':'';$('#schedule-label').textContent=interval?tr('间隔数值','Interval'):type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=interval?'number':type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);} $('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';}; updateScheduleField(); - $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();}); + $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,generate_thumbnail:$('#job-thumbnail').checked,thumbnail_plan_id:$('#job-thumbnail').checked?$('#job-thumbnail-plan').value:null,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();}); await jobSourceEditor(j); + await thumbnailJobFields(j); } views.automation=async epoch=>{ diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index 62ed828..ab9baa0 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ PIGWay Frame Flow -
PIGWay Frame Flow

+
PIGWay Frame Flow

diff --git a/pigway_frame_flow/web/thumbnails.js b/pigway_frame_flow/web/thumbnails.js new file mode 100644 index 0000000..86c2db5 --- /dev/null +++ b/pigway_frame_flow/web/thumbnails.js @@ -0,0 +1,32 @@ +'use strict'; +Object.assign(MESSAGES.zh, {thumbnails:'小图方案',thumbnailPlanInUse:'此方案已被任务或待处理操作使用,请先取消关联或更换方案。'}); +Object.assign(MESSAGES.en, {thumbnails:'Thumbnail plans',thumbnailPlanInUse:'This plan is used by a task or pending operation. Unlink it or select another plan first.'}); + +views.thumbnails=async function(epoch){ + const [plans,sources]=await Promise.all([api('/api/v1/thumbnail-plans'),api('/api/v1/photo-sources')]); + if(epoch!==state.epoch)return; + $('#main').innerHTML=`
${[['width',tr('宽度(像素)','Width (px)'),410,16,2048,1],['height',tr('高度(像素)','Height (px)'),502,16,2048,1],['center_x',tr('水平裁切位置(0 左 / 1 右)','Crop X (0 left / 1 right)'),0.5,0,1,0.01],['center_y',tr('垂直裁切位置(0 上 / 1 下)','Crop Y (0 top / 1 bottom)'),0.5,0,1,0.01],['quality',tr('JPEG 质量','JPEG quality'),85,40,95,1]].map(([key,label,value,min,max,step])=>`
`).join('')}

${tr('不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。','No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.')}

${tr('预览与生成','Preview and generate')}

`; + const defaults={name:'',width:410,height:502,center_x:0.5,center_y:0.5,quality:85,fit:'cover'}; + const fill=p=>{for(const key of Object.keys(defaults))$('#thumb-'+key).value=p[key];}; + const read=()=>{const p={};for(const key of Object.keys(defaults))p[key]=['name','fit'].includes(key)?$('#thumb-'+key).value:Number($('#thumb-'+key).value);return p;}; + fill(defaults);$('#thumb-delete').disabled=true; + $('#thumb-form').oninput=()=>{$('#thumb-image').hidden=true;$('#thumb-result').textContent=tr('参数已修改,请重新预览','Parameters changed; preview again');}; + $('#thumb-plan').onchange=()=>{fill(plans.find(p=>p.id===$('#thumb-plan').value)||defaults);$('#thumb-delete').disabled=!$('#thumb-plan').value;$('#thumb-image').hidden=true;}; + $('#thumb-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#thumb-plan').value;await api('/api/v1/thumbnail-plans'+(id?'/'+id:''),{method:id?'PUT':'POST',body:read()});toast(t('save'));await navigate();}); + bind('thumb-delete',()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/thumbnail-plans/'+$('#thumb-plan').value,{method:'DELETE'});await navigate();})); + let offset=0,version=0; + async function images(){const source=$('#thumb-source').value;const ticket=++version;$('#thumb-item').innerHTML='';if(!source)return;const page=await api('/api/v1/photo-sources/'+encodeURIComponent(source)+'/images?offset='+offset+'&limit=50');if(ticket!==version||epoch!==state.epoch)return;$('#thumb-item').innerHTML=page.items.map(i=>``).join('');$('#thumb-prev').disabled=offset===0;$('#thumb-next').disabled=offset+50>=page.total;} + $('#thumb-source').onchange=busy(async()=>{offset=0;await images();}); + bind('thumb-prev',async()=>{offset=Math.max(0,offset-50);await images();});bind('thumb-next',async()=>{offset+=50;await images();}); + function payload(){if(!$('#thumb-form').reportValidity())throw new Error(tr('请补全方案参数','Complete the plan parameters'));if(!$('#thumb-item').value)throw new Error(tr('请选择源图片','Choose a source image'));return {source_id:$('#thumb-source').value,item_id:$('#thumb-item').value,plan:read()};} + bind('thumb-preview',async()=>{const blob=await api('/api/v1/thumbnails/preview',{method:'POST',body:payload(),binary:true});if(epoch!==state.epoch)return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#thumb-image').src=url;$('#thumb-image').hidden=false;$('#thumb-result').textContent=tr('预览未保存','Preview is not saved');}); + bind('thumb-generate',async()=>{const asset=await api('/api/v1/thumbnails/generate',{method:'POST',body:payload()});if(epoch!==state.epoch)return;$('#thumb-image').hidden=false;await blobImage($('#thumb-image'),asset.url);$('#thumb-result').textContent=tr('已保存到本地图库:','Saved to local gallery: ')+asset.name;}); +}; + +async function thumbnailJobFields(job){ + const plans=await api('/api/v1/thumbnail-plans');if(!$('#job-form'))return; + const box=document.createElement('section');box.innerHTML=`

${tr('仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。','For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.')}

`; + $('#job-form .modal-actions').before(box); + const update=()=>{const supported=$('#job-content-type')?.value==='photo_source';$('#job-thumbnail').disabled=!supported;if(!supported)$('#job-thumbnail').checked=false;$('#job-thumbnail-plan').disabled=!$('#job-thumbnail').checked;$('#job-thumbnail-plan').required=$('#job-thumbnail').checked;}; + $('#job-thumbnail').onchange=update;$('#job-content-type')?.addEventListener('change',update);update(); +} diff --git a/tests/contract.json b/tests/contract.json index 6a74c2c..e1d0d10 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -1510,5 +1510,111 @@ "502", "503" ] + }, + "/api/v1/thumbnail-plans": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "post": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/thumbnail-plans/{plan_id}": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "delete": [ + "204", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/thumbnails/preview": { + "post": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/thumbnails/generate": { + "post": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] } } diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 480d577..6693043 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -581,6 +581,23 @@ "minimum": 0.0, "title": "Priority", "default": 50 + }, + "generate_thumbnail": { + "type": "boolean", + "title": "Generate Thumbnail", + "default": true + }, + "thumbnail_plan_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + { + "type": "null" + } + ], + "title": "Thumbnail Plan Id" } }, "additionalProperties": false, diff --git a/tests/test_thumbnail_plans.py b/tests/test_thumbnail_plans.py new file mode 100644 index 0000000..c76d358 --- /dev/null +++ b/tests/test_thumbnail_plans.py @@ -0,0 +1,146 @@ +import json +from datetime import UTC, datetime +from io import BytesIO + +import pytest +from PIL import Image + +from pigway_frame_flow.models import JobInput +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + +@pytest.fixture +def source(service, settings, legacy_photo_engine): + root = settings.data_dir.parent / "photos" + root.mkdir() + settings.photo_root = root + image = Image.new("RGB", (900, 300), "red") + image.paste("blue", (450, 0, 900, 300)) + image.save(root / "photo.jpg") + service.photo_sources.save( + "family", PhotoSourceInput(name="Family", kind="local", location=".") + ) + return {"source_id": "family", "item_id": "photo.jpg"} + + +def test_plan_crud_and_parameter_validation(client): + assert client.get("/ui/thumbnails.js").status_code == 200 + plan = client.post("/api/v1/thumbnail-plans", json={"name": "Watch"}).json() + path = "/api/v1/thumbnail-plans/" + plan["id"] + assert client.get(path).json()["width"] == 410 + assert len(client.get("/api/v1/thumbnail-plans").json()) == 1 + assert ( + client.put( + path, json={"name": "Square", "width": 240, "height": 240} + ).status_code + == 200 + ) + for changes in [ + {"width": 0}, + {"height": 10000}, + {"center_x": 2}, + {"quality": 100}, + {"fit": "stretch"}, + {"extra": True}, + ]: + assert client.put(path, json={"name": "Invalid", **changes}).status_code == 422 + assert client.delete(path).status_code == 204 + assert client.get(path).status_code == 404 + + +def test_preview_matches_generated_pixels_and_crop(client, source): + request = { + **source, + "plan": {"name": "Right", "width": 120, "height": 180, "center_x": 1}, + } + preview = client.post("/api/v1/thumbnails/preview", json=request) + assert preview.status_code == 200 + assert preview.headers["cache-control"] == "no-store" + asset = client.post("/api/v1/thumbnails/generate", json=request).json() + assert client.get(asset["url"]).content == preview.content + with Image.open(BytesIO(preview.content)) as image: + assert image.size == (120, 180) + assert image.getpixel((60, 90))[2] > 240 + + +def test_task_requires_one_existing_plan_and_blocks_delete(client, app, source): + plan = client.post("/api/v1/thumbnail-plans", json={"name": "Watch"}).json() + job = { + "name": "Photos", + "schedule": {"type": "daily", "time": "07:00"}, + "source": {"type": "photo_source", "source_id": source["source_id"]}, + "generate_thumbnail": True, + "thumbnail_plan_id": plan["id"], + } + saved = client.post("/api/v1/jobs", json=job) + assert saved.status_code == 201 + assert saved.json()["thumbnail_plan_id"] == plan["id"] + service = app.state.service + now = datetime.now(UTC) + service.db.execute( + "UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved.json()["id"]) + ) + app.state.scheduler.tick(now) + pending = service.db.one("SELECT id,payload FROM operations WHERE action='display'") + assert json.loads(pending["payload"])["options"]["thumbnail_plan_id"] == plan["id"] + assert service.process_one() + assert ( + service.operation(pending["id"])["result"]["wrist_thumbnail"]["plan_id"] + == plan["id"] + ) + assert client.delete("/api/v1/thumbnail-plans/" + plan["id"]).status_code == 409 + assert ( + client.post("/api/v1/jobs", json={**job, "thumbnail_plan_id": None}).status_code + == 422 + ) + assert ( + client.post( + "/api/v1/jobs", json={**job, "thumbnail_plan_id": "0" * 32} + ).status_code + == 404 + ) + assert ( + client.post( + "/api/v1/jobs", + json={**job, "source": {"type": "template", "template": "quote"}}, + ).status_code + == 422 + ) + assert ( + JobInput.model_validate( + { + k: v + for k, v in job.items() + if k not in ("generate_thumbnail", "thumbnail_plan_id") + } + ).generate_thumbnail + is False + ) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_upload_uses_selected_plan_or_skips(service, client, source, enabled): + plan = client.post( + "/api/v1/thumbnail-plans", json={"name": "Square", "width": 128, "height": 128} + ).json() + operation = service.submit( + "display", + { + "content": {"type": "photo_source", "source_id": "family"}, + "options": { + "priority": 0, + "generate_thumbnail": enabled, + "thumbnail_plan_id": plan["id"] if enabled else None, + }, + }, + "living-room", + "test", + "request", + ) + service.process_one() + result = service.operation(operation)["result"] + if enabled: + assert result["wrist_thumbnail"]["width"] == 128 + assert result["wrist_thumbnail"]["plan_id"] == plan["id"] + else: + assert "wrist_thumbnail" not in result