feat: manage saved thumbnail plans and select plans for tasks
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 5m58s

This commit is contained in:
渡口浪人
2026-10-23 14:47:27 +08:00
parent d94031eb50
commit 7178235022
17 changed files with 1736 additions and 37 deletions
+106
View File
@@ -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"
]
}
}
+17
View File
@@ -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,
+146
View File
@@ -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