feat: manage saved thumbnail plans and select plans for tasks
This commit is contained in:
+16
-12
@@ -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
|
||||
|
||||
+11
-5
@@ -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:<device_id>` 保存该设备最后一次成功生成的记录;日期用于判断是否属于当天,跨日不将旧图冒充当天图片。资源沿用 `/api/v1/assets/{asset_id}` 的鉴权规则,不开放公开下载或手表传输接口。
|
||||
自动化任务提供“生成小图”勾选,默认关闭。勾选后必须单选一个已保存方案,仅支持照片数据源任务。画框上传成功后,按所选方案生成小图;计划执行时使用该方案最新保存的参数。正在被任务或待处理操作引用的方案不能删除,请先取消任务勾选或更换方案。已有任务默认保持不生成小图;手动照片上传沿用原有默认小图行为。
|
||||
|
||||
上传失败不更新小图。辅助小图生成失败保留已有记录并记录日志,不重放已经成功的画框上传。最新小图受现有本地清理策略保护;更早小图作为 cache 沿用现有清理策略。本阶段未接入 BLE、手表端下载或实机显示验证。
|
||||
小图始终使用添加画框说明前的源画面,不叠加日期、文字或边框,不修改源文件。Synology Photos 当前只能提供源预览,不称为原图。没有可验证源画面的模板和旧图库成品不生成。
|
||||
|
||||
成功操作结果的 `wrist_thumbnail` 包含鉴权资源 URL、方案 ID 及参数、尺寸、字节数、SHA-256、生成时间、服务器配置时区及当地日期。`app_config` 的 `wrist-thumbnail:<device_id>` 保存该设备最后一次成功生成的记录;跨日不将旧图冒充当天图片。资源沿用 `/api/v1/assets/{asset_id}` 的鉴权规则。
|
||||
|
||||
方案接口为 `/api/v1/thumbnail-plans` 和 `/{plan_id}`;预览及生成分别使用 `/api/v1/thumbnails/preview` 与 `/generate`。读取和预览需要 `images:read`,写入需要 `admin`。参数由服务端校验,任务提交也校验方案存在性与来源类型。
|
||||
|
||||
上传失败不更新小图。辅助小图生成失败保留已有记录并记录日志,不重放已成功的画框上传。最新小图受现有本地清理策略保护,较早小图沿用 cache 清理策略。本阶段未接入 BLE、手表端下载或实机显示验证。
|
||||
|
||||
+1174
-3
File diff suppressed because it is too large
Load Diff
@@ -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,6 +989,7 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def create_job(payload: JobInput):
|
||||
with service.lock:
|
||||
return scheduler.save_job(payload)
|
||||
|
||||
@app.put(
|
||||
@@ -997,6 +1001,7 @@ 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)
|
||||
with service.lock:
|
||||
return scheduler.save_job(payload, job_id)
|
||||
|
||||
@app.delete(
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
)
|
||||
@@ -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 = (
|
||||
render(data, plan) if plan else convert(data, "cover", "color", size=SIZE)
|
||||
)
|
||||
rendered = 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",
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||
};
|
||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...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(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{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=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).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=>{
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script></body></html>
|
||||
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script></body></html>
|
||||
|
||||
@@ -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=`<section class="card"><div class="actions"><select id="thumb-plan"><option value="">${tr('新建方案','New plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}">${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select><button id="thumb-delete" class="danger">${t('delete')}</button></div><form id="thumb-form"><div class="field"><label for="thumb-name">${t('name')}</label><input id="thumb-name" required maxlength="120"></div><div class="form-grid">${[['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])=>`<div class="field"><label for="thumb-${key}">${label}</label><input id="thumb-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${value}"></div>`).join('')}<div class="field"><label for="thumb-fit">${tr('适配方式','Fit')}</label><select id="thumb-fit"><option value="cover">${tr('等比裁切铺满(默认)','Fill with proportional crop (default)')}</option><option value="contain">${tr('完整画面(必要时补白)','Full picture (white padding if needed)')}</option></select></div></div><p class="hint">${tr('不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。','No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.')}</p><button type="submit" class="primary">${tr('保存方案','Save plan')}</button></form></section><section class="card"><h2>${tr('预览与生成','Preview and generate')}</h2><div class="field"><label for="thumb-source">${t('source')}</label><select id="thumb-source"><option value="">${tr('选择照片数据源','Choose photo source')}</option>${sources.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('')}</select></div><div class="field"><label for="thumb-item">${tr('源图片','Source image')}</label><select id="thumb-item"></select></div><div class="actions"><button id="thumb-prev">${tr('上一页','Previous page')}</button><button id="thumb-next">${tr('下一页','Next page')}</button><button id="thumb-preview">${tr('预览小图','Preview thumbnail')}</button><button id="thumb-generate" class="primary">${tr('生成并保存到图库','Generate and save to gallery')}</button></div><p id="thumb-result" class="hint" aria-live="polite"></p><img id="thumb-image" alt="${tr('小图预览','Thumbnail preview')}" hidden style="max-width:100%;max-height:502px;width:auto;height:auto"></section>`;
|
||||
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=>`<option value="${esc(i.id)}">${esc(i.name)}</option>`).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=`<label class="check-row"><input type="checkbox" id="job-thumbnail" ${job.generate_thumbnail?'checked':''}>${tr('生成小图','Generate thumbnail')}</label><div class="field"><label for="job-thumbnail-plan">${tr('小图生成方案(单选)','Thumbnail plan (choose one)')}</label><select id="job-thumbnail-plan"><option value="">${tr('请选择已保存方案','Choose a saved plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}" ${job.thumbnail_plan_id===p.id?'selected':''}>${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select></div><p class="hint">${tr('仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。','For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.')}</p>`;
|
||||
$('#job-form .modal-actions').before(box);
|
||||
const update=()=>{const supported=$('#job-content-type')?.value==='photo_source';$('#job-thumbnail').disabled=!supported;if(!supported)$('#job-thumbnail').checked=false;$('#job-thumbnail-plan').disabled=!$('#job-thumbnail').checked;$('#job-thumbnail-plan').required=$('#job-thumbnail').checked;};
|
||||
$('#job-thumbnail').onchange=update;$('#job-content-type')?.addEventListener('change',update);update();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user