feat: generate source-only watch thumbnails after photo uploads
This commit is contained in:
+5
-2
@@ -32,6 +32,7 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI
|
|||||||
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||||
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||||
|
eeb127adac67c18be1fd25b7fba3bd9e41624e844105b2f27488ecd3a1bd0c10 docs/WATCH-THUMBNAIL.md
|
||||||
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
||||||
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
|
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
|
||||||
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
|
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
|
||||||
@@ -55,8 +56,9 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f
|
|||||||
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
||||||
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||||
d974caaa2772b013cc6f080f997d37cd0d2212b29fa9524002f3fb87dd937364 pigway_frame_flow/display/service.py
|
14bb319a5e81c6d95d2a2038073b1db616b3cce8bb7f715376c54ddf4ab02f5a pigway_frame_flow/display/service.py
|
||||||
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||||
|
fcedbac7b8fe57a906882d6fdb54bd570fe2ae32d828cc6dcc98339fb9e77745 pigway_frame_flow/display/wrist_thumbnail.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||||
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||||
54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py
|
54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py
|
||||||
@@ -77,7 +79,7 @@ d463465ca3aea449e67386bc84a5f25b58535f74d89d7858c5b9bca0d93189d3 pigway_frame_f
|
|||||||
6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py
|
6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py
|
||||||
ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.py
|
ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.py
|
||||||
8e7d7da457d3b82811dc98cf6c3a93fcd7cbe8a4addb5682f48b1acd4983de82 pigway_frame_flow/extensions/secrets.py
|
8e7d7da457d3b82811dc98cf6c3a93fcd7cbe8a4addb5682f48b1acd4983de82 pigway_frame_flow/extensions/secrets.py
|
||||||
49f2d6f7a35675fedaf03ca648f046bde6e49aebb21d6e7afb39f1b8a523fbb2 pigway_frame_flow/extensions/storage.py
|
83fea5407cbb83ca426cda420cc995e9346955e747e0cda2f7e6546fbd50dc06 pigway_frame_flow/extensions/storage.py
|
||||||
1f2db3a58e7ebe5a4531f4550356fc9bcb331a2701f0bc25edf0ea8b13a57d97 pigway_frame_flow/extensions/webhooks.py
|
1f2db3a58e7ebe5a4531f4550356fc9bcb331a2701f0bc25edf0ea8b13a57d97 pigway_frame_flow/extensions/webhooks.py
|
||||||
b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py
|
b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py
|
||||||
d3025e294466d72c9332a7c577c3262816c6a12d12c7c2f0c241a7e423034eed pigway_frame_flow/integrations/kokonna_official/README.md
|
d3025e294466d72c9332a7c577c3262816c6a12d12c7c2f0c241a7e423034eed pigway_frame_flow/integrations/kokonna_official/README.md
|
||||||
@@ -231,6 +233,7 @@ ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_syn
|
|||||||
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
|
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
|
||||||
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
|
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
|
||||||
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
|
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
|
||||||
|
2b3ab560a0211e45a7bc7e26104d3d9416c4615fbeccaf3bb5eec4747aaea4cf tests/test_wrist_thumbnail.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py
|
||||||
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
|
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
|
||||||
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
|
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 手表小图
|
||||||
|
|
||||||
|
照片数据源图片成功上传到本地图库或画框云端后,生成当前手表屏幕尺寸的 410×502 RGB JPEG。默认等比缩放、居中裁切铺满屏幕,不拉伸、不补边、不叠加日期、文字或边框;源文件不变。自定义裁切尚未实现。
|
||||||
|
|
||||||
|
小图使用添加画框说明前的源画面;Synology Photos 当前只能提供源预览,结果中的 `source_rendition` 标记为 `preview`,不称为原图。没有可验证源画面的模板和旧图库成品暂不生成。
|
||||||
|
|
||||||
|
成功操作结果的 `wrist_thumbnail` 包含鉴权资源 URL、尺寸、字节数、SHA-256、生成时间、服务器配置时区及当地日期。`app_config` 的 `wrist-thumbnail:<device_id>` 保存该设备最后一次成功生成的记录;日期用于判断是否属于当天,跨日不将旧图冒充当天图片。资源沿用 `/api/v1/assets/{asset_id}` 的鉴权规则,不开放公开下载或手表传输接口。
|
||||||
|
|
||||||
|
上传失败不更新小图。辅助小图生成失败保留已有记录并记录日志,不重放已经成功的画框上传。最新小图受现有本地清理策略保护;更早小图作为 cache 沿用现有清理策略。本阶段未接入 BLE、手表端下载或实机显示验证。
|
||||||
@@ -205,7 +205,12 @@ class DisplayService:
|
|||||||
result.pop("preview_url", None)
|
result.pop("preview_url", None)
|
||||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||||
previous = json.loads(row["result"]) if row and row["result"] else {}
|
previous = json.loads(row["result"]) if row and row["result"] else {}
|
||||||
for key in ("photo_attempts", "upload_intent", "sync_observations"):
|
for key in (
|
||||||
|
"photo_attempts",
|
||||||
|
"upload_intent",
|
||||||
|
"sync_observations",
|
||||||
|
"wrist_thumbnail",
|
||||||
|
):
|
||||||
if key in previous:
|
if key in previous:
|
||||||
result = {**result, key: previous[key]}
|
result = {**result, key: previous[key]}
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
@@ -265,7 +270,13 @@ class DisplayService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def content_asset(
|
def content_asset(
|
||||||
self, content, device_id=None, operation_id=None, *, staging=False
|
self,
|
||||||
|
content,
|
||||||
|
device_id=None,
|
||||||
|
operation_id=None,
|
||||||
|
*,
|
||||||
|
staging=False,
|
||||||
|
source_image=None,
|
||||||
):
|
):
|
||||||
if isinstance(content, AutoCollageContent):
|
if isinstance(content, AutoCollageContent):
|
||||||
from pigway_frame_flow.extensions.auto_collage import prepare
|
from pigway_frame_flow.extensions.auto_collage import prepare
|
||||||
@@ -419,6 +430,8 @@ class DisplayService:
|
|||||||
):
|
):
|
||||||
raise
|
raise
|
||||||
continue
|
continue
|
||||||
|
if source_image is not None:
|
||||||
|
source_image["data"] = data
|
||||||
attempt["status"] = "PREPARED"
|
attempt["status"] = "PREPARED"
|
||||||
if operation_id:
|
if operation_id:
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
@@ -625,15 +638,26 @@ class DisplayService:
|
|||||||
|
|
||||||
from pigway_frame_flow.models import Content
|
from pigway_frame_flow.models import Content
|
||||||
|
|
||||||
|
source_image: dict[str, bytes] = {}
|
||||||
asset = self.content_asset(
|
asset = self.content_asset(
|
||||||
TypeAdapter(Content).validate_python(payload), device_id, id
|
TypeAdapter(Content).validate_python(payload),
|
||||||
|
device_id,
|
||||||
|
id,
|
||||||
|
source_image=source_image,
|
||||||
)
|
)
|
||||||
self.assets.record_origin(asset["asset_id"], op)
|
self.assets.record_origin(asset["asset_id"], op)
|
||||||
if action == "local-upload":
|
if action == "local-upload":
|
||||||
asset = self.assets.promote(asset["asset_id"])
|
asset = self.assets.promote(asset["asset_id"])
|
||||||
self.update(
|
result = dict(asset_id=asset["asset_id"], url=asset["url"])
|
||||||
id, "COMPLETED", dict(asset_id=asset["asset_id"], url=asset["url"])
|
if action == "local-upload":
|
||||||
)
|
from pigway_frame_flow.display.wrist_thumbnail import publish
|
||||||
|
|
||||||
|
thumbnail = publish(
|
||||||
|
self, device_id, asset["asset_id"], source_image.get("data")
|
||||||
|
)
|
||||||
|
if thumbnail:
|
||||||
|
result["wrist_thumbnail"] = thumbnail
|
||||||
|
self.update(id, "COMPLETED", result)
|
||||||
self.db.history(op, "COMPLETED")
|
self.db.history(op, "COMPLETED")
|
||||||
return
|
return
|
||||||
device = driver.device(refresh=True)
|
device = driver.device(refresh=True)
|
||||||
@@ -757,6 +781,7 @@ class DisplayService:
|
|||||||
content = request.content
|
content = request.content
|
||||||
self.update(id, "RENDERING")
|
self.update(id, "RENDERING")
|
||||||
asset = None
|
asset = None
|
||||||
|
source_image = {}
|
||||||
if isinstance(content, ImageContent) and (
|
if isinstance(content, ImageContent) and (
|
||||||
content.image_id or content.image_name
|
content.image_id or content.image_name
|
||||||
):
|
):
|
||||||
@@ -769,7 +794,9 @@ class DisplayService:
|
|||||||
)
|
)
|
||||||
image_id = matches[0].image_id
|
image_id = matches[0].image_id
|
||||||
else:
|
else:
|
||||||
asset = self.content_asset(content, device_id, id, staging=True)
|
asset = self.content_asset(
|
||||||
|
content, device_id, id, staging=True, source_image=source_image
|
||||||
|
)
|
||||||
self.assets.record_origin(asset["asset_id"], op)
|
self.assets.record_origin(asset["asset_id"], op)
|
||||||
image_id = None
|
image_id = None
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
@@ -803,6 +830,14 @@ class DisplayService:
|
|||||||
else:
|
else:
|
||||||
driver.display(image_id)
|
driver.display(image_id)
|
||||||
result = {"image_id": image_id}
|
result = {"image_id": image_id}
|
||||||
|
if asset:
|
||||||
|
from pigway_frame_flow.display.wrist_thumbnail import publish
|
||||||
|
|
||||||
|
thumbnail = publish(
|
||||||
|
self, device_id, asset["asset_id"], source_image.get("data")
|
||||||
|
)
|
||||||
|
if thumbnail:
|
||||||
|
result["wrist_thumbnail"] = thumbnail
|
||||||
if asset:
|
if asset:
|
||||||
result["rendered_asset_id"] = asset["asset_id"]
|
result["rendered_asset_id"] = asset["asset_id"]
|
||||||
source = self.db.one(
|
source = self.db.one(
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Source-only rendition for the current 410 × 502 watch display."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.renderers.images import convert
|
||||||
|
from pigway_frame_flow.storage.database import encode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
SIZE = (410, 502)
|
||||||
|
|
||||||
|
|
||||||
|
def publish(service, device_id, asset_id, data=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,)
|
||||||
|
)
|
||||||
|
source = json.loads(row["payload"]) if row else {}
|
||||||
|
if source.get("type") != "photo_source":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if data is None:
|
||||||
|
data, _, _ = service.photo_sources.read(
|
||||||
|
source["source_id"], source["item_id"], target_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],
|
||||||
|
"date": now.date().isoformat(),
|
||||||
|
"timezone": service.settings.timezone,
|
||||||
|
"generated_at": now.isoformat(),
|
||||||
|
"sha256": hashlib.sha256(rendered).hexdigest(),
|
||||||
|
"bytes": len(rendered),
|
||||||
|
"source": source,
|
||||||
|
"fit": "cover",
|
||||||
|
"source_rendition": "preview"
|
||||||
|
if service.photo_sources.get(source["source_id"]).kind == "synology-photos"
|
||||||
|
else "source",
|
||||||
|
}
|
||||||
|
service.db.execute(
|
||||||
|
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
|
||||||
|
("wrist-thumbnail:" + device_id, encode(result)),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except (FlowError, OSError, ValueError):
|
||||||
|
# An auxiliary rendition must never cause a successful upload to replay.
|
||||||
|
logger.warning("Watch thumbnail generation failed for asset %s", asset_id)
|
||||||
|
return None
|
||||||
@@ -44,6 +44,7 @@ class LocalRetention:
|
|||||||
"SELECT payload FROM template_config",
|
"SELECT payload FROM template_config",
|
||||||
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
||||||
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'",
|
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'",
|
||||||
|
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'",
|
||||||
]:
|
]:
|
||||||
payloads += [row["payload"] for row in db.all(query)]
|
payloads += [row["payload"] for row in db.all(query)]
|
||||||
for payload in payloads:
|
for payload in payloads:
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import ErrorCode, FlowError
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def photo(service, settings, legacy_photo_engine):
|
||||||
|
root = settings.data_dir.parent / "photos"
|
||||||
|
root.mkdir()
|
||||||
|
settings.photo_root = root
|
||||||
|
Image.new("RGB", (900, 300), (30, 150, 220)).save(root / "photo.jpg")
|
||||||
|
service.photo_sources.save(
|
||||||
|
"family", PhotoSourceInput(name="Family", kind="local", location=".")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upload(service):
|
||||||
|
operation = service.submit(
|
||||||
|
"display",
|
||||||
|
{
|
||||||
|
"content": {"type": "photo_source", "source_id": "family"},
|
||||||
|
"options": {"priority": 0},
|
||||||
|
},
|
||||||
|
"living-room",
|
||||||
|
"test",
|
||||||
|
"request",
|
||||||
|
)
|
||||||
|
assert service.process_one()
|
||||||
|
return service.operation(operation)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_source_generates_full_bleed_watch_image(service, photo, monkeypatch):
|
||||||
|
# Deliberately decorate the frame rendition; the watch must use source pixels.
|
||||||
|
def captioned(*args, **kwargs):
|
||||||
|
output = BytesIO()
|
||||||
|
Image.new("RGB", kwargs["size"], "black").save(output, "JPEG")
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
read = service.photo_sources.read
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service.photo_sources,
|
||||||
|
"read",
|
||||||
|
lambda *a, **kw: (*read(*a, **kw)[:2], {"title": "Caption"}),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pigway_frame_flow.display.service.render_captioned_photo", captioned
|
||||||
|
)
|
||||||
|
op = upload(service)
|
||||||
|
thumbnail = op["result"]["wrist_thumbnail"]
|
||||||
|
with Image.open(service.assets.path(thumbnail["asset_id"])) as image:
|
||||||
|
assert image.size == (410, 502)
|
||||||
|
assert image.mode == "RGB"
|
||||||
|
assert not image.info.get("progressive")
|
||||||
|
for point in [(0, 0), (409, 501), (205, 251)]:
|
||||||
|
assert image.getpixel(point)[2] > 200
|
||||||
|
row = service.db.one(
|
||||||
|
"SELECT payload FROM app_config WHERE key='wrist-thumbnail:living-room'"
|
||||||
|
)
|
||||||
|
assert json.loads(row["payload"]) == thumbnail
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_upload_does_not_publish_thumbnail(service, photo, monkeypatch):
|
||||||
|
def fail(*args):
|
||||||
|
raise FlowError(ErrorCode.UPLOAD_FAILED, "Rejected", 502)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service.registry.get("living-room"), "upload", fail)
|
||||||
|
op = upload(service)
|
||||||
|
assert op["status"] == "FAILED"
|
||||||
|
assert (
|
||||||
|
service.db.one(
|
||||||
|
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumbnail_failure_does_not_change_upload_outcome(service, photo, monkeypatch):
|
||||||
|
def fail(*args, **kwargs):
|
||||||
|
raise OSError("Image encoder unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr("pigway_frame_flow.display.wrist_thumbnail.convert", fail)
|
||||||
|
op = upload(service)
|
||||||
|
assert op["status"] == "SYNCING"
|
||||||
|
assert "wrist_thumbnail" not in op["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_upload_publishes_and_retention_protects_latest(service, photo):
|
||||||
|
from pigway_frame_flow.extensions.storage import LocalRetention
|
||||||
|
|
||||||
|
operation = service.submit(
|
||||||
|
"local-upload",
|
||||||
|
{"type": "photo_source", "source_id": "family"},
|
||||||
|
"living-room",
|
||||||
|
"test",
|
||||||
|
"local-request",
|
||||||
|
)
|
||||||
|
assert service.process_one()
|
||||||
|
op = service.operation(operation)
|
||||||
|
assert op["status"] == "COMPLETED"
|
||||||
|
asset_id = op["result"]["wrist_thumbnail"]["asset_id"]
|
||||||
|
assert asset_id in LocalRetention(service).protected()
|
||||||
Reference in New Issue
Block a user