Compare commits

..
Author SHA1 Message Date
渡口浪人 a843d4de9b Add documented KoKonna failure policy, bounded retries and durable recovery escalation [skip ci] 2026-10-16 12:25:16 +08:00
渡口浪人 d2f02d8054 Preserve uncertain-write evidence and safe transport diagnostics for v0.1.3 [skip ci] 2026-10-16 10:30:08 +08:00
渡口浪人 8a3c616b5f Fix webhook starvation and isolate scheduled job failures for v0.1.2 [skip ci] 2026-10-16 09:48:51 +08:00
渡口浪人 e1b001a431 Reconcile lost upload responses without replaying frame writes 2026-10-14 13:10:33 +08:00
渡口浪人 e9d7b12c66 Release deployment lock before pruning old production image 2026-10-12 21:12:44 +08:00
渡口浪人 b46fbea4c9 Retain only the verified production latest image 2026-10-12 20:51:08 +08:00
渡口浪人 df1eba74e8 Keep one rollback tag derived from original release 2026-10-11 17:15:56 +08:00
渡口浪人 b41163965e docs: clarify Raspberry Pi project names and integration scope 2026-10-11 16:29:30 +08:00
渡口浪人 aabbff8127 Release version 0.1.1 [skip ci] 2026-10-11 14:43:29 +08:00
渡口浪人 7cdf712829 Reconcile delayed frame sync before scheduled writes 2026-10-11 14:24:32 +08:00
渡口浪人 327fa53d7b Retain current production image and one rollback baseline 2026-10-11 09:14:23 +08:00
Pigway 80c6bf3b7d Document persistent offline reuse of pinned Runner Actions [skip ci] 2026-10-11 07:57:21 +08:00
Pigway b682bdd5b0 Use pinned official Actions without a public Tea mirror 2026-10-11 07:45:43 +08:00
Pigway d109ad6ff1 Clarify verified architecture in bilingual installation guides 2026-10-11 07:29:11 +08:00
Pigway f45fce7d69 Align project introduction with bilingual PIGWay READMEs 2026-10-11 07:24:32 +08:00
Pigway a11a88456c Keep private captures ignored and clarify protected deployment settings 2026-10-11 01:17:05 +08:00
Pigway c9c950de1b Prepare public repository privacy and trusted runner gates 2026-10-11 01:13:55 +08:00
Pigway 9c9697dc6e Clarify shared Photos session and independent source settings 2026-10-10 09:38:27 +08:00
Pigway a869f0b042 Make collage selection automatic and restore dashboard navigation order 2026-10-10 09:28:37 +08:00
Pigway cc4126d23c Improve Photos connection guidance and audit responsive UI spacing 2026-10-10 08:36:23 +08:00
Pigway 4fdb7a4407 Run initial retention checks regardless of host uptime 2026-10-10 01:35:32 +08:00
Pigway 7d10d5b601 Sync published OpenAPI snapshot with integration endpoints 2026-10-10 01:29:07 +08:00
Pigway 2c2ec81d51 Wait for confirmed template display in browser smoke 2026-10-10 01:28:27 +08:00
Pigway 4acd616555 Align integration contract and image generation validation 2026-10-10 01:25:32 +08:00
56 changed files with 7500 additions and 660 deletions
+12 -10
View File
@@ -11,6 +11,8 @@ on:
default: plan
jobs:
verify:
# The NAS Docker runner must never execute code from external forks.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
@@ -20,8 +22,8 @@ jobs:
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
steps:
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/checkout@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v4
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/setup-python@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v5
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.14'
- name: Install CI dependencies with index diagnostics and fallback
@@ -56,12 +58,12 @@ jobs:
PROD_PROJECT: ${{ vars.PROD_PROJECT }}
PROD_CONTAINER: ${{ vars.PROD_CONTAINER }}
PROD_IMAGE_REPO: ${{ vars.PROD_IMAGE_REPO }}
PROD_DATA_DIR: ${{ vars.PROD_DATA_DIR }}
PROD_CONFIG_DIR: ${{ vars.PROD_CONFIG_DIR }}
PROD_PHOTOS_DIR: ${{ vars.PROD_PHOTOS_DIR }}
SYNOLOGY_PHOTOS_URL: ${{ vars.SYNOLOGY_PHOTOS_URL }}
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ vars.SYNOLOGY_PHOTOS_CERT_SHA256 }}
PROD_WEB_BIND: ${{ vars.PROD_WEB_BIND }}
PROD_DATA_DIR: ${{ secrets.PROD_DATA_DIR }}
PROD_CONFIG_DIR: ${{ secrets.PROD_CONFIG_DIR }}
PROD_PHOTOS_DIR: ${{ secrets.PROD_PHOTOS_DIR }}
SYNOLOGY_PHOTOS_URL: ${{ secrets.SYNOLOGY_PHOTOS_URL }}
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }}
PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }}
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
PROD_MOCK: ${{ vars.PROD_MOCK }}
@@ -73,8 +75,8 @@ jobs:
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
steps:
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/checkout@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v4
- uses: https://tea.pigway.com/way/frame-flow-ci-actions/setup-python@bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46 # v5
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.14'
- name: Install deploy tooling
+4
View File
@@ -20,3 +20,7 @@ build-cache/debs/*
!build-cache/debs/.gitkeep
build-cache/wheels/*
!build-cache/wheels/.gitkeep
# Private browser captures and platform metadata
docs/screenshots/
.DS_Store
+54 -320
View File
@@ -1,359 +1,93 @@
# Pigway Frame Flow
Pigway Frame Flow is a lightweight self-hosted platform for programmable smart-frame automation.
[简体中文](README.zh-CN.md) | English
独立运行的智能画框服务:中英文 Web UI、深浅色模式、设备驱动抽象、KoKonna 全部已公开接口、图片处理、模板、SQLite 调度与播放列表、临时覆盖恢复和 REST API。
Pigway Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
KoKonna is a supported device integration and is not affiliated with this project unless otherwise stated.
### Installation
## Quick Start / 快速开始
Deploy with Docker Compose. The current default configuration and verified platform are AMD64:
```sh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.lock
.venv/bin/python scripts/run_local.py
```
Open http://127.0.0.1:18787. The local demo creates a private `.env` with a random
FRAME_FLOW_TOKEN. Read that token locally in an editor and enter it on the login
screen. Do not paste a KoKonna API Key into Web UI. Demo always forces mock mode
and disables real writes. It leaves data persistent in ./data and ./config.
网页使用账号密码登录。通过 `python scripts/create-account.py --username YOUR_NAME` 创建账号,再启动服务;密码不会写入代码或明文配置。勾选记住登录可保持 30 天,设置页面可修改密码或退出。
## Architecture / 架构
```
providers → templates / renderers → generated image
↓
Web UI / external REST → DisplayService → FrameDeviceDriver → KokonnaDriver
Scheduler / Playlist ─────────────┘
SQLite: operations / history / jobs / overrides / assets
```
Package: pigway_frame_flow. Device composition is in core/registry.py;
vendor-specific code is in drivers/kokonna. One process, one worker, no Redis,
message broker, OpenClaw, Home Assistant or external application dependency.
## Configuration / 配置
Copy `.env.example` to `.env`, set a strong FRAME_FLOW_TOKEN, chmod 600 .env.
- KOKONNA_MOCK=true: independent persistent demo, no cloud calls.
- KOKONNA_WRITE_ENABLED=false: live write protection, mandatory before verification.
- KOKONNA_API_KEY: device key, never stored in DB or returned to Web UI.
- *_FILE: optional Docker Secret equivalent.
- WEB_BIND/WEB_PORT: Compose host bind and port. Internal app binds container network.
- CORS_ALLOWED_ORIGINS: exact comma-separated origins; default empty.
- IMAGE_URL_ALLOWED_HOSTS: exact public HTTPS hosts; default disabled. Redirects denied.
- TZ=Asia/Shanghai; DATA_DIR=/data; CONFIG_DIR=/config.
For multiple devices copy deploy/devices.yml.example into config/devices.yml,
reference a separate environment key per device. Jobs bind a device_id. Layout
currently targets 1200 × 1600; incompatible geometry is refused before writes.
## Docker
```sh
```bash
cp .env.example .env
# Configure a token; keep API key empty, mock true, writes false.
# Set a strong FRAME_FLOW_TOKEN; keep mock=true and writes=false initially.
chmod 600 .env
mkdir -p data/images data/generated data/cache config
sudo chown -R 10333:10333 data config
./scripts/build-verify.sh
# Create the browser administrator account; enter its password interactively.
docker compose run --rm --no-deps -e PYTHONPATH=/app \
-v "$PWD/scripts:/bootstrap:ro" --entrypoint python pigway-frame-flow \
/bootstrap/create-account.py --username admin --config-dir /config
docker compose up -d
python3 scripts/smoke-test.py
```
Image pigway-frame-flow:latest, service/container pigway-frame-flow, AMD64,
Python 3.14.5, pinned runtime dependencies, CJK fonts, non-root UID/GID 10333,
read-only root filesystem, writable /data and /config, no capabilities, no host
network, no Docker socket. Restart unless-stopped. JSON-file logs capped 10m × 3.
Healthcheck uses service /health, not cloud availability. No second log database.
Open `http://127.0.0.1:18787` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
## Synology NAS
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
Target `/volume2/docker/pigway-frame-flow/`. See [NAS deployment](docs/NAS.md).
Set WEB_BIND to the NAS LAN IP to restrict the published interface, or explicitly
0.0.0.0 when desired. Port 18787 must be free. No router/firewall/reverse proxy
rules are modified. Source and persistent volumes stay in this project directory.
No artificial logs directory is required. Compose creates its own bridge network.
For a local mock demo, use Python 3.14:
## KoKonna Setup / 真实设备
```bash
python3.14 -m venv .venv
.venv/bin/python -m pip install -r requirements.lock
.venv/bin/python scripts/create-account.py --username admin
.venv/bin/python scripts/run_local.py
```
First deploy Mock and verify persistence. Then set KOKONNA_MOCK=false and fill
KOKONNA_API_KEY through private environment/secret. Keep writes false. /ready checks
configuration/DB/worker/token without a cloud call. Query device and images metadata;
compare model, geometry, timezone, heartbeat, battery, imageId, coverId, sync.
If different, stop and report. **Do not enable writes or perform the single test
until the owner explicitly authorizes it.** Test design: [REAL_DEVICE_TEST.md](docs/REAL_DEVICE_TEST.md).
The demo forces mock mode and disables device writes. Data and configuration remain in `data/` and `config/` across restarts.
## Security
### Features and boundaries
Protected /api/v1 capabilities accept an administrator account session, or external
Frame Flow Bearer auth with scope checks.
SQLite stores token hashes. Device keys live only in env/secret and runtime memory.
Browser login uses SQLite accounts with salted scrypt password hashes and revocable,
HttpOnly/SameSite=Strict cookies (30 days when remembered, 12 hours otherwise).
Mutations require a session CSRF header. Logout and password changes revoke sessions.
Passwords and browser sessions are never stored in localStorage. Bootstrap hashes
in private `config/accounts.json` seed new accounts only; restarting does not undo
a password change. Five failed logins per source IP cause a five-minute lockout.
The browser needs no API Token. Bearer authentication remains compatible for
external clients; the KoKonna key is never used for account login.
The LAN deployment uses HTTP; use an explicitly configured HTTPS endpoint if
transport encryption is needed. No reverse proxy or firewall is changed automatically.
- Chinese and English Web UI, light/dark themes, account login, device dashboard and image previews.
- Local staging and KoKonna cloud gallery, uploads, image switching, stars, source/task filters and batch actions.
- Aspect-ratio-preserving contain/cover/smart conversion, color/grayscale profiles and orientation selection for photo sources.
- Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert.
- Layout Studio for collages/posters with 1–12 images; optional OpenAI-compatible layout planning and source-driven automatic collages.
- Device-bound schedules, ordered/random playlists, temporary priority overrides, restore and persistent operation history.
- Read-only NAS folders, Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own configuration and authorization.
- Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection.
## API
Templates **render images**, including text-only templates. Choose a template, edit its data, generate a preview, then display it. Preview generation and saving template settings do not update the frame. Photo + Text and Daily Dashboard can include a selected local photo; other templates do not automatically choose one. Scheduled template jobs render afresh on each run. See [photo sources](docs/PHOTO_SOURCES.md), [automatic collages](docs/AUTOMATIC_COLLAGE.md) and [integration setup/limits](docs/EXTENSIONS.md).
OpenAPI 3.x at /openapi.json; Swagger at /docs; stable /api/v1. Display/render/upload
return 202 + operation_id. Poll /api/v1/operations/{id}; completion requires matching
image ID and cloud synced flag. Idempotency-Key retained seven days. X-Request-ID
traces history. UNKNOWN writes block later mutations; read-only reconciliation
and explicit admin acknowledgement release the block, never replay a write.
The older /api/display route is retained and deprecated (same v1 payload).
See [external integration contract](docs/integrations/TICKTAR.md).
Real weather uses the configured provider/location and is refreshed when rendered; it does not update an already displayed image by itself. Official KoKonna widgets are separate from Frame Flow templates and require optional vendor-account authorization. API-key access remains independent.
## Scheduler / Playback
### Device integration and API
Cron (5 fields), daily, weekdays, weekends, date and temporary tasks; Shanghai time.
Use source {"type":"restore"} for explicit restore jobs. Unique job/slot and persistent
idempotency prevent duplicate dispatch. Overdue tasks >5 minutes are skipped rather
than unexpectedly overwriting artwork after downtime. State persists across restart.
Overrides use priority then recency; lower priorities wait without uploading.
If another app changes the frame, expired overrides do not overwrite its artwork.
Frame Flow playlists support ordered/random cloud image IDs and >=5-minute intervals;
only one enabled playlist per device. They pause for overrides/unknown writes.
Native cloud slideshow mode/interval are readable; membership/editing are unavailable
in the official API. Avoid having native and Frame Flow playback compete.
```text
Providers → Templates / Renderers → Generated image
Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKonna
↓
SQLite: jobs, operations, history, overrides, assets
```
## Templates / 图片
Configure the device key privately through `.env` or a secret file. Start with `KOKONNA_MOCK=true` and `KOKONNA_WRITE_ENABLED=false`; after mock verification, connect read-only before explicitly enabling writes. Multi-device definitions use `config/devices.yml` and device-specific key references. Vendor code stays in `drivers/kokonna`; other applications use the same service API as the Web UI.
Seven JSON layout templates: daily-dashboard, weather, calendar, quote, photo-text,
daily-poetry and system-alert, with color and grayscale profiles. Every template
renders an image, including templates that contain only text.
The public API is `/api/v1`, with OpenAPI at `/openapi.json` and Swagger UI at `/docs`. Display/render/upload operations return HTTP 202 and an operation ID; poll `/api/v1/operations/{id}` for the outcome. `Idempotency-Key` prevents duplicate dispatch, and `X-Request-ID` links requests to history. An uncertain write blocks subsequent mutations until read-only reconciliation or explicit administrator acknowledgement; it is never automatically replayed.
使用流程:选择模板 → 编辑数据 → 生成预览 → 显示到画框。生成预览只创建本地图片;
保存默认数据只保存配置,两者都不会更新设备。自动化模板任务每次运行重新生成图片。
Ticktar integration is optional. Frame Flow has no runtime dependency on Ticktar and never accesses its database. See the [external integration contract](docs/integrations/TICKTAR.md).
- **照片与文字 / Photo + Text**:标题、中央照片区域、下方正文;与照片数据源任务的底部水印布局不同。
- **家庭早报 / Daily Dashboard**:日期、天气、事项、一句话与所选照片区域。
- **天气 / Weather**:当前天气与三天预报;天气 JSON 留空时使用配置的 Provider。
- **日历、每日诗词、一句话、系统告警**:将相应信息排版为图片,不自动选择图库照片。
### Storage and security
Photos use a local asset ID (`photo_id`); without a selected photo, a placeholder
is rendered. Configure Open-Meteo and location coordinates in **Settings → Weather
source** for real weather. Forecasts are fetched on demand and cached for ten minutes;
unavailable/stale data is labeled explicitly. Weather remains static on the frame
until the next render and display, so use an Automation job for regular updates.
Official KoKonna widgets are a separate integration, not these local templates.
No unreliable lunar date is fabricated.
Contain preserves composition with margins; cover crops centrally; smart is an
edge-saliency heuristic. Always inspect preview; face-aware guarantees are not claimed.
Cloud uploads activate immediately, so gallery-only means **local staging**.
Docker persists `/data/frameflow.db`, images/generated/cache under `/data`, and private configuration/session files under `/config`. It runs as UID/GID 10333 with a read-only root filesystem, its own bridge network, `unless-stopped` restart policy and bounded stdout/stderr logs. No privileged mode, host network or Docker socket is required.
## Development / Testing
Passwords are hashed; browser mutations require CSRF protection. API keys and provider credentials stay server-side and must never enter Git or logs. CORS is disabled unless origins are explicitly configured. `/health` checks the service; `/ready` checks local dependencies without making cloud availability a restart trigger. See [public repository privacy](docs/PUBLICATION.md), [LICENSE](LICENSE) and [NOTICE](NOTICE.md).
```sh
KoKonna is a supported integration. This project is not affiliated with or endorsed by KoKonna.
### Development and known limitations
```bash
.venv/bin/python -m pip install -e '.[test]'
.venv/bin/python -m pytest -q
.venv/bin/python -m ruff check pigway_frame_flow tests scripts
.venv/bin/python -m ruff format --check pigway_frame_flow tests scripts
.venv/bin/python -m mypy pigway_frame_flow
.venv/bin/python -m pytest -q
.venv/bin/python -m pip_audit -r requirements.lock --no-deps --disable-pip
```
Tests mock every cloud request; no real-device writes. scripts/ui-smoke.cjs runs an
isolated headless browser and checks settings.mock before acting. CI builds AMD64,
validates Compose, starts a container, checks health/ready and restarts for persistence.
Tests mock cloud/device calls. CI also verifies an isolated Docker build, readiness, smoke tests, restart and cleanup. External fork PRs do not execute on the production-capable NAS runner.
## Known Limitations / 已知边界
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
Cloud acknowledgements do not prove physical display pixels. Model fields may be
omitted and are explicitly marked configured. No native slideshow membership API,
no cloud upload-only API, no automatic retries after uncertain writes. Opt-in local retention protects manual uploads, stars and active references; its quota is a cleanup target, not a hard disk limit. Rendering assets are local, limited
to 25M pixels/20 MiB. This release uses a single worker and is not multi-replica.
Gallery reads have no pagination because upstream documents none. Web UI hides
secrets and lazy-loads thumbnails; generating a first thumbnail fetches one original.
Scoped client provisioning and bounded signed webhook delivery are available in Connections. Docker build/NAS
status is recorded honestly in docs/ACCEPTANCE.md.
## Roadmap
Browser OAuth login, AI daily-art scheduling, custom ICS timezones/EXRULE and additional hardware-specific adapters remain pending. MQTT, bounded recurrence, token refresh, transition events and single-image AI generation are optional capabilities. See [optional capabilities and exact limits](docs/EXTENSIONS.md).
## Layout Studio and optional integrations
Bilingual Layout Studio imports 1–12 images, edits bounded regions and renders a collage or poster. Optional OpenAI-compatible AI plans receive text and dimensions only. Connections configures provider credentials, calendars/state, retention, client scopes and webhooks. Photo Sources includes NAS, WebDAV, Google Drive, AliPan and Baidu readers. Authorization is separate from implementation; unconnected providers return explicit errors. See [setup and limitations](docs/EXTENSIONS.md).
## Template editing
Templates provide bilingual form fields, event lists, local photo selection, advanced
JSON, color/grayscale previews, persistent defaults and reset to built-in defaults.
Changing content invalidates the previous preview. Weather uses the labeled Mock
provider until a real provider is configured. Live display controls stay disabled
until real writes are separately approved.
Browser smoke tests use a disposable Mock account via FRAME_FLOW_TEST_USERNAME
and FRAME_FLOW_TEST_PASSWORD; never run the mutation smoke suite against live devices.
## Current content and completed page workflows
Dashboard displays the actual current image and recorded content source. Frame Flow
stores immutable template/data/profile snapshots when it renders an asset and maps
successful upload results to the device/image ID. The current-content endpoint
matches the live device image ID; vendor images with no recorded source remain
explicitly unknown. Switching externally never makes the previous template appear
as the current one. Restore also restores the matching content provenance.
Templates can load the current recorded template or continue editing a generated
local image. They support direct photo import and protected image downloads.
Gallery provides search, sorting, dimensions and downloads. Display confirmation
supports duration, priority and restoration. History includes operation details.
Automation offers content pickers, template profiles, previews and manual execution;
playlists offer gallery image checkboxes. Real writes remain blocked by the server
regardless of the UI controls.
Settings persists weather coordinates/provider and image URL host allowlists in
SQLite. Open-Meteo provides current model weather and a three-day forecast, cached
for ten minutes on demand. Network failures show unavailable/stale data and use a
two-minute backoff, without replacing it with fictitious live data. Default remains
Mock until configured in Settings. Open-Meteo attribution is included in the UI and
weather/dashboard renders. Image URL import is HTTPS-only with explicit hostnames,
public-address validation and pinned DNS; redirects and private addresses are blocked.
Additional v1 endpoints: `/devices/{id}/current-content`, `/assets/{id}/source`,
`/providers/weather`, `/settings/weather`, `/settings/image-url`, `/jobs/{id}`,
`/jobs/{id}/preview`, `/jobs/{id}/run` (all under `/api/v1`). Existing contracts remain
compatible; new endpoints are included in generated OpenAPI and contract tests.
## Daily Poetry / 每日诗词
The daily-poetry template includes 20 public-domain classical Chinese poems, selected deterministically by Shanghai date, or manually. Create a daily job from its editor; new poetry jobs are paused by default until you enable them. Custom title, author and verses are editable. This is an independent Frame Flow content provider, not the KoKonna mini-program subscription. Official public API does not expose mini-program subscription/template settings.
每日诗词支持按上海日期选诗、手选 20 首古典诗词、自定义文本以及每日任务。模板显示操作使用统一上传/显示服务;真实写入须开启 KOKONNA_WRITE_ENABLED,结果未知不会自动重试。官方小程序原有每日诗词订阅不能通过当前公开 API 查询或修改。
### 官方账号连接 / Official account connection
在 Templates → 官方小组件中,由管理员输入 KoKonna 官方账号和密码,查询账号下
的设备后手动关联当前 Frame Flow 设备。此流程不绑定或解绑云端设备,也不改变
图片或订阅。现有 API Key 接入继续负责设备、图库和显示能力。
密码不保存;会话与关联信息保存在 `/config/official-{device_id}.secret`,权限 0600。
容器重启可恢复连接。会话过期需要重新登录;断开连接会清除本地会话,不撤销
官方服务器上的其他登录。局域网访问沿用现有部署方式。
Official account connection is admin-only and separate from API Key access.
Login, device discovery, local association and disconnect are available through
`/api/v1/devices/{device_id}/official/connection`. Passwords are never persisted;
server-side sessions are stored as restricted secret files. No official widget
mutation or other extra RPC write is enabled by connecting.
### Photo sources / 照片数据源
Gallery supports read-only NAS folders and HTTPS WebDAV sources, random photo
preview, and device-bound scheduled random photos. Source definitions are persisted
in SQLite. See [setup and limits](docs/PHOTO_SOURCES.md).
NAS 本地目录需要显式只读挂载;网盘先支持通用 WebDAV,也可使用同步到 NAS 的目录。
### Build networking and cache
The Dockerfile retains the pinned Python base, locked Python dependencies,
`fonts-noto-cjk`, Debian repository signature checks, and offline artifact
SHA256 verification. Both production and CI use `scripts/build-verify.sh`,
which enables BuildKit and retains `docker compose build --pull`.
APT mirror hosts are configurable through `DEBIAN_MIRROR` and
`DEBIAN_SECURITY_MIRROR` (hosts only, without scheme or path). Defaults are
`deb.debian.org`; HTTPS is used for both Debian and security repositories.
For mainland-China NAS builds, use the shared profile:
```sh
. ./deploy/build-cn.env
./scripts/build-verify.sh
```
CI loads this same profile, using Tsinghua TUNA for both repositories unless
Gitea repository variables `DEBIAN_MIRROR` / `DEBIAN_SECURITY_MIRROR` override
it. Production may also set the two hosts directly in `.env` when calling the
build script without the profile. An alternate mainland mirror is
`mirrors.ustc.edu.cn`; availability and throughput depend on the Runner network.
No fallback disables signature verification or removes packages.
Layer order stays: pinned base → APT/font dependencies → `requirements.lock`
→ pip dependencies → application/vendor source → build metadata.
A source-only change therefore reuses the APT and pip layers, provided the
same builder, base digest, mirror arguments, and optional offline artifacts
are used. Changing the lockfile rebuilds pip and later layers, not APT.
Changing the mirror or APT instructions rebuilds APT and later layers.
Previously an APT layer miss redownloaded indexes/packages, and pip used
`--no-cache-dir`. Now locked BuildKit mounts retain APT indexes, downloaded
`.deb` files, and pip downloads/wheels outside the final image. Debian's
`docker-clean` hook is disabled so it cannot discard cached packages.
Re-executed APT steps still run `apt-get update` and validate repository
signatures; cached packages and pip artifacts can avoid repeat large downloads.
The initial build after this Dockerfile change will miss dependency layers;
subsequent builds benefit. No measured speedup is claimed before CI completes.
Caches belong to the Docker builder, not the ephemeral CI workspace. Keep the
NAS Runner's builder storage across jobs; removing the builder, pruning caches,
or using a fresh Docker daemon loses the benefit. Cache mounts are disposable:
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
digest check alone does not invalidate dependency layers. To deliberately
refresh installed APT versions, use `docker compose build --pull --no-cache`.
### CI with a host Docker socket
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
The script derives a temporary Compose document from production settings, with
an absolute client-side build context, a UUID project name, a unique CI image
tag, and project-scoped `data` / `config` named volumes. It removes production
bind mounts, the fixed container name, host ports, and the production `.env`
reference. A fresh test token, mock driver, and disabled device writes are
explicitly configured. Docker initializes empty named volumes from the image's
owned `/data` and `/config` directories; the service remains UID/GID 10333.
Build contexts are sent by the Docker client, so the job workspace can remain
inside the job container. Runtime storage is managed by the daemon; no matching
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
nine-endpoint smoke script run inside the service with `compose exec -T`.
Restart is followed by another readiness check and the same smoke test.
A `finally` block removes the test containers, network, volumes, and unique
image tag on success or failure. The workflow records the private temporary
Compose path in `GITHUB_ENV` and repeats cleanup in an `always()` step, covering
normal cancellation/interruption. Failed cleanup retains the manifest so the
fallback can retry. Abrupt host/daemon loss or a forcibly terminated entire job
can prevent both cleanup paths; no CI script can guarantee cleanup in that case.
No global prune or production resource removal is performed. Keep builder
caches: only the run-specific image tag is removed.
### CI Job pip indexes versus Docker build indexes
The Job bootstrap runs `python scripts/ci-install.py` before Ruff/mypy/tests.
It needs only the Python standard library, so it runs before PyYAML or tooling
is installed. `CI_PIP_INDEX_URL` and `CI_PIP_FALLBACK_INDEX_URL` are optional
Gitea repository variables. Defaults are the TUNA HTTPS PyPI simple endpoint
and official `https://pypi.org/simple`. Each is attempted separately; the
fallback repeats the unchanged editable `.[test]` installation, including
PEP 517 build isolation and `setuptools>=75`. No simultaneous extra index is
used, no build dependency is removed, and both failures fail CI.
Logs show inherited index/config/proxy hosts and the selected index host for
each attempt, with URL credentials and query strings removed. Bootstrap
disables inherited pip config files via `PIP_CONFIG_FILE=/dev/null`, explicitly
sets the selected index, and clears no-index/extra-index/find-links, insecure
trusted-host, and no-build-isolation environment settings. Constraints, TLS
certificate environment settings, and proxy settings remain in effect.
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
passed into Docker build arguments or persisted into the production image.
The Dockerfile keeps its own cached/offline dependency installation and logs
the Docker pip index host for online installs. Debian mirror variables affect
APT only and are loaded later in the Docker verification step.
If dependency installation fails, the always() cleanup step still executes.
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
as no Docker verification resources have been created at that point.
Production CD: [safe trial, configuration, and rollback](docs/deployment/GITEA_CD.md).
Related projects: [PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
+93
View File
@@ -0,0 +1,93 @@
# Pigway Frame Flow
简体中文 | [English](README.md)
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
### 安装
使用 Docker Compose 部署。当前默认配置与已验证平台为 AMD64:
```bash
cp .env.example .env
# 设置强 FRAME_FLOW_TOKEN;首次保持 mock=true、writes=false。
chmod 600 .env
mkdir -p data/images data/generated data/cache config
sudo chown -R 10333:10333 data config
./scripts/build-verify.sh
# 创建网页管理员账号,密码通过交互输入。
docker compose run --rm --no-deps -e PYTHONPATH=/app \
-v "$PWD/scripts:/bootstrap:ro" --entrypoint python pigway-frame-flow \
/bootstrap/create-account.py --username admin --config-dir /config
docker compose up -d
```
打开 `http://127.0.0.1:18787`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)和 [Gitea 部署与回滚](docs/deployment/GITEA_CD.md)。不会自动修改防火墙、路由器或反向代理。
使用 Python 3.14 运行本地 Mock 演示:
```bash
python3.14 -m venv .venv
.venv/bin/python -m pip install -r requirements.lock
.venv/bin/python scripts/create-account.py --username admin
.venv/bin/python scripts/run_local.py
```
演示强制使用 Mock,并关闭设备写入。数据与配置保存在 `data/`、`config/`,重启后保留。
### 功能与边界
- 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。
- 本地暂存和 KoKonna 云端图库,上传、切换、星标、来源/任务筛选及批量操作。
- 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
- 只读 NAS 文件夹、Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
天气使用配置的服务和地点,在渲染时更新,不会自行刷新已显示的图片。KoKonna 官方小组件与 Frame Flow 模板是两项独立能力;使用官方小组件需要额外授权厂商账号,API Key 接入仍可单独使用。
### 设备集成与 API
```text
Provider → 模板 / Renderer → 生成图片
Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKonna
↓
SQLite:任务、操作、历史、覆盖状态、图片
```
设备 Key 通过私有 `.env` 或 Secret 文件配置。首次保持 `KOKONNA_MOCK=true`、`KOKONNA_WRITE_ENABLED=false`,Mock 验证后先只读连接,再明确开启写入。多设备使用 `config/devices.yml` 配置,每台设备引用自己的 Key。厂商代码位于 `drivers/kokonna`;Web 界面与外部程序使用相同业务服务。
正式 API 为 `/api/v1`,OpenAPI 位于 `/openapi.json`,Swagger 位于 `/docs`。显示、渲染、上传返回 HTTP 202 和操作 ID,通过 `/api/v1/operations/{id}` 查询结果。`Idempotency-Key` 防止重复派发,`X-Request-ID` 串联请求与历史。写入结果不确定时会阻止后续修改,必须只读核对或由管理员明确确认,不会自动重放写请求。
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
### 存储与安全
Docker 持久化 `/data/frameflow.db`、`/data` 下的图片/生成文件/缓存,以及 `/config` 下的私有配置和会话文件。容器以 UID/GID 10333 运行,根文件系统只读,使用独立桥接网络、`unless-stopped` 重启策略和有大小限制的标准输出日志;不需要 privileged、host network 或 Docker socket。
账号密码保存为哈希,网页修改操作有 CSRF 校验。API Key 和 Provider 凭据仅留在服务端,不得提交 Git 或写入日志。CORS 默认不开启,需明确配置来源。`/health` 检查服务自身,`/ready` 检查本地依赖,不以云端可用性触发重启。参见[公开仓库隐私](docs/PUBLICATION.md)、[LICENSE](LICENSE) 和 [NOTICE](NOTICE.md)。
KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不代表其官方认可。
### 开发与已知限制
```bash
.venv/bin/python -m pip install -e '.[test]'
.venv/bin/python -m ruff check pigway_frame_flow tests scripts
.venv/bin/python -m ruff format --check pigway_frame_flow tests scripts
.venv/bin/python -m mypy pigway_frame_flow
.venv/bin/python -m pytest -q
.venv/bin/python -m pip_audit -r requirements.lock --no-deps --disable-pip
```
测试使用 Mock 云端/设备调用;CI 还验证隔离的 Docker 构建、就绪检查、smoke test、重启与清理。外部 Fork PR 不会在可访问生产环境的 NAS Runner 上执行。
云端同步不等于已确认物理屏幕刷新。KoKonna 云端上传会立即切换,因此“仅上传”使用本地暂存。官方接口不支持编辑原生轮播成员,应避免原生轮播与 Frame Flow 播放同时运行。smart 裁切采用显著性启发式,不保证识别人脸。当前服务使用单 Worker,不面向多副本部署。浏览器 OAuth、AI 每日艺术调度、更多日历重复规则及设备驱动仍属于后续计划。
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
+55
View File
@@ -0,0 +1,55 @@
# Automatic collage
The navigation starts with Dashboard, followed by Gallery, Photo Sources, Upload,
Templates, Layout Studio, Automation, Playlists, History, Connections and Settings.
## Web workflow / 界面操作
在「拼图」选择照片来源与数量(1–12),填写可选标题或排版要求,点击
「随机选图并生成预览」。默认使用启用的数据源;没有数据源时使用本地上传照片。
AI 只接收图片尺寸、内部资产 ID 和排版文字,不发送照片。
AI 未配置或请求失败会明确报错,不会偷偷切换引擎;取消 AI 勾选可用自动网格。
预览不会上传或切换画框,确认效果后再点「显示到画框」。
Choose a source and count, then create a preview. Only dimensions, asset IDs and
layout instructions are sent to AI. AI configuration/errors are explicit; uncheck
AI for deterministic grid layout. Preview never changes the frame. Canvas
orientation follows the target device. Manual layout is under Advanced.
「创建自动化任务」保存来源和数量,而不是固定图片列表。任务初始暂停,
请在自动化中编辑执行时间后启用。每次运行重新随机选图,但不同运行之间可能重复;
单次不重复选同一照片。照片不足时失败,不重复图片凑数。
NAS 来源继续使用人物、方向与质量筛选;一次最多检查数量的五倍候选图片。
输入图缓存与生成图在 NAS;原始 NAS 照片不会被修改或删除。
定时拼图沿用云库每任务保留最近一张成功图的策略,下一张确认成功前不删除上一张。
Create automation stores the source/count, initially paused. Edit its schedule
and enable it. Each execution selects new candidates; repeats across executions
are possible. Too few usable photos fails before upload. Existing unknown-write,
restoration and cloud-retention safeguards apply.
## Public API
`POST /api/v1/render` accepts this additive content type, returning HTTP 202 and
an operation ID. `POST /api/v1/display` uses the same content inside its existing
request envelope. Scheduler `source` accepts the same object; job previews are
read-only.
```json
{
"type": "auto_collage",
"source_id": "nas-photos",
"count": 4,
"title": "Our moments",
"orientation": "auto",
"style": "grid",
"use_ai": true,
"prompt": "Feature one photo with smaller supporting photos",
"profile": "color"
}
```
`source_id: null` chooses local uploaded assets (generated and cache images are
excluded). AI configuration is shared with existing Layout Studio. No new API key
or vendor dependency is introduced. The OpenAPI contract test records the new
content schema while keeping all existing schema fields unchanged.
+103
View File
@@ -0,0 +1,103 @@
# Build networking, cache and CI
### Build networking and cache
The Dockerfile retains the pinned Python base, locked Python dependencies,
`fonts-noto-cjk`, Debian repository signature checks, and offline artifact
SHA256 verification. Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
Both build the same Dockerfile with BuildKit and retain `docker compose build --pull`.
APT mirror hosts are configurable through `DEBIAN_MIRROR` and
`DEBIAN_SECURITY_MIRROR` (hosts only, without scheme or path). Defaults are
`deb.debian.org`; HTTPS is used for both Debian and security repositories.
For mainland-China NAS builds, use the shared profile:
```sh
. ./deploy/build-cn.env
./scripts/build-verify.sh
```
CI loads this same profile, using Tsinghua TUNA for both repositories unless
Gitea repository variables `DEBIAN_MIRROR` / `DEBIAN_SECURITY_MIRROR` override
it. Production may also set the two hosts directly in `.env` when calling the
build script without the profile. An alternate mainland mirror is
`mirrors.ustc.edu.cn`; availability and throughput depend on the Runner network.
No fallback disables signature verification or removes packages.
Layer order stays: pinned base → APT/font dependencies → `requirements.lock`
→ pip dependencies → application/vendor source → build metadata.
A source-only change therefore reuses the APT and pip layers, provided the
same builder, base digest, mirror arguments, and optional offline artifacts
are used. Changing the lockfile rebuilds pip and later layers, not APT.
Changing the mirror or APT instructions rebuilds APT and later layers.
Previously an APT layer miss redownloaded indexes/packages, and pip used
`--no-cache-dir`. Now locked BuildKit mounts retain APT indexes, downloaded
`.deb` files, and pip downloads/wheels outside the final image. Debian's
`docker-clean` hook is disabled so it cannot discard cached packages.
Re-executed APT steps still run `apt-get update` and validate repository
signatures; cached packages and pip artifacts can avoid repeat large downloads.
The initial build after this Dockerfile change will miss dependency layers;
subsequent builds benefit. No measured speedup is claimed before CI completes.
Caches belong to the Docker builder, not the ephemeral CI workspace. Keep the
NAS Runner's builder storage across jobs; removing the builder, pruning caches,
or using a fresh Docker daemon loses the benefit. Cache mounts are disposable:
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
digest check alone does not invalidate dependency layers. To deliberately
refresh installed APT versions, use `docker compose build --pull --no-cache`.
### CI with a host Docker socket
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
The script derives a temporary Compose document from production settings, with
an absolute client-side build context, a UUID project name, a unique CI image
tag, and project-scoped `data` / `config` named volumes. It removes production
bind mounts, the fixed container name, host ports, and the production `.env`
reference. A fresh test token, mock driver, and disabled device writes are
explicitly configured. Docker initializes empty named volumes from the image's
owned `/data` and `/config` directories; the service remains UID/GID 10333.
Build contexts are sent by the Docker client, so the job workspace can remain
inside the job container. Runtime storage is managed by the daemon; no matching
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
nine-endpoint smoke script run inside the service with `compose exec -T`.
Restart is followed by another readiness check and the same smoke test.
A `finally` block removes the test containers, network, volumes, and unique
image tag on success or failure. The workflow records the private temporary
Compose path in `GITHUB_ENV` and repeats cleanup in an `always()` step, covering
normal cancellation/interruption. Failed cleanup retains the manifest so the
fallback can retry. Abrupt host/daemon loss or a forcibly terminated entire job
can prevent both cleanup paths; no CI script can guarantee cleanup in that case.
No global prune or production resource removal is performed. Keep builder
caches: only the run-specific image tag is removed.
### CI Job pip indexes versus Docker build indexes
The Job bootstrap runs `python scripts/ci-install.py` before Ruff/mypy/tests.
It needs only the Python standard library, so it runs before PyYAML or tooling
is installed. `CI_PIP_INDEX_URL` and `CI_PIP_FALLBACK_INDEX_URL` are optional
Gitea repository variables. Defaults are the TUNA HTTPS PyPI simple endpoint
and official `https://pypi.org/simple`. Each is attempted separately; the
fallback repeats the unchanged editable `.[test]` installation, including
PEP 517 build isolation and `setuptools>=75`. No simultaneous extra index is
used, no build dependency is removed, and both failures fail CI.
Logs show inherited index/config/proxy hosts and the selected index host for
each attempt, with URL credentials and query strings removed. Bootstrap
disables inherited pip config files via `PIP_CONFIG_FILE=/dev/null`, explicitly
sets the selected index, and clears no-index/extra-index/find-links, insecure
trusted-host, and no-build-isolation environment settings. Constraints, TLS
certificate environment settings, and proxy settings remain in effect.
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
passed into Docker build arguments or persisted into the production image.
The Dockerfile keeps its own cached/offline dependency installation and logs
the Docker pip index host for online installs. Debian mirror variables affect
APT only and are loaded later in the Docker verification step.
If dependency installation fails, the always() cleanup step still executes.
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
as no Docker verification resources have been created at that point.
+61
View File
@@ -0,0 +1,61 @@
# KoKonna 错误、重试与阻塞处理
核对日期:2026-10-16。依据:[KoKonna OpenAPI 官方文档](https://kokonna.art/pages/openapi)、[Requests 异常定义](https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/)。本页区分厂商契约与 Frame Flow 的本地策略;没有文档保证的行为不作为自动重发依据。
## 官方接口契约
- 设备 API Key 使用 Bearer 鉴权;单设备每分钟上限 20 次,请求超限返回 429。
- 上传成功返回新图片 ID 和计数,并立即激活新图;接口不提供幂等键或上传状态查询契约。
- 删除可能改变当前图片。切换与删除都不能视为天然可重放。
- 404 可表示方法不存在;图片下载时也可表示目标图片不存在。
- 500 既可能是通用服务故障,也可能明确表示设备密钥无效、图片不存在或图片 ID 参数错误。实现只匹配官方明确列出的签名,其他 500 不猜测是否产生副作用。
- 上传请求体上限 50 MB;本服务原图限制仍为 20 MiB,并保留像素上限和 base64 检查。
- 官方文档未承诺服务处理时长、超时后的取消、事务回滚或 Retry-After 返回;不能靠等待固定时间证明写入没有执行。
- Requests 将连接建立超时定义为可安全重试;读取超时不提供相同保证。一般连接异常也不能推定请求未发送。
## 已落实的本地策略
| 结果 | 处理 | 自动重试 | 设备阻塞 |
| --- | --- | --- | --- |
| 未配置密钥 | CONFIG_REQUIRED | 无 | 无 |
| 明确的设备密钥无效 | CONFIG_REQUIRED,保存固定错误分类 | 无 | 无 |
| 明确的图片不存在 | NOT_FOUND | 无 | 无 |
| 明确的图片 ID 参数错误 | INVALID_REQUEST | 无 | 无 |
| 明确的方法不存在 | UNSUPPORTED_CAPABILITY | 无 | 无 |
| 其他 HTTP 4xx 拒绝 | 保存状态码,结束失败 | 除 429 外无 | 无 |
| 本地请求预算耗尽 | 等待预算窗口 | 同一请求最多 3 次尝试 | 不创建 UNKNOWN |
| HTTP 429 | RATE_LIMITED,尊重服务端冷却时间 | 最多 3 次尝试 | 耗尽后失败,不创建 UNKNOWN |
| ConnectTimeout | 确定请求未发送 | 读写均最多 3 次尝试 | 耗尽后失败,不创建 UNKNOWN |
| 只读查询的临时网络错误、通用 5xx、无效 JSON | CLOUD_UNAVAILABLE | 最多 3 次尝试 | 不创建 UNKNOWN |
| TLS 异常 | 保存固定分类 | 无 | 写请求仍保守标为 UNKNOWN |
| 写请求读取超时、一般连接异常、通用 5xx | 无法确定写入结果 | 不重发,只读核对 | UNKNOWN,保留保护 |
| 写请求无效响应或无有效上传 ID | 无法确认成功 | 不重发,只读核对 | UNKNOWN,保留保护 |
“最多 3 次”包含首次请求,重试发生在同一传输请求内,不重新渲染、不重新执行整个操作。普通退避为 1、2 秒;每次实际请求都计入本地 18 次/分钟预算。429 优先使用 Retry-After 秒数或 HTTP 日期;缺失/无效时按 60 秒。单次等待不超过 60 秒,超过此值保留冷却并结束当前尝试,不提前请求。没有无限循环,也不在重启后重放不确定的写入。
操作错误的 details 保存 attempts、retryable、write_outcome,以及 transport_error / http_status / failure_kind 等固定分类。不会保存异常原文、完整下载 URL、带密钥的厂商消息或响应正文。是否重试由结果类别决定,不能仅依据 HTTP 500。
## 未知写入的核对与升级
上传前记录资产、哈希、字节数、名称、上传前图库和时间。响应丢失后只读查找并校验唯一候选;图片相同且设备已同步才能记为成功。已证明上传、但设备持续停留原图时,沿用两次稳定观察的失败关闭规则。
没有候选、候选不唯一、内容不符、设备未同步、查询失败或缺少历史证据,都有独立恢复原因。图库没有目标图片不等于厂商保证从未接受请求,因此不自动把它改为失败或重传。
`GET /api/v1/operations/{operation_id}` 的 `recovery` 返回:
- `state`:checking / manual_review / resolved。
- `reason`:gallery_match_missing、gallery_match_ambiguous、content_mismatch、waiting_device_sync、target_mismatch、read_unavailable、reconciliation_error、missing_upload_evidence 或 manual_reconciliation_required。
- `first_checked_at`、`last_checked_at`、`checks`、`requires_manual_review`。
只读核对每条操作至多每分钟一次。操作持续未知超过 sync_timeout + 300 秒(默认约 15 分钟)时,持久化一次 `operation.blocked` 事件和 `MANUAL_REVIEW_REQUIRED` 历史记录;服务重启、重复检查或原因改变均不会重复升级同一条未解决操作。既有检查继续运行,不自动取消设备保护。
管理员可在 Webhooks 中订阅 `operation.blocked`;未配置订阅不会自动向外发送通知。事件和 API 记录始终保留,但它们不能替代已配置的通知渠道。单个任务失败、单条未知操作的元数据异常均不应中断其他任务处理。
## 人工处理
1. 根据 operation_id 查看原始错误、恢复原因、当前设备和图库;不点击重新执行来猜测结果。
2. 调用现有 reconcile 接口并传 `acknowledge=false` 进行核对。
3. 无法证明成功、但管理员确认可以结束旧操作时,传 `acknowledge=true`:结束为失败,保留原始目标、上传意图和此前错误,记录观察到的图片与人工处理历史。该接口不会重发设备写入。
4. 有目标一致且同步完成的证据才记为成功。处理后恢复原计划,错过的任务不补发。
仍可能因厂商无法给出明确写入结果而需要人工处理;本策略解决错误误判、可安全重试的暂时失败和长期阻塞缺乏解释的问题,不承诺网络故障时所有设备任务都能自动成功。
+25
View File
@@ -0,0 +1,25 @@
# Photo sources and shared connections
Each photo source has a unique ID and independently persisted directory/URL,
orientation, caption, quality, duplicate and person-selection settings. Jobs refer
to the source ID. Updating one source does not change another source.
Local sources read folders under the configured NAS mount. Ordinary file reads
and local Synology thumbnails do not require a Photos login. Person filtering
uses the shared Synology Photos session, intersected with that source's folder
catalog. Shared/personal space and person IDs are saved separately per source.
The current service supports one NAS Photos endpoint and one active account.
Signing in with another account or disconnecting affects every people-filtered
source. Personal-space person IDs may differ between accounts: recheck each
source after an account switch. Multiple independent NAS/account connections
are not supported. Do not interpret multiple folder sources as multiple logins.
WebDAV credentials and native drive credentials belong to their respective
sources and do not use the shared NAS Photos login. Explicitly reusing a WebDAV
credential variable shares that credential, but does not share filter settings.
The UI labels the top card **Shared connection**, displays source IDs and paths
on each source card, and explains each source's connection dependency. Shared
session disconnect is available only in the shared connection card and asks for
confirmation of its impact on all people-filtered sources.
+20
View File
@@ -0,0 +1,20 @@
# Public repository and deployment privacy
Runtime keys, passwords, sessions, private photos, databases and local `.env` files
must remain outside Git. Examples use documentation-only addresses and identities.
Personal deployment reports and screenshots are excluded from public history.
The NAS Docker runner accepts trusted repository events only. External fork PRs
are not executed on this runner; maintainers must review them before copying changes
to a trusted branch. All lint, type, test, audit and isolated Docker checks remain.
Private deployment configuration is stored in Gitea Actions Secrets:
`PROD_DATA_DIR`, `PROD_CONFIG_DIR`, `PROD_PHOTOS_DIR`, `PROD_WEB_BIND`,
`SYNOLOGY_PHOTOS_URL`, `SYNOLOGY_PHOTOS_CERT_SHA256`, and `PRODUCTION_ENV`.
Project/image names, ports, enable flags and mirror settings remain Variables.
Never print complete Docker inspect environment or secret values.
Publishing requires scanning all refs, removing private historic files, pruning
unreachable remote Git objects and archiving/removing historical Actions logs.
A private original mirror is retained outside the repository. Existing clones must
reclone after the privacy history rewrite; do not merge the original history back.
+20
View File
@@ -25,3 +25,23 @@
The API cannot set coverId. Restoration guarantees a requested image switch when
confirmed, not arbitrary cover metadata restoration. Physical screen refresh has
no documented pixel telemetry; visual owner observation supplements cloud synced.
### Upload response reconciliation
Before an upload, the service persists its asset ID, exact SHA256/size, filename,
existing gallery IDs and timestamp. When the response is lost, read-only queries
look for one new image in the upload time window; only an exact downloaded-byte
match identifies it. Ambiguous, missing or altered content remains locked for
manual review. No upload or switch is replayed by reconciliation.
A proven upload completes the display only when the device reports that exact
image and `synced=true`. If a normal display remains on its previous synced
image beyond the sync timeout plus five minutes, two observations at least one
minute apart record `DISPLAY_NOT_CONFIRMED` and unblock future scheduled work.
Temporary overrides retain their restore behavior and are not automatically
closed as failed. Legacy operations without durable upload evidence require
manual acknowledgement; absence from the gallery alone never proves failure.
### 错误与有限重试
完整错误矩阵、重试边界、持久化恢复状态和人工处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。自动重试只允许只读请求、连接建立超时或明确限流拒绝;读取超时等未知写入不会重发。
+3 -2
View File
@@ -1,6 +1,7 @@
# KoKonna API research / 接口调查
Checked 2026-10-01 against https://kokonna.art/pages/openapi.
Rechecked 2026-10-16 against https://kokonna.art/pages/openapi.
错误分类、重试和未知写入处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。
Base: https://api.kokonna.art/openapi. No fallback to legacy galaxyguide.cn.
| Official endpoint | Frame Flow capability |
@@ -29,7 +30,7 @@ headroom at 18. Device state has a 60-second cache, gallery is loaded on demand.
Uploads automatically activate a new image; there is no upload-without-activation
flag. Gallery-only uploads are therefore local staging, not a fictitious cloud
operation. Maximum upstream request size is 50 MB; this service accepts 20 MiB
image files with bounded decoded pixels. No non-idempotent cloud write is retried.
image files with bounded decoded pixels. Only proven-unsent connections or explicit rate-limit rejections may retry a write, within a three-attempt budget. Ambiguous writes are never replayed.
Cloud acknowledgement is not physical refresh confirmation; /device synced and
imageId drive the operation state. Unconfirmed or interrupted writes become UNKNOWN
and block later device writes until read-only reconciliation/explicit acknowledgement.
+36
View File
@@ -0,0 +1,36 @@
# UI layout audit
The Mock-only `scripts/ui-layout-audit.cjs` checks all 11 navigation pages,
local/cloud gallery layouts and six modal states plus expanded manual collage at 1440, 768 and 390 CSS pixels,
in Chinese/English and light/dark themes. It checks page horizontal overflow and
at least 8 pixels between visible input/select/textarea/button rectangles, and
44-pixel control heights on mobile.
Closed details, background content behind dialogs and hidden fields are excluded.
Photos authentication is intercepted with fixture credentials; no NAS login or
frame write occurs. Populated task/history fixtures remain in disposable Mock data.
This audit found and corrected missing upload preview spacing, nested task field
spacing, small mobile action gaps and inconsistent dynamically inserted form
spacing. NAS source forms now hide irrelevant cloud credential fields. Native
drive forms expose their credential field and hide local-only quality controls.
Synology Photos has a dedicated connection card on the Photo Sources page, with
status, an explanation of affected automation and a direct NAS sign-in dialog.
Folder settings and person selection remain separate. Credentials are not saved;
the existing protected session API is used. Successful login does not run a job
or change frame content. Passwords are cleared from the dialog before submission.
Run against an isolated Mock server with the existing Playwright runtime:
```sh
FRAME_FLOW_URL=http://127.0.0.1:18789 \
FRAME_FLOW_ENV_FILE=/path/to/mock.env \
FRAME_FLOW_TEST_USERNAME=test-ui \
FRAME_FLOW_TEST_PASSWORD=test-only-password \
node scripts/ui-layout-audit.cjs
```
Set `NODE_PATH` to the installed Playwright runtime if needed. Optional
`FRAME_FLOW_SCREENSHOT_DIR` saves fixture-only screenshots for visual review.
The full audit passed 229 checks with no console errors. This complements
`ui-smoke.cjs` and `ui-integrations.cjs`; it does not replace functional tests.
+72 -34
View File
@@ -9,8 +9,8 @@ host bind/port. SQLite/images/generated/cache live under `/data`; accounts,
vendor sessions and configuration live under `/config`. Neither is disposable.
The optional photos mount is read-only and has supplementary group access.
Historical repository receipts mention `/volume2/docker/pigway-frame-flow/data`,
`/volume2/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
Historical repository receipts mention `/volume1/docker/pigway-frame-flow/data`,
`/volume1/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
container/project `pigway-frame-flow`. These are **confirmation candidates**, not
script defaults. The deploy action accepts only explicitly configured paths
that match the existing running container on the Runner's Docker daemon.
@@ -24,18 +24,18 @@ configured names and existing bind mounts, with `create_host_path: false`.
## Gitea configuration
Repository Settings → Actions → Variables (ordinary configuration):
Repository Settings → Actions configuration (private paths/addresses use Secrets):
| Variable | Value to explicitly confirm |
| Variable or Secret | Value to explicitly confirm |
| --- | --- |
| `PROD_DEPLOY_ENABLED` | Initially `false`; change to `true` only after plan acceptance |
| `PROD_PROJECT` | Existing Compose project label; candidate `pigway-frame-flow` |
| `PROD_CONTAINER` | Existing container name; candidate `pigway-frame-flow` |
| `PROD_IMAGE_REPO` | Fixed local image repository; suggested `pigway-frame-flow` |
| `PROD_DATA_DIR` | Existing absolute NAS host data path; no default |
| `PROD_CONFIG_DIR` | Existing absolute NAS host config path; no default |
| `PROD_PHOTOS_DIR` | Existing read-only photos source if present; otherwise empty |
| `PROD_WEB_BIND` | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
| `PROD_DATA_DIR` (Secret) | Existing absolute NAS host data path; no default |
| `PROD_CONFIG_DIR` (Secret) | Existing absolute NAS host config path; no default |
| `PROD_PHOTOS_DIR` (Secret) | Existing read-only photos source if present; otherwise empty |
| `PROD_WEB_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
| `PROD_WEB_PORT` | Exact existing mapped port |
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
@@ -75,7 +75,7 @@ Never upload that journal or a full Docker inspect as a public CI artifact.
docker inspect pigway-frame-flow --format '{{json .Mounts}}'
docker inspect pigway-frame-flow --format '{{json .HostConfig.PortBindings}} {{.HostConfig.NetworkMode}}'
```
Confirm the Variables against these actual values, including photos.
Confirm the Variables/Secrets against these actual values, including photos.
2. Configure the Secret privately. Keep `PROD_DEPLOY_ENABLED=false`.
3. Manually run **Verify Frame Flow** on `main`, `deploy_action=plan`.
Verify must pass first. Plan checks resource identity, paths/port, required
@@ -107,7 +107,7 @@ been performed during implementation; those are first-run acceptance steps.
an immutable rollback tag so normal dangling-image cleanup cannot remove it.
4. Refuse a stale checked-out `main` revision that no longer equals remote main.
Build an immutable `release-<commit>-<owner>` tag locally, without a registry
and without changing `latest` or CI image tags.
then point `latest` at the candidate; CI image tags are never changed.
5. Persist a `prepared` journal with old/new runtime configs. Create an additive
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
directory or file is removed, replaced or recursively chowned.
@@ -174,15 +174,15 @@ The confirmed NAS residue is a `created`, never-started container with dead
`/workspace/way/...` bind paths. It is not an adoption/rollback baseline.
No migration from those paths is performed. Production Compose remains independently usable.
Set these repository Actions **Variables** (not Secrets):
Set the ordinary repository Actions Variables below; set paths and addresses as Secrets (see docs/PUBLICATION.md):
```text
PROD_DEPLOY_ENABLED=true
PROD_PROJECT=pigway-frame-flow
PROD_CONTAINER=pigway-frame-flow
PROD_IMAGE_REPO=pigway-frame-flow
PROD_DATA_DIR=/volume2/docker/pigway-frame-flow/data
PROD_CONFIG_DIR=/volume2/docker/pigway-frame-flow/config
PROD_DATA_DIR=/volume1/docker/pigway-frame-flow/data
PROD_CONFIG_DIR=/volume1/docker/pigway-frame-flow/config
PROD_WEB_BIND=192.0.2.10
PROD_WEB_PORT=18787
PROD_STATE_VOLUME=pigway-frame-flow-cd-state
@@ -262,11 +262,11 @@ as an Actions artifact. First bootstrap has no prior healthy version to roll bac
No NAS bootstrap is executed merely by committing this implementation.
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
exact `/volume2/docker/pigway-frame-flow/{data,config}` mounts and
exact `/volume1/docker/pigway-frame-flow/{data,config}` mounts and
`192.0.2.10:18787` binding (not loopback).
4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
is `pigway-frame-flow:release-<commit>-<transaction>`; compare its image ID in
is `pigway-frame-flow:latest`; compare its image ID in
Container Manager with the running container.
5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using
this same runtime and journal mechanism. The first update records bootstrap as
@@ -279,33 +279,56 @@ real host mounts and restart, remains the required live acceptance.
## Actions download transport
Verify and deploy fetch commit-pinned Actions from the supporting repository
`https://tea.pigway.com/way/frame-flow-ci-actions`, subdirectories `checkout`
and `setup-python`, at commit `bada0aa1b7e0d23fe9da66ce2ec9b193c211ea46`.
This avoids unstable NAS downloads from GitHub (EOF/timeouts in runs 27/28)
and slow external Gitea Action preparation (run 29, superseded).
Verify and deploy reference official GitHub Actions directly, pinned to immutable
commits rather than moving tags:
The cache contains unmodified official upstream source, including MIT licenses:
| Action | Official upstream commit |
| --- | --- |
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 |
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 |
| Action | Official upstream commit | Identical Git tree |
| --- | --- | --- |
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 | f8a7b72dc00648d050099727d25ca92a43ad1162 |
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 | 568c6310706725a8b725497dbe6b3b909cbcf6cd |
The workflow uses explicit `https://github.com/actions/...` URLs so Gitea's
configured default Actions host cannot redirect downloads to the project owner's
server. Forks download Actions through their own Runner/network; no public action
mirror is served by this project. The former Tea auxiliary repository is private
and is no longer required by current workflows.
`UPSTREAM.json` records provenance. Upstream tag SHAs were checked using the official
GitHub API; each cached subtree hash was compared to the upstream commit tree.
No application secrets are stored in this public upstream-source cache; its own
Actions are disabled. Intentional action updates must repeat tree verification
and change the workflow pin. All validation steps remain unchanged.
Keep the Runner's action cache and Python toolcache persistent. For Gitea Runner
versions supporting `cache.offline_mode`, use a private Runner configuration:
Python toolcache misses may still require setup-python to download Python from
its upstream release source. This removes Action source download dependence,
not every possible external download. No Runner or NAS network configuration is changed.
```yaml
runner:
file: /data/.runner
cache:
offline_mode: true
```
In the Runner's own Compose service (not the application), set `HOME=/data` and
`CONFIG_FILE=/data/config.yaml`, with `/data` already persisted. Before recreating
an idle Runner, back up its configuration and copy the existing action cache from
`/root/.cache/act` to `/data/.cache/act`. Preserve registration, labels, image,
mounts and networks; do not interrupt active jobs or recreate other services.
Verify cached actions against the pinned upstream commit and tree before reuse.
This is administrator configuration, not a public workflow dependency.
Offline reuse avoids refreshing cached actions from GitHub on each job. Moving
tags/branches may remain at the cached version; this workflow deliberately pins
immutable SHAs. A new action URL or commit requires an initial download or verified
administrator prewarming; an old mirror subtree is not automatically equivalent
to an upstream Git repository. Do not prune Runner caches between jobs. Python
toolcache misses still download Python from its upstream release source. Network
failures remain failures, never reasons to skip validation or silently fall back
to the owner's Tea server.
Administrators needing a mainland-specific download route can configure their
own Runner/network cache privately. Such settings must not introduce public
workflow references to a personal mirror. This change does not modify NAS VPN,
proxy, firewall or other applications. All validation and deploy gates remain.
### Synology Photos dependencies
For a production photo source using Synology Photos person filtering, configure
`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Variables. The
`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Secrets. The
certificate fingerprint must match the configured HTTPS endpoint. Keep the Photos
session in the persistent `/config` directory; never put session credentials in
Variables or repository files.
@@ -340,3 +363,18 @@ the audit client's normal HTTP freshness rules. It is not a permanent offline
snapshot or a cross-job cache. Raw network exceptions are omitted because proxy
URLs may contain credentials. Logs identify the advisory host and whether the
complete audit succeeded.
### Production image retention
Production uses `pigway-frame-flow:latest`. Builds first create a unique release
image, then tag it as latest before activation. The old image is pinned during
an update so a failed deployment can restore it. Only after the new version
passes verification are old release/rollback images and duplicate tags removed.
After successful deployment, only the current production image remains; there
is no retained historical image for manual rollback. Rebuild a historical
revision before manually reverting a successful deployment.
Images referenced by other containers (including stopped containers), images
with unrelated tags, and CI resources remain protected. Cleanup never prunes
volumes, data, or shared build cache. Cleanup failure is a warning and does not
roll back a verified deployment. Failed deployments do not run cleanup.
+17 -8
View File
@@ -17,8 +17,11 @@ Authorization: Bearer FRAME_FLOW_TOKEN. The bootstrap token is provided through
client_id, name, permissions, enabled, created_at, last_used_at. Permissions:
devices:read, images:read, images:delete, display:write, templates:read, render:write,
jobs:read, jobs:write, history:read, settings:write, admin.
0.1.0 seeds one admin token; provisioning scoped client rows is supported by middleware
but multi-token management UI is not implemented. Use a trusted server-to-server
The deployment token seeds an administrator. Administrators can create separate
scoped clients through Connections or POST /api/v1/clients. The returned token is
shown once; GET /api/v1/clients never returns tokens. DELETE /api/v1/clients/{id}
revokes access. For Ticktar, grant devices:read, templates:read, render:write and
display:write; add images:read or jobs permissions only when needed. Use a trusted server-to-server
connection; CORS is opt-in by exact origins, never wildcard by default.
## Discovery and capabilities
@@ -86,13 +89,19 @@ The authenticated client is authoritative; source.client is optional metadata.
History stores client_id/request_id/operation_id/device_id/action/result/timestamp.
No internal endpoints, headers, stacks, paths or secrets are returned.
## Future webhook
## Webhooks
SQLite events and webhook_subscriptions reserve operation.completed/failed,
display.changed, device.online/offline/low_battery events. 0.1.0 emits operation/display
events only; no callback delivery, retry worker or registration API is implemented.
Future deliveries should use signed payloads and bounded retries.
OAuth, tenancy, RBAC management and generated SDKs are future work.
Administrators register callbacks through POST /api/v1/webhooks. Allowed public
HTTPS hosts must be configured explicitly. Durable callbacks cover
operation.completed, operation.failed, display.changed and observed
device.online/device.offline/device.low_battery transitions. Device observation
runs at most once per minute; failed cloud queries do not imply offline.
X-Frame-Flow-Signature: sha256=<hex> is HMAC-SHA256 over the exact request body.
Signing secrets are shown once and stored in mode-0600 files. Verify signatures
and deduplicate event IDs. Delivery retries at most three times; events before
registration are not replayed. Polling Operations remains available.
OAuth, tenancy and generated SDKs are future work.
## Compatibility
+4869 -139
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -29,6 +29,7 @@ from pigway_frame_flow.models import (
Accepted,
Asset,
AssetBatch,
AutoCollageContent,
CurrentDisplay,
DeleteRequest,
Device,
@@ -123,7 +124,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI(
title="Pigway Frame Flow API",
docs_url=None,
version="0.1.0",
version="0.1.4",
lifespan=lifespan,
responses=error_responses,
)
@@ -485,7 +486,7 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("render:write"))],
)
def render(
payload: TemplateContent,
payload: TemplateContent | AutoCollageContent,
request: Request,
idempotency_key: str | None = Header(default=None),
):
@@ -999,7 +1000,7 @@ def create_app(settings=None, start_worker=True):
)
def settings_view():
return SettingsView(
version="0.1.0",
version="0.1.4",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
@@ -1175,9 +1176,11 @@ def create_app(settings=None, start_worker=True):
idempotency_key: str | None = Header(default=None),
):
source = get_job(job_id)["source"]
if source["type"] != "template":
if source["type"] not in ("template", "auto_collage"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Job preview requires a template source", 422
E.UNSUPPORTED_CAPABILITY,
"Job preview requires template or auto-collage content",
422,
)
return submit(
request, "render", source, get_job(job_id)["device_id"], idempotency_key
+2 -2
View File
@@ -23,7 +23,7 @@ class CloudLibrary:
job_id = (
job["job_id"]
if job
and content.get("type") == "photo_source"
and content.get("type") in ("photo_source", "auto_collage")
and content.get("item_id") is None
else None
)
@@ -101,7 +101,7 @@ class CloudLibrary:
return True
def sweep(self):
if time.monotonic() - self.checked < 60:
if self.checked and time.monotonic() - self.checked < 60:
return
self.checked = time.monotonic()
with self.service.lock:
+73
View File
@@ -0,0 +1,73 @@
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
from datetime import UTC, datetime
from uuid import uuid4
from pigway_frame_flow.storage.database import encode, now_iso
class RecoveryMonitor:
def __init__(self, service):
self.service, self.db = service, service.db
def observe(self, op, reason):
timestamp = now_iso()
age = (
datetime.now(UTC) - datetime.fromisoformat(op["created_at"])
).total_seconds()
review = age >= self.service.settings.sync_timeout + 300
with self.db.transaction() as tx:
previous = tx.execute(
"SELECT * FROM operation_recovery WHERE operation_id=?", (op["id"],)
).fetchone()
# Once escalated, wall-clock corrections must not clear the alert.
review = review or bool(previous and previous["state"] == "manual_review")
state = "manual_review" if review else "checking"
tx.execute(
"INSERT INTO operation_recovery VALUES(?,?,?,?,?,1) ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,reason=excluded.reason,last_checked_at=excluded.last_checked_at,checks=operation_recovery.checks+1",
(op["id"], state, reason, timestamp, timestamp),
)
if review and (not previous or previous["state"] != "manual_review"):
tx.execute(
"INSERT INTO events VALUES(?,?,?,?)",
(
uuid4().hex,
"operation.blocked",
encode(
{
"operation_id": op["id"],
"device_id": op["device_id"],
"reason": reason,
"requires_manual_review": True,
}
),
timestamp,
),
)
tx.execute(
"INSERT INTO history(client_id,request_id,operation_id,device_id,action,result,timestamp) VALUES(?,?,?,?,?,?,?)",
(
op["client_id"],
op["request_id"],
op["id"],
op["device_id"],
op["action"],
"MANUAL_REVIEW_REQUIRED",
timestamp,
),
)
def resolve(self, operation_id):
self.db.execute(
"UPDATE operation_recovery SET state='resolved' WHERE operation_id=? AND state!='resolved'",
(operation_id,),
)
def info(self, operation_id):
row = self.db.one(
"SELECT state,reason,first_checked_at,last_checked_at,checks FROM operation_recovery WHERE operation_id=?",
(operation_id,),
)
if row:
row["requires_manual_review"] = row["state"] == "manual_review"
return row
+1 -1
View File
@@ -90,7 +90,7 @@ class Retention:
)
def sweep(self):
if time.monotonic() - self.checked < 3600:
if self.checked and time.monotonic() - self.checked < 3600:
return
self.checked = time.monotonic()
with self.service.lock:
+100 -4
View File
@@ -6,11 +6,14 @@ from datetime import UTC, datetime, timedelta
from uuid import uuid4
from pigway_frame_flow.display.cloud_library import CloudLibrary
from pigway_frame_flow.display.recovery import RecoveryMonitor
from pigway_frame_flow.display.retention import Retention
from pigway_frame_flow.display.upload_reconciliation import UploadReconciliation
from pigway_frame_flow.drivers.base import DriverRegistry
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import (
AutoCollageContent,
DisplayRequest,
FutureContent,
ImageContent,
@@ -48,7 +51,10 @@ class DisplayService:
self.lock = threading.RLock()
self.retention = Retention(self)
self.cloud_library = CloudLibrary(self)
self.recovery = RecoveryMonitor(self)
self.upload_reconciliation = UploadReconciliation(self)
self.expiration_checked: dict[str, float] = {}
self.unknown_checked: dict[str, float] = {}
# A killed write cannot be replayed safely; pre-write work can resume.
db.execute(
"UPDATE operations SET status='UNKNOWN', updated_at=? WHERE write_started=1 AND status IN ('UPLOADING','RENDERING')",
@@ -81,6 +87,11 @@ class DisplayService:
) from None
for region in plan.regions:
self.assets.path(region.asset_id)
if (
isinstance(request.content, AutoCollageContent)
and request.content.source_id
):
self.photo_sources.get(request.content.source_id)
if isinstance(request.content, PhotoSourceContent):
self.photo_sources.get(request.content.source_id)
if writing and not self.settings.mock and not self.settings.write_enabled:
@@ -156,14 +167,16 @@ class DisplayService:
request_id=op["request_id"],
client_id=op["client_id"],
action=op["action"],
recovery=self.recovery.info(id),
)
def update(self, id, status, result=None, error=None):
if result:
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
previous = json.loads(row["result"]) if row and row["result"] else {}
if "photo_attempts" in previous:
result = {**result, "photo_attempts": previous["photo_attempts"]}
for key in ("photo_attempts", "upload_intent"):
if key in previous:
result = {**result, key: previous[key]}
self.db.execute(
"UPDATE operations SET status=?,updated_at=?,result=COALESCE(?,result),error=? WHERE id=?",
(
@@ -174,6 +187,8 @@ class DisplayService:
id,
),
)
if status in ("COMPLETED", "FAILED"):
self.recovery.resolve(id)
def blocked(self, device_id):
return (
@@ -191,6 +206,12 @@ class DisplayService:
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
def content_asset(self, content, device_id=None, operation_id=None):
if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare
return self.content_asset(
prepare(self, content, device_id), device_id, operation_id
)
if isinstance(content, ProviderContent):
values = {**content.data, **self.providers.get(content.provider_id)}
return self.content_asset(
@@ -426,7 +447,7 @@ class DisplayService:
"code": exc.code,
"message": exc.message,
"request_id": op["request_id"],
"details": {},
"details": exc.details,
}
self.update(
op["id"], "UNKNOWN" if uncertain else "FAILED", error=error
@@ -614,6 +635,8 @@ class DisplayService:
raise FlowError(
E.CONFLICT, "Temporary override requires an existing image", 409
)
if asset:
self.upload_reconciliation.prepare(id, driver, asset, previous)
self._start_write(id)
if asset:
self.update(id, "UPLOADING")
@@ -677,6 +700,63 @@ class DisplayService:
)
def sync(self):
with self.lock:
self._sync()
def _sync(self):
# Late acknowledgements only resolve a known display target. Never replay
# writes or infer success from gallery presence alone.
for op in self.db.all(
"SELECT * FROM operations WHERE status='UNKNOWN' AND action!='render-ai' AND write_started=1"
):
expected = json.loads(op["result"] or "{}").get("image_id")
intent = json.loads(op["result"] or "{}").get("upload_intent")
checked = time.monotonic()
if checked - self.unknown_checked.get(op["id"], float("-inf")) < 60:
continue
self.unknown_checked[op["id"]] = checked
if op["action"] != "display" or (not expected and not intent):
self.recovery.observe(
op,
"missing_upload_evidence"
if op["action"] == "display"
else "manual_reconciliation_required",
)
continue
try:
driver = self.registry.get(op["device_id"])
device = driver.device(refresh=True)
if intent:
expected = self.upload_reconciliation.check(op, driver, device)
if (
self.db.one(
"SELECT status FROM operations WHERE id=?", (op["id"],)
)["status"]
== "FAILED"
):
continue
if (
expected
and device.current_content.id == expected
and device.current_content.synced
):
self.update(op["id"], "COMPLETED")
self.cloud_library.confirm(op["id"])
self.db.history(op, "COMPLETED")
self.db.event("operation.completed", {"operation_id": op["id"]})
self.unknown_checked.pop(op["id"], None)
elif expected:
self.recovery.observe(
op,
"waiting_device_sync"
if device.current_content.id == expected
else "target_mismatch",
)
except FlowError:
self.recovery.observe(op, "read_unavailable")
except Exception:
# Bad vendor metadata for one operation must not stop the worker.
self.recovery.observe(op, "reconciliation_error")
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
try:
device = self.registry.get(op["device_id"]).device()
@@ -809,9 +889,25 @@ class DisplayService:
self.update(
id,
"COMPLETED" if confirmed else "FAILED",
{"image_id": device.current_content.id},
op["result"],
error=None
if confirmed
else {
"code": E.DISPLAY_NOT_CONFIRMED
if op["action"] == "display"
else E.WRITE_RESULT_UNKNOWN,
"message": "Manually closed unconfirmed write; no write replayed",
"request_id": op["request_id"],
"details": {
"observed_image_id": device.current_content.id,
"previous_error": op["error"],
},
},
)
resolved = True
if confirmed and op["action"] == "display":
self.cloud_library.confirm(id)
self.db.event("operation.completed", {"operation_id": id})
self.db.history(
dict(
id=id,
@@ -0,0 +1,164 @@
"""Resolve missing upload responses using read-only, exact-content evidence."""
import hashlib
import json
from datetime import UTC, datetime, timedelta
from pigway_frame_flow.errors import ErrorCode
from pigway_frame_flow.storage.database import encode, now_iso
class UploadReconciliation:
def __init__(self, service):
self.service = service
def prepare(self, operation_id, driver, asset, previous):
path = self.service.assets.path(asset["asset_id"])
self.service.update(
operation_id,
"RENDERING",
{
"rendered_asset_id": asset["asset_id"],
"upload_intent": {
"name": asset["name"],
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"size": path.stat().st_size,
"before_ids": [image.image_id for image in driver.images()],
"previous_image_id": previous,
"started_at": now_iso(),
},
},
)
def check(self, op, driver, device):
service = self.service
result = json.loads(op["result"] or "{}")
intent = result.get("upload_intent")
if not intent:
return None
expected = result.get("image_id")
if not expected:
start = datetime.fromisoformat(intent["started_at"])
candidates = []
for image in driver.images():
if (
image.image_id in intent["before_ids"]
or image.name != intent["name"]
):
continue
if image.size_bytes is not None and image.size_bytes != intent["size"]:
continue
if not image.created_at:
continue
try:
created = datetime.fromisoformat(image.created_at)
age = (created - start).total_seconds()
except (ValueError, TypeError):
continue
if 0 <= age <= service.settings.sync_timeout + 300:
candidates.append(image)
# Ambiguous matches never release a write lock.
if len(candidates) != 1:
service.recovery.observe(
op,
"gallery_match_missing"
if not candidates
else "gallery_match_ambiguous",
)
return None
expected = candidates[0].image_id
data = driver.download(expected)
if (
len(data) != intent["size"]
or hashlib.sha256(data).hexdigest() != intent["sha256"]
):
service.recovery.observe(op, "content_mismatch")
return None
result["image_id"] = expected
result["upload_confirmed"] = True
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
service.assets.promote(result["rendered_asset_id"])
source = service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?",
(result["rendered_asset_id"],),
)
service.db.execute(
"INSERT OR REPLACE INTO image_sources VALUES(?,?,?,?,?)",
(
op["device_id"],
expected,
result["rendered_asset_id"],
op["id"],
source["payload"] if source else encode({"type": "image"}),
),
)
if not service.db.one(
"SELECT image_id FROM managed_cloud_uploads WHERE operation_id=?",
(op["id"],),
):
service.cloud_library.register(
op["device_id"], expected, result["rendered_asset_id"], op["id"]
)
if device.current_content.id == expected and device.current_content.synced:
options = json.loads(op["payload"]).get("options", {})
service.db.execute(
"UPDATE device_state SET managed_image_id=? WHERE device_id=?",
(expected, op["device_id"]),
)
if options.get("restore_previous"):
expires = datetime.fromisoformat(intent["started_at"]) + timedelta(
seconds=options["duration"]
)
service.db.execute(
"INSERT OR IGNORE INTO overrides VALUES(?,?,?,?,?,?)",
(
op["id"],
op["device_id"],
expected,
options["priority"],
expires.isoformat(),
intent["started_at"],
),
)
return expected
elapsed = (
datetime.now(UTC) - datetime.fromisoformat(intent["started_at"])
).total_seconds()
if (
result.get("upload_confirmed")
and not json.loads(op["payload"]).get("options", {}).get("restore_previous")
and elapsed > service.settings.sync_timeout + 300
and device.current_content.id == intent["previous_image_id"]
and device.current_content.synced
):
# Upload is proven; display is not. Two separated stable observations
# close the failed display, without replaying either vendor write.
observed = result.get("unchanged_observed_at")
if not observed:
result["unchanged_observed_at"] = now_iso()
service.update(
op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}")
)
elif (
datetime.now(UTC) - datetime.fromisoformat(observed)
).total_seconds() >= 60:
service.update(
op["id"],
"FAILED",
result,
{
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
"message": "Upload confirmed; frame remained on previous image",
"request_id": op["request_id"],
"details": {},
},
)
service.db.history(op, "DISPLAY_NOT_CONFIRMED")
service.db.event(
"operation.failed",
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
)
elif result.get("unchanged_observed_at"):
result.pop("unchanged_observed_at")
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
return expected
+191 -43
View File
@@ -1,7 +1,7 @@
"""Hardened adapter over the MIT-0 guooscar 1.2.0 device/upload methods.
No UI, scheduler, renderer, environment lookup, or constructor cloud calls.
Never retry a write; a timeout may have occurred after cloud acceptance.
Retry only read-only requests or writes proven unsent/rejected; never replay ambiguous writes.
"""
import threading
@@ -19,28 +19,85 @@ BASE_URL = "https://api.kokonna.art/openapi"
class SafeTransport:
MAX_ATTEMPTS = 3
MAX_RETRY_DELAY = 60
def __init__(self, key, session=None):
self.key = key
self.session = session or requests.Session()
self.calls: deque[float] = deque()
self.lock = threading.Lock()
self.cooldown_until = 0.0
def request(self, endpoint, payload=None, write=False, binary=False):
if not self.key:
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
for attempt in range(1, self.MAX_ATTEMPTS + 1):
try:
return self._request_once(endpoint, payload, write, binary)
except FlowError as exc:
exc.details["attempts"] = attempt
delay = max(
2 ** (attempt - 1), exc.details.get("retry_after_seconds", 0)
)
if (
not exc.details.get("retryable")
or attempt == self.MAX_ATTEMPTS
or delay > self.MAX_RETRY_DELAY
):
if exc.details.get("retryable"):
exc.details["retry_exhausted"] = attempt == self.MAX_ATTEMPTS
exc.details["retry_deferred"] = delay > self.MAX_RETRY_DELAY
raise
time.sleep(delay)
raise AssertionError("Retry loop exhausted")
def _reserve(self):
with self.lock:
current = time.monotonic()
while self.calls and current - self.calls[0] >= 60:
self.calls.popleft()
# Reserve capacity beneath the official 20/minute ceiling.
delay = max(0.0, self.cooldown_until - current)
if len(self.calls) >= 18:
raise FlowError(E.RATE_LIMITED, "Device request budget exhausted", 429)
delay = max(delay, 60 - (current - self.calls[0]))
if delay:
raise FlowError(
E.RATE_LIMITED,
"Device request budget exhausted",
429,
details={
"retryable": True,
"retry_after_seconds": delay,
"write_outcome": "not_sent",
"failure_kind": "local_rate_limit",
},
)
self.calls.append(current)
url = BASE_URL + endpoint
@staticmethod
def _retry_after(value):
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
try:
seconds = float(value)
except (ValueError, TypeError):
try:
seconds = (
parsedate_to_datetime(value) - datetime.now(UTC)
).total_seconds()
except (ValueError, TypeError, OverflowError):
return 60.0
import math
return max(0.0, seconds) if math.isfinite(seconds) else 60.0
def _request_once(self, endpoint, payload, write, binary):
if not self.key:
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
self._reserve()
try:
response = self.session.request(
"GET" if binary else "POST",
url,
BASE_URL + endpoint,
headers={}
if binary
else {
@@ -51,49 +108,140 @@ class SafeTransport:
timeout=(5, 120 if endpoint == "/upload" else 30),
allow_redirects=False,
)
except requests.RequestException:
except requests.RequestException as exc:
# Never retain exception text or a URL: binary URLs contain the key.
category = next(
(
label
for kind, label in (
(requests.ConnectTimeout, "connect_timeout"),
(requests.ReadTimeout, "read_timeout"),
(requests.exceptions.SSLError, "tls_error"),
(requests.ConnectionError, "connection_error"),
(requests.Timeout, "timeout"),
)
if isinstance(exc, kind)
),
"request_error",
)
not_sent = isinstance(exc, requests.ConnectTimeout)
uncertain = write and not not_sent
raise FlowError(
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE,
"Cloud write outcome is unknown"
if write
if uncertain
else "Device cloud is unavailable",
502,
details={
"transport_error": category,
"retryable": (not write and category != "tls_error") or not_sent,
"write_outcome": "unknown"
if uncertain
else ("not_sent" if write else "read_only"),
},
) from None
if response.status_code != 200:
code = (
E.RATE_LIMITED
if response.status_code == 429
else (
E.WRITE_RESULT_UNKNOWN
if write and response.status_code >= 500
else E.CLOUD_UNAVAILABLE
)
)
raise FlowError(
code,
"Device cloud request failed",
429 if code == E.RATE_LIMITED else 502,
)
if binary:
if len(response.content) > 20 * 1024 * 1024:
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
return response.content
try:
value = response.json()
if not isinstance(value, dict):
raise ValueError()
# Cloud errors may echo credentials: never return raw messages.
if value.get("success") is False or (
"message" in value and "id" not in value
value = None
if not binary or response.status_code != 200:
try:
value = response.json()
except ValueError:
pass
message = value.get("message") if isinstance(value, dict) else None
status = response.status_code
# Only exact documented business failures establish rejection. Generic
# 5xx responses may arrive after a write; those remain UNKNOWN.
business = None
if status in (404, 500) and isinstance(message, str):
if message == "image not found":
business = (
E.NOT_FOUND,
"Cloud image not found",
404,
"image_not_found",
)
elif status == 500 and message == "imageId is empty":
business = (
E.INVALID_REQUEST,
"Cloud image ID is invalid",
400,
"invalid_image_id",
)
elif status == 500 and message.startswith("can not find robot "):
business = (
E.CONFIG_REQUIRED,
"Device key does not identify a frame",
503,
"device_key_invalid",
)
elif status == 404 and message.startswith("can not find method "):
business = (
E.UNSUPPORTED_CAPABILITY,
"Cloud method not found",
422,
"method_not_found",
)
if business:
code, safe_message, local_status, kind = business
raise FlowError(
code,
safe_message,
local_status,
details={
"http_status": status,
"failure_kind": kind,
"retryable": False,
"write_outcome": "rejected" if write else "read_only",
},
)
if status != 200:
limited = status == 429
rejected = 400 <= status < 500
uncertain = write and not rejected
details = {
"http_status": status,
"retryable": limited or (not write and status >= 500),
"write_outcome": "unknown"
if uncertain
else ("rejected" if write else "read_only"),
}
if limited:
delay = self._retry_after(response.headers.get("Retry-After"))
details["retry_after_seconds"] = delay
with self.lock:
self.cooldown_until = max(
self.cooldown_until, time.monotonic() + delay
)
raise FlowError(
E.RATE_LIMITED
if limited
else (E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE),
"Device cloud request failed",
429 if limited else 502,
details=details,
)
if binary:
if len(response.content) > 20 * 1024 * 1024:
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
return response.content
if (
not isinstance(value, dict)
or value.get("success") is False
or ("message" in value and "id" not in value)
):
raise ValueError()
raise FlowError(
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
"Invalid device cloud response",
502,
details={
"failure_kind": "invalid_response",
"retryable": not write,
"write_outcome": "unknown" if write else "read_only",
},
)
return value
except ValueError:
raise FlowError(
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
"Invalid device cloud response",
502,
) from None
finally:
response.close()
class KokonnaClient:
@@ -118,7 +266,7 @@ class KokonnaClient:
def upload(self, path, name):
self.guard()
value = guooscar_protocol.upload(self.transport, path.read_bytes(), name)
if not isinstance(value.get("id"), int) or value["id"] <= 0:
if type(value.get("id")) is not int or value["id"] <= 0:
raise FlowError(E.WRITE_RESULT_UNKNOWN, "Upload ID not confirmed", 502)
return value["id"]
+3 -1
View File
@@ -11,6 +11,7 @@ class ErrorCode(StrEnum):
UNSUPPORTED_CAPABILITY = "UNSUPPORTED_CAPABILITY"
RENDER_FAILED = "RENDER_FAILED"
UPLOAD_FAILED = "UPLOAD_FAILED"
DISPLAY_NOT_CONFIRMED = "DISPLAY_NOT_CONFIRMED"
WRITE_RESULT_UNKNOWN = "WRITE_RESULT_UNKNOWN"
RATE_LIMITED = "RATE_LIMITED"
INTERNAL_ERROR = "INTERNAL_ERROR"
@@ -22,6 +23,7 @@ class ErrorCode(StrEnum):
class FlowError(Exception):
def __init__(self, code: ErrorCode, message: str, status: int = 400):
def __init__(self, code: ErrorCode, message: str, status: int = 400, details=None):
self.code, self.message, self.status = code, message, status
self.details = details or {}
super().__init__(message)
@@ -146,6 +146,7 @@ def install_ai_images(app, service):
}
).encode(),
limit=service.settings.max_bytes,
timeout=180,
)
result = json.loads(raw)["data"]
if len(result) != 1:
@@ -0,0 +1,80 @@
"""Random selection and layout shared by preview, display and scheduled jobs."""
import random
from io import BytesIO
from PIL import Image, ImageOps
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutStudio
from pigway_frame_flow.models import TemplateContent
def prepare(service, content, device_id=None):
rng = random.SystemRandom()
ids = []
hashes = set()
if content.source_id:
from pigway_frame_flow.extensions.quality import passes, photo_quality
source = service.photo_sources.get(content.source_id)
items = service.photo_sources.catalog(content.source_id)
candidates = rng.sample(items, min(len(items), content.count * 5))
for item in candidates:
try:
data, name, _ = service.photo_sources.read(
content.source_id, item["id"]
)
quality = photo_quality(data)
if not passes(quality, source) or quality["sha256"] in hashes:
continue
if source.avoid_duplicates and quality["fingerprint"] in hashes:
continue
hashes.update([quality["sha256"], quality["fingerprint"]])
with Image.open(BytesIO(data)) as image:
photo = ImageOps.exif_transpose(image).convert("RGB")
photo.thumbnail((2400, 2400))
output = BytesIO()
photo.save(output, "JPEG", quality=92)
asset = service.assets.put(output.getvalue(), name, "cache")
if asset["asset_id"] not in ids:
ids.append(asset["asset_id"])
except (OSError, ValueError):
continue
if len(ids) == content.count:
break
else:
rows = service.db.all(
"SELECT id AS asset_id FROM assets WHERE kind='images' ORDER BY created_at DESC LIMIT 10000"
)
ids = rng.sample(
[row["asset_id"] for row in rows], min(content.count, len(rows))
)
if len(ids) != content.count:
raise FlowError(
E.INVALID_REQUEST, "Not enough distinct photos for requested count", 422
)
values = content.model_dump(exclude={"type", "source_id", "count", "profile"})
if content.orientation == "auto":
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
display = (
service.registry.get(device_id or service.settings.device_id)
.device()
.display
)
width, height = frame_canvas(display)
values["orientation"] = "landscape" if width > height else "portrait"
plan = LayoutStudio(service.renderer).plan(
LayoutInput(
asset_ids=ids,
**values,
)
)
return TemplateContent(
type="template",
template="poster" if content.style == "poster" else "collage",
data={"layout": plan.model_dump()},
profile=content.profile,
)
+1 -1
View File
@@ -86,7 +86,7 @@ class LocalRetention:
}
def sweep(self, force=False, dry_run=False):
if not force and time.monotonic() - self.last < 3600:
if not force and self.last and time.monotonic() - self.last < 3600:
return {}
if not dry_run:
self.last = time.monotonic()
+6 -4
View File
@@ -39,12 +39,13 @@ class Webhooks:
db = self.service.db
for sub in db.all("SELECT * FROM webhook_subscriptions WHERE enabled=1"):
types = json.loads(sub["event_types"])
if not types:
continue
placeholders = ",".join("?" for _ in types)
for event in db.all(
"SELECT * FROM events e WHERE created_at>=? AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
(sub["created_at"], sub["id"]),
f"SELECT * FROM events e WHERE created_at>=? AND event_type IN ({placeholders}) AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
(sub["created_at"], *types, sub["id"]),
):
if event["event_type"] not in types:
continue
db.execute(
"INSERT OR IGNORE INTO webhook_deliveries VALUES(?,?,'QUEUED',0,?,NULL)",
(sub["id"], event["id"], now_iso()),
@@ -137,6 +138,7 @@ def install_webhooks(app, service):
allowed = {
"operation.completed",
"operation.failed",
"operation.blocked",
"display.changed",
"device.online",
"device.offline",
+26 -2
View File
@@ -68,6 +68,19 @@ class PhotoSourceContent(StrictModel):
profile: Profile = Profile.color
class AutoCollageContent(StrictModel):
type: Literal["auto_collage"]
source_id: str | None = Field(default=None, pattern=r"^[a-z0-9-]{1,60}$")
count: int = Field(default=4, ge=1, le=12)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
orientation: Literal["auto", "portrait", "landscape"] = "auto"
style: Literal["grid", "hero", "poster"] = "grid"
use_ai: bool = True
prompt: str = Field(default="", max_length=1500)
profile: Profile = Profile.color
class ProviderContent(StrictModel):
type: Literal["provider"]
provider_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
@@ -86,6 +99,7 @@ Content = Annotated[
| URLContent
| TemplateContent
| PhotoSourceContent
| AutoCollageContent
| ProviderContent
| FutureContent,
Field(discriminator="type"),
@@ -163,7 +177,17 @@ class OperationResult(BaseModel):
deferred: bool = False
class RecoveryState(BaseModel):
state: Literal["checking", "manual_review", "resolved"]
reason: str
first_checked_at: str
last_checked_at: str
checks: int
requires_manual_review: bool
class Operation(BaseModel):
recovery: RecoveryState | None = None
operation_id: str
status: OperationStatus
device_id: str
@@ -322,7 +346,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.0"
version: str = "0.1.4"
class Readiness(BaseModel):
@@ -352,7 +376,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
version: str = "0.1.0"
version: str = "0.1.4"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
+4 -3
View File
@@ -97,7 +97,7 @@ class Assets:
"task"
if job
else "template"
if content.get("type") == "template"
if content.get("type") in ("template", "auto_collage")
else "manual"
if operation["client_id"].startswith("user:")
else "external"
@@ -161,6 +161,7 @@ class Assets:
body=None,
limit=None,
accepted_status=(200,),
timeout=10,
):
limit = limit or self.settings.max_bytes
parsed = urlsplit(url)
@@ -193,12 +194,12 @@ class Assets:
class PinnedHTTPS(http.client.HTTPSConnection):
def connect(self):
sock = socket.create_connection((ip, 443), timeout=10)
sock = socket.create_connection((ip, 443), timeout=timeout)
self.sock = ssl.create_default_context().wrap_socket(
sock, server_hostname=parsed.hostname
)
connection = PinnedHTTPS(parsed.hostname, timeout=10)
connection = PinnedHTTPS(parsed.hostname, timeout=timeout)
try:
connection.request(
method,
+68 -54
View File
@@ -147,61 +147,72 @@ class Scheduler:
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
(current.isoformat(),),
):
job = JobInput.model_validate_json(row["payload"])
slot = row["next_run"]
if self.db.one(
"SELECT * FROM job_runs WHERE job_id=? AND scheduled_at=?",
(row["id"], slot),
):
continue
# Skip overdue tasks (>5 minutes): avoid an unexpected artwork
# overwrite after downtime. One-time tasks are retired too.
late = (current - datetime.fromisoformat(slot)).total_seconds() > 300
operation_id = None
if not late:
action = "restore" if job.source.type == "restore" else "display"
payload = (
{}
if isinstance(job.source, RestoreContent)
else DisplayRequest(
device_id=job.device_id,
content=job.source,
options=Options(
duration=job.display_duration,
restore_previous=job.restore_previous,
priority=job.priority,
cleanup_unstarred=job.cleanup_unstarred,
try:
job = JobInput.model_validate_json(row["payload"])
slot = row["next_run"]
if self.db.one(
"SELECT * FROM job_runs WHERE job_id=? AND scheduled_at=?",
(row["id"], slot),
):
continue
# Skip overdue tasks (>5 minutes): avoid an unexpected artwork
# overwrite after downtime. One-time tasks are retired too.
late = (
current - datetime.fromisoformat(slot)
).total_seconds() > 300
operation_id = None
if not late:
action = (
"restore" if job.source.type == "restore" else "display"
)
payload = (
{}
if isinstance(job.source, RestoreContent)
else DisplayRequest(
device_id=job.device_id,
content=job.source,
options=Options(
duration=job.display_duration,
restore_previous=job.restore_previous,
priority=job.priority,
cleanup_unstarred=job.cleanup_unstarred,
),
).model_dump(mode="json")
)
operation_id = self.service.submit(
action,
payload,
job.device_id,
"scheduler",
"schedule-" + uuid4().hex,
"job-" + row["id"] + "-" + slot,
)
following = next_run(job.schedule, current)
enabled = bool(following)
updated = job.model_copy(update={"enabled": enabled}).model_dump(
mode="json"
)
with self.db.transaction() as tx:
tx.execute(
"INSERT OR IGNORE INTO job_runs VALUES(?,?,?)",
(row["id"], slot, operation_id),
)
tx.execute(
"UPDATE jobs SET payload=?,enabled=?,next_run=?,last_run=?,last_result=? WHERE id=?",
(
encode(updated),
int(enabled),
following,
now_iso(),
"SKIPPED_OVERDUE" if late else operation_id,
row["id"],
),
).model_dump(mode="json")
)
operation_id = self.service.submit(
action,
payload,
job.device_id,
"scheduler",
"schedule-" + uuid4().hex,
"job-" + row["id"] + "-" + slot,
)
following = next_run(job.schedule, current)
enabled = bool(following)
updated = job.model_copy(update={"enabled": enabled}).model_dump(
mode="json"
)
with self.db.transaction() as tx:
tx.execute(
"INSERT OR IGNORE INTO job_runs VALUES(?,?,?)",
(row["id"], slot, operation_id),
)
tx.execute(
"UPDATE jobs SET payload=?,enabled=?,next_run=?,last_run=?,last_result=? WHERE id=?",
(
encode(updated),
int(enabled),
following,
now_iso(),
"SKIPPED_OVERDUE" if late else operation_id,
row["id"],
),
)
except Exception:
# Keep this slot pending for retry or the existing overdue policy.
# A single device must not prevent other jobs or queue consumption.
logging.getLogger("frame-flow").error(
"Scheduled job failed; details suppressed"
)
for row in self.db.all(
"SELECT * FROM playlists WHERE next_run<=?", (current.isoformat(),)
@@ -269,6 +280,9 @@ class Scheduler:
def run(self):
while not self.stop_event.is_set():
try:
# Resolve late sync before submitting due jobs: a write lock
# otherwise makes tick fail before reconciliation can run.
self.service.sync()
self.tick()
self.service.expire()
self.service.cloud_library.sweep()
+1
View File
@@ -31,6 +31,7 @@ class Database:
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS playlists (id TEXT PRIMARY KEY, payload TEXT, position INTEGER, next_run TEXT);
CREATE TABLE IF NOT EXISTS operation_recovery (operation_id TEXT PRIMARY KEY, state TEXT NOT NULL, reason TEXT NOT NULL, first_checked_at TEXT NOT NULL, last_checked_at TEXT NOT NULL, checks INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS operations (
id TEXT PRIMARY KEY, status TEXT NOT NULL, action TEXT NOT NULL,
payload TEXT NOT NULL, device_id TEXT NOT NULL, client_id TEXT NOT NULL,
+1 -1
View File
@@ -3,7 +3,7 @@ const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
const icons={studio:'▦',connections:'↔',dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k;
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
+14 -1
View File
@@ -3,6 +3,7 @@ const tr=(zh,en)=>state.lang==='zh'?zh:en;
async function layoutStudioPage(){
const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null;
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label>${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label>${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label>${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label>${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label>${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label>${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
await autoCollagePanel();
let rendered=null;
const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;};
$('#studio-assets').onchange=()=>{selected=new Set([...document.querySelectorAll('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();};
@@ -47,7 +48,7 @@ async function officialWidgetControls(info,data){
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
}
function addPhotoQualityFields(source){
const box=document.createElement('section');box.innerHTML=`<h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label>${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label>${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label>${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label>${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label><div class="field"><label>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></textarea></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label>${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label>${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label>${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label>${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
}
async function aiImagePanel(){
@@ -56,3 +57,15 @@ async function aiImagePanel(){
async function mqttPanel(){
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label>Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label>Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label>Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label>${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
}
async function autoCollagePanel(){
const sources=await api('/api/v1/photo-sources');const card=document.createElement('section');card.className='card auto-collage';
card.innerHTML=`<h2>${tr('自动拼图','Automatic collage')}</h2><p class="hint">${tr('选择照片来源和数量,系统每次随机选取不同照片,并自动排版生成一张图片。无需手动拼接。预览不会上传或切换画框。','Choose a photo source and count. Each run randomly selects distinct photos and automatically creates one composition. Preview never uploads to or changes the frame.')}</p><form id="auto-collage-form"><div class="form-grid"><div class="field"><label for="collage-source">${tr('照片来源','Photo source')}</label><select id="collage-source"><option value="">${tr('本地手动上传照片','Locally uploaded photos')}</option>${sources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="collage-count">${tr('拼图数量(1–12)','Photo count (1–12)')}</label><input id="collage-count" type="number" min="1" max="12" value="4" required></div></div><div class="field"><label for="collage-title">${tr('标题(可选)','Title (optional)')}</label><input id="collage-title" maxlength="100"></div><div class="field"><label for="collage-prompt">${tr('排版要求(可选)','Layout instructions (optional)')}</label><textarea id="collage-prompt" maxlength="1500"></textarea></div><label class="check-row"><input id="collage-ai" type="checkbox" checked>${tr('AI 自动排版','AI layout')}</label><p class="hint">${tr('AI 需先在连接中心配置,只接收尺寸与文字,不发送照片。未配置时可取消勾选,使用自动网格。','Configure AI in Connections first. Only dimensions and text are sent, never photos. Uncheck AI to use an automatic grid.')}</p><div class="actions"><button type="submit" class="primary">${tr('随机选图并生成预览','Pick photos and create preview')}</button><button id="collage-job" type="button">${tr('创建自动化任务','Create automation')}</button></div></form><div id="collage-preview"></div><div class="actions"><button id="collage-display" hidden>${t('display')}</button></div>`;
$('#main').prepend(card);if(sources.some(x=>x.enabled))$('#collage-source').value=sources.find(x=>x.enabled).id;let asset=null;
$('#auto-collage-form').oninput=()=>{asset=null;$('#collage-display').hidden=true;};
const content=()=>({type:'auto_collage',source_id:$('#collage-source').value||null,count:Number($('#collage-count').value),title:$('#collage-title').value,prompt:$('#collage-prompt').value,use_ai:$('#collage-ai').checked});
$('#auto-collage-form').onsubmit=busy(async e=>{e.preventDefault();asset=null;$('#collage-display').hidden=true;$('#collage-preview').textContent=t('previewLoading');try{const id=await accepted('/api/v1/render',content());const op=await waitOperation(id);asset=op.result;$('#collage-preview').innerHTML='<img class="preview" id="collage-image" alt="Collage">';await blobImage($('#collage-image'),asset.url);$('#collage-display').hidden=false;$('#collage-display').disabled=!liveWriteAllowed();}catch(error){$('#collage-preview').textContent=error.message;}});
bind('collage-display',()=>displayDialog({type:'image',asset_id:asset.asset_id}));
bind('collage-job',async()=>{if(!$('#auto-collage-form').reportValidity())return;await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:$('#collage-title').value||tr('自动随机拼图','Automatic random collage'),enabled:false,schedule:{type:'daily',time:'08:00'},source:content(),display_duration:0,restore_previous:false,priority:50}});await navigate('automation');toast(tr('任务已创建并暂停;编辑时间后启用。每次执行重新随机选图。','Created paused. Edit the schedule, then enable. Each run picks new photos.'));});
const manual=document.createElement('details');manual.className='card manual-collage';manual.innerHTML=`<summary>${tr('高级:手动选图与调整布局','Advanced: manual photos and layout')}</summary>`;const columns=$('#main>.columns');columns.before(manual);manual.append(columns);
}
+26 -8
View File
@@ -1,7 +1,9 @@
'use strict';
// Page features share the same authenticated API and display operation pipeline.
async function currentPanel(container){
const epoch=state.epoch;
const content=await api('/api/v1/devices/'+state.device+'/current-content');
if(epoch!==state.epoch||!container.isConnected)return content;
state.currentDisplay=content;
const known=content.source_known&&content.source?.type==='template';
container.innerHTML=`<h3>${t('currentContent')}</h3><p>#${esc(content.image_id??'—')} · ${t(content.synced?'synced':'syncing')}</p><p>${known?`${t('currentTemplate')}: <strong>${esc(content.source.template)}</strong>`:content.source_known?t('sourceKnown'):t('sourceUnknown')}</p>${known?buttons('edit-current-template','editCurrent'):''}${content.overrides.length?`<div class="notice"><strong>${t('temporaryDisplay')}</strong>${content.overrides.map(x=>`<p>#${esc(x.image_id)} · ${t('priority')}: ${x.priority} · ${fmt(x.expires_at)}</p>`).join('')}</div>`:''}`;
@@ -44,16 +46,17 @@ async function weatherSettings(){
async function enhanceHistory(){
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(current.action)}</p><p>${esc(current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
async function enhanceAutomation(){
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(job.source.type==='template'){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
}
async function jobSourceEditor(job){
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.parentElement.before(advanced);advanced.append(raw.parentElement);
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[],photoSources=[];try{[images,assets,photoSources]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets'),api('/api/v1/photo-sources')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','auto_collage','restore'].map(k=>`<option value="${k}">${k==='auto_collage'?(state.lang==='zh'?'自动随机拼图':'Automatic collage'):k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
$('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove();
function update(){raw.value=JSON.stringify(source,null,2);}
function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;}
if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`<div class="field"><label for="job-field-${k}">${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();}
else if(type==='auto_collage'){source={count:4,use_ai:true,...source,type};panel.innerHTML=`<div class="field"><label for="job-collage-source">${t('photoSources')}</label><select id="job-collage-source"><option value="">${state.lang==='zh'?'本地手动上传照片':'Locally uploaded photos'}</option>${photoSources.filter(x=>x.enabled).map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-collage-count">${state.lang==='zh'?'拼图数量(1–12)':'Photo count (1–12)'}</label><input id="job-collage-count" type="number" min="1" max="12" required value="${source.count}"></div><div class="field"><label for="job-collage-title">${state.lang==='zh'?'标题':'Title'}</label><input id="job-collage-title" maxlength="100" value="${esc(source.title||'')}"></div><div class="field"><label for="job-collage-prompt">${state.lang==='zh'?'排版要求':'Layout instructions'}</label><textarea id="job-collage-prompt" maxlength="1500">${esc(source.prompt||'')}</textarea></div><label class="check-row"><input id="job-collage-ai" type="checkbox" ${source.use_ai?'checked':''}>${state.lang==='zh'?'AI 自动排版(需先配置连接中心)':'AI layout (configure Connections first)'}</label><p class="hint">${state.lang==='zh'?'每次执行重新随机选图;取消 AI 使用自动网格。':'Each run picks new photos; uncheck AI for an automatic grid.'}</p>`;$('#job-collage-source').value=source.source_id||'';for(const id of ['source','count','title','prompt','ai'])$('#job-collage-'+id).oninput=()=>{source={...source,source_id:$('#job-collage-source').value||null,count:Number($('#job-collage-count').value),title:$('#job-collage-title').value,prompt:$('#job-collage-prompt').value,use_ai:$('#job-collage-ai').checked};update();};update();}
else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};}
else if(type==='photo_source'){panel.innerHTML=`<div class="field"><label for="job-photo-source">${t('photoSources')}</label><select id="job-photo-source" required><option value="">—</option>${photoSources.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select><p class="hint">${t('sourceJobHint')}</p></div>`;$('#job-photo-source').value=source.source_id||'';$('#job-photo-source').onchange=()=>{source={type:'photo_source',source_id:$('#job-photo-source').value,fit:'contain',profile:'color'};update();};}
else{panel.innerHTML=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}}
@@ -110,19 +113,34 @@ async function photosPeoplePicker(selected){
const status=await api(path);
if(!status.configured){target.innerHTML=`<p class="hint">${t('photosConfigure')}</p>`;return;}
if(!status.connected){
target.innerHTML=`<div class="field"><label for="photos-username">${t('username')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('password')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
target.innerHTML=`<div class="field"><label for="photos-username">${t('nasUsername')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('nasPassword')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
$('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return;
}
const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared'));
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p><button type="button" id="photos-disconnect">${t('photosDisconnect')}</button>`;
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p>`;
const render=()=>{const query=$('#people-search').value.toLowerCase();const rows=people.filter(p=>(p.named||selected.has(p.id)||$('#people-unnamed').checked)&&(p.name.toLowerCase().includes(query)||String(p.id).includes(query)));$('#people-list').innerHTML=rows.map(p=>`<label class="check-row"><input type="checkbox" data-person="${p.id}" ${selected.has(p.id)?'checked':''}>${esc(p.name)} · ${p.count}</label>`).join('');$('#people-selected').textContent=t('peopleSelected')+selected.size;$('#people-list').querySelectorAll('[data-person]').forEach(box=>box.onchange=()=>{const id=Number(box.dataset.person);box.checked?selected.add(id):selected.delete(id);$('#people-selected').textContent=t('peopleSelected')+selected.size;});};
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render();
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;render();
}
async function photosConnectionPanel(){
const status=await api('/api/v1/integrations/synology-photos');
const box=$('#photos-connection');if(!box)return;
box.innerHTML=`<div class="section-heading"><div><h3>Synology Photos · ${t('photosSharedConnection')}</h3><p class="hint">${t('photosConnectionHelp')}</p></div><span class="badge ${status.connected?'ok':'warning'}">${t(status.connected?'photosConnected':'photosDisconnected')}</span></div><p class="notice ${status.connected?'':'warning'}">${t(!status.configured?'photosSetupHelp':status.connected?'photosConnectedHelp':'photosReconnectHelp')}</p><div class="actions">${buttons('photos-login',status.connected?'photosReconnect':'photosConnect',true)}${buttons('photos-status-refresh','refresh')}${status.connected?buttons('photos-disconnect','photosDisconnect'):''}</div>`;
$('#photos-login').disabled=!status.configured;
bind('photos-login',()=>photosLoginDialog());
bind('photos-status-refresh',()=>photosConnectionPanel());
bind('photos-disconnect',()=>confirmDialog(t('photosSharedDisconnectWarning'),async()=>{await api('/api/v1/integrations/synology-photos',{method:'DELETE'});await navigate('photo-sources');}));
}
function photosLoginDialog(){
showModal(`<h2>${t('photosConnect')}</h2><p class="hint">${t('photosLoginHelp')}</p><form id="photos-login-form"><div class="field"><label for="photos-login-user">${t('nasUsername')}</label><input id="photos-login-user" required autocomplete="username"></div><div class="field"><label for="photos-login-password">${t('nasPassword')}</label><input id="photos-login-password" required type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><p id="photos-login-error" role="alert" hidden></p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('photosConnect')}</button></div></form>`);
$('#photos-login-form').onsubmit=busy(async e=>{e.preventDefault();const password=$('#photos-login-password').value;$('#photos-login-password').value='';try{await api('/api/v1/integrations/synology-photos',{method:'POST',body:{username:$('#photos-login-user').value,password}});$('#modal').close();await photosConnectionPanel();toast(t('photosConnectedHelp'));}catch(error){const box=$('#photos-login-error');box.hidden=false;box.className='notice warning';box.textContent=error.message;}});
}
async function photoSourcesPanel(){
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
await photosConnectionPanel();
bind('add-photo-source',()=>photoSourceForm());
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');}));
@@ -131,8 +149,8 @@ async function photoSourcesPanel(){
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
}
async function photoSourceForm(source=null){
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;addPhotoQualityFields(source);$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=$('#source-kind').value!=='local';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
}
async function browseSource(id,offset=0,refresh=false){
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></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"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.4</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></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"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
+29
View File
@@ -75,3 +75,32 @@ dialog.image-viewer h2.image-viewer-name{font-size:16px;min-width:0;flex:1}
@media(max-width:600px){.image-viewer-footer{flex-wrap:wrap;gap:8px}.image-viewer-name{flex-basis:100%!important}.image-viewer-actions{width:100%;justify-content:flex-end;gap:6px}.image-viewer-actions button{padding:8px 10px}}
.studio-assets{max-height:240px;overflow:auto;margin:18px 0}.studio-canvas{position:relative;width:100%;background:white;margin-bottom:20px}.studio-canvas img{position:absolute;object-fit:cover;cursor:move;touch-action:none;border:2px solid var(--accent)}.region-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.region-fields input{width:100%}#client-secret,#hook-secret{overflow-wrap:anywhere}
/* Shared control rhythm for static forms and dynamically inserted panels. */
[hidden]{display:none!important}
form:not(#login-form){display:flex;flex-direction:column;gap:18px}
form>.field,form>.check-row,form>.hint,form>.notice{margin:0}
.field{display:flex;flex-direction:column;gap:10px}
.field>label,.field>.hint{margin:0}
.field>:is(input,select,textarea)+:is(input,select,textarea){margin-top:0}
.field>.check-row{margin:0}
.form-grid>.field{margin-bottom:0}
.actions,.row-actions,.image-viewer-actions{gap:12px}
#source-people-panel:not(:empty){margin:18px 0;display:flex;flex-direction:column;gap:16px}
#source-people-panel>.field,#source-people-panel>.hint{margin:0}
#source-people-panel>button{align-self:flex-start}
.photos-connection{margin:24px 0;padding:24px;border:1px solid var(--border);border-radius:14px;background:var(--soft)}
.photos-connection .section-heading{margin-bottom:16px}
.photos-connection .notice{margin:0 0 18px}
#source-local-quality{margin-bottom:20px}
@media(max-width:720px){.photos-connection{padding:18px}.actions,.row-actions,.image-viewer-actions{gap:10px}}
.card>.form-grid+button{margin-top:18px}
:not(form)>.field+.form-grid{margin-top:18px}
.photos-connection .badge.warning{color:var(--bad);background:transparent;border:1px solid var(--bad)}
form>.modal-actions{margin-top:8px}
@media(max-width:720px){button,input:not([type=checkbox]):not([type=radio]),select{min-height:44px}.image-card button,.row-actions button,.history-actions button{font-size:14px;padding:9px 12px}.check-row{min-height:44px;align-items:center}}
.auto-collage{margin-bottom:24px}.auto-collage h2{margin-bottom:12px}.auto-collage>p{margin-bottom:20px}.auto-collage #collage-preview:not(:empty){margin-top:20px}.auto-collage>.actions{margin-top:16px}.manual-collage>summary{cursor:pointer;font-weight:600}.manual-collage[open]>summary{margin-bottom:24px}
.source-identity{overflow-wrap:anywhere}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pigway-frame-flow"
version = "0.1.0"
version = "0.1.4"
requires-python = ">=3.12"
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
[project.optional-dependencies]
+99 -3
View File
@@ -37,7 +37,19 @@ class Deploy:
self.lock_id = None
self.state = None
self.old_image = None
self.secret_values = []
self.old_image_tag = ""
self.secret_values = [
self.env[key]
for key in (
"PROD_DATA_DIR",
"PROD_CONFIG_DIR",
"PROD_PHOTOS_DIR",
"PROD_WEB_BIND",
"SYNOLOGY_PHOTOS_URL",
"SYNOLOGY_PHOTOS_CERT_SHA256",
)
if self.env.get(key)
]
self.docker = self.env.get("DOCKER_BIN", "docker")
def run(self, *args, data=None, check=True):
@@ -185,6 +197,7 @@ class Deploy:
):
raise ValueError("Production user/restart policy differs from repository")
self.old_image = old["Image"]
self.old_image_tag = old["Config"].get("Image", "")
volume = self.run(
"volume", "inspect", self.env["PROD_STATE_VOLUME"], check=False
)
@@ -324,6 +337,12 @@ class Deploy:
def rollback(self, previous):
self.activate(previous)
self.verify(previous)
self.run(
"image",
"tag",
previous["services"]["pigway-frame-flow"]["image"],
self.env["PROD_IMAGE_REPO"] + ":latest",
)
print("ROLLBACK VERIFIED", flush=True)
def secret_config(self):
@@ -379,7 +398,9 @@ class Deploy:
command += ["--build-arg", key + "=" + value]
self.run(*command, str(ROOT))
service = config["services"]["pigway-frame-flow"]
service["image"] = image
latest = self.env["PROD_IMAGE_REPO"] + ":latest"
self.run("image", "tag", image, latest)
service["image"] = latest
proposed = self.secret_config()
service["environment"].update(proposed)
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
@@ -653,6 +674,7 @@ class Deploy:
self.state["status"] = "success"
self.state_io(self.state)
print("BOOTSTRAP VERIFIED", flush=True)
self.cleanup_images()
except BaseException:
print("BOOTSTRAP FAILED", flush=True)
self.diagnostics()
@@ -712,6 +734,68 @@ class Deploy:
text = re.sub(r"https?://[^\s\"'<>]+", "[REDACTED-URL]", text)
print("Scoped container logs (redacted):", text, flush=True)
def cleanup_images(self):
"""Keep only latest after verification; protect unrelated container images."""
try:
protected = set()
referenced_tags = set()
container_ids = self.run("ps", "-aq").stdout.decode().split()
if container_ids:
containers = json.loads(self.run("inspect", *container_ids).stdout)
protected.update(container["Image"] for container in containers)
referenced_tags.update(
container.get("Config", {}).get("Image", "")
for container in containers
)
repo = self.env["PROD_IMAGE_REPO"]
image_ids = (
self.run("image", "ls", "--no-trunc", "-q", repo)
.stdout.decode()
.split()
)
if not image_ids:
return
images = json.loads(
self.run("image", "inspect", *sorted(set(image_ids))).stdout
)
removed = 0
for image in images:
tags = image.get("RepoTags") or []
# Shared images and unrelated tags are never cleanup targets.
if not tags:
continue
if not all(
re.fullmatch(
re.escape(repo)
+ r":(?:latest|release-[0-9a-f]{40}-[0-9a-f]{8}(?:-rollback)?|rollback-[0-9a-f]{32})",
tag,
)
for tag in tags
):
continue
latest = repo + ":latest"
if image["Id"] in protected:
# Current production uses latest. Other containers retain
# their referenced tags and images, even when stopped.
if latest not in tags:
continue
tags = [
tag
for tag in tags
if tag != latest and tag not in referenced_tags
]
if not tags:
continue
result = self.run("image", "rm", *tags, check=False)
if result.returncode:
print("IMAGE RETENTION: referenced image retained", flush=True)
else:
removed += 1
print(f"IMAGE RETENTION: removed {removed} obsolete images", flush=True)
except Exception:
# Cleanup failure must never roll back an already verified deployment.
print("IMAGE RETENTION WARNING: cleanup deferred", flush=True)
def deploy(self):
previous = self.preflight()
self.lock()
@@ -721,7 +805,15 @@ class Deploy:
# Refresh baseline under the daemon-level lock.
previous = self.preflight()
version = self.verify(previous)
pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner
release = self.old_image_tag.removesuffix("-rollback")
if re.fullmatch(
re.escape(self.env["PROD_IMAGE_REPO"])
+ r":release-[0-9a-f]{40}-[0-9a-f]{8}",
release,
):
pin = release + "-rollback"
else:
pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner
self.run("image", "tag", self.old_image, pin)
candidate = self.build(json.loads(json.dumps(previous)))
self.state = {
@@ -780,6 +872,10 @@ class Deploy:
if safe_unlock:
self.unlock()
# The deployment lock itself references the old image. Release it
# before retention; other container references remain protected.
self.cleanup_images()
def recover(self, manual=False):
self.settings()
lock = self.run(
+14
View File
@@ -0,0 +1,14 @@
/* Isolated Mock-only automatic collage workflow. */
const assert=require('assert');const fs=require('fs');const{chromium}=require('playwright');
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);const headers={Authorization:'Bearer '+token};assert((await(await fetch(base+'/api/v1/settings',{headers})).json()).mock===true);
const form=new FormData();for(let i=0;i<4;i++)form.append('files',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')],{type:'image/png'}),'fixture-'+i+'.png');assert((await fetch(base+'/api/v1/layout/images',{method:'POST',headers,body:form})).status===201);
const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('#nav button').first().getAttribute('data-page')==='dashboard');
await page.evaluate(()=>navigate('studio'));assert(await page.locator('#studio-plan').isHidden());await page.locator('#collage-ai').uncheck();await page.locator('#collage-count').fill('3');await page.locator('#collage-title').fill('Collage fixture');await page.locator('#auto-collage-form button[type=submit]').click();await page.locator('#collage-image').waitFor({timeout:30000});assert(await page.locator('#collage-display').isVisible());
const uploads=await(await fetch(base+'/api/v1/devices/living-room/images',{headers})).json();assert(uploads.length===1,'Preview must not upload');
await page.locator('#collage-job').click();await page.locator('[data-job-edit]').last().click();await page.locator('#job-collage-count').waitFor();assert(await page.locator('#job-collage-count').inputValue()==='3');assert(await page.locator('#job-collage-ai').isChecked()===false);await page.locator('#modal-cancel').click();
const jobs=await(await fetch(base+'/api/v1/jobs',{headers})).json();const job=jobs.find(j=>j.name==='Collage fixture');assert(job&&job.source.type==='auto_collage'&&job.source.count===3&&!job.enabled);
assert.deepStrictEqual(errors,[]);console.log('Automatic collage: 8 workflow checks passed; no frame writes or console errors');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+2 -2
View File
@@ -12,13 +12,13 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
for(const view of ['studio','connections']){
await page.evaluate(view=>navigate(view),view);await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
await page.evaluate(view=>navigate(view),view);if(view==='studio')await page.locator('.manual-collage summary').click();await page.locator(view==='studio'?'#studio-plan':'#mqtt-config').waitFor();
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth),view+' overflow '+width);checks++;
const gaps=await page.locator('.actions').evaluateAll(rows=>rows.flatMap(row=>{const nodes=[...row.querySelectorAll('button')];return nodes.slice(1).map((node,i)=>{const a=nodes[i].getBoundingClientRect(),b=node.getBoundingClientRect();return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});}));
assert(gaps.every(g=>g>=8),view+' buttons touch '+width);checks++;
}
}
await page.evaluate(()=>navigate('studio'));await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await page.locator('#studio-files').setInputFiles({name:'fixture.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});
await page.locator('#studio-assets input:checked').waitFor();await page.locator('#studio-plan').click();await page.locator('#studio-layout img').waitFor();await page.locator('#studio-render').click();await page.locator('#studio-result').waitFor({timeout:30000});assert(await page.locator('#studio-download').isEnabled());checks++;
await page.locator('#studio-title').fill('Changed');assert(await page.locator('#studio-display').isDisabled());checks++;
assert.deepStrictEqual(errors,[]);checks++;
+34
View File
@@ -0,0 +1,34 @@
/* Full-page and modal layout audit in an isolated Mock service only. */
const assert=require('assert');const fs=require('fs');const {chromium}=require('playwright');
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
assert((await (await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));let connected=false;let logins=0;
await page.route('**/api/v1/integrations/synology-photos',async route=>{if(route.request().method()==='POST'){assert.deepStrictEqual(route.request().postDataJSON(),{username:'fixture-nas',password:'fixture-only'});connected=true;logins++;}await route.fulfill({json:{configured:true,connected}});});
await page.route('**/api/v1/photo-sources',async route=>{assert(route.request().method()==='GET');await route.fulfill({json:[{id:'fixture-source',name:'NAS photos / NAS 照片',kind:'local',location:'.',enabled:false,people_filter:true,orientation:'auto'}]});});
await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
async function inspect(label){
const result=await page.evaluate(()=>{
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),r:e.getBoundingClientRect()}));const close=[];
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);if((x>1&&dy<7.5)||(y>1&&dx<7.5))close.push([controls[i].id,controls[j].id]);}
return {overflow:document.documentElement.scrollWidth>innerWidth,close,small:innerWidth<=720?controls.filter(e=>e.r.height<43.5).map(e=>e.id):[]};
});checks++;if(result.overflow||result.close.length||result.small.length)failures.push({label,...result});
}
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);}
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await inspect(`Manual collage/${width}/${lang}/${theme}`);await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('playlists'));await page.locator('#new-playlist').click();await inspect(`Playlist/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>displayDialog({type:'image',image_id:1}));await inspect(`Display options/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await inspect(`Local gallery/${width}/${lang}/${theme}`);await page.evaluate(()=>{state.tab='cloud';});
if(out&&lang==='zh'&&theme==='dark'){await page.evaluate(()=>navigate('photo-sources'));await page.screenshot({path:out+`/photos-${width}.png`,fullPage:true});}
}
await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await page.locator('#photos-login-user').fill('fixture-nas');await page.locator('#photos-login-password').fill('fixture-only');await page.locator('#photos-login-form button[type=submit]').click();await page.locator('#photos-login').filter({hasText:'Sign in again'}).waitFor();assert(logins===1);checks++;
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 6 modal states, 12 viewport/language/theme combinations; console errors:',errors.length);
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+2 -2
View File
@@ -17,9 +17,9 @@ const fs=require('fs');const path=require('path');const assert=require('assert')
await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++;
await page.locator('#save-local').click();const uploaded=page.locator('.image-card').filter({hasText:uploadName+'.jpg'});await uploaded.waitFor();checks++;
await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++;
await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===7);checks++;
await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===9);checks++;
await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++;
await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++;
await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.waitForFunction(async()=>{const response=await fetch('/api/v1/devices/living-room/current-content');const content=await response.json();return content.source?.data?.title==='Provenance UI title';});await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++;
await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-content-type').waitFor();await page.locator('#job-template').selectOption('quote');checks++;await page.locator('#job-name').fill('UI acceptance');await page.locator('#job-form button[type=submit]').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor();checks++;
await page.locator('[data-job-toggle]').first().click();await page.getByText('Paused',{exact:true}).first().waitFor();checks++;
await page.locator('[data-job-delete]').first().click();await page.locator('#modal-confirm').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor({state:'hidden'});checks++;
+138
View File
@@ -373,6 +373,9 @@
{
"$ref": "#/components/schemas/PhotoSourceContent"
},
{
"$ref": "#/components/schemas/AutoCollageContent"
},
{
"$ref": "#/components/schemas/ProviderContent"
},
@@ -384,6 +387,7 @@
"discriminator": {
"propertyName": "type",
"mapping": {
"auto_collage": "#/components/schemas/AutoCollageContent",
"html": "#/components/schemas/FutureContent",
"image": "#/components/schemas/ImageContent",
"image_url": "#/components/schemas/URLContent",
@@ -609,6 +613,16 @@
},
"Operation": {
"properties": {
"recovery": {
"anyOf": [
{
"$ref": "#/components/schemas/RecoveryState"
},
{
"type": "null"
}
]
},
"operation_id": {
"type": "string",
"title": "Operation Id"
@@ -1337,5 +1351,129 @@
"name"
],
"title": "PhotosPerson"
},
"AutoCollageContent": {
"properties": {
"type": {
"type": "string",
"const": "auto_collage",
"title": "Type"
},
"source_id": {
"anyOf": [
{
"type": "string",
"pattern": "^[a-z0-9-]{1,60}$"
},
{
"type": "null"
}
],
"title": "Source Id"
},
"count": {
"type": "integer",
"maximum": 12.0,
"minimum": 1.0,
"title": "Count",
"default": 4
},
"title": {
"type": "string",
"maxLength": 100,
"title": "Title",
"default": ""
},
"message": {
"type": "string",
"maxLength": 500,
"title": "Message",
"default": ""
},
"orientation": {
"type": "string",
"enum": [
"auto",
"portrait",
"landscape"
],
"title": "Orientation",
"default": "auto"
},
"style": {
"type": "string",
"enum": [
"grid",
"hero",
"poster"
],
"title": "Style",
"default": "grid"
},
"use_ai": {
"type": "boolean",
"title": "Use Ai",
"default": true
},
"prompt": {
"type": "string",
"maxLength": 1500,
"title": "Prompt",
"default": ""
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type"
],
"title": "AutoCollageContent"
},
"RecoveryState": {
"properties": {
"state": {
"type": "string",
"enum": [
"checking",
"manual_review",
"resolved"
],
"title": "State"
},
"reason": {
"type": "string",
"title": "Reason"
},
"first_checked_at": {
"type": "string",
"title": "First Checked At"
},
"last_checked_at": {
"type": "string",
"title": "Last Checked At"
},
"checks": {
"type": "integer",
"title": "Checks"
},
"requires_manual_review": {
"type": "boolean",
"title": "Requires Manual Review"
}
},
"type": "object",
"required": [
"state",
"reason",
"first_checked_at",
"last_checked_at",
"checks",
"requires_manual_review"
],
"title": "RecoveryState"
}
}
+1
View File
@@ -50,6 +50,7 @@ def test_generation_queued_once_and_never_displays(client, service, monkeypatch)
operation = service.operation(first.json()["operation_id"])
assert operation["status"] == "COMPLETED"
assert len(calls) == 1
assert calls[0]["timeout"] == 180
assert json.loads(calls[0]["body"])["n"] == 1
assert service.registry.get("living-room").client.uploads == 0
assert "private-ai-key" not in client.get("/api/v1/settings/ai-images").text
+160
View File
@@ -0,0 +1,160 @@
from io import BytesIO
import pytest
from PIL import Image
from pydantic import ValidationError
from pigway_frame_flow.models import AutoCollageContent
def photos(service, count=4):
for i in range(count):
data = BytesIO()
Image.new("RGB", (320 + i, 200), (i * 50, 60, 100)).save(data, "JPEG")
service.assets.put(data.getvalue(), f"photo-{i}.jpg")
def test_auto_preview_selects_exact_distinct_photos_without_frame_write(
client, service
):
photos(service)
result = client.post(
"/api/v1/render", json={"type": "auto_collage", "count": 3, "use_ai": False}
)
assert result.status_code == 202
service.process_one()
operation = service.operation(result.json()["operation_id"])
assert operation["status"] == "COMPLETED"
asset = operation["result"]["asset_id"]
import json
source = json.loads(
service.db.one("SELECT payload FROM asset_sources WHERE asset_id=?", (asset,))[
"payload"
]
)
regions = source["data"]["layout"]["regions"]
assert len({x["asset_id"] for x in regions}) == 3
assert service.registry.get("living-room").client.uploads == 0
def test_insufficient_photos_and_unconfigured_ai_fail_before_upload(service):
from pigway_frame_flow.errors import FlowError
photos(service, 1)
for content in [
AutoCollageContent(type="auto_collage", count=2, use_ai=False),
AutoCollageContent(type="auto_collage", count=1),
]:
with pytest.raises(FlowError):
service.content_asset(content)
assert service.registry.get("living-room").client.uploads == 0
@pytest.mark.parametrize("count", [0, 13])
def test_count_bounds(count):
with pytest.raises(ValidationError):
AutoCollageContent(type="auto_collage", count=count)
def test_auto_collage_job_contract_and_preview(client, service):
photos(service, 2)
job = client.post(
"/api/v1/jobs",
json={
"name": "Collage",
"device_id": "living-room",
"enabled": False,
"schedule": {"type": "daily", "time": "08:00"},
"source": {"type": "auto_collage", "count": 2, "use_ai": False},
},
)
assert job.status_code == 201
result = client.post("/api/v1/jobs/" + job.json()["id"] + "/preview")
assert result.status_code == 202
service.process_one()
assert service.operation(result.json()["operation_id"])["status"] == "COMPLETED"
assert service.registry.get("living-room").client.uploads == 0
def test_source_selection_respects_quality_and_keeps_inputs_out_of_manual_library(
service, monkeypatch
):
from types import SimpleNamespace
source = SimpleNamespace(
minimum_sharpness=0,
minimum_brightness=50,
maximum_brightness=255,
avoid_duplicates=False,
)
monkeypatch.setattr(service.photo_sources, "get", lambda _: source)
monkeypatch.setattr(
service.photo_sources, "catalog", lambda _: [{"id": str(i)} for i in range(3)]
)
def read(_, item):
data = BytesIO()
Image.new("RGB", (320, 240), (int(item) * 80,) * 3).save(data, "JPEG")
return data.getvalue(), item + ".jpg", None
monkeypatch.setattr(service.photo_sources, "read", read)
content = AutoCollageContent(
type="auto_collage", source_id="nas", count=2, use_ai=False
)
for _ in range(2):
asset = service.content_asset(content)
assert asset["source"] == "generated"
assert (
service.db.one("SELECT COUNT(*) AS n FROM assets WHERE kind='images'")["n"] == 0
)
assert (
service.db.one("SELECT COUNT(*) AS n FROM assets WHERE kind='cache'")["n"] == 4
)
def test_ai_receives_only_selected_asset_metadata(service, monkeypatch):
from pigway_frame_flow.extensions.layout import LayoutStudio, local_plan
photos(service, 3)
calls = []
def plan(_, request):
calls.append(request)
assert request.use_ai is True
assert len(set(request.asset_ids)) == 3
return local_plan(request)
monkeypatch.setattr(LayoutStudio, "plan", plan)
service.content_asset(
AutoCollageContent(type="auto_collage", count=3, title="Family")
)
assert len(calls) == 1
assert calls[0].title == "Family"
assert service.registry.get("living-room").client.uploads == 0
def test_scheduled_collage_executes_once_and_tracks_cloud_task_head(app, service):
from datetime import UTC, datetime, timedelta
from pigway_frame_flow.models import JobInput
photos(service, 3)
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Scheduled collage",
schedule={"type": "daily", "time": "08:00"},
source={"type": "auto_collage", "count": 3, "use_ai": False},
)
)
due = (datetime.now(UTC) - timedelta(seconds=2)).isoformat()
service.db.execute("UPDATE jobs SET next_run=? WHERE id=?", (due, job["id"]))
scheduler.tick()
scheduler.tick()
assert len(service.db.all("SELECT * FROM job_runs")) == 1
service.process_one()
service.sync()
assert service.registry.get("living-room").client.uploads == 1
row = service.db.one("SELECT job_id FROM managed_cloud_uploads")
assert row["job_id"] == job["id"]
+199 -6
View File
@@ -1,3 +1,4 @@
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
@@ -7,11 +8,28 @@ from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
from pigway_frame_flow.errors import FlowError
@pytest.fixture(autouse=True)
def transport_clock(monkeypatch):
import pigway_frame_flow.drivers.kokonna.client as module
clock = SimpleNamespace(now=0.0, waits=[])
def sleep(seconds):
clock.waits.append(seconds)
clock.now += seconds
monkeypatch.setattr(
module, "time", SimpleNamespace(monotonic=lambda: clock.now, sleep=sleep)
)
return clock
def session(body=None, status=200):
s = Mock()
s.request.return_value.status_code = status
s.request.return_value.json.return_value = body or {}
s.request.return_value.content = b"image"
s.request.return_value.headers = {}
return s
@@ -88,7 +106,7 @@ def test_timeout_redaction_no_retry(write):
c.display(1) if write else c.get_device()
assert "secret" not in str(e.value)
assert e.value.code == ("WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE")
assert s.request.call_count == 1
assert s.request.call_count == (1 if write else 3)
@pytest.mark.parametrize("status", [401, 429, 500, 530])
@@ -109,12 +127,187 @@ def test_invalid_upload_id(tmp_path):
assert e.value.code == "WRITE_RESULT_UNKNOWN"
def test_rate_budget():
def test_rate_budget(transport_clock):
s = session({"nickname": "x"})
c = KokonnaClient("secret", session=s)
for _ in range(18):
c.get_device()
with pytest.raises(FlowError) as e:
c.get_device()
assert e.value.code == "RATE_LIMITED"
assert s.request.call_count == 18
c.get_device()
assert s.request.call_count == 19
assert transport_clock.waits == [60]
@pytest.mark.parametrize(
"exception,category",
[
(requests.ConnectTimeout, "connect_timeout"),
(requests.ReadTimeout, "read_timeout"),
(requests.exceptions.SSLError, "tls_error"),
(requests.ConnectionError, "connection_error"),
(requests.Timeout, "timeout"),
(requests.RequestException, "request_error"),
],
)
def test_transport_failure_records_safe_category_without_replay(exception, category):
s = session()
s.request.side_effect = exception("secret https://host/secret")
c = KokonnaClient("secret", True, s)
with pytest.raises(FlowError) as error:
c.display(1)
assert error.value.code == (
"CLOUD_UNAVAILABLE" if category == "connect_timeout" else "WRITE_RESULT_UNKNOWN"
)
assert error.value.details["transport_error"] == category
assert error.value.details["attempts"] == (
3 if category == "connect_timeout" else 1
)
assert "secret" not in str(error.value.details)
assert s.request.call_count == (3 if category == "connect_timeout" else 1)
@pytest.mark.parametrize("write", [False, True])
def test_connect_timeout_retries_then_succeeds(write, transport_clock):
s = session({"success": True})
response = s.request.return_value
s.request.side_effect = [requests.ConnectTimeout("private"), response]
c = KokonnaClient("private", True, s)
c.display(1) if write else c.get_device()
assert s.request.call_count == 2
assert transport_clock.waits == [1]
@pytest.mark.parametrize(
"message,code,kind",
[
("image not found", "NOT_FOUND", "image_not_found"),
("imageId is empty", "INVALID_REQUEST", "invalid_image_id"),
("can not find robot private", "CONFIG_REQUIRED", "device_key_invalid"),
],
)
def test_documented_500_rejection_does_not_lock_or_retry(message, code, kind):
s = session({"message": message}, 500)
c = KokonnaClient("private", True, s)
with pytest.raises(FlowError) as error:
c.display(1)
assert error.value.code == code
assert error.value.details["write_outcome"] == "rejected"
assert error.value.details["failure_kind"] == kind
assert "private" not in str(error.value.details)
assert s.request.call_count == 1
@pytest.mark.parametrize("status", [500, 502, 503, 530])
def test_generic_5xx_read_retries_but_write_never_replays(status):
for write in [False, True]:
s = session({"message": "unknown failure private"}, status)
c = KokonnaClient("private", True, s)
with pytest.raises(FlowError) as error:
c.display(1) if write else c.get_device()
assert s.request.call_count == (1 if write else 3)
assert error.value.code == (
"WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE"
)
assert "private" not in str(error.value.details)
def test_rate_limit_honors_retry_after_and_closes_response(transport_clock):
s = session({"message": "rate limited"}, 429)
first = s.request.return_value
first.headers = {"Retry-After": "7"}
second = session({"success": True}).request.return_value
s.request.side_effect = [first, second]
KokonnaClient("private", True, s).display(1)
assert s.request.call_count == 2
assert transport_clock.waits == [7]
first.close.assert_called_once()
second.close.assert_called_once()
def test_long_retry_after_preserves_cooldown_without_early_request(transport_clock):
s = session({"message": "rate limited"}, 429)
s.request.return_value.headers = {"Retry-After": "120"}
c = KokonnaClient("private", True, s)
for _ in range(2):
with pytest.raises(FlowError) as error:
c.display(1)
assert error.value.code == "RATE_LIMITED"
assert s.request.call_count == 1
assert transport_clock.waits == []
@pytest.mark.parametrize("header", [None, "bad", "nan", "inf"])
def test_invalid_retry_after_defaults_to_one_minute(header):
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
assert SafeTransport._retry_after(header) == 60
def test_retry_after_accepts_http_date():
from datetime import UTC, datetime, timedelta
from email.utils import format_datetime
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
future = format_datetime(datetime.now(UTC) + timedelta(seconds=30), usegmt=True)
assert 28 <= SafeTransport._retry_after(future) <= 30
def test_invalid_write_response_is_never_retried():
s = session({"success": False, "message": "private"})
with pytest.raises(FlowError) as error:
KokonnaClient("private", True, s).display(1)
assert error.value.code == "WRITE_RESULT_UNKNOWN"
assert error.value.details["failure_kind"] == "invalid_response"
assert s.request.call_count == 1
@pytest.mark.parametrize(
"failure,expected",
[
("connect_timeout", "CLOUD_UNAVAILABLE"),
("business_rejection", "CONFIG_REQUIRED"),
("rate_limit", "RATE_LIMITED"),
],
)
def test_proven_upload_failures_end_failed_without_device_lock(
service, monkeypatch, failure, expected
):
from test_display import submit
s = session({"message": "can not find robot private"}, 500)
if failure == "rate_limit":
s.request.return_value.status_code = 429
if failure == "connect_timeout":
s.request.side_effect = requests.ConnectTimeout("private")
c = KokonnaClient("private", True, s)
monkeypatch.setattr(service.registry.get("living-room"), "upload", c.upload)
operation = submit(service)
service.process_one()
op = service.operation(operation)
assert op["status"] == "FAILED"
assert op["error"]["code"] == expected
assert not service.blocked("living-room")
@pytest.mark.parametrize("value", [True, False, 0, -1, "12", None])
def test_invalid_upload_id_never_becomes_confirmed(tmp_path, value):
path = tmp_path / "image.jpg"
path.write_bytes(b"jpeg")
s = session({"id": value})
with pytest.raises(FlowError) as error:
KokonnaClient("private", True, s).upload(path, "image.jpg")
assert error.value.code == "WRITE_RESULT_UNKNOWN"
assert s.request.call_count == 1
def test_ambiguous_failure_after_safe_rejection_stops_retrying(transport_clock):
s = session({"message": "limited"}, 429)
first = s.request.return_value
first.headers = {"Retry-After": "1"}
s.request.side_effect = [first, requests.ReadTimeout("private")]
with pytest.raises(FlowError) as error:
KokonnaClient("private", True, s).display(1)
assert error.value.code == "WRITE_RESULT_UNKNOWN"
assert error.value.details["attempts"] == 2
assert s.request.call_count == 2
assert transport_clock.waits == [1]
+52
View File
@@ -152,3 +152,55 @@ def test_offline(service):
id = submit(service)
service.process_one()
assert service.operation(id)["error"]["code"] == "DEVICE_OFFLINE"
def test_late_sync_resolves_unknown_without_upload_retry(service, monkeypatch):
id = submit(service)
service.process_one()
service.update(id, "UNKNOWN")
driver = service.registry.get("living-room")
calls = []
device = driver.device
monkeypatch.setattr(driver, "device", lambda **kw: calls.append(kw) or device(**kw))
service.sync()
assert service.operation(id)["status"] == "COMPLETED"
assert driver.client.uploads == 1
assert calls == [{"refresh": True}]
assert not service.blocked("living-room")
assert service.db.one(
"SELECT confirmed_at FROM managed_cloud_uploads WHERE operation_id=?", (id,)
)["confirmed_at"]
def test_unknown_wrong_target_stays_locked_and_queries_at_most_once_per_minute(
service, monkeypatch
):
id = submit(service)
service.process_one()
service.update(id, "UNKNOWN")
driver = service.registry.get("living-room")
driver.client.display(1)
driver.invalidate()
calls = []
device = driver.device
monkeypatch.setattr(driver, "device", lambda **kw: calls.append(kw) or device(**kw))
service.sync()
service.sync()
assert service.operation(id)["status"] == "UNKNOWN"
assert service.blocked("living-room")
assert driver.client.uploads == 1
assert len(calls) == 1
def test_unknown_matching_image_without_sync_stays_locked(service, monkeypatch):
id = submit(service)
service.process_one()
service.update(id, "UNKNOWN")
driver = service.registry.get("living-room")
device = driver.device()
device.current_content.synced = False
monkeypatch.setattr(driver, "device", lambda **kw: device)
service.sync()
assert service.operation(id)["status"] == "UNKNOWN"
assert service.blocked("living-room")
assert driver.client.uploads == 1
+27
View File
@@ -373,3 +373,30 @@ def test_ai_layout_sends_metadata_only_and_validates_response(
assert b"test-ai-secret" not in calls[0]["body"]
assert image.getvalue() not in calls[0]["body"]
assert calls[0]["headers"]["Authorization"] == "Bearer test-ai-secret"
def test_webhook_matching_event_after_unrelated_backlog(client, service, monkeypatch):
import json
service.settings.image_url_hosts.append("example.com")
response = client.post(
"/api/v1/webhooks",
json={
"callback_url": "https://example.com/events",
"event_types": ["operation.failed"],
},
)
assert response.status_code == 201
for _ in range(500):
service.db.event("display.changed", {})
service.db.event("operation.failed", {"operation_id": "expected"})
sent = []
monkeypatch.setattr(
service.assets, "fetch_url", lambda url, **kwargs: sent.append(kwargs) or b""
)
service.webhooks.tick()
service.webhooks.last = 0
service.webhooks.tick()
assert len(sent) == 1
assert json.loads(sent[0]["body"])["data"] == {"operation_id": "expected"}
assert len(service.db.all("SELECT * FROM webhook_deliveries")) == 1
+49
View File
@@ -17,3 +17,52 @@ def test_filename_does_not_determine_photo_source(client, service):
client.get("/api/v1/assets?source=uploaded").json()[0]["name"] == "weather.jpg"
)
assert client.get("/api/v1/assets?source=invalid").status_code == 422
def test_multiple_sources_keep_separate_people_and_folder_settings(
service, settings, tmp_path
):
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
root = tmp_path / "photos"
root.mkdir()
(root / "family").mkdir()
(root / "travel").mkdir()
settings.photo_root = root
service.photo_sources.save(
"family",
PhotoSourceInput(
name="Family",
kind="local",
location="family",
people_filter=True,
person_ids=[1],
orientation="portrait",
),
)
service.photo_sources.save(
"travel",
PhotoSourceInput(
name="Travel",
kind="local",
location="travel",
people_filter=True,
person_ids=[2],
orientation="landscape",
),
)
before = service.photo_sources.get("travel").model_dump()
service.photo_sources.save(
"family",
PhotoSourceInput(
name="Family",
kind="local",
location="family",
people_filter=True,
person_ids=[3],
orientation="auto",
),
)
assert service.photo_sources.get("travel").model_dump() == before
assert service.photo_sources.get("family").person_ids == [3]
assert service.photo_sources.get("travel").person_ids == [2]
+70
View File
@@ -98,6 +98,7 @@ def test_preflight_rejects_wrong_host_resources(tool, monkeypatch, change):
def test_deploy_rolls_back_exact_baseline_without_deleting_data(
tool, monkeypatch, failure
):
tool.old_image_tag = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8
old = {"services": {"pigway-frame-flow": {"image": "sha256:previous"}}}
new = {"services": {"pigway-frame-flow": {"image": "candidate"}}}
activated, calls, saved = [], [], []
@@ -111,6 +112,10 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data(
monkeypatch.setattr(
tool, "state_io", lambda state: saved.append(json.loads(json.dumps(state)))
)
cleanup = []
monkeypatch.setattr(
tool, "cleanup_images", lambda: cleanup.append(saved[-1]["status"])
)
verifies = []
def verify(config):
@@ -125,13 +130,17 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data(
if failure:
with pytest.raises(RuntimeError):
tool.deploy()
assert cleanup == []
assert activated == [new, old]
assert saved[0]["previous"] == old
assert ("unlock" in calls) is (failure != "rollback")
else:
tool.deploy()
assert cleanup == ["success"]
assert "unlock" in calls
assert activated == [new]
assert saved[-1]["status"] == "success"
assert saved[-1]["rollback_tag"] == tool.old_image_tag + "-rollback"
assert not any(
isinstance(c, tuple) and ("down" in c or "prune" in c) for c in calls
)
@@ -243,3 +252,64 @@ def test_lock_conflict_never_activates_production(tool, monkeypatch):
monkeypatch.setattr(tool, "run", fail)
with pytest.raises(RuntimeError):
tool.deploy()
def test_image_retention_keeps_latest_and_protects_other_containers(tool, monkeypatch):
tool.state = {"previous_image": "previous"}
release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8
rollback = "pigway-frame-flow:rollback-" + "c" * 32
tool.state["rollback_tag"] = release + "-rollback"
images = [
{"Id": "current", "RepoTags": ["pigway-frame-flow:latest", release]},
{"Id": "previous", "RepoTags": [release + "-rollback", release, rollback]},
{"Id": "stopped", "RepoTags": [release]},
{"Id": "obsolete", "RepoTags": [release, rollback]},
{"Id": "shared", "RepoTags": [release, "other:latest"]},
{"Id": "ci", "RepoTags": ["ffci-fixture:latest"]},
]
calls = []
def run(*args, **kwargs):
calls.append(args)
if args == ("ps", "-aq"):
data = "container1 container2"
elif args[0] == "inspect":
data = json.dumps(
[
{
"Image": "current",
"Config": {"Image": "pigway-frame-flow:latest"},
},
{"Image": "stopped"},
]
)
elif args[:2] == ("image", "ls"):
data = "current previous stopped obsolete shared ci"
elif args[:2] == ("image", "inspect"):
data = json.dumps(images)
else:
data = ""
return SimpleNamespace(stdout=data.encode(), returncode=0)
monkeypatch.setattr(tool, "run", run)
tool.cleanup_images()
assert [c for c in calls if c[:2] == ("image", "rm")] == [
("image", "rm", release),
("image", "rm", release + "-rollback", release, rollback),
("image", "rm", release, rollback),
]
def test_image_retention_failure_does_not_fail_verified_deploy(
tool, monkeypatch, capsys
):
tool.state = {"previous_image": "previous"}
def unavailable(*args, **kwargs):
raise RuntimeError("private diagnostic")
monkeypatch.setattr(tool, "run", unavailable)
tool.cleanup_images()
output = capsys.readouterr().out
assert "cleanup deferred" in output
assert "private diagnostic" not in output
+36
View File
@@ -0,0 +1,36 @@
from pathlib import Path
def test_external_forks_do_not_execute_on_nas_runner():
workflow = Path(".github/workflows/verify.yml").read_text()
assert (
"github.event.pull_request.head.repo.full_name == github.repository" in workflow
)
assert "needs: verify" in workflow
def test_private_production_configuration_uses_secrets():
workflow = Path(".github/workflows/verify.yml").read_text()
for key in (
"PROD_DATA_DIR",
"PROD_CONFIG_DIR",
"PROD_PHOTOS_DIR",
"PROD_WEB_BIND",
"SYNOLOGY_PHOTOS_URL",
"SYNOLOGY_PHOTOS_CERT_SHA256",
):
assert "${{ secrets." + key + " }}" in workflow
assert "${{ vars." + key + " }}" not in workflow
def test_actions_downloads_do_not_depend_on_personal_server():
import re
workflow = Path(".github/workflows/verify.yml").read_text()
actions = re.findall(r"uses:\s*(\S+)", workflow)
assert len(actions) == 4
for action in actions:
assert re.fullmatch(
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
action,
)
+109
View File
@@ -0,0 +1,109 @@
from datetime import UTC, datetime, timedelta
from test_display import submit
from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode, FlowError
def unknown_upload(service, monkeypatch):
def fail(*args):
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Response lost", 502)
monkeypatch.setattr(service.registry.get("living-room"), "upload", fail)
operation = submit(service)
service.process_one()
return operation
def test_missing_upload_is_visible_without_unsafe_unlock(client, service, monkeypatch):
operation = unknown_upload(service, monkeypatch)
service.sync()
recovery = client.get("/api/v1/operations/" + operation).json()["recovery"]
assert recovery["reason"] == "gallery_match_missing"
assert recovery["state"] == "checking"
assert not recovery["requires_manual_review"]
assert service.blocked("living-room")
assert service.registry.get("living-room").client.uploads == 0
def test_persistent_block_escalates_once_survives_restart_and_preserves_evidence(
service, monkeypatch
):
operation = unknown_upload(service, monkeypatch)
original = service.operation(operation)
service.db.execute(
"UPDATE operations SET created_at=? WHERE id=?",
(
(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
operation,
),
)
service.sync()
first = service.operation(operation)["recovery"]
assert first["state"] == "manual_review"
assert first["reason"] == "gallery_match_missing"
service.unknown_checked.clear()
service.sync()
restarted = DisplayService(
service.db, service.settings, service.registry, service.assets, service.renderer
)
restarted.sync()
assert (
len(service.db.all("SELECT * FROM events WHERE event_type='operation.blocked'"))
== 1
)
assert (
service.db.one(
"SELECT COUNT(*) AS n FROM history WHERE result='MANUAL_REVIEW_REQUIRED'"
)["n"]
== 1
)
assert service.operation(operation)["recovery"]["checks"] == 3
assert service.operation(operation)["result"] == original["result"]
service.reconcile(operation, acknowledge=True)
assert service.operation(operation)["recovery"]["state"] == "resolved"
assert not service.operation(operation)["recovery"]["requires_manual_review"]
assert not service.blocked("living-room")
def test_missing_legacy_evidence_escalates_without_cloud_write(service):
operation = submit(service)
service._start_write(operation)
service.update(operation, "UNKNOWN")
service.sync()
assert (
service.operation(operation)["recovery"]["reason"] == "missing_upload_evidence"
)
assert service.blocked("living-room")
def test_delete_unknown_requires_manual_review_without_replay(service):
operation = service.submit(
"delete", {"image_id": 1}, "living-room", "admin", "test"
)
service._start_write(operation)
service.update(operation, "UNKNOWN")
service.sync()
assert (
service.operation(operation)["recovery"]["reason"]
== "manual_reconciliation_required"
)
assert service.blocked("living-room")
assert service.registry.get("living-room").images()[0].image_id == 1
def test_malformed_reconciliation_does_not_abort_other_background_work(
service, monkeypatch
):
operation = unknown_upload(service, monkeypatch)
def invalid_metadata(*args):
raise ValueError("vendor metadata with private data")
monkeypatch.setattr(service.upload_reconciliation, "check", invalid_metadata)
service.sync()
op = service.operation(operation)
assert op["recovery"]["reason"] == "reconciliation_error"
assert "private" not in str(op["recovery"])
assert service.blocked("living-room")
+21
View File
@@ -151,3 +151,24 @@ def test_cleanup_option_requires_random_photo_job():
source={"type": "template", "template": "quote"},
cleanup_unstarred=True,
)
def test_first_cleanup_runs_after_recent_host_boot(
client, service, settings, monkeypatch
):
image_id = upload_random(client, service, settings)
due(service, image_id)
monkeypatch.setattr(
"pigway_frame_flow.display.retention.time.monotonic", lambda: 120
)
service.retention.sweep()
row = service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
assert row["cleanup_operation"]
assert service.retention.checked == 120
service.retention.sweep()
assert (
service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='delete'")[
"n"
]
== 1
)
+73
View File
@@ -75,3 +75,76 @@ def test_playlist(app, service):
def test_invalid_cron():
with pytest.raises(FlowError):
next_run(Schedule(type="cron", cron="* * * * * *"))
def test_worker_checks_late_sync_before_due_tasks(app, service, monkeypatch):
scheduler = app.state.scheduler
calls = []
monkeypatch.setattr(service, "sync", lambda: calls.append("sync"))
def tick():
calls.append("tick")
scheduler.stop_event.set()
monkeypatch.setattr(scheduler, "tick", tick)
monkeypatch.setattr(service, "expire", lambda: None)
monkeypatch.setattr(service.cloud_library, "sweep", lambda: None)
monkeypatch.setattr(service.local_retention, "sweep", lambda: None)
monkeypatch.setattr(service, "process_one", lambda: False)
scheduler.run()
assert calls[:2] == ["sync", "tick"]
def test_blocked_job_keeps_retry_slot_without_blocking_worker(
app, service, monkeypatch
):
scheduler = app.state.scheduler
service.registry.drivers["other-room"] = service.registry.get("living-room")
jobs = []
for device in ("living-room", "other-room"):
jobs.append(
scheduler.save_job(
JobInput(
name=device,
device_id=device,
schedule={"type": "daily", "time": "07:00"},
source={"type": "restore"},
)
)
)
current = datetime.now(UTC)
due = (current - timedelta(seconds=2)).isoformat()
service.db.execute("UPDATE jobs SET next_run=?", (due,))
unknown = service.submit("display", {}, "living-room", "test", "unknown")
service.db.execute(
"UPDATE operations SET status='UNKNOWN',write_started=1 WHERE id=?", (unknown,)
)
queued = service.submit("render", {}, "living-room", "test", "render")
calls = []
monkeypatch.setattr(service, "sync", lambda: None)
monkeypatch.setattr(service, "expire", lambda: calls.append("expire"))
monkeypatch.setattr(service.cloud_library, "sweep", lambda: None)
monkeypatch.setattr(service.local_retention, "sweep", lambda: None)
def process():
calls.append("process")
assert service.operation(queued)["status"] == "QUEUED"
scheduler.stop_event.set()
return False
monkeypatch.setattr(service, "process_one", process)
monkeypatch.setattr(
scheduler.stop_event, "wait", lambda timeout: scheduler.stop_event.set()
)
scheduler.run()
assert calls == ["expire", "process"]
blocked = service.db.one("SELECT * FROM jobs WHERE id=?", (jobs[0]["id"],))
assert blocked["next_run"] == due
assert blocked["last_run"] is None
assert len(service.db.all("SELECT * FROM job_runs")) == 1
assert service.db.one("SELECT * FROM job_runs")["job_id"] == jobs[1]["id"]
scheduler.tick(current + timedelta(minutes=6))
assert (
service.db.one("SELECT * FROM jobs WHERE id=?", (jobs[0]["id"],))["last_result"]
== "SKIPPED_OVERDUE"
)
+153
View File
@@ -0,0 +1,153 @@
import json
from datetime import UTC, datetime, timedelta
import pytest
from test_display import submit
from pigway_frame_flow.errors import ErrorCode, FlowError
def lose_response(service, monkeypatch, change_display=True):
driver = service.registry.get("living-room")
upload = driver.upload
def uncertain(path, name):
upload(path, name)
if not change_display:
driver.client.display(1)
raise FlowError(
ErrorCode.WRITE_RESULT_UNKNOWN, "Cloud write outcome is unknown", 502
)
monkeypatch.setattr(driver, "upload", uncertain)
operation = submit(service)
service.process_one()
assert service.operation(operation)["status"] == "UNKNOWN"
return operation, driver
def test_lost_upload_response_resolves_by_exact_bytes_without_reupload(
service, monkeypatch
):
operation, driver = lose_response(service, monkeypatch)
service.sync()
assert service.operation(operation)["status"] == "COMPLETED"
assert service.operation(operation)["result"]["image_id"] == 2
assert driver.client.uploads == 1
assert not service.blocked("living-room")
def test_same_name_wrong_bytes_does_not_unlock(service, monkeypatch):
operation, driver = lose_response(service, monkeypatch)
monkeypatch.setattr(driver, "download", lambda _: b"unrelated")
service.sync()
assert service.operation(operation)["status"] == "UNKNOWN"
assert service.blocked("living-room")
assert driver.client.uploads == 1
def test_ambiguous_gallery_does_not_download_or_unlock(service, monkeypatch):
operation, driver = lose_response(service, monkeypatch)
driver.client.state["images"].append(dict(driver.client.state["images"][-1], id=3))
monkeypatch.setattr(driver, "download", lambda _: pytest.fail("Ambiguous download"))
service.sync()
assert service.operation(operation)["status"] == "UNKNOWN"
def test_confirmed_upload_not_displayed_closes_failed_display_after_stable_checks(
service, monkeypatch
):
operation, driver = lose_response(service, monkeypatch, change_display=False)
service.sync()
result = service.operation(operation)["result"]
assert result["upload_confirmed"]
start = datetime.fromisoformat(result["upload_intent"]["started_at"])
import pigway_frame_flow.display.upload_reconciliation as module
class Later(datetime):
@classmethod
def now(cls, tz=None):
return start + timedelta(seconds=service.settings.sync_timeout + 600)
monkeypatch.setattr(module, "datetime", Later)
service.unknown_checked.clear()
service.sync()
assert service.operation(operation)["status"] == "UNKNOWN"
result = service.operation(operation)["result"]
result["unchanged_observed_at"] = (
datetime.now(UTC) - timedelta(seconds=61)
).isoformat()
service.db.execute(
"UPDATE operations SET result=? WHERE id=?", (json.dumps(result), operation)
)
service.unknown_checked.clear()
service.sync()
assert service.operation(operation)["status"] == "FAILED"
assert service.operation(operation)["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
assert not service.blocked("living-room")
assert driver.client.uploads == 1
def test_legacy_unknown_without_intent_remains_locked(service):
operation = submit(service)
service._start_write(operation)
service.update(operation, "UNKNOWN")
service.sync()
assert service.blocked("living-room")
def test_recovered_upload_preserves_temporary_restore(service, monkeypatch):
driver = service.registry.get("living-room")
upload = driver.upload
def uncertain(path, name):
upload(path, name)
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Response lost", 502)
monkeypatch.setattr(driver, "upload", uncertain)
operation = submit(service, duration=60, restore=True)
service.process_one()
service.sync()
assert service.operation(operation)["status"] == "COMPLETED"
assert service.top("living-room")["id"] == operation
assert service.state("living-room")["managed_image_id"] == 2
def test_manual_close_preserves_unknown_upload_evidence(service, monkeypatch):
operation, driver = lose_response(service, monkeypatch, change_display=False)
before = service.operation(operation)
assert before["result"].get("image_id") is None
result = service.reconcile(operation, acknowledge=True)
after = service.operation(operation)
assert result["resolved"]
assert after["status"] == "FAILED"
assert after["result"] == before["result"]
assert after["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
assert after["error"]["details"]["previous_error"] == before["error"]
assert after["error"]["details"]["observed_image_id"] == 1
assert not service.blocked("living-room")
assert driver.client.uploads == 1
assert (
service.db.one("SELECT * FROM history ORDER BY id DESC LIMIT 1")["result"]
== "ACKNOWLEDGED"
)
def test_upload_transport_diagnostics_persist_on_operation(service, monkeypatch):
driver = service.registry.get("living-room")
def fail(*args):
raise FlowError(
ErrorCode.WRITE_RESULT_UNKNOWN,
"Cloud write outcome is unknown",
502,
details={"transport_error": "read_timeout"},
)
monkeypatch.setattr(driver, "upload", fail)
operation = submit(service)
service.process_one()
op = service.operation(operation)
assert op["status"] == "UNKNOWN"
assert op["error"]["details"] == {"transport_error": "read_timeout"}
assert op["result"]["upload_intent"]