初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
data/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
2026-09-08T10:19+08:00
|
||||
@@ -0,0 +1,178 @@
|
||||
# 奇妙小屏幕控制器(核桃派 ZeroW)
|
||||
|
||||
本目录是核桃派 ZeroW/H618 的独立服务源码,控制一块 64×64、1/32 扫描、ABCDE 行寻址的 HUB75 RGB 点阵屏。生产驱动是项目自带的 `walnutpi-h618-hub75`,不依赖其他开发板的 GPIO 库。
|
||||
|
||||
## 路径与运行方式
|
||||
|
||||
下列源码运行、编译和手工部署命令均在本文件所在的 `核桃派软件源代码/` 目录执行;发布命令另行标明从工作区根目录执行。Windows 本机测试使用根目录下 `测试相关资料/如何测试/本机测试环境/README.md` 的命令。
|
||||
|
||||
- 程序:`/opt/matrix-screen-controller`
|
||||
- 持久数据:`/var/lib/matrix-screen-controller`
|
||||
- 运行数据:`/run/matrix-screen-controller`
|
||||
- 服务:`matrix-screen-controller.service`
|
||||
- 网页:`http://<设备通过 DHCP 获得的 IPv4>:8080/`
|
||||
|
||||
本机 mock:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
MATRIX_DRIVER=mock .venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
核桃派不具备境外网络条件。生产安装必须使用项目根目录 `发布更新相关/其他依赖/aarch64-py311/` 中已校验的 wheel,并强制 `--no-index`:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --no-index --find-links ../发布更新相关/其他依赖/aarch64-py311 -r requirements.txt
|
||||
make -C app/display/native clean all
|
||||
```
|
||||
|
||||
## 原生驱动边界
|
||||
|
||||
- PIO 控制器:`allwinner,sun50i-h616-pinctrl`
|
||||
- 映射基址:`0x0300B000`
|
||||
- PWM 控制器:`0x0300A000` 的 PWM4,OE 使用 `PI14/PWM4`,LAT 使用 `PI15/GPIO`
|
||||
- 硬件映射:`walnutpi-pi-bank-pwm-oe-v2`,不得搭配旧版 LAT/OE 接线
|
||||
- PI bank:偏移 `0x120`,数据寄存器再偏移 `0x10`
|
||||
- 图像:64×64 RGB888 输入、7-bit PWM、双缓冲帧边界交换
|
||||
- 刷新档:15/20/30/45/60/80/100 Hz
|
||||
- CPU3 绑定、实时优先级、内存锁定和 governor 会逐项报告实际结果,状态不得伪报成功
|
||||
- 专用主机停用 lightdm、蓝牙、官方 `gpioc-server` 和 `map_device`;Wi-Fi、SSH、NetworkManager 不受影响。正式可刷镜像默认启用 SSH,使用配置账户密码登录并以同一密码执行完整 sudo,禁止 root 直接登录
|
||||
- 退出或异常时先禁用 PWM4 并强制 OE 高、清屏,再恢复 GPIO/PWM 状态;systemd 前后都执行独立 `hub75_safeoff`
|
||||
|
||||
编译和纯逻辑测试:
|
||||
|
||||
```bash
|
||||
make -C app/display/native clean all test
|
||||
```
|
||||
|
||||
真实 `/dev/mem` 基准必须在 HUB75 和 ADC 都断开时、由 root 执行:
|
||||
|
||||
```bash
|
||||
sudo app/display/native/hub75_benchmark --duration 60 --refresh-rate 100 --brightness 40 --dev-mem
|
||||
```
|
||||
|
||||
接屏门槛与全部人工停顿顺序见项目上层 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`。
|
||||
|
||||
## 闪烁修复状态
|
||||
|
||||
- 旧版 CPU 忙等 OE 已复现局部高亮块;当前版本使用 H618 PWM4 的硬件单脉冲模式(`PWM_MODE/PWM_PUL_START`)自动结束低有效 OE 脉冲,并在点亮当前锁存画面的同时移入下一 bitplane,以 50% 扫描槽预算恢复 `1..100%` 全范围亮度。不得用 `PER` 反复启停连续 PWM 模拟单脉冲。该实现尚须依次通过板端、实屏与摄像头验收,在这些门槛完成前不得写成“闪烁已解决”。
|
||||
- 任一门槛失败必须回滚旧 `/opt`、保持持久数据不变,并在断电后恢复原 LAT/OE 接线;不得把部分亮度通过写成全部通过。
|
||||
|
||||
## I²C 与电压采样
|
||||
|
||||
Unit ADC 仅使用物理脚 3/5 的 `/dev/i2c-1`(PI8/PI7),默认地址 `0x48`。HUB75 驱动不会更改 PI7、PI8 和保留的 PI16。ADC 必须等屏幕基础画面全部通过后,在整机断电状态下连接;校准时万用表和 ADC 必须测量同一点。
|
||||
|
||||
## 数据安全
|
||||
|
||||
配置 schema 为 v8,包含设备共享的 `workspace_order`,并在 v7→v8 时把 `media-import` 插入文字与动图工作区之间。媒体源文件和严格 schema v1 任务记录位于 `/var/lib/matrix-screen-controller/media-import/jobs`;成功或取消立即清理,失败及等待设置保留 24 小时。只有数据文件不存在时才能创建默认值;损坏、未知更高版本或迁移失败时保留原文件并拒绝启动。更新程序必须先克隆 `/var/lib/matrix-screen-controller`,在副本上完成登记迁移并验证后再原子切换,不得把设备凭据、主机密钥、旧设备配置或测试数据打进部署载荷。
|
||||
|
||||
媒体转换依赖 Debian 的 `ffmpeg`、`ffprobe`、`heif-convert`(`libheif-examples`)以及带 `zscale`、`tonemap` 的 FFmpeg 构建。分析和转换由 `matrix-screen-converter@.service` 在 CPU0–1、150% CPU、384 MiB 内存、无交换区和私有网络限制下串行执行;主服务只负责流式上传、任务管理与结果展示。
|
||||
|
||||
现有设备使用原子更新脚本;它在版本化 staging 中离线安装、编译和测试,健康检查失败会自动恢复旧 `/opt`:
|
||||
|
||||
```bash
|
||||
sudo sh scripts/update_walnutpi.sh
|
||||
```
|
||||
|
||||
## 浏览器全量 OTA
|
||||
|
||||
系统设置显示 `VERSION` 中的正式软件版本和 `FEATURE_UPDATED_AT` 中固定的北京时间,并允许上传完整 `.ota` 文件。页面不展示最近 OTA 结果,但上传、安装、断线恢复和失败反馈保持可见;失败后会自动打开可滚动、可复制的完整诊断日志,关闭后仍可重新查看或复制。设备只在 `/var/lib/matrix-screen-controller/ota/last-failure.log` 原子保留最近一份不超过 1 MiB 的脱敏失败日志,下一次成功更新会将其删除;`GET /api/ota/failure-log` 仅在最近结果失败且日志存在时返回 `text/plain`,并禁止缓存。设备不连接更新服务器,不使用增量、加密或签名;包内 SHA-256 只检查文件损坏。同版和旧版在停服前拒绝,新版失败自动恢复更新前程序与用户数据。
|
||||
|
||||
从 1.0.3 首次升级到包含该能力的版本时,安装过程仍由 1.0.3 的旧 worker 控制,因此新日志页面只能在升级成功后使用。新版本测试入口兼容旧 worker 传入的隔离变量,并将 pytest、应用数据、运行数据和 Python 临时文件全部约束到 OTA 事务目录;它不会为安装日志功能而忽略测试失败或绕过原子回滚。
|
||||
|
||||
普通功能开发、修复和 SSH 开发部署都保持 `VERSION` 不变;每次可部署的功能新增或优化把 `FEATURE_UPDATED_AT` 写为精确到分钟的固定 `+08:00` 时间。只有用户明确要求导出 OTA 或可刷镜像时才推进共用正式版本;单纯导出、重复部署和文档修改不得刷新功能时间。普通更新成功导出默认将补丁位加一;新增或变更系统软件必须增加 minor 并归零 patch,使用 `--version` 指定已登记节点。失败不占号,全部操作登记在项目根 `发布记录.json`。
|
||||
|
||||
需要交付或测试 OTA 包时才从项目根执行:
|
||||
|
||||
```powershell
|
||||
python ".\核桃派软件源代码\scripts\export_release.py" ota --notes "本次更新说明"
|
||||
```
|
||||
|
||||
OTA 输出为 `发布更新相关/OTA数据包/<版本>/`;镜像输出为 `发布更新相关/导出包/<版本>/`。已有版本目录不会覆盖。导出 IMG 可由用户删除以节省空间,缺失默认正常,不追补、不自动重建、不影响开发或常规测试。OTA 今后长期保留;1.0.1 缺失为已接受的历史例外,不再追补或提醒,不影响使用。后续构建不得引用历史产物,原发布记录保留。
|
||||
|
||||
## 可刷镜像导出与同版本修复
|
||||
|
||||
镜像导出只在 Linux root 构建主机上执行。入口使用标准库完成打包和镜像操作,不要求主机安装 Pillow、FontTools 或应用 venv;应用依赖由镜像中的 AArch64 离线 wheelhouse 在首次启动时安装。执行前先检查构建主机,而不是假设远端具备本机工具:
|
||||
|
||||
```bash
|
||||
command -v python3
|
||||
command -v bash
|
||||
command -v losetup
|
||||
command -v mount
|
||||
command -v umount
|
||||
command -v systemctl
|
||||
command -v sha256sum
|
||||
```
|
||||
|
||||
普通新版本镜像需要显式配置 JSON,并使用两阶段门禁。第一阶段只生成静态验证完成的候选,不修改正式版本或发布记录:
|
||||
|
||||
```bash
|
||||
sudo python3 核桃派软件源代码/scripts/export_release.py image \
|
||||
--version 1.1.1 --config /受保护临时目录/image-config.json \
|
||||
--candidate-output /受保护临时目录/image-1.1.1-candidate \
|
||||
--notes "完整可刷镜像 1.1.1"
|
||||
```
|
||||
|
||||
同一字节候选完成真实 TF 卡首启、默认 Wi-Fi、SSH、sudo、内核、网页和再次重启验收后,使用不含秘密且与 IMG 摘要绑定的严格 JSON 晋升;晋升会重建期望 bundle 并重新只读验证镜像,成功后才推进版本:
|
||||
|
||||
```bash
|
||||
sudo python3 核桃派软件源代码/scripts/export_release.py image \
|
||||
--validated-candidate /受保护临时目录/image-1.1.1-candidate \
|
||||
--validation-report /受保护临时目录/image-1.1.1-validation.json \
|
||||
--notes "完整可刷镜像 1.1.1"
|
||||
```
|
||||
|
||||
IMG 自带完整系统依赖,不应用 OTA 必经节点跳转规则。正式 IMG README 是公开默认账户和 Wi-Fi 值的唯一发布文档;建议用户刷写前用镜像编辑器修改。已经登记但经真实首启证明不可用的当前版本,只有获得明确授权后才使用同版本修复入口:
|
||||
|
||||
```bash
|
||||
sudo python3 核桃派软件源代码/scripts/export_release.py image \
|
||||
--repair-current \
|
||||
--notes "同版本镜像修复说明"
|
||||
```
|
||||
|
||||
修复入口从现有正式 IMG 复用完整双槽配置,从未修改官方基线重建,在隐藏目录完成全部验证后才替换正式目录和原发布记录;不追加重复版本、不修改 `VERSION`。失败会恢复旧目录和记录。旧的 `refresh_sd_image_payload.py` 原地刷新方式已停用,因为它不能安全处理 rootfs 应用载荷和 FAT 内核安装空间。
|
||||
|
||||
镜像格式 v3 在 FAT16 中只放配置、元数据和首启入口;应用 bundle 与候选内核分别预置到 `/opt/matrix-image-bootstrap/app` 和 `/opt/matrix-image-bootstrap/axp313a`。构建及设备安装均要求候选 boot 文件实际预算之外仍有 `32 MiB` 余量。首次启动失败保留 rootfs 压缩载荷供重试,全部成功后才统一删除。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
node --test tests/*.mjs
|
||||
```
|
||||
|
||||
真实硬件测试必须严格按上层测试文档逐项执行,上一项失败时不得跨过。任何接线、拔插、万用表测量或电压调整前都必须暂停并等待人工确认。
|
||||
|
||||
## 软件安装节点与 1.1.0
|
||||
|
||||
从 1.1.0 起,每个 minor 的 `.0` 是**软件安装包(必经升级版本)**。例如 `1.0.6 → 1.1.0 → 1.2.0 → 1.2.3`;不能跳过任何安装节点。同系列补丁可以跳过,例如 `1.1.0 → 1.1.3`。已预装 frp 的 1.0.6 调试设备也必须先完成 1.1.0,版本号与组件完整性分别检查。同版和降级仍不允许。
|
||||
|
||||
正式导出命令(项目根):
|
||||
|
||||
```powershell
|
||||
python "核桃派软件源代码/scripts/export_release.py" ota --version 1.1.0 --notes "软件安装包:离线安装或修复 frpc,建立必经升级节点"
|
||||
```
|
||||
|
||||
`UPGRADE_POLICY.json` 登记连续安装节点及固定依赖摘要;已发布节点不得改写。1.1.0 使用旧更新器支持的 v1 外层协议,frpc 放在 `software/system-dependencies/frpc/`。以后使用 v2,正式旧更新器会拒绝该格式;新更新器会在安装前提示必须先安装的版本。普通补丁不携带系统二进制,仍包含应用及 Python wheelhouse;系统组件缺失时先修复再更新。首装镜像仍携带完整离线依赖。
|
||||
|
||||
frpc 完整匹配时不替换二进制或重启服务;否则离线修复,保留 UUID 配置、选择项和启停状态。初次默认关闭。OTA 从现有 frp 非 root 账户、镜像生成的 `/etc/sudoers.d/90-matrix-screen-controller-account` 或唯一 sudo 普通账户确定运行账户,无法确定则在修改前失败。
|
||||
|
||||
`prepare_data_root` 仅在根权限、真实 OTA 请求、候选源码和候选数据路径全部匹配时启动组件事务,普通迁移和测试不会安装系统软件。完整 pytest 必须先通过。事务记录及原组件备份镜像保存在原数据根和候选数据根的 `ota/component-transaction/`;系统启动恢复程序临时位于 `/opt/matrix-screen-controller-component-recovery.py`,恢复 unit 为 `matrix-screen-component-recovery.service`,运行期监护为 `matrix-screen-component-watch.service`。原 worker 成功记录与目标版本共同决定提交;失败或中断恢复原组件、权限、程序和数据。无法完成恢复时保留记录并拒绝下一次更新;成功恢复/提交后删除事务文件、临时恢复程序和 unit,成功提交还删除程序目录内的安装载荷。只包含运行状态的文件仍位于 `/run/matrix-screen-controller`。
|
||||
|
||||
## OTA 停服运行目录保护(必须保留的防回归规则)
|
||||
|
||||
`RuntimeDirectoryPreserve=restart` 只保护重启,不保护分开的 `systemctl stop` / `start`。OTA 会显式停止主服务,因此主服务使用 `RuntimeDirectoryPreserve=yes`;该运行目录仍位于 `/run`,整机重启后清空。日志写入重新 mkdir 只能救回日志,不能救回已经删除的请求和进度,不能作为生命周期修复。
|
||||
|
||||
从旧版本安装时,候选迁移入口必须先创建 `/run/systemd/system/matrix-screen-controller.service.d/90-matrix-ota-runtime.conf`,执行 daemon-reload 并检查有效 `RuntimeDirectoryPreserve=yes`,再允许旧 worker 停服。保护一直保留到提交或回滚及清理结束;原有用户 drop-in 不覆盖。该标准 systemd 运行期目录是临时服务配置的明确例外;普通运行数据继续集中在 `/run/matrix-screen-controller`。
|
||||
|
||||
真实验收使用 `sudo python3 scripts/test_runtime_directory_systemd.py`,仅操作随机命名的专用测试 unit/目录,不停止生产服务。必须包含旧配置失败对照、显式 stop、restart、启动失败及保护清理,不能用 mock systemctl 代替。诊断存储故障不得中断回滚,有限内存缓冲可补写最近失败日志。
|
||||
|
||||
经用户明确授权修复当前 OTA 时,先构建到隔离候选目录并实机验收,再执行:
|
||||
|
||||
```powershell
|
||||
python "核桃派软件源代码/scripts/export_release.py" ota --repair-current --validated-candidate "<已验收候选包>" --validation-report "<实机验收JSON>" --notes "修复说明"
|
||||
```
|
||||
|
||||
此入口校验原产物及记录、候选摘要、最终源码一致性和实机验收结果;完整归档原包与记录后,按原字节晋升候选包,不再构建、不推进 VERSION、不刷新功能时间。验证或提交失败保留原产物与记录。
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"checkpoints": [
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"components": {
|
||||
"frpc": {
|
||||
"version": "0.71.0",
|
||||
"sha256": "6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2",
|
||||
"bundle": "frp/0.71.0/linux-arm64"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1.1.1
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Matrix screen controller application package."""
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .store import AnimationStore
|
||||
|
||||
__all__ = ["AnimationStore"]
|
||||
@@ -0,0 +1,3 @@
|
||||
DEFAULT_DURATION_MS = 500
|
||||
MIN_DURATION_MS = 50
|
||||
MAX_DURATION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
@@ -0,0 +1,711 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.animations.constants import DEFAULT_DURATION_MS, MAX_DURATION_MS, MIN_DURATION_MS
|
||||
from app.display.text_renderer import FontResolver
|
||||
from app.persistence import atomic_write_bytes
|
||||
from app.templates.store import (
|
||||
TemplateConflictError, TemplateError, TemplateNotFoundError,
|
||||
TemplateStorageFullError, TemplateValidationError,
|
||||
render_scene_thumbnail, scene_digest, validate_scene,
|
||||
)
|
||||
|
||||
ANIMATION_SCHEMA_VERSION = 2
|
||||
NAME_LIMIT = 80
|
||||
RECORD_FIELDS = frozenset({"id", "name", "created_at", "updated_at", "frames"})
|
||||
FRAME_FIELDS = frozenset({
|
||||
"id", "name", "duration_ms", "created_at", "updated_at",
|
||||
"digest", "thumbnail", "scene_file",
|
||||
})
|
||||
V1_FRAME_FIELDS = frozenset((FRAME_FIELDS - {"scene_file"}) | {"scene"})
|
||||
THUMBNAIL_RE = re.compile(r"^[0-9a-f-]{36}-[0-9a-f]{16}\.png$")
|
||||
SCENE_RE = re.compile(r"^[0-9a-f-]{36}-[0-9a-f]{16}\.json$")
|
||||
|
||||
|
||||
def _uuid(value: Any, message: str) -> str:
|
||||
try:
|
||||
return str(UUID(str(value)))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise TemplateNotFoundError(message) from exc
|
||||
|
||||
|
||||
def _name(value: Any, *, optional: bool = False) -> str | None:
|
||||
if optional and (value is None or value == ""):
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise TemplateValidationError("name must be a string")
|
||||
checked = value.strip()
|
||||
if not checked or len(checked) > NAME_LIMIT:
|
||||
raise TemplateValidationError(f"name must contain 1..{NAME_LIMIT} characters")
|
||||
return checked
|
||||
|
||||
|
||||
def _duration(value: Any) -> int:
|
||||
if type(value) is not int or not MIN_DURATION_MS <= value <= MAX_DURATION_MS:
|
||||
raise TemplateValidationError(
|
||||
f"duration_ms must be in {MIN_DURATION_MS}..{MAX_DURATION_MS}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _persisted(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": record["id"], "name": record["name"],
|
||||
"created_at": record["created_at"], "updated_at": record["updated_at"],
|
||||
"frames": [{key: frame[key] for key in FRAME_FIELDS} for frame in record["frames"]],
|
||||
}
|
||||
|
||||
|
||||
def _revision(record: dict[str, Any]) -> str:
|
||||
raw = json.dumps(_persisted(record), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
class AnimationStore:
|
||||
"""Schema-v2 animations: compact metadata plus one strict scene per frame."""
|
||||
|
||||
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None,
|
||||
cleanup_playback: bool = True) -> None:
|
||||
self.root = Path(data_dir) / "animations"
|
||||
self._font_resolver = font_resolver
|
||||
self._lock = threading.RLock()
|
||||
self._metadata_size_cache: dict[str, tuple[str, int]] = {}
|
||||
self._list_cache: dict[str, Any] | None = None
|
||||
self._list_signature: tuple[tuple[str, int, int], ...] = ()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._playback_root = self.root / ".playback"
|
||||
if cleanup_playback:
|
||||
shutil.rmtree(self._playback_root, ignore_errors=True)
|
||||
self._playback_root.mkdir(parents=True, exist_ok=True)
|
||||
records: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for child in self.root.iterdir():
|
||||
if child.is_dir() and child.name.startswith(".staging-"):
|
||||
if cleanup_playback and child.stat().st_mtime < time.time() - 24 * 60 * 60:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
elif child.is_dir() and child.name != ".playback":
|
||||
record = self._read(child.name)
|
||||
self._cleanup_orphans(record)
|
||||
records.append(record)
|
||||
# Startup already performed the expensive strict frame/thumbnail
|
||||
# validation. Build the first listing from those same records so a
|
||||
# large library is immediately usable instead of doing all I/O a
|
||||
# second time on the first browser request.
|
||||
self._prime_list_cache(records)
|
||||
self._list_signature = self._library_signature()
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _dir(self, animation_id: str) -> Path:
|
||||
return self.root / animation_id
|
||||
|
||||
def _path(self, animation_id: str) -> Path:
|
||||
return self._dir(animation_id) / "animation.json"
|
||||
|
||||
def _thumb_dir(self, animation_id: str) -> Path:
|
||||
return self._dir(animation_id) / "thumbnails"
|
||||
|
||||
def _scene_dir(self, animation_id: str) -> Path:
|
||||
return self._dir(animation_id) / "scenes"
|
||||
|
||||
@staticmethod
|
||||
def _translate(exc: OSError) -> None:
|
||||
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
|
||||
raise TemplateStorageFullError("animation storage is full") from exc
|
||||
raise TemplateError(str(exc)) from exc
|
||||
|
||||
def _write_at(self, record: dict[str, Any], directory_id: str) -> None:
|
||||
self._dir(directory_id).mkdir(parents=True, exist_ok=True)
|
||||
data = json.dumps(
|
||||
{"schema_version": 2, **_persisted(record)},
|
||||
ensure_ascii=False, sort_keys=True, indent=2,
|
||||
).encode("utf-8") + b"\n"
|
||||
try:
|
||||
atomic_write_bytes(self._path(directory_id), data)
|
||||
except OSError as exc:
|
||||
self._translate(exc)
|
||||
|
||||
def _write(self, record: dict[str, Any]) -> None:
|
||||
self._write_at(record, record["id"])
|
||||
self._list_cache = None
|
||||
|
||||
def _write_scene(self, animation_id: str, frame: dict[str, Any]) -> None:
|
||||
scene = validate_scene(frame.get("scene"), strict_schema=True)
|
||||
self._scene_dir(animation_id).mkdir(parents=True, exist_ok=True)
|
||||
data = json.dumps(scene, ensure_ascii=False, sort_keys=True, indent=2).encode("utf-8") + b"\n"
|
||||
try:
|
||||
atomic_write_bytes(self._scene_dir(animation_id) / frame["scene_file"], data)
|
||||
except OSError as exc:
|
||||
self._translate(exc)
|
||||
|
||||
def _load_scene(self, animation_id: str, frame: dict[str, Any]) -> dict[str, Any]:
|
||||
path = self._scene_dir(animation_id) / frame["scene_file"]
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TemplateValidationError(f"animation frame {frame['id']} is unreadable") from exc
|
||||
scene = validate_scene(value, strict_schema=True)
|
||||
if scene_digest(scene) != frame["digest"]:
|
||||
raise TemplateValidationError("animation frame digest does not match scene")
|
||||
return scene
|
||||
|
||||
def _write_thumbnail(self, animation_id: str, frame: dict[str, Any]) -> None:
|
||||
self._thumb_dir(animation_id).mkdir(parents=True, exist_ok=True)
|
||||
path = self._thumb_dir(animation_id) / frame["thumbnail"]
|
||||
if path.exists():
|
||||
return
|
||||
scene = frame.get("scene") or self._load_scene(animation_id, frame)
|
||||
try:
|
||||
atomic_write_bytes(path, render_scene_thumbnail(scene, font_resolver=self._font_resolver))
|
||||
except OSError as exc:
|
||||
self._translate(exc)
|
||||
|
||||
def _v1_record(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
|
||||
document = dict(value)
|
||||
if document.pop("schema_version", None) != 1 or set(document) != RECORD_FIELDS:
|
||||
raise TemplateValidationError("animation schema_version is unsupported")
|
||||
if document.get("id") != expected_id:
|
||||
raise TemplateValidationError("animation id does not match its directory")
|
||||
if not document.get("created_at") or not document.get("updated_at"):
|
||||
raise TemplateValidationError("animation timestamps are required")
|
||||
if not isinstance(document.get("frames"), list):
|
||||
raise TemplateValidationError("animation frames must be an array")
|
||||
frames, ids = [], set()
|
||||
for raw in document["frames"]:
|
||||
if not isinstance(raw, dict) or set(raw) != V1_FRAME_FIELDS:
|
||||
raise TemplateValidationError("animation frame fields do not match schema v1")
|
||||
frame_id = _uuid(raw.get("id"), "animation frame not found")
|
||||
if frame_id in ids:
|
||||
raise TemplateValidationError("animation frame ids must be unique")
|
||||
ids.add(frame_id)
|
||||
scene = validate_scene(raw.get("scene"), strict_schema=True)
|
||||
digest = scene_digest(scene)
|
||||
if raw.get("digest") != digest or raw.get("thumbnail") != f"{frame_id}-{digest}.png":
|
||||
raise TemplateValidationError("animation frame digest does not match scene")
|
||||
frames.append({
|
||||
"id": frame_id, "name": _name(raw.get("name"), optional=True),
|
||||
"duration_ms": _duration(raw.get("duration_ms")),
|
||||
"created_at": str(raw.get("created_at") or ""),
|
||||
"updated_at": str(raw.get("updated_at") or ""),
|
||||
"digest": digest, "thumbnail": f"{frame_id}-{digest}.png",
|
||||
"scene_file": f"{frame_id}-{digest}.json", "scene": scene,
|
||||
})
|
||||
return {
|
||||
"id": expected_id, "name": _name(document.get("name")),
|
||||
"created_at": str(document["created_at"]),
|
||||
"updated_at": str(document["updated_at"]), "frames": frames,
|
||||
}
|
||||
|
||||
def _migrate_v1(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
|
||||
record = self._v1_record(value, expected_id)
|
||||
# The entire v1 document is validated before authoritative metadata changes.
|
||||
for frame in record["frames"]:
|
||||
self._write_scene(expected_id, frame)
|
||||
self._write_thumbnail(expected_id, frame)
|
||||
self._write(record)
|
||||
return {"schema_version": 2, **_persisted(record)}
|
||||
|
||||
def _validate_record(self, value: Any, expected_id: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise TemplateValidationError("animation record must be an object")
|
||||
if value.get("schema_version") == 1:
|
||||
value = self._migrate_v1(value, expected_id)
|
||||
document = dict(value)
|
||||
if document.pop("schema_version", None) != 2:
|
||||
raise TemplateValidationError("animation schema_version is unsupported")
|
||||
if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
|
||||
raise TemplateValidationError("animation fields do not match the current schema")
|
||||
if not document.get("created_at") or not document.get("updated_at"):
|
||||
raise TemplateValidationError("animation timestamps are required")
|
||||
if not isinstance(document.get("frames"), list):
|
||||
raise TemplateValidationError("animation frames must be an array")
|
||||
frames, ids = [], set()
|
||||
for raw in document["frames"]:
|
||||
if not isinstance(raw, dict) or set(raw) != FRAME_FIELDS:
|
||||
raise TemplateValidationError("animation frame fields do not match the current schema")
|
||||
frame_id = _uuid(raw.get("id"), "animation frame not found")
|
||||
if frame_id in ids:
|
||||
raise TemplateValidationError("animation frame ids must be unique")
|
||||
ids.add(frame_id)
|
||||
digest = str(raw.get("digest") or "")
|
||||
thumbnail, scene_file = f"{frame_id}-{digest}.png", f"{frame_id}-{digest}.json"
|
||||
if raw.get("thumbnail") != thumbnail or raw.get("scene_file") != scene_file:
|
||||
raise TemplateValidationError("animation frame paths do not match its digest")
|
||||
frame = {
|
||||
"id": frame_id, "name": _name(raw.get("name"), optional=True),
|
||||
"duration_ms": _duration(raw.get("duration_ms")),
|
||||
"created_at": str(raw.get("created_at") or ""),
|
||||
"updated_at": str(raw.get("updated_at") or ""),
|
||||
"digest": digest, "thumbnail": thumbnail, "scene_file": scene_file,
|
||||
}
|
||||
self._load_scene(expected_id, frame)
|
||||
if not (self._thumb_dir(expected_id) / thumbnail).is_file():
|
||||
self._write_thumbnail(expected_id, frame)
|
||||
frames.append(frame)
|
||||
return {
|
||||
"id": expected_id, "name": _name(document.get("name")),
|
||||
"created_at": str(document["created_at"]),
|
||||
"updated_at": str(document["updated_at"]), "frames": frames,
|
||||
}
|
||||
|
||||
def _read(self, animation_id: Any) -> dict[str, Any]:
|
||||
normalized = _uuid(animation_id, "animation not found")
|
||||
path = self._path(normalized)
|
||||
if not path.is_file():
|
||||
raise TemplateNotFoundError("animation not found")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TemplateValidationError(f"animation {normalized} is unreadable") from exc
|
||||
return self._validate_record(value, normalized)
|
||||
|
||||
def _cleanup_orphans(self, record: dict[str, Any]) -> None:
|
||||
for directory, expected, pattern in (
|
||||
(self._thumb_dir(record["id"]), {f["thumbnail"] for f in record["frames"]}, THUMBNAIL_RE),
|
||||
(self._scene_dir(record["id"]), {f["scene_file"] for f in record["frames"]}, SCENE_RE),
|
||||
):
|
||||
if directory.exists():
|
||||
for path in directory.iterdir():
|
||||
if path.is_file() and path.name not in expected and pattern.fullmatch(path.name):
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _all(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
self._read(child.name) for child in self.root.iterdir()
|
||||
if child.is_dir() and not child.name.startswith(".")
|
||||
]
|
||||
|
||||
def _library_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||
"""Cheaply identify committed library changes made by another process."""
|
||||
entries: list[tuple[str, int, int]] = []
|
||||
for child in self.root.iterdir():
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
stat = self._path(child.name).stat()
|
||||
entries.append((child.name, stat.st_mtime_ns, stat.st_size))
|
||||
except FileNotFoundError:
|
||||
# Keep the in-progress directory transition visible to the
|
||||
# before/after stability check in _refresh_list_cache().
|
||||
entries.append((child.name, -1, -1))
|
||||
return tuple(sorted(entries))
|
||||
|
||||
def _refresh_list_cache(self) -> None:
|
||||
# An isolated media worker commits animations with an atomic directory
|
||||
# rename. Read only across a stable signature so that a concurrent
|
||||
# commit or delete cannot become the new long-lived cached snapshot.
|
||||
for _attempt in range(3):
|
||||
before = self._library_signature()
|
||||
try:
|
||||
records = self._all()
|
||||
except (TemplateNotFoundError, TemplateValidationError):
|
||||
if before != self._library_signature():
|
||||
continue
|
||||
raise
|
||||
after = self._library_signature()
|
||||
if before == after:
|
||||
self._prime_list_cache(records)
|
||||
self._list_signature = after
|
||||
return
|
||||
|
||||
# Continuous external changes are exceptional. Return a fresh scan and
|
||||
# leave the signature from after that scan so the next request checks it
|
||||
# again instead of trusting an older snapshot.
|
||||
self._prime_list_cache(self._all())
|
||||
self._list_signature = self._library_signature()
|
||||
|
||||
def _prime_list_cache(self, records: Iterable[dict[str, Any]]) -> None:
|
||||
ordered = sorted(records, key=lambda record: record["updated_at"], reverse=True)
|
||||
metadata = [self._metadata(record) for record in ordered]
|
||||
usage = shutil.disk_usage(self.root)
|
||||
self._list_cache = {
|
||||
"animations": metadata,
|
||||
"storage": {
|
||||
"animations_bytes": sum(item["size_bytes"] for item in metadata),
|
||||
"total_bytes": usage.total,
|
||||
"used_bytes": usage.used,
|
||||
"free_bytes": usage.free,
|
||||
},
|
||||
}
|
||||
|
||||
def _unique(self, name: str, exclude: str | None = None) -> None:
|
||||
folded = name.casefold()
|
||||
if any(r["id"] != exclude and r["name"].casefold() == folded for r in self._all()):
|
||||
raise TemplateConflictError("animation name already exists")
|
||||
|
||||
def name_available(self, name: Any) -> bool:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
return all(r["name"].casefold() != checked.casefold() for r in self._all())
|
||||
|
||||
def thumbnail_url(self, animation_id: str, frame: dict[str, Any]) -> str:
|
||||
return f"/api/animations/{animation_id}/frames/{frame['id']}/thumbnail?v={frame['digest']}"
|
||||
|
||||
def _metadata(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
revision = _revision(record)
|
||||
cached = self._metadata_size_cache.get(record["id"])
|
||||
if cached is not None and cached[0] == revision:
|
||||
size = cached[1]
|
||||
else:
|
||||
size = self._path(record["id"]).stat().st_size
|
||||
for frame in record["frames"]:
|
||||
for path in (
|
||||
self._thumb_dir(record["id"]) / frame["thumbnail"],
|
||||
self._scene_dir(record["id"]) / frame["scene_file"],
|
||||
):
|
||||
if path.exists():
|
||||
size += path.stat().st_size
|
||||
self._metadata_size_cache[record["id"]] = (revision, size)
|
||||
first = record["frames"][0] if record["frames"] else None
|
||||
return {
|
||||
"id": record["id"], "name": record["name"],
|
||||
"created_at": record["created_at"], "updated_at": record["updated_at"],
|
||||
"revision": revision, "frame_count": len(record["frames"]),
|
||||
"total_duration_ms": sum(f["duration_ms"] for f in record["frames"]),
|
||||
"size_bytes": size,
|
||||
"thumbnail_url": self.thumbnail_url(record["id"], first) if first else None,
|
||||
}
|
||||
|
||||
def _detail(self, record: dict[str, Any], *, scenes: bool = False,
|
||||
offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
|
||||
if offset is None and limit is None:
|
||||
start, end, page = 0, len(record["frames"]), {}
|
||||
else:
|
||||
start = max(0, int(offset or 0))
|
||||
checked_limit = max(1, min(500, int(limit or 100)))
|
||||
end = min(len(record["frames"]), start + checked_limit)
|
||||
page = {
|
||||
"frame_offset": start, "frame_limit": checked_limit,
|
||||
"frame_total": len(record["frames"]),
|
||||
}
|
||||
frames = []
|
||||
for index in range(start, end):
|
||||
frame = record["frames"][index]
|
||||
item = {
|
||||
"id": frame["id"], "name": frame["name"], "position": index + 1,
|
||||
"duration_ms": frame["duration_ms"], "created_at": frame["created_at"],
|
||||
"updated_at": frame["updated_at"], "digest": frame["digest"],
|
||||
"thumbnail_url": self.thumbnail_url(record["id"], frame),
|
||||
}
|
||||
if scenes:
|
||||
item["scene"] = self._load_scene(record["id"], frame)
|
||||
frames.append(item)
|
||||
return {**self._metadata(record), **page, "frames": frames}
|
||||
|
||||
def list(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
signature = self._library_signature()
|
||||
if self._list_cache is None or signature != self._list_signature:
|
||||
self._refresh_list_cache()
|
||||
return {
|
||||
"animations": [dict(item) for item in self._list_cache["animations"]],
|
||||
"storage": dict(self._list_cache["storage"]),
|
||||
}
|
||||
|
||||
def get(self, animation_id: Any, *, scenes: bool = False,
|
||||
offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._detail(self._read(animation_id), scenes=scenes, offset=offset, limit=limit)
|
||||
|
||||
def create(self, name: Any) -> dict[str, Any]:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
self._unique(checked)
|
||||
animation_id, now = str(uuid4()), self._now()
|
||||
record = {
|
||||
"id": animation_id, "name": checked,
|
||||
"created_at": now, "updated_at": now, "frames": [],
|
||||
}
|
||||
self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def _new_frame(self, source: dict[str, Any], now: str) -> dict[str, Any]:
|
||||
frame_id = str(uuid4())
|
||||
scene = validate_scene(source.get("scene"), strict_schema=True)
|
||||
digest = scene_digest(scene)
|
||||
return {
|
||||
"id": frame_id, "name": _name(source.get("name"), optional=True),
|
||||
"duration_ms": _duration(source.get("duration_ms")),
|
||||
"created_at": now, "updated_at": now, "digest": digest,
|
||||
"thumbnail": f"{frame_id}-{digest}.png",
|
||||
"scene_file": f"{frame_id}-{digest}.json", "scene": scene,
|
||||
}
|
||||
|
||||
def create_from_frames(self, name: Any, frames: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
self._unique(checked)
|
||||
animation_id, now = str(uuid4()), self._now()
|
||||
staging_id = ".staging-" + animation_id
|
||||
record = {
|
||||
"id": animation_id, "name": checked,
|
||||
"created_at": now, "updated_at": now, "frames": [],
|
||||
}
|
||||
try:
|
||||
for source in frames:
|
||||
frame = self._new_frame(source, now)
|
||||
self._write_scene(staging_id, frame)
|
||||
self._write_thumbnail(staging_id, frame)
|
||||
record["frames"].append(frame)
|
||||
if not record["frames"]:
|
||||
raise TemplateValidationError("animation must contain at least one frame")
|
||||
self._write_at(record, staging_id)
|
||||
self._dir(staging_id).replace(self._dir(animation_id))
|
||||
self._list_cache = None
|
||||
except Exception:
|
||||
shutil.rmtree(self._dir(staging_id), ignore_errors=True)
|
||||
raise
|
||||
return self._detail(record)
|
||||
|
||||
def copy_from_frames(self, source_name: Any, frames: Any) -> dict[str, Any]:
|
||||
source_name = _name(source_name)
|
||||
if not isinstance(frames, list) or not frames:
|
||||
raise TemplateValidationError("animation copy source must contain frames")
|
||||
with self._lock:
|
||||
base, sequence = f"{source_name} - 副本", 2
|
||||
candidate = base
|
||||
names = {r["name"].casefold() for r in self._all()}
|
||||
while candidate.casefold() in names:
|
||||
candidate = f"{base} {sequence}"
|
||||
sequence += 1
|
||||
return self.create_from_frames(candidate, frames)
|
||||
|
||||
def _match(self, record: dict[str, Any], expected: str) -> None:
|
||||
if _revision(record) != expected:
|
||||
raise TemplateConflictError("animation was modified")
|
||||
|
||||
def rename(self, animation_id: Any, name: Any, expected: str) -> dict[str, Any]:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
record = self._read(animation_id)
|
||||
self._match(record, expected); self._unique(checked, record["id"])
|
||||
record["name"] = checked; record["updated_at"] = self._now(); self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def add_frame(self, animation_id: Any, scene: Any, expected: str, *,
|
||||
name: Any = None, duration_ms: Any = DEFAULT_DURATION_MS) -> dict[str, Any]:
|
||||
source = {
|
||||
"scene": validate_scene(scene), "name": _name(name, optional=True),
|
||||
"duration_ms": _duration(duration_ms),
|
||||
}
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected); now = self._now()
|
||||
frame = self._new_frame(source, now)
|
||||
self._write_scene(record["id"], frame); self._write_thumbnail(record["id"], frame)
|
||||
record["frames"].append(frame); record["updated_at"] = now; self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def _frame(self, record: dict[str, Any], frame_id: Any) -> dict[str, Any]:
|
||||
normalized = _uuid(frame_id, "animation frame not found")
|
||||
for frame in record["frames"]:
|
||||
if frame["id"] == normalized:
|
||||
return frame
|
||||
raise TemplateNotFoundError("animation frame not found")
|
||||
|
||||
def _frames(self, record: dict[str, Any], frame_ids: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(frame_ids, list) or not frame_ids:
|
||||
raise TemplateValidationError("frame_ids must be a non-empty array")
|
||||
normalized = [_uuid(v, "animation frame not found") for v in frame_ids]
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise TemplateValidationError("frame_ids must not contain duplicates")
|
||||
selected = set(normalized)
|
||||
ordered = [f for f in record["frames"] if f["id"] in selected]
|
||||
if len(ordered) != len(normalized):
|
||||
raise TemplateNotFoundError("animation frame not found")
|
||||
return ordered
|
||||
|
||||
def get_frame(self, animation_id: Any, frame_id: Any) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); frame = self._frame(record, frame_id)
|
||||
return {
|
||||
"animation_id": record["id"], "animation_name": record["name"],
|
||||
"animation_revision": _revision(record),
|
||||
"position": record["frames"].index(frame) + 1,
|
||||
"id": frame["id"], "name": frame["name"],
|
||||
"duration_ms": frame["duration_ms"], "created_at": frame["created_at"],
|
||||
"updated_at": frame["updated_at"], "digest": frame["digest"],
|
||||
"scene": self._load_scene(record["id"], frame),
|
||||
"thumbnail_url": self.thumbnail_url(record["id"], frame),
|
||||
}
|
||||
|
||||
def update_frame(self, animation_id: Any, frame_id: Any, expected: str, *,
|
||||
scene: Any | None = None, name: Any = ..., duration_ms: Any = ...) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
frame = self._frame(record, frame_id); now = self._now()
|
||||
old_thumb, old_scene = frame["thumbnail"], frame["scene_file"]
|
||||
if scene is not None:
|
||||
checked, digest = validate_scene(scene), scene_digest(validate_scene(scene))
|
||||
frame.update({
|
||||
"scene": checked, "digest": digest,
|
||||
"thumbnail": f"{frame['id']}-{digest}.png",
|
||||
"scene_file": f"{frame['id']}-{digest}.json",
|
||||
})
|
||||
self._write_scene(record["id"], frame); self._write_thumbnail(record["id"], frame)
|
||||
if name is not ...:
|
||||
frame["name"] = _name(name, optional=True)
|
||||
if duration_ms is not ...:
|
||||
frame["duration_ms"] = _duration(duration_ms)
|
||||
frame["updated_at"] = now; record["updated_at"] = now; self._write(record)
|
||||
if old_thumb != frame["thumbnail"]:
|
||||
(self._thumb_dir(record["id"]) / old_thumb).unlink(missing_ok=True)
|
||||
(self._scene_dir(record["id"]) / old_scene).unlink(missing_ok=True)
|
||||
return self._detail(record)
|
||||
|
||||
def reorder(self, animation_id: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
|
||||
if not isinstance(frame_ids, list):
|
||||
raise TemplateValidationError("frame_ids must be an array")
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
normalized = [_uuid(v, "animation frame not found") for v in frame_ids]
|
||||
current = [f["id"] for f in record["frames"]]
|
||||
if len(normalized) != len(set(normalized)) or set(normalized) != set(current):
|
||||
raise TemplateValidationError("frame_ids must be an exact permutation")
|
||||
by_id = {f["id"]: f for f in record["frames"]}
|
||||
record["frames"] = [by_id[v] for v in normalized]
|
||||
record["updated_at"] = self._now(); self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def move_frames(self, animation_id: Any, frame_ids: Any, expected: str, *,
|
||||
before_frame_id: Any = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
moved = self._frames(record, frame_ids); moved_ids = {f["id"] for f in moved}
|
||||
remaining = [f for f in record["frames"] if f["id"] not in moved_ids]
|
||||
if before_frame_id is None:
|
||||
insert_at = len(remaining)
|
||||
else:
|
||||
before = _uuid(before_frame_id, "animation frame not found")
|
||||
if before in moved_ids:
|
||||
raise TemplateValidationError("before_frame_id must not be moved")
|
||||
try:
|
||||
insert_at = next(i for i, frame in enumerate(remaining) if frame["id"] == before)
|
||||
except StopIteration as exc:
|
||||
raise TemplateNotFoundError("animation frame not found") from exc
|
||||
remaining[insert_at:insert_at] = moved
|
||||
record["frames"] = remaining; record["updated_at"] = self._now(); self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def set_durations(self, animation_id: Any, duration_ms: Any, expected: str, *,
|
||||
frame_ids: Any = None) -> dict[str, Any]:
|
||||
checked = _duration(duration_ms)
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected); now = self._now()
|
||||
targets = record["frames"] if frame_ids is None else self._frames(record, frame_ids)
|
||||
for frame in targets:
|
||||
frame["duration_ms"] = checked; frame["updated_at"] = now
|
||||
record["updated_at"] = now; self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def set_all_durations(self, animation_id: Any, duration_ms: Any, expected: str) -> dict[str, Any]:
|
||||
return self.set_durations(animation_id, duration_ms, expected)
|
||||
|
||||
def copy_frames(self, source_animation_id: Any, frame_ids: Any,
|
||||
source_expected: str, destination_animation_id: Any,
|
||||
destination_expected: str, insert_after_frame_id: Any = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
source = self._read(source_animation_id); self._match(source, source_expected)
|
||||
source_frames = self._frames(source, frame_ids)
|
||||
destination = source if source["id"] == str(destination_animation_id) else self._read(destination_animation_id)
|
||||
self._match(destination, destination_expected)
|
||||
insert_at = 0 if insert_after_frame_id is None else (
|
||||
destination["frames"].index(self._frame(destination, insert_after_frame_id)) + 1
|
||||
)
|
||||
now, copied, created = self._now(), [], []
|
||||
try:
|
||||
for original in source_frames:
|
||||
frame = self._new_frame({
|
||||
"scene": self._load_scene(source["id"], original),
|
||||
"name": original["name"], "duration_ms": original["duration_ms"],
|
||||
}, now)
|
||||
created += [
|
||||
self._thumb_dir(destination["id"]) / frame["thumbnail"],
|
||||
self._scene_dir(destination["id"]) / frame["scene_file"],
|
||||
]
|
||||
self._write_scene(destination["id"], frame)
|
||||
self._write_thumbnail(destination["id"], frame)
|
||||
copied.append(frame)
|
||||
destination["frames"][insert_at:insert_at] = copied
|
||||
destination["updated_at"] = now; self._write(destination)
|
||||
except Exception:
|
||||
for path in created:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
return {
|
||||
"animation": self._detail(destination),
|
||||
"copied_frame_ids": [frame["id"] for frame in copied],
|
||||
}
|
||||
|
||||
def delete_frames(self, animation_id: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
removed = self._frames(record, frame_ids); ids = {f["id"] for f in removed}
|
||||
record["frames"] = [f for f in record["frames"] if f["id"] not in ids]
|
||||
record["updated_at"] = self._now(); self._write(record)
|
||||
for frame in removed:
|
||||
(self._thumb_dir(record["id"]) / frame["thumbnail"]).unlink(missing_ok=True)
|
||||
(self._scene_dir(record["id"]) / frame["scene_file"]).unlink(missing_ok=True)
|
||||
return self._detail(record)
|
||||
|
||||
def delete_frame(self, animation_id: Any, frame_id: Any, expected: str) -> dict[str, Any]:
|
||||
return self.delete_frames(animation_id, [frame_id], expected)
|
||||
|
||||
def delete(self, animation_id: Any, expected: str) -> None:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
shutil.rmtree(self._dir(record["id"]))
|
||||
self._list_cache = None
|
||||
|
||||
def thumbnail_path(self, animation_id: Any, frame_id: Any, digest: str | None = None) -> Path:
|
||||
if digest is not None:
|
||||
normalized_animation = _uuid(animation_id, "animation not found")
|
||||
normalized_frame = _uuid(frame_id, "animation frame not found")
|
||||
checked_digest = str(digest)
|
||||
if re.fullmatch(r"[0-9a-f]{16}", checked_digest) is None:
|
||||
raise TemplateNotFoundError("animation thumbnail not found")
|
||||
path = self._thumb_dir(normalized_animation) / f"{normalized_frame}-{checked_digest}.png"
|
||||
if not path.is_file():
|
||||
raise TemplateNotFoundError("animation thumbnail not found")
|
||||
return path
|
||||
with self._lock:
|
||||
record = self._read(animation_id); frame = self._frame(record, frame_id)
|
||||
path = self._thumb_dir(record["id"]) / frame["thumbnail"]
|
||||
if not path.is_file():
|
||||
raise TemplateNotFoundError("animation thumbnail not found")
|
||||
return path
|
||||
|
||||
def playback_snapshot(self, animation_id: Any, expected: str | None = None) -> dict[str, Any]:
|
||||
"""Create immutable hard-linked PNG descriptors for lazy display playback."""
|
||||
with self._lock:
|
||||
record = self._read(animation_id); revision = _revision(record)
|
||||
if expected is not None and revision != expected:
|
||||
raise TemplateConflictError("animation was modified")
|
||||
snapshot_dir = self._playback_root / f"{record['id']}-{revision}"
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
frames = []
|
||||
for frame in record["frames"]:
|
||||
source = self._thumb_dir(record["id"]) / frame["thumbnail"]
|
||||
target = snapshot_dir / frame["thumbnail"]
|
||||
if not target.exists():
|
||||
try:
|
||||
os.link(source, target)
|
||||
except OSError:
|
||||
shutil.copyfile(source, target)
|
||||
frames.append({
|
||||
"id": frame["id"], "duration_ms": frame["duration_ms"], "path": target,
|
||||
})
|
||||
return {**self._metadata(record), "frames": frames}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Configuration helpers."""
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_COLOR_PALETTE = [
|
||||
"#000000", "#FFFFFF", "#FF0000", "#FF8000", "#FFFF00", "#00FF00",
|
||||
"#00FFFF", "#0000FF", "#8000FF", "#FF00FF", "#808080", "#C0C0C0",
|
||||
]
|
||||
COLOR_PALETTE_LIMIT = 24
|
||||
DEFAULT_PREVIEW_REFRESH_INTERVAL_MS = 1000
|
||||
MIN_PREVIEW_REFRESH_INTERVAL_MS = 1
|
||||
MAX_PREVIEW_REFRESH_INTERVAL_MS = 60000
|
||||
DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT = 2
|
||||
MIN_ANIMATION_PREVIEW_MAX_CONCURRENT = 1
|
||||
MAX_ANIMATION_PREVIEW_MAX_CONCURRENT = 50
|
||||
DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ = 100
|
||||
DEFAULT_DISPLAY = {
|
||||
"type": "animation",
|
||||
"id": "00000000-0000-4000-8000-000000000102",
|
||||
}
|
||||
DEFAULT_WORKSPACE_ORDER = [
|
||||
"device",
|
||||
"settings",
|
||||
"canvas",
|
||||
"text",
|
||||
"media-import",
|
||||
"animations",
|
||||
"templates",
|
||||
]
|
||||
MAX_WORKSPACE_ORDER_ITEMS = 128
|
||||
MATRIX_REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
|
||||
HEX_COLOR_RE = re.compile(r"^#([0-9A-Fa-f]{6})$")
|
||||
WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"orientation": 0,
|
||||
"brightness": 40,
|
||||
"default_font": "default",
|
||||
"default_text_size": 12,
|
||||
"last_mode": "clear",
|
||||
"color_palette": DEFAULT_COLOR_PALETTE,
|
||||
"voltage_calibration_factor": 1.0,
|
||||
"voltage_calibrated_at": None,
|
||||
"voltage_calibration_reference": None,
|
||||
"voltage_calibration_uncalibrated": None,
|
||||
"preview_refresh_interval_ms": DEFAULT_PREVIEW_REFRESH_INTERVAL_MS,
|
||||
"low_voltage_protection_enabled": False,
|
||||
"matrix_refresh_rate_limit_hz": DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ,
|
||||
"custom_test_color": "#40A0FF",
|
||||
"default_display": DEFAULT_DISPLAY,
|
||||
"workspace_order": DEFAULT_WORKSPACE_ORDER,
|
||||
"performance_mode_enabled": False,
|
||||
"animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA_VERSION = 10
|
||||
CONFIG_V1_FIELDS = frozenset({
|
||||
"orientation",
|
||||
"brightness",
|
||||
"default_font",
|
||||
"default_text_size",
|
||||
"last_mode",
|
||||
"color_palette",
|
||||
"voltage_calibration_factor",
|
||||
"voltage_calibrated_at",
|
||||
"voltage_calibration_reference",
|
||||
"voltage_calibration_uncalibrated",
|
||||
})
|
||||
CONFIG_V2_FIELDS = CONFIG_V1_FIELDS | {"preview_refresh_interval_ms"}
|
||||
CONFIG_V3_FIELDS = CONFIG_V2_FIELDS | {"low_voltage_protection_enabled"}
|
||||
CONFIG_V4_FIELDS = CONFIG_V3_FIELDS | {"matrix_refresh_rate_limit_hz"}
|
||||
CONFIG_V5_FIELDS = CONFIG_V4_FIELDS | {"custom_test_color"}
|
||||
CONFIG_V6_FIELDS = CONFIG_V5_FIELDS | {"default_display"}
|
||||
CONFIG_V7_FIELDS = CONFIG_V6_FIELDS | {"workspace_order"}
|
||||
CONFIG_V8_FIELDS = CONFIG_V7_FIELDS
|
||||
CONFIG_V9_FIELDS = CONFIG_V8_FIELDS | {"performance_mode_enabled"}
|
||||
CONFIG_V10_FIELDS = CONFIG_V9_FIELDS | {"animation_preview_max_concurrent"}
|
||||
CONFIG_FIELDS = CONFIG_V10_FIELDS
|
||||
ORIENTATIONS = {0, 90, 180, 270}
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when a persisted or requested configuration is invalid."""
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
def __init__(self, project_root: Path | None = None) -> None:
|
||||
self.project_root = (
|
||||
Path(project_root)
|
||||
if project_root is not None
|
||||
else Path(__file__).resolve().parents[2]
|
||||
)
|
||||
self.data_dir = resolve_data_dir(project_root)
|
||||
self.runtime_dir = resolve_runtime_dir(project_root, data_dir=self.data_dir)
|
||||
self.path = self.data_dir / "config.json"
|
||||
self._lock = threading.RLock()
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._config = self.load()
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
**self._config,
|
||||
"color_palette": list(self._config["color_palette"]),
|
||||
"default_display": dict(self._config["default_display"]),
|
||||
"workspace_order": list(self._config["workspace_order"]),
|
||||
}
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
config = default_config()
|
||||
self._write(config)
|
||||
logger.info("Created default config at %s", self.path)
|
||||
return config
|
||||
|
||||
try:
|
||||
raw = json.loads(self.path.read_bytes().decode("utf-8"))
|
||||
config, migrated = load_persisted_config(raw)
|
||||
if migrated:
|
||||
self._write(config)
|
||||
logger.info("Migrated config at %s", self.path)
|
||||
logger.info("Loaded config from %s", self.path)
|
||||
return config
|
||||
except ConfigError:
|
||||
raise
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ConfigError(f"persisted config is unreadable: {self.path}") from exc
|
||||
except Exception as exc:
|
||||
raise ConfigError(f"persisted config is invalid: {self.path}") from exc
|
||||
|
||||
def update(self, values: dict[str, Any]) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
unknown = set(values) - CONFIG_FIELDS
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown config fields: {', '.join(sorted(unknown))}")
|
||||
merged = {**self._config, **values}
|
||||
config = validate_config(merged)
|
||||
if config == self._config:
|
||||
return {
|
||||
**config,
|
||||
"color_palette": list(config["color_palette"]),
|
||||
"default_display": dict(config["default_display"]),
|
||||
"workspace_order": list(config["workspace_order"]),
|
||||
}
|
||||
self._write(config)
|
||||
self._config = config
|
||||
logger.info("Saved config to %s", self.path)
|
||||
return {
|
||||
**config,
|
||||
"color_palette": list(config["color_palette"]),
|
||||
"default_display": dict(config["default_display"]),
|
||||
"workspace_order": list(config["workspace_order"]),
|
||||
}
|
||||
|
||||
def add_palette_color(self, color: str) -> dict[str, Any]:
|
||||
normalized = normalize_hex_color(color)
|
||||
with self._lock:
|
||||
palette = list(self._config["color_palette"])
|
||||
if normalized in palette:
|
||||
return self.config
|
||||
if len(palette) >= COLOR_PALETTE_LIMIT:
|
||||
raise ConfigError(f"color palette is limited to {COLOR_PALETTE_LIMIT} colors")
|
||||
palette.append(normalized)
|
||||
return self.update({"color_palette": palette})
|
||||
|
||||
def remove_palette_color(self, color: str) -> dict[str, Any]:
|
||||
normalized = normalize_hex_color(color)
|
||||
with self._lock:
|
||||
palette = [item for item in self._config["color_palette"] if item != normalized]
|
||||
if len(palette) == len(self._config["color_palette"]):
|
||||
return self.config
|
||||
return self.update({"color_palette": palette})
|
||||
|
||||
def set_last_mode(self, mode: str) -> None:
|
||||
self.update({"last_mode": mode})
|
||||
|
||||
def _write(self, config: dict[str, Any]) -> None:
|
||||
persisted = {"schema_version": CONFIG_SCHEMA_VERSION, **config}
|
||||
atomic_write_bytes(
|
||||
self.path,
|
||||
(json.dumps(persisted, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
def _configured_path(name: str) -> Path | None:
|
||||
value = os.environ.get(name)
|
||||
return Path(value) if value else None
|
||||
|
||||
|
||||
def resolve_data_dir(project_root: Path | None = None) -> Path:
|
||||
if project_root is not None:
|
||||
return Path(project_root) / "data"
|
||||
return (
|
||||
_configured_path("MATRIX_DATA_DIR")
|
||||
or _configured_path("STATE_DIRECTORY")
|
||||
or Path(__file__).resolve().parents[2] / "data"
|
||||
)
|
||||
|
||||
|
||||
def resolve_runtime_dir(
|
||||
project_root: Path | None = None,
|
||||
*,
|
||||
data_dir: Path | None = None,
|
||||
) -> Path:
|
||||
if project_root is not None:
|
||||
return Path(project_root) / "data" / "runtime"
|
||||
return (
|
||||
_configured_path("MATRIX_RUNTIME_DIR")
|
||||
or _configured_path("RUNTIME_DIRECTORY")
|
||||
or Path(__file__).resolve().parents[2] / "data" / "runtime"
|
||||
)
|
||||
|
||||
|
||||
def default_config() -> dict[str, Any]:
|
||||
return {
|
||||
**DEFAULT_CONFIG,
|
||||
"color_palette": list(DEFAULT_COLOR_PALETTE),
|
||||
"default_display": dict(DEFAULT_DISPLAY),
|
||||
"workspace_order": list(DEFAULT_WORKSPACE_ORDER),
|
||||
}
|
||||
|
||||
|
||||
def _check_known_fields(
|
||||
document: dict[str, Any],
|
||||
allowed_fields: frozenset[str] = CONFIG_FIELDS,
|
||||
) -> None:
|
||||
unknown = set(document) - allowed_fields
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown persisted config fields: {', '.join(sorted(unknown))}")
|
||||
|
||||
|
||||
def _validate_current_config(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document)
|
||||
missing = CONFIG_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(f"persisted config is missing fields: {', '.join(sorted(missing))}")
|
||||
_validate_current_field_types(document)
|
||||
try:
|
||||
checked = validate_config(document)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConfigError("persisted config values are invalid") from exc
|
||||
if checked != document:
|
||||
raise ConfigError(
|
||||
f"persisted config does not match schema version {CONFIG_SCHEMA_VERSION}"
|
||||
)
|
||||
return checked
|
||||
|
||||
|
||||
def _validate_current_field_types(document: dict[str, Any]) -> None:
|
||||
integer_fields = (
|
||||
"orientation",
|
||||
"brightness",
|
||||
"default_text_size",
|
||||
"preview_refresh_interval_ms",
|
||||
"matrix_refresh_rate_limit_hz",
|
||||
"animation_preview_max_concurrent",
|
||||
)
|
||||
for field in integer_fields:
|
||||
if type(document[field]) is not int:
|
||||
raise ConfigError(f"persisted config field {field} must be an integer")
|
||||
for field in ("default_font", "last_mode"):
|
||||
if not isinstance(document[field], str):
|
||||
raise ConfigError(f"persisted config field {field} must be a string")
|
||||
if type(document["low_voltage_protection_enabled"]) is not bool:
|
||||
raise ConfigError(
|
||||
"persisted config field low_voltage_protection_enabled must be a boolean"
|
||||
)
|
||||
if type(document["performance_mode_enabled"]) is not bool:
|
||||
raise ConfigError(
|
||||
"persisted config field performance_mode_enabled must be a boolean"
|
||||
)
|
||||
if not isinstance(document["custom_test_color"], str):
|
||||
raise ConfigError("persisted config field custom_test_color must be a string")
|
||||
default_display = document["default_display"]
|
||||
if not isinstance(default_display, dict):
|
||||
raise ConfigError("persisted config field default_display must be an object")
|
||||
workspace_order = document["workspace_order"]
|
||||
if not isinstance(workspace_order, list) or any(
|
||||
not isinstance(value, str) for value in workspace_order
|
||||
):
|
||||
raise ConfigError("persisted config field workspace_order must be an array of strings")
|
||||
palette = document["color_palette"]
|
||||
if not isinstance(palette, list) or any(not isinstance(value, str) for value in palette):
|
||||
raise ConfigError("persisted config field color_palette must be an array of strings")
|
||||
factor = document["voltage_calibration_factor"]
|
||||
if isinstance(factor, bool) or not isinstance(factor, (int, float)):
|
||||
raise ConfigError("persisted config field voltage_calibration_factor must be a number")
|
||||
calibrated_at = document["voltage_calibrated_at"]
|
||||
if calibrated_at is not None and not isinstance(calibrated_at, str):
|
||||
raise ConfigError("persisted config field voltage_calibrated_at must be a string or null")
|
||||
for field in ("voltage_calibration_reference", "voltage_calibration_uncalibrated"):
|
||||
value = document[field]
|
||||
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
||||
raise ConfigError(f"persisted config field {field} must be a number or null")
|
||||
|
||||
|
||||
def _migrate_config_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V1_FIELDS)
|
||||
try:
|
||||
migrated = validate_config({**default_config(), **document})
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConfigError("legacy config cannot be migrated safely") from exc
|
||||
calibration_fields = {
|
||||
"voltage_calibration_factor",
|
||||
"voltage_calibrated_at",
|
||||
"voltage_calibration_reference",
|
||||
"voltage_calibration_uncalibrated",
|
||||
}
|
||||
present_calibration_fields = calibration_fields & set(document)
|
||||
if present_calibration_fields:
|
||||
if present_calibration_fields != calibration_fields:
|
||||
raise ConfigError("legacy voltage calibration is incomplete")
|
||||
if any(migrated[field] != document[field] for field in calibration_fields):
|
||||
raise ConfigError("legacy voltage calibration cannot be migrated safely")
|
||||
migrated.pop("preview_refresh_interval_ms")
|
||||
migrated.pop("low_voltage_protection_enabled")
|
||||
migrated.pop("matrix_refresh_rate_limit_hz")
|
||||
migrated.pop("custom_test_color")
|
||||
migrated.pop("default_display")
|
||||
migrated.pop("workspace_order")
|
||||
migrated.pop("performance_mode_enabled")
|
||||
migrated.pop("animation_preview_max_concurrent")
|
||||
return migrated
|
||||
|
||||
|
||||
def _migrate_config_v1_to_v2(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V1_FIELDS)
|
||||
missing = CONFIG_V1_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {
|
||||
**document,
|
||||
"preview_refresh_interval_ms": DEFAULT_PREVIEW_REFRESH_INTERVAL_MS,
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config_v2_to_v3(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V2_FIELDS)
|
||||
missing = CONFIG_V2_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {
|
||||
**document,
|
||||
"low_voltage_protection_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config_v3_to_v4(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V3_FIELDS)
|
||||
missing = CONFIG_V3_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {
|
||||
**document,
|
||||
"matrix_refresh_rate_limit_hz": DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ,
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config_v4_to_v5(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V4_FIELDS)
|
||||
missing = CONFIG_V4_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {
|
||||
**document,
|
||||
"custom_test_color": DEFAULT_CONFIG["custom_test_color"],
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config_v5_to_v6(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V5_FIELDS)
|
||||
missing = CONFIG_V5_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {
|
||||
**document,
|
||||
"default_display": dict(DEFAULT_DISPLAY),
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config_v6_to_v7(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V6_FIELDS)
|
||||
missing = CONFIG_V6_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {
|
||||
**document,
|
||||
"workspace_order": [
|
||||
"device", "settings", "canvas", "text", "animations", "templates",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config_v7_to_v8(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V7_FIELDS)
|
||||
missing = CONFIG_V7_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
order = normalize_workspace_order(document["workspace_order"])
|
||||
if "media-import" not in order:
|
||||
insert_at = order.index("animations") if "animations" in order else len(order)
|
||||
order.insert(insert_at, "media-import")
|
||||
return {**document, "workspace_order": order}
|
||||
|
||||
|
||||
def _migrate_config_v8_to_v9(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V8_FIELDS)
|
||||
missing = CONFIG_V8_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {**document, "performance_mode_enabled": False}
|
||||
|
||||
|
||||
def _migrate_config_v9_to_v10(document: dict[str, Any]) -> dict[str, Any]:
|
||||
_check_known_fields(document, CONFIG_V9_FIELDS)
|
||||
missing = CONFIG_V9_FIELDS - set(document)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"persisted config is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return {
|
||||
**document,
|
||||
"animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
|
||||
}
|
||||
|
||||
|
||||
CONFIG_MIGRATIONS = {
|
||||
0: _migrate_config_v0_to_v1,
|
||||
1: _migrate_config_v1_to_v2,
|
||||
2: _migrate_config_v2_to_v3,
|
||||
3: _migrate_config_v3_to_v4,
|
||||
4: _migrate_config_v4_to_v5,
|
||||
5: _migrate_config_v5_to_v6,
|
||||
6: _migrate_config_v6_to_v7,
|
||||
7: _migrate_config_v7_to_v8,
|
||||
8: _migrate_config_v8_to_v9,
|
||||
9: _migrate_config_v9_to_v10,
|
||||
}
|
||||
|
||||
|
||||
def load_persisted_config(raw: Any) -> tuple[dict[str, Any], bool]:
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("persisted config must be a JSON object")
|
||||
|
||||
document = dict(raw)
|
||||
if "schema_version" in document:
|
||||
version = document.pop("schema_version")
|
||||
if type(version) is not int:
|
||||
raise ConfigError("config schema_version must be an integer")
|
||||
else:
|
||||
version = 0
|
||||
|
||||
if version > CONFIG_SCHEMA_VERSION:
|
||||
raise ConfigError(
|
||||
f"config schema version {version} is newer than supported version "
|
||||
f"{CONFIG_SCHEMA_VERSION}"
|
||||
)
|
||||
if version < 0:
|
||||
raise ConfigError(f"unsupported config schema version {version}")
|
||||
if version == CONFIG_SCHEMA_VERSION:
|
||||
return _validate_current_config(document), False
|
||||
|
||||
while version < CONFIG_SCHEMA_VERSION:
|
||||
migrator = CONFIG_MIGRATIONS.get(version)
|
||||
if migrator is None:
|
||||
raise ConfigError(f"no config migration is registered for version {version}")
|
||||
document = migrator(document)
|
||||
version += 1
|
||||
return _validate_current_config(document), True
|
||||
|
||||
|
||||
def validate_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
orientation = int(config.get("orientation", DEFAULT_CONFIG["orientation"]))
|
||||
if orientation not in ORIENTATIONS:
|
||||
raise ConfigError("orientation must be one of 0, 90, 180, 270")
|
||||
|
||||
brightness = int(config.get("brightness", DEFAULT_CONFIG["brightness"]))
|
||||
if brightness < 1 or brightness > 100:
|
||||
raise ConfigError("brightness must be in 1..100")
|
||||
|
||||
default_font = str(config.get("default_font", DEFAULT_CONFIG["default_font"])).strip()
|
||||
if not default_font:
|
||||
default_font = DEFAULT_CONFIG["default_font"]
|
||||
|
||||
default_text_size = int(config.get("default_text_size", DEFAULT_CONFIG["default_text_size"]))
|
||||
if default_text_size < 1 or default_text_size > 64:
|
||||
raise ConfigError("default_text_size must be in 1..64")
|
||||
|
||||
preview_refresh_interval = config.get(
|
||||
"preview_refresh_interval_ms",
|
||||
DEFAULT_PREVIEW_REFRESH_INTERVAL_MS,
|
||||
)
|
||||
if type(preview_refresh_interval) is not int:
|
||||
raise ConfigError("preview_refresh_interval_ms must be an integer")
|
||||
if not (
|
||||
MIN_PREVIEW_REFRESH_INTERVAL_MS
|
||||
<= preview_refresh_interval
|
||||
<= MAX_PREVIEW_REFRESH_INTERVAL_MS
|
||||
):
|
||||
raise ConfigError(
|
||||
"preview_refresh_interval_ms must be in "
|
||||
f"{MIN_PREVIEW_REFRESH_INTERVAL_MS}..{MAX_PREVIEW_REFRESH_INTERVAL_MS}"
|
||||
)
|
||||
|
||||
low_voltage_protection_enabled = config.get(
|
||||
"low_voltage_protection_enabled",
|
||||
DEFAULT_CONFIG["low_voltage_protection_enabled"],
|
||||
)
|
||||
if type(low_voltage_protection_enabled) is not bool:
|
||||
raise ConfigError("low_voltage_protection_enabled must be a boolean")
|
||||
|
||||
performance_mode_enabled = config.get(
|
||||
"performance_mode_enabled",
|
||||
DEFAULT_CONFIG["performance_mode_enabled"],
|
||||
)
|
||||
if type(performance_mode_enabled) is not bool:
|
||||
raise ConfigError("performance_mode_enabled must be a boolean")
|
||||
|
||||
animation_preview_max_concurrent = config.get(
|
||||
"animation_preview_max_concurrent",
|
||||
DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
|
||||
)
|
||||
if type(animation_preview_max_concurrent) is not int:
|
||||
raise ConfigError("animation_preview_max_concurrent must be an integer")
|
||||
if not (
|
||||
MIN_ANIMATION_PREVIEW_MAX_CONCURRENT
|
||||
<= animation_preview_max_concurrent
|
||||
<= MAX_ANIMATION_PREVIEW_MAX_CONCURRENT
|
||||
):
|
||||
raise ConfigError(
|
||||
"animation_preview_max_concurrent must be in "
|
||||
f"{MIN_ANIMATION_PREVIEW_MAX_CONCURRENT}.."
|
||||
f"{MAX_ANIMATION_PREVIEW_MAX_CONCURRENT}"
|
||||
)
|
||||
|
||||
matrix_refresh_rate_limit = config.get(
|
||||
"matrix_refresh_rate_limit_hz",
|
||||
DEFAULT_CONFIG["matrix_refresh_rate_limit_hz"],
|
||||
)
|
||||
if type(matrix_refresh_rate_limit) is not int:
|
||||
raise ConfigError("matrix_refresh_rate_limit_hz must be an integer")
|
||||
if matrix_refresh_rate_limit not in MATRIX_REFRESH_RATE_LIMITS:
|
||||
allowed = ", ".join(str(value) for value in sorted(MATRIX_REFRESH_RATE_LIMITS))
|
||||
raise ConfigError(
|
||||
f"matrix_refresh_rate_limit_hz must be one of {allowed}"
|
||||
)
|
||||
|
||||
custom_test_color = normalize_hex_color(
|
||||
config.get("custom_test_color", DEFAULT_CONFIG["custom_test_color"])
|
||||
)
|
||||
default_display = normalize_default_display(
|
||||
config.get("default_display", DEFAULT_DISPLAY)
|
||||
)
|
||||
workspace_order = normalize_workspace_order(
|
||||
config.get("workspace_order", DEFAULT_WORKSPACE_ORDER)
|
||||
)
|
||||
|
||||
last_mode = str(config.get("last_mode", DEFAULT_CONFIG["last_mode"]))
|
||||
|
||||
raw_palette = config.get("color_palette", DEFAULT_COLOR_PALETTE)
|
||||
if not isinstance(raw_palette, list):
|
||||
raw_palette = DEFAULT_COLOR_PALETTE
|
||||
color_palette: list[str] = []
|
||||
for value in raw_palette:
|
||||
try:
|
||||
normalized = normalize_hex_color(value)
|
||||
except ConfigError:
|
||||
continue
|
||||
if normalized not in color_palette:
|
||||
color_palette.append(normalized)
|
||||
if len(color_palette) == COLOR_PALETTE_LIMIT:
|
||||
break
|
||||
|
||||
voltage_factor = config.get("voltage_calibration_factor", 1.0)
|
||||
voltage_calibrated_at = config.get("voltage_calibrated_at")
|
||||
voltage_reference = config.get("voltage_calibration_reference")
|
||||
voltage_uncalibrated = config.get("voltage_calibration_uncalibrated")
|
||||
try:
|
||||
voltage_factor = float(voltage_factor)
|
||||
factor_valid = math.isfinite(voltage_factor) and 0.8 <= voltage_factor <= 1.2
|
||||
except (TypeError, ValueError):
|
||||
factor_valid = False
|
||||
|
||||
calibration_values = (voltage_calibrated_at, voltage_reference, voltage_uncalibrated)
|
||||
if all(value is None for value in calibration_values):
|
||||
calibration_valid = factor_valid and voltage_factor == 1.0
|
||||
elif all(value is not None for value in calibration_values) and factor_valid:
|
||||
try:
|
||||
datetime.fromisoformat(str(voltage_calibrated_at).replace("Z", "+00:00"))
|
||||
voltage_reference = float(voltage_reference)
|
||||
voltage_uncalibrated = float(voltage_uncalibrated)
|
||||
calibration_valid = (
|
||||
math.isfinite(voltage_reference)
|
||||
and 4.5 <= voltage_reference <= 5.5
|
||||
and math.isfinite(voltage_uncalibrated)
|
||||
and 0.0 < voltage_uncalibrated <= 12.0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
calibration_valid = False
|
||||
else:
|
||||
calibration_valid = False
|
||||
|
||||
if not calibration_valid:
|
||||
voltage_factor = 1.0
|
||||
voltage_calibrated_at = None
|
||||
voltage_reference = None
|
||||
voltage_uncalibrated = None
|
||||
|
||||
return {
|
||||
"orientation": orientation,
|
||||
"brightness": brightness,
|
||||
"default_font": default_font,
|
||||
"default_text_size": default_text_size,
|
||||
"last_mode": last_mode,
|
||||
"color_palette": color_palette,
|
||||
"voltage_calibration_factor": voltage_factor,
|
||||
"voltage_calibrated_at": voltage_calibrated_at,
|
||||
"voltage_calibration_reference": voltage_reference,
|
||||
"voltage_calibration_uncalibrated": voltage_uncalibrated,
|
||||
"preview_refresh_interval_ms": preview_refresh_interval,
|
||||
"low_voltage_protection_enabled": low_voltage_protection_enabled,
|
||||
"matrix_refresh_rate_limit_hz": matrix_refresh_rate_limit,
|
||||
"custom_test_color": custom_test_color,
|
||||
"default_display": default_display,
|
||||
"workspace_order": workspace_order,
|
||||
"performance_mode_enabled": performance_mode_enabled,
|
||||
"animation_preview_max_concurrent": animation_preview_max_concurrent,
|
||||
}
|
||||
|
||||
|
||||
def normalize_hex_color(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError("palette color must be #RRGGBB")
|
||||
match = HEX_COLOR_RE.fullmatch(value.strip())
|
||||
if not match:
|
||||
raise ConfigError("palette color must be #RRGGBB")
|
||||
return f"#{match.group(1).upper()}"
|
||||
|
||||
|
||||
def normalize_default_display(value: Any) -> dict[str, str]:
|
||||
if not isinstance(value, dict) or set(value) != {"type", "id"}:
|
||||
raise ConfigError("default_display must contain only type and id")
|
||||
display_type = value.get("type")
|
||||
if display_type not in {"template", "animation"}:
|
||||
raise ConfigError("default_display type must be template or animation")
|
||||
raw_id = value.get("id")
|
||||
if not isinstance(raw_id, str):
|
||||
raise ConfigError("default_display id must be a UUID string")
|
||||
try:
|
||||
display_id = str(UUID(raw_id))
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ConfigError("default_display id must be a UUID string") from exc
|
||||
return {"type": display_type, "id": display_id}
|
||||
|
||||
|
||||
def normalize_workspace_order(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise ConfigError("workspace_order must be an array")
|
||||
if not 1 <= len(value) <= MAX_WORKSPACE_ORDER_ITEMS:
|
||||
raise ConfigError(
|
||||
f"workspace_order must contain 1..{MAX_WORKSPACE_ORDER_ITEMS} items"
|
||||
)
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for workspace_id in value:
|
||||
if not isinstance(workspace_id, str) or not WORKSPACE_ID_RE.fullmatch(workspace_id):
|
||||
raise ConfigError("workspace_order contains an invalid workspace id")
|
||||
if workspace_id in seen:
|
||||
raise ConfigError("workspace_order items must be unique")
|
||||
seen.add(workspace_id)
|
||||
normalized.append(workspace_id)
|
||||
return normalized
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.display.startup_indicator import (
|
||||
CLOSED_EYE_SECONDS,
|
||||
OPEN_EYE_SECONDS,
|
||||
render_startup_smile,
|
||||
)
|
||||
from app.templates.store import render_scene_thumbnail, scene_digest
|
||||
|
||||
|
||||
DEMO_STATIC_ID = "00000000-0000-4000-8000-000000000101"
|
||||
DEMO_ANIMATION_ID = "00000000-0000-4000-8000-000000000102"
|
||||
DEMO_OPEN_FRAME_ID = "00000000-0000-4000-8000-000000000201"
|
||||
DEMO_CLOSED_FRAME_ID = "00000000-0000-4000-8000-000000000202"
|
||||
DEMO_STATIC_NAME = "演示静态图"
|
||||
DEMO_ANIMATION_NAME = "演示动图"
|
||||
DEMO_RESTRICTED_MESSAGE = "演示案例不得编辑,请复制"
|
||||
DEMO_TIMESTAMP = "1970-01-01T00:00:00+00:00"
|
||||
|
||||
|
||||
def _scene(image: Image.Image) -> dict[str, Any]:
|
||||
rgb = image.convert("RGB")
|
||||
return {
|
||||
"version": 1,
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"pixelRgb": base64.b64encode(rgb.tobytes()).decode("ascii"),
|
||||
"elements": [],
|
||||
}
|
||||
|
||||
|
||||
DEMO_OPEN_SCENE = _scene(render_startup_smile(eyes_closed=False))
|
||||
DEMO_CLOSED_SCENE = _scene(render_startup_smile(eyes_closed=True))
|
||||
|
||||
|
||||
def _revision(value: Any) -> str:
|
||||
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def demo_template(*, include_scene: bool = False) -> dict[str, Any]:
|
||||
digest = scene_digest(DEMO_OPEN_SCENE)
|
||||
item = {
|
||||
"id": DEMO_STATIC_ID,
|
||||
"name": DEMO_STATIC_NAME,
|
||||
"created_at": DEMO_TIMESTAMP,
|
||||
"updated_at": DEMO_TIMESTAMP,
|
||||
"digest": digest,
|
||||
"thumbnail_url": f"/api/templates/{DEMO_STATIC_ID}/thumbnail?v={digest}",
|
||||
"size_bytes": 0,
|
||||
"is_demo": True,
|
||||
"demo_order": 1,
|
||||
"read_only": True,
|
||||
}
|
||||
item["revision"] = _revision({**item, "scene": DEMO_OPEN_SCENE})
|
||||
if include_scene:
|
||||
item["scene"] = DEMO_OPEN_SCENE
|
||||
return item
|
||||
|
||||
|
||||
def _demo_frames(*, include_scenes: bool) -> list[dict[str, Any]]:
|
||||
frames = []
|
||||
definitions = (
|
||||
(DEMO_OPEN_FRAME_ID, "睁眼", round(OPEN_EYE_SECONDS * 1000), DEMO_OPEN_SCENE),
|
||||
(DEMO_CLOSED_FRAME_ID, "闭眼", round(CLOSED_EYE_SECONDS * 1000), DEMO_CLOSED_SCENE),
|
||||
)
|
||||
for position, (frame_id, name, duration_ms, scene) in enumerate(definitions, start=1):
|
||||
digest = scene_digest(scene)
|
||||
frame = {
|
||||
"id": frame_id,
|
||||
"name": name,
|
||||
"position": position,
|
||||
"duration_ms": duration_ms,
|
||||
"created_at": DEMO_TIMESTAMP,
|
||||
"updated_at": DEMO_TIMESTAMP,
|
||||
"digest": digest,
|
||||
"thumbnail_url": (
|
||||
f"/api/animations/{DEMO_ANIMATION_ID}/frames/{frame_id}/thumbnail?v={digest}"
|
||||
),
|
||||
}
|
||||
if include_scenes:
|
||||
frame["scene"] = scene
|
||||
frames.append(frame)
|
||||
return frames
|
||||
|
||||
|
||||
def demo_animation(*, include_scenes: bool = False) -> dict[str, Any]:
|
||||
frames_with_scenes = _demo_frames(include_scenes=True)
|
||||
revision = _revision({"id": DEMO_ANIMATION_ID, "name": DEMO_ANIMATION_NAME, "frames": frames_with_scenes})
|
||||
frames = frames_with_scenes if include_scenes else _demo_frames(include_scenes=False)
|
||||
return {
|
||||
"id": DEMO_ANIMATION_ID,
|
||||
"name": DEMO_ANIMATION_NAME,
|
||||
"created_at": DEMO_TIMESTAMP,
|
||||
"updated_at": DEMO_TIMESTAMP,
|
||||
"revision": revision,
|
||||
"frame_count": len(frames),
|
||||
"total_duration_ms": sum(frame["duration_ms"] for frame in frames),
|
||||
"size_bytes": 0,
|
||||
"thumbnail_url": frames[0]["thumbnail_url"],
|
||||
"frames": frames,
|
||||
"is_demo": True,
|
||||
"demo_order": 2,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
|
||||
def demo_animation_frame(frame_id: str) -> dict[str, Any]:
|
||||
animation = demo_animation(include_scenes=True)
|
||||
for frame in animation["frames"]:
|
||||
if frame["id"] == frame_id:
|
||||
return {
|
||||
"animation_id": DEMO_ANIMATION_ID,
|
||||
"animation_name": DEMO_ANIMATION_NAME,
|
||||
"animation_revision": animation["revision"],
|
||||
**frame,
|
||||
}
|
||||
raise KeyError(frame_id)
|
||||
|
||||
|
||||
def demo_thumbnail(scene: dict[str, Any]) -> bytes:
|
||||
return render_scene_thumbnail(scene)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Display abstraction and drivers."""
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RGB = tuple[int, int, int]
|
||||
HEX_RE = re.compile(r"^#([0-9a-fA-F]{6})$")
|
||||
|
||||
|
||||
def parse_color(value: Any) -> RGB:
|
||||
if isinstance(value, str):
|
||||
match = HEX_RE.match(value.strip())
|
||||
if not match:
|
||||
raise ValueError("color must be #RRGGBB")
|
||||
raw = match.group(1)
|
||||
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
|
||||
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
return validate_rgb((value["r"], value["g"], value["b"]))
|
||||
except KeyError as exc:
|
||||
raise ValueError("color object must contain r, g, and b") from exc
|
||||
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
return validate_rgb(value)
|
||||
|
||||
raise ValueError("color must be #RRGGBB or {r,g,b}")
|
||||
|
||||
|
||||
def validate_rgb(value: Any) -> RGB:
|
||||
try:
|
||||
channels = tuple(int(channel) for channel in value)
|
||||
except Exception as exc:
|
||||
raise ValueError("RGB channels must be integers") from exc
|
||||
|
||||
if len(channels) != 3:
|
||||
raise ValueError("RGB color must have three channels")
|
||||
|
||||
if any(channel < 0 or channel > 255 for channel in channels):
|
||||
raise ValueError("RGB channels must be in 0..255")
|
||||
|
||||
return channels # type: ignore[return-value]
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .text_renderer import TextOptions, render_text
|
||||
|
||||
DIAGNOSTIC_MODES = {
|
||||
"corners_lines",
|
||||
"row_bands",
|
||||
"address_check",
|
||||
"address_bit_0",
|
||||
"address_bit_1",
|
||||
"address_bit_2",
|
||||
"address_bit_3",
|
||||
"address_bit_4",
|
||||
"text_ok123",
|
||||
"clear",
|
||||
}
|
||||
|
||||
|
||||
def render_diagnostic(mode: str, size: tuple[int, int] = (64, 64)) -> Image.Image:
|
||||
if mode not in DIAGNOSTIC_MODES:
|
||||
allowed = ", ".join(sorted(DIAGNOSTIC_MODES))
|
||||
raise ValueError(f"diagnostic mode must be one of: {allowed}")
|
||||
|
||||
if mode == "clear":
|
||||
return Image.new("RGB", size, (0, 0, 0))
|
||||
if mode == "corners_lines":
|
||||
return _render_corners_lines(size)
|
||||
if mode == "row_bands":
|
||||
return _render_row_bands(size)
|
||||
if mode == "address_check":
|
||||
return _render_address_check(size)
|
||||
if mode.startswith("address_bit_"):
|
||||
return _render_address_bit(int(mode.rsplit("_", 1)[1]), size)
|
||||
return render_text(TextOptions(text="OK123", size=12, x=4, y=24), size=size)
|
||||
|
||||
|
||||
def _render_corners_lines(size: tuple[int, int]) -> Image.Image:
|
||||
width, height = size
|
||||
image = Image.new("RGB", size, (0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
for y, color in ((0, (80, 80, 80)), (31, (255, 180, 0)), (32, (0, 180, 255)), (63, (80, 80, 80))):
|
||||
if y < height:
|
||||
draw.line((0, y, width - 1, y), fill=color)
|
||||
for x in (0, width - 1):
|
||||
draw.line((x, 0, x, height - 1), fill=(80, 80, 80))
|
||||
|
||||
marker = 4
|
||||
draw.rectangle((0, 0, marker, marker), fill=(255, 0, 0))
|
||||
draw.rectangle((width - marker - 1, 0, width - 1, marker), fill=(0, 255, 0))
|
||||
draw.rectangle((0, height - marker - 1, marker, height - 1), fill=(0, 0, 255))
|
||||
draw.rectangle((width - marker - 1, height - marker - 1, width - 1, height - 1), fill=(255, 255, 255))
|
||||
return image
|
||||
|
||||
|
||||
def _render_row_bands(size: tuple[int, int]) -> Image.Image:
|
||||
width, height = size
|
||||
image = Image.new("RGB", size, (0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
colors = (
|
||||
(255, 0, 0),
|
||||
(255, 120, 0),
|
||||
(255, 255, 0),
|
||||
(0, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 0, 255),
|
||||
(180, 0, 255),
|
||||
(255, 255, 255),
|
||||
)
|
||||
|
||||
band_height = max(1, height // len(colors))
|
||||
for index, color in enumerate(colors):
|
||||
y0 = index * band_height
|
||||
y1 = height - 1 if index == len(colors) - 1 else min(height - 1, y0 + band_height - 1)
|
||||
draw.rectangle((0, y0, width - 1, y1), fill=color)
|
||||
draw.line((0, y0, width - 1, y0), fill=(0, 0, 0))
|
||||
return image
|
||||
|
||||
|
||||
def _render_address_check(size: tuple[int, int]) -> Image.Image:
|
||||
width, height = size
|
||||
image = Image.new("RGB", size, (0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
bit_colors = (
|
||||
(255, 0, 0),
|
||||
(0, 255, 0),
|
||||
(0, 0, 255),
|
||||
(255, 255, 0),
|
||||
(255, 0, 255),
|
||||
)
|
||||
segment_width = max(1, width // len(bit_colors))
|
||||
|
||||
for y in range(height):
|
||||
for bit, color in enumerate(bit_colors):
|
||||
if y & (1 << bit):
|
||||
x0 = bit * segment_width
|
||||
x1 = width - 1 if bit == len(bit_colors) - 1 else min(width - 1, x0 + segment_width - 2)
|
||||
draw.line((x0, y, x1, y), fill=color)
|
||||
|
||||
diagonal_x = min(width - 1, y)
|
||||
draw.point((diagonal_x, y), fill=(255, 255, 255))
|
||||
draw.point((width - 1 - diagonal_x, y), fill=(80, 80, 80))
|
||||
|
||||
for y in (0, 31, 32, 63):
|
||||
if y < height:
|
||||
draw.line((0, y, width - 1, y), fill=(255, 255, 255))
|
||||
return image
|
||||
|
||||
|
||||
def _render_address_bit(bit: int, size: tuple[int, int]) -> Image.Image:
|
||||
width, height = size
|
||||
image = Image.new("RGB", size, (0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
colors = (
|
||||
(255, 0, 0),
|
||||
(0, 255, 0),
|
||||
(0, 0, 255),
|
||||
(255, 255, 0),
|
||||
(255, 0, 255),
|
||||
)
|
||||
color = colors[bit]
|
||||
|
||||
for y in range(height):
|
||||
if y & (1 << bit):
|
||||
draw.line((0, y, width - 1, y), fill=color)
|
||||
|
||||
for y in (0, 31, 32, 63):
|
||||
if y < height:
|
||||
draw.line((0, y, width - 1, y), fill=(255, 255, 255))
|
||||
return image
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
BACKGROUND = (0, 0, 0)
|
||||
RED = (255, 0, 0)
|
||||
|
||||
|
||||
def render_low_voltage_indicator() -> Image.Image:
|
||||
"""Render the fixed 64x64 critical-battery indicator."""
|
||||
image = Image.new("RGB", (64, 64), BACKGROUND)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# A thick, empty battery outline. The terminal deliberately touches the
|
||||
# body so it remains legible after the existing orientation transform.
|
||||
draw.rectangle((9, 17, 49, 21), fill=RED)
|
||||
draw.rectangle((9, 42, 49, 46), fill=RED)
|
||||
draw.rectangle((9, 17, 13, 46), fill=RED)
|
||||
draw.rectangle((45, 17, 49, 46), fill=RED)
|
||||
draw.rectangle((50, 27, 56, 36), fill=RED)
|
||||
|
||||
# Keep the interior otherwise empty, with one short low-charge bar.
|
||||
draw.rectangle((17, 34, 22, 40), fill=RED)
|
||||
return image
|
||||
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
|
||||
NATIVE_LIBRARY = Path(__file__).with_name("native") / "libh618_hub75.so"
|
||||
HARDWARE_MAPPING = "walnutpi-pi-bank-pwm-oe-v2"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixDriverOptions:
|
||||
rows: int = 64
|
||||
cols: int = 64
|
||||
scan_rows: int = 32
|
||||
row_address_bits: int = 5
|
||||
pwm_bits: int = 7
|
||||
hardware_mapping: str = HARDWARE_MAPPING
|
||||
brightness: int = 40
|
||||
limit_refresh_rate_hz: int = 100
|
||||
pio_base: str = "0x0300B000"
|
||||
pi_bank_offset: str = "0x120"
|
||||
data_offset: str = "0x10"
|
||||
cpu_affinity: int = 3
|
||||
realtime_priority: int = 50
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def get_default_driver_options(
|
||||
brightness: int,
|
||||
limit_refresh_rate_hz: int = 100,
|
||||
) -> MatrixDriverOptions:
|
||||
options = MatrixDriverOptions(
|
||||
brightness=brightness,
|
||||
limit_refresh_rate_hz=limit_refresh_rate_hz,
|
||||
)
|
||||
validate_driver_options(options)
|
||||
return options
|
||||
|
||||
|
||||
def get_matrix_driver_options(
|
||||
brightness: int,
|
||||
limit_refresh_rate_hz: int = 100,
|
||||
) -> MatrixDriverOptions:
|
||||
# The panel geometry, GPIO mapping and PWM depth are hardware contracts,
|
||||
# not environment-variable tuning knobs. Keeping this function preserves
|
||||
# the DisplayService boundary while making every deployment deterministic.
|
||||
return get_default_driver_options(brightness, limit_refresh_rate_hz)
|
||||
|
||||
|
||||
def validate_driver_options(options: MatrixDriverOptions) -> None:
|
||||
if type(options.brightness) is not int or not 1 <= options.brightness <= 100:
|
||||
raise ValueError("brightness must be in 1..100")
|
||||
if options.limit_refresh_rate_hz not in REFRESH_RATE_LIMITS:
|
||||
raise ValueError(
|
||||
"limit_refresh_rate_hz must be one of 15, 20, 30, 45, 60, 80, or 100"
|
||||
)
|
||||
fixed = {
|
||||
"rows": (options.rows, 64),
|
||||
"cols": (options.cols, 64),
|
||||
"scan_rows": (options.scan_rows, 32),
|
||||
"row_address_bits": (options.row_address_bits, 5),
|
||||
"pwm_bits": (options.pwm_bits, 7),
|
||||
"hardware_mapping": (options.hardware_mapping, HARDWARE_MAPPING),
|
||||
}
|
||||
for name, (actual, expected) in fixed.items():
|
||||
if actual != expected:
|
||||
raise ValueError(f"{name} is fixed at {expected!r} for this hardware")
|
||||
|
||||
|
||||
class _NativeConfig(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("brightness", ctypes.c_int),
|
||||
("refresh_rate_hz", ctypes.c_int),
|
||||
("cpu_affinity", ctypes.c_int),
|
||||
("realtime_priority", ctypes.c_int),
|
||||
("use_dev_mem", ctypes.c_int),
|
||||
("fake_pwm5_mode", ctypes.c_int),
|
||||
("fake_pwm4_stuck", ctypes.c_int),
|
||||
]
|
||||
|
||||
|
||||
class _NativeStats(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("actual_refresh_rate_hz", ctypes.c_double),
|
||||
("panel_scan_rate_hz", ctypes.c_double),
|
||||
("completed_frames", ctypes.c_uint64),
|
||||
("completed_scans", ctypes.c_uint64),
|
||||
("deadline_misses", ctypes.c_uint64),
|
||||
("buffer_swaps", ctypes.c_uint64),
|
||||
("submitted_frames", ctypes.c_uint64),
|
||||
("oe_pulse_faults", ctypes.c_uint64),
|
||||
("oe_forced_blanks", ctypes.c_uint64),
|
||||
("max_programmed_oe_ns", ctypes.c_uint64),
|
||||
("cpu_affinity", ctypes.c_int),
|
||||
("cpu_affinity_active", ctypes.c_int),
|
||||
("realtime_priority", ctypes.c_int),
|
||||
("realtime_priority_active", ctypes.c_int),
|
||||
("memory_locked", ctypes.c_int),
|
||||
("running", ctypes.c_int),
|
||||
("brightness", ctypes.c_int),
|
||||
("refresh_rate_hz", ctypes.c_int),
|
||||
("scans_per_frame", ctypes.c_int),
|
||||
("governor", ctypes.c_char * 32),
|
||||
("affinity_error", ctypes.c_char * 128),
|
||||
("realtime_error", ctypes.c_char * 128),
|
||||
("memory_lock_error", ctypes.c_char * 128),
|
||||
("oe_timing_backend", ctypes.c_char * 32),
|
||||
("oe_timing_error", ctypes.c_char * 256),
|
||||
("safeoff_error", ctypes.c_char * 256),
|
||||
("last_error", ctypes.c_char * 256),
|
||||
]
|
||||
|
||||
|
||||
def _decode_c_string(value: bytes | ctypes.Array) -> str | None:
|
||||
raw = bytes(value).split(b"\0", 1)[0]
|
||||
return raw.decode("utf-8", errors="replace") or None
|
||||
|
||||
|
||||
def _load_native_library() -> ctypes.CDLL:
|
||||
if not NATIVE_LIBRARY.is_file():
|
||||
raise RuntimeError(
|
||||
f"WalnutPi native HUB75 library is missing: {NATIVE_LIBRARY}; "
|
||||
"run app/display/native/Makefile on the AArch64 target"
|
||||
)
|
||||
library = ctypes.CDLL(str(NATIVE_LIBRARY))
|
||||
library.hub75_hardware_mapping.argtypes = []
|
||||
library.hub75_hardware_mapping.restype = ctypes.c_char_p
|
||||
native_mapping = library.hub75_hardware_mapping()
|
||||
if native_mapping is None or native_mapping.decode("ascii", errors="replace") != HARDWARE_MAPPING:
|
||||
raise RuntimeError(
|
||||
"WalnutPi native HUB75 hardware mapping ABI mismatch; output remains blank"
|
||||
)
|
||||
library.hub75_create.argtypes = [
|
||||
ctypes.POINTER(_NativeConfig),
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
library.hub75_create.restype = ctypes.c_void_p
|
||||
library.hub75_start.argtypes = [ctypes.c_void_p]
|
||||
library.hub75_start.restype = ctypes.c_int
|
||||
library.hub75_submit_rgb.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t]
|
||||
library.hub75_submit_rgb.restype = ctypes.c_int
|
||||
library.hub75_set_brightness.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
library.hub75_set_brightness.restype = ctypes.c_int
|
||||
library.hub75_set_refresh_rate.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
library.hub75_set_refresh_rate.restype = ctypes.c_int
|
||||
library.hub75_clear.argtypes = [ctypes.c_void_p]
|
||||
library.hub75_clear.restype = ctypes.c_int
|
||||
library.hub75_get_stats.argtypes = [ctypes.c_void_p, ctypes.POINTER(_NativeStats)]
|
||||
library.hub75_get_stats.restype = ctypes.c_int
|
||||
library.hub75_destroy.argtypes = [ctypes.c_void_p]
|
||||
library.hub75_destroy.restype = None
|
||||
return library
|
||||
|
||||
|
||||
class MatrixDisplayDriver:
|
||||
name = "walnutpi-h618-hub75"
|
||||
hardware_mapping = HARDWARE_MAPPING
|
||||
|
||||
def __init__(self, brightness: int, limit_refresh_rate_hz: int = 100) -> None:
|
||||
self.driver_options = get_matrix_driver_options(
|
||||
brightness,
|
||||
limit_refresh_rate_hz,
|
||||
)
|
||||
self._library = _load_native_library()
|
||||
self._context: int | None = None
|
||||
error = ctypes.create_string_buffer(512)
|
||||
config = _NativeConfig(
|
||||
brightness=self.driver_options.brightness,
|
||||
refresh_rate_hz=self.driver_options.limit_refresh_rate_hz,
|
||||
cpu_affinity=self.driver_options.cpu_affinity,
|
||||
realtime_priority=self.driver_options.realtime_priority,
|
||||
use_dev_mem=1,
|
||||
fake_pwm5_mode=0,
|
||||
fake_pwm4_stuck=0,
|
||||
)
|
||||
context = self._library.hub75_create(
|
||||
ctypes.byref(config),
|
||||
error,
|
||||
ctypes.sizeof(error),
|
||||
)
|
||||
if not context:
|
||||
reason = _decode_c_string(error) or "unknown native initialization error"
|
||||
raise RuntimeError(f"WalnutPi native HUB75 initialization failed: {reason}")
|
||||
self._context = context
|
||||
if self._library.hub75_start(context) != 0:
|
||||
reason = self._stats_error() or "native refresh thread failed to start"
|
||||
self.close()
|
||||
raise RuntimeError(reason)
|
||||
self.available = True
|
||||
self.brightness = brightness
|
||||
logger.info(
|
||||
"Initialized %s with options: %s",
|
||||
self.name,
|
||||
self.driver_options.to_dict(),
|
||||
)
|
||||
|
||||
def _require_context(self) -> int:
|
||||
if self._context is None:
|
||||
raise RuntimeError("WalnutPi native HUB75 driver is closed")
|
||||
return self._context
|
||||
|
||||
def _stats(self) -> _NativeStats:
|
||||
stats = _NativeStats()
|
||||
if self._library.hub75_get_stats(self._require_context(), ctypes.byref(stats)) != 0:
|
||||
raise RuntimeError("Failed to read WalnutPi native HUB75 status")
|
||||
return stats
|
||||
|
||||
def _stats_error(self) -> str | None:
|
||||
try:
|
||||
return _decode_c_string(self._stats().last_error)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def set_image(self, image: Image.Image, *, synchronized: bool = False) -> None:
|
||||
if image.mode != "RGB" or image.size != (64, 64):
|
||||
raise ValueError("native HUB75 frame must be a 64x64 RGB image")
|
||||
frame = image.tobytes()
|
||||
buffer = (ctypes.c_uint8 * len(frame)).from_buffer_copy(frame)
|
||||
if self._library.hub75_submit_rgb(
|
||||
self._require_context(),
|
||||
ctypes.cast(buffer, ctypes.c_void_p),
|
||||
len(frame),
|
||||
) != 0:
|
||||
raise RuntimeError(self._stats_error() or "Native RGB frame submission failed")
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._library.hub75_clear(self._require_context()) != 0:
|
||||
raise RuntimeError(self._stats_error() or "Native display clear failed")
|
||||
|
||||
def set_brightness(self, value: int) -> None:
|
||||
if type(value) is not int or not 1 <= value <= 100:
|
||||
raise ValueError("brightness must be in 1..100")
|
||||
if self._library.hub75_set_brightness(self._require_context(), value) != 0:
|
||||
raise RuntimeError(self._stats_error() or "Runtime brightness update failed")
|
||||
self.brightness = value
|
||||
self.driver_options = replace(self.driver_options, brightness=value)
|
||||
|
||||
def set_refresh_rate_limit(self, value: int) -> None:
|
||||
if value not in REFRESH_RATE_LIMITS:
|
||||
raise ValueError("unsupported refresh-rate limit")
|
||||
if self._library.hub75_set_refresh_rate(self._require_context(), value) != 0:
|
||||
raise RuntimeError(self._stats_error() or "Runtime refresh-rate update failed")
|
||||
self.driver_options = replace(self.driver_options, limit_refresh_rate_hz=value)
|
||||
|
||||
def close(self) -> None:
|
||||
context = self._context
|
||||
if context is None:
|
||||
return
|
||||
self._context = None
|
||||
self._library.hub75_destroy(context)
|
||||
|
||||
def get_status(self) -> dict:
|
||||
stats = self._stats()
|
||||
return {
|
||||
"driver": self.name,
|
||||
"driver_available": True,
|
||||
"driver_error": _decode_c_string(stats.last_error),
|
||||
"hardware_mapping": self.hardware_mapping,
|
||||
"driver_options": self.driver_options.to_dict(),
|
||||
"driver_status": {
|
||||
"actual_refresh_rate_hz": round(stats.actual_refresh_rate_hz, 3),
|
||||
"panel_scan_rate_hz": round(stats.panel_scan_rate_hz, 3),
|
||||
"completed_frames": stats.completed_frames,
|
||||
"completed_scans": stats.completed_scans,
|
||||
"scans_per_frame": stats.scans_per_frame,
|
||||
"deadline_misses": stats.deadline_misses,
|
||||
"buffer_swaps": stats.buffer_swaps,
|
||||
"submitted_frames": stats.submitted_frames,
|
||||
"oe_timing_backend": _decode_c_string(stats.oe_timing_backend),
|
||||
"oe_pulse_faults": stats.oe_pulse_faults,
|
||||
"oe_forced_blanks": stats.oe_forced_blanks,
|
||||
"max_programmed_oe_ns": stats.max_programmed_oe_ns,
|
||||
"oe_timing_error": _decode_c_string(stats.oe_timing_error),
|
||||
"safeoff_error": _decode_c_string(stats.safeoff_error),
|
||||
"cpu_affinity": stats.cpu_affinity,
|
||||
"cpu_affinity_active": bool(stats.cpu_affinity_active),
|
||||
"cpu_affinity_error": _decode_c_string(stats.affinity_error),
|
||||
"realtime_priority": stats.realtime_priority,
|
||||
"realtime_priority_active": bool(stats.realtime_priority_active),
|
||||
"realtime_priority_error": _decode_c_string(stats.realtime_error),
|
||||
"memory_locked": bool(stats.memory_locked),
|
||||
"memory_lock_error": _decode_c_string(stats.memory_lock_error),
|
||||
"running": bool(stats.running),
|
||||
"governor": _decode_c_string(stats.governor),
|
||||
"last_error": _decode_c_string(stats.last_error),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .matrix_driver import MatrixDriverOptions, get_default_driver_options
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MockDisplayDriver:
|
||||
name = "mock"
|
||||
hardware_mapping = "walnutpi-pi-bank-pwm-oe-v2"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_dir: Path,
|
||||
brightness: int = 40,
|
||||
limit_refresh_rate_hz: int = 100,
|
||||
reason: str | None = None,
|
||||
driver_options: MatrixDriverOptions | None = None,
|
||||
) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.reason = reason
|
||||
self.available = reason is None
|
||||
self.last_frame_path = self.data_dir / "last_frame.png"
|
||||
self.brightness = brightness
|
||||
self.driver_options = driver_options or get_default_driver_options(
|
||||
brightness,
|
||||
limit_refresh_rate_hz,
|
||||
)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
if reason:
|
||||
logger.warning("Using mock display driver: %s", reason)
|
||||
else:
|
||||
logger.info("Using mock display driver")
|
||||
|
||||
def set_image(self, image: Image.Image, *, synchronized: bool = False) -> None:
|
||||
image.save(self.last_frame_path)
|
||||
logger.debug("Saved mock frame to %s", self.last_frame_path)
|
||||
|
||||
def clear(self) -> None:
|
||||
if self.last_frame_path.exists():
|
||||
Image.new("RGB", (64, 64), (0, 0, 0)).save(self.last_frame_path)
|
||||
|
||||
def set_brightness(self, value: int) -> None:
|
||||
self.brightness = value
|
||||
self.driver_options = replace(self.driver_options, brightness=value)
|
||||
|
||||
def set_refresh_rate_limit(self, value: int) -> None:
|
||||
self.driver_options = replace(
|
||||
self.driver_options,
|
||||
limit_refresh_rate_hz=value,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.clear()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return {
|
||||
"driver": self.name,
|
||||
"driver_available": self.available,
|
||||
"driver_error": self.reason,
|
||||
"hardware_mapping": self.driver_options.hardware_mapping,
|
||||
"driver_options": self.driver_options.to_dict(),
|
||||
"driver_status": {
|
||||
"actual_refresh_rate_hz": None,
|
||||
"panel_scan_rate_hz": None,
|
||||
"completed_frames": None,
|
||||
"completed_scans": None,
|
||||
"scans_per_frame": None,
|
||||
"deadline_misses": None,
|
||||
"buffer_swaps": None,
|
||||
"submitted_frames": None,
|
||||
"oe_timing_backend": None,
|
||||
"oe_pulse_faults": None,
|
||||
"oe_forced_blanks": None,
|
||||
"max_programmed_oe_ns": None,
|
||||
"oe_timing_error": None,
|
||||
"safeoff_error": None,
|
||||
"cpu_affinity": None,
|
||||
"cpu_affinity_active": False,
|
||||
"cpu_affinity_error": None,
|
||||
"realtime_priority": None,
|
||||
"realtime_priority_active": False,
|
||||
"realtime_priority_error": None,
|
||||
"memory_locked": False,
|
||||
"memory_lock_error": None,
|
||||
"running": False,
|
||||
"governor": None,
|
||||
"last_error": self.reason,
|
||||
},
|
||||
"last_frame": str(self.last_frame_path),
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
CC ?= gcc
|
||||
CFLAGS ?= -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread
|
||||
LDFLAGS ?= -pthread
|
||||
|
||||
LIBRARY := libh618_hub75.so
|
||||
BENCHMARK := hub75_benchmark
|
||||
UNIT_TEST := hub75_native_test
|
||||
SAFEOFF := hub75_safeoff
|
||||
|
||||
.PHONY: all clean test
|
||||
|
||||
all: $(LIBRARY) $(BENCHMARK) $(UNIT_TEST) $(SAFEOFF)
|
||||
|
||||
$(LIBRARY): h618_hub75.c h618_hub75.h
|
||||
$(CC) $(CFLAGS) -shared h618_hub75.c -o $@ $(LDFLAGS)
|
||||
|
||||
$(BENCHMARK): benchmark.c h618_hub75.c h618_hub75.h
|
||||
$(CC) $(CFLAGS) benchmark.c h618_hub75.c -o $@ $(LDFLAGS)
|
||||
|
||||
$(UNIT_TEST): native_test.c h618_hub75.c h618_hub75.h
|
||||
$(CC) $(CFLAGS) native_test.c h618_hub75.c -o $@ $(LDFLAGS)
|
||||
|
||||
$(SAFEOFF): safeoff.c h618_hub75.c h618_hub75.h
|
||||
$(CC) $(CFLAGS) safeoff.c h618_hub75.c -o $@ $(LDFLAGS)
|
||||
|
||||
test: $(UNIT_TEST) $(SAFEOFF)
|
||||
./$(UNIT_TEST)
|
||||
./$(SAFEOFF) --fake
|
||||
|
||||
clean:
|
||||
rm -f -- $(LIBRARY) $(BENCHMARK) $(UNIT_TEST) $(SAFEOFF)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Third-party notices
|
||||
|
||||
The H618 PIO register layout and WalnutPi 40-pin physical mapping used by
|
||||
`h618_hub75.c` were derived from:
|
||||
|
||||
- Project: `walnutpi/gpioc`
|
||||
- Source: https://github.com/walnutpi/gpioc
|
||||
- Commit: `220a46267469cc9cb1cbc35cd72f35eb1699a6e8`
|
||||
- License: MIT
|
||||
- Copyright: Copyright (c) 2023 酥饼
|
||||
|
||||
## MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,188 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "h618_hub75.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static double monotonic_seconds(void) {
|
||||
struct timespec value;
|
||||
clock_gettime(CLOCK_MONOTONIC, &value);
|
||||
return (double)value.tv_sec + (double)value.tv_nsec / 1000000000.0;
|
||||
}
|
||||
|
||||
static int integer_argument(const char *value, const char *name) {
|
||||
char *end = NULL;
|
||||
long parsed = strtol(value, &end, 10);
|
||||
if (value[0] == '\0' || end == NULL || *end != '\0' || parsed < 0 || parsed > 1000000) {
|
||||
fprintf(stderr, "invalid %s: %s\n", name, value);
|
||||
exit(2);
|
||||
}
|
||||
return (int)parsed;
|
||||
}
|
||||
|
||||
static int hex_nibble(char value) {
|
||||
if (value >= '0' && value <= '9') return value - '0';
|
||||
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
|
||||
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void color_argument(const char *value, uint8_t output[3]) {
|
||||
if (strlen(value) != 6) {
|
||||
fprintf(stderr, "invalid color: %s (expected RRGGBB)\n", value);
|
||||
exit(2);
|
||||
}
|
||||
for (unsigned channel = 0; channel < 3; ++channel) {
|
||||
int high = hex_nibble(value[channel * 2]);
|
||||
int low = hex_nibble(value[channel * 2 + 1]);
|
||||
if (high < 0 || low < 0) {
|
||||
fprintf(stderr, "invalid color: %s (expected RRGGBB)\n", value);
|
||||
exit(2);
|
||||
}
|
||||
output[channel] = (uint8_t)((high << 4) | low);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int duration = 10;
|
||||
int refresh_rate = 100;
|
||||
int brightness = 40;
|
||||
int use_dev_mem = 0;
|
||||
int use_static_color = 0;
|
||||
uint8_t static_color[3] = {0};
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
if (strcmp(argv[index], "--duration") == 0 && index + 1 < argc) {
|
||||
duration = integer_argument(argv[++index], "duration");
|
||||
} else if (strcmp(argv[index], "--refresh-rate") == 0 && index + 1 < argc) {
|
||||
refresh_rate = integer_argument(argv[++index], "refresh rate");
|
||||
} else if (strcmp(argv[index], "--brightness") == 0 && index + 1 < argc) {
|
||||
brightness = integer_argument(argv[++index], "brightness");
|
||||
} else if (strcmp(argv[index], "--dev-mem") == 0) {
|
||||
use_dev_mem = 1;
|
||||
} else if (strcmp(argv[index], "--color") == 0 && index + 1 < argc) {
|
||||
color_argument(argv[++index], static_color);
|
||||
use_static_color = 1;
|
||||
} else {
|
||||
fprintf(stderr, "usage: %s [--duration N] [--refresh-rate N] [--brightness N] [--color RRGGBB] [--dev-mem]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
if (duration < 1) {
|
||||
fputs("duration must be at least one second\n", stderr);
|
||||
return 2;
|
||||
}
|
||||
|
||||
hub75_config config = {
|
||||
.brightness = brightness,
|
||||
.refresh_rate_hz = refresh_rate,
|
||||
.cpu_affinity = 3,
|
||||
.realtime_priority = 50,
|
||||
.use_dev_mem = use_dev_mem,
|
||||
};
|
||||
char error[512] = "";
|
||||
hub75_context *context = hub75_create(&config, error, sizeof(error));
|
||||
if (context == NULL) {
|
||||
fprintf(stderr, "create failed: %s\n", error);
|
||||
return 1;
|
||||
}
|
||||
if (hub75_start(context) != 0) {
|
||||
fputs("start failed\n", stderr);
|
||||
hub75_destroy(context);
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint8_t frame[HUB75_FRAME_BYTES];
|
||||
if (use_static_color) {
|
||||
for (unsigned pixel = 0; pixel < HUB75_WIDTH * HUB75_HEIGHT; ++pixel) {
|
||||
memcpy(frame + pixel * 3u, static_color, sizeof(static_color));
|
||||
}
|
||||
if (hub75_submit_rgb(context, frame, sizeof(frame)) != 0) {
|
||||
fputs("initial static submit failed\n", stderr);
|
||||
hub75_destroy(context);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
double started = monotonic_seconds();
|
||||
unsigned submitted = 0;
|
||||
while (monotonic_seconds() - started < duration) {
|
||||
if (!use_static_color) {
|
||||
for (unsigned pixel = 0; pixel < HUB75_WIDTH * HUB75_HEIGHT; ++pixel) {
|
||||
frame[pixel * 3] = (uint8_t)(pixel + submitted);
|
||||
frame[pixel * 3 + 1] = (uint8_t)(pixel * 3u + submitted);
|
||||
frame[pixel * 3 + 2] = (uint8_t)(pixel * 7u + submitted);
|
||||
}
|
||||
if (hub75_submit_rgb(context, frame, sizeof(frame)) != 0) {
|
||||
fputs("submit failed\n", stderr);
|
||||
hub75_destroy(context);
|
||||
return 1;
|
||||
}
|
||||
++submitted;
|
||||
}
|
||||
struct timespec delay = {.tv_sec = 0, .tv_nsec = 20000000};
|
||||
nanosleep(&delay, NULL);
|
||||
}
|
||||
|
||||
hub75_stats stats;
|
||||
if (hub75_get_stats(context, &stats) != 0) {
|
||||
fputs("stats failed\n", stderr);
|
||||
hub75_destroy(context);
|
||||
return 1;
|
||||
}
|
||||
hub75_destroy(context);
|
||||
int expected_scans = hub75_scans_per_frame_for_brightness(brightness);
|
||||
double miss_rate = stats.completed_frames == 0 ? 100.0 :
|
||||
(double)stats.deadline_misses * 100.0 / (double)stats.completed_frames;
|
||||
printf(
|
||||
"{\"mode\":\"%s\",\"configured_hz\":%d,\"actual_hz\":%.3f,"
|
||||
"\"panel_scan_hz\":%.3f,\"completed_frames\":%llu,"
|
||||
"\"completed_scans\":%llu,\"scans_per_frame\":%d,"
|
||||
"\"deadline_misses\":%llu,"
|
||||
"\"deadline_miss_percent\":%.6f,\"buffer_swaps\":%llu,"
|
||||
"\"submitted_frames\":%llu,\"oe_timing_backend\":\"%s\","
|
||||
"\"oe_pulse_faults\":%llu,\"oe_forced_blanks\":%llu,"
|
||||
"\"max_programmed_oe_ns\":%llu,\"oe_timing_error\":\"%s\","
|
||||
"\"cpu_affinity_active\":%s,"
|
||||
"\"realtime_priority_active\":%s,\"memory_locked\":%s,"
|
||||
"\"governor\":\"%s\",\"affinity_error\":\"%s\","
|
||||
"\"realtime_error\":\"%s\",\"memory_lock_error\":\"%s\","
|
||||
"\"last_error\":\"%s\"}\n",
|
||||
use_dev_mem ? "dev-mem" : "fake-registers",
|
||||
refresh_rate,
|
||||
stats.actual_refresh_rate_hz,
|
||||
stats.panel_scan_rate_hz,
|
||||
(unsigned long long)stats.completed_frames,
|
||||
(unsigned long long)stats.completed_scans,
|
||||
stats.scans_per_frame,
|
||||
(unsigned long long)stats.deadline_misses,
|
||||
miss_rate,
|
||||
(unsigned long long)stats.buffer_swaps,
|
||||
(unsigned long long)stats.submitted_frames,
|
||||
stats.oe_timing_backend,
|
||||
(unsigned long long)stats.oe_pulse_faults,
|
||||
(unsigned long long)stats.oe_forced_blanks,
|
||||
(unsigned long long)stats.max_programmed_oe_ns,
|
||||
stats.oe_timing_error,
|
||||
stats.cpu_affinity_active ? "true" : "false",
|
||||
stats.realtime_priority_active ? "true" : "false",
|
||||
stats.memory_locked ? "true" : "false",
|
||||
stats.governor,
|
||||
stats.affinity_error,
|
||||
stats.realtime_error,
|
||||
stats.memory_lock_error,
|
||||
stats.last_error
|
||||
);
|
||||
if (
|
||||
stats.completed_frames == 0 ||
|
||||
stats.actual_refresh_rate_hz <= 0.0 ||
|
||||
stats.panel_scan_rate_hz <= 0.0 ||
|
||||
stats.scans_per_frame != expected_scans ||
|
||||
stats.completed_scans != stats.completed_frames * (uint64_t)expected_scans ||
|
||||
stats.oe_pulse_faults != 0 ||
|
||||
strcmp(stats.oe_timing_backend, HUB75_OE_TIMING_BACKEND) != 0
|
||||
) return 1;
|
||||
return 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Native HUB75 refresh driver for WalnutPi ZeroW / Allwinner H618.
|
||||
*
|
||||
* GPIO register layout and physical-pin mapping are derived from WalnutPi's
|
||||
* MIT-licensed gpioc project, commit 220a46267469cc9cb1cbc35cd72f35eb1699a6e8:
|
||||
* https://github.com/walnutpi/gpioc
|
||||
*
|
||||
* This project keeps the upstream MIT license in THIRD_PARTY_NOTICES.md.
|
||||
*/
|
||||
#ifndef WALNUTPI_H618_HUB75_H
|
||||
#define WALNUTPI_H618_HUB75_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define HUB75_WIDTH 64
|
||||
#define HUB75_HEIGHT 64
|
||||
#define HUB75_SCAN_ROWS 32
|
||||
#define HUB75_PWM_BITS 7
|
||||
#define HUB75_INTERNAL_PWM_BITS 11
|
||||
#define HUB75_PWM_PLANE_OFFSET (HUB75_INTERNAL_PWM_BITS - HUB75_PWM_BITS)
|
||||
#define HUB75_SCANS_PER_FRAME 1
|
||||
#define HUB75_FRAME_BYTES (HUB75_WIDTH * HUB75_HEIGHT * 3)
|
||||
#define HUB75_BITPLANE_WORDS (HUB75_SCAN_ROWS * HUB75_PWM_BITS * HUB75_WIDTH)
|
||||
#define HUB75_HARDWARE_MAPPING "walnutpi-pi-bank-pwm-oe-v2"
|
||||
#define HUB75_OE_TIMING_BACKEND "h618-pwm4"
|
||||
#define HUB75_OE_PWM_TICK_NS 125u
|
||||
|
||||
typedef struct hub75_context hub75_context;
|
||||
|
||||
typedef struct hub75_config {
|
||||
int brightness;
|
||||
int refresh_rate_hz;
|
||||
int cpu_affinity;
|
||||
int realtime_priority;
|
||||
int use_dev_mem;
|
||||
int fake_pwm5_mode;
|
||||
int fake_pwm4_stuck;
|
||||
} hub75_config;
|
||||
|
||||
typedef struct hub75_stats {
|
||||
double actual_refresh_rate_hz;
|
||||
double panel_scan_rate_hz;
|
||||
uint64_t completed_frames;
|
||||
uint64_t completed_scans;
|
||||
uint64_t deadline_misses;
|
||||
uint64_t buffer_swaps;
|
||||
uint64_t submitted_frames;
|
||||
uint64_t oe_pulse_faults;
|
||||
uint64_t oe_forced_blanks;
|
||||
uint64_t max_programmed_oe_ns;
|
||||
int cpu_affinity;
|
||||
int cpu_affinity_active;
|
||||
int realtime_priority;
|
||||
int realtime_priority_active;
|
||||
int memory_locked;
|
||||
int running;
|
||||
int brightness;
|
||||
int refresh_rate_hz;
|
||||
int scans_per_frame;
|
||||
char governor[32];
|
||||
char affinity_error[128];
|
||||
char realtime_error[128];
|
||||
char memory_lock_error[128];
|
||||
char oe_timing_backend[32];
|
||||
char oe_timing_error[256];
|
||||
char safeoff_error[256];
|
||||
char last_error[256];
|
||||
} hub75_stats;
|
||||
|
||||
hub75_context *hub75_create(const hub75_config *config, char *error, size_t error_size);
|
||||
int hub75_start(hub75_context *context);
|
||||
int hub75_submit_rgb(hub75_context *context, const uint8_t *rgb, size_t length);
|
||||
int hub75_set_brightness(hub75_context *context, int brightness);
|
||||
int hub75_set_refresh_rate(hub75_context *context, int refresh_rate_hz);
|
||||
int hub75_clear(hub75_context *context);
|
||||
int hub75_get_stats(hub75_context *context, hub75_stats *stats);
|
||||
void hub75_destroy(hub75_context *context);
|
||||
const char *hub75_hardware_mapping(void);
|
||||
int hub75_force_safeoff(int use_dev_mem, char *error, size_t error_size);
|
||||
|
||||
/* Pure conversion hook used by tests; output words contain PI0..PI5 colors. */
|
||||
int hub75_build_bitplanes(
|
||||
const uint8_t *rgb,
|
||||
size_t length,
|
||||
uint16_t *output,
|
||||
size_t output_words
|
||||
);
|
||||
|
||||
/* Raspberry-Pi-compatible CIE1931 brightness mapping into 11-bit color. */
|
||||
int hub75_build_bitplanes_for_brightness(
|
||||
const uint8_t *rgb,
|
||||
size_t length,
|
||||
int brightness,
|
||||
uint16_t *output,
|
||||
size_t output_words
|
||||
);
|
||||
|
||||
/* Pure 8-bit RGB channel to 11-bit CIE1931 mapping hook. */
|
||||
uint16_t hub75_map_channel_for_brightness(uint8_t value, int brightness);
|
||||
|
||||
/* Pure scan-count hook used by native tests and the benchmark. */
|
||||
int hub75_scans_per_frame_for_brightness(int brightness);
|
||||
|
||||
/* Fixed binary OE weights matching internal bitplanes 4..10. */
|
||||
uint32_t hub75_oe_ticks_for_plane(
|
||||
uint64_t scan_slot_ns,
|
||||
int brightness,
|
||||
unsigned plane,
|
||||
uint64_t *remainder
|
||||
);
|
||||
|
||||
typedef struct hub75_fake_registers {
|
||||
uint32_t mux0;
|
||||
uint32_t mux1;
|
||||
uint32_t data;
|
||||
uint32_t pwm_pccr45;
|
||||
uint32_t pwm_per;
|
||||
uint32_t pwm_pcr4;
|
||||
uint32_t pwm_ppr4;
|
||||
uint32_t pwm_pcr5;
|
||||
uint32_t pwm_ppr5;
|
||||
} hub75_fake_registers;
|
||||
|
||||
int hub75_get_fake_registers(
|
||||
hub75_context *context,
|
||||
hub75_fake_registers *registers
|
||||
);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,195 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "h618_hub75.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
enum {
|
||||
PI_R1 = 1u << 0,
|
||||
PI_G1 = 1u << 1,
|
||||
PI_B1 = 1u << 2,
|
||||
PI_R2 = 1u << 3,
|
||||
PI_G2 = 1u << 4,
|
||||
PI_B2 = 1u << 5,
|
||||
};
|
||||
|
||||
static size_t word_index(unsigned row, unsigned plane, unsigned column) {
|
||||
return ((size_t)row * HUB75_PWM_BITS + plane) * HUB75_WIDTH + column;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
assert(strcmp(hub75_hardware_mapping(), HUB75_HARDWARE_MAPPING) == 0);
|
||||
assert(HUB75_INTERNAL_PWM_BITS == 11);
|
||||
assert(HUB75_PWM_PLANE_OFFSET == 4);
|
||||
assert(HUB75_SCANS_PER_FRAME == 1);
|
||||
assert(hub75_scans_per_frame_for_brightness(1) == 1);
|
||||
assert(hub75_scans_per_frame_for_brightness(20) == 1);
|
||||
assert(hub75_scans_per_frame_for_brightness(100) == 1);
|
||||
assert(hub75_scans_per_frame_for_brightness(0) == -1);
|
||||
assert(hub75_scans_per_frame_for_brightness(101) == -1);
|
||||
|
||||
uint16_t previous_mapped = 0;
|
||||
for (int brightness = 1; brightness <= 100; ++brightness) {
|
||||
uint16_t mapped = hub75_map_channel_for_brightness(255, brightness);
|
||||
assert(mapped >= previous_mapped);
|
||||
previous_mapped = mapped;
|
||||
for (unsigned plane = 0; plane < HUB75_PWM_BITS; ++plane) {
|
||||
uint64_t remainder = 123u;
|
||||
assert(hub75_oe_ticks_for_plane(
|
||||
10250000, brightness, plane, &remainder
|
||||
) == (1u << (plane + HUB75_PWM_PLANE_OFFSET)));
|
||||
assert(remainder == 0);
|
||||
}
|
||||
}
|
||||
assert(previous_mapped == 2047u);
|
||||
assert(hub75_map_channel_for_brightness(255, 0) == UINT16_MAX);
|
||||
uint64_t invalid_remainder = 0;
|
||||
assert(hub75_oe_ticks_for_plane(5000000, 0, 0, &invalid_remainder) == UINT32_MAX);
|
||||
assert(hub75_oe_ticks_for_plane(5000000, 1, HUB75_PWM_BITS, &invalid_remainder) == UINT32_MAX);
|
||||
uint64_t full_scale_remainder = 0;
|
||||
assert(hub75_oe_ticks_for_plane(
|
||||
10250000, 100, HUB75_PWM_BITS - 1, &full_scale_remainder
|
||||
) == 1024u);
|
||||
uint8_t *frame = calloc(1, HUB75_FRAME_BYTES);
|
||||
uint16_t *planes = calloc(HUB75_BITPLANE_WORDS, sizeof(*planes));
|
||||
assert(frame != NULL);
|
||||
assert(planes != NULL);
|
||||
|
||||
/* Compare extracted planes with the Raspberry-compatible 11-bit map. */
|
||||
frame[0] = 0xff;
|
||||
frame[1] = 0x80;
|
||||
frame[2] = 0x40;
|
||||
size_t bottom = (size_t)HUB75_SCAN_ROWS * HUB75_WIDTH * 3u;
|
||||
frame[bottom] = 0x20;
|
||||
frame[bottom + 1] = 0x10;
|
||||
frame[bottom + 2] = 0x08;
|
||||
assert(hub75_build_bitplanes(
|
||||
frame,
|
||||
HUB75_FRAME_BYTES,
|
||||
planes,
|
||||
HUB75_BITPLANE_WORDS
|
||||
) == 0);
|
||||
const uint8_t values[] = {0xff, 0x80, 0x40, 0x20, 0x10, 0x08};
|
||||
const uint16_t bits[] = {PI_R1, PI_G1, PI_B1, PI_R2, PI_G2, PI_B2};
|
||||
for (unsigned plane = 0; plane < HUB75_PWM_BITS; ++plane) {
|
||||
uint16_t expected = 0;
|
||||
for (unsigned component = 0; component < 6; ++component) {
|
||||
uint16_t mapped = hub75_map_channel_for_brightness(values[component], 100);
|
||||
if ((mapped >> (plane + HUB75_PWM_PLANE_OFFSET)) & 1u) {
|
||||
expected |= bits[component];
|
||||
}
|
||||
}
|
||||
assert(planes[word_index(0, plane, 0)] == expected);
|
||||
}
|
||||
|
||||
memset(frame, 0xff, HUB75_FRAME_BYTES);
|
||||
assert(hub75_build_bitplanes(
|
||||
frame,
|
||||
HUB75_FRAME_BYTES,
|
||||
planes,
|
||||
HUB75_BITPLANE_WORDS
|
||||
) == 0);
|
||||
for (size_t index = 0; index < HUB75_BITPLANE_WORDS; ++index) {
|
||||
assert(planes[index] == (PI_R1 | PI_G1 | PI_B1 | PI_R2 | PI_G2 | PI_B2));
|
||||
}
|
||||
|
||||
/* Brightness changes bitplane data rather than OE pulse duration. */
|
||||
memset(frame, 0, HUB75_FRAME_BYTES);
|
||||
frame[0] = 0xff;
|
||||
frame[1] = 0x4f;
|
||||
frame[2] = 0xa3;
|
||||
uint16_t low_planes[HUB75_BITPLANE_WORDS];
|
||||
assert(hub75_build_bitplanes_for_brightness(
|
||||
frame, HUB75_FRAME_BYTES, 100, planes, HUB75_BITPLANE_WORDS
|
||||
) == 0);
|
||||
assert(hub75_build_bitplanes_for_brightness(
|
||||
frame, HUB75_FRAME_BYTES, 20, low_planes, HUB75_BITPLANE_WORDS
|
||||
) == 0);
|
||||
assert(memcmp(low_planes, planes, sizeof(low_planes)) != 0);
|
||||
|
||||
assert(hub75_build_bitplanes(frame, 1, planes, HUB75_BITPLANE_WORDS) == -1);
|
||||
assert(hub75_build_bitplanes(frame, HUB75_FRAME_BYTES, planes, 1) == -1);
|
||||
|
||||
hub75_config config = {
|
||||
.brightness = 40,
|
||||
.refresh_rate_hz = 100,
|
||||
.cpu_affinity = 0,
|
||||
.realtime_priority = 1,
|
||||
.use_dev_mem = 0,
|
||||
};
|
||||
char error[512] = {0};
|
||||
hub75_context *context = hub75_create(&config, error, sizeof(error));
|
||||
assert(context != NULL);
|
||||
hub75_fake_registers registers;
|
||||
assert(hub75_get_fake_registers(context, ®isters) == 0);
|
||||
assert(((registers.mux1 >> ((14u - 8u) * 4u)) & 0xfu) == 0x5u);
|
||||
assert(((registers.mux1 >> ((15u - 8u) * 4u)) & 0xfu) == 0x1u);
|
||||
assert((registers.pwm_pccr45 & (1u << 4)) != 0);
|
||||
assert((registers.pwm_pccr45 & ((0xfu) | (1u << 5) | (0x3u << 7))) == 0);
|
||||
assert((registers.pwm_per & (1u << 4)) != 0);
|
||||
assert((registers.pwm_pcr4 & 0x1ffu) == 2u);
|
||||
assert((registers.pwm_pcr4 & (1u << 9)) != 0);
|
||||
assert(registers.pwm_ppr4 == (65535u << 16));
|
||||
hub75_stats stats;
|
||||
assert(hub75_get_stats(context, &stats) == 0);
|
||||
assert(strcmp(stats.oe_timing_backend, HUB75_OE_TIMING_BACKEND) == 0);
|
||||
assert(stats.oe_pulse_faults == 0);
|
||||
memset(frame, 0xff, HUB75_FRAME_BYTES);
|
||||
assert(hub75_submit_rgb(context, frame, HUB75_FRAME_BYTES) == 0);
|
||||
assert(hub75_set_brightness(context, 100) == 0);
|
||||
assert(hub75_start(context) == 0);
|
||||
struct timespec pulse_test_delay = {.tv_sec = 0, .tv_nsec = 100000000};
|
||||
nanosleep(&pulse_test_delay, NULL);
|
||||
assert(hub75_get_stats(context, &stats) == 0);
|
||||
assert(stats.max_programmed_oe_ns == 128000u);
|
||||
assert(stats.oe_pulse_faults == 0);
|
||||
assert(hub75_get_fake_registers(context, ®isters) == 0);
|
||||
assert((registers.pwm_pcr4 & (1u << 9)) != 0);
|
||||
assert((registers.pwm_pcr4 & (1u << 10)) == 0);
|
||||
assert((registers.pwm_per & (1u << 4)) != 0);
|
||||
assert((registers.pwm_ppr4 & 0xffffu) != 0);
|
||||
assert((registers.pwm_ppr4 >> 16) + 1u ==
|
||||
(registers.pwm_ppr4 & 0xffffu));
|
||||
hub75_destroy(context);
|
||||
|
||||
config.fake_pwm4_stuck = 1;
|
||||
context = hub75_create(&config, error, sizeof(error));
|
||||
assert(context != NULL);
|
||||
assert(hub75_start(context) == 0);
|
||||
struct timespec stuck_test_delay = {.tv_sec = 0, .tv_nsec = 100000000};
|
||||
nanosleep(&stuck_test_delay, NULL);
|
||||
assert(hub75_get_stats(context, &stats) == 0);
|
||||
assert(stats.oe_pulse_faults == 1);
|
||||
assert(stats.oe_forced_blanks == 1);
|
||||
assert(strstr(stats.oe_timing_error, "start bit did not clear") != NULL);
|
||||
assert(hub75_get_fake_registers(context, ®isters) == 0);
|
||||
assert((registers.pwm_per & (1u << 4)) == 0);
|
||||
assert(((registers.mux1 >> ((14u - 8u) * 4u)) & 0xfu) == 0x1u);
|
||||
hub75_destroy(context);
|
||||
config.fake_pwm4_stuck = 0;
|
||||
|
||||
config.fake_pwm5_mode = 1;
|
||||
context = hub75_create(&config, error, sizeof(error));
|
||||
assert(context != NULL);
|
||||
assert(hub75_get_fake_registers(context, ®isters) == 0);
|
||||
assert((registers.pwm_per & (1u << 5)) != 0);
|
||||
assert((registers.pwm_pccr45 & (1u << 6)) != 0);
|
||||
assert(registers.pwm_pcr5 == 0x100u);
|
||||
assert(registers.pwm_ppr5 == 0x000b0006u);
|
||||
hub75_destroy(context);
|
||||
|
||||
config.fake_pwm5_mode = 2;
|
||||
context = hub75_create(&config, error, sizeof(error));
|
||||
assert(context == NULL);
|
||||
assert(strstr(error, "incompatible shared PWM4/5 clock") != NULL);
|
||||
assert(hub75_force_safeoff(0, error, sizeof(error)) == 0);
|
||||
|
||||
free(planes);
|
||||
free(frame);
|
||||
puts("native_bitplane_tests=PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only H618 PI-bank snapshot for no-load safety verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mmap
|
||||
import os
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
PIO_BASE = 0x0300B000
|
||||
PIO_LENGTH = 0x400
|
||||
PI_OFFSET = 0x120
|
||||
DATA_OFFSET = 0x10
|
||||
OWNED_BITS = tuple([*range(0, 7), *range(9, 16)])
|
||||
RESERVED_BITS = (7, 8, 16)
|
||||
|
||||
|
||||
def read_u32(mapped: mmap.mmap, offset: int) -> int:
|
||||
return struct.unpack_from("<I", mapped, offset)[0]
|
||||
|
||||
|
||||
def mux_mode(mapped: mmap.mmap, pin: int) -> int:
|
||||
register = PI_OFFSET + (pin // 8) * 4
|
||||
return (read_u32(mapped, register) >> ((pin % 8) * 4)) & 0xF
|
||||
|
||||
|
||||
def device_tree_contract() -> dict:
|
||||
node = Path("/proc/device-tree/soc/pinctrl@300b000")
|
||||
compatible = (node / "compatible").read_bytes().rstrip(b"\0").decode("ascii")
|
||||
values = struct.unpack(">II", (node / "reg").read_bytes())
|
||||
if compatible != "allwinner,sun50i-h616-pinctrl" or values != (PIO_BASE, PIO_LENGTH):
|
||||
raise RuntimeError(f"unexpected pinctrl contract: {compatible=} {values=}")
|
||||
return {"compatible": compatible, "base": f"0x{values[0]:08X}", "length": values[1]}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
contract = device_tree_contract()
|
||||
descriptor = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC)
|
||||
try:
|
||||
with mmap.mmap(descriptor, PIO_LENGTH, access=mmap.ACCESS_READ, offset=PIO_BASE) as mapped:
|
||||
data = read_u32(mapped, PI_OFFSET + DATA_OFFSET)
|
||||
modes = {f"PI{pin}": mux_mode(mapped, pin) for pin in range(17)}
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
owned_mask = sum(1 << bit for bit in OWNED_BITS)
|
||||
reserved_mask = sum(1 << bit for bit in RESERVED_BITS)
|
||||
print(json.dumps({
|
||||
"device_tree": contract,
|
||||
"data_register": f"0x{data:08X}",
|
||||
"owned_data_bits": f"0x{data & owned_mask:08X}",
|
||||
"reserved_data_bits": f"0x{data & reserved_mask:08X}",
|
||||
"modes": modes,
|
||||
}, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "h618_hub75.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int use_dev_mem = 1;
|
||||
if (argc == 2 && strcmp(argv[1], "--fake") == 0) {
|
||||
use_dev_mem = 0;
|
||||
} else if (argc != 1) {
|
||||
fprintf(stderr, "usage: %s [--fake]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
char error[512] = {0};
|
||||
if (hub75_force_safeoff(use_dev_mem, error, sizeof(error)) != 0) {
|
||||
fprintf(stderr, "HUB75 safeoff failed: %s\n", error[0] ? error : "unknown error");
|
||||
return 1;
|
||||
}
|
||||
puts("HUB75 safeoff complete");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Pure-Python reference model for the fixed WalnutPi HUB75 mapping.
|
||||
|
||||
The production refresh loop is C. This module is intentionally small and
|
||||
deterministic so mapping and bitplane contracts can be tested on any host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
WIDTH = 64
|
||||
HEIGHT = 64
|
||||
SCAN_ROWS = 32
|
||||
PWM_BITS = 7
|
||||
PIO_BASE = 0x0300B000
|
||||
PI_BANK_OFFSET = 0x120
|
||||
PI_DATA_OFFSET = 0x10
|
||||
PWM_BASE = 0x0300A000
|
||||
HARDWARE_MAPPING = "walnutpi-pi-bank-pwm-oe-v2"
|
||||
OE_PWM_TICK_NS = 125
|
||||
INTERNAL_PWM_BITS = 11
|
||||
PWM_PLANE_OFFSET = INTERNAL_PWM_BITS - PWM_BITS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hub75Pin:
|
||||
signal: str
|
||||
physical: int
|
||||
gpio: str
|
||||
bit: int
|
||||
|
||||
|
||||
HUB75_PINS = (
|
||||
Hub75Pin("R1", 29, "PI0", 0),
|
||||
Hub75Pin("G1", 31, "PI1", 1),
|
||||
Hub75Pin("B1", 33, "PI2", 2),
|
||||
Hub75Pin("R2", 35, "PI3", 3),
|
||||
Hub75Pin("G2", 37, "PI4", 4),
|
||||
Hub75Pin("B2", 8, "PI5", 5),
|
||||
Hub75Pin("A", 10, "PI6", 6),
|
||||
Hub75Pin("B", 28, "PI9", 9),
|
||||
Hub75Pin("C", 27, "PI10", 10),
|
||||
Hub75Pin("D", 15, "PI11", 11),
|
||||
Hub75Pin("E", 16, "PI12", 12),
|
||||
Hub75Pin("CLK", 38, "PI13", 13),
|
||||
Hub75Pin("OE", 40, "PI14/PWM4", 14),
|
||||
Hub75Pin("LAT", 36, "PI15", 15),
|
||||
)
|
||||
|
||||
RESERVED_PI_BITS = frozenset({7, 8, 16})
|
||||
REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
|
||||
|
||||
|
||||
def address_mask(row: int) -> int:
|
||||
if type(row) is not int or not 0 <= row < SCAN_ROWS:
|
||||
raise ValueError("row must be in 0..31")
|
||||
mapping = (6, 9, 10, 11, 12)
|
||||
return sum(1 << gpio_bit for row_bit, gpio_bit in enumerate(mapping) if row & (1 << row_bit))
|
||||
|
||||
|
||||
def map_channel(value: int, brightness: int) -> int:
|
||||
if type(value) is not int or not 0 <= value <= 255:
|
||||
raise ValueError("value must be in 0..255")
|
||||
if type(brightness) is not int or not 1 <= brightness <= 100:
|
||||
raise ValueError("brightness must be in 1..100")
|
||||
output_factor = (1 << INTERNAL_PWM_BITS) - 1
|
||||
v = value * brightness / 255.0
|
||||
luminance = v / 902.3 if v <= 8 else ((v + 16) / 116.0) ** 3
|
||||
return int(output_factor * luminance + 0.5)
|
||||
|
||||
|
||||
def build_bitplanes(frame: bytes, brightness: int = 100) -> tuple[int, ...]:
|
||||
if len(frame) != WIDTH * HEIGHT * 3:
|
||||
raise ValueError("frame must contain 64x64 RGB888 bytes")
|
||||
mapped = tuple(map_channel(value, brightness) for value in range(256))
|
||||
output = [0] * (SCAN_ROWS * PWM_BITS * WIDTH)
|
||||
for row in range(SCAN_ROWS):
|
||||
for plane in range(PWM_BITS):
|
||||
for column in range(WIDTH):
|
||||
top = (row * WIDTH + column) * 3
|
||||
bottom = ((row + SCAN_ROWS) * WIDTH + column) * 3
|
||||
word = 0
|
||||
for component in range(3):
|
||||
if mapped[frame[top + component]] & (1 << (plane + PWM_PLANE_OFFSET)):
|
||||
word |= 1 << component
|
||||
if mapped[frame[bottom + component]] & (1 << (plane + PWM_PLANE_OFFSET)):
|
||||
word |= 1 << (component + 3)
|
||||
index = ((row * PWM_BITS + plane) * WIDTH) + column
|
||||
output[index] = word
|
||||
return tuple(output)
|
||||
|
||||
|
||||
def bitplane_index(row: int, plane: int, column: int) -> int:
|
||||
if not 0 <= row < SCAN_ROWS or not 0 <= plane < PWM_BITS or not 0 <= column < WIDTH:
|
||||
raise ValueError("bitplane coordinate out of range")
|
||||
return ((row * PWM_BITS + plane) * WIDTH) + column
|
||||
|
||||
|
||||
def oe_ticks_for_plane(
|
||||
scan_slot_ns: int,
|
||||
brightness: int,
|
||||
plane: int,
|
||||
remainder: int,
|
||||
) -> tuple[int, int]:
|
||||
if type(scan_slot_ns) is not int or scan_slot_ns <= 0:
|
||||
raise ValueError("scan_slot_ns must be positive")
|
||||
if type(brightness) is not int or not 1 <= brightness <= 100:
|
||||
raise ValueError("brightness must be in 1..100")
|
||||
if type(plane) is not int or not 0 <= plane < PWM_BITS:
|
||||
raise ValueError("plane must be in 0..6")
|
||||
if type(remainder) is not int or remainder < 0:
|
||||
raise ValueError("remainder must be non-negative")
|
||||
return 1 << (plane + PWM_PLANE_OFFSET), 0
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
OTA_INDICATOR_BRIGHTNESS = 40
|
||||
OTA_CANVAS_SIZE = 64
|
||||
OTA_CONTENT_SIZE = 58
|
||||
OTA_CONTENT_OFFSET = (OTA_CANVAS_SIZE - OTA_CONTENT_SIZE) // 2
|
||||
|
||||
|
||||
def _draw_binary_text(
|
||||
image: Image.Image,
|
||||
position: tuple[int, int],
|
||||
text: str,
|
||||
*,
|
||||
font: ImageFont.ImageFont,
|
||||
fill: tuple[int, int, int],
|
||||
) -> tuple[int, int, int, int]:
|
||||
probe = ImageDraw.Draw(image)
|
||||
bbox = probe.textbbox((0, 0), text, font=font)
|
||||
width = max(1, bbox[2] - bbox[0])
|
||||
height = max(1, bbox[3] - bbox[1])
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, font=font, fill=255)
|
||||
# Small system labels must be LED pixels, not dim antialiasing samples.
|
||||
mask = mask.point(lambda value: 255 if value >= 64 else 0)
|
||||
left = position[0] + bbox[0]
|
||||
top = position[1] + bbox[1]
|
||||
image.paste(fill, (left, top), mask)
|
||||
return left, top, left + width, top + height
|
||||
|
||||
|
||||
def _resize_preserving_lit_pixels(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
"""Shrink a pixel composition without dropping one-pixel strokes."""
|
||||
source = image.convert("RGB")
|
||||
target = Image.new("RGB", size, (0, 0, 0))
|
||||
source_pixels = source.load()
|
||||
target_pixels = target.load()
|
||||
source_width, source_height = source.size
|
||||
target_width, target_height = size
|
||||
# Forward-map every lit source pixel once. Pillow's inverse nearest-neighbor
|
||||
# shrink skips some source rows/columns, which is exactly what breaks thin
|
||||
# strokes such as the top of "T".
|
||||
for source_y in range(source_height):
|
||||
target_y = min(target_height - 1, int((source_y + 0.5) * target_height / source_height))
|
||||
for source_x in range(source_width):
|
||||
pixel = source_pixels[source_x, source_y]
|
||||
if pixel == (0, 0, 0):
|
||||
continue
|
||||
target_x = min(target_width - 1, int((source_x + 0.5) * target_width / source_width))
|
||||
if max(pixel) > max(target_pixels[target_x, target_y]):
|
||||
target_pixels[target_x, target_y] = pixel
|
||||
return target
|
||||
|
||||
|
||||
def render_ota_indicator(percent: int = 0, stage: str = "准备更新") -> Image.Image:
|
||||
value = max(0, min(100, int(percent)))
|
||||
content = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(content)
|
||||
cyan = (34, 220, 220)
|
||||
dim = (24, 70, 76)
|
||||
white = (245, 250, 250)
|
||||
|
||||
draw.rounded_rectangle((15, 4, 48, 32), radius=5, outline=cyan, width=2)
|
||||
draw.polygon(((32, 8), (23, 18), (28, 18), (28, 27), (36, 27), (36, 18), (41, 18)), fill=cyan)
|
||||
font = ImageFont.load_default()
|
||||
# One source-pixel space prevents the coverage shrink from joining adjacent
|
||||
# glyph edges into the misleading "OIA" seen on the device.
|
||||
ota_label = "O T A"
|
||||
label_box = draw.textbbox((0, 0), ota_label, font=font)
|
||||
label_width = label_box[2] - label_box[0]
|
||||
_draw_binary_text(
|
||||
content,
|
||||
((OTA_CANVAS_SIZE - label_width) // 2, 35),
|
||||
ota_label,
|
||||
font=font,
|
||||
fill=white,
|
||||
)
|
||||
draw.rectangle((6, 48, 57, 55), outline=dim, width=1)
|
||||
fill_width = round(49 * value / 100)
|
||||
if fill_width:
|
||||
draw.rectangle((7, 49, 6 + fill_width, 54), fill=cyan)
|
||||
label = f"{value}%"
|
||||
percent_box = draw.textbbox((0, 0), label, font=font)
|
||||
percent_width = percent_box[2] - percent_box[0]
|
||||
left = (OTA_CANVAS_SIZE - percent_width) // 2
|
||||
# Position from the measured bottom so the source glyph is never clipped.
|
||||
top = OTA_CANVAS_SIZE - percent_box[3]
|
||||
_draw_binary_text(content, (left, top), label, font=font, fill=white)
|
||||
|
||||
# The stage changes a tiny activity marker even when the percentage is fixed.
|
||||
marker = sum(stage.encode("utf-8")) % 4
|
||||
draw.rectangle((52 + marker * 2, 37, 53 + marker * 2, 39), fill=cyan)
|
||||
|
||||
scaled = _resize_preserving_lit_pixels(content, (OTA_CONTENT_SIZE, OTA_CONTENT_SIZE))
|
||||
image = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
|
||||
image.paste(scaled, (OTA_CONTENT_OFFSET, OTA_CONTENT_OFFSET))
|
||||
return image
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STARTUP_BRIGHTNESS = 50
|
||||
OPEN_EYE_SECONDS = 1.3
|
||||
CLOSED_EYE_SECONDS = 0.2
|
||||
BACKGROUND = (255, 255, 255)
|
||||
PINK = (255, 79, 163)
|
||||
_PROCESS_BOOT_ID = f"process-{uuid4()}"
|
||||
|
||||
|
||||
def render_startup_smile(*, eyes_closed: bool = False) -> Image.Image:
|
||||
image = Image.new("RGB", (64, 64), BACKGROUND)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
if eyes_closed:
|
||||
draw.rectangle((14, 23, 25, 26), fill=PINK)
|
||||
draw.rectangle((38, 23, 49, 26), fill=PINK)
|
||||
else:
|
||||
draw.rectangle((16, 18, 23, 29), fill=PINK)
|
||||
draw.rectangle((40, 18, 47, 29), fill=PINK)
|
||||
|
||||
for box in (
|
||||
(14, 36, 17, 39),
|
||||
(18, 40, 21, 43),
|
||||
(22, 44, 25, 47),
|
||||
(26, 48, 37, 51),
|
||||
(38, 44, 41, 47),
|
||||
(42, 40, 45, 43),
|
||||
(46, 36, 49, 39),
|
||||
):
|
||||
draw.rectangle(box, fill=PINK)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def read_boot_id() -> str:
|
||||
try:
|
||||
value = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
|
||||
if value:
|
||||
return value
|
||||
except (OSError, UnicodeError):
|
||||
logger.info("Linux boot ID is unavailable; using the current process session")
|
||||
return _PROCESS_BOOT_ID
|
||||
|
||||
|
||||
class StartupSessionStore:
|
||||
def __init__(self, data_dir: Path, boot_id: str | None = None) -> None:
|
||||
self.path = data_dir / "startup_indicator.json"
|
||||
self.boot_id = boot_id or read_boot_id()
|
||||
|
||||
def should_show(self) -> bool:
|
||||
if not self.path.exists():
|
||||
return True
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("startup indicator state must be an object")
|
||||
return raw.get("dismissed_boot_id") != self.boot_id
|
||||
except (OSError, ValueError, TypeError):
|
||||
logger.exception("Startup indicator state is invalid; showing the indicator")
|
||||
return True
|
||||
|
||||
def mark_dismissed(self) -> None:
|
||||
atomic_write_bytes(
|
||||
self.path,
|
||||
(
|
||||
json.dumps(
|
||||
{"dismissed_boot_id": self.boot_id},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8"),
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unicodedata
|
||||
from functools import lru_cache
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
from PIL import Image, ImageDraw, ImageFont, features
|
||||
|
||||
from .colors import RGB
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextOptions:
|
||||
text: str
|
||||
font: str = "default"
|
||||
size: int = 12
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
align: str = "left"
|
||||
color: RGB = (255, 255, 255)
|
||||
background: RGB = (0, 0, 0)
|
||||
|
||||
|
||||
DEFAULT_FONT_CANDIDATES = (
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||||
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
|
||||
"C:/Windows/Fonts/arial.ttf",
|
||||
"C:/Windows/Fonts/segoeui.ttf",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/msjh.ttc",
|
||||
"C:/Windows/Fonts/YuGothR.ttc",
|
||||
"C:/Windows/Fonts/malgun.ttf",
|
||||
"C:/Windows/Fonts/LeelUIsl.ttf",
|
||||
)
|
||||
|
||||
FONTCONFIG_LANGUAGES = (
|
||||
"en", "ru", "el", "zh-cn", "ja", "ko", "ar", "he", "hi",
|
||||
"bn", "ta", "te", "th",
|
||||
)
|
||||
|
||||
PREFERRED_FONT_CANDIDATES: dict[str, tuple[tuple[str, int], ...]] = {
|
||||
"en": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"ru": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"el": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"zh-cn": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), ("C:/Windows/Fonts/msyh.ttc", 0)),
|
||||
"ja": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0), ("C:/Windows/Fonts/YuGothR.ttc", 0)),
|
||||
"ko": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 1), ("C:/Windows/Fonts/malgun.ttf", 0)),
|
||||
"ar": (("/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"he": (("/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"hi": (("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", 0),),
|
||||
"bn": (("/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf", 0),),
|
||||
"ta": (("/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf", 0),),
|
||||
"te": (("/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf", 0),),
|
||||
"th": (("/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", 0), ("C:/Windows/Fonts/LeelUIsl.ttf", 0)),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontFace:
|
||||
path: str
|
||||
index: int = 0
|
||||
|
||||
|
||||
FontResolver = Callable[[str], tuple[FontFace, ...]]
|
||||
|
||||
|
||||
def render_text_layer(
|
||||
options: TextOptions,
|
||||
size: tuple[int, int] = (64, 64),
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
if options.size < 1 or options.size > 64:
|
||||
raise ValueError("text size must be in 1..64")
|
||||
if options.align not in {"left", "center", "right"}:
|
||||
raise ValueError("align must be left, center, or right")
|
||||
|
||||
image = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = load_font(options.font, options.size, options.text, font_resolver=font_resolver)
|
||||
|
||||
x = int(options.x)
|
||||
y = int(options.y)
|
||||
bbox = draw.textbbox((0, 0), options.text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
if options.align == "center":
|
||||
x = x - text_width // 2
|
||||
elif options.align == "right":
|
||||
x = x - text_width
|
||||
|
||||
draw.text((x, y), options.text, fill=(*options.color, 255), font=font)
|
||||
return image
|
||||
|
||||
|
||||
def render_text(
|
||||
options: TextOptions,
|
||||
size: tuple[int, int] = (64, 64),
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
background = Image.new("RGBA", size, (*options.background, 255))
|
||||
layer = render_text_layer(options, size=size, font_resolver=font_resolver)
|
||||
return Image.alpha_composite(background, layer).convert("RGB")
|
||||
|
||||
|
||||
def _required_codepoints(text: str) -> frozenset[int]:
|
||||
return frozenset(
|
||||
ord(character)
|
||||
for character in text
|
||||
if not character.isspace()
|
||||
and unicodedata.category(character) not in {"Cc", "Cf"}
|
||||
and not 0xFE00 <= ord(character) <= 0xFE0F
|
||||
and not 0xE0100 <= ord(character) <= 0xE01EF
|
||||
)
|
||||
|
||||
|
||||
def _contains_default_emoji(codepoints: frozenset[int]) -> bool:
|
||||
# Default automatic text intentionally stays deterministic across Debian
|
||||
# images. Some DejaVu releases contain a few monochrome emoji glyphs while
|
||||
# others do not, so accepting them based on the host cmap makes the API
|
||||
# change after an OS/font update. Explicitly imported fonts remain eligible.
|
||||
return any(0x1F000 <= codepoint <= 0x1FAFF for codepoint in codepoints)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _font_coverage(face: FontFace) -> frozenset[int]:
|
||||
try:
|
||||
# Fontconfig encodes variable-font named instances above the base
|
||||
# collection index. FontTools needs the base face while Pillow uses the
|
||||
# complete FreeType face index.
|
||||
with TTFont(face.path, fontNumber=face.index & 0xFFFF, lazy=True) as font:
|
||||
cmap = font.getBestCmap() or {}
|
||||
return frozenset(cmap)
|
||||
except Exception:
|
||||
return frozenset()
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _fontconfig_face(language: str) -> FontFace | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fc-match", "--format=%{file}\n%{index}\n", f":lang={language}"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=2,
|
||||
)
|
||||
lines = result.stdout.splitlines()
|
||||
if not lines:
|
||||
return None
|
||||
path = lines[0].strip()
|
||||
index = int(lines[1].strip() or "0") if len(lines) > 1 else 0
|
||||
if path and Path(path).is_file():
|
||||
return FontFace(path, index)
|
||||
except (FileNotFoundError, OSError, subprocess.SubprocessError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fontconfig_faces() -> list[FontFace]:
|
||||
return [face for language in FONTCONFIG_LANGUAGES if (face := _fontconfig_face(language))]
|
||||
|
||||
|
||||
def _preferred_font_face(language: str) -> FontFace | None:
|
||||
for candidate, index in PREFERRED_FONT_CANDIDATES.get(language, ()):
|
||||
path = Path(candidate)
|
||||
if path.is_file():
|
||||
return FontFace(str(path.resolve()), index)
|
||||
face = _fontconfig_face(language)
|
||||
if face and Path(face.path).name == "NotoSansCJK-Regular.ttc":
|
||||
indexes = {"ja": 0, "ko": 1, "zh-cn": 2}
|
||||
return FontFace(face.path, indexes.get(language, face.index))
|
||||
return face
|
||||
|
||||
|
||||
def _preferred_font_language(text: str) -> str | None:
|
||||
codepoints = _required_codepoints(text)
|
||||
script_ranges = (
|
||||
("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xAC00, 0xD7AF))),
|
||||
("ja", ((0x3040, 0x30FF), (0x31F0, 0x31FF))),
|
||||
("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF))),
|
||||
("he", ((0x0590, 0x05FF),)),
|
||||
("hi", ((0x0900, 0x097F),)),
|
||||
("bn", ((0x0980, 0x09FF),)),
|
||||
("ta", ((0x0B80, 0x0BFF),)),
|
||||
("te", ((0x0C00, 0x0C7F),)),
|
||||
("th", ((0x0E00, 0x0E7F),)),
|
||||
("el", ((0x0370, 0x03FF),)),
|
||||
("ru", ((0x0400, 0x052F),)),
|
||||
("zh-cn", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF))),
|
||||
)
|
||||
for language, ranges in script_ranges:
|
||||
if any(start <= codepoint <= end for codepoint in codepoints for start, end in ranges):
|
||||
return language
|
||||
return "en" if codepoints else None
|
||||
|
||||
|
||||
def _configured_faces() -> list[FontFace]:
|
||||
raw = os.environ.get("MATRIX_FONT_PATHS", "")
|
||||
return [
|
||||
FontFace(str(Path(value).expanduser()))
|
||||
for value in raw.split(os.pathsep)
|
||||
if value.strip() and Path(value.strip()).expanduser().is_file()
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def fallback_font_faces() -> tuple[FontFace, ...]:
|
||||
faces = _configured_faces() + _fontconfig_faces()
|
||||
faces.extend(
|
||||
FontFace(str(path))
|
||||
for candidate in DEFAULT_FONT_CANDIDATES
|
||||
if (path := Path(candidate)).is_file()
|
||||
)
|
||||
unique: list[FontFace] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
for face in faces:
|
||||
key = (str(Path(face.path).resolve()), face.index)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(FontFace(key[0], face.index))
|
||||
return tuple(unique)
|
||||
|
||||
|
||||
def _custom_font_faces(font: str) -> tuple[FontFace, ...]:
|
||||
if not font or font == "default":
|
||||
return ()
|
||||
path = Path(font).expanduser()
|
||||
if not path.is_file():
|
||||
return ()
|
||||
# Existing requests do not carry a TTC index. Trying the indexes returned by
|
||||
# Fontconfig first retains useful custom TTC behavior without changing the API.
|
||||
indexes = [face.index for face in fallback_font_faces() if Path(face.path) == path.resolve()]
|
||||
indexes.append(0)
|
||||
return tuple(FontFace(str(path.resolve()), index) for index in dict.fromkeys(indexes))
|
||||
|
||||
|
||||
def resolve_font_face(
|
||||
font: str,
|
||||
text: str,
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> FontFace | None:
|
||||
required = _required_codepoints(text)
|
||||
if font == "default" and _contains_default_emoji(required):
|
||||
return None
|
||||
language = _preferred_font_language(text)
|
||||
preferred = _preferred_font_face(language) if language else None
|
||||
preferred_faces = (preferred,) if preferred is not None else ()
|
||||
resolved_faces = font_resolver(font) if font_resolver and font != "default" else ()
|
||||
primary_candidates = resolved_faces + _custom_font_faces(font) + preferred_faces
|
||||
if not required and primary_candidates:
|
||||
return primary_candidates[0]
|
||||
for face in primary_candidates:
|
||||
if required <= _font_coverage(face):
|
||||
return face
|
||||
candidates = primary_candidates + fallback_font_faces()
|
||||
if not candidates:
|
||||
return None
|
||||
if not required:
|
||||
return candidates[0]
|
||||
for face in candidates[len(primary_candidates):]:
|
||||
if required <= _font_coverage(face):
|
||||
return face
|
||||
missing = sorted(
|
||||
codepoint
|
||||
for codepoint in required
|
||||
if not any(codepoint in _font_coverage(face) for face in candidates)
|
||||
)
|
||||
if not missing:
|
||||
missing = sorted(required)
|
||||
codes = ", ".join(f"U+{codepoint:04X}" for codepoint in missing[:12])
|
||||
if len(missing) > 12:
|
||||
codes += ", ..."
|
||||
raise ValueError(f"no installed font covers Unicode code points: {codes}")
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _load_truetype(face: FontFace, size: int) -> ImageFont.FreeTypeFont:
|
||||
layout_engine = ImageFont.Layout.RAQM if features.check_feature("raqm") else ImageFont.Layout.BASIC
|
||||
return ImageFont.truetype(
|
||||
face.path,
|
||||
size=size,
|
||||
index=face.index,
|
||||
layout_engine=layout_engine,
|
||||
)
|
||||
|
||||
|
||||
def load_font(
|
||||
font: str,
|
||||
size: int,
|
||||
text: str = "",
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> ImageFont.ImageFont:
|
||||
face = resolve_font_face(font, text, font_resolver=font_resolver)
|
||||
if face is not None:
|
||||
return _load_truetype(face, size)
|
||||
if _required_codepoints(text):
|
||||
codes = ", ".join(f"U+{value:04X}" for value in sorted(_required_codepoints(text)))
|
||||
raise ValueError(f"no installed font covers Unicode code points: {codes}")
|
||||
try:
|
||||
return ImageFont.load_default(size=size)
|
||||
except TypeError:
|
||||
return ImageFont.load_default()
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
VALID_ORIENTATIONS = {0, 90, 180, 270}
|
||||
DEFAULT_ROW_BIT_ORDER = (0, 1, 2, 3, 4)
|
||||
|
||||
|
||||
def apply_orientation(image: Image.Image, orientation: int) -> Image.Image:
|
||||
if orientation not in VALID_ORIENTATIONS:
|
||||
raise ValueError("orientation must be one of 0, 90, 180, 270")
|
||||
|
||||
if orientation == 0:
|
||||
return image.copy()
|
||||
if orientation == 90:
|
||||
return image.transpose(Image.Transpose.ROTATE_270)
|
||||
if orientation == 180:
|
||||
return image.transpose(Image.Transpose.ROTATE_180)
|
||||
return image.transpose(Image.Transpose.ROTATE_90)
|
||||
|
||||
|
||||
def get_row_bit_order() -> tuple[int, int, int, int, int]:
|
||||
raw = os.environ.get("MATRIX_ROW_BIT_ORDER", "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_ROW_BIT_ORDER
|
||||
|
||||
try:
|
||||
values = tuple(int(part.strip()) for part in raw.split(","))
|
||||
except ValueError as exc:
|
||||
raise ValueError("MATRIX_ROW_BIT_ORDER must be five comma-separated integers") from exc
|
||||
|
||||
if len(values) != 5 or sorted(values) != [0, 1, 2, 3, 4]:
|
||||
raise ValueError("MATRIX_ROW_BIT_ORDER must be a permutation of 0,1,2,3,4")
|
||||
return values
|
||||
|
||||
|
||||
def apply_row_bit_order(image: Image.Image, bit_order: tuple[int, int, int, int, int]) -> Image.Image:
|
||||
if bit_order == DEFAULT_ROW_BIT_ORDER:
|
||||
return image.copy()
|
||||
|
||||
width, height = image.size
|
||||
if height % 32 != 0:
|
||||
raise ValueError("row bit remap requires a display height divisible by 32")
|
||||
|
||||
remapped = Image.new("RGB", image.size)
|
||||
for y in range(height):
|
||||
scan_row = y & 31
|
||||
half_offset = y - scan_row
|
||||
physical_scan_row = _permute_low_five_bits(scan_row, bit_order)
|
||||
source_y = half_offset + physical_scan_row
|
||||
remapped.paste(image.crop((0, source_y, width, source_y + 1)), (0, y))
|
||||
return remapped
|
||||
|
||||
|
||||
def _permute_low_five_bits(value: int, bit_order: tuple[int, int, int, int, int]) -> int:
|
||||
result = 0
|
||||
for driver_bit, physical_bit in enumerate(bit_order):
|
||||
if value & (1 << driver_bit):
|
||||
result |= 1 << physical_bit
|
||||
return result
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .text_renderer import load_font
|
||||
|
||||
WIFI_INDICATOR_BRIGHTNESS = 50
|
||||
WIFI_SCROLL_PIXELS_PER_SECOND = 16.0
|
||||
WIFI_SCROLL_FPS = 20.0
|
||||
WIFI_SCROLL_GAP_PIXELS = 16
|
||||
WIFI_TEXT_SIZE = 10
|
||||
|
||||
|
||||
def _draw_binary_text(
|
||||
image: Image.Image,
|
||||
position: tuple[int, int],
|
||||
message: str,
|
||||
*,
|
||||
font: ImageFont.ImageFont,
|
||||
) -> None:
|
||||
probe = ImageDraw.Draw(image)
|
||||
bbox = probe.textbbox((0, 0), message, font=font)
|
||||
width = max(1, bbox[2] - bbox[0])
|
||||
height = max(1, bbox[3] - bbox[1])
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), message, font=font, fill=255)
|
||||
# Promote every covered font sample to a complete LED pixel. At this size,
|
||||
# retaining grayscale antialiasing makes thin strokes disappear at 50%.
|
||||
mask = mask.point(lambda value: 255 if value >= 64 else 0)
|
||||
image.paste((255, 255, 255), (position[0] + bbox[0], position[1] + bbox[1]), mask)
|
||||
|
||||
|
||||
def text_width(message: str) -> int:
|
||||
font = load_font("default", WIFI_TEXT_SIZE, message)
|
||||
draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
box = draw.textbbox((0, 0), message, font=font)
|
||||
return max(1, box[2] - box[0])
|
||||
|
||||
|
||||
def _draw_disconnected_icon(draw: ImageDraw.ImageDraw) -> None:
|
||||
# A compact WiFi fan with a red slash remains recognizable on 64x64.
|
||||
white = (255, 255, 255)
|
||||
red = (255, 48, 48)
|
||||
draw.arc((19, 4, 45, 28), 205, 335, fill=white, width=2)
|
||||
draw.arc((23, 10, 41, 28), 205, 335, fill=white, width=2)
|
||||
draw.arc((28, 17, 36, 27), 205, 335, fill=white, width=2)
|
||||
draw.rectangle((31, 27, 33, 29), fill=white)
|
||||
draw.line((19, 4, 45, 30), fill=red, width=3)
|
||||
|
||||
|
||||
def render_wifi_indicator(
|
||||
message: str,
|
||||
*,
|
||||
offset: float,
|
||||
connected: bool,
|
||||
size: tuple[int, int] = (64, 64),
|
||||
) -> Image.Image:
|
||||
image = Image.new("RGB", size, (0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = load_font("default", WIFI_TEXT_SIZE, message)
|
||||
if not connected:
|
||||
_draw_disconnected_icon(draw)
|
||||
x = size[0] - int(offset)
|
||||
y = 26 if connected else 42
|
||||
_draw_binary_text(image, (x, y), message, font=font)
|
||||
return image
|
||||
@@ -0,0 +1,21 @@
|
||||
from .store import (
|
||||
ACCEPTED_FONT_EXTENSIONS,
|
||||
MAX_FONT_UPLOAD_BYTES,
|
||||
FontCatalog,
|
||||
FontCatalogError,
|
||||
FontStorageFullError,
|
||||
FontTooLargeError,
|
||||
FontTypeError,
|
||||
FontValidationError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTED_FONT_EXTENSIONS",
|
||||
"MAX_FONT_UPLOAD_BYTES",
|
||||
"FontCatalog",
|
||||
"FontCatalogError",
|
||||
"FontStorageFullError",
|
||||
"FontTooLargeError",
|
||||
"FontTypeError",
|
||||
"FontValidationError",
|
||||
]
|
||||
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterable, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
from fontTools.ttLib import TTCollection, TTFont, TTLibError
|
||||
|
||||
from app.display.text_renderer import DEFAULT_FONT_CANDIDATES, FontFace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCEPTED_FONT_EXTENSIONS = (".ttf", ".otf", ".ttc", ".otc")
|
||||
MAX_FONT_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
_FONTCONFIG_TIMEOUT_SECONDS = 8
|
||||
_USER_FONT_NAME = "{digest}.font"
|
||||
|
||||
|
||||
class FontCatalogError(RuntimeError):
|
||||
"""Base error for font catalog operations."""
|
||||
|
||||
|
||||
class FontTooLargeError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
class FontTypeError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
class FontValidationError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
class FontStorageFullError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DiscoveredFace:
|
||||
face: FontFace
|
||||
family: str
|
||||
style: str
|
||||
|
||||
|
||||
def _friendly_name(font: TTFont, fallback: str) -> tuple[str, str]:
|
||||
names = font.get("name")
|
||||
if names is None:
|
||||
return fallback, "Regular"
|
||||
family = names.getBestFamilyName() or names.getDebugName(1) or fallback
|
||||
style = names.getBestSubFamilyName() or names.getDebugName(2) or "Regular"
|
||||
return str(family).strip() or fallback, str(style).strip() or "Regular"
|
||||
|
||||
|
||||
def _has_unicode_cmap(font: TTFont) -> bool:
|
||||
cmap = font.get("cmap")
|
||||
return bool(cmap and any(table.isUnicode() and table.cmap for table in cmap.tables))
|
||||
|
||||
|
||||
def _inspect_font_file(path: Path, *, require_unicode: bool) -> tuple[_DiscoveredFace, ...]:
|
||||
path = Path(path)
|
||||
fallback = path.stem or "Unnamed font"
|
||||
collection: TTCollection | None = None
|
||||
fonts: list[TTFont] = []
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
try:
|
||||
collection = TTCollection(BytesIO(content), lazy=False)
|
||||
fonts = list(collection.fonts)
|
||||
except TTLibError:
|
||||
fonts = [TTFont(BytesIO(content), lazy=False)]
|
||||
discovered: list[_DiscoveredFace] = []
|
||||
for index, font in enumerate(fonts):
|
||||
if require_unicode and not _has_unicode_cmap(font):
|
||||
continue
|
||||
family, style = _friendly_name(font, fallback)
|
||||
discovered.append(
|
||||
_DiscoveredFace(FontFace(str(path.resolve()), index), family, style)
|
||||
)
|
||||
if not discovered:
|
||||
raise FontValidationError("font file does not contain a usable Unicode font face")
|
||||
return tuple(discovered)
|
||||
except FontValidationError:
|
||||
raise
|
||||
except (OSError, TTLibError, KeyError, ValueError) as exc:
|
||||
raise FontValidationError("font file is invalid or unsupported") from exc
|
||||
finally:
|
||||
if collection is not None:
|
||||
collection.close()
|
||||
else:
|
||||
for font in fonts:
|
||||
font.close()
|
||||
|
||||
|
||||
def _system_font_id(face: FontFace) -> str:
|
||||
raw = f"{Path(face.path).resolve()}\0{face.index}".encode("utf-8")
|
||||
return f"system:{hashlib.sha256(raw).hexdigest()}"
|
||||
|
||||
|
||||
def _user_font_id(digest: str, index: int) -> str:
|
||||
return f"user:{digest}:{index}"
|
||||
|
||||
|
||||
def _entry(identifier: str, family: str, style: str, source: str) -> dict[str, str]:
|
||||
family = family.strip() or "未命名字体"
|
||||
style = style.strip() or "Regular"
|
||||
label = family if style.casefold() in {"regular", "normal", "book"} else f"{family} — {style}"
|
||||
return {
|
||||
"id": identifier,
|
||||
"label": label,
|
||||
"family": family,
|
||||
"style": style,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def _fontconfig_faces() -> tuple[_DiscoveredFace, ...]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fc-list", "--format=%{file}\t%{index}\t%{family[0]}\t%{style[0]}\n"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=_FONTCONFIG_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.SubprocessError):
|
||||
return ()
|
||||
|
||||
discovered: list[_DiscoveredFace] = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split("\t", 3)
|
||||
if len(fields) != 4:
|
||||
continue
|
||||
raw_path, raw_index, family, style = fields
|
||||
path = Path(raw_path.strip())
|
||||
if path.suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS or not path.is_file():
|
||||
continue
|
||||
try:
|
||||
index = int(raw_index.strip() or "0")
|
||||
except ValueError:
|
||||
continue
|
||||
discovered.append(
|
||||
_DiscoveredFace(
|
||||
FontFace(str(path.resolve()), index),
|
||||
family.strip() or path.stem,
|
||||
style.strip() or "Regular",
|
||||
)
|
||||
)
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
def _configured_paths() -> Iterable[Path]:
|
||||
raw = os.environ.get("MATRIX_FONT_PATHS", "")
|
||||
for value in raw.split(os.pathsep):
|
||||
if value.strip():
|
||||
yield Path(value.strip()).expanduser()
|
||||
|
||||
|
||||
def _sync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
return
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
class FontCatalog:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.root = Path(data_dir) / "fonts"
|
||||
self._lock = threading.RLock()
|
||||
self._items: tuple[dict[str, str], ...] = ()
|
||||
self._faces: dict[str, tuple[FontFace, ...]] = {}
|
||||
self._warnings: tuple[str, ...] = ()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if os.name != "nt":
|
||||
self.root.chmod(0o750)
|
||||
self._cleanup_upload_temporaries()
|
||||
self._system_catalog = self._system_faces()
|
||||
self.refresh()
|
||||
|
||||
def _cleanup_upload_temporaries(self) -> None:
|
||||
for path in self.root.glob(".upload-*.tmp"):
|
||||
if path.is_file() and path.parent == self.root:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _system_faces(self) -> tuple[_DiscoveredFace, ...]:
|
||||
discovered = list(_fontconfig_faces())
|
||||
known = {(str(Path(item.face.path).resolve()), item.face.index) for item in discovered}
|
||||
known_paths = {path for path, _index in known}
|
||||
for candidate in (*_configured_paths(), *(Path(value) for value in DEFAULT_FONT_CANDIDATES)):
|
||||
if candidate.suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS or not candidate.is_file():
|
||||
continue
|
||||
resolved_candidate = str(candidate.resolve())
|
||||
if resolved_candidate in known_paths:
|
||||
continue
|
||||
try:
|
||||
inspected = _inspect_font_file(candidate, require_unicode=True)
|
||||
except FontValidationError:
|
||||
continue
|
||||
for item in inspected:
|
||||
key = (str(Path(item.face.path).resolve()), item.face.index)
|
||||
if key not in known:
|
||||
known.add(key)
|
||||
known_paths.add(key[0])
|
||||
discovered.append(item)
|
||||
return tuple(discovered)
|
||||
|
||||
def refresh(self) -> None:
|
||||
faces: dict[str, tuple[FontFace, ...]] = {}
|
||||
system_items: list[dict[str, str]] = []
|
||||
imported_items: list[dict[str, str]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
seen_system: set[tuple[str, int]] = set()
|
||||
for item in self._system_catalog:
|
||||
key = (str(Path(item.face.path).resolve()), item.face.index)
|
||||
if key in seen_system:
|
||||
continue
|
||||
seen_system.add(key)
|
||||
identifier = _system_font_id(item.face)
|
||||
faces[identifier] = (item.face,)
|
||||
system_items.append(_entry(identifier, item.family, item.style, "system"))
|
||||
|
||||
for path in sorted(self.root.glob("*.font"), key=lambda item: item.name):
|
||||
digest = path.stem.casefold()
|
||||
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
||||
warnings.append(f"已忽略未登记字体文件:{path.name}")
|
||||
continue
|
||||
try:
|
||||
inspected = _inspect_font_file(path, require_unicode=True)
|
||||
except FontValidationError:
|
||||
warnings.append(f"已保留但忽略损坏字体:{path.name}")
|
||||
logger.warning("Ignoring invalid persisted font %s", path)
|
||||
continue
|
||||
for item in inspected:
|
||||
identifier = _user_font_id(digest, item.face.index)
|
||||
faces[identifier] = (item.face,)
|
||||
imported_items.append(_entry(identifier, item.family, item.style, "imported"))
|
||||
|
||||
sort_key = lambda value: (value["family"].casefold(), value["style"].casefold(), value["id"])
|
||||
automatic = {
|
||||
"id": "default",
|
||||
"label": "自动多语言字体",
|
||||
"family": "自动多语言字体",
|
||||
"style": "自动回退",
|
||||
"source": "automatic",
|
||||
}
|
||||
with self._lock:
|
||||
self._items = (
|
||||
automatic,
|
||||
*sorted(imported_items, key=sort_key),
|
||||
*sorted(system_items, key=sort_key),
|
||||
)
|
||||
self._faces = faces
|
||||
self._warnings = tuple(warnings)
|
||||
|
||||
def document(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"items": [dict(item) for item in self._items],
|
||||
"accepted_extensions": list(ACCEPTED_FONT_EXTENSIONS),
|
||||
"max_upload_bytes": MAX_FONT_UPLOAD_BYTES,
|
||||
"warnings": list(self._warnings),
|
||||
}
|
||||
|
||||
def resolve(self, identifier: str) -> tuple[FontFace, ...]:
|
||||
with self._lock:
|
||||
return self._faces.get(identifier, ())
|
||||
|
||||
async def import_font(
|
||||
self,
|
||||
filename: str,
|
||||
chunks: AsyncIterable[bytes],
|
||||
) -> tuple[bool, list[dict[str, str]]]:
|
||||
if (
|
||||
not filename
|
||||
or len(filename) > 255
|
||||
or "\0" in filename
|
||||
or "/" in filename
|
||||
or "\\" in filename
|
||||
):
|
||||
raise FontTypeError("font filename is missing or invalid")
|
||||
if Path(filename).suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS:
|
||||
raise FontTypeError("font type must be TTF, OTF, TTC, or OTC")
|
||||
|
||||
temporary = self.root / f".upload-{uuid4().hex}.tmp"
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
try:
|
||||
with temporary.open("xb") as handle:
|
||||
if os.name != "nt":
|
||||
os.chmod(temporary, 0o640)
|
||||
async for chunk in chunks:
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > MAX_FONT_UPLOAD_BYTES:
|
||||
raise FontTooLargeError("font file exceeds the 32 MiB limit")
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if total == 0:
|
||||
raise FontValidationError("font file is empty")
|
||||
inspected = _inspect_font_file(temporary, require_unicode=True)
|
||||
file_digest = digest.hexdigest()
|
||||
target = self.root / _USER_FONT_NAME.format(digest=file_digest)
|
||||
with self._lock:
|
||||
created = not target.exists()
|
||||
if created:
|
||||
os.replace(temporary, target)
|
||||
_sync_directory(self.root)
|
||||
else:
|
||||
temporary.unlink(missing_ok=True)
|
||||
self.refresh()
|
||||
identifiers = {_user_font_id(file_digest, item.face.index) for item in inspected}
|
||||
imported = [dict(item) for item in self._items if item["id"] in identifiers]
|
||||
return created, imported
|
||||
except FontCatalogError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
except OSError as exc:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
|
||||
raise FontStorageFullError("not enough disk space to import font") from exc
|
||||
raise FontCatalogError(str(exc)) from exc
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -0,0 +1,19 @@
|
||||
from .manager import (
|
||||
FrpConflictError,
|
||||
FrpController,
|
||||
FrpError,
|
||||
FrpServiceError,
|
||||
FrpValidationError,
|
||||
MockFrpBackend,
|
||||
SystemdFrpBackend,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FrpConflictError",
|
||||
"FrpController",
|
||||
"FrpError",
|
||||
"FrpServiceError",
|
||||
"FrpValidationError",
|
||||
"MockFrpBackend",
|
||||
"SystemdFrpBackend",
|
||||
]
|
||||
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
FRP_CATALOG_SCHEMA_VERSION = 1
|
||||
MAX_FRP_CONFIG_BYTES = 1024 * 1024
|
||||
SUPPORTED_FRP_SUFFIXES = {".toml", ".yaml", ".yml", ".json", ".ini"}
|
||||
_SAFE_NAME_RE = re.compile(r"[^\w\-. ()\u4e00-\u9fff]+", re.UNICODE)
|
||||
|
||||
|
||||
class FrpError(RuntimeError):
|
||||
"""Base error whose text is safe to return to the browser."""
|
||||
|
||||
|
||||
class FrpValidationError(FrpError):
|
||||
pass
|
||||
|
||||
|
||||
class FrpConflictError(FrpError):
|
||||
pass
|
||||
|
||||
|
||||
class FrpServiceError(FrpError):
|
||||
pass
|
||||
|
||||
|
||||
class FrpBackend(Protocol):
|
||||
def validate(self, path: Path) -> None: ...
|
||||
def status(self) -> dict[str, Any]: ...
|
||||
def select(self, path: Path) -> None: ...
|
||||
def start(self) -> None: ...
|
||||
def stop(self) -> None: ...
|
||||
def restart(self) -> None: ...
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _checked_uuid(value: str) -> str:
|
||||
try:
|
||||
checked = str(UUID(str(value)))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise FrpValidationError("FRP config id is invalid") from exc
|
||||
if checked != str(value):
|
||||
raise FrpValidationError("FRP config id is invalid")
|
||||
return checked
|
||||
|
||||
|
||||
def _checked_suffix(filename: str) -> str:
|
||||
suffix = Path(str(filename)).suffix.casefold()
|
||||
if suffix not in SUPPORTED_FRP_SUFFIXES:
|
||||
raise FrpValidationError("FRP config must be TOML, YAML, JSON or INI")
|
||||
return suffix
|
||||
|
||||
|
||||
def _checked_content(content: bytes) -> bytes:
|
||||
if not content:
|
||||
raise FrpValidationError("FRP config must not be empty")
|
||||
if len(content) > MAX_FRP_CONFIG_BYTES:
|
||||
raise FrpValidationError("FRP config exceeds the 1 MiB limit")
|
||||
try:
|
||||
text = content.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise FrpValidationError("FRP config must be UTF-8 text") from exc
|
||||
if "\x00" in text:
|
||||
raise FrpValidationError("FRP config must not contain NUL bytes")
|
||||
return content
|
||||
|
||||
|
||||
def _display_name(name: str, filename: str) -> str:
|
||||
candidate = str(name or "").strip() or Path(str(filename)).stem.strip()
|
||||
candidate = _SAFE_NAME_RE.sub("_", candidate).strip(" ._")
|
||||
if not candidate:
|
||||
candidate = "frpc"
|
||||
if len(candidate) > 80:
|
||||
candidate = candidate[:80].rstrip()
|
||||
return candidate
|
||||
|
||||
|
||||
def _checked_timestamp(value: str) -> str:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FrpValidationError("FRP config timestamp is invalid") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise FrpValidationError("FRP config timestamp is invalid")
|
||||
return value
|
||||
|
||||
|
||||
class FrpCatalog:
|
||||
def __init__(self, data_root: Path, backend: FrpBackend) -> None:
|
||||
self.root = Path(data_root) / "frp"
|
||||
self.catalog_path = self.root / "catalog.json"
|
||||
self.backend = backend
|
||||
self._lock = threading.RLock()
|
||||
self._error: str | None = None
|
||||
self._document = {
|
||||
"schema_version": FRP_CATALOG_SCHEMA_VERSION,
|
||||
"selected_id": None,
|
||||
"profiles": [],
|
||||
}
|
||||
try:
|
||||
self._load()
|
||||
except FrpError as exc:
|
||||
self._error = str(exc)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._error is None
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
return self._error
|
||||
|
||||
def _require_available(self) -> None:
|
||||
if self._error is not None:
|
||||
raise FrpServiceError(self._error)
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.catalog_path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self.catalog_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise FrpServiceError("FRP config catalog is damaged; the original file was preserved") from exc
|
||||
if not isinstance(raw, dict) or set(raw) != {"schema_version", "selected_id", "profiles"}:
|
||||
raise FrpServiceError("FRP config catalog fields are unsupported; the original file was preserved")
|
||||
if raw["schema_version"] != FRP_CATALOG_SCHEMA_VERSION:
|
||||
raise FrpServiceError("FRP config catalog version is unsupported; the original file was preserved")
|
||||
if not isinstance(raw["profiles"], list):
|
||||
raise FrpServiceError("FRP config catalog profiles are invalid; the original file was preserved")
|
||||
seen: set[str] = set()
|
||||
profiles: list[dict[str, str]] = []
|
||||
for item in raw["profiles"]:
|
||||
if not isinstance(item, dict) or set(item) != {"id", "name", "filename", "created_at", "updated_at"}:
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
|
||||
profile_id = _checked_uuid(item["id"])
|
||||
if profile_id in seen or not all(isinstance(item[key], str) and item[key] for key in item):
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
|
||||
suffix = _checked_suffix(item["filename"])
|
||||
if item["filename"] != f"frpc{suffix}" or _display_name(item["name"], item["filename"]) != item["name"]:
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
|
||||
try:
|
||||
_checked_timestamp(item["created_at"])
|
||||
_checked_timestamp(item["updated_at"])
|
||||
except FrpValidationError as exc:
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved") from exc
|
||||
path = self._profile_path(item)
|
||||
if not path.is_file():
|
||||
raise FrpServiceError("FRP config catalog references a missing file; the original catalog was preserved")
|
||||
seen.add(profile_id)
|
||||
profiles.append(dict(item))
|
||||
selected = raw["selected_id"]
|
||||
if selected is not None and (not isinstance(selected, str) or selected not in seen):
|
||||
raise FrpServiceError("FRP selected config is invalid; the original catalog was preserved")
|
||||
self._document = {"schema_version": FRP_CATALOG_SCHEMA_VERSION, "selected_id": selected, "profiles": profiles}
|
||||
|
||||
def _profile_path(self, profile: dict[str, str]) -> Path:
|
||||
return self.root / "profiles" / profile["id"] / profile["filename"]
|
||||
|
||||
@staticmethod
|
||||
def _verification_path(path: Path) -> Path:
|
||||
return path.with_name(f".{path.stem}.verify{path.suffix}")
|
||||
|
||||
def _save(self) -> None:
|
||||
content = (json.dumps(self._document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
atomic_write_bytes(self.catalog_path, content)
|
||||
self._protect(self.catalog_path)
|
||||
|
||||
@staticmethod
|
||||
def _protect(path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
path.chmod(0o640)
|
||||
|
||||
def _unique_name(self, requested: str, *, excluding: str | None = None) -> str:
|
||||
existing = {
|
||||
item["name"].casefold()
|
||||
for item in self._document["profiles"]
|
||||
if item["id"] != excluding
|
||||
}
|
||||
if requested.casefold() not in existing:
|
||||
return requested
|
||||
counter = 2
|
||||
while f"{requested} ({counter})".casefold() in existing:
|
||||
counter += 1
|
||||
return f"{requested} ({counter})"
|
||||
|
||||
def list_document(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"available": self.available,
|
||||
"error": self.error,
|
||||
"selected_id": self._document["selected_id"],
|
||||
"profiles": deepcopy(self._document["profiles"]),
|
||||
}
|
||||
|
||||
def get(self, profile_id: str) -> tuple[dict[str, str], Path]:
|
||||
self._require_available()
|
||||
checked = _checked_uuid(profile_id)
|
||||
for profile in self._document["profiles"]:
|
||||
if profile["id"] == checked:
|
||||
return deepcopy(profile), self._profile_path(profile)
|
||||
raise FrpValidationError("FRP config was not found")
|
||||
|
||||
def create(self, *, filename: str, name: str, content: bytes) -> dict[str, str]:
|
||||
self._require_available()
|
||||
checked_content = _checked_content(content)
|
||||
suffix = _checked_suffix(filename)
|
||||
with self._lock:
|
||||
profile_id = str(uuid4())
|
||||
now = _utc_now()
|
||||
profile = {
|
||||
"id": profile_id,
|
||||
"name": self._unique_name(_display_name(name, filename)),
|
||||
"filename": f"frpc{suffix}",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
path = self._profile_path(profile)
|
||||
path.parent.mkdir(parents=True, exist_ok=False)
|
||||
if os.name != "nt":
|
||||
path.parent.chmod(0o2750)
|
||||
try:
|
||||
temporary = self._verification_path(path)
|
||||
atomic_write_bytes(temporary, checked_content)
|
||||
self.backend.validate(temporary)
|
||||
os.replace(temporary, path)
|
||||
self._protect(path)
|
||||
self._document["profiles"].append(profile)
|
||||
if self._document["selected_id"] is None:
|
||||
self._document["selected_id"] = profile_id
|
||||
self.backend.select(path)
|
||||
self._save()
|
||||
except BaseException:
|
||||
temporary = self._verification_path(path)
|
||||
temporary.unlink(missing_ok=True)
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
try:
|
||||
path.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
self._document["profiles"] = [item for item in self._document["profiles"] if item["id"] != profile_id]
|
||||
if self._document["selected_id"] == profile_id:
|
||||
self._document["selected_id"] = None
|
||||
raise
|
||||
return deepcopy(profile)
|
||||
|
||||
def update(self, profile_id: str, *, name: str, content: bytes) -> dict[str, str]:
|
||||
self._require_available()
|
||||
checked_content = _checked_content(content)
|
||||
with self._lock:
|
||||
profile, path = self.get(profile_id)
|
||||
old_content = path.read_bytes()
|
||||
temporary = self._verification_path(path)
|
||||
atomic_write_bytes(temporary, checked_content)
|
||||
try:
|
||||
self.backend.validate(temporary)
|
||||
os.replace(temporary, path)
|
||||
self._protect(path)
|
||||
profile["name"] = self._unique_name(_display_name(name, profile["filename"]), excluding=profile_id)
|
||||
profile["updated_at"] = _utc_now()
|
||||
index = next(i for i, item in enumerate(self._document["profiles"]) if item["id"] == profile_id)
|
||||
previous = self._document["profiles"][index]
|
||||
self._document["profiles"][index] = profile
|
||||
try:
|
||||
self._save()
|
||||
if self._document["selected_id"] == profile_id and self.backend.status()["running"]:
|
||||
self.backend.restart()
|
||||
except BaseException:
|
||||
atomic_write_bytes(path, old_content)
|
||||
self._protect(path)
|
||||
self._document["profiles"][index] = previous
|
||||
self._save()
|
||||
if self._document["selected_id"] == profile_id and self.backend.status()["running"]:
|
||||
try:
|
||||
self.backend.restart()
|
||||
except FrpError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return deepcopy(profile)
|
||||
|
||||
def select(self, profile_id: str) -> dict[str, str]:
|
||||
self._require_available()
|
||||
with self._lock:
|
||||
profile, path = self.get(profile_id)
|
||||
previous_id = self._document["selected_id"]
|
||||
previous_path = self.get(previous_id)[1] if previous_id else None
|
||||
was_running = bool(self.backend.status()["running"])
|
||||
try:
|
||||
if was_running:
|
||||
self.backend.stop()
|
||||
self.backend.select(path)
|
||||
self._document["selected_id"] = profile_id
|
||||
self._save()
|
||||
if was_running:
|
||||
self.backend.start()
|
||||
except BaseException:
|
||||
self._document["selected_id"] = previous_id
|
||||
if previous_path is not None:
|
||||
self.backend.select(previous_path)
|
||||
self._save()
|
||||
if was_running:
|
||||
try:
|
||||
self.backend.start()
|
||||
except FrpError:
|
||||
pass
|
||||
raise
|
||||
return profile
|
||||
|
||||
def delete(self, profile_id: str) -> None:
|
||||
self._require_available()
|
||||
with self._lock:
|
||||
profile, path = self.get(profile_id)
|
||||
selected = self._document["selected_id"] == profile_id
|
||||
if selected and self.backend.status()["running"]:
|
||||
raise FrpConflictError("stop FRP before deleting the active config")
|
||||
old_content = path.read_bytes()
|
||||
old_document = deepcopy(self._document)
|
||||
self._document["profiles"] = [item for item in self._document["profiles"] if item["id"] != profile_id]
|
||||
if selected:
|
||||
self._document["selected_id"] = None
|
||||
try:
|
||||
self._save()
|
||||
path.unlink()
|
||||
path.parent.rmdir()
|
||||
except BaseException:
|
||||
self._document = old_document
|
||||
atomic_write_bytes(path, old_content)
|
||||
self._protect(path)
|
||||
self._save()
|
||||
raise
|
||||
|
||||
|
||||
class SystemdFrpBackend:
|
||||
def __init__(
|
||||
self,
|
||||
data_root: Path,
|
||||
*,
|
||||
binary: Path = Path("/usr/local/bin/frpc"),
|
||||
unit: str = "matrix-screen-frpc.service",
|
||||
runner=subprocess.run,
|
||||
) -> None:
|
||||
self.binary = Path(binary)
|
||||
self.unit = unit
|
||||
self.environment_path = Path(data_root) / "frp" / "active.env"
|
||||
self.runner = runner
|
||||
|
||||
def _run(self, command: list[str], *, check: bool = True, timeout: int = 15) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
result = self.runner(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise FrpServiceError("FRP system service command failed") from exc
|
||||
if check and result.returncode != 0:
|
||||
raise FrpServiceError("FRP system service rejected the operation")
|
||||
return result
|
||||
|
||||
def validate(self, path: Path) -> None:
|
||||
if not self.binary.is_file():
|
||||
raise FrpServiceError("frpc is not installed")
|
||||
result = self._run([
|
||||
str(self.binary), "verify", "-c", str(path),
|
||||
"--allow-unsafe=TokenSourceExec",
|
||||
], check=False)
|
||||
if result.returncode != 0:
|
||||
message = (result.stderr or result.stdout or "FRP config validation failed").strip()
|
||||
message = re.sub(r"(?i)(token|password|secret)(\s*[=:]\s*)\S+", r"\1\2[redacted]", message)
|
||||
raise FrpValidationError(message[:1000])
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
version = None
|
||||
if self.binary.is_file():
|
||||
result = self._run([str(self.binary), "--version"], check=False, timeout=5)
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip()[:80]
|
||||
enabled_result = self._run(["/usr/bin/systemctl", "is-enabled", self.unit], check=False, timeout=5)
|
||||
active_result = self._run(["/usr/bin/systemctl", "is-active", self.unit], check=False, timeout=5)
|
||||
enabled = enabled_result.stdout.strip() == "enabled"
|
||||
state = active_result.stdout.strip() or "unknown"
|
||||
return {
|
||||
"installed": self.binary.is_file(),
|
||||
"version": version,
|
||||
"enabled": enabled,
|
||||
"running": state == "active",
|
||||
"state": state,
|
||||
"last_error": None if state in {"active", "inactive"} else "FRP service is not running normally",
|
||||
}
|
||||
|
||||
def select(self, path: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
content = f"FRPC_CONFIG={resolved}\n".encode("utf-8")
|
||||
atomic_write_bytes(self.environment_path, content)
|
||||
if os.name != "nt":
|
||||
self.environment_path.chmod(0o600)
|
||||
|
||||
def start(self) -> None:
|
||||
self._run(["/usr/bin/systemctl", "enable", "--now", self.unit])
|
||||
if not self.status()["running"]:
|
||||
raise FrpServiceError("FRP service did not enter the running state")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._run(["/usr/bin/systemctl", "disable", "--now", self.unit])
|
||||
|
||||
def restart(self) -> None:
|
||||
self._run(["/usr/bin/systemctl", "restart", self.unit])
|
||||
if not self.status()["running"]:
|
||||
raise FrpServiceError("FRP service did not restart successfully")
|
||||
|
||||
|
||||
class MockFrpBackend:
|
||||
def __init__(self) -> None:
|
||||
self.installed = True
|
||||
self.enabled = False
|
||||
self.running = False
|
||||
self.selected: Path | None = None
|
||||
self.fail_validation = False
|
||||
self.fail_start = False
|
||||
self.fail_restart = False
|
||||
|
||||
def validate(self, path: Path) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if self.fail_validation or "INVALID_FRP_CONFIG" in text:
|
||||
raise FrpValidationError("FRP config validation failed")
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"installed": self.installed,
|
||||
"version": "0.71.0-mock",
|
||||
"enabled": self.enabled,
|
||||
"running": self.running,
|
||||
"state": "active" if self.running else "inactive",
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
def select(self, path: Path) -> None:
|
||||
self.selected = Path(path)
|
||||
|
||||
def start(self) -> None:
|
||||
if self.fail_start:
|
||||
raise FrpServiceError("FRP service did not enter the running state")
|
||||
self.enabled = True
|
||||
self.running = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.enabled = False
|
||||
self.running = False
|
||||
|
||||
def restart(self) -> None:
|
||||
if self.fail_restart:
|
||||
raise FrpServiceError("FRP service did not restart successfully")
|
||||
self.running = True
|
||||
|
||||
|
||||
class FrpController:
|
||||
def __init__(self, data_root: Path, backend: FrpBackend) -> None:
|
||||
self.backend = backend
|
||||
self.catalog = FrpCatalog(data_root, backend)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
catalog = self.catalog.list_document()
|
||||
service = self.backend.status()
|
||||
if not catalog["available"]:
|
||||
service = {**service, "enabled": False, "running": False, "last_error": catalog["error"]}
|
||||
return {**catalog, "service": service}
|
||||
|
||||
def content(self, profile_id: str) -> tuple[dict[str, str], bytes]:
|
||||
profile, path = self.catalog.get(profile_id)
|
||||
return profile, path.read_bytes()
|
||||
|
||||
def start(self) -> dict[str, Any]:
|
||||
state = self.catalog.list_document()
|
||||
if not state["selected_id"]:
|
||||
raise FrpConflictError("select an FRP config before starting")
|
||||
_profile, path = self.catalog.get(state["selected_id"])
|
||||
self.backend.validate(path)
|
||||
self.backend.select(path)
|
||||
self.backend.start()
|
||||
return self.status()
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
self.backend.stop()
|
||||
return self.status()
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
|
||||
LIBRARY_ORDER_SCHEMA_VERSION = 1
|
||||
LIBRARY_ITEM_TYPES = frozenset({"template", "animation"})
|
||||
LIBRARY_ORDER_FIELDS = frozenset({"schema_version", "items"})
|
||||
LIBRARY_ITEM_FIELDS = frozenset({"type", "id"})
|
||||
|
||||
|
||||
class LibraryOrderError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class LibraryOrderValidationError(LibraryOrderError):
|
||||
pass
|
||||
|
||||
|
||||
class LibraryOrderConflictError(LibraryOrderError):
|
||||
pass
|
||||
|
||||
|
||||
class LibraryOrderStorageFullError(LibraryOrderError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_library_items(value: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
raise LibraryOrderValidationError("library order items must be an array")
|
||||
normalized: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or set(item) != LIBRARY_ITEM_FIELDS:
|
||||
raise LibraryOrderValidationError("library order item fields are invalid")
|
||||
item_type = item.get("type")
|
||||
item_id = item.get("id")
|
||||
if item_type not in LIBRARY_ITEM_TYPES:
|
||||
raise LibraryOrderValidationError("library order item type is invalid")
|
||||
if not isinstance(item_id, str):
|
||||
raise LibraryOrderValidationError("library order item id must be a UUID")
|
||||
try:
|
||||
normalized_id = str(UUID(item_id))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise LibraryOrderValidationError("library order item id must be a UUID") from exc
|
||||
if normalized_id != item_id:
|
||||
raise LibraryOrderValidationError("library order item id must be a canonical UUID")
|
||||
key = (item_type, normalized_id)
|
||||
if key in seen:
|
||||
raise LibraryOrderValidationError("library order items must be unique")
|
||||
seen.add(key)
|
||||
normalized.append({"type": item_type, "id": normalized_id})
|
||||
return normalized
|
||||
|
||||
|
||||
def library_order_revision(items: list[dict[str, str]]) -> str:
|
||||
canonical = json.dumps(items, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
class LibraryOrderStore:
|
||||
def __init__(self, data_dir: Path, *, forbidden_ids: set[str] | None = None):
|
||||
self.path = Path(data_dir) / "library" / "order.json"
|
||||
self._forbidden_ids = set(forbidden_ids or ())
|
||||
self._lock = threading.RLock()
|
||||
if self.path.exists():
|
||||
self._read_items()
|
||||
|
||||
@staticmethod
|
||||
def _json_bytes(items: list[dict[str, str]]) -> bytes:
|
||||
document = {
|
||||
"schema_version": LIBRARY_ORDER_SCHEMA_VERSION,
|
||||
"items": items,
|
||||
}
|
||||
return (json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
def _read_items(self) -> list[dict[str, str]] | None:
|
||||
if not self.path.exists():
|
||||
return None
|
||||
try:
|
||||
document = json.loads(self.path.read_bytes().decode("utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise LibraryOrderValidationError("library order file is unreadable") from exc
|
||||
if not isinstance(document, dict) or set(document) != LIBRARY_ORDER_FIELDS:
|
||||
raise LibraryOrderValidationError("library order fields do not match the current schema")
|
||||
version = document.get("schema_version")
|
||||
if type(version) is not int or version != LIBRARY_ORDER_SCHEMA_VERSION:
|
||||
raise LibraryOrderValidationError("library order schema version is unsupported")
|
||||
items = normalize_library_items(document.get("items"))
|
||||
if any(item["id"] in self._forbidden_ids for item in items):
|
||||
raise LibraryOrderValidationError("demo items cannot be included in library order")
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _resolve_items(
|
||||
stored: list[dict[str, str]] | None,
|
||||
defaults: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
current = {(item["type"], item["id"]) for item in defaults}
|
||||
resolved = [item for item in (stored or []) if (item["type"], item["id"]) in current]
|
||||
included = {(item["type"], item["id"]) for item in resolved}
|
||||
resolved.extend(item for item in defaults if (item["type"], item["id"]) not in included)
|
||||
return resolved
|
||||
|
||||
def get(self, default_items: Any) -> dict[str, Any]:
|
||||
defaults = normalize_library_items(default_items)
|
||||
with self._lock:
|
||||
items = self._resolve_items(self._read_items(), defaults)
|
||||
return {"items": items, "revision": library_order_revision(items)}
|
||||
|
||||
def update(self, items: Any, default_items: Any, expected_revision: str) -> dict[str, Any]:
|
||||
requested = normalize_library_items(items)
|
||||
if any(item["id"] in self._forbidden_ids for item in requested):
|
||||
raise LibraryOrderValidationError("demo items cannot be included in library order")
|
||||
defaults = normalize_library_items(default_items)
|
||||
with self._lock:
|
||||
current = self._resolve_items(self._read_items(), defaults)
|
||||
if library_order_revision(current) != expected_revision:
|
||||
raise LibraryOrderConflictError("library order was changed by another client")
|
||||
requested_set = {(item["type"], item["id"]) for item in requested}
|
||||
current_set = {(item["type"], item["id"]) for item in defaults}
|
||||
if requested_set != current_set:
|
||||
raise LibraryOrderConflictError("library contents changed; refresh before sorting")
|
||||
try:
|
||||
atomic_write_bytes(self.path, self._json_bytes(requested))
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)}:
|
||||
raise LibraryOrderStorageFullError("not enough disk space to save library order") from exc
|
||||
raise
|
||||
return {"items": requested, "revision": library_order_revision(requested)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
"""Persistent media analysis and conversion jobs."""
|
||||
|
||||
from .manager import MediaImportError, MediaImportManager
|
||||
|
||||
__all__ = ["MediaImportError", "MediaImportManager"]
|
||||
@@ -0,0 +1,402 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
from bisect import bisect_right
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Iterator
|
||||
|
||||
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
|
||||
|
||||
MAX_DURATION_SECONDS = 2 * 60 * 60
|
||||
MAX_DIMENSION = 32768
|
||||
MAX_PIXELS = 160_000_000
|
||||
MAX_MERGED_FRAMES = 30_000
|
||||
SAMPLE_MS = 50
|
||||
OUTPUT_SIZE = 64
|
||||
MIN_CENTER = -31.0
|
||||
MAX_CENTER = 32.0
|
||||
|
||||
|
||||
class MediaConversionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def rgb_scene(rgb: bytes) -> dict[str, Any]:
|
||||
if len(rgb) != 64 * 64 * 3:
|
||||
raise MediaConversionError("converted frame is not 64x64 RGB888")
|
||||
return {
|
||||
"version": 1, "width": 64, "height": 64,
|
||||
"pixelRgb": base64.b64encode(rgb).decode("ascii"), "elements": [],
|
||||
}
|
||||
|
||||
|
||||
def _run(args: list[str], *, timeout: int = 120, stdout: Any = subprocess.PIPE) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args, stdin=subprocess.DEVNULL, stdout=stdout, stderr=subprocess.PIPE,
|
||||
timeout=timeout, check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise MediaConversionError(f"required decoder is missing: {args[0]}") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise MediaConversionError("media decoder made no progress before timeout") from exc
|
||||
if result.returncode:
|
||||
detail = result.stderr.decode("utf-8", "replace")[-1200:].strip()
|
||||
raise MediaConversionError(detail or "media decoder failed")
|
||||
return result
|
||||
|
||||
|
||||
def _hex_color(value: Any, field: str) -> tuple[int, int, int]:
|
||||
if not isinstance(value, str) or len(value) != 7 or value[0] != "#":
|
||||
raise MediaConversionError(f"{field} must be #RRGGBB")
|
||||
try:
|
||||
return tuple(bytes.fromhex(value[1:])) # type: ignore[return-value]
|
||||
except ValueError as exc:
|
||||
raise MediaConversionError(f"{field} must be #RRGGBB") from exc
|
||||
|
||||
|
||||
def validate_settings(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise MediaConversionError("settings must be an object")
|
||||
fit_mode = value.get("fit_mode")
|
||||
if fit_mode not in {"crop", "contain", "stretch"}:
|
||||
raise MediaConversionError("fit_mode must be crop, contain, or stretch")
|
||||
center_x, center_y, zoom = value.get("center_x"), value.get("center_y"), value.get("zoom")
|
||||
for field, number in (("center_x", center_x), ("center_y", center_y)):
|
||||
if (
|
||||
isinstance(number, bool) or not isinstance(number, (int, float))
|
||||
or not math.isfinite(number) or not MIN_CENTER <= number <= MAX_CENTER
|
||||
):
|
||||
raise MediaConversionError(f"{field} must be a finite free-framing coordinate")
|
||||
if isinstance(zoom, bool) or not isinstance(zoom, (int, float)) or not 1 <= zoom <= 16:
|
||||
raise MediaConversionError("zoom must be in 1..16")
|
||||
_hex_color(value.get("transparency_color"), "transparency_color")
|
||||
_hex_color(value.get("padding_color"), "padding_color")
|
||||
return {
|
||||
"fit_mode": fit_mode, "center_x": float(center_x), "center_y": float(center_y),
|
||||
"zoom": float(zoom), "transparency_color": value["transparency_color"].upper(),
|
||||
"padding_color": value["padding_color"].upper(),
|
||||
}
|
||||
|
||||
|
||||
def _orient_and_color(image: Image.Image) -> Image.Image:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
rgba = image.convert("RGBA")
|
||||
profile = image.info.get("icc_profile")
|
||||
if profile:
|
||||
alpha = rgba.getchannel("A")
|
||||
try:
|
||||
source = ImageCms.ImageCmsProfile(bytes(profile))
|
||||
target = ImageCms.createProfile("sRGB")
|
||||
rgb = ImageCms.profileToProfile(rgba.convert("RGB"), source, target, outputMode="RGB")
|
||||
rgba = rgb.convert("RGBA")
|
||||
rgba.putalpha(alpha)
|
||||
except (ImageCms.PyCMSError, OSError, TypeError, ValueError) as exc:
|
||||
raise MediaConversionError("image ICC profile cannot be converted to sRGB") from exc
|
||||
return rgba
|
||||
|
||||
|
||||
def _sample_aspect_ratio(value: Any) -> float:
|
||||
if not isinstance(value, str) or ":" not in value:
|
||||
return 1.0
|
||||
numerator, denominator = value.split(":", 1)
|
||||
try:
|
||||
ratio = float(numerator) / float(denominator)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return 1.0
|
||||
return ratio if math.isfinite(ratio) and ratio > 0 else 1.0
|
||||
|
||||
|
||||
def _metadata_display_dimensions(metadata: dict[str, Any]) -> tuple[float, float]:
|
||||
width, height = float(metadata["width"]), float(metadata["height"])
|
||||
if metadata.get("decoder") == "ffmpeg":
|
||||
width *= _sample_aspect_ratio(metadata.get("sample_aspect_ratio"))
|
||||
return width, height
|
||||
|
||||
|
||||
def _free_crop_geometry(
|
||||
width: float, height: float, settings: dict[str, Any],
|
||||
) -> tuple[int, int, int, int]:
|
||||
scale = OUTPUT_SIZE * settings["zoom"] / max(width, height)
|
||||
out_w = max(1, round(width * scale))
|
||||
out_h = max(1, round(height * scale))
|
||||
left = round(OUTPUT_SIZE / 2 - settings["center_x"] * out_w)
|
||||
top = round(OUTPUT_SIZE / 2 - settings["center_y"] * out_h)
|
||||
if left >= OUTPUT_SIZE or left + out_w <= 0 or top >= OUTPUT_SIZE or top + out_h <= 0:
|
||||
raise MediaConversionError("free-framing position must leave at least one output pixel visible")
|
||||
return out_w, out_h, left, top
|
||||
|
||||
|
||||
def validate_settings_for_metadata(value: Any, metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
settings = validate_settings(value)
|
||||
if settings["fit_mode"] == "crop" and metadata is not None:
|
||||
_free_crop_geometry(*_metadata_display_dimensions(metadata), settings)
|
||||
return settings
|
||||
|
||||
|
||||
def _composite_free_crop(
|
||||
image: Image.Image, settings: dict[str, Any], geometry: tuple[int, int, int, int],
|
||||
) -> bytes:
|
||||
out_w, out_h, left, top = geometry
|
||||
transparency = _hex_color(settings["transparency_color"], "transparency_color")
|
||||
padding = _hex_color(settings["padding_color"], "padding_color")
|
||||
resized = image if image.size == (out_w, out_h) else image.resize((out_w, out_h), Image.Resampling.LANCZOS)
|
||||
content = Image.alpha_composite(
|
||||
Image.new("RGBA", resized.size, (*transparency, 255)), resized,
|
||||
).convert("RGB")
|
||||
output = Image.new("RGB", (OUTPUT_SIZE, OUTPUT_SIZE), padding)
|
||||
output.paste(content, (left, top))
|
||||
return output.tobytes()
|
||||
|
||||
|
||||
def transform_frame(image: Image.Image, settings: dict[str, Any]) -> bytes:
|
||||
settings = validate_settings(settings)
|
||||
image = _orient_and_color(image)
|
||||
width, height = image.size
|
||||
if width < 1 or height < 1 or width > MAX_DIMENSION or height > MAX_DIMENSION or width * height > MAX_PIXELS:
|
||||
raise MediaConversionError("media pixel dimensions exceed the safety limit")
|
||||
transparency = _hex_color(settings["transparency_color"], "transparency_color")
|
||||
padding = _hex_color(settings["padding_color"], "padding_color")
|
||||
mode = settings["fit_mode"]
|
||||
if mode == "crop":
|
||||
return _composite_free_crop(image, settings, _free_crop_geometry(width, height, settings))
|
||||
if mode == "stretch":
|
||||
image = image.resize((64, 64), Image.Resampling.LANCZOS)
|
||||
background = Image.new("RGBA", (64, 64), (*transparency, 255))
|
||||
return Image.alpha_composite(background, image).convert("RGB").tobytes()
|
||||
scale = min(64 / width, 64 / height)
|
||||
size = (max(1, round(width * scale)), max(1, round(height * scale)))
|
||||
image = image.resize(size, Image.Resampling.LANCZOS)
|
||||
content = Image.alpha_composite(Image.new("RGBA", size, (*transparency, 255)), image).convert("RGB")
|
||||
output = Image.new("RGB", (64, 64), padding)
|
||||
output.paste(content, ((64 - size[0]) // 2, (64 - size[1]) // 2))
|
||||
return output.tobytes()
|
||||
|
||||
|
||||
def _check_dimensions(width: int, height: int) -> None:
|
||||
if width < 1 or height < 1 or width > MAX_DIMENSION or height > MAX_DIMENSION or width * height > MAX_PIXELS:
|
||||
raise MediaConversionError("media pixel dimensions exceed the safety limit")
|
||||
|
||||
|
||||
def _pillow_probe(source: Path) -> tuple[dict[str, Any], list[Image.Image]]:
|
||||
with Image.open(source) as image:
|
||||
width, height = image.size
|
||||
_check_dimensions(width, height)
|
||||
count = int(getattr(image, "n_frames", 1))
|
||||
durations, previews = [], []
|
||||
preview_indexes = {
|
||||
min(count - 1, round(i * (count - 1) / min(4, count - 1)))
|
||||
for i in range(min(5, count))
|
||||
} if count > 1 else {0}
|
||||
for index in range(count):
|
||||
image.seek(index)
|
||||
duration = image.info.get("duration", 100 if count > 1 else 0)
|
||||
if isinstance(duration, bool) or not isinstance(duration, (int, float)) or duration <= 0:
|
||||
duration = 100
|
||||
durations.append(int(round(duration)))
|
||||
if index in preview_indexes:
|
||||
previews.append(_orient_and_color(image.copy()))
|
||||
total_ms = sum(durations) if count > 1 else 0
|
||||
if total_ms > MAX_DURATION_SECONDS * 1000:
|
||||
raise MediaConversionError("media duration exceeds 2 hours")
|
||||
return {
|
||||
"decoder": "pillow", "format": str(image.format or "image").lower(),
|
||||
"width": previews[0].width, "height": previews[0].height, "duration_ms": total_ms,
|
||||
"source_frame_count": count, "dynamic": count > 1,
|
||||
"has_alpha": "A" in image.getbands() or "transparency" in image.info,
|
||||
}, previews
|
||||
|
||||
|
||||
def _ffprobe(source: Path) -> dict[str, Any]:
|
||||
result = _run([
|
||||
"ffprobe", "-v", "error", "-protocol_whitelist", "file,pipe",
|
||||
"-show_streams", "-show_format", "-of", "json", str(source),
|
||||
])
|
||||
try:
|
||||
value = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise MediaConversionError("ffprobe returned invalid metadata") from exc
|
||||
streams = [
|
||||
stream for stream in value.get("streams", [])
|
||||
if stream.get("codec_type") == "video"
|
||||
and not int((stream.get("disposition") or {}).get("attached_pic", 0))
|
||||
]
|
||||
if not streams:
|
||||
raise MediaConversionError("input does not contain a supported video stream")
|
||||
stream = streams[0]
|
||||
width, height = int(stream.get("width") or 0), int(stream.get("height") or 0)
|
||||
_check_dimensions(width, height)
|
||||
duration = stream.get("duration") or (value.get("format") or {}).get("duration")
|
||||
try:
|
||||
duration_ms = round(float(duration) * 1000)
|
||||
except (TypeError, ValueError):
|
||||
raise MediaConversionError("video duration is unavailable")
|
||||
if duration_ms <= 0 or duration_ms > MAX_DURATION_SECONDS * 1000:
|
||||
raise MediaConversionError("media duration must be in 0..2 hours")
|
||||
transfer = str(stream.get("color_transfer") or "").lower()
|
||||
hdr = transfer in {"smpte2084", "arib-std-b67"}
|
||||
if hdr:
|
||||
filters = _run(["ffmpeg", "-hide_banner", "-filters"]).stdout.decode("utf-8", "replace")
|
||||
if " zscale " not in filters or " tonemap " not in filters:
|
||||
raise MediaConversionError("HDR input requires FFmpeg zscale and tonemap filters")
|
||||
return {
|
||||
"decoder": "ffmpeg", "format": str((value.get("format") or {}).get("format_name") or "video"),
|
||||
"width": width, "height": height, "duration_ms": duration_ms,
|
||||
"source_frame_count": int(stream.get("nb_frames") or 0), "dynamic": True,
|
||||
"has_alpha": "a" in str(stream.get("pix_fmt") or ""),
|
||||
"stream_index": int(stream.get("index") or 0), "hdr": hdr,
|
||||
"sample_aspect_ratio": str(stream.get("sample_aspect_ratio") or "1:1"),
|
||||
}
|
||||
|
||||
|
||||
def _heif_probe(source: Path) -> tuple[dict[str, Any], list[Image.Image]]:
|
||||
decoded = source.parent / "decoded-heif.png"
|
||||
_run(["heif-convert", str(source), str(decoded)], timeout=120)
|
||||
metadata, images = _pillow_probe(decoded)
|
||||
metadata.update({"decoder": "heif", "format": "heif", "dynamic": False})
|
||||
return metadata, images
|
||||
|
||||
|
||||
def analyze(source: Path, preview_dir: Path) -> dict[str, Any]:
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
metadata, images = _pillow_probe(source)
|
||||
for index, image in enumerate(images):
|
||||
image.thumbnail((512, 512), Image.Resampling.LANCZOS)
|
||||
image.save(preview_dir / f"{index}.png", "PNG")
|
||||
except (UnidentifiedImageError, OSError):
|
||||
try:
|
||||
metadata, images = _heif_probe(source)
|
||||
for index, image in enumerate(images):
|
||||
image.thumbnail((512, 512), Image.Resampling.LANCZOS)
|
||||
image.save(preview_dir / f"{index}.png", "PNG")
|
||||
except MediaConversionError:
|
||||
metadata = _ffprobe(source)
|
||||
count = min(5, max(1, math.ceil(metadata["duration_ms"] / 1000)))
|
||||
times = [metadata["duration_ms"] * i / max(1, count - 1) / 1000 for i in range(count)]
|
||||
for index, when in enumerate(times):
|
||||
_run([
|
||||
"ffmpeg", "-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe",
|
||||
"-ss", f"{when:.3f}", "-i", str(source), "-map", f"0:{metadata['stream_index']}",
|
||||
"-frames:v", "1", "-vf",
|
||||
"scale=trunc(iw*sar+0.5):ih:flags=lanczos,setsar=1,"
|
||||
"scale=512:512:force_original_aspect_ratio=decrease:flags=lanczos",
|
||||
"-y", str(preview_dir / f"{index}.png"),
|
||||
], timeout=120)
|
||||
metadata["preview_count"] = len(list(preview_dir.glob("*.png")))
|
||||
return metadata
|
||||
|
||||
|
||||
def _pillow_frames(source: Path, settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
|
||||
with Image.open(source) as image:
|
||||
count = int(getattr(image, "n_frames", 1))
|
||||
if count == 1:
|
||||
yield transform_frame(image.copy(), settings), 0
|
||||
return
|
||||
frames, ends, elapsed = [], [], 0
|
||||
for index in range(count):
|
||||
image.seek(index)
|
||||
duration = image.info.get("duration", 100)
|
||||
if not isinstance(duration, (int, float)) or isinstance(duration, bool) or duration <= 0:
|
||||
duration = 100
|
||||
elapsed += int(round(duration))
|
||||
ends.append(elapsed)
|
||||
frames.append(image.copy())
|
||||
if elapsed > MAX_DURATION_SECONDS * 1000:
|
||||
raise MediaConversionError("media duration exceeds 2 hours")
|
||||
sample_times = range(0, elapsed, SAMPLE_MS)
|
||||
for time_ms in sample_times:
|
||||
index = min(len(frames) - 1, bisect_right(ends, time_ms))
|
||||
duration = min(SAMPLE_MS, elapsed - time_ms)
|
||||
yield transform_frame(frames[index], settings), duration
|
||||
|
||||
|
||||
def _ffmpeg_frames(source: Path, metadata: dict[str, Any], settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
|
||||
width, height = _metadata_display_dimensions(metadata)
|
||||
mode = settings["fit_mode"]
|
||||
if mode == "contain":
|
||||
scale = min(64 / width, 64 / height)
|
||||
out_w, out_h = max(1, round(width * scale)), max(1, round(height * scale))
|
||||
geometry = f"scale={out_w}:{out_h}:flags=lanczos"
|
||||
elif mode == "stretch":
|
||||
out_w = out_h = 64
|
||||
geometry = "scale=64:64:flags=lanczos"
|
||||
else:
|
||||
out_w, out_h, left, top = _free_crop_geometry(width, height, settings)
|
||||
geometry = f"scale={out_w}:{out_h}:flags=lanczos"
|
||||
color_filter = ""
|
||||
if metadata.get("hdr"):
|
||||
color_filter = "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709,"
|
||||
filters = f"fps=20,{color_filter}{geometry},setsar=1,format=rgba"
|
||||
try:
|
||||
process = subprocess.Popen([
|
||||
"ffmpeg", "-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe",
|
||||
"-i", str(source), "-map", f"0:{metadata['stream_index']}", "-an", "-sn", "-dn",
|
||||
"-vf", filters, "-f", "rawvideo", "-pix_fmt", "rgba", "pipe:1",
|
||||
], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except FileNotFoundError as exc:
|
||||
raise MediaConversionError("required decoder is missing: ffmpeg") from exc
|
||||
assert process.stdout is not None
|
||||
frame_bytes = out_w * out_h * 4
|
||||
count = 0
|
||||
while True:
|
||||
raw = process.stdout.read(frame_bytes)
|
||||
if not raw:
|
||||
break
|
||||
if len(raw) != frame_bytes:
|
||||
process.kill()
|
||||
raise MediaConversionError("FFmpeg returned a truncated video frame")
|
||||
rgba = Image.frombytes("RGBA", (out_w, out_h), raw)
|
||||
if mode == "contain":
|
||||
transparency = _hex_color(settings["transparency_color"], "transparency_color")
|
||||
padding = _hex_color(settings["padding_color"], "padding_color")
|
||||
content = Image.alpha_composite(Image.new("RGBA", rgba.size, (*transparency, 255)), rgba).convert("RGB")
|
||||
output = Image.new("RGB", (64, 64), padding)
|
||||
output.paste(content, ((64 - out_w) // 2, (64 - out_h) // 2))
|
||||
rgb = output.tobytes()
|
||||
elif mode == "crop":
|
||||
rgb = _composite_free_crop(rgba, settings, (out_w, out_h, left, top))
|
||||
else:
|
||||
background = Image.new("RGBA", (64, 64), (*_hex_color(settings["transparency_color"], "transparency_color"), 255))
|
||||
rgb = Image.alpha_composite(background, rgba).convert("RGB").tobytes()
|
||||
count += 1
|
||||
yield rgb, SAMPLE_MS
|
||||
stderr = process.stderr.read().decode("utf-8", "replace") if process.stderr else ""
|
||||
if process.wait(timeout=30):
|
||||
raise MediaConversionError(stderr[-1200:].strip() or "FFmpeg conversion failed")
|
||||
if count == 0:
|
||||
raise MediaConversionError("video decoder produced no frames")
|
||||
|
||||
|
||||
def converted_frames(source: Path, metadata: dict[str, Any], settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
|
||||
settings = validate_settings_for_metadata(settings, metadata)
|
||||
raw_frames: Iterable[tuple[bytes, int]]
|
||||
if metadata["decoder"] in {"pillow", "heif"}:
|
||||
actual_source = source if metadata["decoder"] == "pillow" else source.parent / "decoded-heif.png"
|
||||
raw_frames = _pillow_frames(actual_source, settings)
|
||||
else:
|
||||
raw_frames = _ffmpeg_frames(source, metadata, settings)
|
||||
previous: bytes | None = None
|
||||
duration = 0
|
||||
merged = 0
|
||||
for rgb, frame_duration in raw_frames:
|
||||
if previous is None:
|
||||
previous, duration = rgb, frame_duration
|
||||
elif rgb == previous:
|
||||
duration += frame_duration
|
||||
else:
|
||||
merged += 1
|
||||
if merged > MAX_MERGED_FRAMES:
|
||||
raise MediaConversionError("converted animation exceeds 30,000 merged frames")
|
||||
yield previous, max(SAMPLE_MS, duration)
|
||||
previous, duration = rgb, frame_duration
|
||||
if previous is None:
|
||||
raise MediaConversionError("decoder produced no frames")
|
||||
merged += 1
|
||||
if merged > MAX_MERGED_FRAMES:
|
||||
raise MediaConversionError("converted animation exceeds 30,000 merged frames")
|
||||
yield previous, max(SAMPLE_MS, duration) if metadata["dynamic"] else 0
|
||||
@@ -0,0 +1,524 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.animations.store import AnimationStore
|
||||
from app.persistence import atomic_write_bytes
|
||||
from app.templates.store import TemplateConflictError, TemplateStore
|
||||
|
||||
from .converter import (
|
||||
MAX_MERGED_FRAMES, MediaConversionError, analyze, converted_frames,
|
||||
rgb_scene, validate_settings_for_metadata,
|
||||
)
|
||||
|
||||
JOB_SCHEMA_VERSION = 1
|
||||
MAX_SOURCE_BYTES = 4 * 1024 * 1024 * 1024
|
||||
MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
RETENTION_HOURS = 24
|
||||
STATES = {
|
||||
"uploading", "analyzing", "awaiting_settings", "queued", "converting", "failed",
|
||||
}
|
||||
JOB_FIELDS = {
|
||||
"id", "filename", "name", "source_size", "state", "action", "created_at",
|
||||
"updated_at", "expires_at", "progress", "metadata", "settings",
|
||||
"preview_count", "error", "queue_sequence",
|
||||
}
|
||||
|
||||
|
||||
class MediaImportError(ValueError):
|
||||
def __init__(self, message: str, *, status_code: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _job_id(value: Any) -> str:
|
||||
try:
|
||||
return str(UUID(str(value)))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise MediaImportError("media import job not found", status_code=404) from exc
|
||||
|
||||
|
||||
def _output_name(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise MediaImportError("output name must be a string")
|
||||
checked = value.strip()
|
||||
if not checked or len(checked) > 80:
|
||||
raise MediaImportError("output name must contain 1..80 characters")
|
||||
return checked
|
||||
|
||||
|
||||
def _default_output_name(filename: str) -> str:
|
||||
leaf = filename.replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||||
stem = leaf.rsplit(".", 1)[0].strip() if "." in leaf else leaf
|
||||
return (stem[:80].strip() or "未命名媒体")
|
||||
|
||||
|
||||
class MediaImportManager:
|
||||
"""Persistent single-worker media queue.
|
||||
|
||||
The record is authoritative, so analyzing/queued/converting work can be
|
||||
restored after either service or device restart.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Path, templates: TemplateStore, animations: AnimationStore) -> None:
|
||||
self.root = Path(data_dir) / "media-import" / "jobs"
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.templates = templates
|
||||
self.animations = animations
|
||||
self._lock = threading.RLock()
|
||||
self._queue: queue.PriorityQueue[tuple[int, str, str]] = queue.PriorityQueue()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self._canceled: set[str] = set()
|
||||
self._external_worker = os.environ.get("MATRIX_MEDIA_WORKER_MODE") == "systemd"
|
||||
self._sequence = 0
|
||||
with self._lock:
|
||||
for child in self.root.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
record = self._read(child.name)
|
||||
self._sequence = max(self._sequence, record["queue_sequence"])
|
||||
if record["state"] == "uploading":
|
||||
record.update({
|
||||
"state": "failed", "action": "analyze",
|
||||
"error": "upload was interrupted", "progress": 0,
|
||||
"updated_at": _iso(_now()),
|
||||
"expires_at": _iso(_now() + timedelta(hours=RETENTION_HOURS)),
|
||||
})
|
||||
self._write(record)
|
||||
elif record["state"] in {"analyzing", "queued", "converting"}:
|
||||
action = "analyze" if record["action"] == "analyze" else "convert"
|
||||
record["state"] = "analyzing" if action == "analyze" else "queued"
|
||||
record["progress"] = 0
|
||||
self._write(record)
|
||||
self._queue.put((record["queue_sequence"], record["id"], action))
|
||||
|
||||
def _dir(self, job_id: str) -> Path:
|
||||
return self.root / job_id
|
||||
|
||||
def _path(self, job_id: str) -> Path:
|
||||
return self._dir(job_id) / "job.json"
|
||||
|
||||
def source_path(self, job_id: Any) -> Path:
|
||||
return self._dir(_job_id(job_id)) / "source.bin"
|
||||
|
||||
def preview_path(self, job_id: Any, index: int) -> Path:
|
||||
normalized = _job_id(job_id)
|
||||
record = self._read(normalized)
|
||||
if index < 0 or index >= record["preview_count"]:
|
||||
raise MediaImportError("media preview not found", status_code=404)
|
||||
path = self._dir(normalized) / "previews" / f"{index}.png"
|
||||
if not path.is_file():
|
||||
raise MediaImportError("media preview not found", status_code=404)
|
||||
return path
|
||||
|
||||
def _write(self, record: dict[str, Any]) -> None:
|
||||
payload = json.dumps(
|
||||
{"schema_version": JOB_SCHEMA_VERSION, **record},
|
||||
ensure_ascii=False, sort_keys=True, indent=2,
|
||||
).encode("utf-8") + b"\n"
|
||||
atomic_write_bytes(self._path(record["id"]), payload)
|
||||
|
||||
def _validate(self, value: Any, expected_id: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise MediaImportError("media job record is invalid", status_code=500)
|
||||
document = dict(value)
|
||||
if document.pop("schema_version", None) != 1 or set(document) != JOB_FIELDS:
|
||||
raise MediaImportError("media job schema is unsupported", status_code=500)
|
||||
if document.get("id") != expected_id or document.get("state") not in STATES:
|
||||
raise MediaImportError("media job record is invalid", status_code=500)
|
||||
if document.get("action") not in {"analyze", "convert"}:
|
||||
raise MediaImportError("media job action is invalid", status_code=500)
|
||||
if not isinstance(document.get("settings"), dict):
|
||||
raise MediaImportError("media job settings are invalid", status_code=500)
|
||||
return document
|
||||
|
||||
def _read(self, job_id: Any) -> dict[str, Any]:
|
||||
normalized = _job_id(job_id)
|
||||
path = self._path(normalized)
|
||||
if not path.is_file():
|
||||
raise MediaImportError("media import job not found", status_code=404)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise MediaImportError("media job record is unreadable", status_code=500) from exc
|
||||
return self._validate(value, normalized)
|
||||
|
||||
def _public(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
queued = []
|
||||
for _, queued_id, action in list(self._queue.queue):
|
||||
if action == "convert":
|
||||
queued.append(queued_id)
|
||||
queue_position = queued.index(record["id"]) + 1 if record["id"] in queued else None
|
||||
return {
|
||||
key: record[key] for key in (
|
||||
"id", "filename", "name", "source_size", "state", "created_at",
|
||||
"updated_at", "expires_at", "progress", "metadata", "settings",
|
||||
"error",
|
||||
)
|
||||
} | {
|
||||
"queue_position": queue_position,
|
||||
"previews": [
|
||||
f"/api/media-imports/{record['id']}/previews/{index}"
|
||||
for index in range(record["preview_count"])
|
||||
],
|
||||
}
|
||||
|
||||
def _reconcile_external(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self._external_worker or record["state"] not in {"analyzing", "queued", "converting"}:
|
||||
return record
|
||||
result = subprocess.run(
|
||||
["systemctl", "show", f"matrix-screen-converter@{record['id']}.service",
|
||||
"-p", "ActiveState", "-p", "Result", "--value"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
check=False, timeout=5,
|
||||
)
|
||||
values = set(result.stdout.decode("utf-8", "replace").split())
|
||||
if result.returncode == 0 and "failed" in values:
|
||||
now = _now()
|
||||
record.update({
|
||||
"state": "failed", "progress": 0,
|
||||
"error": "isolated converter process exited unexpectedly",
|
||||
"updated_at": _iso(now),
|
||||
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
|
||||
})
|
||||
self._write(record)
|
||||
return record
|
||||
|
||||
def _ensure_space(self, incoming: int = 0) -> None:
|
||||
free = shutil.disk_usage(self.root).free
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise MediaImportError("device must retain at least 2 GiB free space", status_code=507)
|
||||
|
||||
def begin_upload(self, filename: Any, name: Any | None, size: int) -> tuple[dict[str, Any], Path]:
|
||||
if not isinstance(filename, str) or not filename.strip() or len(filename) > 255:
|
||||
raise MediaImportError("filename must contain 1..255 characters")
|
||||
checked_name = _default_output_name(filename) if name is None else _output_name(name)
|
||||
if isinstance(size, bool) or not isinstance(size, int) or size <= 0:
|
||||
raise MediaImportError("Content-Length must be a positive integer", status_code=411)
|
||||
if size > MAX_SOURCE_BYTES:
|
||||
raise MediaImportError("source file exceeds 4 GiB", status_code=413)
|
||||
with self._lock:
|
||||
self._ensure_space(size)
|
||||
job_id, now = str(uuid4()), _now()
|
||||
self._sequence += 1
|
||||
directory = self._dir(job_id)
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
record = {
|
||||
"id": job_id, "filename": filename.strip(), "name": checked_name,
|
||||
"source_size": size, "state": "uploading", "action": "analyze",
|
||||
"created_at": _iso(now), "updated_at": _iso(now),
|
||||
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
|
||||
"progress": 0, "metadata": None,
|
||||
"settings": {
|
||||
"fit_mode": "crop", "center_x": 0.5, "center_y": 0.5,
|
||||
"zoom": 1.0, "transparency_color": "#000000",
|
||||
"padding_color": "#000000",
|
||||
},
|
||||
"preview_count": 0, "error": None, "queue_sequence": self._sequence,
|
||||
}
|
||||
self._write(record)
|
||||
return self._public(record), directory / "source.bin"
|
||||
|
||||
def finish_upload(self, job_id: Any, received: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(job_id)
|
||||
if record["state"] != "uploading":
|
||||
raise MediaImportError("media upload is not active", status_code=409)
|
||||
if received != record["source_size"]:
|
||||
shutil.rmtree(self._dir(record["id"]), ignore_errors=True)
|
||||
raise MediaImportError("uploaded byte count does not match Content-Length")
|
||||
record.update({
|
||||
"state": "analyzing", "action": "analyze", "progress": 0,
|
||||
"updated_at": _iso(_now()), "error": None,
|
||||
})
|
||||
self._write(record)
|
||||
self._schedule(record["id"], "analyze", record["queue_sequence"])
|
||||
return self._public(record)
|
||||
|
||||
def fail_upload(self, job_id: Any) -> None:
|
||||
with self._lock:
|
||||
shutil.rmtree(self._dir(_job_id(job_id)), ignore_errors=True)
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._external_worker:
|
||||
pending = []
|
||||
while not self._queue.empty():
|
||||
pending.append(self._queue.get_nowait())
|
||||
for sequence, job_id, action in pending:
|
||||
self._schedule(job_id, action, sequence)
|
||||
return
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="media-import-queue", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._external_worker:
|
||||
return
|
||||
self._stop.set()
|
||||
self._queue.put((2**63 - 1, "", "stop"))
|
||||
if self._thread:
|
||||
self._thread.join(timeout=10)
|
||||
|
||||
def _schedule(self, job_id: str, action: str, sequence: int) -> None:
|
||||
if not self._external_worker:
|
||||
self._queue.put((sequence, job_id, action))
|
||||
return
|
||||
result = subprocess.run(
|
||||
["systemctl", "start", "--no-block", f"matrix-screen-converter@{job_id}.service"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
check=False, timeout=15,
|
||||
)
|
||||
if result.returncode:
|
||||
raise MediaImportError(
|
||||
"cannot start isolated converter: "
|
||||
+ result.stderr.decode("utf-8", "replace")[-800:].strip(),
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
_, job_id, action = self._queue.get()
|
||||
if not job_id or self._stop.is_set():
|
||||
return
|
||||
with self._lock:
|
||||
if job_id in self._canceled or not self._path(job_id).is_file():
|
||||
continue
|
||||
try:
|
||||
if action == "analyze":
|
||||
self._analyze(job_id)
|
||||
else:
|
||||
self._convert(job_id)
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
if job_id in self._canceled or not self._path(job_id).is_file():
|
||||
continue
|
||||
record = self._read(job_id)
|
||||
now = _now()
|
||||
record.update({
|
||||
"state": "failed", "progress": 0,
|
||||
"error": str(exc)[:2000], "updated_at": _iso(now),
|
||||
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
|
||||
})
|
||||
self._write(record)
|
||||
|
||||
def run_job(self, job_id: Any) -> None:
|
||||
"""Run one persisted action; used by the isolated systemd instance."""
|
||||
normalized = _job_id(job_id)
|
||||
record = self._read(normalized)
|
||||
try:
|
||||
if record["action"] == "analyze":
|
||||
self._analyze(normalized)
|
||||
else:
|
||||
self._convert(normalized)
|
||||
except Exception as exc:
|
||||
if not self._path(normalized).is_file():
|
||||
return
|
||||
record = self._read(normalized)
|
||||
now = _now()
|
||||
record.update({
|
||||
"state": "failed", "progress": 0, "error": str(exc)[:2000],
|
||||
"updated_at": _iso(now),
|
||||
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
|
||||
})
|
||||
self._write(record)
|
||||
raise
|
||||
|
||||
def _analyze(self, job_id: str) -> None:
|
||||
metadata = analyze(self.source_path(job_id), self._dir(job_id) / "previews")
|
||||
with self._lock:
|
||||
if job_id in self._canceled:
|
||||
return
|
||||
record = self._read(job_id)
|
||||
record.update({
|
||||
"state": "awaiting_settings", "metadata": metadata,
|
||||
"preview_count": metadata["preview_count"], "progress": 100,
|
||||
"updated_at": _iso(_now()), "error": None,
|
||||
})
|
||||
self._write(record)
|
||||
|
||||
def _library_name_conflict(self, name: str) -> bool:
|
||||
folded = name.casefold()
|
||||
if any(item["name"].casefold() == folded for item in self.templates.list()["templates"]):
|
||||
return True
|
||||
return not self.animations.name_available(name)
|
||||
|
||||
def _reserved_name_conflict(self, name: str, *, exclude_job_id: str) -> bool:
|
||||
folded = name.casefold()
|
||||
for child in self.root.iterdir():
|
||||
if not child.is_dir() or child.name == exclude_job_id:
|
||||
continue
|
||||
other = self._read(child.name)
|
||||
if other["state"] in {"queued", "converting"} and other["name"].casefold() == folded:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _name_conflict(self, record: dict[str, Any], *, include_reservations: bool) -> bool:
|
||||
if self._library_name_conflict(record["name"]):
|
||||
return True
|
||||
return include_reservations and self._reserved_name_conflict(
|
||||
record["name"], exclude_job_id=record["id"],
|
||||
)
|
||||
|
||||
def _convert(self, job_id: str) -> None:
|
||||
with self._lock:
|
||||
self._ensure_space()
|
||||
record = self._read(job_id)
|
||||
if self._name_conflict(record, include_reservations=False):
|
||||
raise TemplateConflictError("output name already exists")
|
||||
record.update({
|
||||
"state": "converting", "action": "convert", "progress": 1,
|
||||
"updated_at": _iso(_now()), "error": None,
|
||||
})
|
||||
self._write(record)
|
||||
|
||||
frames = converted_frames(
|
||||
self.source_path(job_id), record["metadata"], record["settings"],
|
||||
)
|
||||
if record["metadata"]["dynamic"]:
|
||||
def scenes():
|
||||
for index, (rgb, duration) in enumerate(frames):
|
||||
with self._lock:
|
||||
if job_id in self._canceled:
|
||||
raise MediaConversionError("media conversion was canceled")
|
||||
current = self._read(job_id)
|
||||
current["progress"] = min(99, 1 + index * 98 // MAX_MERGED_FRAMES)
|
||||
self._write(current)
|
||||
yield {
|
||||
"scene": rgb_scene(rgb), "duration_ms": duration,
|
||||
"name": None,
|
||||
}
|
||||
self.animations.create_from_frames(record["name"], scenes())
|
||||
else:
|
||||
rgb, _ = next(iter(frames))
|
||||
with self._lock:
|
||||
if job_id in self._canceled:
|
||||
raise MediaConversionError("media conversion was canceled")
|
||||
self.templates.create(record["name"], rgb_scene(rgb))
|
||||
with self._lock:
|
||||
if job_id not in self._canceled:
|
||||
shutil.rmtree(self._dir(job_id), ignore_errors=True)
|
||||
|
||||
def list(self) -> dict[str, Any]:
|
||||
self.cleanup_expired()
|
||||
with self._lock:
|
||||
records = sorted(
|
||||
(self._reconcile_external(self._read(child.name)) for child in self.root.iterdir() if child.is_dir()),
|
||||
key=lambda record: record["created_at"],
|
||||
)
|
||||
return {"jobs": [self._public(record) for record in records]}
|
||||
|
||||
def get(self, job_id: Any) -> dict[str, Any]:
|
||||
self.cleanup_expired()
|
||||
with self._lock:
|
||||
return self._public(self._reconcile_external(self._read(job_id)))
|
||||
|
||||
def update_settings(self, job_id: Any, value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise MediaImportError("settings must be an object")
|
||||
allowed = {
|
||||
"name", "fit_mode", "center_x", "center_y", "zoom",
|
||||
"transparency_color", "padding_color",
|
||||
}
|
||||
if set(value) - allowed:
|
||||
raise MediaImportError("unknown media settings field")
|
||||
with self._lock:
|
||||
record = self._read(job_id)
|
||||
if record["state"] not in {"awaiting_settings", "failed"}:
|
||||
raise MediaImportError("media settings cannot be changed in the current state", status_code=409)
|
||||
settings = {**record["settings"], **{k: v for k, v in value.items() if k != "name"}}
|
||||
try:
|
||||
record["settings"] = validate_settings_for_metadata(settings, record["metadata"])
|
||||
except MediaConversionError as exc:
|
||||
raise MediaImportError(str(exc)) from exc
|
||||
if "name" in value:
|
||||
proposed_name = _output_name(value["name"])
|
||||
proposed = {**record, "name": proposed_name}
|
||||
if self._name_conflict(proposed, include_reservations=True):
|
||||
raise MediaImportError("output name already exists", status_code=409)
|
||||
record["name"] = proposed_name
|
||||
record.update({"updated_at": _iso(_now()), "error": None})
|
||||
if record["state"] == "failed" and record["metadata"] is not None:
|
||||
record["state"] = "awaiting_settings"
|
||||
self._write(record)
|
||||
return self._public(record)
|
||||
|
||||
def enqueue_conversion(self, job_id: Any) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(job_id)
|
||||
if record["state"] != "awaiting_settings":
|
||||
raise MediaImportError("media job is not ready for conversion", status_code=409)
|
||||
if self._name_conflict(record, include_reservations=True):
|
||||
raise MediaImportError("output name already exists", status_code=409)
|
||||
self._sequence += 1
|
||||
record.update({
|
||||
"state": "queued", "action": "convert", "progress": 0,
|
||||
"queue_sequence": self._sequence, "updated_at": _iso(_now()),
|
||||
"error": None,
|
||||
})
|
||||
self._write(record)
|
||||
self._schedule(record["id"], "convert", record["queue_sequence"])
|
||||
return self._public(record)
|
||||
|
||||
def retry(self, job_id: Any) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(job_id)
|
||||
if record["state"] != "failed":
|
||||
raise MediaImportError("only failed jobs can be retried", status_code=409)
|
||||
action = record["action"]
|
||||
if action == "convert" and record["metadata"] is not None and self._name_conflict(
|
||||
record, include_reservations=True,
|
||||
):
|
||||
raise MediaImportError("output name already exists", status_code=409)
|
||||
self._sequence += 1
|
||||
record.update({
|
||||
"state": "analyzing" if action == "analyze" else "queued",
|
||||
"queue_sequence": self._sequence, "progress": 0,
|
||||
"updated_at": _iso(_now()), "error": None,
|
||||
})
|
||||
self._write(record)
|
||||
self._schedule(record["id"], action, record["queue_sequence"])
|
||||
return self._public(record)
|
||||
|
||||
def cancel(self, job_id: Any) -> None:
|
||||
normalized = _job_id(job_id)
|
||||
with self._lock:
|
||||
self._read(normalized)
|
||||
self._canceled.add(normalized)
|
||||
shutil.rmtree(self._dir(normalized), ignore_errors=True)
|
||||
|
||||
def cleanup_expired(self) -> None:
|
||||
with self._lock:
|
||||
now = _now()
|
||||
for child in list(self.root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
record = self._read(child.name)
|
||||
if record["state"] in {"failed", "awaiting_settings"}:
|
||||
try:
|
||||
expires = datetime.fromisoformat(record["expires_at"])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if expires <= now:
|
||||
self._canceled.add(record["id"])
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from app.animations.store import AnimationStore
|
||||
from app.config.store import ConfigStore
|
||||
from app.media.manager import MediaImportManager
|
||||
from app.templates.store import TemplateStore
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run one isolated media import action")
|
||||
parser.add_argument("job_id")
|
||||
args = parser.parse_args()
|
||||
config = ConfigStore()
|
||||
templates = TemplateStore(config.data_dir)
|
||||
animations = AnimationStore(config.data_dir, cleanup_playback=False)
|
||||
manager = MediaImportManager(config.data_dir, templates, animations)
|
||||
manager.run_job(args.job_id)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Lightweight in-process monitoring helpers."""
|
||||
@@ -0,0 +1,257 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
def isoformat_utc(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _clamp_percent(value: float) -> float:
|
||||
return min(100.0, max(0.0, value))
|
||||
|
||||
|
||||
def _parse_cpu_values(fields: list[str], label: str) -> tuple[int, int]:
|
||||
if len(fields) < 4:
|
||||
raise ValueError(f"/proc/stat {label} 行无效")
|
||||
values = [int(value) for value in fields]
|
||||
total = sum(values)
|
||||
idle = values[3] + (values[4] if len(values) > 4 else 0)
|
||||
if total <= 0:
|
||||
raise ValueError(f"/proc/stat {label} 总计无效")
|
||||
return total, idle
|
||||
|
||||
|
||||
def _parse_cpu_snapshot(text: str) -> tuple[tuple[int, int], tuple[tuple[str, int, int], ...]]:
|
||||
aggregate: tuple[int, int] | None = None
|
||||
cores: list[tuple[str, int, int]] = []
|
||||
for line in text.splitlines():
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
label = parts[0]
|
||||
if label == "cpu":
|
||||
aggregate = _parse_cpu_values(parts[1:], label)
|
||||
elif label.startswith("cpu") and label[3:].isdigit():
|
||||
total, idle = _parse_cpu_values(parts[1:], label)
|
||||
cores.append((label, total, idle))
|
||||
if aggregate is None or not cores:
|
||||
raise ValueError("/proc/stat 缺少 CPU 汇总或逻辑核心行")
|
||||
cores.sort(key=lambda item: int(item[0][3:]))
|
||||
return aggregate, tuple(cores)
|
||||
|
||||
|
||||
def _parse_process_cpu_ticks(text: str) -> int:
|
||||
closing_paren = text.rfind(")")
|
||||
if closing_paren < 0:
|
||||
raise ValueError("/proc/self/stat 进程名无效")
|
||||
fields = text[closing_paren + 1:].split()
|
||||
if len(fields) < 13:
|
||||
raise ValueError("/proc/self/stat 字段不足")
|
||||
return int(fields[11]) + int(fields[12])
|
||||
|
||||
|
||||
def _parse_rss_bytes(text: str, page_size: int) -> int:
|
||||
fields = text.split()
|
||||
if len(fields) < 2:
|
||||
raise ValueError("/proc/self/statm 字段不足")
|
||||
rss_pages = int(fields[1])
|
||||
if rss_pages < 0:
|
||||
raise ValueError("/proc/self/statm RSS 无效")
|
||||
return rss_pages * page_size
|
||||
|
||||
|
||||
def _parse_memory_bytes(text: str) -> tuple[int, int]:
|
||||
values: dict[str, int] = {}
|
||||
for line in text.splitlines():
|
||||
key, separator, remainder = line.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
parts = remainder.split()
|
||||
if parts and parts[0].isdigit():
|
||||
values[key] = int(parts[0]) * 1024
|
||||
total = values.get("MemTotal", 0)
|
||||
available = values.get("MemAvailable", -1)
|
||||
if total <= 0 or not 0 <= available <= total:
|
||||
raise ValueError("/proc/meminfo 内存汇总无效")
|
||||
return total, available
|
||||
|
||||
|
||||
class ResourceMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
proc_root: Path | str = "/proc",
|
||||
poll_interval: float = POLL_INTERVAL_SECONDS,
|
||||
page_size: int | None = None,
|
||||
now: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
self._proc_root = Path(proc_root)
|
||||
self._poll_interval = poll_interval
|
||||
if page_size is None:
|
||||
sysconf = getattr(os, "sysconf", None)
|
||||
page_size = int(sysconf("SC_PAGE_SIZE")) if sysconf is not None else 4096
|
||||
self._page_size = page_size
|
||||
self._now = now or (lambda: datetime.now(timezone.utc))
|
||||
self._state_lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._previous_cpu: tuple[
|
||||
int,
|
||||
int,
|
||||
int,
|
||||
tuple[tuple[str, int, int], ...],
|
||||
] | None = None
|
||||
self._snapshot = self._empty_snapshot("starting", None, None)
|
||||
|
||||
@staticmethod
|
||||
def _empty_snapshot(status: str, sampled_at: str | None, error_code: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"sampled_at": sampled_at,
|
||||
"cpu": {
|
||||
"application_percent": None,
|
||||
"total_percent": None,
|
||||
"logical_cpu_count": None,
|
||||
"application_core_equivalent": None,
|
||||
"cores_percent": [],
|
||||
},
|
||||
"memory": {
|
||||
"application_bytes": None,
|
||||
"application_percent": None,
|
||||
"total_percent": None,
|
||||
},
|
||||
"error_code": error_code,
|
||||
}
|
||||
|
||||
def start(self) -> None:
|
||||
with self._state_lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="system-resource-monitor",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop.set()
|
||||
with self._state_lock:
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=max(2.0, self._poll_interval + 1.0))
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
with self._state_lock:
|
||||
snapshot = self._snapshot
|
||||
return {
|
||||
**snapshot,
|
||||
"cpu": {
|
||||
**snapshot["cpu"],
|
||||
"cores_percent": list(snapshot["cpu"]["cores_percent"]),
|
||||
},
|
||||
"memory": dict(snapshot["memory"]),
|
||||
}
|
||||
|
||||
def sample_now(self) -> dict[str, Any]:
|
||||
sampled_at = isoformat_utc(self._now())
|
||||
try:
|
||||
(cpu_total, cpu_idle), cpu_cores = _parse_cpu_snapshot(
|
||||
(self._proc_root / "stat").read_text(encoding="ascii")
|
||||
)
|
||||
process_ticks = _parse_process_cpu_ticks(
|
||||
(self._proc_root / "self" / "stat").read_text(encoding="ascii")
|
||||
)
|
||||
rss_bytes = _parse_rss_bytes(
|
||||
(self._proc_root / "self" / "statm").read_text(encoding="ascii"),
|
||||
self._page_size,
|
||||
)
|
||||
memory_total, memory_available = _parse_memory_bytes(
|
||||
(self._proc_root / "meminfo").read_text(encoding="ascii")
|
||||
)
|
||||
|
||||
application_cpu: float | None = None
|
||||
application_cores: float | None = None
|
||||
total_cpu: float | None = None
|
||||
cores_percent: list[float | None] = [None] * len(cpu_cores)
|
||||
previous = self._previous_cpu
|
||||
if previous is not None:
|
||||
previous_total, previous_idle, previous_process, previous_cores = previous
|
||||
current_labels = tuple(item[0] for item in cpu_cores)
|
||||
previous_labels = tuple(item[0] for item in previous_cores)
|
||||
if current_labels == previous_labels:
|
||||
total_delta = cpu_total - previous_total
|
||||
idle_delta = cpu_idle - previous_idle
|
||||
process_delta = process_ticks - previous_process
|
||||
if total_delta > 0 and idle_delta >= 0 and process_delta >= 0:
|
||||
total_cpu = _clamp_percent(
|
||||
(total_delta - idle_delta) * 100.0 / total_delta
|
||||
)
|
||||
application_cpu = _clamp_percent(process_delta * 100.0 / total_delta)
|
||||
application_cores = min(
|
||||
float(len(cpu_cores)),
|
||||
max(0.0, process_delta * len(cpu_cores) / total_delta),
|
||||
)
|
||||
for index, (current, old) in enumerate(zip(cpu_cores, previous_cores)):
|
||||
_, current_total, current_idle = current
|
||||
_, old_total, old_idle = old
|
||||
core_delta = current_total - old_total
|
||||
core_idle_delta = current_idle - old_idle
|
||||
if core_delta > 0 and core_idle_delta >= 0:
|
||||
cores_percent[index] = _clamp_percent(
|
||||
(core_delta - core_idle_delta) * 100.0 / core_delta
|
||||
)
|
||||
self._previous_cpu = (cpu_total, cpu_idle, process_ticks, cpu_cores)
|
||||
|
||||
snapshot = {
|
||||
"status": "ok",
|
||||
"sampled_at": sampled_at,
|
||||
"cpu": {
|
||||
"application_percent": application_cpu,
|
||||
"total_percent": total_cpu,
|
||||
"logical_cpu_count": len(cpu_cores),
|
||||
"application_core_equivalent": application_cores,
|
||||
"cores_percent": cores_percent,
|
||||
},
|
||||
"memory": {
|
||||
"application_bytes": rss_bytes,
|
||||
"application_percent": _clamp_percent(rss_bytes * 100.0 / memory_total),
|
||||
"total_percent": _clamp_percent(
|
||||
(memory_total - memory_available) * 100.0 / memory_total
|
||||
),
|
||||
},
|
||||
"error_code": None,
|
||||
}
|
||||
except (OSError, UnicodeError, ValueError, IndexError):
|
||||
snapshot = self._empty_snapshot("error", sampled_at, "proc_read_error")
|
||||
except Exception:
|
||||
logger.exception("Unexpected system resource monitor failure")
|
||||
snapshot = self._empty_snapshot("error", sampled_at, "unexpected_error")
|
||||
|
||||
with self._state_lock:
|
||||
previous_status = self._snapshot["status"]
|
||||
self._snapshot = snapshot
|
||||
if previous_status != snapshot["status"]:
|
||||
if snapshot["status"] == "ok":
|
||||
logger.info("System resource monitor is available")
|
||||
else:
|
||||
logger.warning("System resource monitor is unavailable: %s", snapshot["error_code"])
|
||||
return self.get_status()
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
started = time.monotonic()
|
||||
self.sample_now()
|
||||
remaining = max(0.0, self._poll_interval - (time.monotonic() - started))
|
||||
self._stop.wait(remaining)
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
DEVICE_STORAGE_CACHE_SECONDS = 300.0
|
||||
|
||||
|
||||
def _normalized_roots(paths: Iterable[Path]) -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
for candidate in sorted(
|
||||
{Path(path).resolve() for path in paths},
|
||||
key=lambda path: len(path.parts),
|
||||
):
|
||||
if any(candidate == root or candidate.is_relative_to(root) for root in roots):
|
||||
continue
|
||||
roots.append(candidate)
|
||||
return roots
|
||||
|
||||
|
||||
def directory_bytes(paths: Iterable[Path]) -> int:
|
||||
total = 0
|
||||
pending = _normalized_roots(paths)
|
||||
while pending:
|
||||
path = pending.pop()
|
||||
if not path.exists():
|
||||
continue
|
||||
if not path.is_dir():
|
||||
total += path.stat(follow_symlinks=False).st_size
|
||||
continue
|
||||
with os.scandir(path) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
pending.append(Path(entry.path))
|
||||
else:
|
||||
total += entry.stat(follow_symlinks=False).st_size
|
||||
return total
|
||||
|
||||
|
||||
class DeviceStorageMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
program_root: Path,
|
||||
data_root: Path,
|
||||
*,
|
||||
cache_seconds: float = DEVICE_STORAGE_CACHE_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
usage_reader: Callable[[Path], shutil._ntuple_diskusage] = shutil.disk_usage,
|
||||
directory_reader: Callable[[Iterable[Path]], int] = directory_bytes,
|
||||
) -> None:
|
||||
self.program_root = Path(program_root).resolve()
|
||||
self.data_root = Path(data_root).resolve()
|
||||
self.cache_seconds = float(cache_seconds)
|
||||
self._clock = clock
|
||||
self._usage_reader = usage_reader
|
||||
self._directory_reader = directory_reader
|
||||
self._lock = threading.RLock()
|
||||
self._snapshot: dict | None = None
|
||||
self._sampled_monotonic = float("-inf")
|
||||
|
||||
def invalidate(self) -> None:
|
||||
with self._lock:
|
||||
self._snapshot = None
|
||||
self._sampled_monotonic = float("-inf")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
if (
|
||||
self._snapshot is not None
|
||||
and now - self._sampled_monotonic < self.cache_seconds
|
||||
):
|
||||
return dict(self._snapshot)
|
||||
|
||||
usage = self._usage_reader(self.program_root)
|
||||
snapshot = {
|
||||
"sampled_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"device_total_bytes": usage.total,
|
||||
"device_free_bytes": usage.free,
|
||||
"software_bytes": self._directory_reader(
|
||||
[self.program_root, self.data_root]
|
||||
),
|
||||
"templates_bytes": self._directory_reader(
|
||||
[self.data_root / "templates", self.data_root / "animations"]
|
||||
),
|
||||
}
|
||||
self._snapshot = snapshot
|
||||
self._sampled_monotonic = now
|
||||
return dict(snapshot)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""NetworkManager-backed WiFi configuration and boot coordination."""
|
||||
|
||||
from .manager import MockNetworkManager, NetworkManagerError, NmcliNetworkManager
|
||||
from .service import WifiNetworkService
|
||||
from .store import WifiConfigError, WifiConfigStore
|
||||
|
||||
__all__ = [
|
||||
"MockNetworkManager",
|
||||
"NetworkManagerError",
|
||||
"NmcliNetworkManager",
|
||||
"WifiConfigError",
|
||||
"WifiConfigStore",
|
||||
"WifiNetworkService",
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class NetworkDiagnostics:
|
||||
def __init__(
|
||||
self,
|
||||
status_provider: Callable[[], dict[str, Any]],
|
||||
*,
|
||||
runner=subprocess.run,
|
||||
resolver=socket.getaddrinfo,
|
||||
connector=socket.create_connection,
|
||||
) -> None:
|
||||
self.status_provider = status_provider
|
||||
self.runner = runner
|
||||
self.resolver = resolver
|
||||
self.connector = connector
|
||||
|
||||
@staticmethod
|
||||
def _check(code: str, ok: bool, message: str, *, warning: bool = False) -> dict[str, Any]:
|
||||
return {"code": code, "ok": ok, "warning": warning, "message": message}
|
||||
|
||||
def _command(self, command: list[str], timeout: int = 3) -> subprocess.CompletedProcess[str] | None:
|
||||
try:
|
||||
return self.runner(command, capture_output=True, text=True, timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
checks: list[dict[str, Any]] = []
|
||||
active = (self.status_provider().get("active") or {})
|
||||
connected = active.get("connected") is True and bool(active.get("ipv4_address"))
|
||||
checks.append(self._check("interface", connected, "网络接口已连接" if connected else "网卡或 WiFi 未连接"))
|
||||
if not connected:
|
||||
return self._result(checks, started, "failed", "网卡或 WiFi 未连接")
|
||||
|
||||
route = self._command(["/usr/sbin/ip", "-4", "route", "show", "default"])
|
||||
route_ok = bool(route and route.returncode == 0 and route.stdout.strip())
|
||||
checks.append(self._check("default_route", route_ok, "默认路由正常" if route_ok else "默认网关缺失"))
|
||||
if not route_ok:
|
||||
return self._result(checks, started, "failed", "默认网关缺失")
|
||||
|
||||
dns_ok = False
|
||||
for host in ("www.baidu.com", "www.qq.com"):
|
||||
try:
|
||||
if self.resolver(host, 443, type=socket.SOCK_STREAM):
|
||||
dns_ok = True
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
checks.append(self._check("dns", dns_ok, "DNS 解析正常" if dns_ok else "DNS 解析失败"))
|
||||
if not dns_ok:
|
||||
return self._result(checks, started, "failed", "DNS 解析失败,请检查 DNS 或网关配置")
|
||||
|
||||
reachable = False
|
||||
tls_ok = False
|
||||
for host in ("www.baidu.com", "www.qq.com"):
|
||||
try:
|
||||
raw = self.connector((host, 443), timeout=3)
|
||||
reachable = True
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
with context.wrap_socket(raw, server_hostname=host):
|
||||
tls_ok = True
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
raw.close()
|
||||
except OSError:
|
||||
pass
|
||||
except (OSError, ssl.SSLError):
|
||||
continue
|
||||
checks.append(self._check("internet", reachable, "外网 TCP 连接正常" if reachable else "网关或上游网络不可达"))
|
||||
checks.append(self._check("tls", tls_ok, "TLS 校验正常" if tls_ok else "TLS 或系统时间可能异常"))
|
||||
|
||||
ping = self._command(["/usr/bin/ping", "-c", "1", "-W", "2", "www.baidu.com"])
|
||||
ping_ok = bool(ping and ping.returncode == 0)
|
||||
checks.append(self._check(
|
||||
"icmp", ping_ok,
|
||||
"百度 ping 正常" if ping_ok else "目标未响应 ping;部分网络会屏蔽 ICMP",
|
||||
warning=not ping_ok and tls_ok,
|
||||
))
|
||||
if tls_ok:
|
||||
return self._result(checks, started, "ok", "网络连接正常")
|
||||
if reachable:
|
||||
return self._result(checks, started, "failed", "TLS 或系统时间可能异常")
|
||||
return self._result(checks, started, "failed", "网关或上游网络不可达")
|
||||
|
||||
@staticmethod
|
||||
def _result(checks: list[dict[str, Any]], started: float, overall: str, diagnosis: str) -> dict[str, Any]:
|
||||
return {
|
||||
"overall": overall,
|
||||
"diagnosis": diagnosis,
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
class MockNetworkDiagnostics:
|
||||
def __init__(self, result: dict[str, Any] | None = None) -> None:
|
||||
self.result = result or {
|
||||
"overall": "ok",
|
||||
"diagnosis": "网络连接正常(mock)",
|
||||
"duration_ms": 1,
|
||||
"checks": [
|
||||
{"code": "interface", "ok": True, "warning": False, "message": "网络接口已连接"},
|
||||
{"code": "dns", "ok": True, "warning": False, "message": "DNS 解析正常"},
|
||||
{"code": "internet", "ok": True, "warning": False, "message": "外网连接正常"},
|
||||
],
|
||||
}
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
return deepcopy(self.result)
|
||||
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class NetworkManagerError(RuntimeError):
|
||||
"""A sanitized NetworkManager operation failure."""
|
||||
|
||||
|
||||
class NetworkBackend(Protocol):
|
||||
def discover_connection_uuid(self) -> str | None: ...
|
||||
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ...
|
||||
def read_active(self) -> dict[str, Any]: ...
|
||||
def create_connection(self, settings: dict[str, Any]) -> str: ...
|
||||
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: ...
|
||||
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: ...
|
||||
|
||||
|
||||
def _decode_terse(value: str) -> str:
|
||||
output: list[str] = []
|
||||
escaped = False
|
||||
for character in value:
|
||||
if escaped:
|
||||
output.append(character)
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
else:
|
||||
output.append(character)
|
||||
if escaped:
|
||||
output.append("\\")
|
||||
return "".join(output)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NmcliNetworkManager:
|
||||
executable: str = "/usr/bin/nmcli"
|
||||
interface: str = "wlan0"
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
timeout: int = 10,
|
||||
) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.executable, *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError as exc:
|
||||
raise NetworkManagerError("NetworkManager nmcli is unavailable") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise NetworkManagerError("NetworkManager operation timed out") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# stderr may echo a secret on some nmcli failures, so never include it.
|
||||
raise NetworkManagerError("NetworkManager rejected the requested operation") from exc
|
||||
|
||||
def _connection_value(
|
||||
self,
|
||||
connection_uuid: str,
|
||||
field: str,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> str:
|
||||
arguments = []
|
||||
if include_secret:
|
||||
arguments.append("--show-secrets")
|
||||
arguments.extend(["--get-values", field, "connection", "show", "uuid", connection_uuid])
|
||||
return self._run(arguments)
|
||||
|
||||
def discover_connection_uuid(self) -> str | None:
|
||||
active = self._run([
|
||||
"--get-values", "GENERAL.CON-UUID", "device", "show", self.interface,
|
||||
])
|
||||
if active and active != "--":
|
||||
return active.splitlines()[0].strip()
|
||||
|
||||
rows = self._run([
|
||||
"--terse", "--fields", "UUID,TYPE,AUTOCONNECT", "connection", "show",
|
||||
])
|
||||
candidates = []
|
||||
for row in rows.splitlines():
|
||||
parts = row.split(":")
|
||||
if len(parts) == 3 and parts[1] == "802-11-wireless" and parts[2] == "yes":
|
||||
candidates.append(parts[0])
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
|
||||
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]:
|
||||
ssid = self._connection_value(connection_uuid, "802-11-wireless.ssid")
|
||||
method = self._connection_value(connection_uuid, "ipv4.method") or "auto"
|
||||
raw_addresses = self._connection_value(connection_uuid, "ipv4.addresses")
|
||||
address = raw_addresses.splitlines()[0].strip() if raw_addresses else ""
|
||||
raw_dns = self._connection_value(connection_uuid, "ipv4.dns")
|
||||
password = None
|
||||
password_configured = False
|
||||
if include_secret:
|
||||
password = self._connection_value(
|
||||
connection_uuid,
|
||||
"802-11-wireless-security.psk",
|
||||
include_secret=True,
|
||||
)
|
||||
password_configured = bool(password and password != "--")
|
||||
else:
|
||||
key_mgmt = self._connection_value(
|
||||
connection_uuid,
|
||||
"802-11-wireless-security.key-mgmt",
|
||||
)
|
||||
password_configured = bool(key_mgmt and key_mgmt != "--")
|
||||
prefix = None
|
||||
plain_address = address
|
||||
if "/" in address:
|
||||
plain_address, raw_prefix = address.rsplit("/", 1)
|
||||
try:
|
||||
prefix = int(raw_prefix)
|
||||
except ValueError:
|
||||
prefix = None
|
||||
return {
|
||||
"connection_uuid": connection_uuid,
|
||||
"ssid": ssid,
|
||||
"password": password if password_configured else None,
|
||||
"password_configured": password_configured,
|
||||
"ipv4_mode": "dhcp" if method == "auto" else "manual",
|
||||
"address": plain_address or None,
|
||||
"prefix": prefix,
|
||||
"gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None,
|
||||
"dns_servers": [item.strip() for item in raw_dns.splitlines() if item.strip()],
|
||||
}
|
||||
|
||||
def read_active(self) -> dict[str, Any]:
|
||||
raw_state = self._run([
|
||||
"--get-values", "GENERAL.STATE", "device", "show", self.interface,
|
||||
])
|
||||
connected = raw_state.startswith("100")
|
||||
connection_uuid = self._run([
|
||||
"--get-values", "GENERAL.CON-UUID", "device", "show", self.interface,
|
||||
]) or None
|
||||
addresses = [
|
||||
item.strip() for item in self._run([
|
||||
"--get-values", "IP4.ADDRESS", "device", "show", self.interface,
|
||||
]).splitlines() if item.strip()
|
||||
] if connected else []
|
||||
ssid = None
|
||||
if connected:
|
||||
rows = self._run([
|
||||
"--terse", "--fields", "IN-USE,SSID", "device", "wifi", "list",
|
||||
"ifname", self.interface,
|
||||
])
|
||||
for row in rows.splitlines():
|
||||
if row.startswith("*:"):
|
||||
ssid = _decode_terse(row[2:])
|
||||
break
|
||||
ipv4 = addresses[0].split("/", 1)[0] if addresses else None
|
||||
return {
|
||||
"connected": bool(connected and ipv4),
|
||||
"connection_uuid": connection_uuid,
|
||||
"ssid": ssid,
|
||||
"ipv4_address": ipv4,
|
||||
"control_url": f"http://{ipv4}:8080/" if ipv4 else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _modify_arguments(settings: dict[str, Any]) -> list[str]:
|
||||
arguments = [
|
||||
"802-11-wireless.ssid", settings["ssid"],
|
||||
"802-11-wireless-security.key-mgmt", "wpa-psk",
|
||||
]
|
||||
if settings.get("password") is not None:
|
||||
arguments.extend(["802-11-wireless-security.psk", settings["password"]])
|
||||
if settings["ipv4_mode"] == "dhcp":
|
||||
arguments.extend([
|
||||
"ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", "",
|
||||
"ipv4.dns", "",
|
||||
])
|
||||
else:
|
||||
dns = settings["dns_servers"] or [settings["gateway"]]
|
||||
arguments.extend([
|
||||
"ipv4.method", "manual",
|
||||
"ipv4.addresses", f"{settings['address']}/{settings['prefix']}",
|
||||
"ipv4.gateway", settings["gateway"],
|
||||
"ipv4.dns", ",".join(dns),
|
||||
])
|
||||
return arguments
|
||||
|
||||
def create_connection(self, settings: dict[str, Any]) -> str:
|
||||
name = "matrix-screen-controller-wifi"
|
||||
self._run([
|
||||
"connection", "add", "type", "wifi", "ifname", self.interface,
|
||||
"con-name", name, "ssid", settings["ssid"],
|
||||
])
|
||||
connection_uuid = self._run([
|
||||
"--get-values", "connection.uuid", "connection", "show", "id", name,
|
||||
])
|
||||
self.save_connection(connection_uuid, settings)
|
||||
return connection_uuid
|
||||
|
||||
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
|
||||
self._run([
|
||||
"connection", "modify", "uuid", connection_uuid,
|
||||
*self._modify_arguments(settings),
|
||||
])
|
||||
|
||||
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
|
||||
bounded = max(1, min(int(timeout), 30))
|
||||
self._run([
|
||||
"--wait", str(bounded), "connection", "up", "uuid", connection_uuid,
|
||||
], timeout=bounded + 2)
|
||||
|
||||
|
||||
class MockNetworkManager:
|
||||
def __init__(
|
||||
self,
|
||||
saved: dict[str, Any] | None = None,
|
||||
active: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.saved = deepcopy(saved)
|
||||
self.active = deepcopy(active) if active is not None else {
|
||||
"connected": False,
|
||||
"connection_uuid": None,
|
||||
"ssid": None,
|
||||
"ipv4_address": None,
|
||||
"control_url": None,
|
||||
}
|
||||
self.activations: list[str] = []
|
||||
self.fail_save = False
|
||||
self.fail_activate = False
|
||||
|
||||
def discover_connection_uuid(self) -> str | None:
|
||||
with self._lock:
|
||||
if self.active.get("connection_uuid"):
|
||||
return self.active["connection_uuid"]
|
||||
return self.saved.get("connection_uuid") if self.saved else None
|
||||
|
||||
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if not self.saved or self.saved.get("connection_uuid") != connection_uuid:
|
||||
raise NetworkManagerError("managed WiFi connection is unavailable")
|
||||
value = deepcopy(self.saved)
|
||||
if not include_secret:
|
||||
value["password"] = None
|
||||
return value
|
||||
|
||||
def read_active(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return deepcopy(self.active)
|
||||
|
||||
def create_connection(self, settings: dict[str, Any]) -> str:
|
||||
connection_uuid = str(uuid4())
|
||||
with self._lock:
|
||||
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
|
||||
self.saved["password_configured"] = bool(settings.get("password"))
|
||||
return connection_uuid
|
||||
|
||||
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
|
||||
if self.fail_save:
|
||||
raise NetworkManagerError("NetworkManager rejected the requested operation")
|
||||
with self._lock:
|
||||
previous_password = self.saved.get("password") if self.saved else None
|
||||
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
|
||||
if settings.get("password") is None:
|
||||
self.saved["password"] = previous_password
|
||||
self.saved["password_configured"] = bool(self.saved.get("password"))
|
||||
|
||||
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
|
||||
del timeout
|
||||
if self.fail_activate:
|
||||
raise NetworkManagerError("NetworkManager rejected the requested operation")
|
||||
with self._lock:
|
||||
self.activations.append(connection_uuid)
|
||||
if self.saved and self.saved.get("connection_uuid") == connection_uuid:
|
||||
existing_address = None
|
||||
if (
|
||||
self.saved.get("ipv4_mode") == "dhcp"
|
||||
and self.active.get("connected")
|
||||
and self.active.get("connection_uuid") == connection_uuid
|
||||
):
|
||||
existing_address = self.active.get("ipv4_address")
|
||||
address = self.saved.get("address") or existing_address or "192.168.1.100"
|
||||
self.active = {
|
||||
"connected": True,
|
||||
"connection_uuid": connection_uuid,
|
||||
"ssid": self.saved.get("ssid"),
|
||||
"ipv4_address": address,
|
||||
"control_url": f"http://{address}:8080/",
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from app.display.service import DisplayService
|
||||
|
||||
from .manager import NetworkBackend, NetworkManagerError
|
||||
from .store import WifiConfigError, WifiConfigStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utf8_length(value: str) -> int:
|
||||
return len(value.encode("utf-8"))
|
||||
|
||||
|
||||
def validate_wifi_settings(
|
||||
raw: dict[str, Any],
|
||||
*,
|
||||
previous: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ssid = str(raw.get("ssid") or "").strip()
|
||||
if not 1 <= _utf8_length(ssid) <= 32:
|
||||
raise WifiConfigError("ssid must contain 1..32 UTF-8 bytes")
|
||||
|
||||
password_value = raw.get("password")
|
||||
password = None if password_value in (None, "") else str(password_value)
|
||||
if password is not None and not 8 <= _utf8_length(password) <= 63:
|
||||
raise WifiConfigError("password must contain 8..63 UTF-8 bytes")
|
||||
if previous is None or previous.get("ssid") != ssid:
|
||||
if password is None:
|
||||
raise WifiConfigError("a new password is required when the SSID changes")
|
||||
elif password is None and not previous.get("password_configured"):
|
||||
raise WifiConfigError("the managed WiFi connection does not have a saved password")
|
||||
|
||||
mode = str(raw.get("ipv4_mode") or "dhcp")
|
||||
if mode not in {"dhcp", "manual"}:
|
||||
raise WifiConfigError("ipv4_mode must be dhcp or manual")
|
||||
|
||||
address = gateway = None
|
||||
prefix = 24
|
||||
dns_servers: list[str] = []
|
||||
if mode == "manual":
|
||||
try:
|
||||
address = str(ipaddress.IPv4Address(str(raw.get("address") or "")))
|
||||
except ipaddress.AddressValueError as exc:
|
||||
raise WifiConfigError("manual IPv4 address is required") from exc
|
||||
try:
|
||||
gateway = str(ipaddress.IPv4Address(str(raw.get("gateway") or "")))
|
||||
except ipaddress.AddressValueError as exc:
|
||||
raise WifiConfigError("manual IPv4 gateway is required") from exc
|
||||
prefix_value = raw.get("prefix")
|
||||
if prefix_value not in (None, ""):
|
||||
if type(prefix_value) is not int:
|
||||
raise WifiConfigError("IPv4 prefix must be an integer")
|
||||
prefix = prefix_value
|
||||
if not 1 <= prefix <= 32:
|
||||
raise WifiConfigError("IPv4 prefix must be in 1..32")
|
||||
network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False)
|
||||
if ipaddress.IPv4Address(address) in {network.network_address, network.broadcast_address}:
|
||||
raise WifiConfigError("manual IPv4 address cannot be the network or broadcast address")
|
||||
if ipaddress.IPv4Address(gateway) not in network:
|
||||
raise WifiConfigError("manual IPv4 gateway must be in the selected subnet")
|
||||
for item in raw.get("dns_servers") or []:
|
||||
try:
|
||||
normalized = str(ipaddress.IPv4Address(str(item).strip()))
|
||||
except ipaddress.AddressValueError as exc:
|
||||
raise WifiConfigError("DNS servers must be valid IPv4 addresses") from exc
|
||||
if normalized not in dns_servers:
|
||||
dns_servers.append(normalized)
|
||||
if not dns_servers:
|
||||
dns_servers = [gateway]
|
||||
|
||||
return {
|
||||
"ssid": ssid,
|
||||
"password": password,
|
||||
"password_configured": bool(password or (previous or {}).get("password_configured")),
|
||||
"ipv4_mode": mode,
|
||||
"address": address,
|
||||
"prefix": prefix if mode == "manual" else None,
|
||||
"gateway": gateway,
|
||||
"dns_servers": dns_servers,
|
||||
}
|
||||
|
||||
|
||||
class WifiNetworkService:
|
||||
def __init__(
|
||||
self,
|
||||
store: WifiConfigStore,
|
||||
backend: NetworkBackend,
|
||||
display: DisplayService,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
poll_seconds: float = 2.0,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.backend = backend
|
||||
self.display = display
|
||||
self.monotonic = monotonic
|
||||
self.poll_seconds = poll_seconds
|
||||
self._lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._activation_active = False
|
||||
self._status_cache: dict[str, Any] | None = None
|
||||
self._ensure_managed_connection()
|
||||
|
||||
def _ensure_managed_connection(self) -> None:
|
||||
if self.store.config["managed_connection_uuid"] is not None:
|
||||
return
|
||||
try:
|
||||
discovered = self.backend.discover_connection_uuid()
|
||||
except NetworkManagerError:
|
||||
logger.warning("Unable to discover a managed WiFi connection")
|
||||
return
|
||||
if discovered:
|
||||
self.store.update({"managed_connection_uuid": discovered})
|
||||
logger.info("Bound the active NetworkManager WiFi connection")
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
# Board-side NetworkManager queries can take around a second.
|
||||
# Populate once before serving requests, then refresh in the
|
||||
# coordinator instead of blocking every /api/status response.
|
||||
self.get_status()
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="wifi-boot-coordinator",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop.set()
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=2.5)
|
||||
self.display.dismiss_wifi_indicator()
|
||||
|
||||
def dismiss_for_boot(self) -> None:
|
||||
try:
|
||||
changed = self.store.dismiss_for_boot()
|
||||
except Exception:
|
||||
logger.exception("Failed to persist WiFi indicator dismissal")
|
||||
changed = True
|
||||
if changed or self.display.get_status()["state"].get("wifi_indicator_active"):
|
||||
self.display.dismiss_wifi_indicator()
|
||||
|
||||
def get_status(self, *, include_secret: bool = False) -> dict[str, Any]:
|
||||
managed_uuid = self.store.config["managed_connection_uuid"]
|
||||
saved: dict[str, Any] | None = None
|
||||
error = None
|
||||
try:
|
||||
if managed_uuid:
|
||||
saved = self.backend.read_saved(managed_uuid, include_secret=include_secret)
|
||||
active = self.backend.read_active()
|
||||
except NetworkManagerError as exc:
|
||||
active = {
|
||||
"connected": False,
|
||||
"connection_uuid": None,
|
||||
"ssid": None,
|
||||
"ipv4_address": None,
|
||||
"control_url": None,
|
||||
}
|
||||
error = str(exc)
|
||||
if saved is not None and not include_secret:
|
||||
saved = {key: value for key, value in saved.items() if key != "password"}
|
||||
session = self.store.session
|
||||
result = {
|
||||
"available": error is None,
|
||||
"error": error,
|
||||
"saved": saved,
|
||||
"active": active,
|
||||
"prompt_delay_seconds": self.store.config["prompt_delay_seconds"],
|
||||
"prompt": {
|
||||
"dismissed_for_boot": session["dismissed"],
|
||||
"deadline_monotonic": session["deadline_monotonic"],
|
||||
"active": self.display.get_status()["state"].get("wifi_indicator_active", False),
|
||||
},
|
||||
"operation": deepcopy(session["operation"]),
|
||||
}
|
||||
if not include_secret:
|
||||
with self._lock:
|
||||
self._status_cache = deepcopy(result)
|
||||
return result
|
||||
|
||||
def get_cached_status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
cached = deepcopy(self._status_cache)
|
||||
if cached is None:
|
||||
return self.get_status()
|
||||
session = self.store.session
|
||||
cached["prompt"] = {
|
||||
"dismissed_for_boot": session["dismissed"],
|
||||
"deadline_monotonic": session["deadline_monotonic"],
|
||||
"active": self.display.get_status()["state"].get("wifi_indicator_active", False),
|
||||
}
|
||||
cached["operation"] = deepcopy(session["operation"])
|
||||
cached["prompt_delay_seconds"] = self.store.config["prompt_delay_seconds"]
|
||||
return cached
|
||||
|
||||
def save_settings(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||
activation = str(raw.get("activation") or "")
|
||||
if activation not in {"immediate", "next_boot"}:
|
||||
raise WifiConfigError("activation must be immediate or next_boot")
|
||||
delay = raw.get("prompt_delay_seconds")
|
||||
if delay is not None and type(delay) is not int:
|
||||
raise WifiConfigError("prompt_delay_seconds must be an integer")
|
||||
|
||||
with self._lock:
|
||||
if self._activation_active:
|
||||
raise WifiConfigError("another WiFi activation is already running")
|
||||
managed_uuid = self.store.config["managed_connection_uuid"]
|
||||
previous = None
|
||||
if managed_uuid:
|
||||
previous = self.backend.read_saved(managed_uuid, include_secret=False)
|
||||
settings = validate_wifi_settings(raw, previous=previous)
|
||||
|
||||
if managed_uuid:
|
||||
rollback = self.backend.read_saved(managed_uuid, include_secret=True)
|
||||
try:
|
||||
self.backend.save_connection(managed_uuid, settings)
|
||||
if delay is not None:
|
||||
self.store.update({"prompt_delay_seconds": delay})
|
||||
except Exception:
|
||||
try:
|
||||
self.backend.save_connection(managed_uuid, rollback)
|
||||
except Exception:
|
||||
logger.exception("Failed to roll back NetworkManager profile metadata")
|
||||
raise
|
||||
else:
|
||||
managed_uuid = self.backend.create_connection(settings)
|
||||
try:
|
||||
store_update = {"managed_connection_uuid": managed_uuid}
|
||||
if delay is not None:
|
||||
store_update["prompt_delay_seconds"] = delay
|
||||
self.store.update(store_update)
|
||||
except Exception:
|
||||
logger.exception("WiFi profile was created but its managed UUID could not be saved")
|
||||
raise
|
||||
|
||||
operation_id = str(uuid4())
|
||||
state = "scheduled" if activation == "immediate" else "succeeded"
|
||||
message = (
|
||||
"WiFi 配置已保存,正在准备立即切换"
|
||||
if activation == "immediate"
|
||||
else "WiFi 配置已保存,将在下次断电开机后生效"
|
||||
)
|
||||
self.store.set_operation({"id": operation_id, "state": state, "message": message})
|
||||
return {
|
||||
"operation_id": operation_id,
|
||||
"activation": activation,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def save_prompt_delay(self, value: Any) -> dict[str, Any]:
|
||||
if type(value) is not int:
|
||||
raise WifiConfigError("prompt_delay_seconds must be an integer")
|
||||
with self._lock:
|
||||
config = self.store.update({"prompt_delay_seconds": value})
|
||||
return {
|
||||
"prompt_delay_seconds": config["prompt_delay_seconds"],
|
||||
"message": "网络提示等待时间已保存",
|
||||
}
|
||||
|
||||
def activate_saved(self, operation_id: str) -> None:
|
||||
with self._lock:
|
||||
if self._activation_active:
|
||||
return
|
||||
self._activation_active = True
|
||||
try:
|
||||
managed_uuid = self.store.config["managed_connection_uuid"]
|
||||
if not managed_uuid:
|
||||
raise NetworkManagerError("managed WiFi connection is unavailable")
|
||||
self.store.set_operation({
|
||||
"id": operation_id,
|
||||
"state": "applying",
|
||||
"message": "正在切换 WiFi 与 IPv4 配置",
|
||||
})
|
||||
self.backend.activate(managed_uuid, timeout=30)
|
||||
self.store.set_operation({
|
||||
"id": operation_id,
|
||||
"state": "succeeded",
|
||||
"message": "WiFi 与 IPv4 配置已生效",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("WiFi activation failed without exposing NetworkManager output")
|
||||
self.store.set_operation({
|
||||
"id": operation_id,
|
||||
"state": "failed",
|
||||
"message": str(exc),
|
||||
})
|
||||
finally:
|
||||
with self._lock:
|
||||
self._activation_active = False
|
||||
|
||||
def _run(self) -> None:
|
||||
session = self.store.session
|
||||
deadline = float(session["deadline_monotonic"])
|
||||
managed_uuid = self.store.config["managed_connection_uuid"]
|
||||
|
||||
if self.store.is_new_boot_session and managed_uuid:
|
||||
while not self._stop.is_set() and self.monotonic() < deadline:
|
||||
try:
|
||||
self.backend.activate(managed_uuid, timeout=5)
|
||||
if self.backend.read_active().get("connected"):
|
||||
break
|
||||
except NetworkManagerError:
|
||||
pass
|
||||
self._stop.wait(min(self.poll_seconds, max(0.0, deadline - self.monotonic())))
|
||||
|
||||
remaining = deadline - self.monotonic()
|
||||
if remaining > 0 and self._stop.wait(remaining):
|
||||
return
|
||||
|
||||
while not self._stop.is_set():
|
||||
self.get_status()
|
||||
if self.store.session["dismissed"]:
|
||||
self.display.dismiss_wifi_indicator()
|
||||
else:
|
||||
self._refresh_indicator()
|
||||
self._stop.wait(self.poll_seconds)
|
||||
|
||||
def _refresh_indicator(self) -> None:
|
||||
managed_uuid = self.store.config["managed_connection_uuid"]
|
||||
try:
|
||||
active = self.backend.read_active()
|
||||
if active.get("connected") and active.get("ssid") and active.get("ipv4_address"):
|
||||
message = (
|
||||
f"SSID: {active['ssid']} "
|
||||
f"IP: http://{active['ipv4_address']}:8080/"
|
||||
)
|
||||
self.display.show_wifi_indicator(connected=True, message=message)
|
||||
return
|
||||
|
||||
if managed_uuid:
|
||||
saved = self.backend.read_saved(managed_uuid, include_secret=True)
|
||||
password = saved.get("password") or "未保存"
|
||||
message = f"SSID: {saved.get('ssid') or '未配置'} 密码: {password}"
|
||||
else:
|
||||
message = "尚未配置 WiFi"
|
||||
self.display.show_wifi_indicator(connected=False, message=message)
|
||||
except NetworkManagerError:
|
||||
self.display.show_wifi_indicator(connected=False, message="WiFi 状态不可用")
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from uuid import UUID
|
||||
|
||||
from app.display.startup_indicator import read_boot_id
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WIFI_CONFIG_SCHEMA_VERSION = 1
|
||||
DEFAULT_PROMPT_DELAY_SECONDS = 30
|
||||
MIN_PROMPT_DELAY_SECONDS = 1
|
||||
MAX_PROMPT_DELAY_SECONDS = 3600
|
||||
WIFI_CONFIG_FIELDS = frozenset({"managed_connection_uuid", "prompt_delay_seconds"})
|
||||
|
||||
|
||||
class WifiConfigError(ValueError):
|
||||
"""Raised when persisted WiFi metadata is unsafe or invalid."""
|
||||
|
||||
|
||||
def validate_wifi_config(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise WifiConfigError("wifi config must be a JSON object")
|
||||
unknown = set(value) - WIFI_CONFIG_FIELDS
|
||||
if unknown:
|
||||
raise WifiConfigError(f"unknown wifi config fields: {', '.join(sorted(unknown))}")
|
||||
missing = WIFI_CONFIG_FIELDS - set(value)
|
||||
if missing:
|
||||
raise WifiConfigError(f"wifi config is missing fields: {', '.join(sorted(missing))}")
|
||||
|
||||
managed_uuid = value["managed_connection_uuid"]
|
||||
if managed_uuid is not None:
|
||||
if not isinstance(managed_uuid, str):
|
||||
raise WifiConfigError("managed_connection_uuid must be a UUID string or null")
|
||||
try:
|
||||
managed_uuid = str(UUID(managed_uuid))
|
||||
except ValueError as exc:
|
||||
raise WifiConfigError("managed_connection_uuid must be a valid UUID") from exc
|
||||
|
||||
delay = value["prompt_delay_seconds"]
|
||||
if type(delay) is not int:
|
||||
raise WifiConfigError("prompt_delay_seconds must be an integer")
|
||||
if not MIN_PROMPT_DELAY_SECONDS <= delay <= MAX_PROMPT_DELAY_SECONDS:
|
||||
raise WifiConfigError(
|
||||
f"prompt_delay_seconds must be in {MIN_PROMPT_DELAY_SECONDS}.."
|
||||
f"{MAX_PROMPT_DELAY_SECONDS}"
|
||||
)
|
||||
return {
|
||||
"managed_connection_uuid": managed_uuid,
|
||||
"prompt_delay_seconds": delay,
|
||||
}
|
||||
|
||||
|
||||
class WifiConfigStore:
|
||||
def __init__(
|
||||
self,
|
||||
data_dir: Path,
|
||||
runtime_dir: Path,
|
||||
*,
|
||||
boot_id: str | None = None,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.path = Path(data_dir) / "wifi_config.json"
|
||||
self.session_path = Path(runtime_dir) / "wifi_session.json"
|
||||
self.boot_id = boot_id or read_boot_id()
|
||||
self.monotonic = monotonic
|
||||
self._lock = threading.RLock()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.session_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._config = self._load_config()
|
||||
self._session, self._new_boot_session = self._load_session()
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._config)
|
||||
|
||||
@property
|
||||
def is_new_boot_session(self) -> bool:
|
||||
return self._new_boot_session
|
||||
|
||||
@property
|
||||
def session(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._session)
|
||||
|
||||
def update(self, values: dict[str, Any]) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
unknown = set(values) - WIFI_CONFIG_FIELDS
|
||||
if unknown:
|
||||
raise WifiConfigError(f"unknown wifi config fields: {', '.join(sorted(unknown))}")
|
||||
checked = validate_wifi_config({**self._config, **values})
|
||||
if checked != self._config:
|
||||
self._write_config(checked)
|
||||
self._config = checked
|
||||
return dict(self._config)
|
||||
|
||||
def dismiss_for_boot(self) -> bool:
|
||||
with self._lock:
|
||||
if self._session["dismissed"]:
|
||||
return False
|
||||
self._session["dismissed"] = True
|
||||
self._write_session(self._session)
|
||||
return True
|
||||
|
||||
def set_operation(self, operation: dict[str, Any]) -> None:
|
||||
safe = {
|
||||
"id": str(operation.get("id") or ""),
|
||||
"state": str(operation.get("state") or "idle"),
|
||||
"message": str(operation.get("message") or ""),
|
||||
}
|
||||
if safe["state"] not in {"idle", "scheduled", "applying", "succeeded", "failed"}:
|
||||
raise WifiConfigError("invalid wifi operation state")
|
||||
with self._lock:
|
||||
self._session["operation"] = safe
|
||||
self._write_session(self._session)
|
||||
|
||||
def _load_config(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
value = {
|
||||
"managed_connection_uuid": None,
|
||||
"prompt_delay_seconds": DEFAULT_PROMPT_DELAY_SECONDS,
|
||||
}
|
||||
self._write_config(value)
|
||||
return value
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise WifiConfigError(f"persisted wifi config is unreadable: {self.path}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise WifiConfigError("persisted wifi config must be a JSON object")
|
||||
document = dict(raw)
|
||||
version = document.pop("schema_version", None)
|
||||
if type(version) is not int:
|
||||
raise WifiConfigError("wifi config schema_version must be an integer")
|
||||
if version != WIFI_CONFIG_SCHEMA_VERSION:
|
||||
raise WifiConfigError(
|
||||
f"unsupported wifi config schema version {version}; "
|
||||
f"supported version is {WIFI_CONFIG_SCHEMA_VERSION}"
|
||||
)
|
||||
return validate_wifi_config(document)
|
||||
|
||||
def _load_session(self) -> tuple[dict[str, Any], bool]:
|
||||
now = float(self.monotonic())
|
||||
default = {
|
||||
"boot_id": self.boot_id,
|
||||
"deadline_monotonic": now + self._config["prompt_delay_seconds"],
|
||||
"dismissed": False,
|
||||
"operation": {"id": "", "state": "idle", "message": ""},
|
||||
}
|
||||
if self.session_path.exists():
|
||||
try:
|
||||
raw = json.loads(self.session_path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
isinstance(raw, dict)
|
||||
and raw.get("boot_id") == self.boot_id
|
||||
and isinstance(raw.get("deadline_monotonic"), (int, float))
|
||||
and type(raw.get("dismissed")) is bool
|
||||
and isinstance(raw.get("operation"), dict)
|
||||
):
|
||||
return raw, False
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
logger.warning("Resetting unreadable runtime WiFi session")
|
||||
self._write_session(default)
|
||||
return default, True
|
||||
|
||||
def _write_config(self, value: dict[str, Any]) -> None:
|
||||
payload = {"schema_version": WIFI_CONFIG_SCHEMA_VERSION, **value}
|
||||
atomic_write_bytes(
|
||||
self.path,
|
||||
(json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
||||
)
|
||||
|
||||
def _write_session(self, value: dict[str, Any]) -> None:
|
||||
atomic_write_bytes(
|
||||
self.session_path,
|
||||
(json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Offline, browser-uploaded full-release updates.
|
||||
|
||||
Keep package initialization dependency-free: release tooling imports the
|
||||
stdlib-only package builder on bare Linux hosts that do not install the
|
||||
controller's Pillow/FontTools runtime.
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
__all__ = [
|
||||
"OtaBusyError",
|
||||
"OtaManager",
|
||||
"OtaPackageError",
|
||||
"OtaUploadError",
|
||||
"SoftwareVersion",
|
||||
"inspect_package",
|
||||
"read_software_version",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in {"OtaBusyError", "OtaManager", "OtaUploadError"}:
|
||||
return getattr(import_module(".manager", __name__), name)
|
||||
if name in {"OtaPackageError", "inspect_package"}:
|
||||
return getattr(import_module(".package", __name__), name)
|
||||
if name in {"SoftwareVersion", "read_software_version"}:
|
||||
return getattr(import_module(".versioning", __name__), name)
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
|
||||
MAX_FAILURE_LOG_BYTES = 1024 * 1024
|
||||
FAILURE_LOG_RELATIVE = Path("ota") / "last-failure.log"
|
||||
_TRUNCATION_MARKER = (
|
||||
"\n\n========== 日志已截断 =========="
|
||||
"\n原始日志超过 1 MiB;保留开头和最新的失败尾部。"
|
||||
"\n========== 继续显示最新日志 ==========\n\n"
|
||||
).encode("utf-8")
|
||||
_HEAD_BYTES = 128 * 1024
|
||||
|
||||
|
||||
def _timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def bounded_failure_log(body: bytes, *, maximum: int = MAX_FAILURE_LOG_BYTES) -> bytes:
|
||||
normalized = bytes(body).decode("utf-8", errors="replace").encode("utf-8")
|
||||
if len(normalized) <= maximum:
|
||||
return normalized
|
||||
head = min(_HEAD_BYTES, maximum // 4)
|
||||
tail = maximum - head - len(_TRUNCATION_MARKER)
|
||||
if tail <= 0:
|
||||
return normalized[-maximum:]
|
||||
return normalized[:head] + _TRUNCATION_MARKER + normalized[-tail:]
|
||||
|
||||
|
||||
def failure_log_path(data_root: Path) -> Path:
|
||||
return Path(data_root) / FAILURE_LOG_RELATIVE
|
||||
|
||||
|
||||
def persist_failure_log(data_root: Path, runtime_log: Path) -> Path:
|
||||
source = Path(runtime_log)
|
||||
body = bounded_failure_log(source.read_bytes())
|
||||
target = failure_log_path(data_root)
|
||||
atomic_write_bytes(target, body)
|
||||
try:
|
||||
target.chmod(0o600)
|
||||
except OSError:
|
||||
target.unlink(missing_ok=True)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def clear_failure_log(data_root: Path) -> None:
|
||||
failure_log_path(data_root).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def failure_log_metadata(data_root: Path, last_result: dict[str, Any] | None) -> tuple[bool, int]:
|
||||
if not isinstance(last_result, dict) or last_result.get("status") != "failed":
|
||||
return False, 0
|
||||
path = failure_log_path(data_root)
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return False, 0
|
||||
if size <= 0 or size > MAX_FAILURE_LOG_BYTES:
|
||||
return False, 0
|
||||
return True, size
|
||||
|
||||
|
||||
def read_failure_log(data_root: Path, last_result: dict[str, Any] | None) -> bytes | None:
|
||||
available, expected_size = failure_log_metadata(data_root, last_result)
|
||||
if not available:
|
||||
return None
|
||||
try:
|
||||
body = failure_log_path(data_root).read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
if len(body) != expected_size:
|
||||
return None
|
||||
return body
|
||||
|
||||
|
||||
class DiagnosticLog:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._recent = bytearray()
|
||||
self._degraded = False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._recent.clear()
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_bytes(b"")
|
||||
except OSError:
|
||||
self._degraded = True
|
||||
self.write("OTA 诊断日志已创建")
|
||||
|
||||
def write(self, message: str) -> None:
|
||||
rendered = str(message).replace("\r\n", "\n").replace("\r", "\n")
|
||||
entry = f"[{_timestamp()}] {rendered}\n".encode("utf-8", errors="replace")
|
||||
self._recent.extend(entry)
|
||||
del self._recent[:-MAX_FAILURE_LOG_BYTES]
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
restore = self._degraded or not self.path.exists()
|
||||
with self.path.open("wb" if restore else "ab") as handle:
|
||||
handle.write(bytes(self._recent) if restore else entry)
|
||||
self._degraded = False
|
||||
except OSError as exc:
|
||||
self._degraded = True
|
||||
try:
|
||||
# Avoid printing OS messages, paths or the command's content here.
|
||||
sys.stderr.write(f"OTA diagnostic storage unavailable ({type(exc).__name__}); recovery continues\n")
|
||||
except (OSError, UnicodeError):
|
||||
pass
|
||||
|
||||
def persist(self, data_root: Path) -> Path:
|
||||
try:
|
||||
body = self.path.read_bytes() if not self._degraded else bytes(self._recent)
|
||||
except OSError:
|
||||
body = bytes(self._recent)
|
||||
target = failure_log_path(data_root)
|
||||
atomic_write_bytes(target, bounded_failure_log(body))
|
||||
target.chmod(0o600)
|
||||
return target
|
||||
|
||||
def section(self, name: str) -> None:
|
||||
self.write(f"========== {name} ==========")
|
||||
|
||||
def host_summary(self, *, test_root: Path) -> None:
|
||||
self.section("安全主机摘要")
|
||||
self.write(f"system={platform.system()} release={platform.release()} machine={platform.machine()}")
|
||||
self.write(f"python={sys.version.splitlines()[0]}")
|
||||
for path in (Path("/opt"), Path("/run"), Path(test_root)):
|
||||
probe = path if path.exists() else path.parent
|
||||
try:
|
||||
usage = shutil.disk_usage(probe)
|
||||
self.write(f"space path={path} free={usage.free} total={usage.total}")
|
||||
except OSError as exc:
|
||||
self.write(f"space path={path} unavailable={exc.__class__.__name__}")
|
||||
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
check: bool = True,
|
||||
failure_label: str | None = None,
|
||||
log_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
display = (
|
||||
shlex.join([str(item) for item in command])
|
||||
if log_output
|
||||
else f"{shlex.quote(str(command[0]))} <参数已按脱敏规则隐藏>"
|
||||
)
|
||||
self.section(f"命令开始:{failure_label or Path(command[0]).name}")
|
||||
self.write(f"cwd={cwd if cwd is not None else os.getcwd()}")
|
||||
self.write(f"command={display}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
except OSError as exc:
|
||||
self.write(f"command_start_failed={exc.__class__.__name__}: {exc}")
|
||||
raise RuntimeError(f"{failure_label or '命令'}无法启动:{exc}") from exc
|
||||
output = result.stdout.decode("utf-8", errors="replace")
|
||||
if output and log_output:
|
||||
self.write("stdout_stderr:\n" + output.rstrip("\n"))
|
||||
try:
|
||||
sys.stdout.write(output)
|
||||
sys.stdout.flush()
|
||||
except (UnicodeError, OSError):
|
||||
pass
|
||||
elif output:
|
||||
self.write("stdout_stderr=<已按脱敏规则隐藏>")
|
||||
self.write(f"exit_code={result.returncode}")
|
||||
if check and result.returncode != 0:
|
||||
tail = (
|
||||
output.rstrip()[-12000:] or "命令没有输出。"
|
||||
if log_output
|
||||
else "命令输出已按脱敏规则隐藏。"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"{failure_label or '命令'}失败(退出码 {result.returncode}):\n{tail}"
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any, AsyncIterable, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from app.display.service import DisplayService
|
||||
|
||||
from .diagnostics import failure_log_metadata, read_failure_log
|
||||
from .package import MAX_OTA_UPLOAD_BYTES, OtaPackageError, inspect_package
|
||||
from .state import read_json, read_last_result, utc_now, write_json
|
||||
from .versioning import SoftwareVersion
|
||||
|
||||
_OTA_FILENAME = re.compile(r"^matrix-screen-controller-[0-9]+\.[0-9]+\.[0-9]+\.ota$")
|
||||
|
||||
|
||||
class OtaUploadError(RuntimeError):
|
||||
def __init__(self, message: str, *, status_code: int = 422) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class OtaBusyError(OtaUploadError):
|
||||
def __init__(self, message: str = "an OTA upload or update is already active") -> None:
|
||||
super().__init__(message, status_code=409)
|
||||
|
||||
|
||||
class OtaManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
code_root: Path,
|
||||
data_root: Path,
|
||||
runtime_root: Path,
|
||||
software_version: SoftwareVersion,
|
||||
display: DisplayService,
|
||||
worker_starter: Callable[[], None] | None = None,
|
||||
staging_root: Path | None = None,
|
||||
) -> None:
|
||||
self.code_root = Path(code_root)
|
||||
self.data_root = Path(data_root)
|
||||
self.runtime_root = Path(runtime_root)
|
||||
self.software_version = software_version
|
||||
self.display = display
|
||||
configured_staging = os.environ.get("MATRIX_OTA_STAGING_DIR")
|
||||
if staging_root is not None:
|
||||
self.staging_root = Path(staging_root)
|
||||
elif configured_staging:
|
||||
self.staging_root = Path(configured_staging)
|
||||
elif os.name != "nt" and str(self.code_root).startswith("/opt/"):
|
||||
self.staging_root = Path("/opt/matrix-screen-controller-ota")
|
||||
else:
|
||||
self.staging_root = self.runtime_root / "ota-staging"
|
||||
self.status_path = self.runtime_root / "ota-status.json"
|
||||
self.request_path = self.runtime_root / "ota-request.json"
|
||||
self._worker_starter = worker_starter or self._start_systemd_worker
|
||||
self._upload_lock = asyncio.Lock()
|
||||
self._monitor_stop = threading.Event()
|
||||
self._monitor_thread: threading.Thread | None = None
|
||||
self._completion_callback: Callable[[], None] = lambda: None
|
||||
|
||||
def set_completion_callback(self, callback: Callable[[], None]) -> None:
|
||||
self._completion_callback = callback
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
active_document = read_json(self.status_path)
|
||||
job = active_document.get("job") if isinstance(active_document, dict) else None
|
||||
component_pending = (self.data_root / "ota/component-transaction").exists()
|
||||
active = component_pending or bool(active_document and active_document.get("active") is True and isinstance(job, dict))
|
||||
last_result = read_last_result(self.data_root)
|
||||
failure_log_available, failure_log_bytes = failure_log_metadata(self.data_root, last_result)
|
||||
return {
|
||||
"current_version": str(self.software_version),
|
||||
"max_upload_bytes": MAX_OTA_UPLOAD_BYTES,
|
||||
"active": active,
|
||||
"component_recovery_pending": component_pending,
|
||||
"job": dict(job) if active or isinstance(job, dict) else None,
|
||||
"last_result": last_result,
|
||||
"failure_log_available": failure_log_available,
|
||||
"failure_log_bytes": failure_log_bytes,
|
||||
}
|
||||
|
||||
def failure_log(self) -> bytes | None:
|
||||
last_result = read_last_result(self.data_root)
|
||||
return read_failure_log(self.data_root, last_result)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self.status()["active"] or self._upload_lock.locked()
|
||||
|
||||
async def accept_upload(
|
||||
self,
|
||||
*,
|
||||
filename: str,
|
||||
chunks: AsyncIterable[bytes],
|
||||
content_length: int | None,
|
||||
orientation: int,
|
||||
) -> dict[str, Any]:
|
||||
if Path(filename).name != filename or not _OTA_FILENAME.fullmatch(filename):
|
||||
raise OtaUploadError(
|
||||
"OTA filename must be matrix-screen-controller-MAJOR.MINOR.PATCH.ota",
|
||||
status_code=400,
|
||||
)
|
||||
if content_length is not None and (content_length <= 0 or content_length > MAX_OTA_UPLOAD_BYTES):
|
||||
raise OtaUploadError("OTA package exceeds the 256 MiB upload limit", status_code=413)
|
||||
if self._upload_lock.locked() or self.status()["active"]:
|
||||
raise OtaBusyError()
|
||||
|
||||
async with self._upload_lock:
|
||||
if self.status()["active"]:
|
||||
raise OtaBusyError()
|
||||
upload_root = self.staging_root / "uploads"
|
||||
upload_root.mkdir(parents=True, exist_ok=True)
|
||||
temporary = upload_root / f".{uuid4().hex}.part"
|
||||
final: Path | None = None
|
||||
total = 0
|
||||
try:
|
||||
with temporary.open("xb") as handle:
|
||||
os.chmod(temporary, 0o600)
|
||||
async for chunk in chunks:
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > MAX_OTA_UPLOAD_BYTES:
|
||||
raise OtaUploadError("OTA package exceeds the 256 MiB upload limit", status_code=413)
|
||||
handle.write(chunk)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if total == 0:
|
||||
raise OtaUploadError("OTA package is empty", status_code=400)
|
||||
if content_length is not None and total != content_length:
|
||||
raise OtaUploadError("OTA upload length does not match Content-Length", status_code=400)
|
||||
try:
|
||||
package = inspect_package(temporary, current_version=self.software_version)
|
||||
except OtaPackageError as exc:
|
||||
message = str(exc)
|
||||
status_code = 409 if "must be newer" in message else 422
|
||||
raise OtaUploadError(message, status_code=status_code) from exc
|
||||
expected_name = f"matrix-screen-controller-{package.target_version}.ota"
|
||||
if filename != expected_name:
|
||||
raise OtaUploadError("OTA filename version does not match its manifest", status_code=422)
|
||||
job_id = uuid4().hex
|
||||
final = upload_root / f"{job_id}.ota"
|
||||
os.replace(temporary, final)
|
||||
request = {
|
||||
"schema_version": 1,
|
||||
"job_id": job_id,
|
||||
"package_path": str(final),
|
||||
"status_path": str(self.status_path),
|
||||
"request_path": str(self.request_path),
|
||||
"current_version": str(self.software_version),
|
||||
"target_version": str(package.target_version),
|
||||
"packaged_at": package.created_at,
|
||||
"release_notes": package.release_notes,
|
||||
"orientation": int(orientation),
|
||||
"accepted_at": utc_now(),
|
||||
}
|
||||
job = {
|
||||
"id": job_id,
|
||||
"target_version": str(package.target_version),
|
||||
"packaged_at": package.created_at,
|
||||
"phase": "queued",
|
||||
"percent": 5,
|
||||
"message": "软件安装包已校验,准备检查并安装系统组件" if package.target_version.patch == 0 else "更新包已校验,准备安装",
|
||||
"started_at": request["accepted_at"],
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
}
|
||||
write_json(self.request_path, request)
|
||||
write_json(self.status_path, {"schema_version": 1, "active": True, "job": job})
|
||||
self.display.start_ota_indicator(self.status_path)
|
||||
try:
|
||||
self._worker_starter()
|
||||
except Exception as exc:
|
||||
failed = dict(job)
|
||||
failed.update(
|
||||
phase="failed",
|
||||
percent=0,
|
||||
message="无法启动独立 OTA 工作服务",
|
||||
finished_at=utc_now(),
|
||||
error=str(exc),
|
||||
)
|
||||
write_json(self.status_path, {"schema_version": 1, "active": False, "job": failed})
|
||||
self.display.stop_ota_indicator()
|
||||
raise OtaUploadError("independent OTA worker could not be started", status_code=500) from exc
|
||||
self.resume_or_start_monitor(self._completion_callback)
|
||||
return {"accepted": True, "job": job}
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if final is not None and not self.status()["active"]:
|
||||
final.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def resume_or_start_monitor(self, on_finished: Callable[[], None]) -> bool:
|
||||
if not self.status()["active"]:
|
||||
return False
|
||||
self.display.start_ota_indicator(self.status_path)
|
||||
if self._monitor_thread is not None and self._monitor_thread.is_alive():
|
||||
return True
|
||||
self._monitor_stop.clear()
|
||||
|
||||
def monitor() -> None:
|
||||
while not self._monitor_stop.wait(0.25):
|
||||
if self.status()["active"]:
|
||||
continue
|
||||
try:
|
||||
self.display.stop_ota_indicator()
|
||||
on_finished()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
self._monitor_thread = threading.Thread(target=monitor, name="ota-status-monitor", daemon=True)
|
||||
self._monitor_thread.start()
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
self._monitor_stop.set()
|
||||
thread = self._monitor_thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
@staticmethod
|
||||
def _start_systemd_worker() -> None:
|
||||
result = subprocess.run(
|
||||
["systemctl", "start", "--no-block", "matrix-screen-controller-ota.service"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout or "systemctl start failed").strip())
|
||||
@@ -0,0 +1,294 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from typing import Any, BinaryIO
|
||||
import zipfile
|
||||
|
||||
from .versioning import SoftwareVersion
|
||||
from .policy import check_upgrade, package_format, release_metadata
|
||||
|
||||
PRODUCT_ID = "matrix-screen-controller-walnutpi"
|
||||
PACKAGE_FORMAT_VERSION = 1
|
||||
MAX_OTA_UPLOAD_BYTES = 256 * 1024 * 1024
|
||||
MAX_OTA_PAYLOAD_BYTES = 256 * 1024 * 1024
|
||||
MAX_OTA_EXPANDED_BYTES = 1024 * 1024 * 1024
|
||||
_MAX_MANIFEST_BYTES = 64 * 1024
|
||||
_PACKAGE_MEMBERS = {"manifest.json", "payload.tar.gz"}
|
||||
_NATIVE_BUILD_OUTPUTS = {
|
||||
"app/display/native/libh618_hub75.so",
|
||||
"app/display/native/hub75_benchmark",
|
||||
"app/display/native/hub75_native_test",
|
||||
"app/display/native/hub75_safeoff",
|
||||
}
|
||||
_EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"}
|
||||
|
||||
|
||||
class OtaPackageError(ValueError):
|
||||
"""The uploaded archive is not a supported complete release."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OtaPackageInfo:
|
||||
path: Path
|
||||
target_version: SoftwareVersion
|
||||
created_at: str
|
||||
release_notes: str
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
expanded_bytes: int
|
||||
|
||||
def document(self) -> dict[str, Any]:
|
||||
return {
|
||||
"target_version": str(self.target_version),
|
||||
"created_at": self.created_at,
|
||||
"release_notes": self.release_notes,
|
||||
"payload_sha256": self.payload_sha256,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"expanded_bytes": self.expanded_bytes,
|
||||
**release_metadata(self.target_version),
|
||||
}
|
||||
|
||||
|
||||
def _validated_manifest(raw: bytes) -> tuple[dict[str, Any], SoftwareVersion]:
|
||||
if len(raw) > _MAX_MANIFEST_BYTES:
|
||||
raise OtaPackageError("OTA manifest is too large")
|
||||
try:
|
||||
manifest = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise OtaPackageError("OTA manifest is not valid UTF-8 JSON") from exc
|
||||
expected = {
|
||||
"format_version",
|
||||
"product",
|
||||
"software_version",
|
||||
"created_at",
|
||||
"release_notes",
|
||||
"payload_sha256",
|
||||
"payload_bytes",
|
||||
"expanded_bytes",
|
||||
}
|
||||
if not isinstance(manifest, dict) or set(manifest) != expected:
|
||||
raise OtaPackageError("OTA manifest fields do not match format version 1")
|
||||
if type(manifest["format_version"]) is not int or manifest["format_version"] not in (1, 2):
|
||||
raise OtaPackageError("OTA package format is unsupported")
|
||||
if manifest["product"] != PRODUCT_ID:
|
||||
raise OtaPackageError("OTA package is for a different product")
|
||||
try:
|
||||
version = SoftwareVersion.parse(manifest["software_version"])
|
||||
if manifest["format_version"] != package_format(version):
|
||||
raise ValueError("OTA version requires a different package format")
|
||||
except ValueError as exc:
|
||||
raise OtaPackageError(str(exc)) from exc
|
||||
if not isinstance(manifest["created_at"], str) or not manifest["created_at"].strip():
|
||||
raise OtaPackageError("OTA package created_at is missing")
|
||||
if not isinstance(manifest["release_notes"], str) or len(manifest["release_notes"]) > 4000:
|
||||
raise OtaPackageError("OTA release notes are invalid")
|
||||
digest = manifest["payload_sha256"]
|
||||
if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
|
||||
raise OtaPackageError("OTA payload SHA-256 is invalid")
|
||||
for key, maximum in (
|
||||
("payload_bytes", MAX_OTA_PAYLOAD_BYTES),
|
||||
("expanded_bytes", MAX_OTA_EXPANDED_BYTES),
|
||||
):
|
||||
value = manifest[key]
|
||||
if type(value) is not int or value <= 0 or value > maximum:
|
||||
raise OtaPackageError(f"OTA {key} is outside the supported limit")
|
||||
return manifest, version
|
||||
|
||||
|
||||
def _payload_digest(handle: BinaryIO) -> tuple[str, int]:
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
while True:
|
||||
chunk = handle.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_OTA_PAYLOAD_BYTES:
|
||||
raise OtaPackageError("OTA payload exceeds the supported limit")
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest(), total
|
||||
|
||||
|
||||
def inspect_package(path: Path, *, current_version: SoftwareVersion | None = None) -> OtaPackageInfo:
|
||||
package_path = Path(path)
|
||||
try:
|
||||
size = package_path.stat().st_size
|
||||
except OSError as exc:
|
||||
raise OtaPackageError("OTA package cannot be read") from exc
|
||||
if size <= 0 or size > MAX_OTA_UPLOAD_BYTES:
|
||||
raise OtaPackageError("OTA package exceeds the 256 MiB upload limit")
|
||||
try:
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
entries = archive.infolist()
|
||||
if {entry.filename for entry in entries} != _PACKAGE_MEMBERS or len(entries) != 2:
|
||||
raise OtaPackageError("OTA package must contain only manifest.json and payload.tar.gz")
|
||||
if any(entry.is_dir() or entry.flag_bits & 0x1 for entry in entries):
|
||||
raise OtaPackageError("OTA package entries must be unencrypted files")
|
||||
manifest_entry = archive.getinfo("manifest.json")
|
||||
payload_entry = archive.getinfo("payload.tar.gz")
|
||||
if manifest_entry.file_size > _MAX_MANIFEST_BYTES:
|
||||
raise OtaPackageError("OTA manifest is too large")
|
||||
if payload_entry.file_size > MAX_OTA_PAYLOAD_BYTES:
|
||||
raise OtaPackageError("OTA payload exceeds the supported limit")
|
||||
manifest, target_version = _validated_manifest(archive.read(manifest_entry))
|
||||
with archive.open(payload_entry, "r") as payload:
|
||||
digest, payload_bytes = _payload_digest(payload)
|
||||
except (OtaPackageError, zipfile.BadZipFile):
|
||||
raise
|
||||
except (KeyError, OSError, RuntimeError) as exc:
|
||||
raise OtaPackageError("OTA package cannot be inspected") from exc
|
||||
if payload_bytes != manifest["payload_bytes"] or digest != manifest["payload_sha256"]:
|
||||
raise OtaPackageError("OTA payload checksum or size does not match the manifest")
|
||||
if current_version is not None:
|
||||
try:
|
||||
check_upgrade(current_version, target_version)
|
||||
except ValueError as exc:
|
||||
raise OtaPackageError(str(exc)) from exc
|
||||
return OtaPackageInfo(
|
||||
path=package_path,
|
||||
target_version=target_version,
|
||||
created_at=manifest["created_at"],
|
||||
release_notes=manifest["release_notes"],
|
||||
payload_sha256=digest,
|
||||
payload_bytes=payload_bytes,
|
||||
expanded_bytes=manifest["expanded_bytes"],
|
||||
)
|
||||
|
||||
|
||||
def _safe_member_path(name: str) -> Path:
|
||||
pure = PurePosixPath(name)
|
||||
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
|
||||
raise OtaPackageError(f"unsafe OTA payload path: {name}")
|
||||
if pure.parts[0] not in {"software", "wheelhouse", "system-dependencies"}:
|
||||
raise OtaPackageError(f"unexpected OTA payload root: {pure.parts[0]}")
|
||||
return Path(*pure.parts)
|
||||
|
||||
|
||||
def extract_payload(package: OtaPackageInfo, destination: Path) -> None:
|
||||
target = Path(destination)
|
||||
target.mkdir(parents=True, exist_ok=False)
|
||||
expanded = 0
|
||||
try:
|
||||
with zipfile.ZipFile(package.path, "r") as archive:
|
||||
with archive.open("payload.tar.gz", "r") as payload:
|
||||
with tarfile.open(fileobj=payload, mode="r|gz") as tar:
|
||||
for member in tar:
|
||||
relative = _safe_member_path(member.name)
|
||||
output = target / relative
|
||||
if member.isdir():
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if not member.isfile():
|
||||
raise OtaPackageError("OTA payload may contain only regular files and directories")
|
||||
expanded += member.size
|
||||
if expanded > MAX_OTA_EXPANDED_BYTES or expanded > package.expanded_bytes:
|
||||
raise OtaPackageError("OTA payload expands beyond its declared limit")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = tar.extractfile(member)
|
||||
if source is None:
|
||||
raise OtaPackageError(f"OTA payload member cannot be read: {member.name}")
|
||||
with output.open("xb") as handle:
|
||||
shutil.copyfileobj(source, handle, length=1024 * 1024)
|
||||
output.chmod(member.mode & 0o777 or 0o644)
|
||||
if expanded != package.expanded_bytes:
|
||||
raise OtaPackageError("OTA expanded size does not match the manifest")
|
||||
if not (target / "software" / "VERSION").is_file():
|
||||
raise OtaPackageError("OTA payload does not contain software/VERSION")
|
||||
if not (target / "wheelhouse" / "SHA256SUMS").is_file():
|
||||
raise OtaPackageError("OTA payload does not contain the offline wheel manifest")
|
||||
except BaseException:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _source_file_allowed(path: Path, source_root: Path) -> bool:
|
||||
relative = path.relative_to(source_root)
|
||||
if any(part in _EXCLUDED_PARTS | {"system-dependencies", "output", ".playwright-cli"} for part in relative.parts):
|
||||
return False
|
||||
if relative.as_posix() in _NATIVE_BUILD_OUTPUTS:
|
||||
return False
|
||||
if path.suffix in {".pyc", ".pyo"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _tar_add_file(tar: tarfile.TarFile, source: Path, archive_name: str) -> int:
|
||||
info = tar.gettarinfo(str(source), arcname=archive_name)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
info.mtime = 0
|
||||
info.mode = 0o755 if source.suffix == ".sh" else 0o644
|
||||
with source.open("rb") as handle:
|
||||
tar.addfile(info, handle)
|
||||
return info.size
|
||||
|
||||
|
||||
def build_package(
|
||||
source_root: Path,
|
||||
wheelhouse: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
version: SoftwareVersion,
|
||||
release_notes: str,
|
||||
created_at: datetime | None = None,
|
||||
system_dependencies: Path | None = None,
|
||||
) -> OtaPackageInfo:
|
||||
source_root = Path(source_root).resolve()
|
||||
wheelhouse = Path(wheelhouse).resolve()
|
||||
system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None
|
||||
if version.patch != 0 and version >= SoftwareVersion(1, 1, 0) and system_dependencies is not None:
|
||||
raise OtaPackageError("普通补丁包不得携带系统软件,请导出新的 minor .0 安装包")
|
||||
if SoftwareVersion.parse((source_root / "VERSION").read_text(encoding="utf-8")) != version:
|
||||
raise OtaPackageError("requested package version does not match source VERSION")
|
||||
if not (wheelhouse / "SHA256SUMS").is_file():
|
||||
raise OtaPackageError("offline wheelhouse SHA256SUMS is missing")
|
||||
if system_dependencies is not None and not (system_dependencies / "SHA256SUMS").is_file():
|
||||
raise OtaPackageError("offline system dependency SHA256SUMS is missing")
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if output.exists():
|
||||
raise FileExistsError(output)
|
||||
timestamp = (created_at or datetime.now(timezone.utc)).astimezone().isoformat(timespec="seconds")
|
||||
with tempfile.TemporaryDirectory(prefix="matrix-ota-build-") as temporary:
|
||||
payload_path = Path(temporary) / "payload.tar.gz"
|
||||
expanded = 0
|
||||
with tarfile.open(payload_path, "w:gz", format=tarfile.PAX_FORMAT) as tar:
|
||||
for path in sorted(source_root.rglob("*"), key=lambda item: item.relative_to(source_root).as_posix()):
|
||||
if path.is_file() and _source_file_allowed(path, source_root):
|
||||
expanded += _tar_add_file(tar, path, f"software/{path.relative_to(source_root).as_posix()}")
|
||||
for path in sorted(wheelhouse.rglob("*"), key=lambda item: item.relative_to(wheelhouse).as_posix()):
|
||||
if path.is_file():
|
||||
expanded += _tar_add_file(tar, path, f"wheelhouse/{path.relative_to(wheelhouse).as_posix()}")
|
||||
if system_dependencies is not None:
|
||||
for path in sorted(system_dependencies.rglob("*"), key=lambda item: item.relative_to(system_dependencies).as_posix()):
|
||||
if path.is_file():
|
||||
expanded += _tar_add_file(
|
||||
tar,
|
||||
path,
|
||||
f"software/system-dependencies/frpc/{path.relative_to(system_dependencies).as_posix()}",
|
||||
)
|
||||
payload_bytes = payload_path.stat().st_size
|
||||
if payload_bytes > MAX_OTA_PAYLOAD_BYTES or expanded > MAX_OTA_EXPANDED_BYTES:
|
||||
raise OtaPackageError("generated OTA payload exceeds the supported limit")
|
||||
digest = hashlib.sha256(payload_path.read_bytes()).hexdigest()
|
||||
manifest = {
|
||||
"format_version": package_format(version),
|
||||
"product": PRODUCT_ID,
|
||||
"software_version": str(version),
|
||||
"created_at": timestamp,
|
||||
"release_notes": str(release_notes).strip(),
|
||||
"payload_sha256": digest,
|
||||
"payload_bytes": payload_bytes,
|
||||
"expanded_bytes": expanded,
|
||||
}
|
||||
with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
archive.write(payload_path, "payload.tar.gz")
|
||||
return inspect_package(output)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Ordered dependency checkpoints, independent of removable release artifacts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .versioning import SoftwareVersion
|
||||
|
||||
BRIDGE_VERSION = SoftwareVersion(1, 1, 0)
|
||||
|
||||
|
||||
def check_upgrade(current: SoftwareVersion, target: SoftwareVersion) -> None:
|
||||
if target <= current:
|
||||
raise ValueError(f"OTA target {target} must be newer than installed version {current}")
|
||||
if current.major != target.major:
|
||||
raise ValueError("跨主版本升级尚未登记,请使用明确支持此路径的安装包")
|
||||
if target.minor > current.minor:
|
||||
required = SoftwareVersion(current.major, current.minor + 1, 0)
|
||||
if target != required:
|
||||
raise ValueError(f"请先安装 {required} 软件安装包,不能跳过必经升级版本")
|
||||
|
||||
|
||||
def package_format(version: SoftwareVersion) -> int:
|
||||
return 1 if version <= BRIDGE_VERSION else 2
|
||||
|
||||
|
||||
def release_metadata(version: SoftwareVersion) -> dict:
|
||||
checkpoint = version.patch == 0 and version >= BRIDGE_VERSION
|
||||
predecessor = None
|
||||
if version >= BRIDGE_VERSION:
|
||||
predecessor = str(SoftwareVersion(version.major, version.minor - 1 if checkpoint else version.minor, 0))
|
||||
return {"package_kind": "software-install" if checkpoint else "application",
|
||||
"required_checkpoint": predecessor, "is_checkpoint": checkpoint}
|
||||
|
||||
|
||||
def read_policy(source: Path) -> dict:
|
||||
value = json.loads((source / "UPGRADE_POLICY.json").read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict) or set(value) != {"schema_version", "checkpoints"} or value["schema_version"] != 1:
|
||||
raise ValueError("invalid upgrade policy")
|
||||
previous = SoftwareVersion(1, 0, 0)
|
||||
for entry in value["checkpoints"]:
|
||||
version = SoftwareVersion.parse(entry["version"])
|
||||
if version != SoftwareVersion(previous.major, previous.minor + 1, 0) or not entry["components"]:
|
||||
raise ValueError("dependency checkpoints must be consecutive minor .0 versions")
|
||||
for name, component in entry["components"].items():
|
||||
if name != "frpc" or set(component) != {"version", "sha256", "bundle"}:
|
||||
raise ValueError("component installer is not registered")
|
||||
if len(component["sha256"]) != 64 or any(c not in "0123456789abcdef" for c in component["sha256"]):
|
||||
raise ValueError("invalid component digest")
|
||||
bundle = Path(component["bundle"])
|
||||
if bundle.is_absolute() or ".." in bundle.parts or "\\" in component["bundle"]:
|
||||
raise ValueError("unsafe component bundle path")
|
||||
previous = version
|
||||
return value
|
||||
|
||||
|
||||
def required_components(source: Path, version: SoftwareVersion) -> dict:
|
||||
components = {}
|
||||
for entry in read_policy(source)["checkpoints"]:
|
||||
if SoftwareVersion.parse(entry["version"]) <= version:
|
||||
components.update(entry["components"])
|
||||
return components
|
||||
|
||||
|
||||
def export_bundle(source: Path, dependency_root: Path, current: SoftwareVersion, target: SoftwareVersion) -> Path | None:
|
||||
check_upgrade(current, target)
|
||||
policy = read_policy(source)
|
||||
if policy["checkpoints"] and target < SoftwareVersion.parse(policy["checkpoints"][-1]["version"]):
|
||||
raise ValueError("源码已引入新系统依赖;必须先导出已登记的 minor .0 软件安装包")
|
||||
matching = [e for e in policy["checkpoints"] if e["version"] == str(target)]
|
||||
if target.patch == 0 and not matching:
|
||||
raise ValueError("软件安装包必须登记本次依赖变化")
|
||||
components = required_components(source, target)
|
||||
# Verify the actual offline source, including on patch exports: silently replacing
|
||||
# a binary at the same version must not bypass a dependency checkpoint.
|
||||
for component in components.values():
|
||||
binary = dependency_root / component["bundle"] / "frpc"
|
||||
if hashlib.sha256(binary.read_bytes()).hexdigest() != component["sha256"]:
|
||||
raise ValueError("系统依赖发生变化;请登记新的 minor .0 软件安装版本")
|
||||
if not matching:
|
||||
return None
|
||||
return dependency_root / matching[0]["components"]["frpc"]["bundle"]
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
OTA_STATE_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def write_json(path: Path, document: dict[str, Any]) -> None:
|
||||
atomic_write_bytes(
|
||||
Path(path),
|
||||
(json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
def read_last_result(data_root: Path) -> dict[str, Any] | None:
|
||||
document = read_json(Path(data_root) / "ota" / "state.json")
|
||||
if not document or document.get("schema_version") != OTA_STATE_SCHEMA_VERSION:
|
||||
return None
|
||||
result = document.get("last_result")
|
||||
return dict(result) if isinstance(result, dict) else None
|
||||
|
||||
|
||||
def write_last_result(data_root: Path, result: dict[str, Any]) -> None:
|
||||
write_json(
|
||||
Path(data_root) / "ota" / "state.json",
|
||||
{"schema_version": OTA_STATE_SCHEMA_VERSION, "last_result": dict(result)},
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
_VERSION_PATTERN = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
|
||||
_FEATURE_UPDATED_AT_PATTERN = re.compile(
|
||||
r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]\+08:00$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class SoftwareVersion:
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: str) -> "SoftwareVersion":
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("software version must be a string")
|
||||
match = _VERSION_PATTERN.fullmatch(value.strip())
|
||||
if match is None:
|
||||
raise ValueError("software version must use strict MAJOR.MINOR.PATCH digits")
|
||||
return cls(*(int(part) for part in match.groups()))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.major}.{self.minor}.{self.patch}"
|
||||
|
||||
|
||||
def read_software_version(code_root: Path) -> SoftwareVersion:
|
||||
path = Path(code_root) / "VERSION"
|
||||
try:
|
||||
return SoftwareVersion.parse(path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"software VERSION file is unreadable: {path}") from exc
|
||||
|
||||
|
||||
def read_feature_updated_at(code_root: Path) -> str:
|
||||
path = Path(code_root) / "FEATURE_UPDATED_AT"
|
||||
try:
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"feature update timestamp file is unreadable: {path}") from exc
|
||||
if _FEATURE_UPDATED_AT_PATTERN.fullmatch(value) is None:
|
||||
raise ValueError("feature update timestamp must use YYYY-MM-DDTHH:MM+08:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("feature update timestamp is not a valid calendar time") from exc
|
||||
if parsed.utcoffset() != timedelta(hours=8):
|
||||
raise ValueError("feature update timestamp must use Beijing UTC+08:00")
|
||||
return value
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def atomic_write_bytes(path: Path, content: bytes) -> None:
|
||||
"""Durably replace *path* without exposing a partially written file."""
|
||||
atomic_write_many_bytes(((Path(path), content),))
|
||||
|
||||
|
||||
def atomic_write_many_bytes(writes: Iterable[tuple[Path, bytes]]) -> None:
|
||||
"""Durably replace one or more files and roll back a failed transaction."""
|
||||
entries = [(Path(path), bytes(content)) for path, content in writes]
|
||||
if not entries:
|
||||
return
|
||||
targets = [path for path, _content in entries]
|
||||
if len(set(targets)) != len(targets):
|
||||
raise ValueError("atomic write transaction contains duplicate paths")
|
||||
|
||||
originals: dict[Path, bytes | None] = {}
|
||||
temporaries: dict[Path, Path] = {}
|
||||
replaced: list[Path] = []
|
||||
parents = sorted({target.parent for target in targets}, key=str)
|
||||
|
||||
try:
|
||||
for target, _content in entries:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
originals[target] = target.read_bytes() if target.exists() else None
|
||||
for target, content in entries:
|
||||
temporary = _write_temporary(target, content)
|
||||
temporaries[target] = temporary
|
||||
for parent in parents:
|
||||
_fsync_directory(parent)
|
||||
for target, _content in entries:
|
||||
os.replace(temporaries[target], target)
|
||||
replaced.append(target)
|
||||
for parent in parents:
|
||||
_fsync_directory(parent)
|
||||
except BaseException as original_error:
|
||||
_cleanup_temporaries(temporaries.values())
|
||||
if replaced:
|
||||
try:
|
||||
_restore_originals(replaced, originals)
|
||||
except BaseException as recovery_error:
|
||||
logger.critical(
|
||||
"Unable to roll back failed durable write transaction",
|
||||
exc_info=recovery_error,
|
||||
)
|
||||
raise RuntimeError(
|
||||
"durable write failed and its previous files could not be restored"
|
||||
) from original_error
|
||||
raise
|
||||
|
||||
|
||||
def _write_temporary(target: Path, content: bytes) -> Path:
|
||||
temporary = target.with_name(f".{target.name}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
return temporary
|
||||
|
||||
|
||||
def _cleanup_temporaries(paths: Iterable[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Unable to clean temporary persistence file %s", path, exc_info=True)
|
||||
|
||||
|
||||
def _restore_originals(
|
||||
replaced: Iterable[Path],
|
||||
originals: dict[Path, bytes | None],
|
||||
) -> None:
|
||||
targets = list(replaced)
|
||||
recovery_files: dict[Path, Path] = {}
|
||||
try:
|
||||
for target in targets:
|
||||
original = originals[target]
|
||||
if original is not None:
|
||||
recovery_files[target] = _write_temporary(target, original)
|
||||
for target in targets:
|
||||
recovery = recovery_files.get(target)
|
||||
if recovery is None:
|
||||
target.unlink(missing_ok=True)
|
||||
else:
|
||||
os.replace(recovery, target)
|
||||
for parent in sorted({target.parent for target in targets}, key=str):
|
||||
_fsync_directory(parent)
|
||||
finally:
|
||||
_cleanup_temporaries(recovery_files.values())
|
||||
|
||||
|
||||
def _fsync_directory(directory: Path) -> None:
|
||||
"""Sync a directory entry where the platform supports directory handles."""
|
||||
if os.name == "nt":
|
||||
return
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
descriptor = os.open(directory, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""屏幕供电监测组件。"""
|
||||
|
||||
from app.power.voltage import VoltageMonitor
|
||||
|
||||
__all__ = ["VoltageMonitor"]
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_FLOOR
|
||||
|
||||
LOW_VOLTAGE_CRITICAL_THRESHOLD = 4.5
|
||||
LOW_VOLTAGE_LIMIT_THRESHOLD = 4.8
|
||||
LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT = 35
|
||||
|
||||
PROTECTION_MODE_DISABLED = "disabled"
|
||||
PROTECTION_MODE_UNAVAILABLE = "unavailable"
|
||||
PROTECTION_MODE_INACTIVE = "inactive"
|
||||
PROTECTION_MODE_LIMITING = "limiting"
|
||||
PROTECTION_MODE_CRITICAL = "critical"
|
||||
|
||||
NORMAL_SAMPLING_INTERVAL_SECONDS = 5.0
|
||||
LIMITING_SAMPLING_INTERVAL_SECONDS = 1.0
|
||||
FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5
|
||||
MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5
|
||||
MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS = 2.0
|
||||
MAX_CRITICAL_SLOWDOWN_STEP_SECONDS = 0.5
|
||||
ERROR_RETRY_INTERVALS_SECONDS = (1.0, 2.0, 5.0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LowVoltageProtectionDirective:
|
||||
sequence: int
|
||||
enabled: bool
|
||||
mode: str
|
||||
brightness_limit_percent: int | None
|
||||
reading_stale: bool
|
||||
|
||||
|
||||
def brightness_limit_for_voltage(volts: float) -> int | None:
|
||||
"""Return the output cap for a calibrated, unrounded voltage reading."""
|
||||
voltage = float(volts)
|
||||
if not math.isfinite(voltage):
|
||||
raise ValueError("voltage must be finite")
|
||||
if voltage > LOW_VOLTAGE_LIMIT_THRESHOLD:
|
||||
return None
|
||||
if voltage < LOW_VOLTAGE_CRITICAL_THRESHOLD:
|
||||
return LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT
|
||||
if voltage == LOW_VOLTAGE_LIMIT_THRESHOLD:
|
||||
return 100
|
||||
if voltage == LOW_VOLTAGE_CRITICAL_THRESHOLD:
|
||||
return 50
|
||||
decimal_voltage = Decimal(str(voltage))
|
||||
limit = int(
|
||||
(
|
||||
Decimal(50)
|
||||
+ Decimal(50)
|
||||
* (decimal_voltage - Decimal("4.5"))
|
||||
/ Decimal("0.3")
|
||||
).to_integral_value(rounding=ROUND_FLOOR)
|
||||
)
|
||||
return max(50, min(100, limit))
|
||||
|
||||
|
||||
def protection_directive_for_voltage(
|
||||
sequence: int,
|
||||
enabled: bool,
|
||||
volts: float | None,
|
||||
*,
|
||||
reading_stale: bool = False,
|
||||
) -> LowVoltageProtectionDirective:
|
||||
if not enabled:
|
||||
return LowVoltageProtectionDirective(
|
||||
sequence=sequence,
|
||||
enabled=False,
|
||||
mode=PROTECTION_MODE_DISABLED,
|
||||
brightness_limit_percent=None,
|
||||
reading_stale=False,
|
||||
)
|
||||
if volts is None:
|
||||
return LowVoltageProtectionDirective(
|
||||
sequence=sequence,
|
||||
enabled=True,
|
||||
mode=PROTECTION_MODE_UNAVAILABLE,
|
||||
brightness_limit_percent=None,
|
||||
reading_stale=reading_stale,
|
||||
)
|
||||
|
||||
limit = brightness_limit_for_voltage(volts)
|
||||
if volts > LOW_VOLTAGE_LIMIT_THRESHOLD:
|
||||
mode = PROTECTION_MODE_INACTIVE
|
||||
limit = None
|
||||
elif volts < LOW_VOLTAGE_CRITICAL_THRESHOLD:
|
||||
mode = PROTECTION_MODE_CRITICAL
|
||||
else:
|
||||
mode = PROTECTION_MODE_LIMITING
|
||||
return LowVoltageProtectionDirective(
|
||||
sequence=sequence,
|
||||
enabled=True,
|
||||
mode=mode,
|
||||
brightness_limit_percent=limit,
|
||||
reading_stale=reading_stale,
|
||||
)
|
||||
|
||||
|
||||
def same_protection_level(
|
||||
left: LowVoltageProtectionDirective,
|
||||
right: LowVoltageProtectionDirective,
|
||||
) -> bool:
|
||||
return (
|
||||
left.enabled == right.enabled
|
||||
and left.mode == right.mode
|
||||
and left.brightness_limit_percent == right.brightness_limit_percent
|
||||
)
|
||||
|
||||
|
||||
def is_more_restrictive(
|
||||
candidate: LowVoltageProtectionDirective,
|
||||
current: LowVoltageProtectionDirective,
|
||||
) -> bool:
|
||||
ranks = {
|
||||
PROTECTION_MODE_DISABLED: 0,
|
||||
PROTECTION_MODE_UNAVAILABLE: 0,
|
||||
PROTECTION_MODE_INACTIVE: 0,
|
||||
PROTECTION_MODE_LIMITING: 1,
|
||||
PROTECTION_MODE_CRITICAL: 2,
|
||||
}
|
||||
candidate_rank = ranks[candidate.mode]
|
||||
current_rank = ranks[current.mode]
|
||||
if candidate_rank != current_rank:
|
||||
return candidate_rank > current_rank
|
||||
if candidate.mode == PROTECTION_MODE_LIMITING:
|
||||
assert candidate.brightness_limit_percent is not None
|
||||
assert current.brightness_limit_percent is not None
|
||||
return candidate.brightness_limit_percent < current.brightness_limit_percent
|
||||
return False
|
||||
|
||||
|
||||
def more_conservative(
|
||||
left: LowVoltageProtectionDirective,
|
||||
right: LowVoltageProtectionDirective,
|
||||
) -> LowVoltageProtectionDirective:
|
||||
"""Choose the stricter directive while retaining the newest sequence on a tie."""
|
||||
if is_more_restrictive(left, right):
|
||||
return left
|
||||
if is_more_restrictive(right, left):
|
||||
return right
|
||||
return right if right.sequence >= left.sequence else left
|
||||
|
||||
|
||||
def error_retry_interval(consecutive_errors: int) -> float:
|
||||
if consecutive_errors <= 0:
|
||||
return NORMAL_SAMPLING_INTERVAL_SECONDS
|
||||
index = min(consecutive_errors, len(ERROR_RETRY_INTERVALS_SECONDS)) - 1
|
||||
return ERROR_RETRY_INTERVALS_SECONDS[index]
|
||||
|
||||
|
||||
def successful_sampling_interval(
|
||||
*,
|
||||
enabled: bool,
|
||||
mode: str,
|
||||
volts: float,
|
||||
previous_volts: float | None,
|
||||
elapsed_seconds: float | None,
|
||||
previous_interval_seconds: float,
|
||||
first_critical_sample: bool,
|
||||
) -> float:
|
||||
"""Return the next group-start interval for a successful voltage sample."""
|
||||
if not enabled:
|
||||
return NORMAL_SAMPLING_INTERVAL_SECONDS
|
||||
if mode == PROTECTION_MODE_LIMITING:
|
||||
return LIMITING_SAMPLING_INTERVAL_SECONDS
|
||||
if mode == PROTECTION_MODE_CRITICAL:
|
||||
if first_critical_sample:
|
||||
return FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS
|
||||
rate = _voltage_rate(volts, previous_volts, elapsed_seconds)
|
||||
target = _clamp(
|
||||
MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS,
|
||||
MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS,
|
||||
0.02 / max(abs(rate), 0.01),
|
||||
)
|
||||
if target > previous_interval_seconds:
|
||||
return min(
|
||||
target,
|
||||
previous_interval_seconds + MAX_CRITICAL_SLOWDOWN_STEP_SECONDS,
|
||||
)
|
||||
return target
|
||||
if mode == PROTECTION_MODE_INACTIVE:
|
||||
rate = _voltage_rate(volts, previous_volts, elapsed_seconds)
|
||||
if rate < 0:
|
||||
seconds_to_limit = (volts - LOW_VOLTAGE_LIMIT_THRESHOLD) / -rate
|
||||
return _clamp(1.0, NORMAL_SAMPLING_INTERVAL_SECONDS, seconds_to_limit)
|
||||
return NORMAL_SAMPLING_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def _voltage_rate(
|
||||
volts: float,
|
||||
previous_volts: float | None,
|
||||
elapsed_seconds: float | None,
|
||||
) -> float:
|
||||
if (
|
||||
previous_volts is None
|
||||
or elapsed_seconds is None
|
||||
or not math.isfinite(elapsed_seconds)
|
||||
or elapsed_seconds <= 0
|
||||
):
|
||||
return 0.0
|
||||
return (volts - previous_volts) / elapsed_seconds
|
||||
|
||||
|
||||
def _clamp(minimum: float, maximum: float, value: float) -> float:
|
||||
return max(minimum, min(maximum, value))
|
||||
@@ -0,0 +1,754 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config.store import ConfigStore
|
||||
from app.power.protection import (
|
||||
NORMAL_SAMPLING_INTERVAL_SECONDS,
|
||||
LowVoltageProtectionDirective,
|
||||
error_retry_interval,
|
||||
is_more_restrictive,
|
||||
more_conservative,
|
||||
protection_directive_for_voltage,
|
||||
same_protection_level,
|
||||
successful_sampling_interval,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BUS = "/dev/i2c-1"
|
||||
DEFAULT_ADDRESS = 0x48
|
||||
I2C_SLAVE = 0x0703
|
||||
|
||||
CONFIG_CONTINUOUS_15SPS_PGA1 = 0x0C
|
||||
CONFIG_VALUE_MASK = 0x7F
|
||||
DATA_READY_MASK = 0x80
|
||||
|
||||
REFERENCE_VOLTS = 2.048
|
||||
ADC_CODE_SCALE = 32768
|
||||
BOARD_DIVIDER_RATIO = 6.1
|
||||
CONVERSION_WAIT_SECONDS = (1.0 / 15.0) + 0.010
|
||||
FRESH_DATA_TIMEOUT_SECONDS = 0.75
|
||||
|
||||
NORMAL_SAMPLE_COUNT = 5
|
||||
CALIBRATION_SAMPLE_COUNT = 15
|
||||
POLL_INTERVAL_SECONDS = 5.0
|
||||
CALIBRATION_PROPOSAL_SECONDS = 120
|
||||
CALIBRATION_REFERENCE_MIN = 4.5
|
||||
CALIBRATION_REFERENCE_MAX = 5.5
|
||||
CALIBRATION_FACTOR_MIN = 0.8
|
||||
CALIBRATION_FACTOR_MAX = 1.2
|
||||
CALIBRATION_MAX_SPREAD_VOLTS = 0.05
|
||||
PROTECTION_ENFORCEMENT_ERROR_CODE = "display_enforcement_failed"
|
||||
|
||||
|
||||
class AdcError(RuntimeError):
|
||||
code = "adc_error"
|
||||
|
||||
|
||||
class AdcDisconnectedError(AdcError):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class AdcProtocolError(AdcError):
|
||||
code = "protocol_error"
|
||||
|
||||
|
||||
class VoltageMonitorError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CalibrationError(VoltageMonitorError):
|
||||
pass
|
||||
|
||||
|
||||
class CalibrationUnavailableError(CalibrationError):
|
||||
pass
|
||||
|
||||
|
||||
class CalibrationConflictError(CalibrationError):
|
||||
pass
|
||||
|
||||
|
||||
class AdcDevice(Protocol):
|
||||
def __enter__(self) -> "AdcDevice": ...
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: ...
|
||||
|
||||
def configure(self) -> None: ...
|
||||
|
||||
def read_fresh(self) -> tuple[int, int]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SampleGroup:
|
||||
raw_median: float
|
||||
uncalibrated_volts: float
|
||||
config: int
|
||||
spread_volts: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationProposal:
|
||||
proposal_id: str
|
||||
reference_volts: float
|
||||
uncalibrated_volts: float
|
||||
current_factor: float
|
||||
proposed_factor: float
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
def response(self) -> dict[str, Any]:
|
||||
return {
|
||||
"proposal_id": self.proposal_id,
|
||||
"created_at": isoformat_utc(self.created_at),
|
||||
"expires_at": isoformat_utc(self.expires_at),
|
||||
"reference_volts": self.reference_volts,
|
||||
"uncalibrated_volts": self.uncalibrated_volts,
|
||||
"current_factor": self.current_factor,
|
||||
"proposed_factor": self.proposed_factor,
|
||||
"projected_volts": self.uncalibrated_volts * self.proposed_factor,
|
||||
}
|
||||
|
||||
|
||||
def isoformat_utc(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def voltage_from_raw(raw: int | float, calibration_factor: float = 1.0) -> float:
|
||||
if not -32768 <= raw <= 32767:
|
||||
raise ValueError("ADS1110 raw 必须在 -32768 到 32767 之间")
|
||||
if not math.isfinite(calibration_factor) or calibration_factor <= 0:
|
||||
raise ValueError("校准系数必须是正有限数")
|
||||
return float(raw) * REFERENCE_VOLTS / ADC_CODE_SCALE * BOARD_DIVIDER_RATIO * calibration_factor
|
||||
|
||||
|
||||
def validate_conversion(data: bytes) -> tuple[int, int]:
|
||||
if len(data) != 3:
|
||||
raise AdcProtocolError(f"ADS1110 应返回 3 字节,实际收到 {len(data)} 字节")
|
||||
raw = int.from_bytes(data[:2], byteorder="big", signed=True)
|
||||
config = data[2]
|
||||
if (config & CONFIG_VALUE_MASK) != CONFIG_CONTINUOUS_15SPS_PGA1:
|
||||
raise AdcProtocolError(
|
||||
f"ADS1110 配置不符:收到 0x{config:02X},低 7 位应为 0x0C"
|
||||
)
|
||||
return raw, config
|
||||
|
||||
|
||||
def disconnected_from_os_error(action: str, exc: OSError) -> AdcDisconnectedError:
|
||||
remote_io = getattr(errno, "EREMOTEIO", 121)
|
||||
if exc.errno in (remote_io, errno.ENXIO):
|
||||
return AdcDisconnectedError("address_no_response", f"{action}:I2C 地址 0x48 没有响应")
|
||||
if exc.errno == errno.EBUSY:
|
||||
return AdcDisconnectedError("device_busy", f"{action}:I2C 地址 0x48 已被占用")
|
||||
if exc.errno in (errno.EACCES, errno.EPERM):
|
||||
return AdcDisconnectedError("permission_denied", f"{action}:没有 I2C 访问权限")
|
||||
return AdcDisconnectedError("io_error", f"{action}:{exc.strerror or exc}")
|
||||
|
||||
|
||||
class Ads1110Device:
|
||||
def __init__(self, bus_path: str = DEFAULT_BUS, address: int = DEFAULT_ADDRESS) -> None:
|
||||
self.bus_path = bus_path
|
||||
self.address = address
|
||||
self._fd: int | None = None
|
||||
|
||||
def __enter__(self) -> "Ads1110Device":
|
||||
if os.name != "posix":
|
||||
raise AdcDisconnectedError("unsupported_platform", "真实 I2C 读取只支持 Linux")
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError as exc:
|
||||
raise AdcDisconnectedError("unsupported_platform", "当前 Python 缺少 Linux fcntl") from exc
|
||||
try:
|
||||
self._fd = os.open(self.bus_path, os.O_RDWR)
|
||||
except FileNotFoundError as exc:
|
||||
raise AdcDisconnectedError("bus_missing", f"没有找到 {self.bus_path}") from exc
|
||||
except PermissionError as exc:
|
||||
raise AdcDisconnectedError("permission_denied", f"没有权限打开 {self.bus_path}") from exc
|
||||
except OSError as exc:
|
||||
raise disconnected_from_os_error(f"打开 {self.bus_path} 失败", exc) from exc
|
||||
try:
|
||||
fcntl.ioctl(self._fd, I2C_SLAVE, self.address)
|
||||
except OSError as exc:
|
||||
self.close()
|
||||
raise disconnected_from_os_error("选择 I2C 从设备失败", exc) from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._fd is not None:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
|
||||
def _require_fd(self) -> int:
|
||||
if self._fd is None:
|
||||
raise AdcDisconnectedError("device_closed", "I2C 设备尚未打开")
|
||||
return self._fd
|
||||
|
||||
def configure(self) -> None:
|
||||
try:
|
||||
written = os.write(self._require_fd(), bytes([CONFIG_CONTINUOUS_15SPS_PGA1]))
|
||||
except OSError as exc:
|
||||
raise disconnected_from_os_error("写入 ADS1110 配置失败", exc) from exc
|
||||
if written != 1:
|
||||
raise AdcProtocolError(f"ADS1110 配置应写入 1 字节,实际写入 {written} 字节")
|
||||
|
||||
def read_fresh(self) -> tuple[int, int]:
|
||||
deadline = time.monotonic() + FRESH_DATA_TIMEOUT_SECONDS
|
||||
while True:
|
||||
time.sleep(CONVERSION_WAIT_SECONDS)
|
||||
try:
|
||||
data = os.read(self._require_fd(), 3)
|
||||
except OSError as exc:
|
||||
raise disconnected_from_os_error("读取 ADS1110 失败", exc) from exc
|
||||
raw, config = validate_conversion(data)
|
||||
if (config & DATA_READY_MASK) == 0:
|
||||
return raw, config
|
||||
if time.monotonic() >= deadline:
|
||||
raise AdcProtocolError("等待 ADS1110 新转换数据超时")
|
||||
|
||||
|
||||
class VoltageMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
config_store: ConfigStore,
|
||||
device_factory: Callable[[], AdcDevice] | None = None,
|
||||
poll_interval: float | None = None,
|
||||
) -> None:
|
||||
if poll_interval is not None and (
|
||||
isinstance(poll_interval, bool)
|
||||
or not math.isfinite(float(poll_interval))
|
||||
or float(poll_interval) <= 0
|
||||
):
|
||||
raise ValueError("poll_interval must be a positive finite number")
|
||||
|
||||
self.config_store = config_store
|
||||
self._device_factory = device_factory or Ads1110Device
|
||||
self._fixed_poll_interval = (
|
||||
None if poll_interval is None else float(poll_interval)
|
||||
)
|
||||
self._state_lock = threading.RLock()
|
||||
self._io_lock = threading.Lock()
|
||||
self._listener_lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._reschedule = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._proposal: CalibrationProposal | None = None
|
||||
self._sequence = 0
|
||||
self._last_published_sequence = 0
|
||||
self._snapshot: dict[str, Any] = {
|
||||
"status": "starting",
|
||||
"volts": None,
|
||||
"sampled_at": None,
|
||||
"calibrated": self._is_calibrated(),
|
||||
"error_code": None,
|
||||
}
|
||||
|
||||
enabled = self.config_store.config["low_voltage_protection_enabled"]
|
||||
self._protection = protection_directive_for_voltage(0, enabled, None)
|
||||
self._protection_revision = 0
|
||||
self._protection_listener: (
|
||||
Callable[[LowVoltageProtectionDirective], None] | None
|
||||
) = None
|
||||
self._enforced_sequence = -1
|
||||
self._enforcement_error_code: str | None = None
|
||||
self._has_successful_protection_sample = False
|
||||
self._relaxation_candidate: LowVoltageProtectionDirective | None = None
|
||||
self._relaxation_success_count = 0
|
||||
self._last_successful_voltage: float | None = None
|
||||
self._last_successful_monotonic: float | None = None
|
||||
self._last_sample_mode: str | None = None
|
||||
self._consecutive_errors = 0
|
||||
self._sampling_interval = (
|
||||
self._fixed_poll_interval
|
||||
if self._fixed_poll_interval is not None
|
||||
else NORMAL_SAMPLING_INTERVAL_SECONDS
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
with self._state_lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._reschedule.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="screen-voltage-monitor",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop.set()
|
||||
self._reschedule.set()
|
||||
with self._state_lock:
|
||||
thread = self._thread
|
||||
interval = self._sampling_interval
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=max(2.0, min(6.0, interval + 1.0)))
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
with self._state_lock:
|
||||
return copy.deepcopy(self._snapshot)
|
||||
|
||||
def get_protection_status(self) -> dict[str, Any]:
|
||||
with self._state_lock:
|
||||
status = {
|
||||
"enabled": self._protection.enabled,
|
||||
"mode": self._protection.mode,
|
||||
"brightness_limit_percent": (
|
||||
self._protection.brightness_limit_percent
|
||||
),
|
||||
"reading_stale": self._protection.reading_stale,
|
||||
"revision": self._protection_revision,
|
||||
"sampling_interval_seconds": self._sampling_interval,
|
||||
"enforcement_error_code": self._enforcement_error_code,
|
||||
}
|
||||
return copy.deepcopy(status)
|
||||
|
||||
def set_protection_listener(
|
||||
self,
|
||||
callback: Callable[[LowVoltageProtectionDirective], None] | None,
|
||||
) -> None:
|
||||
if callback is not None and not callable(callback):
|
||||
raise TypeError("protection listener must be callable or None")
|
||||
with self._state_lock:
|
||||
self._protection_listener = callback
|
||||
self._enforced_sequence = -1
|
||||
self._enforcement_error_code = None
|
||||
self._retry_protection_listener()
|
||||
|
||||
def set_protection_enabled(self, enabled: bool) -> dict[str, Any]:
|
||||
if type(enabled) is not bool:
|
||||
raise TypeError("enabled must be a boolean")
|
||||
schedule_changed = False
|
||||
with self._state_lock:
|
||||
if enabled != self._protection.enabled:
|
||||
sequence = self._allocate_sequence_locked()
|
||||
self._last_published_sequence = sequence
|
||||
self._replace_protection_locked(
|
||||
protection_directive_for_voltage(sequence, enabled, None)
|
||||
)
|
||||
self._has_successful_protection_sample = False
|
||||
self._clear_relaxation_locked()
|
||||
self._last_successful_voltage = None
|
||||
self._last_successful_monotonic = None
|
||||
self._last_sample_mode = None
|
||||
self._consecutive_errors = 0
|
||||
schedule_changed = self._set_sampling_interval_locked(
|
||||
self._fixed_poll_interval
|
||||
if self._fixed_poll_interval is not None
|
||||
else NORMAL_SAMPLING_INTERVAL_SECONDS
|
||||
)
|
||||
if schedule_changed:
|
||||
self._request_reschedule()
|
||||
self._retry_protection_listener()
|
||||
return self.get_protection_status()
|
||||
|
||||
def sample_now(self, sample_count: int = NORMAL_SAMPLE_COUNT) -> dict[str, Any]:
|
||||
sequence = self._allocate_sequence()
|
||||
try:
|
||||
group = self._read_group(sample_count)
|
||||
return self._publish_success(sequence, group)
|
||||
except AdcDisconnectedError as exc:
|
||||
return self._publish_failure(sequence, "disconnected", exc.code)
|
||||
except (AdcProtocolError, ValueError):
|
||||
return self._publish_failure(sequence, "error", "invalid_reading")
|
||||
except Exception:
|
||||
return self._publish_failure(sequence, "error", "unexpected_error")
|
||||
|
||||
def preview_calibration(self, reference_volts: float) -> dict[str, Any]:
|
||||
reference = float(reference_volts)
|
||||
if not math.isfinite(reference) or not CALIBRATION_REFERENCE_MIN <= reference <= CALIBRATION_REFERENCE_MAX:
|
||||
raise CalibrationError("万用表参考电压必须在 4.500V 到 5.500V 之间")
|
||||
sequence = self._allocate_sequence()
|
||||
try:
|
||||
group = self._read_group(CALIBRATION_SAMPLE_COUNT)
|
||||
except AdcDisconnectedError as exc:
|
||||
self._publish_failure(sequence, "disconnected", exc.code)
|
||||
raise CalibrationUnavailableError("电压传感器断开,无法校准") from exc
|
||||
except (AdcProtocolError, ValueError) as exc:
|
||||
self._publish_failure(sequence, "error", "invalid_reading")
|
||||
raise CalibrationUnavailableError("电压读取异常,无法校准") from exc
|
||||
|
||||
self._publish_success(sequence, group)
|
||||
if group.spread_volts > CALIBRATION_MAX_SPREAD_VOLTS:
|
||||
raise CalibrationError(
|
||||
f"采样波动 {group.spread_volts:.3f}V,超过 0.050V;请等待供电稳定后重试"
|
||||
)
|
||||
if group.uncalibrated_volts <= 0:
|
||||
raise CalibrationError("未校准电压必须大于 0V")
|
||||
proposed_factor = reference / group.uncalibrated_volts
|
||||
if not CALIBRATION_FACTOR_MIN <= proposed_factor <= CALIBRATION_FACTOR_MAX:
|
||||
raise CalibrationError(
|
||||
f"建议系数 {proposed_factor:.6f} 超出 0.8 到 1.2;请检查型号、接线和共地"
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
proposal = CalibrationProposal(
|
||||
proposal_id=str(uuid4()),
|
||||
reference_volts=reference,
|
||||
uncalibrated_volts=group.uncalibrated_volts,
|
||||
current_factor=float(self.config_store.config["voltage_calibration_factor"]),
|
||||
proposed_factor=proposed_factor,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(seconds=CALIBRATION_PROPOSAL_SECONDS),
|
||||
)
|
||||
with self._state_lock:
|
||||
self._proposal = proposal
|
||||
return proposal.response()
|
||||
|
||||
def confirm_calibration(self, proposal_id: str) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
with self._state_lock:
|
||||
proposal = self._proposal
|
||||
status = self._snapshot["status"]
|
||||
if proposal is None or proposal.proposal_id != proposal_id:
|
||||
raise CalibrationConflictError("校准提案不存在或已经失效,请重新预览")
|
||||
if now > proposal.expires_at:
|
||||
with self._state_lock:
|
||||
self._proposal = None
|
||||
raise CalibrationConflictError("校准提案已超过 120 秒,请重新预览")
|
||||
if status != "ok":
|
||||
raise CalibrationUnavailableError("传感器状态已经变化,请恢复后重新预览")
|
||||
|
||||
self.config_store.update({
|
||||
"voltage_calibration_factor": proposal.proposed_factor,
|
||||
"voltage_calibrated_at": isoformat_utc(now),
|
||||
"voltage_calibration_reference": proposal.reference_volts,
|
||||
"voltage_calibration_uncalibrated": proposal.uncalibrated_volts,
|
||||
})
|
||||
with self._state_lock:
|
||||
self._proposal = None
|
||||
return self.sample_now()
|
||||
|
||||
def reset_calibration(self) -> dict[str, Any]:
|
||||
self.config_store.update({
|
||||
"voltage_calibration_factor": 1.0,
|
||||
"voltage_calibrated_at": None,
|
||||
"voltage_calibration_reference": None,
|
||||
"voltage_calibration_uncalibrated": None,
|
||||
})
|
||||
with self._state_lock:
|
||||
self._proposal = None
|
||||
return self.sample_now()
|
||||
|
||||
def _run(self) -> None:
|
||||
# The application performs one synchronous startup sample. Waiting here
|
||||
# avoids immediately opening the I2C device for the same information.
|
||||
wait_seconds = self._current_sampling_interval()
|
||||
while not self._stop.is_set():
|
||||
if self._wait_until_due(wait_seconds):
|
||||
return
|
||||
started = time.monotonic()
|
||||
self.sample_now()
|
||||
wait_seconds = max(
|
||||
0.0,
|
||||
self._current_sampling_interval() - (time.monotonic() - started),
|
||||
)
|
||||
|
||||
def _wait_until_due(self, seconds: float) -> bool:
|
||||
deadline = time.monotonic() + seconds
|
||||
while not self._stop.is_set():
|
||||
remaining = max(0.0, deadline - time.monotonic())
|
||||
if not self._reschedule.wait(remaining):
|
||||
return False
|
||||
self._reschedule.clear()
|
||||
if self._stop.is_set():
|
||||
return True
|
||||
deadline = time.monotonic() + self._current_sampling_interval()
|
||||
return True
|
||||
|
||||
def _read_group(self, sample_count: int) -> SampleGroup:
|
||||
if sample_count <= 0:
|
||||
raise ValueError("样本数量必须大于 0")
|
||||
raw_values: list[int] = []
|
||||
last_config = CONFIG_CONTINUOUS_15SPS_PGA1
|
||||
with self._io_lock:
|
||||
with self._device_factory() as device:
|
||||
device.configure()
|
||||
for _ in range(sample_count):
|
||||
raw, last_config = device.read_fresh()
|
||||
raw_values.append(raw)
|
||||
raw_median = float(statistics.median(raw_values))
|
||||
uncalibrated = voltage_from_raw(raw_median)
|
||||
minimum = voltage_from_raw(min(raw_values))
|
||||
maximum = voltage_from_raw(max(raw_values))
|
||||
if not 0.0 <= uncalibrated <= 12.0:
|
||||
raise AdcProtocolError(f"屏幕输入电压 {uncalibrated:.3f}V 超出 0V 到 12V")
|
||||
return SampleGroup(
|
||||
raw_median=raw_median,
|
||||
uncalibrated_volts=uncalibrated,
|
||||
config=last_config,
|
||||
spread_volts=maximum - minimum,
|
||||
)
|
||||
|
||||
def _publish_success(self, sequence: int, group: SampleGroup) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
sampled_monotonic = time.monotonic()
|
||||
factor = float(self.config_store.config["voltage_calibration_factor"])
|
||||
volts = group.uncalibrated_volts * factor
|
||||
snapshot = {
|
||||
"status": "ok",
|
||||
"volts": volts,
|
||||
"sampled_at": isoformat_utc(now),
|
||||
"calibrated": self._is_calibrated(),
|
||||
"error_code": None,
|
||||
}
|
||||
accepted = False
|
||||
schedule_changed = False
|
||||
previous_status = "ok"
|
||||
with self._state_lock:
|
||||
if sequence > self._last_published_sequence:
|
||||
accepted = True
|
||||
self._last_published_sequence = sequence
|
||||
previous_status = self._snapshot["status"]
|
||||
self._snapshot = snapshot
|
||||
self._consecutive_errors = 0
|
||||
|
||||
elapsed = (
|
||||
None
|
||||
if self._last_successful_monotonic is None
|
||||
else sampled_monotonic - self._last_successful_monotonic
|
||||
)
|
||||
candidate = protection_directive_for_voltage(
|
||||
sequence,
|
||||
self._protection.enabled,
|
||||
volts,
|
||||
)
|
||||
first_critical = (
|
||||
candidate.mode == "critical"
|
||||
and self._last_sample_mode != "critical"
|
||||
)
|
||||
if self._protection.enabled:
|
||||
self._accept_successful_protection_candidate_locked(candidate)
|
||||
|
||||
interval = (
|
||||
self._fixed_poll_interval
|
||||
if self._fixed_poll_interval is not None
|
||||
else successful_sampling_interval(
|
||||
enabled=self._protection.enabled,
|
||||
mode=candidate.mode,
|
||||
volts=volts,
|
||||
previous_volts=self._last_successful_voltage,
|
||||
elapsed_seconds=elapsed,
|
||||
previous_interval_seconds=self._sampling_interval,
|
||||
first_critical_sample=first_critical,
|
||||
)
|
||||
)
|
||||
schedule_changed = self._set_sampling_interval_locked(interval)
|
||||
self._last_successful_voltage = volts
|
||||
self._last_successful_monotonic = sampled_monotonic
|
||||
self._last_sample_mode = candidate.mode
|
||||
result = copy.deepcopy(self._snapshot)
|
||||
|
||||
if accepted and previous_status != "ok":
|
||||
logger.info("Screen voltage sensor is available")
|
||||
if schedule_changed:
|
||||
self._request_reschedule()
|
||||
self._retry_protection_listener()
|
||||
return result
|
||||
|
||||
def _publish_failure(
|
||||
self,
|
||||
sequence: int,
|
||||
status: str,
|
||||
error_code: str,
|
||||
) -> dict[str, Any]:
|
||||
snapshot = {
|
||||
"status": status,
|
||||
"volts": None,
|
||||
"sampled_at": None,
|
||||
"calibrated": self._is_calibrated(),
|
||||
"error_code": error_code,
|
||||
}
|
||||
accepted = False
|
||||
schedule_changed = False
|
||||
previous = (status, error_code)
|
||||
with self._state_lock:
|
||||
if sequence > self._last_published_sequence:
|
||||
accepted = True
|
||||
self._last_published_sequence = sequence
|
||||
previous = (self._snapshot["status"], self._snapshot["error_code"])
|
||||
self._snapshot = snapshot
|
||||
self._proposal = None
|
||||
self._clear_relaxation_locked()
|
||||
self._consecutive_errors += 1
|
||||
self._mark_protection_stale_locked(sequence)
|
||||
interval = (
|
||||
self._fixed_poll_interval
|
||||
if self._fixed_poll_interval is not None
|
||||
else error_retry_interval(self._consecutive_errors)
|
||||
)
|
||||
schedule_changed = self._set_sampling_interval_locked(interval)
|
||||
result = copy.deepcopy(self._snapshot)
|
||||
|
||||
if accepted and previous != (status, error_code):
|
||||
logger.warning("Screen voltage sensor status=%s error_code=%s", status, error_code)
|
||||
if schedule_changed:
|
||||
self._request_reschedule()
|
||||
self._retry_protection_listener()
|
||||
return result
|
||||
|
||||
def _accept_successful_protection_candidate_locked(
|
||||
self,
|
||||
candidate: LowVoltageProtectionDirective,
|
||||
) -> None:
|
||||
if not self._has_successful_protection_sample:
|
||||
self._has_successful_protection_sample = True
|
||||
self._clear_relaxation_locked()
|
||||
self._replace_protection_locked(candidate)
|
||||
return
|
||||
|
||||
current = self._protection
|
||||
if is_more_restrictive(candidate, current):
|
||||
self._clear_relaxation_locked()
|
||||
self._replace_protection_locked(candidate)
|
||||
return
|
||||
|
||||
if same_protection_level(candidate, current):
|
||||
self._clear_relaxation_locked()
|
||||
if current.reading_stale:
|
||||
self._replace_protection_locked(candidate)
|
||||
return
|
||||
|
||||
# A successful sample makes the reading fresh immediately, while any
|
||||
# output relaxation still waits for a second consecutive success.
|
||||
if current.reading_stale:
|
||||
self._replace_protection_locked(
|
||||
LowVoltageProtectionDirective(
|
||||
sequence=candidate.sequence,
|
||||
enabled=current.enabled,
|
||||
mode=current.mode,
|
||||
brightness_limit_percent=current.brightness_limit_percent,
|
||||
reading_stale=False,
|
||||
)
|
||||
)
|
||||
|
||||
self._relaxation_success_count += 1
|
||||
self._relaxation_candidate = (
|
||||
candidate
|
||||
if self._relaxation_candidate is None
|
||||
else more_conservative(self._relaxation_candidate, candidate)
|
||||
)
|
||||
if self._relaxation_success_count >= 2:
|
||||
conservative = self._relaxation_candidate
|
||||
assert conservative is not None
|
||||
self._replace_protection_locked(
|
||||
LowVoltageProtectionDirective(
|
||||
sequence=candidate.sequence,
|
||||
enabled=conservative.enabled,
|
||||
mode=conservative.mode,
|
||||
brightness_limit_percent=(
|
||||
conservative.brightness_limit_percent
|
||||
),
|
||||
reading_stale=False,
|
||||
)
|
||||
)
|
||||
self._clear_relaxation_locked()
|
||||
|
||||
def _mark_protection_stale_locked(self, sequence: int) -> None:
|
||||
if not self._protection.enabled:
|
||||
return
|
||||
if self._has_successful_protection_sample:
|
||||
if not self._protection.reading_stale:
|
||||
self._replace_protection_locked(
|
||||
LowVoltageProtectionDirective(
|
||||
sequence=sequence,
|
||||
enabled=True,
|
||||
mode=self._protection.mode,
|
||||
brightness_limit_percent=(
|
||||
self._protection.brightness_limit_percent
|
||||
),
|
||||
reading_stale=True,
|
||||
)
|
||||
)
|
||||
return
|
||||
# There is no successful reading to become stale yet. Keep the
|
||||
# non-restrictive unavailable directive and expose the ADC error via
|
||||
# the sensor status instead of churning the protection revision.
|
||||
|
||||
def _replace_protection_locked(
|
||||
self,
|
||||
directive: LowVoltageProtectionDirective,
|
||||
) -> None:
|
||||
if directive == self._protection:
|
||||
return
|
||||
self._protection = directive
|
||||
self._protection_revision += 1
|
||||
|
||||
def _clear_relaxation_locked(self) -> None:
|
||||
self._relaxation_candidate = None
|
||||
self._relaxation_success_count = 0
|
||||
|
||||
def _retry_protection_listener(self) -> None:
|
||||
with self._listener_lock:
|
||||
with self._state_lock:
|
||||
listener = self._protection_listener
|
||||
directive = self._protection
|
||||
needs_enforcement = (
|
||||
listener is not None
|
||||
and (
|
||||
self._enforced_sequence != directive.sequence
|
||||
or self._enforcement_error_code is not None
|
||||
)
|
||||
)
|
||||
if not needs_enforcement or listener is None:
|
||||
return
|
||||
|
||||
try:
|
||||
listener(directive)
|
||||
except Exception:
|
||||
with self._state_lock:
|
||||
if self._protection == directive:
|
||||
self._enforcement_error_code = (
|
||||
PROTECTION_ENFORCEMENT_ERROR_CODE
|
||||
)
|
||||
logger.exception("Failed to enforce low-voltage display protection")
|
||||
return
|
||||
|
||||
with self._state_lock:
|
||||
if self._protection == directive:
|
||||
self._enforced_sequence = directive.sequence
|
||||
self._enforcement_error_code = None
|
||||
|
||||
def _allocate_sequence(self) -> int:
|
||||
with self._state_lock:
|
||||
return self._allocate_sequence_locked()
|
||||
|
||||
def _allocate_sequence_locked(self) -> int:
|
||||
self._sequence += 1
|
||||
return self._sequence
|
||||
|
||||
def _set_sampling_interval_locked(self, interval: float) -> bool:
|
||||
normalized = float(interval)
|
||||
changed = normalized != self._sampling_interval
|
||||
self._sampling_interval = normalized
|
||||
return changed
|
||||
|
||||
def _current_sampling_interval(self) -> float:
|
||||
with self._state_lock:
|
||||
return self._sampling_interval
|
||||
|
||||
def _request_reschedule(self) -> None:
|
||||
with self._state_lock:
|
||||
thread = self._thread
|
||||
if thread is not None and threading.current_thread() is not thread:
|
||||
self._reschedule.set()
|
||||
|
||||
def _is_calibrated(self) -> bool:
|
||||
return self.config_store.config.get("voltage_calibrated_at") is not None
|
||||
@@ -0,0 +1,466 @@
|
||||
import { apiJson } from "./core.js";
|
||||
import { animationPreviewFrameAtElapsed } from "./animation-ui-model.js";
|
||||
|
||||
export const ANIMATION_PREVIEW_PAGE_SIZE = 500;
|
||||
export const ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT = 2;
|
||||
export const ANIMATION_PREVIEW_MIN_MAX_CONCURRENT = 1;
|
||||
export const ANIMATION_PREVIEW_MAX_MAX_CONCURRENT = 50;
|
||||
export const ANIMATION_PREVIEW_PRELOAD_COUNT = 3;
|
||||
|
||||
export function normalizeAnimationPreviewMaxConcurrent(value) {
|
||||
if (!Number.isInteger(value)
|
||||
|| value < ANIMATION_PREVIEW_MIN_MAX_CONCURRENT
|
||||
|| value > ANIMATION_PREVIEW_MAX_MAX_CONCURRENT) {
|
||||
throw new Error(
|
||||
`动态缩略图并发数量必须是 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stalePreviewError() {
|
||||
const error = new Error("动图已在预览加载期间发生变化");
|
||||
error.code = "ANIMATION_PREVIEW_STALE";
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function loadCompleteAnimationPreview(
|
||||
animation,
|
||||
{ fetchJson = apiJson, signal, pageSize = ANIMATION_PREVIEW_PAGE_SIZE } = {},
|
||||
) {
|
||||
const expectedRevision = String(animation?.revision || "");
|
||||
const expectedCount = Number(animation?.frame_count || 0);
|
||||
if (!animation?.id || !expectedRevision) throw new Error("动图预览缺少标识或修订值");
|
||||
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > ANIMATION_PREVIEW_PAGE_SIZE) {
|
||||
throw new Error("动图预览分页大小无效");
|
||||
}
|
||||
if (expectedCount === 0) return [];
|
||||
|
||||
const frames = [];
|
||||
let offset = 0;
|
||||
let total = expectedCount;
|
||||
while (offset < total) {
|
||||
const query = new URLSearchParams({
|
||||
frame_offset: String(offset),
|
||||
frame_limit: String(pageSize),
|
||||
});
|
||||
const page = await fetchJson(`/api/animations/${animation.id}?${query}`, { signal });
|
||||
if (String(page?.revision || "") !== expectedRevision) throw stalePreviewError();
|
||||
if (!Array.isArray(page.frames)) throw new Error("动图预览帧列表无效");
|
||||
total = Number(page.frame_total ?? page.frame_count ?? page.frames.length);
|
||||
if (!Number.isInteger(total) || total < 0 || total !== expectedCount) throw stalePreviewError();
|
||||
if (!page.frames.length && offset < total) throw new Error("动图预览分页提前结束");
|
||||
frames.push(...page.frames);
|
||||
offset += page.frames.length;
|
||||
}
|
||||
if (frames.length !== expectedCount) throw stalePreviewError();
|
||||
return frames;
|
||||
}
|
||||
|
||||
class AnimationPreviewEngine {
|
||||
constructor() {
|
||||
this.records = new Set();
|
||||
this.initialQueue = [];
|
||||
this.activeInitialLoads = 0;
|
||||
this.queue = [];
|
||||
this.activeLoads = 0;
|
||||
this.maxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT;
|
||||
this.timer = null;
|
||||
this.cache = new Map();
|
||||
this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
this.observer = typeof IntersectionObserver === "function"
|
||||
? new IntersectionObserver((entries) => this.handleIntersections(entries), { rootMargin: "0px" })
|
||||
: null;
|
||||
this.viewportSyncPending = false;
|
||||
this.handleFallbackViewport = () => {
|
||||
if (this.observer || this.viewportSyncPending) return;
|
||||
this.viewportSyncPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
this.viewportSyncPending = false;
|
||||
this.syncAll();
|
||||
});
|
||||
};
|
||||
this.handleVisibility = () => this.syncAll();
|
||||
this.handleMotionChange = () => this.syncAll();
|
||||
document.addEventListener("visibilitychange", this.handleVisibility);
|
||||
this.reducedMotion.addEventListener?.("change", this.handleMotionChange);
|
||||
window.addEventListener("scroll", this.handleFallbackViewport, true);
|
||||
window.addEventListener("resize", this.handleFallbackViewport);
|
||||
}
|
||||
|
||||
cacheKey(animation) {
|
||||
return `${animation.id}:${animation.revision}`;
|
||||
}
|
||||
|
||||
setMaxConcurrent(value) {
|
||||
const normalized = normalizeAnimationPreviewMaxConcurrent(value);
|
||||
if (normalized === this.maxConcurrent) return normalized;
|
||||
this.maxConcurrent = normalized;
|
||||
this.syncAll();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
createGroup() {
|
||||
const engine = this;
|
||||
const records = new Set();
|
||||
return {
|
||||
active: false,
|
||||
register(options) {
|
||||
const record = engine.register(this, options);
|
||||
records.add(record);
|
||||
return () => {
|
||||
records.delete(record);
|
||||
engine.disposeRecord(record);
|
||||
engine.syncAll();
|
||||
};
|
||||
},
|
||||
setActive(active) {
|
||||
this.active = Boolean(active);
|
||||
engine.syncAll();
|
||||
},
|
||||
clear() {
|
||||
const keys = new Set();
|
||||
for (const record of records) {
|
||||
keys.add(engine.cacheKey(record.animation));
|
||||
engine.disposeRecord(record);
|
||||
}
|
||||
records.clear();
|
||||
for (const key of keys) engine.cache.delete(key);
|
||||
engine.scheduleTick();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
register(group, {
|
||||
animation,
|
||||
image,
|
||||
initialUrl,
|
||||
target = image,
|
||||
onFrame = null,
|
||||
onLoad = null,
|
||||
onInitialError = null,
|
||||
}) {
|
||||
const record = {
|
||||
group,
|
||||
animation,
|
||||
image,
|
||||
initialUrl,
|
||||
target,
|
||||
onFrame,
|
||||
onLoad,
|
||||
onInitialError,
|
||||
visible: false,
|
||||
disposed: false,
|
||||
initialQueued: false,
|
||||
initialLoading: false,
|
||||
initialReady: false,
|
||||
initialFailed: false,
|
||||
queued: false,
|
||||
loading: false,
|
||||
loadController: null,
|
||||
schedule: null,
|
||||
startedAt: null,
|
||||
currentIndex: -1,
|
||||
preloads: new Map(),
|
||||
lastSuccessfulUrl: "",
|
||||
restoring: false,
|
||||
};
|
||||
record.handleLoad = () => {
|
||||
if (record.initialLoading) this.finishInitialLoad(record, true);
|
||||
record.lastSuccessfulUrl = image.currentSrc || image.src;
|
||||
record.restoring = false;
|
||||
onLoad?.();
|
||||
};
|
||||
record.handleError = () => {
|
||||
if (record.initialLoading) {
|
||||
this.finishInitialLoad(record, false);
|
||||
record.restoring = false;
|
||||
onInitialError?.();
|
||||
return;
|
||||
}
|
||||
const failedUrl = image.currentSrc || image.src;
|
||||
if (record.lastSuccessfulUrl && failedUrl !== record.lastSuccessfulUrl && !record.restoring) {
|
||||
record.restoring = true;
|
||||
image.src = record.lastSuccessfulUrl;
|
||||
return;
|
||||
}
|
||||
record.restoring = false;
|
||||
onInitialError?.();
|
||||
};
|
||||
image.addEventListener("load", record.handleLoad);
|
||||
image.addEventListener("error", record.handleError);
|
||||
this.records.add(record);
|
||||
this.observer?.observe(target);
|
||||
this.syncAll();
|
||||
return record;
|
||||
}
|
||||
|
||||
disposeRecord(record) {
|
||||
if (record.disposed) return;
|
||||
record.disposed = true;
|
||||
record.initialQueued = false;
|
||||
if (record.initialLoading) {
|
||||
record.initialLoading = false;
|
||||
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
||||
record.image.removeAttribute("src");
|
||||
}
|
||||
record.loadController?.abort();
|
||||
record.preloads.clear();
|
||||
record.image.removeEventListener("load", record.handleLoad);
|
||||
record.image.removeEventListener("error", record.handleError);
|
||||
this.observer?.unobserve(record.target);
|
||||
this.records.delete(record);
|
||||
this.pumpInitialQueue();
|
||||
}
|
||||
|
||||
canLoadInitial(record) {
|
||||
return !record.disposed
|
||||
&& record.group.active
|
||||
&& this.isRecordVisible(record)
|
||||
&& document.visibilityState === "visible"
|
||||
&& Boolean(record.initialUrl);
|
||||
}
|
||||
|
||||
isRecordVisible(record) {
|
||||
if (this.observer) return record.visible;
|
||||
const rect = record.target.getBoundingClientRect();
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& rect.right > 0
|
||||
&& rect.bottom > 0
|
||||
&& rect.left < window.innerWidth
|
||||
&& rect.top < window.innerHeight;
|
||||
}
|
||||
|
||||
syncInitial(record) {
|
||||
if (!this.canLoadInitial(record)
|
||||
|| record.initialReady
|
||||
|| record.initialFailed
|
||||
|| record.initialLoading
|
||||
|| record.initialQueued) return;
|
||||
record.initialQueued = true;
|
||||
this.initialQueue.push(record);
|
||||
}
|
||||
|
||||
pumpInitialQueue() {
|
||||
while (this.activeInitialLoads < this.maxConcurrent
|
||||
&& this.initialQueue.length) {
|
||||
const record = this.initialQueue.shift();
|
||||
record.initialQueued = false;
|
||||
if (!this.canLoadInitial(record) || record.initialReady || record.initialFailed) continue;
|
||||
record.initialLoading = true;
|
||||
this.activeInitialLoads += 1;
|
||||
record.image.src = record.initialUrl;
|
||||
}
|
||||
}
|
||||
|
||||
finishInitialLoad(record, succeeded) {
|
||||
if (!record.initialLoading) return;
|
||||
record.initialLoading = false;
|
||||
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
||||
record.initialReady = succeeded;
|
||||
record.initialFailed = !succeeded;
|
||||
this.pumpInitialQueue();
|
||||
this.syncAll();
|
||||
}
|
||||
|
||||
handleIntersections(entries) {
|
||||
for (const entry of entries) {
|
||||
for (const record of this.records) {
|
||||
if (record.target !== entry.target) continue;
|
||||
record.visible = entry.isIntersecting;
|
||||
}
|
||||
}
|
||||
this.syncAll();
|
||||
}
|
||||
|
||||
canRun(record) {
|
||||
return !record.disposed
|
||||
&& record.group.active
|
||||
&& this.isRecordVisible(record)
|
||||
&& document.visibilityState === "visible"
|
||||
&& !this.reducedMotion.matches
|
||||
&& Number(record.animation.frame_count) > 1;
|
||||
}
|
||||
|
||||
shouldRun(record) {
|
||||
let selected = 0;
|
||||
for (const candidate of this.records) {
|
||||
if (!this.canRun(candidate)) continue;
|
||||
if (candidate === record) return selected < this.maxConcurrent;
|
||||
selected += 1;
|
||||
if (selected >= this.maxConcurrent) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
syncRecord(record) {
|
||||
if (!this.shouldRun(record)) {
|
||||
record.loadController?.abort();
|
||||
record.loadController = null;
|
||||
record.loading = false;
|
||||
record.queued = false;
|
||||
record.preloads.clear();
|
||||
if (record.schedule) this.cache.delete(this.cacheKey(record.animation));
|
||||
record.schedule = null;
|
||||
record.startedAt = null;
|
||||
record.currentIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (!record.initialReady) return;
|
||||
if (record.schedule) return;
|
||||
const cached = this.cache.get(this.cacheKey(record.animation));
|
||||
if (cached) {
|
||||
record.schedule = cached;
|
||||
record.startedAt = performance.now();
|
||||
return;
|
||||
}
|
||||
if (!record.loading && !record.queued) {
|
||||
record.queued = true;
|
||||
this.queue.push(record);
|
||||
this.pumpQueue();
|
||||
}
|
||||
}
|
||||
|
||||
syncAll() {
|
||||
for (const record of this.records) this.syncInitial(record);
|
||||
this.pumpInitialQueue();
|
||||
for (const record of this.records) this.syncRecord(record);
|
||||
this.scheduleTick();
|
||||
}
|
||||
|
||||
pumpQueue() {
|
||||
while (this.activeLoads < this.maxConcurrent && this.queue.length) {
|
||||
const record = this.queue.shift();
|
||||
record.queued = false;
|
||||
if (!this.shouldRun(record) || record.disposed || record.schedule) continue;
|
||||
this.loadRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
async loadRecord(record) {
|
||||
record.loading = true;
|
||||
record.loadController = new AbortController();
|
||||
this.activeLoads += 1;
|
||||
try {
|
||||
const frames = await loadCompleteAnimationPreview(record.animation, {
|
||||
signal: record.loadController.signal,
|
||||
});
|
||||
if (record.disposed || !this.shouldRun(record)) return;
|
||||
record.schedule = frames;
|
||||
record.startedAt = performance.now();
|
||||
this.cache.set(this.cacheKey(record.animation), frames);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && error?.code !== "ANIMATION_PREVIEW_STALE") {
|
||||
// The first frame remains usable; a later refresh can retry the full schedule.
|
||||
}
|
||||
} finally {
|
||||
record.loading = false;
|
||||
record.loadController = null;
|
||||
this.activeLoads -= 1;
|
||||
this.pumpQueue();
|
||||
this.scheduleTick();
|
||||
}
|
||||
}
|
||||
|
||||
preloadAhead(record, index) {
|
||||
const wanted = new Set();
|
||||
const length = record.schedule.length;
|
||||
for (let step = 1; step <= ANIMATION_PREVIEW_PRELOAD_COUNT && step < length; step += 1) {
|
||||
const nextIndex = (index + step) % length;
|
||||
wanted.add(nextIndex);
|
||||
if (!record.preloads.has(nextIndex)) {
|
||||
const preload = new Image();
|
||||
preload.decoding = "async";
|
||||
preload.src = record.schedule[nextIndex].thumbnail_url;
|
||||
record.preloads.set(nextIndex, preload);
|
||||
}
|
||||
}
|
||||
for (const existing of record.preloads.keys()) {
|
||||
if (!wanted.has(existing)) record.preloads.delete(existing);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleTick() {
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
let nextDelay = Infinity;
|
||||
const now = performance.now();
|
||||
for (const record of this.records) {
|
||||
if (!this.shouldRun(record) || !record.schedule?.length) continue;
|
||||
if (record.startedAt === null) record.startedAt = now;
|
||||
const selected = animationPreviewFrameAtElapsed(record.schedule, now - record.startedAt);
|
||||
if (!selected) continue;
|
||||
if (record.currentIndex !== selected.index) {
|
||||
record.currentIndex = selected.index;
|
||||
record.image.src = selected.frame.thumbnail_url;
|
||||
record.onFrame?.({
|
||||
frame: selected.frame,
|
||||
index: selected.index,
|
||||
total: record.schedule.length,
|
||||
});
|
||||
this.preloadAhead(record, selected.index);
|
||||
}
|
||||
nextDelay = Math.min(nextDelay, selected.remainingMs);
|
||||
}
|
||||
if (Number.isFinite(nextDelay)) {
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.scheduleTick();
|
||||
}, Math.max(16, Math.ceil(nextDelay)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedEngine = null;
|
||||
|
||||
function getSharedEngine() {
|
||||
if (!sharedEngine) sharedEngine = new AnimationPreviewEngine();
|
||||
return sharedEngine;
|
||||
}
|
||||
|
||||
export function createAnimationPreviewGroup() {
|
||||
return getSharedEngine().createGroup();
|
||||
}
|
||||
|
||||
export function setAnimationPreviewMaxConcurrent(value) {
|
||||
return getSharedEngine().setMaxConcurrent(value);
|
||||
}
|
||||
|
||||
export function createAnimationThumbnailBox(group, animation) {
|
||||
const imageBox = document.createElement("div");
|
||||
imageBox.className = "template-image-box";
|
||||
if (!animation.thumbnail_url || !animation.frame_count) {
|
||||
imageBox.classList.add("template-image-empty");
|
||||
imageBox.setAttribute("aria-label", "空动图");
|
||||
return imageBox;
|
||||
}
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "template-image";
|
||||
image.alt = `${animation.name} 动图缩略图`;
|
||||
image.width = 64;
|
||||
image.height = 64;
|
||||
image.draggable = false;
|
||||
image.loading = "lazy";
|
||||
imageBox.append(image);
|
||||
group.register({
|
||||
animation,
|
||||
image,
|
||||
initialUrl: animation.thumbnail_url,
|
||||
target: imageBox,
|
||||
onLoad: () => {
|
||||
image.hidden = false;
|
||||
imageBox.classList.remove("template-image-error");
|
||||
imageBox.removeAttribute("aria-label");
|
||||
},
|
||||
onInitialError: () => {
|
||||
image.hidden = true;
|
||||
imageBox.classList.add("template-image-error");
|
||||
imageBox.setAttribute("aria-label", "缩略图加载失败");
|
||||
},
|
||||
});
|
||||
return imageBox;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export const MIN_FRAME_DURATION_MS = 50;
|
||||
export const MAX_FRAME_DURATION_MS = 604800000;
|
||||
|
||||
export function effectiveFrameName(animationName, frame, position = frame?.position) {
|
||||
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
|
||||
if (customName) return customName;
|
||||
return `${String(animationName || "动图").trim() || "动图"}-第${position}帧`;
|
||||
}
|
||||
|
||||
export function animationEditContextName(animationName, frame, position = frame?.position) {
|
||||
const normalizedAnimationName = String(animationName || "动图").trim() || "动图";
|
||||
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
|
||||
return `${normalizedAnimationName}-${customName || `第${position}帧`}`;
|
||||
}
|
||||
|
||||
export function animationPreviewFrameAtElapsed(frames, elapsedMs) {
|
||||
if (!Array.isArray(frames) || !frames.length) return null;
|
||||
const durations = frames.map((frame) => Number(frame?.duration_ms));
|
||||
if (durations.some((duration) => !Number.isFinite(duration) || duration <= 0)) return null;
|
||||
const totalDurationMs = durations.reduce((total, duration) => total + duration, 0);
|
||||
const normalizedElapsed = Number.isFinite(Number(elapsedMs)) ? Number(elapsedMs) : 0;
|
||||
const cycleElapsedMs = ((normalizedElapsed % totalDurationMs) + totalDurationMs) % totalDurationMs;
|
||||
let boundary = 0;
|
||||
for (let index = 0; index < frames.length; index += 1) {
|
||||
boundary += durations[index];
|
||||
if (cycleElapsedMs < boundary) {
|
||||
return {
|
||||
frame: frames[index],
|
||||
index,
|
||||
remainingMs: boundary - cycleElapsedMs,
|
||||
totalDurationMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatFrameDuration(durationMs, unit = "ms") {
|
||||
const duration = Number(durationMs);
|
||||
if (!Number.isInteger(duration)) return "";
|
||||
if (unit === "ms") return String(duration);
|
||||
if (unit !== "s") throw new Error("unknown animation duration unit");
|
||||
return (duration / 1000).toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
export function validateFrameDuration(value, unit = "ms") {
|
||||
const normalized = typeof value === "string" ? value.trim() : String(value ?? "").trim();
|
||||
const millisecondsPattern = /^\d+$/;
|
||||
const secondsPattern = /^\d+(?:\.\d{1,3})?$/;
|
||||
if (!normalized || (unit === "ms" ? !millisecondsPattern.test(normalized) : !secondsPattern.test(normalized))) {
|
||||
return {
|
||||
valid: false,
|
||||
duration: null,
|
||||
error: unit === "ms"
|
||||
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
|
||||
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
|
||||
};
|
||||
}
|
||||
const numeric = Number(normalized);
|
||||
const duration = unit === "s" ? numeric * 1000 : numeric;
|
||||
if (!Number.isInteger(duration) || duration < MIN_FRAME_DURATION_MS || duration > MAX_FRAME_DURATION_MS) {
|
||||
return {
|
||||
valid: false,
|
||||
duration: null,
|
||||
error: unit === "ms"
|
||||
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
|
||||
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
|
||||
};
|
||||
}
|
||||
return { valid: true, duration, error: "" };
|
||||
}
|
||||
|
||||
export function reorderSelectedFrameIds(frameIds, selectedFrameIds, insertAfterFrameId = null) {
|
||||
if (!Array.isArray(frameIds) || !Array.isArray(selectedFrameIds) || !selectedFrameIds.length) return null;
|
||||
const selected = new Set(selectedFrameIds);
|
||||
if (selected.size !== selectedFrameIds.length || selectedFrameIds.some((id) => !frameIds.includes(id))) return null;
|
||||
if (insertAfterFrameId !== null && (!frameIds.includes(insertAfterFrameId) || selected.has(insertAfterFrameId))) return null;
|
||||
const moving = frameIds.filter((id) => selected.has(id));
|
||||
const remaining = frameIds.filter((id) => !selected.has(id));
|
||||
const insertAt = insertAfterFrameId === null ? 0 : remaining.indexOf(insertAfterFrameId) + 1;
|
||||
return [...remaining.slice(0, insertAt), ...moving, ...remaining.slice(insertAt)];
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { DEFAULT_WORKSPACE_ID, announce, apiJson, getWorkspaces, markAppStale } from "./core.js";
|
||||
import "./interaction-feedback.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js";
|
||||
import { setBoardWorkspace } from "./scene-board.js";
|
||||
import { renderUiCopy } from "./ui-copy-runtime.js";
|
||||
import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js";
|
||||
import {
|
||||
ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT,
|
||||
setAnimationPreviewMaxConcurrent,
|
||||
} from "./animation-thumbnail-preview.js";
|
||||
import "./color-picker.js";
|
||||
import "./views/canvas.js";
|
||||
import { refreshStatus } from "./views/device.js";
|
||||
import "./views/text.js";
|
||||
import "./views/media-import.js";
|
||||
import "./views/animations.js";
|
||||
import "./views/templates.js";
|
||||
import "./views/settings.js";
|
||||
|
||||
const drawer = document.getElementById("workspace-drawer");
|
||||
const backdrop = document.getElementById("drawer-backdrop");
|
||||
const menuButton = document.getElementById("drawer-toggle");
|
||||
const navigation = document.getElementById("workspace-nav");
|
||||
const workspaceOrderToggle = document.getElementById("workspace-order-toggle");
|
||||
const workspaceOrderFeedback = document.getElementById("workspace-order-feedback");
|
||||
const currentTitle = document.getElementById("current-workspace-title");
|
||||
const saveTemplateButton = document.getElementById("save-template");
|
||||
const definitions = getWorkspaces();
|
||||
const buttons = new Map();
|
||||
let activeWorkspace = null;
|
||||
let savedWorkspaceOrder = definitions.map((definition) => definition.id);
|
||||
let draftWorkspaceOrder = null;
|
||||
let workspaceOrderEditing = false;
|
||||
let workspaceOrderBusy = false;
|
||||
let workspaceOrderLoadPromise = null;
|
||||
let statusPollPending = false;
|
||||
const loadedAppVersion = document.querySelector('meta[name="app-version"]')?.content || null;
|
||||
|
||||
function setWorkspaceOrderToggleLabel() {
|
||||
if (workspaceOrderEditing) {
|
||||
renderUiCopy("copy.dynamic.workspace_order.save", workspaceOrderToggle, "保存");
|
||||
} else {
|
||||
renderUiCopy("copy.dynamic.workspace_order.edit", workspaceOrderToggle, "自定义项目位置");
|
||||
}
|
||||
}
|
||||
|
||||
function setWorkspaceOrderFeedback(copyId, defaultText, values = {}, state = "ready") {
|
||||
renderUiCopy(copyId, workspaceOrderFeedback, defaultText, values);
|
||||
workspaceOrderFeedback.hidden = false;
|
||||
workspaceOrderFeedback.dataset.state = state;
|
||||
workspaceOrderFeedback.setAttribute("role", state === "error" ? "alert" : "status");
|
||||
announce(workspaceOrderFeedback.textContent, state === "error");
|
||||
}
|
||||
|
||||
function clearWorkspaceOrderFeedback() {
|
||||
workspaceOrderFeedback.hidden = true;
|
||||
delete workspaceOrderFeedback.dataset.state;
|
||||
}
|
||||
|
||||
function orderedDefinitions(orderIds) {
|
||||
return resolveWorkspaceOrder(definitions, orderIds);
|
||||
}
|
||||
|
||||
function buildNavigation() {
|
||||
const orderIds = workspaceOrderEditing ? draftWorkspaceOrder : savedWorkspaceOrder;
|
||||
const ordered = orderedDefinitions(orderIds);
|
||||
navigation.replaceChildren();
|
||||
navigation.classList.toggle("is-order-editing", workspaceOrderEditing);
|
||||
buttons.clear();
|
||||
ordered.forEach((definition, index) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "nav-item-row";
|
||||
row.dataset.workspace = definition.id;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "nav-item";
|
||||
button.dataset.workspace = definition.id;
|
||||
button.textContent = definition.title;
|
||||
if (workspaceOrderEditing) {
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
button.dataset.unavailableReason = "请先保存项目位置";
|
||||
} else {
|
||||
button.addEventListener("click", () => {
|
||||
navigate(definition.id);
|
||||
requestCloseDrawer(true);
|
||||
});
|
||||
}
|
||||
if (activeWorkspace?.id === definition.id) button.setAttribute("aria-current", "page");
|
||||
row.appendChild(button);
|
||||
buttons.set(definition.id, button);
|
||||
|
||||
if (workspaceOrderEditing) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "nav-order-actions";
|
||||
const moveUp = document.createElement("button");
|
||||
moveUp.type = "button";
|
||||
moveUp.className = "nav-order-action";
|
||||
moveUp.dataset.orderAction = "up";
|
||||
moveUp.dataset.workspace = definition.id;
|
||||
moveUp.setAttribute("aria-label", `上移${definition.title}`);
|
||||
renderUiCopy("copy.dynamic.workspace_order.move_up", moveUp, "上移");
|
||||
moveUp.addEventListener("click", () => moveDraftWorkspace(index, -1, "up"));
|
||||
|
||||
const moveDown = document.createElement("button");
|
||||
moveDown.type = "button";
|
||||
moveDown.className = "nav-order-action";
|
||||
moveDown.dataset.orderAction = "down";
|
||||
moveDown.dataset.workspace = definition.id;
|
||||
moveDown.setAttribute("aria-label", `下移${definition.title}`);
|
||||
renderUiCopy("copy.dynamic.workspace_order.move_down", moveDown, "下移");
|
||||
moveDown.addEventListener("click", () => moveDraftWorkspace(index, 1, "down"));
|
||||
actions.append(moveUp, moveDown);
|
||||
row.appendChild(actions);
|
||||
if (index === 0) setControlUnavailable(moveUp, "当前已经是第一项,不能继续上移。");
|
||||
if (index === ordered.length - 1) {
|
||||
setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
|
||||
}
|
||||
}
|
||||
navigation.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function moveDraftWorkspace(index, offset, action) {
|
||||
const moved = moveWorkspaceOrder(draftWorkspaceOrder, index, offset);
|
||||
if (!moved) return;
|
||||
const workspaceId = draftWorkspaceOrder[index];
|
||||
draftWorkspaceOrder = moved;
|
||||
clearWorkspaceOrderFeedback();
|
||||
buildNavigation();
|
||||
Array.from(navigation.querySelectorAll(`[data-order-action="${action}"]`))
|
||||
.find((button) => button.dataset.workspace === workspaceId)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
async function loadWorkspaceOrder() {
|
||||
if (workspaceOrderLoadPromise) return workspaceOrderLoadPromise;
|
||||
navigation.setAttribute("aria-busy", "true");
|
||||
workspaceOrderLoadPromise = apiJson("/api/config")
|
||||
.then((config) => {
|
||||
try {
|
||||
setAnimationPreviewMaxConcurrent(config.animation_preview_max_concurrent);
|
||||
} catch {
|
||||
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
|
||||
}
|
||||
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
|
||||
if (!workspaceOrderEditing) buildNavigation();
|
||||
return savedWorkspaceOrder;
|
||||
})
|
||||
.catch((error) => {
|
||||
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
navigation.removeAttribute("aria-busy");
|
||||
workspaceOrderLoadPromise = null;
|
||||
});
|
||||
return workspaceOrderLoadPromise;
|
||||
}
|
||||
|
||||
async function beginWorkspaceOrderEdit() {
|
||||
if (workspaceOrderBusy || workspaceOrderEditing) return;
|
||||
workspaceOrderBusy = true;
|
||||
setControlBusy(workspaceOrderToggle);
|
||||
renderUiCopy("copy.dynamic.workspace_order.loading", workspaceOrderToggle, "正在读取…");
|
||||
try {
|
||||
await loadWorkspaceOrder();
|
||||
draftWorkspaceOrder = [...savedWorkspaceOrder];
|
||||
workspaceOrderEditing = true;
|
||||
clearWorkspaceOrderFeedback();
|
||||
buildNavigation();
|
||||
} catch (error) {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.load_failed",
|
||||
"项目位置读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
workspaceOrderBusy = false;
|
||||
setControlReady(workspaceOrderToggle);
|
||||
setWorkspaceOrderToggleLabel();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkspaceOrder() {
|
||||
if (workspaceOrderBusy || !workspaceOrderEditing || !draftWorkspaceOrder) return;
|
||||
workspaceOrderBusy = true;
|
||||
setControlBusy(workspaceOrderToggle);
|
||||
renderUiCopy("copy.dynamic.workspace_order.saving", workspaceOrderToggle, "正在保存…");
|
||||
try {
|
||||
const config = await apiJson("/api/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ workspace_order: draftWorkspaceOrder }),
|
||||
});
|
||||
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
|
||||
draftWorkspaceOrder = null;
|
||||
workspaceOrderEditing = false;
|
||||
buildNavigation();
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.saved",
|
||||
"项目位置已保存",
|
||||
{},
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.save_failed",
|
||||
"项目位置保存失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
workspaceOrderBusy = false;
|
||||
setControlReady(workspaceOrderToggle);
|
||||
setWorkspaceOrderToggleLabel();
|
||||
}
|
||||
}
|
||||
|
||||
function remindWorkspaceOrderSave() {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.save_required",
|
||||
"请先保存项目位置",
|
||||
);
|
||||
workspaceOrderToggle.focus();
|
||||
}
|
||||
|
||||
function resolveWorkspaceId() {
|
||||
const hashId = decodeURIComponent(window.location.hash.replace(/^#/, ""));
|
||||
return definitions.some((item) => item.id === hashId) ? hashId : DEFAULT_WORKSPACE_ID;
|
||||
}
|
||||
|
||||
function navigate(id, updateHash = true) {
|
||||
const next = definitions.find((item) => item.id === id)
|
||||
|| definitions.find((item) => item.id === DEFAULT_WORKSPACE_ID)
|
||||
|| definitions[0];
|
||||
if (!next || activeWorkspace?.id === next.id) return;
|
||||
|
||||
if (activeWorkspace) {
|
||||
document.getElementById(activeWorkspace.elementId).hidden = true;
|
||||
buttons.get(activeWorkspace.id)?.removeAttribute("aria-current");
|
||||
activeWorkspace.onLeave?.();
|
||||
}
|
||||
|
||||
activeWorkspace = next;
|
||||
const element = document.getElementById(next.elementId);
|
||||
element.hidden = false;
|
||||
setBoardWorkspace(next.id);
|
||||
saveTemplateButton.hidden = !next.capabilities.includes("scene-template-save");
|
||||
currentTitle.textContent = next.title;
|
||||
buttons.get(next.id)?.setAttribute("aria-current", "page");
|
||||
document.title = `${next.title} · 奇妙小屏幕控制器`;
|
||||
Promise.resolve(next.onEnter?.()).catch((error) => {
|
||||
console.error(`工作区 ${next.id} 初始化失败`, error);
|
||||
const message = `${next.title}初始化失败:${error.message}`;
|
||||
const feedback = element.querySelector("[aria-live]");
|
||||
if (feedback) {
|
||||
feedback.textContent = message;
|
||||
feedback.dataset.state = "error";
|
||||
}
|
||||
announce(message, true);
|
||||
});
|
||||
|
||||
if (updateHash && window.location.hash !== `#${next.id}`) {
|
||||
history.pushState(null, "", `#${next.id}`);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
drawer.classList.add("open");
|
||||
drawer.removeAttribute("inert");
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
backdrop.hidden = false;
|
||||
document.body.classList.add("drawer-open");
|
||||
menuButton.setAttribute("aria-expanded", "true");
|
||||
menuButton.setAttribute("aria-label", "关闭功能菜单");
|
||||
if (!workspaceOrderEditing && !workspaceOrderBusy) {
|
||||
loadWorkspaceOrder().catch((error) => {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.load_failed",
|
||||
"项目位置读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer(restoreFocus = false) {
|
||||
drawer.classList.remove("open");
|
||||
drawer.setAttribute("inert", "");
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
backdrop.hidden = true;
|
||||
document.body.classList.remove("drawer-open");
|
||||
menuButton.setAttribute("aria-expanded", "false");
|
||||
menuButton.setAttribute("aria-label", "打开功能菜单");
|
||||
if (restoreFocus) menuButton.focus();
|
||||
}
|
||||
|
||||
function requestCloseDrawer(restoreFocus = false) {
|
||||
if (workspaceOrderEditing || workspaceOrderBusy) {
|
||||
remindWorkspaceOrderSave();
|
||||
return false;
|
||||
}
|
||||
closeDrawer(restoreFocus);
|
||||
return true;
|
||||
}
|
||||
|
||||
workspaceOrderToggle.addEventListener("click", () => {
|
||||
if (workspaceOrderEditing) saveWorkspaceOrder();
|
||||
else beginWorkspaceOrderEdit();
|
||||
});
|
||||
|
||||
menuButton.addEventListener("click", () => {
|
||||
if (drawer.classList.contains("open")) {
|
||||
requestCloseDrawer(true);
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
});
|
||||
backdrop.addEventListener("click", () => requestCloseDrawer(true));
|
||||
window.addEventListener("hashchange", () => navigate(resolveWorkspaceId(), false));
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && drawer.classList.contains("open")) {
|
||||
event.preventDefault();
|
||||
requestCloseDrawer(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab" && drawer.classList.contains("open")) {
|
||||
const focusable = [menuButton, ...Array.from(drawer.querySelectorAll("button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled)"))]
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
buildNavigation();
|
||||
setWorkspaceOrderToggleLabel();
|
||||
navigate(resolveWorkspaceId(), false);
|
||||
loadWorkspaceOrder().catch((error) => {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.load_failed",
|
||||
"项目位置读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById("reload-app").addEventListener("click", () => window.location.reload());
|
||||
|
||||
async function pollStatus() {
|
||||
if (statusPollPending || document.visibilityState !== "visible") return;
|
||||
statusPollPending = true;
|
||||
try {
|
||||
const status = await refreshStatus({ source: "poll" });
|
||||
const currentVersion = status.service?.app_version;
|
||||
if (loadedAppVersion && currentVersion && currentVersion !== loadedAppVersion) {
|
||||
markAppStale();
|
||||
}
|
||||
} catch (_error) {
|
||||
// The device workspace reports persistent failures when the user opens it.
|
||||
} finally {
|
||||
statusPollPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.setInterval(pollStatus, 5000);
|
||||
window.addEventListener("focus", pollStatus);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") pollStatus();
|
||||
});
|
||||
pollStatus();
|
||||
@@ -0,0 +1,174 @@
|
||||
export const BRUSH_MODE_NORMAL = "normal";
|
||||
export const BRUSH_MODE_PIXEL = "pixel";
|
||||
export const CANVAS_VIEW_MIN_SCALE = 1;
|
||||
export const CANVAS_VIEW_MAX_SCALE = 8;
|
||||
export const CANVAS_UNDO_LIMIT = 50;
|
||||
|
||||
const MODE_SPECS = Object.freeze({
|
||||
[BRUSH_MODE_NORMAL]: Object.freeze({ min: 1, max: 16, presets: Object.freeze([1, 2, 4, 8]) }),
|
||||
[BRUSH_MODE_PIXEL]: Object.freeze({ min: 1, max: 4, presets: Object.freeze([1, 2, 3, 4]) }),
|
||||
});
|
||||
|
||||
function record(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
export function brushModeSpec(mode) {
|
||||
return MODE_SPECS[mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL];
|
||||
}
|
||||
|
||||
export function normalizeBrushSize(value, mode) {
|
||||
const spec = brushModeSpec(mode);
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Math.max(spec.min, Math.min(spec.max, Number.isFinite(parsed) ? parsed : spec.min));
|
||||
}
|
||||
|
||||
export function restoreCanvasToolState(savedV2, savedV1 = null) {
|
||||
const current = record(savedV2);
|
||||
const legacy = record(savedV1);
|
||||
const source = current || legacy || {};
|
||||
return {
|
||||
paintColor: typeof source.paintColor === "string" ? source.paintColor : null,
|
||||
backgroundColor: typeof source.backgroundColor === "string" ? source.backgroundColor : null,
|
||||
tool: source.tool === "eraser" ? "eraser" : "brush",
|
||||
pixelEditEnabled: Boolean(current?.pixelEditEnabled),
|
||||
normalBrushSize: normalizeBrushSize(
|
||||
current?.normalBrushSize ?? source.brushSize,
|
||||
BRUSH_MODE_NORMAL,
|
||||
),
|
||||
pixelBrushSize: normalizeBrushSize(current?.pixelBrushSize, BRUSH_MODE_PIXEL),
|
||||
};
|
||||
}
|
||||
|
||||
export function linePoints(from, to) {
|
||||
const points = [];
|
||||
let x = from.x;
|
||||
let y = from.y;
|
||||
const dx = Math.abs(to.x - from.x);
|
||||
const sx = from.x < to.x ? 1 : -1;
|
||||
const dy = -Math.abs(to.y - from.y);
|
||||
const sy = from.y < to.y ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
points.push({ x, y });
|
||||
if (x === to.x && y === to.y) break;
|
||||
const doubled = 2 * error;
|
||||
if (doubled >= dy) {
|
||||
error += dy;
|
||||
x += sx;
|
||||
}
|
||||
if (doubled <= dx) {
|
||||
error += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function brushStampPoints(centerX, centerY, size, mode, width = 64, height = 64) {
|
||||
const normalizedMode = mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
|
||||
const diameter = normalizeBrushSize(size, normalizedMode);
|
||||
const start = -Math.floor((diameter - 1) / 2);
|
||||
const radius = Math.max(0.25, (diameter - 0.5) / 2);
|
||||
const shapeCenter = (diameter - 1) / 2;
|
||||
const points = [];
|
||||
for (let localY = 0; localY < diameter; localY += 1) {
|
||||
for (let localX = 0; localX < diameter; localX += 1) {
|
||||
if (normalizedMode === BRUSH_MODE_NORMAL) {
|
||||
const normalizedX = (localX - shapeCenter) / radius;
|
||||
const normalizedY = (localY - shapeCenter) / radius;
|
||||
if ((normalizedX * normalizedX) + (normalizedY * normalizedY) > 1) continue;
|
||||
}
|
||||
const x = centerX + start + localX;
|
||||
const y = centerY + start + localY;
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) points.push({ x, y });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function pixelBuffersEqual(left, right) {
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class PixelUndoHistory {
|
||||
constructor(limit = CANVAS_UNDO_LIMIT) {
|
||||
this.limit = Math.max(1, Number.isInteger(limit) ? limit : CANVAS_UNDO_LIMIT);
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
record(before, after) {
|
||||
if (pixelBuffersEqual(before, after)) return false;
|
||||
this.entries.push(Uint8Array.from(before));
|
||||
if (this.entries.length > this.limit) this.entries.splice(0, this.entries.length - this.limit);
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
return this.entries.pop() || null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.entries.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCanvasScale(value) {
|
||||
const scale = Number(value);
|
||||
if (!Number.isFinite(scale)) return CANVAS_VIEW_MIN_SCALE;
|
||||
return Math.max(CANVAS_VIEW_MIN_SCALE, Math.min(CANVAS_VIEW_MAX_SCALE, scale));
|
||||
}
|
||||
|
||||
export function clampCanvasView(
|
||||
view,
|
||||
{ viewportWidth, viewportHeight, baseSize, minVisible = 48 },
|
||||
) {
|
||||
const scale = normalizeCanvasScale(view?.scale);
|
||||
const width = Math.max(1, Number(baseSize) || 1) * scale;
|
||||
const height = width;
|
||||
const visibleX = Math.min(Math.max(1, Number(minVisible) || 1), width, viewportWidth);
|
||||
const visibleY = Math.min(Math.max(1, Number(minVisible) || 1), height, viewportHeight);
|
||||
const limitX = Math.max(0, (viewportWidth + width) / 2 - visibleX);
|
||||
const limitY = Math.max(0, (viewportHeight + height) / 2 - visibleY);
|
||||
return {
|
||||
scale,
|
||||
x: Math.max(-limitX, Math.min(limitX, Number(view?.x) || 0)),
|
||||
y: Math.max(-limitY, Math.min(limitY, Number(view?.y) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
export function zoomCanvasView(view, nextScale, anchor, bounds) {
|
||||
const scale = normalizeCanvasScale(nextScale);
|
||||
const previousScale = normalizeCanvasScale(view?.scale);
|
||||
const centerX = bounds.viewportWidth / 2;
|
||||
const centerY = bounds.viewportHeight / 2;
|
||||
const ratio = scale / previousScale;
|
||||
const x = (anchor.x - centerX) - ratio * (anchor.x - centerX - (Number(view?.x) || 0));
|
||||
const y = (anchor.y - centerY) - ratio * (anchor.y - centerY - (Number(view?.y) || 0));
|
||||
return clampCanvasView({ scale, x, y }, bounds);
|
||||
}
|
||||
|
||||
export function clampFloatingButton(position, {
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
size = 56,
|
||||
inset = 8,
|
||||
}) {
|
||||
const half = size / 2;
|
||||
const minX = inset + half;
|
||||
const maxX = Math.max(minX, viewportWidth - inset - half);
|
||||
const minY = inset + half;
|
||||
const maxY = Math.max(minY, viewportHeight - inset - half);
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, Number(position?.x) || minX)),
|
||||
y: Math.max(minY, Math.min(maxY, Number(position?.y) || minY)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, Number(value)));
|
||||
}
|
||||
|
||||
export function normalizeHex(value) {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(String(value).trim());
|
||||
return match ? `#${match[1].toUpperCase()}` : null;
|
||||
}
|
||||
|
||||
export function rgbToHex({ r, g, b }) {
|
||||
return `#${[r, g, b]
|
||||
.map((channel) => Math.round(clamp(channel, 0, 255)).toString(16).padStart(2, "0"))
|
||||
.join("")}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function hexToRgb(value) {
|
||||
const hex = normalizeHex(value);
|
||||
if (!hex) throw new TypeError("颜色必须是 #RRGGBB");
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToHsv({ r, g, b }) {
|
||||
const red = clamp(r, 0, 255) / 255;
|
||||
const green = clamp(g, 0, 255) / 255;
|
||||
const blue = clamp(b, 0, 255) / 255;
|
||||
const maximum = Math.max(red, green, blue);
|
||||
const minimum = Math.min(red, green, blue);
|
||||
const delta = maximum - minimum;
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (maximum === red) hue = 60 * (((green - blue) / delta) % 6);
|
||||
else if (maximum === green) hue = 60 * ((blue - red) / delta + 2);
|
||||
else hue = 60 * ((red - green) / delta + 4);
|
||||
}
|
||||
if (hue < 0) hue += 360;
|
||||
return {
|
||||
h: Math.round(hue),
|
||||
s: Math.round(maximum === 0 ? 0 : (delta / maximum) * 100),
|
||||
v: Math.round(maximum * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function hsvToRgb({ h, s, v }) {
|
||||
const hue = ((Number(h) % 360) + 360) % 360;
|
||||
const saturation = clamp(s, 0, 100) / 100;
|
||||
const value = clamp(v, 0, 100) / 100;
|
||||
const chroma = value * saturation;
|
||||
const segment = hue / 60;
|
||||
const secondary = chroma * (1 - Math.abs((segment % 2) - 1));
|
||||
let channels = [0, 0, 0];
|
||||
if (segment < 1) channels = [chroma, secondary, 0];
|
||||
else if (segment < 2) channels = [secondary, chroma, 0];
|
||||
else if (segment < 3) channels = [0, chroma, secondary];
|
||||
else if (segment < 4) channels = [0, secondary, chroma];
|
||||
else if (segment < 5) channels = [secondary, 0, chroma];
|
||||
else channels = [chroma, 0, secondary];
|
||||
const match = value - chroma;
|
||||
return {
|
||||
r: Math.round((channels[0] + match) * 255),
|
||||
g: Math.round((channels[1] + match) * 255),
|
||||
b: Math.round((channels[2] + match) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRecentColors(colors, value, limit = 10) {
|
||||
const normalized = normalizeHex(value);
|
||||
if (!normalized) return Array.isArray(colors) ? colors.slice(0, limit) : [];
|
||||
const existing = Array.isArray(colors)
|
||||
? colors.map(normalizeHex).filter(Boolean).filter((color) => color !== normalized)
|
||||
: [];
|
||||
return [normalized, ...existing].slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { announce, apiJson, createLocalState, setActiveButton } from "./core.js";
|
||||
import {
|
||||
clamp,
|
||||
hexToRgb,
|
||||
hsvToRgb,
|
||||
normalizeHex,
|
||||
rgbToHex,
|
||||
rgbToHsv,
|
||||
updateRecentColors,
|
||||
} from "./color-math.js";
|
||||
import { setControlBusy, setControlReady } from "./interaction-feedback.js";
|
||||
|
||||
const localState = createLocalState("colors", 1);
|
||||
const savedState = localState.load({});
|
||||
let recentColors = Array.isArray(savedState.recentColors)
|
||||
? savedState.recentColors.map(normalizeHex).filter(Boolean).slice(0, 10)
|
||||
: [];
|
||||
let mode = savedState.mode === "rgb" ? "rgb" : "hsv";
|
||||
let palette = [];
|
||||
let activeTrigger = null;
|
||||
let originalColor = "#000000";
|
||||
let currentRgb = hexToRgb(originalColor);
|
||||
let currentHsv = rgbToHsv(currentRgb);
|
||||
let paletteEditing = false;
|
||||
let pickerReadonly = false;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "color-picker-overlay";
|
||||
overlay.className = "color-picker-overlay";
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML = `
|
||||
<section class="color-picker-panel" role="dialog" aria-modal="true" aria-labelledby="color-picker-title">
|
||||
<header class="color-picker-header">
|
||||
<div><p class="eyebrow">软件自有控件</p><h2 id="color-picker-title">选择颜色</h2></div>
|
||||
<button class="compact-button" type="button" data-color-action="cancel" aria-label="关闭颜色选择器">关闭</button>
|
||||
</header>
|
||||
<div class="color-comparison" aria-label="原颜色与新颜色对比">
|
||||
<div><span>原颜色</span><i id="color-original-preview"></i></div>
|
||||
<div><span>新颜色</span><i id="color-current-preview"></i></div>
|
||||
</div>
|
||||
<div class="color-mode-tabs segmented" role="tablist" aria-label="颜色模式">
|
||||
<button type="button" role="tab" data-color-mode="hsv">HSV</button>
|
||||
<button type="button" role="tab" data-color-mode="rgb">RGB</button>
|
||||
</div>
|
||||
<div id="color-hsv-panel" class="color-mode-panel" role="tabpanel">
|
||||
<div id="color-sv-field" class="color-sv-field" role="slider" tabindex="0" aria-label="饱和度和明度" aria-valuemin="0" aria-valuemax="100">
|
||||
<span id="color-sv-handle" class="color-track-handle"></span>
|
||||
</div>
|
||||
<div id="color-hue-track" class="color-linear-track color-hue-track" role="slider" tabindex="0" aria-label="色相" aria-valuemin="0" aria-valuemax="360">
|
||||
<span id="color-hue-handle" class="color-track-handle"></span>
|
||||
</div>
|
||||
<div class="color-number-grid">
|
||||
<label>H<input id="color-h" type="number" min="0" max="360" inputmode="numeric"></label>
|
||||
<label>S<input id="color-s" type="number" min="0" max="100" inputmode="numeric"></label>
|
||||
<label>V<input id="color-v" type="number" min="0" max="100" inputmode="numeric"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="color-rgb-panel" class="color-mode-panel" role="tabpanel" hidden>
|
||||
<div class="rgb-channel"><span>R</span><div class="color-linear-track" data-rgb-track="r" role="slider" tabindex="0" aria-label="红色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-r" type="number" min="0" max="255" inputmode="numeric"></div>
|
||||
<div class="rgb-channel"><span>G</span><div class="color-linear-track" data-rgb-track="g" role="slider" tabindex="0" aria-label="绿色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-g" type="number" min="0" max="255" inputmode="numeric"></div>
|
||||
<div class="rgb-channel"><span>B</span><div class="color-linear-track" data-rgb-track="b" role="slider" tabindex="0" aria-label="蓝色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-b" type="number" min="0" max="255" inputmode="numeric"></div>
|
||||
</div>
|
||||
<label class="color-hex-field">HEX<input id="color-hex" type="text" maxlength="7" autocapitalize="characters" spellcheck="false" inputmode="text"></label>
|
||||
<section class="color-collection" aria-labelledby="recent-colors-title">
|
||||
<div class="color-collection-heading"><h3 id="recent-colors-title">最近颜色</h3></div>
|
||||
<div id="recent-colors" class="color-swatch-grid"></div>
|
||||
</section>
|
||||
<section class="color-collection" aria-labelledby="palette-colors-title">
|
||||
<div class="color-collection-heading"><h3 id="palette-colors-title">设备收藏色</h3><div><button type="button" class="compact-button" data-color-action="favorite">收藏当前</button><button type="button" class="compact-button" data-color-action="edit-palette">编辑</button></div></div>
|
||||
<div id="palette-colors" class="color-swatch-grid"></div>
|
||||
<p id="palette-status" class="hint" aria-live="polite"></p>
|
||||
</section>
|
||||
<footer class="color-picker-actions">
|
||||
<button type="button" data-color-action="cancel">取消</button>
|
||||
<button type="button" class="primary-button" data-color-action="confirm">使用此颜色</button>
|
||||
</footer>
|
||||
</section>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const panel = overlay.querySelector(".color-picker-panel");
|
||||
const hsvPanel = document.getElementById("color-hsv-panel");
|
||||
const rgbPanel = document.getElementById("color-rgb-panel");
|
||||
const svField = document.getElementById("color-sv-field");
|
||||
const hueTrack = document.getElementById("color-hue-track");
|
||||
const modeButtons = Array.from(overlay.querySelectorAll("[data-color-mode]"));
|
||||
const rgbTracks = Array.from(overlay.querySelectorAll("[data-rgb-track]"));
|
||||
|
||||
function persistLocalColors() {
|
||||
localState.save({ recentColors, mode });
|
||||
}
|
||||
|
||||
function renderTrigger(trigger) {
|
||||
const value = normalizeHex(trigger.value) || "#000000";
|
||||
trigger.value = value;
|
||||
trigger.querySelector(".color-trigger-swatch").style.background = value;
|
||||
trigger.querySelector(".color-trigger-value").textContent = value;
|
||||
}
|
||||
|
||||
function setMode(nextMode) {
|
||||
mode = nextMode === "rgb" ? "rgb" : "hsv";
|
||||
setActiveButton(modeButtons, modeButtons.find((button) => button.dataset.colorMode === mode));
|
||||
modeButtons.forEach((button) => button.setAttribute("aria-selected", String(button.dataset.colorMode === mode)));
|
||||
hsvPanel.hidden = mode !== "hsv";
|
||||
rgbPanel.hidden = mode !== "rgb";
|
||||
persistLocalColors();
|
||||
}
|
||||
|
||||
function setCurrentRgb(rgb) {
|
||||
if (pickerReadonly) return;
|
||||
currentRgb = {
|
||||
r: Math.round(clamp(rgb.r, 0, 255)),
|
||||
g: Math.round(clamp(rgb.g, 0, 255)),
|
||||
b: Math.round(clamp(rgb.b, 0, 255)),
|
||||
};
|
||||
currentHsv = rgbToHsv(currentRgb);
|
||||
renderPicker();
|
||||
}
|
||||
|
||||
function setCurrentHsv(hsv) {
|
||||
if (pickerReadonly) return;
|
||||
currentHsv = {
|
||||
h: Math.round(clamp(hsv.h, 0, 360)),
|
||||
s: Math.round(clamp(hsv.s, 0, 100)),
|
||||
v: Math.round(clamp(hsv.v, 0, 100)),
|
||||
};
|
||||
currentRgb = hsvToRgb(currentHsv);
|
||||
renderPicker();
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
const hex = rgbToHex(currentRgb);
|
||||
document.getElementById("color-original-preview").style.background = originalColor;
|
||||
document.getElementById("color-current-preview").style.background = hex;
|
||||
document.getElementById("color-hex").value = hex;
|
||||
for (const channel of ["r", "g", "b"]) document.getElementById(`color-${channel}`).value = currentRgb[channel];
|
||||
for (const channel of ["h", "s", "v"]) document.getElementById(`color-${channel}`).value = currentHsv[channel];
|
||||
|
||||
svField.style.background = `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${currentHsv.h} 100% 50%))`;
|
||||
const svHandle = document.getElementById("color-sv-handle");
|
||||
svHandle.style.left = `${currentHsv.s}%`;
|
||||
svHandle.style.top = `${100 - currentHsv.v}%`;
|
||||
svField.setAttribute("aria-valuenow", String(currentHsv.s));
|
||||
svField.setAttribute("aria-valuetext", `饱和度 ${currentHsv.s}%,明度 ${currentHsv.v}%`);
|
||||
document.getElementById("color-hue-handle").style.left = `${currentHsv.h / 3.6}%`;
|
||||
hueTrack.setAttribute("aria-valuenow", String(currentHsv.h));
|
||||
|
||||
rgbTracks.forEach((track) => {
|
||||
const channel = track.dataset.rgbTrack;
|
||||
const start = { ...currentRgb, [channel]: 0 };
|
||||
const end = { ...currentRgb, [channel]: 255 };
|
||||
track.style.background = `linear-gradient(to right, ${rgbToHex(start)}, ${rgbToHex(end)})`;
|
||||
track.querySelector(".color-track-handle").style.left = `${(currentRgb[channel] / 255) * 100}%`;
|
||||
track.setAttribute("aria-valuenow", String(currentRgb[channel]));
|
||||
});
|
||||
renderSwatches();
|
||||
}
|
||||
|
||||
function swatchButton(color, label) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "color-swatch";
|
||||
button.style.background = color;
|
||||
button.title = color;
|
||||
button.setAttribute("aria-label", `${label} ${color}`);
|
||||
button.addEventListener("click", () => setCurrentRgb(hexToRgb(color)));
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderSwatches() {
|
||||
const recentContainer = document.getElementById("recent-colors");
|
||||
recentContainer.replaceChildren(...recentColors.map((color) => swatchButton(color, "最近颜色")));
|
||||
if (!recentColors.length) recentContainer.textContent = "还没有最近颜色";
|
||||
|
||||
const paletteContainer = document.getElementById("palette-colors");
|
||||
paletteContainer.replaceChildren(...palette.map((color) => {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className = "palette-swatch";
|
||||
wrapper.appendChild(swatchButton(color, "收藏颜色"));
|
||||
if (paletteEditing) {
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "palette-remove";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", `删除收藏颜色 ${color}`);
|
||||
remove.addEventListener("click", () => deleteFavorite(color, remove));
|
||||
wrapper.appendChild(remove);
|
||||
}
|
||||
return wrapper;
|
||||
}));
|
||||
if (!palette.length) paletteContainer.textContent = "收藏色板为空";
|
||||
}
|
||||
|
||||
async function loadPalette() {
|
||||
const status = document.getElementById("palette-status");
|
||||
status.textContent = "正在读取设备收藏色…";
|
||||
try {
|
||||
const response = await apiJson("/api/colors/palette");
|
||||
palette = response.colors.map(normalizeHex).filter(Boolean);
|
||||
status.textContent = `${palette.length} / ${response.limit}`;
|
||||
renderSwatches();
|
||||
} catch (error) {
|
||||
status.textContent = `收藏色读取失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function addFavorite(control) {
|
||||
setControlBusy(control, { label: "正在收藏…", message: "正在收藏当前颜色…" });
|
||||
try {
|
||||
const response = await apiJson("/api/colors/palette", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ color: rgbToHex(currentRgb) }),
|
||||
});
|
||||
palette = response.colors;
|
||||
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
|
||||
renderSwatches();
|
||||
} catch (error) {
|
||||
announce(`收藏颜色失败:${error.message}`, true);
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFavorite(color, control) {
|
||||
setControlBusy(control, { message: `正在删除收藏颜色 ${color}…` });
|
||||
try {
|
||||
const response = await apiJson(`/api/colors/palette/${color.slice(1)}`, { method: "DELETE" });
|
||||
palette = response.colors;
|
||||
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
|
||||
renderSwatches();
|
||||
} catch (error) {
|
||||
announce(`删除收藏颜色失败:${error.message}`, true);
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
function trackRatio(event, track) {
|
||||
const rect = track.getBoundingClientRect();
|
||||
return clamp((event.clientX - rect.left) / rect.width, 0, 1);
|
||||
}
|
||||
|
||||
function bindHorizontalTrack(track, update) {
|
||||
let activePointer = null;
|
||||
const move = (event) => {
|
||||
if (activePointer !== event.pointerId) return;
|
||||
update(trackRatio(event, track));
|
||||
};
|
||||
track.addEventListener("pointerdown", (event) => {
|
||||
activePointer = event.pointerId;
|
||||
track.setPointerCapture(event.pointerId);
|
||||
update(trackRatio(event, track));
|
||||
});
|
||||
track.addEventListener("pointermove", move);
|
||||
const finish = (event) => { if (activePointer === event.pointerId) activePointer = null; };
|
||||
track.addEventListener("pointerup", finish);
|
||||
track.addEventListener("pointercancel", finish);
|
||||
}
|
||||
|
||||
bindHorizontalTrack(hueTrack, (ratio) => setCurrentHsv({ ...currentHsv, h: ratio * 360 }));
|
||||
rgbTracks.forEach((track) => bindHorizontalTrack(track, (ratio) => {
|
||||
setCurrentRgb({ ...currentRgb, [track.dataset.rgbTrack]: ratio * 255 });
|
||||
}));
|
||||
|
||||
let svPointer = null;
|
||||
function updateSvFromPointer(event) {
|
||||
if (event.pointerId !== svPointer) return;
|
||||
const rect = svField.getBoundingClientRect();
|
||||
setCurrentHsv({
|
||||
...currentHsv,
|
||||
s: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
|
||||
v: clamp((1 - ((event.clientY - rect.top) / rect.height)) * 100, 0, 100),
|
||||
});
|
||||
}
|
||||
svField.addEventListener("pointerdown", (event) => {
|
||||
svPointer = event.pointerId;
|
||||
svField.setPointerCapture(event.pointerId);
|
||||
updateSvFromPointer(event);
|
||||
});
|
||||
svField.addEventListener("pointermove", updateSvFromPointer);
|
||||
svField.addEventListener("pointerup", () => { svPointer = null; });
|
||||
svField.addEventListener("pointercancel", () => { svPointer = null; });
|
||||
|
||||
function sliderKeyDelta(event, large = 10) {
|
||||
if (["ArrowRight", "ArrowUp"].includes(event.key)) return event.shiftKey ? large : 1;
|
||||
if (["ArrowLeft", "ArrowDown"].includes(event.key)) return event.shiftKey ? -large : -1;
|
||||
if (event.key === "PageUp") return large;
|
||||
if (event.key === "PageDown") return -large;
|
||||
return 0;
|
||||
}
|
||||
hueTrack.addEventListener("keydown", (event) => {
|
||||
const delta = sliderKeyDelta(event, 10);
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
setCurrentHsv({ ...currentHsv, h: clamp(currentHsv.h + delta, 0, 360) });
|
||||
});
|
||||
rgbTracks.forEach((track) => track.addEventListener("keydown", (event) => {
|
||||
const delta = sliderKeyDelta(event, 10);
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
const channel = track.dataset.rgbTrack;
|
||||
setCurrentRgb({ ...currentRgb, [channel]: currentRgb[channel] + delta });
|
||||
}));
|
||||
svField.addEventListener("keydown", (event) => {
|
||||
const delta = event.shiftKey ? 10 : 1;
|
||||
const next = { ...currentHsv };
|
||||
if (event.key === "ArrowLeft") next.s -= delta;
|
||||
else if (event.key === "ArrowRight") next.s += delta;
|
||||
else if (event.key === "ArrowDown") next.v -= delta;
|
||||
else if (event.key === "ArrowUp") next.v += delta;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
setCurrentHsv(next);
|
||||
});
|
||||
|
||||
for (const channel of ["r", "g", "b"]) {
|
||||
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
|
||||
setCurrentRgb({ ...currentRgb, [channel]: event.target.value });
|
||||
});
|
||||
}
|
||||
for (const channel of ["h", "s", "v"]) {
|
||||
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
|
||||
setCurrentHsv({ ...currentHsv, [channel]: event.target.value });
|
||||
});
|
||||
}
|
||||
document.getElementById("color-hex").addEventListener("input", (event) => {
|
||||
const hex = normalizeHex(event.target.value);
|
||||
if (hex) setCurrentRgb(hexToRgb(hex));
|
||||
});
|
||||
|
||||
function closePicker() {
|
||||
overlay.hidden = true;
|
||||
document.body.classList.remove("color-picker-open");
|
||||
const trigger = activeTrigger;
|
||||
activeTrigger = null;
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
function confirmPicker() {
|
||||
if (!activeTrigger || pickerReadonly) return;
|
||||
setColorValue(activeTrigger, rgbToHex(currentRgb), { recordHistory: true });
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function openPicker(trigger) {
|
||||
activeTrigger = trigger;
|
||||
pickerReadonly = trigger.dataset.colorReadonly === "true";
|
||||
originalColor = normalizeHex(trigger.value) || "#000000";
|
||||
currentRgb = hexToRgb(originalColor);
|
||||
currentHsv = rgbToHsv(currentRgb);
|
||||
paletteEditing = false;
|
||||
overlay.querySelector("[data-color-action='edit-palette']").classList.remove("active");
|
||||
setMode(mode);
|
||||
renderPicker();
|
||||
panel.classList.toggle("color-picker-readonly", pickerReadonly);
|
||||
document.getElementById("color-picker-title").textContent = pickerReadonly ? "查看颜色" : "选择颜色";
|
||||
panel.querySelectorAll(".color-collection, .color-picker-actions").forEach((element) => {
|
||||
element.hidden = pickerReadonly;
|
||||
});
|
||||
for (const id of ["color-h", "color-s", "color-v", "color-r", "color-g", "color-b", "color-hex"]) {
|
||||
document.getElementById(id).readOnly = pickerReadonly;
|
||||
}
|
||||
[svField, hueTrack, ...rgbTracks].forEach((track) => {
|
||||
track.setAttribute("aria-disabled", String(pickerReadonly));
|
||||
track.tabIndex = pickerReadonly ? -1 : 0;
|
||||
});
|
||||
overlay.hidden = false;
|
||||
document.body.classList.add("color-picker-open");
|
||||
if (!pickerReadonly) loadPalette();
|
||||
window.requestAnimationFrame(() => panel.querySelector("[data-color-action='cancel']").focus());
|
||||
}
|
||||
|
||||
export function setColorValue(trigger, value, { recordHistory = true } = {}) {
|
||||
const color = normalizeHex(value);
|
||||
if (!color) throw new TypeError("颜色必须是 #RRGGBB");
|
||||
trigger.value = color;
|
||||
renderTrigger(trigger);
|
||||
if (recordHistory) {
|
||||
recentColors = updateRecentColors(recentColors, color, 10);
|
||||
persistLocalColors();
|
||||
}
|
||||
trigger.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
trigger.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
export function refreshColorTrigger(trigger) {
|
||||
renderTrigger(trigger);
|
||||
}
|
||||
|
||||
modeButtons.forEach((button) => button.addEventListener("click", () => setMode(button.dataset.colorMode)));
|
||||
overlay.addEventListener("click", (event) => {
|
||||
if (event.target === overlay) closePicker();
|
||||
const action = event.target.closest("[data-color-action]")?.dataset.colorAction;
|
||||
if (action === "cancel") closePicker();
|
||||
else if (action === "confirm") confirmPicker();
|
||||
else if (action === "favorite") addFavorite(event.target.closest("[data-color-action='favorite']"));
|
||||
else if (action === "edit-palette") {
|
||||
paletteEditing = !paletteEditing;
|
||||
event.target.classList.toggle("active", paletteEditing);
|
||||
renderSwatches();
|
||||
}
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (overlay.hidden) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(panel.querySelectorAll("button:not(:disabled), input:not(:disabled), [tabindex='0']"))
|
||||
.filter((element) => !element.closest("[hidden]") && element.getClientRects().length > 0);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-color-picker]").forEach((trigger) => {
|
||||
renderTrigger(trigger);
|
||||
trigger.addEventListener("click", () => openPicker(trigger));
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
const workspaces = new Map();
|
||||
const PERSISTENCE_POLICIES = new Set(["none", "local", "server"]);
|
||||
let appStale = false;
|
||||
export const DEFAULT_API_TIMEOUT_MS = 15000;
|
||||
|
||||
export const DEFAULT_WORKSPACE_ID = "device";
|
||||
|
||||
export function isAppStale() {
|
||||
return appStale;
|
||||
}
|
||||
|
||||
export function markAppStale() {
|
||||
if (appStale) return;
|
||||
appStale = true;
|
||||
document.getElementById("app-version-notice")?.removeAttribute("hidden");
|
||||
announce("控制程序已更新,请刷新页面后再操作设备", true);
|
||||
document.dispatchEvent(new CustomEvent("matrix:app-stale"));
|
||||
}
|
||||
|
||||
function ensureWriteAllowed(path, options) {
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
const isWrite = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
||||
if (appStale && isWrite && !path.startsWith("/api/preview/")) {
|
||||
const error = new Error("页面版本已过期,请刷新后再提交此操作");
|
||||
error.status = 409;
|
||||
error.code = "APP_STALE";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorkspace(definition) {
|
||||
const required = ["id", "title", "elementId", "order", "persistence"];
|
||||
for (const key of required) {
|
||||
if (definition[key] === undefined || definition[key] === null || definition[key] === "") {
|
||||
throw new Error(`工作区缺少 ${key}`);
|
||||
}
|
||||
}
|
||||
if (workspaces.has(definition.id)) {
|
||||
throw new Error(`工作区 ${definition.id} 重复注册`);
|
||||
}
|
||||
if (!PERSISTENCE_POLICIES.has(definition.persistence)) {
|
||||
throw new Error(`工作区 ${definition.id} 的持久化策略无效`);
|
||||
}
|
||||
const capabilities = definition.capabilities || [];
|
||||
if (!Array.isArray(capabilities) || capabilities.some((item) => typeof item !== "string")) {
|
||||
throw new Error(`工作区 ${definition.id} 的能力声明无效`);
|
||||
}
|
||||
workspaces.set(definition.id, Object.freeze({
|
||||
...definition,
|
||||
capabilities: Object.freeze([...new Set(capabilities)]),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getWorkspaces() {
|
||||
return Array.from(workspaces.values()).sort((left, right) => (
|
||||
left.order - right.order
|
||||
|| left.title.localeCompare(right.title, "zh-CN")
|
||||
));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(path, options) {
|
||||
const { timeoutMs = DEFAULT_API_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const forwardAbort = () => controller.abort(externalSignal?.reason);
|
||||
if (externalSignal?.aborted) forwardAbort();
|
||||
else externalSignal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
const timer = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
return await fetch(path, { ...fetchOptions, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
const timeoutError = new Error("请求超时,请检查核桃派网络或服务状态后重试");
|
||||
timeoutError.code = "REQUEST_TIMEOUT";
|
||||
throw timeoutError;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
externalSignal?.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson(path, options = {}) {
|
||||
ensureWriteAllowed(path, options);
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.detail || response.statusText);
|
||||
error.status = response.status;
|
||||
error.detail = data.detail;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiBlob(path, options = {}) {
|
||||
ensureWriteAllowed(path, options);
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = response.statusText;
|
||||
try {
|
||||
const data = await response.json();
|
||||
message = data.detail || message;
|
||||
} catch (_error) {
|
||||
// Keep the HTTP status text when the error body is not JSON.
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function apiFile(path, file, options = {}) {
|
||||
ensureWriteAllowed(path, options);
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
method: options.method || "POST",
|
||||
body: file,
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/octet-stream", ...(options.headers || {}) },
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_error) {
|
||||
data = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(data?.detail || response.statusText);
|
||||
error.status = response.status;
|
||||
error.detail = data?.detail;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function announce(text, isError = false) {
|
||||
const message = document.getElementById("last-message");
|
||||
message.textContent = text;
|
||||
message.classList.toggle("message-error", isError);
|
||||
message.setAttribute("role", isError ? "alert" : "status");
|
||||
}
|
||||
|
||||
export function createLocalState(namespace, version) {
|
||||
const key = `matrixController:${namespace}:v${version}`;
|
||||
return {
|
||||
load(fallback = null) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
save(value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (_error) {
|
||||
announce("浏览器无法保存当前编辑草稿", true);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setActiveButton(buttons, activeButton) {
|
||||
buttons.forEach((button) => {
|
||||
const active = button === activeButton;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
}
|
||||
|
||||
export function debounce(callback, wait) {
|
||||
let timer = null;
|
||||
const wrapped = (...args) => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
wrapped.flush = (...args) => {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
callback(...args);
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export const CURRENT_DISPLAY_SPEEDS = Object.freeze([0.5, 1, 1.5, 2]);
|
||||
|
||||
export function formatPlaybackTime(valueMs) {
|
||||
const milliseconds = Math.max(0, Math.floor(Number(valueMs) || 0));
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
const hundredths = Math.floor((milliseconds % 1000) / 10);
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function projectPlaybackPosition(playback, elapsedMs = 0) {
|
||||
if (!playback?.active || !Number.isFinite(Number(playback.total_duration_ms))) return null;
|
||||
const total = Math.max(1, Number(playback.total_duration_ms));
|
||||
const base = Math.max(0, Number(playback.position_ms) || 0);
|
||||
if (playback.paused) return Math.min(total - 1, base);
|
||||
const speed = Number(playback.speed) || 1;
|
||||
const projected = base + Math.max(0, Number(elapsedMs) || 0) * speed;
|
||||
return Math.min(total - 1, projected % total);
|
||||
}
|
||||
|
||||
export class LatestSeekDispatcher {
|
||||
constructor(send, {
|
||||
intervalMs = 100,
|
||||
now = () => performance.now(),
|
||||
setTimer = (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimer = (timer) => window.clearTimeout(timer),
|
||||
onError = () => {},
|
||||
} = {}) {
|
||||
this.send = send;
|
||||
this.intervalMs = intervalMs;
|
||||
this.now = now;
|
||||
this.setTimer = setTimer;
|
||||
this.clearTimer = clearTimer;
|
||||
this.onError = onError;
|
||||
this.inFlight = false;
|
||||
this.lastStartedAt = Number.NEGATIVE_INFINITY;
|
||||
this.queued = null;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
enqueue(positionMs, { flush = false } = {}) {
|
||||
this.queued = { positionMs, flush: flush || Boolean(this.queued?.flush) };
|
||||
this.#drain();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.queued = null;
|
||||
if (this.timer !== null) this.clearTimer(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
#drain() {
|
||||
if (this.inFlight || this.queued === null) return;
|
||||
const waitMs = this.intervalMs - (this.now() - this.lastStartedAt);
|
||||
if (!this.queued.flush && waitMs > 0) {
|
||||
if (this.timer === null) {
|
||||
this.timer = this.setTimer(() => {
|
||||
this.timer = null;
|
||||
this.#drain();
|
||||
}, waitMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.timer !== null) this.clearTimer(this.timer);
|
||||
this.timer = null;
|
||||
const item = this.queued;
|
||||
this.queued = null;
|
||||
this.inFlight = true;
|
||||
this.lastStartedAt = this.now();
|
||||
Promise.resolve()
|
||||
.then(() => this.send(item.positionMs))
|
||||
.catch((error) => this.onError(error, item.positionMs))
|
||||
.finally(() => {
|
||||
this.inFlight = false;
|
||||
this.#drain();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
const SOURCE_ORDER = Object.freeze(["automatic", "imported", "system"]);
|
||||
const SOURCE_LABELS = Object.freeze({
|
||||
automatic: "自动字体",
|
||||
imported: "已导入字体",
|
||||
system: "系统字体",
|
||||
});
|
||||
|
||||
export function normalizeFontItems(items) {
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
for (const raw of Array.isArray(items) ? items : []) {
|
||||
if (!raw || typeof raw.id !== "string" || !raw.id || seen.has(raw.id)) continue;
|
||||
if (!SOURCE_ORDER.includes(raw.source)) continue;
|
||||
seen.add(raw.id);
|
||||
const family = String(raw.family || "未命名字体").trim() || "未命名字体";
|
||||
const style = String(raw.style || "Regular").trim() || "Regular";
|
||||
normalized.push(Object.freeze({
|
||||
id: raw.id,
|
||||
label: String(raw.label || family).trim() || family,
|
||||
family,
|
||||
style,
|
||||
source: raw.source,
|
||||
}));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function filterFontItems(items, query = "") {
|
||||
const needle = String(query).trim().toLocaleLowerCase("zh-CN");
|
||||
if (!needle) return [...items];
|
||||
return items.filter((item) => (
|
||||
`${item.label} ${item.family} ${item.style}`.toLocaleLowerCase("zh-CN").includes(needle)
|
||||
));
|
||||
}
|
||||
|
||||
export function groupFontItems(items) {
|
||||
return SOURCE_ORDER
|
||||
.map((source) => ({
|
||||
source,
|
||||
label: SOURCE_LABELS[source],
|
||||
items: items.filter((item) => item.source === source),
|
||||
}))
|
||||
.filter((group) => group.items.length);
|
||||
}
|
||||
|
||||
export function createFontPicker({ input, listbox, onCommit }) {
|
||||
let items = [];
|
||||
let selectedId = "default";
|
||||
let visibleItems = [];
|
||||
let activeIndex = -1;
|
||||
let enabled = false;
|
||||
let closeTimer = null;
|
||||
|
||||
function selectedItem() {
|
||||
return items.find((item) => item.id === selectedId) || null;
|
||||
}
|
||||
|
||||
function selectedLabel() {
|
||||
return selectedItem()?.label || "当前字体不可用,将自动回退";
|
||||
}
|
||||
|
||||
function restoreLabel() {
|
||||
input.value = selectedLabel();
|
||||
input.classList.toggle("font-reference-missing", !selectedItem());
|
||||
}
|
||||
|
||||
function close({ restore = true } = {}) {
|
||||
window.clearTimeout(closeTimer);
|
||||
listbox.hidden = true;
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
activeIndex = -1;
|
||||
if (restore) restoreLabel();
|
||||
}
|
||||
|
||||
function optionId(index) {
|
||||
return `${listbox.id}-option-${index}`;
|
||||
}
|
||||
|
||||
function setActive(index) {
|
||||
if (!visibleItems.length) {
|
||||
activeIndex = -1;
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
return;
|
||||
}
|
||||
activeIndex = Math.max(0, Math.min(index, visibleItems.length - 1));
|
||||
input.setAttribute("aria-activedescendant", optionId(activeIndex));
|
||||
listbox.querySelectorAll('[role="option"]').forEach((option, optionIndex) => {
|
||||
const active = optionIndex === activeIndex;
|
||||
option.classList.toggle("active", active);
|
||||
if (active) option.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
function commit(item) {
|
||||
if (!item) return;
|
||||
selectedId = item.id;
|
||||
restoreLabel();
|
||||
close({ restore: false });
|
||||
onCommit?.(item);
|
||||
}
|
||||
|
||||
function render(query = "") {
|
||||
visibleItems = filterFontItems(items, query);
|
||||
const groups = groupFontItems(visibleItems);
|
||||
const nodes = [];
|
||||
let optionIndex = 0;
|
||||
for (const group of groups) {
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "font-option-group";
|
||||
heading.textContent = group.label;
|
||||
heading.setAttribute("role", "presentation");
|
||||
nodes.push(heading);
|
||||
for (const item of group.items) {
|
||||
const option = document.createElement("div");
|
||||
option.id = optionId(optionIndex);
|
||||
option.className = "font-option";
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-selected", String(item.id === selectedId));
|
||||
option.setAttribute("aria-label", item.label);
|
||||
option.dataset.fontId = item.id;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = item.family;
|
||||
const source = document.createElement("small");
|
||||
source.textContent = item.style;
|
||||
source.setAttribute("aria-hidden", "true");
|
||||
option.append(label, source);
|
||||
option.addEventListener("pointerdown", (event) => event.preventDefault());
|
||||
option.addEventListener("click", () => commit(item));
|
||||
nodes.push(option);
|
||||
optionIndex += 1;
|
||||
}
|
||||
}
|
||||
if (!visibleItems.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "font-options-empty";
|
||||
empty.textContent = "没有匹配的字体";
|
||||
nodes.push(empty);
|
||||
}
|
||||
listbox.replaceChildren(...nodes);
|
||||
activeIndex = visibleItems.findIndex((item) => item.id === selectedId);
|
||||
if (activeIndex < 0 && visibleItems.length) activeIndex = 0;
|
||||
setActive(activeIndex);
|
||||
}
|
||||
|
||||
function open(query = "") {
|
||||
if (!enabled) return;
|
||||
render(query);
|
||||
listbox.hidden = false;
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
input.addEventListener("focus", () => {
|
||||
if (!enabled) return;
|
||||
input.select();
|
||||
open();
|
||||
});
|
||||
input.addEventListener("click", () => open());
|
||||
input.addEventListener("input", (event) => {
|
||||
event.stopPropagation();
|
||||
open(input.value);
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (!enabled) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (listbox.hidden) open();
|
||||
setActive(activeIndex + (event.key === "ArrowDown" ? 1 : -1));
|
||||
} else if (event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault();
|
||||
if (listbox.hidden) open();
|
||||
setActive(event.key === "Home" ? 0 : visibleItems.length - 1);
|
||||
} else if (event.key === "Enter" && !listbox.hidden) {
|
||||
event.preventDefault();
|
||||
commit(visibleItems[activeIndex]);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", () => {
|
||||
closeTimer = window.setTimeout(() => close(), 0);
|
||||
});
|
||||
|
||||
restoreLabel();
|
||||
return Object.freeze({
|
||||
setItems(nextItems) {
|
||||
items = normalizeFontItems(nextItems);
|
||||
restoreLabel();
|
||||
if (!listbox.hidden) render();
|
||||
},
|
||||
setValue(identifier) {
|
||||
selectedId = typeof identifier === "string" && identifier ? identifier : "default";
|
||||
restoreLabel();
|
||||
},
|
||||
getValue() {
|
||||
return selectedId;
|
||||
},
|
||||
setEnabled(value) {
|
||||
enabled = Boolean(value);
|
||||
input.readOnly = !enabled;
|
||||
if (!enabled) close();
|
||||
},
|
||||
open,
|
||||
close,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#111318">
|
||||
<title>奇妙小屏幕控制器</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<button id="drawer-toggle" class="drawer-toggle" type="button" aria-controls="workspace-drawer" aria-expanded="false" aria-label="打开功能菜单">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path class="drawer-toggle-menu-icon" d="M4 7h16M4 12h16M4 17h16"></path>
|
||||
<path class="drawer-toggle-close-icon" d="M6 6l12 12M18 6L6 18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<aside id="workspace-drawer" class="drawer" aria-label="功能选择" aria-hidden="true" inert>
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="64 × 64 点阵屏"></p>
|
||||
<p class="brand">奇妙小屏幕控制器</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace-order-editor">
|
||||
<button id="workspace-order-toggle" class="workspace-order-toggle compact-button" type="button">自定义项目位置</button>
|
||||
<p id="workspace-order-feedback" class="workspace-order-feedback" role="status" aria-live="polite" hidden></p>
|
||||
</div>
|
||||
<nav id="workspace-nav" class="workspace-nav" aria-label="工作区"></nav>
|
||||
<p class="drawer-note" data-ui-copy-default-text="局域网设备控制台"></p>
|
||||
</aside>
|
||||
|
||||
<button id="drawer-backdrop" class="drawer-backdrop" type="button" aria-label="关闭功能菜单" hidden></button>
|
||||
|
||||
<div class="app-main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-copy">
|
||||
<h1 id="current-workspace-title">设备状态和测试</h1>
|
||||
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
|
||||
</div>
|
||||
<div class="topbar-statuses" aria-label="设备实时状态">
|
||||
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
|
||||
当前画面:正在读取…
|
||||
</button>
|
||||
<button id="topbar-cpu" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看 CPU 占用详情">
|
||||
CPU -
|
||||
</button>
|
||||
<button id="topbar-memory" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看内存占用详情">
|
||||
RAM -
|
||||
</button>
|
||||
<div
|
||||
id="topbar-low-voltage-warning"
|
||||
class="low-voltage-warning"
|
||||
data-mode="disabled"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></div>
|
||||
<div id="topbar-voltage" class="voltage-indicator" data-ui-copy-value data-status="starting" role="status" aria-live="polite">
|
||||
正在读取电压…
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="app-version-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
|
||||
<div>
|
||||
<strong>控制程序已更新</strong>
|
||||
<p>当前页面版本已过期。画板仍可编辑和导出,但刷新前不会再向设备或模板提交操作。</p>
|
||||
</div>
|
||||
<button id="reload-app" class="primary-button" type="button">刷新到新版本</button>
|
||||
</section>
|
||||
|
||||
<section id="draft-conflict-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
|
||||
<div>
|
||||
<strong>另一标签页修改了画板草稿</strong>
|
||||
<p>请选择载入另一标签页的最新草稿,或明确保留当前标签页的草稿。</p>
|
||||
</div>
|
||||
<div class="sync-notice-actions">
|
||||
<button id="load-external-draft" type="button">载入其他标签草稿</button>
|
||||
<button id="keep-current-draft" class="primary-button" type="button">保留当前草稿</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="board-shell" class="board-shell" aria-labelledby="board-title">
|
||||
<div id="standard-board-shell">
|
||||
<div class="board-heading">
|
||||
<div>
|
||||
<p id="board-eyebrow" class="eyebrow" data-ui-copy-value>64 × 64 统一画板</p>
|
||||
<h2 id="board-title" data-ui-copy-value>合成预览</h2>
|
||||
</div>
|
||||
<div class="board-heading-context">
|
||||
<span id="board-mode" class="orientation-label" data-ui-copy-value>正向 0° · 统一画板</span>
|
||||
<section id="animation-edit-notice" class="animation-edit-notice" aria-live="polite" hidden>
|
||||
<strong id="animation-edit-name">正在编辑:</strong>
|
||||
<span id="animation-edit-status">已保存</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div id="board-stage" class="board-stage">
|
||||
<canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas>
|
||||
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
|
||||
<div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div>
|
||||
</div>
|
||||
<p id="board-status" class="board-status" aria-live="polite">正在恢复画板草稿…</p>
|
||||
<div class="board-actions">
|
||||
<button id="apply-composition" class="primary-button" type="button">应用统一画板</button>
|
||||
<button id="exit-animation-edit" type="button" hidden>退出动图编辑</button>
|
||||
<button id="export-composition" type="button">导出合成 PNG</button>
|
||||
<button id="save-template" type="button" hidden>保存为模板</button>
|
||||
</div>
|
||||
<div id="canvas-edit-assistive" class="canvas-edit-assistive" hidden>
|
||||
<button id="canvas-assistive-toggle" class="canvas-assistive-toggle" type="button"
|
||||
aria-controls="canvas-assistive-menu" aria-expanded="false" aria-label="打开画布编辑菜单">
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="canvas-assistive-menu" class="canvas-assistive-menu" role="menu" aria-label="画布编辑操作" hidden>
|
||||
<p id="canvas-edit-mode-hint" role="status" aria-live="polite">绘画模式:直接在画布上绘制</p>
|
||||
<button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true"
|
||||
data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button>
|
||||
<button id="exit-canvas-edit" type="button" role="menuitem">退出编辑</button>
|
||||
<button id="move-canvas-view" type="button" role="menuitem">移动画布视线</button>
|
||||
<button id="continue-canvas-edit" type="button" role="menuitem" aria-disabled="true"
|
||||
data-unavailable-reason="当前已经是绘画模式。">继续编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section id="animation-preview-shell" class="animation-preview-shell" aria-labelledby="animation-preview-shell-title" hidden>
|
||||
<h2 id="animation-preview-shell-title" class="sr-only">动图与当前画板预览</h2>
|
||||
<article class="animation-preview-card">
|
||||
<p class="eyebrow">已保存内容</p>
|
||||
<h3>当前动图</h3>
|
||||
<div class="animation-small-preview" aria-label="当前打开动图的循环预览">
|
||||
<img id="animation-preview-image" alt="" width="64" height="64" hidden>
|
||||
<span id="animation-preview-placeholder">请先打开一个动图</span>
|
||||
</div>
|
||||
<p id="animation-preview-status" class="animation-preview-status" aria-live="polite">请先打开一个动图</p>
|
||||
</article>
|
||||
<article class="animation-preview-card">
|
||||
<p class="eyebrow">新建帧来源</p>
|
||||
<h3>当前画板</h3>
|
||||
<div class="animation-small-preview">
|
||||
<canvas id="animation-board-preview" width="64" height="64" aria-label="当前统一画板预览"></canvas>
|
||||
</div>
|
||||
<p class="animation-preview-status">“从当前画板新建帧”将复制此画面</p>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<main id="workspace-root" class="workspace-root">
|
||||
<section id="workspace-device" class="workspace" aria-label="设备状态和测试" hidden>
|
||||
<div class="device-refresh-row">
|
||||
<button id="refresh-status" class="secondary-button" type="button">刷新状态</button>
|
||||
</div>
|
||||
|
||||
<article class="panel status-panel">
|
||||
<div class="panel-heading">
|
||||
<h3>屏幕状态</h3>
|
||||
<span class="status-badge">仅为程序设定值,屏幕实际情况软件无法得知</span>
|
||||
</div>
|
||||
<dl class="status-grid status-grid-compact">
|
||||
<div><dt>屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)</dt><dd id="status-orientation" data-ui-copy-value>-</dd></div>
|
||||
<div><dt>亮度(实际亮度会受到电压保护功能限制)</dt><dd id="status-brightness" data-ui-copy-value>-</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h3>纯色测试</h3>
|
||||
<div class="choice-grid color-choices" role="group" aria-label="纯色测试选择">
|
||||
<button type="button" data-test-fill="#000000">全黑(不亮)</button>
|
||||
<button type="button" data-test-fill="#FF0000">全红</button>
|
||||
<button type="button" data-test-fill="#00FF00">全绿</button>
|
||||
<button type="button" data-test-fill="#0000FF">全蓝</button>
|
||||
<button type="button" data-test-fill="#FFFFFF">全白(高亮度会导致严重耗电)</button>
|
||||
<button id="custom-test-fill" type="button">自定义纯色</button>
|
||||
</div>
|
||||
<div class="color-field"><span>颜色 RGB / HEX</span>
|
||||
<button id="test-color" class="color-trigger" type="button" value="#40A0FF" data-color-picker data-color-readonly="true" aria-label="查看测试颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#40A0FF</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="display-test-controls" class="display-test-controls" hidden>
|
||||
<div class="panel-heading">
|
||||
<label for="test-brightness">测试亮度(此亮度独立于屏幕亮度)</label>
|
||||
<output id="test-brightness-value" data-ui-copy-value for="test-brightness" class="brightness-output">50%</output>
|
||||
</div>
|
||||
<input id="test-brightness" class="brightness-slider" type="range" min="1" max="100" value="50">
|
||||
<p id="test-brightness-protection" class="brightness-protection-status" role="status" aria-live="polite" hidden></p>
|
||||
<p id="display-test-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
<button id="exit-display-test" class="danger-button" type="button">退出测试</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel device-storage-panel">
|
||||
<div class="panel-heading"><h3>设备容量</h3></div>
|
||||
<section class="template-storage device-storage-grid" aria-label="设备与软件空间">
|
||||
<div><span>设备总空间</span><strong id="device-total-space" data-ui-copy-value>-</strong></div>
|
||||
<div><span>设备可用空间</span><strong id="device-free-space" data-ui-copy-value>-</strong></div>
|
||||
<div><span>当前软件总占用空间</span><strong id="device-software-size" data-ui-copy-value>-</strong></div>
|
||||
<div><span>模板占用空间</span><strong id="device-template-size" data-ui-copy-value>-</strong></div>
|
||||
</section>
|
||||
<p id="device-storage-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="workspace-canvas" class="workspace" aria-labelledby="canvas-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="canvas-title" aria-label="像素画布" data-ui-copy-default-text="像素画布"></h2>
|
||||
</div>
|
||||
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
|
||||
</div>
|
||||
|
||||
<article class="panel canvas-workbench">
|
||||
<div class="canvas-toolbar">
|
||||
<div class="color-field"><span>画笔颜色</span>
|
||||
<button id="paint-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择画笔颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="color-field"><span>背景颜色</span>
|
||||
<button id="background-color" class="color-trigger" type="button" value="#000000" data-color-picker aria-label="选择画布背景颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#000000</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="segmented canvas-tools" role="group" aria-label="绘制工具">
|
||||
<button type="button" data-canvas-tool="brush">画笔</button>
|
||||
<button type="button" data-canvas-tool="eraser">橡皮</button>
|
||||
<button type="button" data-canvas-tool="eyedropper">吸管</button>
|
||||
</div>
|
||||
<div class="brush-size-control">
|
||||
<div class="brush-size-heading"><span>笔尖粗细</span><output id="brush-size-value" data-ui-copy-value for="brush-size">1 px</output></div>
|
||||
<div class="brush-size-row">
|
||||
<span id="brush-size-preview" class="brush-size-preview" aria-hidden="true"></span>
|
||||
<input id="brush-size" type="range" min="1" max="16" value="1" aria-label="画笔和橡皮粗细">
|
||||
</div>
|
||||
<div class="brush-size-presets" role="group" aria-label="笔尖粗细快捷选择">
|
||||
<button type="button" data-brush-size="1">1</button><button type="button" data-brush-size="2">2</button>
|
||||
<button type="button" data-brush-size="4">4</button><button type="button" data-brush-size="8">8</button>
|
||||
</div>
|
||||
<button id="pixel-edit-toggle" class="pixel-edit-toggle" type="button" aria-pressed="false" aria-describedby="pixel-edit-status">开启单像素编辑</button>
|
||||
<p id="pixel-edit-status" class="pixel-edit-status" aria-live="polite" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="choice-grid canvas-actions">
|
||||
<button id="fill-canvas" type="button">填充背景</button>
|
||||
<button id="clear-canvas" type="button">清空像素层</button>
|
||||
</div>
|
||||
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
|
||||
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="workspace-text" class="workspace" aria-labelledby="text-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="text-title" aria-label="文字显示" data-ui-copy-default-text="文字显示"></h2>
|
||||
</div>
|
||||
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
|
||||
</div>
|
||||
|
||||
<article class="panel text-elements-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h3>文字元素</h3>
|
||||
<p id="text-selection-status" class="subtle">新建文字或从列表选择一个元素</p>
|
||||
</div>
|
||||
<span class="status-badge" data-ui-copy-default-text="Pillow"></span>
|
||||
</div>
|
||||
<div class="text-element-actions">
|
||||
<button id="create-text" class="primary-button" type="button">新建文字</button>
|
||||
<button id="duplicate-text" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">复制选中</button>
|
||||
<button id="delete-text" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">删除选中</button>
|
||||
</div>
|
||||
<div id="text-element-list" class="text-element-list" role="list" aria-label="文字元素列表"></div>
|
||||
</article>
|
||||
|
||||
<form id="text-form" class="panel text-form" autocomplete="off">
|
||||
<fieldset id="text-content-fields" disabled>
|
||||
<label class="wide-field">文字内容
|
||||
<textarea id="text-value" rows="2" maxlength="512"></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
<div class="font-field wide-field">
|
||||
<label id="text-font-label" for="text-font">字体</label>
|
||||
<div class="font-control-row">
|
||||
<div class="font-combobox-shell">
|
||||
<input id="text-font" type="search" role="combobox" value="自动多语言字体 — 自动回退"
|
||||
aria-labelledby="text-font-label" aria-autocomplete="list" aria-expanded="false"
|
||||
aria-controls="text-font-options" autocapitalize="off" autocomplete="off" spellcheck="false">
|
||||
<div id="text-font-options" class="font-options" role="listbox" aria-label="可用字体" hidden></div>
|
||||
</div>
|
||||
<button id="import-font" type="button">导入字体</button>
|
||||
<input id="font-file" class="sr-only" type="file" accept=".ttf,.otf,.ttc,.otc">
|
||||
</div>
|
||||
<span class="hint">选择设备已有字体,或导入 TTF、OTF、TTC、OTC 文件;缺字时会自动回退。</span>
|
||||
<p id="font-feedback" class="font-feedback" role="status" aria-live="polite" hidden></p>
|
||||
</div>
|
||||
<fieldset id="text-fields" disabled>
|
||||
<div class="form-grid">
|
||||
<label>字号
|
||||
<input id="text-size" type="number" min="1" max="64" value="12" inputmode="numeric">
|
||||
</label>
|
||||
<label>X 坐标
|
||||
<input id="text-x" type="number" min="-128" max="128" value="4" inputmode="numeric">
|
||||
</label>
|
||||
<label>Y 坐标
|
||||
<input id="text-y" type="number" min="-128" max="128" value="24" inputmode="numeric">
|
||||
</label>
|
||||
<label>对齐
|
||||
<select id="text-align">
|
||||
<option value="left">左对齐</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右对齐</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="color-field"><span>文字颜色</span>
|
||||
<button id="text-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择文字颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="media-import-title" data-ui-copy-default-text="媒体内容转换"></h2>
|
||||
</div>
|
||||
<button id="refresh-media-imports" class="secondary-button" type="button" data-ui-copy-default-text="刷新任务"></button>
|
||||
</div>
|
||||
<form id="media-upload-form" class="panel media-upload-panel">
|
||||
<div class="panel-heading">
|
||||
<h3 data-ui-copy-default-text="导入图片、动图或视频"></h3>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="media-source-field"><span data-ui-copy-default-text="源文件"></span>
|
||||
<input id="media-source-file" type="file" required
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,image/heic,image/heif,image/bmp,image/tiff,image/jp2,video/*,.mkv,.m4v,.mpeg,.mpg">
|
||||
</label>
|
||||
</div>
|
||||
<button id="media-upload-button" class="primary-button" type="submit">上传文件</button>
|
||||
<progress id="media-upload-progress" max="100" value="0" hidden></progress>
|
||||
</form>
|
||||
<p id="media-import-feedback" class="settings-feedback" aria-live="polite"
|
||||
data-ui-copy-default-text="选择文件并填写保存名称。"></p>
|
||||
<div id="media-import-jobs" class="media-job-list" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="workspace-animations" class="workspace" aria-labelledby="animations-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div><p class="eyebrow" data-ui-copy-default-text="内容"></p><h2 id="animations-title">动图管理</h2></div>
|
||||
<button id="refresh-animations" class="secondary-button" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="animation-toolbar panel">
|
||||
<button id="create-animation" class="primary-button" type="button">新建动图</button>
|
||||
<button id="add-animation-frame" type="button" aria-disabled="true" data-unavailable-reason="请先新建或打开一个动图,再把当前画板添加为帧。">从当前画板新建帧</button>
|
||||
</div>
|
||||
<p id="animation-feedback" class="settings-feedback" aria-live="polite">请选择或新建动图。</p>
|
||||
<section id="animation-frame-panel" class="panel" hidden>
|
||||
<div class="panel-heading"><h3 id="animation-frame-title">当前动图</h3></div>
|
||||
<div id="animation-batch-toolbar" class="animation-batch-toolbar" aria-label="动图帧批量操作">
|
||||
<p id="animation-selection-status" aria-live="polite">未选择帧</p>
|
||||
<div class="animation-batch-actions">
|
||||
<button id="animation-batch-copy" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量复制</button>
|
||||
<button id="animation-batch-move" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量移动</button>
|
||||
<button id="animation-batch-delete" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="animation-frames" class="template-grid" aria-label="动图帧"></div>
|
||||
<nav id="animation-frame-pagination" class="animation-frame-pagination" aria-label="动图帧分页" hidden>
|
||||
<button id="animation-page-prev" type="button">上一页</button>
|
||||
<p id="animation-page-status" aria-live="polite">第 1 / 1 页 · 共 0 帧</p>
|
||||
<button id="animation-page-next" type="button">下一页</button>
|
||||
</nav>
|
||||
</section>
|
||||
<section class="animation-existing" aria-labelledby="animation-existing-title">
|
||||
<div class="panel-heading"><h3 id="animation-existing-title">保存的动图</h3></div>
|
||||
<div id="animation-folders" class="animation-folder-list" aria-label="已有动图"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="workspace-templates" class="workspace" aria-labelledby="templates-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="templates-title">模板管理</h2>
|
||||
</div>
|
||||
<div class="workspace-heading-actions">
|
||||
<button id="select-default-content" class="secondary-button" type="button" aria-pressed="false">设置默认显示内容</button>
|
||||
<button id="refresh-templates" class="secondary-button" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="template-feedback" class="settings-feedback" aria-live="polite">正在读取模板…</p>
|
||||
<div id="template-grid" class="template-grid" aria-label="已保存模板"></div>
|
||||
</section>
|
||||
|
||||
<section id="workspace-settings" class="workspace" aria-labelledby="settings-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="设备"></p>
|
||||
<h2 id="settings-title">系统设置</h2>
|
||||
</div>
|
||||
</div>
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h3>屏幕亮度</h3>
|
||||
<p class="hint">拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)</p>
|
||||
</div>
|
||||
<output id="brightness-value" data-ui-copy-value for="config-brightness" class="brightness-output">40%</output>
|
||||
</div>
|
||||
<input id="config-brightness" class="brightness-slider" type="range" min="1" max="100" value="40">
|
||||
<p
|
||||
id="brightness-protection-status"
|
||||
class="brightness-protection-status"
|
||||
data-mode="inactive"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>屏幕方向</h3>
|
||||
<p class="hint">角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。</p>
|
||||
</div>
|
||||
<div class="orientation-grid" role="group" aria-label="屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)">
|
||||
<button type="button" data-orientation="0">0°</button>
|
||||
<button type="button" data-orientation="90">90°</button>
|
||||
<button type="button" data-orientation="180">180°</button>
|
||||
<button type="button" data-orientation="270">270°</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>屏幕扫描刷新率</h3>
|
||||
<p class="hint">设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。</p>
|
||||
</div>
|
||||
<div id="matrix-refresh-rate-grid" class="refresh-rate-grid" role="group" aria-label="屏幕扫描刷新率上限">
|
||||
<button type="button" data-matrix-refresh-rate="15">15 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="20">20 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="30">30 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="45">45 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="60">60 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="80">80 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="100">100 Hz</button>
|
||||
</div>
|
||||
<p class="hint" data-ui-copy-default-text="这里设置的是扫描上限,不是保证精确达到的实时测量值,也不同于下方网页预览刷新间隔。"></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card voltage-setting-card">
|
||||
<div>
|
||||
<h3>供电电压</h3>
|
||||
<p class="hint">设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。</p>
|
||||
</div>
|
||||
<dl class="voltage-details">
|
||||
<div><dt>当前电压</dt><dd id="voltage-current" data-ui-copy-value>-</dd></div>
|
||||
<div><dt>传感器状态</dt><dd id="voltage-sensor-status" data-ui-copy-value>正在读取</dd></div>
|
||||
<div><dt>校准状态</dt><dd id="voltage-calibration-status" data-ui-copy-value>未校准</dd></div>
|
||||
<div><dt>校准时间</dt><dd id="voltage-calibrated-at" data-ui-copy-value>-</dd></div>
|
||||
</dl>
|
||||
<div class="voltage-actions">
|
||||
<button id="start-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="正在读取电压传感器状态,请稍候。">校准电压传感器</button>
|
||||
<button id="reset-voltage-calibration" type="button">恢复标称值</button>
|
||||
</div>
|
||||
<div class="low-voltage-protection-setting">
|
||||
<div class="low-voltage-protection-copy">
|
||||
<h3>低电压亮度保护</h3>
|
||||
<p id="low-voltage-protection-description" class="hint">开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度</p>
|
||||
</div>
|
||||
<label class="switch-control" for="low-voltage-protection-enabled">
|
||||
<input
|
||||
id="low-voltage-protection-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="低电压亮度保护"
|
||||
aria-describedby="low-voltage-protection-description low-voltage-protection-feedback"
|
||||
aria-disabled="true"
|
||||
data-unavailable-reason="正在读取保护开关状态,请稍候。"
|
||||
>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span id="low-voltage-protection-switch-state" class="switch-state">关闭</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
id="low-voltage-protection-feedback"
|
||||
class="low-voltage-protection-feedback"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>预览刷新间隔</h3>
|
||||
<p class="hint">设置设备状态和系统设置中当前屏幕画面的刷新速度。</p>
|
||||
</div>
|
||||
<label class="number-unit-field" for="preview-refresh-interval">
|
||||
<input
|
||||
id="preview-refresh-interval"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60000"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
value="1000"
|
||||
>
|
||||
<span>毫秒</span>
|
||||
</label>
|
||||
<p id="preview-refresh-warning" class="preview-refresh-warning" role="status" hidden>
|
||||
刷新间隔过短,会增加核桃派负载,可能影响系统运行。
|
||||
</p>
|
||||
<p id="preview-refresh-error" class="error-text" role="alert" hidden></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card wifi-setting-card">
|
||||
<div>
|
||||
<h3>WiFi设置</h3>
|
||||
<p class="hint">设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip</p>
|
||||
</div>
|
||||
<div class="wifi-fields">
|
||||
<label>WiFi 名称(SSID)
|
||||
<input id="wifi-ssid" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-ssid-modified" required>
|
||||
<span id="wifi-ssid-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>WiFi 密码
|
||||
<input id="wifi-password" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-password-modified" placeholder="读取中…">
|
||||
<span id="wifi-password-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<div class="wifi-dhcp-row">
|
||||
<div>
|
||||
<strong>自动获取 IP(DHCP)</strong>
|
||||
<p id="wifi-dhcp-description" class="hint">关闭后可指定固定 IPv4 地址。</p>
|
||||
</div>
|
||||
<label class="switch-control" for="wifi-dhcp-enabled">
|
||||
<input id="wifi-dhcp-enabled" type="checkbox" role="switch" aria-describedby="wifi-dhcp-description wifi-dhcp-modified" checked>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span id="wifi-dhcp-state" class="switch-state">开启</span>
|
||||
</label>
|
||||
<p id="wifi-dhcp-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</p>
|
||||
</div>
|
||||
<div id="wifi-manual-fields" class="wifi-manual-fields" hidden>
|
||||
<label>静态 IPv4 地址
|
||||
<input id="wifi-address" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-address-modified" placeholder="192.168.1.50">
|
||||
<span id="wifi-address-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>网关
|
||||
<input id="wifi-gateway" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-gateway-modified" placeholder="192.168.1.1">
|
||||
<span id="wifi-gateway-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>子网前缀(选填)
|
||||
<input id="wifi-prefix" data-ui-copy-ignore type="number" min="1" max="32" step="1" inputmode="numeric" aria-describedby="wifi-prefix-modified" placeholder="24">
|
||||
<span id="wifi-prefix-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>DNS(选填)
|
||||
<input id="wifi-dns" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-dns-modified" placeholder="留空使用网关,多个用逗号分隔">
|
||||
<span id="wifi-dns-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
</div>
|
||||
<p id="wifi-active-status" class="hint" role="status">正在读取当前网络…</p>
|
||||
</div>
|
||||
<div class="wifi-actions">
|
||||
<button id="wifi-apply-immediate" type="button">立即生效(会导致网页断开)</button>
|
||||
<button id="wifi-apply-next-boot" type="button">下次开机生效</button>
|
||||
</div>
|
||||
<p id="wifi-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card wifi-prompt-setting-card">
|
||||
<div>
|
||||
<h3>网络提示显示</h3>
|
||||
<p class="hint">设置设备开机后等待多少秒再显示网络连接提示。</p>
|
||||
</div>
|
||||
<div class="wifi-prompt-delay-row">
|
||||
<label class="number-unit-field" for="wifi-prompt-delay">
|
||||
<input id="wifi-prompt-delay" type="number" min="1" max="3600" step="1" inputmode="numeric" value="30">
|
||||
<span>秒后显示网络提示</span>
|
||||
</label>
|
||||
<button id="wifi-prompt-delay-save" type="button">保存</button>
|
||||
</div>
|
||||
<p id="wifi-prompt-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card ota-setting-card">
|
||||
<div>
|
||||
<h3>软件版本</h3>
|
||||
</div>
|
||||
<dl class="ota-version-details">
|
||||
<div><dt>当前软件版本</dt><dd id="ota-current-version" data-ui-copy-value>读取中…</dd></div>
|
||||
<div><dt>最新功能更新时间</dt><dd id="ota-feature-updated-at" data-ui-copy-value>读取中…</dd></div>
|
||||
</dl>
|
||||
<input id="ota-file-input" class="sr-only" type="file" accept=".ota,application/octet-stream">
|
||||
<div class="ota-actions">
|
||||
<button id="ota-select-file" class="primary-button" type="button">导入 OTA 更新</button>
|
||||
<span class="ota-power-warning">不要在更新期间断电。</span>
|
||||
</div>
|
||||
<div id="ota-progress-group" class="ota-progress-group" hidden>
|
||||
<div class="ota-progress-heading">
|
||||
<strong id="ota-progress-stage" data-ui-copy-value>准备更新</strong>
|
||||
<span id="ota-progress-value" data-ui-copy-value>0%</span>
|
||||
</div>
|
||||
<progress id="ota-progress" max="100" value="0">0%</progress>
|
||||
</div>
|
||||
<p id="ota-feedback" class="settings-feedback" role="status" aria-live="polite" aria-atomic="true" hidden></p>
|
||||
<div id="ota-failure-log-actions" class="ota-failure-log-actions" hidden>
|
||||
<button id="ota-view-failure-log" type="button">查看失败日志</button>
|
||||
<button id="ota-copy-failure-log" type="button">复制日志</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div class="low-voltage-protection-setting">
|
||||
<div class="low-voltage-protection-copy">
|
||||
<h3>性能模式</h3>
|
||||
<p id="performance-mode-description" class="hint">如果使用出现卡顿,可以打开此选项,但会降低续航。</p>
|
||||
</div>
|
||||
<label class="switch-control" for="performance-mode-enabled">
|
||||
<input
|
||||
id="performance-mode-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="性能模式"
|
||||
aria-describedby="performance-mode-description performance-mode-feedback"
|
||||
aria-disabled="true"
|
||||
data-unavailable-reason="正在读取 CPU 调频状态,请稍候。"
|
||||
>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span id="performance-mode-switch-state" class="switch-state">关闭</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
id="performance-mode-feedback"
|
||||
class="low-voltage-protection-feedback"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>正在读取 CPU 调频状态…</p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>模板界面同时播放的动图数量</h3>
|
||||
<p id="animation-preview-concurrency-warning" class="hint">不建议开启太多,否则会出现严重的性能问题。</p>
|
||||
</div>
|
||||
<label class="number-unit-field" for="animation-preview-max-concurrent">
|
||||
<input
|
||||
id="animation-preview-max-concurrent"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
value="2"
|
||||
aria-describedby="animation-preview-concurrency-warning animation-preview-concurrency-feedback"
|
||||
>
|
||||
<span>个</span>
|
||||
</label>
|
||||
<p
|
||||
id="animation-preview-concurrency-feedback"
|
||||
class="settings-feedback"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card frp-setting-card">
|
||||
<div>
|
||||
<h3>frp配置</h3>
|
||||
<p class="hint">此功能通常是远程排查使用,请保证你知道你在做什么。</p>
|
||||
</div>
|
||||
<dl class="frp-status-details">
|
||||
<div><dt>当前配置</dt><dd id="frp-selected-name">无</dd></div>
|
||||
<div><dt>运行状态</dt><dd id="frp-service-state">关闭</dd></div>
|
||||
</dl>
|
||||
<p id="frp-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
|
||||
<div class="frp-actions">
|
||||
<button id="frp-service-toggle" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先导入或新建 FRP 配置。">启动frp</button>
|
||||
<button id="frp-edit-config" type="button">编辑配置文件</button>
|
||||
<button id="network-diagnostics-run" type="button">网络检测</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p id="settings-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="template-name-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<div class="template-dialog-heading">
|
||||
<h2 id="template-dialog-title">保存为模板</h2>
|
||||
<p id="template-dialog-note" hidden></p>
|
||||
</div>
|
||||
<label>模板名称
|
||||
<input id="template-name-input" type="text" maxlength="80" autocomplete="off" required>
|
||||
</label>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel" formnovalidate>取消</button>
|
||||
<button class="primary-button" type="submit" value="save">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="template-confirm-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>确认模板操作</h2>
|
||||
<p id="template-confirm-message"></p>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button class="primary-button" type="submit" value="confirm">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="template-save-choice-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>保存模板</h2>
|
||||
<p id="template-save-choice-message">请选择保存方式。</p>
|
||||
<div class="template-dialog-actions template-save-choice-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button id="template-save-new" type="submit" value="new">另存为新模板</button>
|
||||
<button id="template-save-current" class="primary-button" type="submit" value="current">保存到当前模板</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="library-copy-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>选择复制目标</h2>
|
||||
<label>复制到
|
||||
<select id="library-copy-target"><option value="static">静态模板</option></select>
|
||||
</label>
|
||||
<div class="template-dialog-actions"><button value="cancel">取消</button><button class="primary-button" value="copy">复制</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-dirty-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>当前动图帧尚未保存</h2>
|
||||
<p id="animation-dirty-message">是否应用当前修改到动图?</p>
|
||||
<div class="template-dialog-actions"><button value="cancel">取消</button><button value="discard">否,放弃修改</button><button class="primary-button" value="save">应用修改</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-operation-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2 id="animation-operation-title">确认动图操作</h2>
|
||||
<p id="animation-operation-message"></p>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button id="animation-operation-confirm" class="primary-button" type="submit" value="confirm">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-copy-destination-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>选择批量复制目标</h2>
|
||||
<label>复制到动图
|
||||
<select id="animation-copy-destination"></select>
|
||||
</label>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button class="primary-button" type="submit" value="next">下一步</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-position-dialog" class="template-dialog animation-position-dialog">
|
||||
<form method="dialog">
|
||||
<h2 id="animation-position-title">选择插入位置</h2>
|
||||
<p id="animation-position-hint">选择目标帧后,所选帧将插入到该帧之后;选择“插入到最前”则置于第一帧前。</p>
|
||||
<label>插入位置
|
||||
<select id="animation-position-target"></select>
|
||||
</label>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button class="primary-button" type="submit" value="confirm">确认位置</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="voltage-calibration-dialog" class="template-dialog voltage-calibration-dialog">
|
||||
<form method="dialog">
|
||||
<h2>校准电压传感器</h2>
|
||||
<p>保持整机正常约 5V。把可靠万用表并联到 ADC 相同的屏幕输入 5V/GND 测量点,再输入万用表读数。</p>
|
||||
<label>万用表参考电压(V)
|
||||
<input id="voltage-reference-input" type="number" min="4.500" max="5.500" step="0.001" inputmode="decimal" value="5.000" required>
|
||||
</label>
|
||||
<p id="voltage-calibration-dialog-feedback" class="hint" aria-live="polite">点击“采样并预览”,约需 1 秒。</p>
|
||||
<dl id="voltage-calibration-preview" class="voltage-details" hidden>
|
||||
<div><dt>参考电压</dt><dd id="calibration-reference-result">-</dd></div>
|
||||
<div><dt>未校准读数</dt><dd id="calibration-raw-result">-</dd></div>
|
||||
<div><dt>当前系数</dt><dd id="calibration-current-factor">-</dd></div>
|
||||
<div><dt>建议系数</dt><dd id="calibration-proposed-factor">-</dd></div>
|
||||
<div><dt>预计结果</dt><dd id="calibration-projected-result">-</dd></div>
|
||||
</dl>
|
||||
<div class="voltage-calibration-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button id="preview-voltage-calibration" type="button">采样并预览</button>
|
||||
<button id="confirm-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先完成“采样并预览”,再保存校准。">保存校准</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="current-display-dialog" class="template-dialog current-display-dialog" aria-labelledby="current-display-dialog-title">
|
||||
<div class="current-display-dialog-body">
|
||||
<div class="current-display-dialog-heading">
|
||||
<div>
|
||||
<h2 id="current-display-dialog-title">当前画面预览</h2>
|
||||
<p id="current-display-dialog-name" class="subtle" data-ui-copy-value>正在读取…</p>
|
||||
</div>
|
||||
<button id="current-display-dialog-close" type="button" aria-label="关闭当前画面预览">关闭</button>
|
||||
</div>
|
||||
<button id="current-display-preview-surface" class="current-display-preview-surface" type="button" aria-label="当前实屏逻辑帧预览">
|
||||
<img id="current-display-preview-image" alt="当前实屏逻辑帧" hidden>
|
||||
<span id="current-display-preview-placeholder">正在读取当前画面…</span>
|
||||
</button>
|
||||
<section id="current-display-playback-controls" class="current-display-playback-controls" aria-label="动图播放控制" hidden>
|
||||
<span id="current-display-seek-unavailable-reason" class="sr-only">请先完成当前进度跳转。</span>
|
||||
<div class="current-display-progress-heading">
|
||||
<label for="current-display-progress">播放位置</label>
|
||||
<output id="current-display-progress-time" for="current-display-progress" data-ui-copy-value>0:00.00 / 0:00.00</output>
|
||||
</div>
|
||||
<input id="current-display-progress" type="range" min="0" max="0" step="1" value="0" aria-label="动图播放位置">
|
||||
<div class="current-display-playback-actions">
|
||||
<button id="current-display-play-pause" class="primary-button" type="button">暂停</button>
|
||||
<div id="current-display-speed-controls" class="current-display-speed-controls" role="group" aria-label="动图播放倍速">
|
||||
<button type="button" data-playback-speed="0.5">0.5×</button>
|
||||
<button type="button" data-playback-speed="1">1×</button>
|
||||
<button type="button" data-playback-speed="1.5">1.5×</button>
|
||||
<button type="button" data-playback-speed="2">2×</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-display-playback-feedback" class="hint" aria-live="polite" hidden></p>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="resource-details-dialog" class="template-dialog resource-details-dialog" aria-labelledby="resource-details-title">
|
||||
<form method="dialog">
|
||||
<h2 id="resource-details-title">资源占用详情</h2>
|
||||
<p id="resource-details-message" class="resource-details-message" role="status"></p>
|
||||
<section class="resource-details-section" aria-labelledby="resource-cpu-title">
|
||||
<h3 id="resource-cpu-title">CPU</h3>
|
||||
<dl class="resource-details-grid">
|
||||
<div><dt>应用占用核心</dt><dd id="resource-cpu-app-cores">-</dd></div>
|
||||
<div><dt>折合单核占用</dt><dd id="resource-cpu-single-percent">-</dd></div>
|
||||
<div><dt>占整机 CPU</dt><dd id="resource-cpu-app-percent">-</dd></div>
|
||||
<div><dt>整机 CPU 总占用</dt><dd id="resource-cpu-total-percent">-</dd></div>
|
||||
</dl>
|
||||
<div id="resource-core-list" class="resource-core-list" aria-label="每核心 CPU 占用"></div>
|
||||
</section>
|
||||
<section class="resource-details-section" aria-labelledby="resource-memory-title">
|
||||
<h3 id="resource-memory-title">内存</h3>
|
||||
<dl class="resource-details-grid">
|
||||
<div><dt>应用常驻内存</dt><dd id="resource-memory-app">-</dd></div>
|
||||
<div><dt>应用占整机内存</dt><dd id="resource-memory-app-percent">-</dd></div>
|
||||
<div><dt>整机内存总占用</dt><dd id="resource-memory-total-percent">-</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<div class="template-dialog-actions resource-dialog-actions">
|
||||
<button id="resource-details-close" class="primary-button" type="submit" value="close">关闭</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="ota-failure-log-dialog" class="template-dialog ota-failure-log-dialog" aria-labelledby="ota-failure-log-title">
|
||||
<div class="ota-failure-log-dialog-body">
|
||||
<div class="template-dialog-heading">
|
||||
<div>
|
||||
<h2 id="ota-failure-log-title">OTA 更新失败日志</h2>
|
||||
<p>可将完整内容复制后提供给维护人员排查。</p>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="ota-failure-log-content" tabindex="0" aria-label="OTA 更新失败诊断日志"></pre>
|
||||
<p id="ota-failure-log-feedback" class="settings-feedback" role="status" aria-live="polite" hidden></p>
|
||||
<div class="template-dialog-actions">
|
||||
<button id="ota-failure-log-close" type="button">关闭</button>
|
||||
<button id="ota-failure-log-copy" class="primary-button" type="button">复制全部日志</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="frp-config-dialog" class="template-dialog frp-config-dialog" aria-labelledby="frp-config-dialog-title">
|
||||
<div class="frp-config-dialog-body">
|
||||
<div class="template-dialog-heading">
|
||||
<div>
|
||||
<h2 id="frp-config-dialog-title">编辑 FRP 配置文件</h2>
|
||||
<p>配置可能包含服务器地址和令牌,请只在可信网络中操作。</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>已保存配置
|
||||
<select id="frp-config-select"><option value="">暂无配置</option></select>
|
||||
</label>
|
||||
<label>配置名称
|
||||
<input id="frp-config-name" type="text" maxlength="80" autocomplete="off" placeholder="远程维护">
|
||||
</label>
|
||||
<label>文件格式
|
||||
<select id="frp-config-format">
|
||||
<option value=".toml">TOML</option>
|
||||
<option value=".yaml">YAML</option>
|
||||
<option value=".json">JSON</option>
|
||||
<option value=".ini">INI(兼容格式)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>配置原文
|
||||
<textarea id="frp-config-content" data-ui-copy-ignore spellcheck="false" aria-describedby="frp-config-feedback"></textarea>
|
||||
</label>
|
||||
<input id="frp-config-file" class="sr-only" type="file" accept=".toml,.yaml,.yml,.json,.ini,text/plain,application/json">
|
||||
<p id="frp-config-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
|
||||
<div class="frp-config-secondary-actions">
|
||||
<button id="frp-config-new" type="button">新建空白配置</button>
|
||||
<button id="frp-config-upload" type="button">上传配置文件</button>
|
||||
<button id="frp-config-delete" class="danger-button" type="button">删除</button>
|
||||
</div>
|
||||
<div class="template-dialog-actions">
|
||||
<button id="frp-config-close" type="button">关闭</button>
|
||||
<button id="frp-config-save" class="primary-button" type="button">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="network-diagnostics-dialog" class="template-dialog network-diagnostics-dialog" aria-labelledby="network-diagnostics-title">
|
||||
<div class="network-diagnostics-dialog-body">
|
||||
<h2 id="network-diagnostics-title">网络检测结果</h2>
|
||||
<p id="network-diagnostics-summary" role="status">正在检测…</p>
|
||||
<ul id="network-diagnostics-checks"></ul>
|
||||
<div class="template-dialog-actions">
|
||||
<button id="network-diagnostics-close" class="primary-button" type="button">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="control-unavailable-tooltip" class="control-unavailable-tooltip" role="tooltip" hidden></div>
|
||||
<div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div>
|
||||
<noscript>此控制台需要启用 JavaScript。</noscript>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,182 @@
|
||||
import { announce } from "./core.js";
|
||||
|
||||
const tooltip = document.getElementById("control-unavailable-tooltip");
|
||||
const toast = document.getElementById("control-feedback-toast");
|
||||
const controlRecords = new WeakMap();
|
||||
let descriptionSequence = 0;
|
||||
let toastTimer = null;
|
||||
|
||||
function recordFor(control) {
|
||||
if (!controlRecords.has(control)) {
|
||||
controlRecords.set(control, {
|
||||
label: "textContent" in control ? control.textContent : "",
|
||||
description: null,
|
||||
describedBy: control.getAttribute("aria-describedby") || "",
|
||||
});
|
||||
}
|
||||
return controlRecords.get(control);
|
||||
}
|
||||
|
||||
function removeDescription(control) {
|
||||
const record = recordFor(control);
|
||||
record.description?.remove();
|
||||
record.description = null;
|
||||
if (record.describedBy) control.setAttribute("aria-describedby", record.describedBy);
|
||||
else control.removeAttribute("aria-describedby");
|
||||
}
|
||||
|
||||
function setDescription(control, reason) {
|
||||
const record = recordFor(control);
|
||||
if (!record.description) {
|
||||
const description = document.createElement("span");
|
||||
description.id = `control-unavailable-reason-${++descriptionSequence}`;
|
||||
description.className = "sr-only";
|
||||
control.insertAdjacentElement("afterend", description);
|
||||
record.description = description;
|
||||
}
|
||||
record.description.textContent = reason;
|
||||
const ids = [record.describedBy, record.description.id].filter(Boolean).join(" ");
|
||||
control.setAttribute("aria-describedby", ids);
|
||||
}
|
||||
|
||||
function restoreLabel(control) {
|
||||
const record = recordFor(control);
|
||||
if ("textContent" in control && control.dataset.busyLabelApplied === "true") {
|
||||
control.textContent = record.label;
|
||||
}
|
||||
delete control.dataset.busyLabelApplied;
|
||||
}
|
||||
|
||||
function clearBusy(control) {
|
||||
restoreLabel(control);
|
||||
if ("disabled" in control) control.disabled = false;
|
||||
control.removeAttribute("aria-busy");
|
||||
delete control.dataset.controlState;
|
||||
}
|
||||
|
||||
export function setControlReady(control) {
|
||||
clearBusy(control);
|
||||
removeDescription(control);
|
||||
control.removeAttribute("aria-disabled");
|
||||
delete control.dataset.unavailableReason;
|
||||
}
|
||||
|
||||
export function setControlUnavailable(control, reason) {
|
||||
if (!reason || !String(reason).trim()) throw new Error("不可用控件必须提供原因");
|
||||
clearBusy(control);
|
||||
control.setAttribute("aria-disabled", "true");
|
||||
control.dataset.unavailableReason = String(reason).trim();
|
||||
setDescription(control, control.dataset.unavailableReason);
|
||||
}
|
||||
|
||||
export function setControlBusy(control, { label = "", message = "" } = {}) {
|
||||
const record = recordFor(control);
|
||||
const wasBusy = control.dataset.controlState === "busy";
|
||||
removeDescription(control);
|
||||
control.removeAttribute("aria-disabled");
|
||||
delete control.dataset.unavailableReason;
|
||||
control.dataset.controlState = "busy";
|
||||
control.setAttribute("aria-busy", "true");
|
||||
if (label && "textContent" in control) {
|
||||
if (control.dataset.busyLabelApplied !== "true") record.label = control.textContent;
|
||||
control.textContent = label;
|
||||
control.dataset.busyLabelApplied = "true";
|
||||
}
|
||||
if ("disabled" in control) control.disabled = true;
|
||||
if (message && !wasBusy) announce(message);
|
||||
}
|
||||
|
||||
export async function runControlAction(control, options, action) {
|
||||
setControlBusy(control, options);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableControl(target) {
|
||||
return target instanceof Element
|
||||
? target.closest('[aria-disabled="true"][data-unavailable-reason]')
|
||||
: null;
|
||||
}
|
||||
|
||||
function positionTooltip(control, text = control.dataset.unavailableReason) {
|
||||
if (!tooltip || !text) return;
|
||||
tooltip.textContent = text;
|
||||
tooltip.hidden = false;
|
||||
const controlRect = control.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const left = Math.min(
|
||||
window.innerWidth - tooltipRect.width - margin,
|
||||
Math.max(margin, controlRect.left + (controlRect.width - tooltipRect.width) / 2),
|
||||
);
|
||||
let top = controlRect.top - tooltipRect.height - margin;
|
||||
if (top < margin) top = controlRect.bottom + margin;
|
||||
tooltip.style.left = `${Math.round(left)}px`;
|
||||
tooltip.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
function overflowTooltipTarget(target) {
|
||||
return target instanceof Element ? target.closest("[data-hover-tooltip]") : null;
|
||||
}
|
||||
|
||||
function canShowOverflowTooltip(target, event) {
|
||||
return event.pointerType === "mouse"
|
||||
&& window.matchMedia("(hover: hover) and (pointer: fine)").matches
|
||||
&& target.scrollWidth > target.clientWidth;
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (tooltip) tooltip.hidden = true;
|
||||
}
|
||||
|
||||
function showToast(reason) {
|
||||
if (!toast) return;
|
||||
window.clearTimeout(toastTimer);
|
||||
toast.textContent = reason;
|
||||
toast.hidden = false;
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.hidden = true;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerover", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control) {
|
||||
positionTooltip(control);
|
||||
return;
|
||||
}
|
||||
const overflowTarget = overflowTooltipTarget(event.target);
|
||||
if (overflowTarget && canShowOverflowTooltip(overflowTarget, event)) {
|
||||
positionTooltip(overflowTarget, overflowTarget.dataset.hoverTooltip);
|
||||
}
|
||||
});
|
||||
document.addEventListener("pointerout", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control && !control.contains(event.relatedTarget)) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
const overflowTarget = overflowTooltipTarget(event.target);
|
||||
if (overflowTarget && !overflowTarget.contains(event.relatedTarget)) hideTooltip();
|
||||
});
|
||||
document.addEventListener("focusin", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control) positionTooltip(control);
|
||||
});
|
||||
document.addEventListener("focusout", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control) hideTooltip();
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (!control) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const reason = control.dataset.unavailableReason;
|
||||
hideTooltip();
|
||||
showToast(reason);
|
||||
announce(reason);
|
||||
}, true);
|
||||
@@ -0,0 +1,56 @@
|
||||
const OUTPUT_PIXELS = 64;
|
||||
|
||||
function positiveNumber(value, field) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) {
|
||||
throw new TypeError(field + " must be a positive number");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
export function cropGeometry({
|
||||
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom, centerX, centerY,
|
||||
}) {
|
||||
const viewWidth = positiveNumber(viewportWidth, "viewportWidth");
|
||||
const viewHeight = positiveNumber(viewportHeight, "viewportHeight");
|
||||
const imageWidth = positiveNumber(sourceWidth, "sourceWidth");
|
||||
const imageHeight = positiveNumber(sourceHeight, "sourceHeight");
|
||||
const scaleZoom = positiveNumber(zoom, "zoom");
|
||||
const x = Number(centerX), y = Number(centerY);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
throw new TypeError("crop center must be finite");
|
||||
}
|
||||
const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight) * scaleZoom;
|
||||
const width = imageWidth * scale;
|
||||
const height = imageHeight * scale;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: viewWidth / 2 - x * width,
|
||||
top: viewHeight / 2 - y * height,
|
||||
};
|
||||
}
|
||||
|
||||
export function cropCenterBounds({
|
||||
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
|
||||
}) {
|
||||
const geometry = cropGeometry({
|
||||
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
|
||||
centerX: 0.5, centerY: 0.5,
|
||||
});
|
||||
const visibleX = Math.min(Number(viewportWidth) / OUTPUT_PIXELS, geometry.width);
|
||||
const visibleY = Math.min(Number(viewportHeight) / OUTPUT_PIXELS, geometry.height);
|
||||
return {
|
||||
minX: (visibleX - Number(viewportWidth) / 2) / geometry.width,
|
||||
maxX: 1 + (Number(viewportWidth) / 2 - visibleX) / geometry.width,
|
||||
minY: (visibleY - Number(viewportHeight) / 2) / geometry.height,
|
||||
maxY: 1 + (Number(viewportHeight) / 2 - visibleY) / geometry.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampCropCenter(value, bounds) {
|
||||
return {
|
||||
centerX: Math.max(bounds.minX, Math.min(bounds.maxX, Number(value.centerX))),
|
||||
centerY: Math.max(bounds.minY, Math.min(bounds.maxY, Number(value.centerY))),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function shouldReuseMediaJobCard(rendered, incoming) {
|
||||
if (!rendered || !incoming || rendered.id !== incoming.id) return false;
|
||||
if (rendered.state !== "awaiting_settings" || incoming.state !== "awaiting_settings") return false;
|
||||
return rendered.dirty || rendered.updatedAt === incoming.updated_at;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,577 @@
|
||||
export const SCENE_VERSION = 1;
|
||||
export const SCENE_WIDTH = 64;
|
||||
export const SCENE_HEIGHT = 64;
|
||||
export const RGB_CHANNELS = 3;
|
||||
export const RGBA_CHANNELS = 4;
|
||||
export const RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGB_CHANNELS;
|
||||
export const RGBA_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGBA_CHANNELS;
|
||||
|
||||
export const SCENE_STORAGE_KEY = "matrixController:scene";
|
||||
export const LEGACY_STORAGE_KEYS = Object.freeze({
|
||||
sceneV1: "matrixController:scene:v1",
|
||||
canvasV3: "matrixController:canvas:v3",
|
||||
canvasV2: "matrixController:canvas:v2",
|
||||
canvasRgb: "matrixCanvasRgb",
|
||||
textV1: "matrixController:text:v1",
|
||||
});
|
||||
|
||||
export const DEFAULT_TEXT_ELEMENT = Object.freeze({
|
||||
type: "text",
|
||||
text: "ok",
|
||||
font: "default",
|
||||
size: 12,
|
||||
x: 32,
|
||||
y: 26,
|
||||
align: "center",
|
||||
color: "#FFFFFF",
|
||||
revision: 0,
|
||||
});
|
||||
|
||||
const ALIGNMENTS = new Set(["left", "center", "right"]);
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function finiteInteger(value, fallback, minimum, maximum) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(minimum, Math.min(maximum, Math.round(number)));
|
||||
}
|
||||
|
||||
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) {
|
||||
return typeof value === "string" && HEX_COLOR.test(value.trim())
|
||||
? value.trim().toUpperCase()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const id = value.trim();
|
||||
return id && id.length <= 128 ? id : null;
|
||||
}
|
||||
|
||||
function byteArray(value, expectedLength) {
|
||||
if (value === null || value === undefined || typeof value.length !== "number") return null;
|
||||
if (value.length !== expectedLength) return null;
|
||||
const result = new Uint8ClampedArray(expectedLength);
|
||||
for (let index = 0; index < expectedLength; index += 1) {
|
||||
const channel = value[index];
|
||||
if (!Number.isInteger(channel) || channel < 0 || channel > 255) return null;
|
||||
result[index] = channel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function encodeBase64(bytes) {
|
||||
let encoded = "";
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const first = bytes[index];
|
||||
const hasSecond = index + 1 < bytes.length;
|
||||
const hasThird = index + 2 < bytes.length;
|
||||
const second = hasSecond ? bytes[index + 1] : 0;
|
||||
const third = hasThird ? bytes[index + 2] : 0;
|
||||
encoded += BASE64_ALPHABET[first >> 2];
|
||||
encoded += BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)];
|
||||
encoded += hasSecond ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)] : "=";
|
||||
encoded += hasThird ? BASE64_ALPHABET[third & 63] : "=";
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
if (typeof value !== "string" || value.length % 4 !== 0) return null;
|
||||
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
|
||||
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
||||
const result = new Uint8ClampedArray((value.length / 4) * 3 - padding);
|
||||
let output = 0;
|
||||
for (let index = 0; index < value.length; index += 4) {
|
||||
const first = BASE64_ALPHABET.indexOf(value[index]);
|
||||
const second = BASE64_ALPHABET.indexOf(value[index + 1]);
|
||||
const third = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2]);
|
||||
const fourth = value[index + 3] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 3]);
|
||||
const packed = (first << 18) | (second << 12) | (third << 6) | fourth;
|
||||
if (output < result.length) result[output++] = (packed >> 16) & 255;
|
||||
if (output < result.length) result[output++] = (packed >> 8) & 255;
|
||||
if (output < result.length) result[output++] = packed & 255;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJsonValue(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceValue(source, alias, storageKey) {
|
||||
if (!isRecord(source)) return null;
|
||||
if (Object.prototype.hasOwnProperty.call(source, alias)) return source[alias];
|
||||
if (Object.prototype.hasOwnProperty.call(source, storageKey)) return source[storageKey];
|
||||
return null;
|
||||
}
|
||||
|
||||
function copyTextElement(element) {
|
||||
return {
|
||||
id: element.id,
|
||||
type: "text",
|
||||
text: element.text,
|
||||
font: element.font,
|
||||
size: element.size,
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
align: element.align,
|
||||
color: element.color,
|
||||
revision: element.revision,
|
||||
};
|
||||
}
|
||||
|
||||
function storageTextElement(element) {
|
||||
const { revision: _revision, ...stored } = copyTextElement(element);
|
||||
return stored;
|
||||
}
|
||||
|
||||
function normalizedElementList(elements) {
|
||||
if (!Array.isArray(elements)) return [];
|
||||
const normalized = [];
|
||||
const usedIds = new Set();
|
||||
for (const value of elements) {
|
||||
const element = normalizeTextElement(value, { strict: true });
|
||||
if (!element || usedIds.has(element.id)) continue;
|
||||
usedIds.add(element.id);
|
||||
normalized.push(element);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertScene(scene) {
|
||||
if (!isRecord(scene)) throw new TypeError("scene must be an object");
|
||||
if (scene.version !== SCENE_VERSION || scene.width !== SCENE_WIDTH || scene.height !== SCENE_HEIGHT) {
|
||||
throw new RangeError("scene must be version 1 with a 64x64 frame");
|
||||
}
|
||||
const pixels = byteArray(scene.pixels, RGB_BYTE_LENGTH);
|
||||
if (!pixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
|
||||
if (!Array.isArray(scene.elements)) throw new TypeError("scene elements must be an array");
|
||||
const elements = normalizedElementList(scene.elements);
|
||||
if (elements.length !== scene.elements.length) throw new TypeError("scene contains an invalid or duplicate element");
|
||||
return { pixels, elements };
|
||||
}
|
||||
|
||||
function sceneWithElements(scene, elements) {
|
||||
const checked = assertScene(scene);
|
||||
return {
|
||||
version: SCENE_VERSION,
|
||||
width: SCENE_WIDTH,
|
||||
height: SCENE_HEIGHT,
|
||||
pixels: checked.pixels,
|
||||
elements: elements.map(copyTextElement),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRgbPixels(color = [0, 0, 0]) {
|
||||
const channels = byteArray(color, RGB_CHANNELS);
|
||||
if (!channels) throw new RangeError("color must contain three RGB bytes");
|
||||
const pixels = new Uint8ClampedArray(RGB_BYTE_LENGTH);
|
||||
for (let offset = 0; offset < pixels.length; offset += RGB_CHANNELS) pixels.set(channels, offset);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
export function createScene({ pixels = createRgbPixels(), elements = [] } = {}) {
|
||||
const checkedPixels = byteArray(pixels, RGB_BYTE_LENGTH);
|
||||
if (!checkedPixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
|
||||
return {
|
||||
version: SCENE_VERSION,
|
||||
width: SCENE_WIDTH,
|
||||
height: SCENE_HEIGHT,
|
||||
pixels: checkedPixels,
|
||||
elements: normalizedElementList(elements),
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneToStorageValue(scene) {
|
||||
const checked = assertScene(scene);
|
||||
return {
|
||||
version: SCENE_VERSION,
|
||||
width: SCENE_WIDTH,
|
||||
height: SCENE_HEIGHT,
|
||||
pixelRgb: encodeBase64(checked.pixels),
|
||||
elements: checked.elements.map(storageTextElement),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeScene(scene) {
|
||||
return JSON.stringify(sceneToStorageValue(scene));
|
||||
}
|
||||
|
||||
export function deserializeScene(value) {
|
||||
const stored = parseJsonValue(value);
|
||||
if (!isRecord(stored)) return null;
|
||||
if (stored.version !== SCENE_VERSION || stored.width !== SCENE_WIDTH || stored.height !== SCENE_HEIGHT) return null;
|
||||
const decoded = decodeBase64(stored.pixelRgb);
|
||||
if (!decoded || decoded.length !== RGB_BYTE_LENGTH) return null;
|
||||
return createScene({
|
||||
pixels: decoded,
|
||||
elements: normalizedElementList(stored.elements),
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectSceneStorageValue(value) {
|
||||
if (value === null || value === undefined) return { status: "missing", scene: null };
|
||||
let stored = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
stored = JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return { status: "damaged", scene: null };
|
||||
}
|
||||
}
|
||||
if (!isRecord(stored)) return { status: "damaged", scene: null };
|
||||
if (Number.isInteger(stored.version) && stored.version > SCENE_VERSION) {
|
||||
return { status: "future", scene: null };
|
||||
}
|
||||
const restored = deserializeScene(stored);
|
||||
return restored
|
||||
? { status: "valid", scene: restored }
|
||||
: { status: "damaged", scene: null };
|
||||
}
|
||||
|
||||
export function nextTextElementId(elements = []) {
|
||||
const used = new Set(
|
||||
Array.isArray(elements)
|
||||
? elements.map((element) => normalizeId(element?.id)).filter(Boolean)
|
||||
: [],
|
||||
);
|
||||
let sequence = 1;
|
||||
while (used.has(`text-${sequence}`)) sequence += 1;
|
||||
return `text-${sequence}`;
|
||||
}
|
||||
|
||||
export function normalizeTextElement(value = {}, { id = null, strict = false } = {}) {
|
||||
if (!isRecord(value) || (value.type !== undefined && value.type !== "text")) return null;
|
||||
if (strict && (typeof value.text !== "string" || !normalizeId(value.id))) return null;
|
||||
const normalizedId = normalizeId(value.id) || normalizeId(id) || "text-1";
|
||||
const text = typeof value.text === "string" ? value.text.slice(0, 512) : DEFAULT_TEXT_ELEMENT.text;
|
||||
const font = typeof value.font === "string" && value.font.trim()
|
||||
? value.font.trim().slice(0, 512)
|
||||
: DEFAULT_TEXT_ELEMENT.font;
|
||||
return {
|
||||
id: normalizedId,
|
||||
type: "text",
|
||||
text,
|
||||
font,
|
||||
size: finiteInteger(value.size, DEFAULT_TEXT_ELEMENT.size, 1, 64),
|
||||
x: finiteInteger(value.x, DEFAULT_TEXT_ELEMENT.x, -128, 128),
|
||||
y: finiteInteger(value.y, DEFAULT_TEXT_ELEMENT.y, -128, 128),
|
||||
align: ALIGNMENTS.has(value.align) ? value.align : DEFAULT_TEXT_ELEMENT.align,
|
||||
color: normalizeColor(value.color),
|
||||
revision: finiteInteger(value.revision, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTextElement(existingElements = [], overrides = {}) {
|
||||
const id = nextTextElementId(existingElements);
|
||||
return normalizeTextElement({ ...DEFAULT_TEXT_ELEMENT, ...overrides, id }, { id });
|
||||
}
|
||||
|
||||
export function addTextElement(scene, overrides = {}) {
|
||||
const checked = assertScene(scene);
|
||||
const element = createTextElement(checked.elements, overrides);
|
||||
return sceneWithElements(scene, [...checked.elements, element]);
|
||||
}
|
||||
|
||||
export function updateTextElement(scene, id, patch = {}) {
|
||||
const checked = assertScene(scene);
|
||||
const targetId = normalizeId(id);
|
||||
const index = checked.elements.findIndex((element) => element.id === targetId);
|
||||
if (index < 0 || !isRecord(patch)) return scene;
|
||||
const current = checked.elements[index];
|
||||
const updated = normalizeTextElement({
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
type: "text",
|
||||
revision: current.revision + 1,
|
||||
}, { id: current.id });
|
||||
const elements = checked.elements.map((element, elementIndex) => (
|
||||
elementIndex === index ? updated : copyTextElement(element)
|
||||
));
|
||||
return sceneWithElements(scene, elements);
|
||||
}
|
||||
|
||||
export function deleteTextElement(scene, id) {
|
||||
const checked = assertScene(scene);
|
||||
const targetId = normalizeId(id);
|
||||
if (!checked.elements.some((element) => element.id === targetId)) return scene;
|
||||
return sceneWithElements(scene, checked.elements.filter((element) => element.id !== targetId));
|
||||
}
|
||||
|
||||
export function duplicateTextElement(scene, id) {
|
||||
const checked = assertScene(scene);
|
||||
const source = checked.elements.find((element) => element.id === normalizeId(id));
|
||||
if (!source) return scene;
|
||||
const duplicate = createTextElement(checked.elements, {
|
||||
...source,
|
||||
x: source.x + 2,
|
||||
y: source.y + 2,
|
||||
revision: 0,
|
||||
});
|
||||
return sceneWithElements(scene, [...checked.elements, duplicate]);
|
||||
}
|
||||
|
||||
function legacyCanvasPixels(value) {
|
||||
const parsed = parseJsonValue(value);
|
||||
if (!isRecord(parsed)) return null;
|
||||
return byteArray(parsed.pixels, RGB_BYTE_LENGTH);
|
||||
}
|
||||
|
||||
function legacyCsvPixels(value) {
|
||||
if (typeof value !== "string") return byteArray(value, RGB_BYTE_LENGTH);
|
||||
const channels = value.split(",").map((channel) => Number(channel));
|
||||
return byteArray(channels, RGB_BYTE_LENGTH);
|
||||
}
|
||||
|
||||
function migrateTextV1(value, options) {
|
||||
const parsed = parseJsonValue(value);
|
||||
if (!isRecord(parsed)) return null;
|
||||
const text = parsed["text-value"] ?? parsed.text;
|
||||
if (typeof text !== "string") return null;
|
||||
return normalizeTextElement({
|
||||
id: "text-1",
|
||||
type: "text",
|
||||
text,
|
||||
font: parsed["text-font"] ?? parsed.font ?? options.defaultFont,
|
||||
size: parsed["text-size"] ?? parsed.size ?? options.defaultTextSize,
|
||||
x: parsed["text-x"] ?? parsed.x,
|
||||
y: parsed["text-y"] ?? parsed.y,
|
||||
align: parsed["text-align"] ?? parsed.align,
|
||||
color: parsed["text-color"] ?? parsed.color,
|
||||
}, { id: "text-1" });
|
||||
}
|
||||
|
||||
export function migrateLegacyScene(source = {}, options = {}) {
|
||||
const migrationOptions = {
|
||||
defaultFont: typeof options.defaultFont === "string" && options.defaultFont.trim()
|
||||
? options.defaultFont.trim()
|
||||
: DEFAULT_TEXT_ELEMENT.font,
|
||||
defaultTextSize: finiteInteger(options.defaultTextSize, DEFAULT_TEXT_ELEMENT.size, 1, 64),
|
||||
};
|
||||
const canvasCandidates = [
|
||||
["canvasV3", LEGACY_STORAGE_KEYS.canvasV3],
|
||||
["canvasV2", LEGACY_STORAGE_KEYS.canvasV2],
|
||||
];
|
||||
let pixels = null;
|
||||
for (const [alias, storageKey] of canvasCandidates) {
|
||||
pixels = legacyCanvasPixels(sourceValue(source, alias, storageKey));
|
||||
if (pixels) break;
|
||||
}
|
||||
if (!pixels) {
|
||||
pixels = legacyCsvPixels(sourceValue(source, "matrixCanvasRgb", LEGACY_STORAGE_KEYS.canvasRgb));
|
||||
}
|
||||
const text = migrateTextV1(
|
||||
sourceValue(source, "textV1", LEGACY_STORAGE_KEYS.textV1),
|
||||
migrationOptions,
|
||||
);
|
||||
if (!pixels && !text) return null;
|
||||
return createScene({
|
||||
pixels: pixels || createRgbPixels(),
|
||||
elements: text ? [text] : [],
|
||||
});
|
||||
}
|
||||
|
||||
function draftLoadResult(scene, {
|
||||
canPersist,
|
||||
status,
|
||||
notice = null,
|
||||
} = {}) {
|
||||
return { scene, canPersist, status, notice };
|
||||
}
|
||||
|
||||
function blockedDraft(scene, status, notice) {
|
||||
return draftLoadResult(scene, { canPersist: false, status, notice });
|
||||
}
|
||||
|
||||
function readStorageSnapshot(storage) {
|
||||
if (!storage || typeof storage.getItem !== "function") {
|
||||
throw new TypeError("browser storage is unavailable");
|
||||
}
|
||||
const values = {};
|
||||
values.current = storage.getItem(SCENE_STORAGE_KEY);
|
||||
for (const [alias, key] of Object.entries(LEGACY_STORAGE_KEYS)) {
|
||||
values[alias] = storage.getItem(key);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function invalidSplitLegacyKeys(values) {
|
||||
const invalid = [];
|
||||
if (values.canvasV3 !== null && !legacyCanvasPixels(values.canvasV3)) invalid.push(LEGACY_STORAGE_KEYS.canvasV3);
|
||||
if (values.canvasV2 !== null && !legacyCanvasPixels(values.canvasV2)) invalid.push(LEGACY_STORAGE_KEYS.canvasV2);
|
||||
if (values.canvasRgb !== null && !legacyCsvPixels(values.canvasRgb)) invalid.push(LEGACY_STORAGE_KEYS.canvasRgb);
|
||||
if (values.textV1 !== null && !migrateTextV1(values.textV1, {
|
||||
defaultFont: DEFAULT_TEXT_ELEMENT.font,
|
||||
defaultTextSize: DEFAULT_TEXT_ELEMENT.size,
|
||||
})) invalid.push(LEGACY_STORAGE_KEYS.textV1);
|
||||
return invalid;
|
||||
}
|
||||
|
||||
function writeMigratedDraft(storage, scene, hasLegacyData) {
|
||||
try {
|
||||
if (typeof storage.setItem !== "function") throw new TypeError("browser storage is read-only");
|
||||
storage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
|
||||
} catch (_error) {
|
||||
return blockedDraft(
|
||||
scene,
|
||||
"write-failed",
|
||||
hasLegacyData
|
||||
? "浏览器无法写入升级后的画板草稿;旧草稿已原样保留,本页不会覆盖它。"
|
||||
: "浏览器无法初始化画板草稿;本页编辑不会自动保存或应用。",
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasLegacyData) {
|
||||
return draftLoadResult(scene, { canPersist: true, status: "created" });
|
||||
}
|
||||
|
||||
const cleanupFailures = [];
|
||||
for (const key of Object.values(LEGACY_STORAGE_KEYS)) {
|
||||
try {
|
||||
if (typeof storage.removeItem !== "function") throw new TypeError("browser storage cannot remove old keys");
|
||||
storage.removeItem(key);
|
||||
} catch (_error) {
|
||||
cleanupFailures.push(key);
|
||||
}
|
||||
}
|
||||
if (cleanupFailures.length) {
|
||||
return draftLoadResult(scene, {
|
||||
canPersist: true,
|
||||
status: "cleanup-warning",
|
||||
notice: "画板草稿已升级,但部分旧版草稿键无法清理;旧值仍被保留。",
|
||||
});
|
||||
}
|
||||
return draftLoadResult(scene, {
|
||||
canPersist: true,
|
||||
status: "migrated",
|
||||
notice: "已将旧版浏览器画板草稿升级到当前格式。",
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSceneDraft(storage) {
|
||||
const blankScene = createScene();
|
||||
let values;
|
||||
try {
|
||||
values = readStorageSnapshot(storage);
|
||||
} catch (_error) {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"read-failed",
|
||||
"浏览器草稿存储不可用;本页不会自动保存或应用空白画板。",
|
||||
);
|
||||
}
|
||||
|
||||
const current = inspectSceneStorageValue(values.current);
|
||||
if (current.status === "valid") {
|
||||
return draftLoadResult(current.scene, { canPersist: true, status: "current" });
|
||||
}
|
||||
if (current.status === "future") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"future-version",
|
||||
"检测到更高版本的浏览器画板草稿;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
if (current.status === "damaged") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"damaged-current",
|
||||
"浏览器画板草稿损坏或格式无法识别;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
|
||||
const legacyScene = inspectSceneStorageValue(values.sceneV1);
|
||||
if (legacyScene.status === "future") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"future-legacy-version",
|
||||
"旧版草稿键中包含更高版本数据;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
if (legacyScene.status === "damaged") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"damaged-legacy-scene",
|
||||
"旧版画板草稿损坏或无法迁移;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
|
||||
const hasSplitLegacyData = ["canvasV3", "canvasV2", "canvasRgb", "textV1"]
|
||||
.some((alias) => values[alias] !== null);
|
||||
const hasLegacyData = values.sceneV1 !== null || hasSplitLegacyData;
|
||||
const invalidLegacyKeys = invalidSplitLegacyKeys(values);
|
||||
const migrated = migrateLegacyScene({
|
||||
canvasV3: values.canvasV3,
|
||||
canvasV2: values.canvasV2,
|
||||
matrixCanvasRgb: values.canvasRgb,
|
||||
textV1: values.textV1,
|
||||
});
|
||||
if (invalidLegacyKeys.length) {
|
||||
return blockedDraft(
|
||||
legacyScene.scene || migrated || blankScene,
|
||||
"legacy-migration-failed",
|
||||
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
if (legacyScene.status === "valid") {
|
||||
return writeMigratedDraft(storage, legacyScene.scene, hasLegacyData);
|
||||
}
|
||||
if (hasSplitLegacyData && !migrated) {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"legacy-migration-failed",
|
||||
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
return writeMigratedDraft(storage, migrated || blankScene, hasLegacyData);
|
||||
}
|
||||
|
||||
function compositeInto(target, rgba) {
|
||||
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < rgba.length; sourceOffset += 4, targetOffset += 3) {
|
||||
const alpha = rgba[sourceOffset + 3];
|
||||
if (alpha === 0) continue;
|
||||
if (alpha === 255) {
|
||||
target[targetOffset] = rgba[sourceOffset];
|
||||
target[targetOffset + 1] = rgba[sourceOffset + 1];
|
||||
target[targetOffset + 2] = rgba[sourceOffset + 2];
|
||||
continue;
|
||||
}
|
||||
const inverse = 255 - alpha;
|
||||
target[targetOffset] = Math.round((rgba[sourceOffset] * alpha + target[targetOffset] * inverse) / 255);
|
||||
target[targetOffset + 1] = Math.round((rgba[sourceOffset + 1] * alpha + target[targetOffset + 1] * inverse) / 255);
|
||||
target[targetOffset + 2] = Math.round((rgba[sourceOffset + 2] * alpha + target[targetOffset + 2] * inverse) / 255);
|
||||
}
|
||||
}
|
||||
|
||||
export function compositeRgbaLayer(pixels, rgba) {
|
||||
const result = byteArray(pixels, RGB_BYTE_LENGTH);
|
||||
const layer = byteArray(rgba, RGBA_BYTE_LENGTH);
|
||||
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
|
||||
if (!layer) throw new RangeError("layer must contain 64x64 RGBA8888 bytes");
|
||||
compositeInto(result, layer);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function compositeRgbaLayers(pixels, layers = []) {
|
||||
const result = byteArray(pixels, RGB_BYTE_LENGTH);
|
||||
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
|
||||
if (!Array.isArray(layers)) throw new TypeError("layers must be an array");
|
||||
for (const value of layers) {
|
||||
const layer = byteArray(value, RGBA_BYTE_LENGTH);
|
||||
if (!layer) throw new RangeError("each layer must contain 64x64 RGBA8888 bytes");
|
||||
compositeInto(result, layer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export const DRAG_START_GUARD_PX = 8;
|
||||
|
||||
const INTERACTIVE_SELECTOR = "button, input, select, textarea, a[href], [contenteditable]:not([contenteditable='false'])";
|
||||
|
||||
export function pointInsideExpandedRect(x, y, rect, padding = DRAG_START_GUARD_PX) {
|
||||
return x >= rect.left - padding
|
||||
&& x <= rect.right + padding
|
||||
&& y >= rect.top - padding
|
||||
&& y <= rect.bottom + padding;
|
||||
}
|
||||
|
||||
export function isProtectedDragStart(card, event, padding = DRAG_START_GUARD_PX) {
|
||||
const target = typeof Element !== "undefined" && event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest(INTERACTIVE_SELECTOR)) return true;
|
||||
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return false;
|
||||
return [...card.querySelectorAll(INTERACTIVE_SELECTOR)].some((control) => (
|
||||
pointInsideExpandedRect(event.clientX, event.clientY, control.getBoundingClientRect(), padding)
|
||||
));
|
||||
}
|
||||
|
||||
export function protectCardDragStart(card, { locked = false } = {}) {
|
||||
let dragging = false;
|
||||
let restoreListeners = null;
|
||||
|
||||
const restore = () => {
|
||||
restoreListeners?.();
|
||||
restoreListeners = null;
|
||||
if (!locked && !dragging) card.draggable = true;
|
||||
};
|
||||
|
||||
card.draggable = !locked;
|
||||
card.addEventListener("pointerdown", (event) => {
|
||||
if (locked || dragging) return;
|
||||
restore();
|
||||
if (!isProtectedDragStart(card, event)) {
|
||||
card.draggable = true;
|
||||
return;
|
||||
}
|
||||
card.draggable = false;
|
||||
const pointerId = event.pointerId;
|
||||
const finish = (finishEvent) => {
|
||||
if (finishEvent.pointerId !== pointerId) return;
|
||||
queueMicrotask(restore);
|
||||
};
|
||||
window.addEventListener("pointerup", finish, true);
|
||||
window.addEventListener("pointercancel", finish, true);
|
||||
restoreListeners = () => {
|
||||
window.removeEventListener("pointerup", finish, true);
|
||||
window.removeEventListener("pointercancel", finish, true);
|
||||
};
|
||||
}, true);
|
||||
card.addEventListener("dragstart", (event) => {
|
||||
if (locked || !card.draggable || isProtectedDragStart(card, event)) {
|
||||
event.preventDefault();
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
restoreListeners?.();
|
||||
restoreListeners = null;
|
||||
card.classList.add("is-dragging");
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
dragging = false;
|
||||
card.classList.remove("is-dragging");
|
||||
restore();
|
||||
});
|
||||
|
||||
return { restore };
|
||||
}
|
||||
|
||||
export function reorderByKey(items, sourceKey, targetKey) {
|
||||
const order = [...items];
|
||||
const from = order.indexOf(sourceKey);
|
||||
const target = order.indexOf(targetKey);
|
||||
if (from < 0 || target < 0 || from === target) return null;
|
||||
order.splice(target, 0, order.splice(from, 1)[0]);
|
||||
return order;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
export function createTemplateEditContext(template, baseline) {
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
revision: template.revision,
|
||||
readOnly: Boolean(template.read_only ?? template.readOnly),
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
|
||||
export function templateEditSnapshot(context, serializedScene) {
|
||||
if (!context) return null;
|
||||
return {
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
revision: context.revision,
|
||||
readOnly: Boolean(context.readOnly),
|
||||
dirty: serializedScene !== context.baseline,
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneNeedsTemplateSave(context, serializedScene) {
|
||||
return !context || serializedScene !== context.baseline;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
{
|
||||
"copy.dynamic.template.save_bound": {
|
||||
"text": "保存模板",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::save_bound"
|
||||
},
|
||||
"copy.dynamic.template.save_new": {
|
||||
"text": "保存为模板",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::save_new"
|
||||
},
|
||||
"copy.dynamic.template.save_choice": {
|
||||
"text": "请选择“{name}”的保存方式。",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::save_choice"
|
||||
},
|
||||
"copy.dynamic.template.unsaved_replace": {
|
||||
"text": "当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::unsaved_replace"
|
||||
},
|
||||
"copy.dynamic.animation.page_status": {
|
||||
"text": "第 {page} / {pages} 页 · 共 {total} 帧",
|
||||
"scope": "animations",
|
||||
"source": "views/animations.js::page_status"
|
||||
},
|
||||
"copy.dynamic.workspace_order.edit": {
|
||||
"text": "自定义项目位置",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_edit"
|
||||
},
|
||||
"copy.dynamic.workspace_order.save": {
|
||||
"text": "保存",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_save"
|
||||
},
|
||||
"copy.dynamic.workspace_order.loading": {
|
||||
"text": "正在读取…",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_loading"
|
||||
},
|
||||
"copy.dynamic.workspace_order.move_up": {
|
||||
"text": "上移",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_move_up"
|
||||
},
|
||||
"copy.dynamic.workspace_order.move_down": {
|
||||
"text": "下移",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_move_down"
|
||||
},
|
||||
"copy.dynamic.workspace_order.first_item": {
|
||||
"text": "当前已经是第一项,不能继续上移。",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_first_item"
|
||||
},
|
||||
"copy.dynamic.workspace_order.last_item": {
|
||||
"text": "当前已经是最后一项,不能继续下移。",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_last_item"
|
||||
},
|
||||
"copy.dynamic.workspace_order.saving": {
|
||||
"text": "正在保存…",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_saving"
|
||||
},
|
||||
"copy.dynamic.workspace_order.saved": {
|
||||
"text": "项目位置已保存",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_saved"
|
||||
},
|
||||
"copy.dynamic.workspace_order.save_required": {
|
||||
"text": "请先保存项目位置",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_save_required"
|
||||
},
|
||||
"copy.dynamic.workspace_order.load_failed": {
|
||||
"text": "项目位置读取失败:{reason}",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_load_failed"
|
||||
},
|
||||
"copy.dynamic.workspace_order.save_failed": {
|
||||
"text": "项目位置保存失败:{reason}",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_save_failed"
|
||||
},
|
||||
"copy.dynamic.board.monitor_eyebrow": {
|
||||
"text": "64 × 64 屏幕监视器",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::monitor_eyebrow",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.monitor_title": {
|
||||
"text": "当前屏幕画面",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::monitor_title"
|
||||
},
|
||||
"copy.dynamic.board.monitor_mode": {
|
||||
"text": "预览当前显示屏幕画面",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::monitor_mode",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.composition_eyebrow": {
|
||||
"text": "64 × 64 统一画板",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::composition_eyebrow",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.composition_title": {
|
||||
"text": "合成预览",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::composition_title",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.composition_mode": {
|
||||
"text": "正向 0° · 统一画板",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::composition_mode",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.animation_edit_mode": {
|
||||
"text": "动图帧编辑 · 不会直接更新实屏",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::animation_edit_mode"
|
||||
},
|
||||
"copy.dynamic.device.current_content": {
|
||||
"text": "当前画面:{name}",
|
||||
"scope": "topbar",
|
||||
"source": "views/device.js::current_content"
|
||||
},
|
||||
"copy.dynamic.device.voltage_ok": {
|
||||
"text": "屏幕输入电压 {volts} V",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_ok"
|
||||
},
|
||||
"copy.dynamic.device.voltage_disconnected": {
|
||||
"text": "电压传感器断开",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_disconnected"
|
||||
},
|
||||
"copy.dynamic.device.voltage_error": {
|
||||
"text": "电压读取异常",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_error"
|
||||
},
|
||||
"copy.dynamic.device.voltage_loading": {
|
||||
"text": "正在读取电压…",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_loading"
|
||||
},
|
||||
"copy.dynamic.text.font_selected": {
|
||||
"text": "已选择字体“{name}”",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_selected"
|
||||
},
|
||||
"copy.dynamic.text.font_type_error": {
|
||||
"text": "只支持 TTF、OTF、TTC、OTC 字体文件。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_type_error"
|
||||
},
|
||||
"copy.dynamic.text.font_size_error": {
|
||||
"text": "字体文件不能超过 32 MiB。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_size_error"
|
||||
},
|
||||
"copy.dynamic.text.font_importing": {
|
||||
"text": "正在导入字体“{name}”…",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_importing"
|
||||
},
|
||||
"copy.dynamic.text.font_imported": {
|
||||
"text": "字体“{name}”已导入",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_imported"
|
||||
},
|
||||
"copy.dynamic.text.font_imported_applied": {
|
||||
"text": "字体“{name}”已导入并应用到当前文字",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_imported_applied"
|
||||
},
|
||||
"copy.dynamic.text.font_exists": {
|
||||
"text": "字体“{name}”已经存在",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_exists"
|
||||
},
|
||||
"copy.dynamic.text.font_exists_applied": {
|
||||
"text": "字体“{name}”已经存在,已应用到当前文字",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_exists_applied"
|
||||
},
|
||||
"copy.dynamic.text.font_error_stale": {
|
||||
"text": "页面版本已过期,请刷新后再导入字体。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_stale"
|
||||
},
|
||||
"copy.dynamic.text.font_error_timeout": {
|
||||
"text": "字体导入超时,请检查核桃派网络或服务状态后重试。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_timeout"
|
||||
},
|
||||
"copy.dynamic.text.font_error_too_large": {
|
||||
"text": "字体导入失败:文件超过 32 MiB。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_too_large"
|
||||
},
|
||||
"copy.dynamic.text.font_error_type": {
|
||||
"text": "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_type"
|
||||
},
|
||||
"copy.dynamic.text.font_error_invalid": {
|
||||
"text": "字体导入失败:文件损坏或不包含可用字体。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_invalid"
|
||||
},
|
||||
"copy.dynamic.text.font_error_storage": {
|
||||
"text": "字体导入失败:设备存储空间不足。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_storage"
|
||||
},
|
||||
"copy.dynamic.text.font_error_network": {
|
||||
"text": "字体导入失败:无法连接设备,请检查网络后重试。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_network"
|
||||
},
|
||||
"copy.dynamic.text.font_error_unknown": {
|
||||
"text": "字体导入失败:服务器未能完成导入,请稍后重试。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_unknown"
|
||||
},
|
||||
"copy.dynamic.text.font_catalog_failed": {
|
||||
"text": "字体目录读取失败,当前字体值已保留:{error}",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_catalog_failed"
|
||||
},
|
||||
"copy.dynamic.media.state_uploading": {"text": "上传中", "scope": "media-import", "source": "views/media-import.js::state_uploading"},
|
||||
"copy.dynamic.media.state_analyzing": {"text": "分析中", "scope": "media-import", "source": "views/media-import.js::state_analyzing"},
|
||||
"copy.dynamic.media.state_awaiting": {"text": "等待设置", "scope": "media-import", "source": "views/media-import.js::state_awaiting"},
|
||||
"copy.dynamic.media.state_queued": {"text": "排队中", "scope": "media-import", "source": "views/media-import.js::state_queued"},
|
||||
"copy.dynamic.media.state_converting": {"text": "转换中", "scope": "media-import", "source": "views/media-import.js::state_converting"},
|
||||
"copy.dynamic.media.state_failed": {"text": "失败", "scope": "media-import", "source": "views/media-import.js::state_failed"},
|
||||
"copy.dynamic.media.queue_position": {"text": "{state} · 队列第 {position} 位", "scope": "media-import", "source": "views/media-import.js::queue_position"},
|
||||
"copy.dynamic.media.preview": {"text": "代表帧 {index}", "scope": "media-import", "source": "views/media-import.js::preview"},
|
||||
"copy.dynamic.media.output_name": {"text": "保存名称", "scope": "media-import", "source": "views/media-import.js::output_name"},
|
||||
"copy.dynamic.media.fit_mode": {"text": "1:1 适配", "scope": "media-import", "source": "views/media-import.js::fit_mode"},
|
||||
"copy.dynamic.media.mode_crop": {"text": "自由取景", "scope": "media-import", "source": "views/media-import.js::mode_crop"},
|
||||
"copy.dynamic.media.mode_contain": {"text": "完整包含", "scope": "media-import", "source": "views/media-import.js::mode_contain"},
|
||||
"copy.dynamic.media.mode_stretch": {"text": "拉伸", "scope": "media-import", "source": "views/media-import.js::mode_stretch"},
|
||||
"copy.dynamic.media.crop_zoom": {"text": "取景缩放", "scope": "media-import", "source": "views/media-import.js::crop_zoom"},
|
||||
"copy.dynamic.media.transparency": {"text": "透明像素填充色", "scope": "media-import", "source": "views/media-import.js::transparency"},
|
||||
"copy.dynamic.media.padding": {"text": "留白颜色", "scope": "media-import", "source": "views/media-import.js::padding"},
|
||||
"copy.dynamic.media.convert": {"text": "保存设置并开始转换", "scope": "media-import", "source": "views/media-import.js::convert"},
|
||||
"copy.dynamic.media.cancel_source": {"text": "取消并删除源文件", "scope": "media-import", "source": "views/media-import.js::cancel_source"},
|
||||
"copy.dynamic.media.retry": {"text": "重试", "scope": "media-import", "source": "views/media-import.js::retry"},
|
||||
"copy.dynamic.media.cancel": {"text": "取消并删除", "scope": "media-import", "source": "views/media-import.js::cancel"},
|
||||
"copy.dynamic.media.deleted": {"text": "任务和源文件已删除。", "scope": "media-import", "source": "views/media-import.js::deleted"},
|
||||
"copy.dynamic.media.delete_failed": {"text": "删除任务失败:{error}", "scope": "media-import", "source": "views/media-import.js::delete_failed"},
|
||||
"copy.dynamic.media.queued": {"text": "任务已加入转换队列。", "scope": "media-import", "source": "views/media-import.js::queued"},
|
||||
"copy.dynamic.media.name_conflict": {"text": "保存名称已存在,请更换名称。", "scope": "media-import", "source": "views/media-import.js::name_conflict"},
|
||||
"copy.dynamic.media.convert_failed": {"text": "无法开始转换:{error}", "scope": "media-import", "source": "views/media-import.js::convert_failed"},
|
||||
"copy.dynamic.media.retry_failed": {"text": "重试失败:{error}", "scope": "media-import", "source": "views/media-import.js::retry_failed"},
|
||||
"copy.dynamic.media.completed": {"text": "“{name}”转换完成,已加入内容库。", "scope": "media-import", "source": "views/media-import.js::completed"},
|
||||
"copy.dynamic.media.empty": {"text": "当前没有等待或转换中的任务。", "scope": "media-import", "source": "views/media-import.js::empty"},
|
||||
"copy.dynamic.media.list_failed": {"text": "任务列表读取失败:{error}", "scope": "media-import", "source": "views/media-import.js::list_failed"},
|
||||
"copy.dynamic.media.uploaded": {"text": "上传完成,设备正在分析媒体。", "scope": "media-import", "source": "views/media-import.js::uploaded"},
|
||||
"copy.dynamic.media.upload_failed": {"text": "上传失败:{error}", "scope": "media-import", "source": "views/media-import.js::upload_failed"},
|
||||
"copy.dynamic.current_display.loading": {"text": "正在读取当前画面…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_loading"},
|
||||
"copy.dynamic.current_display.load_failed": {"text": "当前画面读取失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_load_failed"},
|
||||
"copy.dynamic.current_display.pause": {"text": "暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pause"},
|
||||
"copy.dynamic.current_display.resume": {"text": "继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resume"},
|
||||
"copy.dynamic.current_display.pausing": {"text": "正在暂停…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pausing"},
|
||||
"copy.dynamic.current_display.resuming": {"text": "正在继续播放…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resuming"},
|
||||
"copy.dynamic.current_display.paused": {"text": "动图已暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_paused"},
|
||||
"copy.dynamic.current_display.resumed": {"text": "动图继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resumed"},
|
||||
"copy.dynamic.current_display.speed_changing": {"text": "正在切换到 {speed}×…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changing"},
|
||||
"copy.dynamic.current_display.speed_changed": {"text": "已切换到 {speed}×", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changed"},
|
||||
"copy.dynamic.current_display.control_failed": {"text": "动图播放控制失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_control_failed"}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
const bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__ || null;
|
||||
const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
|
||||
const targetRegistry = new Map();
|
||||
const accessibleNameState = new WeakMap();
|
||||
let activeDraft = bootstrap?.initial_draft || { overrides: {} };
|
||||
|
||||
function formatTemplate(text, values = {}) {
|
||||
return String(text).replace(placeholderPattern, (_match, name) => values[name] ?? `{${name}}`);
|
||||
}
|
||||
|
||||
function preserveAccessibleName(record, text) {
|
||||
const host = record.host;
|
||||
if (!host) return;
|
||||
if (!accessibleNameState.has(host)) accessibleNameState.set(host, host.getAttribute("aria-label"));
|
||||
host.setAttribute("aria-label", text || record.defaultText);
|
||||
}
|
||||
|
||||
function restoreAccessibleName(record) {
|
||||
if (!record.host || !accessibleNameState.has(record.host)) return;
|
||||
const original = accessibleNameState.get(record.host);
|
||||
if (original === null) record.host.removeAttribute("aria-label");
|
||||
else record.host.setAttribute("aria-label", original);
|
||||
accessibleNameState.delete(record.host);
|
||||
}
|
||||
|
||||
function applyRecord(record) {
|
||||
if (!record.target.isConnected) return;
|
||||
const override = activeDraft.overrides?.[record.copyId] || {};
|
||||
const sourceText = override.text ?? record.defaultText;
|
||||
const renderedText = formatTemplate(sourceText, record.values);
|
||||
if (record.attribute) {
|
||||
record.target.setAttribute(record.attribute, renderedText);
|
||||
return;
|
||||
}
|
||||
const hidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(record.defaultHidden);
|
||||
record.target.textContent = hidden ? "" : renderedText;
|
||||
if (hidden) preserveAccessibleName(record, renderedText);
|
||||
else restoreAccessibleName(record);
|
||||
if (override.font_size_px) {
|
||||
if (!record.fontCaptured) {
|
||||
record.originalFontSize = record.target.style.fontSize;
|
||||
record.fontCaptured = true;
|
||||
}
|
||||
record.target.style.fontSize = `${override.font_size_px}px`;
|
||||
} else if (record.fontCaptured) {
|
||||
record.target.style.fontSize = record.originalFontSize;
|
||||
record.fontCaptured = false;
|
||||
}
|
||||
record.target.classList.toggle("ui-copy-has-override", Boolean(activeDraft.overrides?.[record.copyId]));
|
||||
}
|
||||
|
||||
function registerRecord(record) {
|
||||
const previous = targetRegistry.get(record.target);
|
||||
if (previous) {
|
||||
record.originalFontSize = previous.originalFontSize;
|
||||
record.fontCaptured = previous.fontCaptured;
|
||||
}
|
||||
targetRegistry.set(record.target, record);
|
||||
if (bootstrap) record.target.dataset.uiCopyId = record.copyId;
|
||||
applyRecord(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function renderUiCopy(copyId, element, defaultText, values = {}, options = {}) {
|
||||
if (!(element instanceof Element)) return;
|
||||
if (!bootstrap) {
|
||||
const renderedText = formatTemplate(defaultText, values);
|
||||
const record = { host: element, defaultText };
|
||||
element.textContent = options.defaultHidden ? "" : renderedText;
|
||||
if (options.defaultHidden) preserveAccessibleName(record, renderedText);
|
||||
else restoreAccessibleName(record);
|
||||
return;
|
||||
}
|
||||
registerRecord({
|
||||
copyId,
|
||||
target: element,
|
||||
host: element,
|
||||
defaultText,
|
||||
values,
|
||||
kind: "dynamic_template",
|
||||
attribute: null,
|
||||
defaultHidden: Boolean(options.defaultHidden),
|
||||
});
|
||||
}
|
||||
|
||||
export function renderUiValue(element, value) {
|
||||
if (!(element instanceof Element)) return;
|
||||
const priorRecord = targetRegistry.get(element);
|
||||
restoreAccessibleName(priorRecord || { host: element });
|
||||
targetRegistry.delete(element);
|
||||
delete element.dataset.uiCopyId;
|
||||
element.textContent = String(value ?? "");
|
||||
}
|
||||
|
||||
export function registerStaticUiCopy(copyId, target, host, defaultText, kind, attribute = null, defaultHidden = false) {
|
||||
if (!bootstrap || !(target instanceof Element)) return null;
|
||||
return registerRecord({
|
||||
copyId,
|
||||
target,
|
||||
host: host || target,
|
||||
defaultText,
|
||||
values: {},
|
||||
kind,
|
||||
attribute,
|
||||
defaultHidden: Boolean(defaultHidden),
|
||||
});
|
||||
}
|
||||
|
||||
export function setUiCopyDraft(nextDraft) {
|
||||
activeDraft = nextDraft;
|
||||
for (const record of targetRegistry.values()) applyRecord(record);
|
||||
}
|
||||
|
||||
export function applyUiCopyId(copyId) {
|
||||
for (const record of targetRegistry.values()) {
|
||||
if (record.copyId === copyId) applyRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
export function uiCopyRecords(copyId = null) {
|
||||
const records = Array.from(targetRegistry.values()).filter((record) => record.target.isConnected);
|
||||
return copyId === null ? records : records.filter((record) => record.copyId === copyId);
|
||||
}
|
||||
|
||||
export function unregisterUiCopySubtree(root) {
|
||||
for (const [target] of targetRegistry) {
|
||||
if (!target.isConnected || target === root || root?.contains?.(target)) targetRegistry.delete(target);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
import {
|
||||
announce,
|
||||
createLocalState,
|
||||
registerWorkspace,
|
||||
setActiveButton,
|
||||
} from "../core.js";
|
||||
import { refreshColorTrigger, setColorValue } from "../color-picker.js";
|
||||
import { hexToRgb, rgbToHex } from "../color-math.js";
|
||||
import {
|
||||
BRUSH_MODE_NORMAL,
|
||||
BRUSH_MODE_PIXEL,
|
||||
PixelUndoHistory,
|
||||
brushModeSpec,
|
||||
brushStampPoints,
|
||||
clampCanvasView,
|
||||
clampFloatingButton,
|
||||
linePoints,
|
||||
normalizeBrushSize,
|
||||
restoreCanvasToolState,
|
||||
zoomCanvasView,
|
||||
} from "../canvas-tools-model.js";
|
||||
import { setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import {
|
||||
boardPoint,
|
||||
commitPixelChanges,
|
||||
fillPixelLayer,
|
||||
getBoardOverlay,
|
||||
getInteractionMode,
|
||||
getPixelBuffer,
|
||||
renderPixelDraft,
|
||||
} from "../scene-board.js";
|
||||
|
||||
const canvas = getBoardOverlay();
|
||||
const localState = createLocalState("canvas-tools", 2);
|
||||
const previousLocalState = createLocalState("canvas-tools", 1).load(null);
|
||||
const legacyState = createLocalState("canvas", 3).load(null)
|
||||
|| createLocalState("canvas", 2).load(null)
|
||||
|| {};
|
||||
const toolButtons = Array.from(document.querySelectorAll("[data-canvas-tool]"));
|
||||
const paintColor = document.getElementById("paint-color");
|
||||
const backgroundColor = document.getElementById("background-color");
|
||||
const brushSizeInput = document.getElementById("brush-size");
|
||||
const brushSizePresets = Array.from(document.querySelectorAll("[data-brush-size]"));
|
||||
const pixelEditToggle = document.getElementById("pixel-edit-toggle");
|
||||
const pixelEditStatus = document.getElementById("pixel-edit-status");
|
||||
const pixelGrid = document.getElementById("pixel-grid");
|
||||
const boardStage = document.getElementById("board-stage");
|
||||
const canvasWorkbench = document.querySelector(".canvas-workbench");
|
||||
const startEditButton = document.getElementById("start-canvas-edit");
|
||||
const assistive = document.getElementById("canvas-edit-assistive");
|
||||
const assistiveToggle = document.getElementById("canvas-assistive-toggle");
|
||||
const assistiveMenu = document.getElementById("canvas-assistive-menu");
|
||||
const editorModeHint = document.getElementById("canvas-edit-mode-hint");
|
||||
const undoButton = document.getElementById("undo-canvas-edit");
|
||||
const exitEditButton = document.getElementById("exit-canvas-edit");
|
||||
const moveViewButton = document.getElementById("move-canvas-view");
|
||||
const continueEditButton = document.getElementById("continue-canvas-edit");
|
||||
const savedTools = restoreCanvasToolState(localState.load(null), previousLocalState || legacyState);
|
||||
let tool = savedTools.tool;
|
||||
let previousDrawingTool = tool;
|
||||
let pixelEditEnabled = savedTools.pixelEditEnabled;
|
||||
let normalBrushSize = savedTools.normalBrushSize;
|
||||
let pixelBrushSize = savedTools.pixelBrushSize;
|
||||
let drawing = false;
|
||||
let drawingPointerId = null;
|
||||
let lastPoint = null;
|
||||
let drawingBefore = null;
|
||||
let editorActive = false;
|
||||
let editorMode = "draw";
|
||||
let view = { scale: 1, x: 0, y: 0 };
|
||||
let viewGesture = null;
|
||||
let assistiveDrag = null;
|
||||
let suppressAssistiveClick = false;
|
||||
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
|
||||
const viewPointers = new Map();
|
||||
const undoHistory = new PixelUndoHistory();
|
||||
|
||||
paintColor.value = savedTools.paintColor || paintColor.value;
|
||||
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
|
||||
|
||||
function brushMode() {
|
||||
return pixelEditEnabled ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
|
||||
}
|
||||
|
||||
function currentBrushSize() {
|
||||
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
|
||||
}
|
||||
|
||||
function setPixel(x, y, rgb) {
|
||||
if (x < 0 || x > 63 || y < 0 || y > 63) return;
|
||||
const offset = (y * 64 + x) * 3;
|
||||
const pixels = getPixelBuffer();
|
||||
pixels[offset] = rgb.r;
|
||||
pixels[offset + 1] = rgb.g;
|
||||
pixels[offset + 2] = rgb.b;
|
||||
}
|
||||
|
||||
function activeRgb() {
|
||||
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
|
||||
}
|
||||
|
||||
function stampBrush(centerX, centerY, rgb) {
|
||||
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
|
||||
.forEach((point) => setPixel(point.x, point.y, rgb));
|
||||
}
|
||||
|
||||
function paintLine(from, to) {
|
||||
const rgb = activeRgb();
|
||||
linePoints(from, to).forEach((point) => stampBrush(point.x, point.y, rgb));
|
||||
}
|
||||
|
||||
function paintEvent(event) {
|
||||
const samples = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [event];
|
||||
for (const sample of samples.length ? samples : [event]) {
|
||||
const point = boardPoint(sample);
|
||||
paintLine(lastPoint || point, point);
|
||||
lastPoint = point;
|
||||
}
|
||||
renderPixelDraft();
|
||||
}
|
||||
|
||||
function pixelSnapshot() {
|
||||
return Uint8Array.from(getPixelBuffer());
|
||||
}
|
||||
|
||||
function updateUndoControl() {
|
||||
if (undoHistory.size > 0) setControlReady(undoButton);
|
||||
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
|
||||
}
|
||||
|
||||
function clearUndoHistory() {
|
||||
undoHistory.clear();
|
||||
updateUndoControl();
|
||||
}
|
||||
|
||||
function recordPixelOperation(before) {
|
||||
const recorded = undoHistory.record(before, getPixelBuffer());
|
||||
updateUndoControl();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function viewportBounds() {
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
baseSize: Math.max(1, boardStage.offsetWidth),
|
||||
minVisible: 48,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCanvasView(next = view) {
|
||||
view = clampCanvasView(next, viewportBounds());
|
||||
boardStage.style.setProperty("--canvas-view-scale", String(view.scale));
|
||||
boardStage.style.setProperty("--canvas-view-x", `${view.x}px`);
|
||||
boardStage.style.setProperty("--canvas-view-y", `${view.y}px`);
|
||||
}
|
||||
|
||||
function resetCanvasView() {
|
||||
view = { scale: 1, x: 0, y: 0 };
|
||||
applyCanvasView();
|
||||
}
|
||||
|
||||
function applyAssistivePosition(next = assistivePosition) {
|
||||
assistivePosition = clampFloatingButton(next, {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
size: 56,
|
||||
inset: 8,
|
||||
});
|
||||
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
|
||||
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
|
||||
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
|
||||
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
|
||||
}
|
||||
|
||||
function resetAssistivePosition() {
|
||||
applyAssistivePosition({ x: 36, y: window.innerHeight / 2 });
|
||||
}
|
||||
|
||||
function closeAssistiveMenu({ restoreFocus = false } = {}) {
|
||||
assistiveMenu.hidden = true;
|
||||
assistiveToggle.setAttribute("aria-expanded", "false");
|
||||
assistiveToggle.setAttribute("aria-label", "打开画布编辑菜单");
|
||||
if (restoreFocus && editorActive) assistiveToggle.focus();
|
||||
}
|
||||
|
||||
function openAssistiveMenu() {
|
||||
applyAssistivePosition();
|
||||
assistiveMenu.hidden = false;
|
||||
assistiveToggle.setAttribute("aria-expanded", "true");
|
||||
assistiveToggle.setAttribute("aria-label", "关闭画布编辑菜单");
|
||||
const firstAvailable = assistiveMenu.querySelector('button:not([aria-disabled="true"])');
|
||||
firstAvailable?.focus();
|
||||
}
|
||||
|
||||
function toggleAssistiveMenu() {
|
||||
if (assistiveMenu.hidden) openAssistiveMenu();
|
||||
else closeAssistiveMenu({ restoreFocus: true });
|
||||
}
|
||||
|
||||
function updateEditorModeControls() {
|
||||
const moving = editorMode === "move";
|
||||
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
|
||||
else delete document.body.dataset.canvasEditorMode;
|
||||
editorModeHint.textContent = moving
|
||||
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
|
||||
: "绘画模式:直接在画布上绘制。";
|
||||
if (moving) {
|
||||
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
|
||||
setControlReady(continueEditButton);
|
||||
} else {
|
||||
setControlReady(moveViewButton);
|
||||
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
|
||||
}
|
||||
updateUndoControl();
|
||||
}
|
||||
|
||||
function resetViewGesture() {
|
||||
const points = Array.from(viewPointers.values());
|
||||
if (points.length >= 2) {
|
||||
const [first, second] = points;
|
||||
viewGesture = {
|
||||
kind: "pinch",
|
||||
startView: { ...view },
|
||||
startMidpoint: { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 },
|
||||
startDistance: Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)),
|
||||
};
|
||||
} else if (points.length === 1) {
|
||||
viewGesture = { kind: "pan", startView: { ...view }, startPoint: { ...points[0] } };
|
||||
} else {
|
||||
viewGesture = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditorMode(mode) {
|
||||
if (!editorActive) return;
|
||||
if (mode === "move") finishDrawing();
|
||||
editorMode = mode === "move" ? "move" : "draw";
|
||||
viewPointers.clear();
|
||||
viewGesture = null;
|
||||
updateEditorModeControls();
|
||||
closeAssistiveMenu({ restoreFocus: true });
|
||||
announce(editorMode === "move"
|
||||
? "已进入移动画布视线模式:单指拖动,双指缩放"
|
||||
: "已继续编辑画布");
|
||||
}
|
||||
|
||||
function enterCanvasEditor() {
|
||||
if (editorActive || getInteractionMode() !== "pixels") return;
|
||||
editorActive = true;
|
||||
editorMode = "draw";
|
||||
canvas.dataset.canvasEditing = "true";
|
||||
document.body.classList.add("canvas-edit-active");
|
||||
assistive.hidden = false;
|
||||
closeAssistiveMenu();
|
||||
updateEditorModeControls();
|
||||
window.requestAnimationFrame(() => {
|
||||
resetCanvasView();
|
||||
resetAssistivePosition();
|
||||
assistiveToggle.focus();
|
||||
});
|
||||
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
|
||||
}
|
||||
|
||||
function exitCanvasEditor({ returnToTools = true } = {}) {
|
||||
if (!editorActive) return;
|
||||
finishDrawing();
|
||||
editorActive = false;
|
||||
editorMode = "draw";
|
||||
viewPointers.clear();
|
||||
viewGesture = null;
|
||||
closeAssistiveMenu();
|
||||
assistive.hidden = true;
|
||||
delete canvas.dataset.canvasEditing;
|
||||
document.body.classList.remove("canvas-edit-active");
|
||||
delete document.body.dataset.canvasEditorMode;
|
||||
boardStage.style.removeProperty("--canvas-view-scale");
|
||||
boardStage.style.removeProperty("--canvas-view-x");
|
||||
boardStage.style.removeProperty("--canvas-view-y");
|
||||
view = { scale: 1, x: 0, y: 0 };
|
||||
if (returnToTools) {
|
||||
window.requestAnimationFrame(() => {
|
||||
canvasWorkbench.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
startEditButton.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
|
||||
}
|
||||
|
||||
function undoPixelOperation() {
|
||||
const previous = undoHistory.undo();
|
||||
if (!previous) {
|
||||
updateUndoControl();
|
||||
return;
|
||||
}
|
||||
getPixelBuffer().set(previous);
|
||||
commitPixelChanges("pixel-undo");
|
||||
updateUndoControl();
|
||||
announce("已撤销上一个像素操作");
|
||||
}
|
||||
|
||||
function persistTools() {
|
||||
localState.save({
|
||||
paintColor: paintColor.value,
|
||||
backgroundColor: backgroundColor.value,
|
||||
tool,
|
||||
pixelEditEnabled,
|
||||
normalBrushSize,
|
||||
pixelBrushSize,
|
||||
});
|
||||
}
|
||||
|
||||
function renderBrushSize() {
|
||||
const mode = brushMode();
|
||||
const brushSize = currentBrushSize();
|
||||
const spec = brushModeSpec(mode);
|
||||
brushSizeInput.min = String(spec.min);
|
||||
brushSizeInput.max = String(spec.max);
|
||||
brushSizeInput.value = String(brushSize);
|
||||
document.getElementById("brush-size-value").textContent = pixelEditEnabled
|
||||
? `${brushSize} × ${brushSize}`
|
||||
: `${brushSize} px`;
|
||||
const preview = document.getElementById("brush-size-preview");
|
||||
const previewSize = Math.max(4, brushSize * 2);
|
||||
preview.style.width = `${previewSize}px`;
|
||||
preview.style.height = `${previewSize}px`;
|
||||
preview.dataset.shape = pixelEditEnabled ? "square" : "round";
|
||||
brushSizePresets.forEach((button, index) => {
|
||||
const value = spec.presets[index];
|
||||
button.dataset.brushSize = String(value);
|
||||
button.textContent = String(value);
|
||||
});
|
||||
setActiveButton(
|
||||
brushSizePresets,
|
||||
brushSizePresets.find((button) => Number(button.dataset.brushSize) === brushSize),
|
||||
);
|
||||
pixelEditToggle.setAttribute("aria-pressed", String(pixelEditEnabled));
|
||||
pixelEditToggle.textContent = pixelEditEnabled ? "退出单像素编辑" : "开启单像素编辑";
|
||||
pixelEditStatus.hidden = !pixelEditEnabled;
|
||||
pixelEditStatus.textContent = pixelEditEnabled
|
||||
? `画布已按 64×64 方形像素分格,当前笔尖为 ${brushSize}×${brushSize}。`
|
||||
: "";
|
||||
}
|
||||
|
||||
function setBrushSize(value) {
|
||||
const normalized = normalizeBrushSize(value, brushMode());
|
||||
if (pixelEditEnabled) pixelBrushSize = normalized;
|
||||
else normalBrushSize = normalized;
|
||||
renderBrushSize();
|
||||
persistTools();
|
||||
}
|
||||
|
||||
function updatePixelGridVisibility() {
|
||||
pixelGrid.hidden = !(pixelEditEnabled && getInteractionMode() === "pixels");
|
||||
}
|
||||
|
||||
function togglePixelEditMode() {
|
||||
pixelEditEnabled = !pixelEditEnabled;
|
||||
renderBrushSize();
|
||||
updatePixelGridVisibility();
|
||||
persistTools();
|
||||
announce(pixelEditEnabled
|
||||
? `已进入单像素编辑模式:画布已按 64×64 方形像素分格,笔尖为 ${pixelBrushSize}×${pixelBrushSize} 正方形。`
|
||||
: `已退出单像素编辑模式:恢复 ${normalBrushSize} px 圆形笔尖。`);
|
||||
}
|
||||
|
||||
function selectTool(button) {
|
||||
tool = button.dataset.canvasTool;
|
||||
if (tool !== "eyedropper") previousDrawingTool = tool;
|
||||
setActiveButton(toolButtons, button);
|
||||
persistTools();
|
||||
}
|
||||
|
||||
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!editorActive || getInteractionMode() !== "pixels") return;
|
||||
if (editorMode === "move") {
|
||||
event.preventDefault();
|
||||
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
resetViewGesture();
|
||||
return;
|
||||
}
|
||||
if (drawing) return;
|
||||
if (tool === "eyedropper") {
|
||||
const point = boardPoint(event);
|
||||
const offset = (point.y * 64 + point.x) * 3;
|
||||
const pixels = getPixelBuffer();
|
||||
setColorValue(paintColor, rgbToHex({
|
||||
r: pixels[offset],
|
||||
g: pixels[offset + 1],
|
||||
b: pixels[offset + 2],
|
||||
}), { recordHistory: true });
|
||||
tool = previousDrawingTool;
|
||||
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
|
||||
persistTools();
|
||||
announce(`已吸取像素层 (${point.x}, ${point.y}) 的颜色`);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
drawing = true;
|
||||
drawingPointerId = event.pointerId;
|
||||
drawingBefore = pixelSnapshot();
|
||||
lastPoint = boardPoint(event);
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
paintEvent(event);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
|
||||
event.preventDefault();
|
||||
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const points = Array.from(viewPointers.values());
|
||||
if (viewGesture?.kind === "pinch" && points.length >= 2) {
|
||||
const [first, second] = points;
|
||||
const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
|
||||
const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
|
||||
const scaled = zoomCanvasView(
|
||||
viewGesture.startView,
|
||||
viewGesture.startView.scale * (distance / viewGesture.startDistance),
|
||||
viewGesture.startMidpoint,
|
||||
viewportBounds(),
|
||||
);
|
||||
applyCanvasView({
|
||||
...scaled,
|
||||
x: scaled.x + midpoint.x - viewGesture.startMidpoint.x,
|
||||
y: scaled.y + midpoint.y - viewGesture.startMidpoint.y,
|
||||
});
|
||||
} else if (viewGesture?.kind === "pan" && points.length === 1) {
|
||||
applyCanvasView({
|
||||
...viewGesture.startView,
|
||||
x: viewGesture.startView.x + points[0].x - viewGesture.startPoint.x,
|
||||
y: viewGesture.startView.y + points[0].y - viewGesture.startPoint.y,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (drawing && drawingPointerId === event.pointerId && editorActive
|
||||
&& editorMode === "draw" && getInteractionMode() === "pixels") paintEvent(event);
|
||||
});
|
||||
|
||||
function finishDrawing() {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
drawingPointerId = null;
|
||||
lastPoint = null;
|
||||
const changed = recordPixelOperation(drawingBefore);
|
||||
drawingBefore = null;
|
||||
if (changed) commitPixelChanges("pixel-draw");
|
||||
persistTools();
|
||||
}
|
||||
|
||||
function finishCanvasPointer(event) {
|
||||
if (viewPointers.delete(event.pointerId)) {
|
||||
resetViewGesture();
|
||||
return;
|
||||
}
|
||||
if (drawingPointerId === event.pointerId) finishDrawing();
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerup", finishCanvasPointer);
|
||||
canvas.addEventListener("pointercancel", finishCanvasPointer);
|
||||
canvas.addEventListener("lostpointercapture", finishCanvasPointer);
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
if (!editorActive || editorMode !== "move") return;
|
||||
event.preventDefault();
|
||||
const factor = Math.exp(-event.deltaY * 0.002);
|
||||
applyCanvasView(zoomCanvasView(view, view.scale * factor, {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
}, viewportBounds()));
|
||||
}, { passive: false });
|
||||
|
||||
paintColor.addEventListener("input", persistTools);
|
||||
backgroundColor.addEventListener("input", persistTools);
|
||||
brushSizeInput.addEventListener("input", () => setBrushSize(brushSizeInput.value));
|
||||
brushSizePresets.forEach((button) => button.addEventListener("click", () => setBrushSize(button.dataset.brushSize)));
|
||||
pixelEditToggle.addEventListener("click", togglePixelEditMode);
|
||||
document.getElementById("fill-canvas").addEventListener("click", () => {
|
||||
const before = pixelSnapshot();
|
||||
const rgb = hexToRgb(backgroundColor.value);
|
||||
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
|
||||
recordPixelOperation(before);
|
||||
announce("背景颜色已填充到像素层,文字元素保持不变");
|
||||
});
|
||||
document.getElementById("clear-canvas").addEventListener("click", () => {
|
||||
const before = pixelSnapshot();
|
||||
fillPixelLayer([0, 0, 0]);
|
||||
recordPixelOperation(before);
|
||||
announce("像素层已清空,文字元素保持不变");
|
||||
});
|
||||
|
||||
startEditButton.addEventListener("click", enterCanvasEditor);
|
||||
undoButton.addEventListener("click", undoPixelOperation);
|
||||
exitEditButton.addEventListener("click", () => exitCanvasEditor());
|
||||
moveViewButton.addEventListener("click", () => setEditorMode("move"));
|
||||
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
|
||||
|
||||
assistiveToggle.addEventListener("pointerdown", (event) => {
|
||||
if (!editorActive) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
assistiveDrag = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: { ...assistivePosition },
|
||||
moved: false,
|
||||
};
|
||||
assistiveToggle.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
assistiveToggle.addEventListener("pointermove", (event) => {
|
||||
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const dx = event.clientX - assistiveDrag.startX;
|
||||
const dy = event.clientY - assistiveDrag.startY;
|
||||
if (Math.hypot(dx, dy) > 6) assistiveDrag.moved = true;
|
||||
if (assistiveDrag.moved) {
|
||||
closeAssistiveMenu();
|
||||
applyAssistivePosition({ x: assistiveDrag.origin.x + dx, y: assistiveDrag.origin.y + dy });
|
||||
}
|
||||
});
|
||||
|
||||
function finishAssistiveDrag(event) {
|
||||
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
|
||||
suppressAssistiveClick = assistiveDrag.moved;
|
||||
assistiveDrag = null;
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
assistiveToggle.addEventListener("pointerup", finishAssistiveDrag);
|
||||
assistiveToggle.addEventListener("pointercancel", finishAssistiveDrag);
|
||||
assistiveToggle.addEventListener("lostpointercapture", finishAssistiveDrag);
|
||||
assistiveToggle.addEventListener("click", () => {
|
||||
if (suppressAssistiveClick) {
|
||||
suppressAssistiveClick = false;
|
||||
return;
|
||||
}
|
||||
toggleAssistiveMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!editorActive || event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
if (!assistiveMenu.hidden) closeAssistiveMenu({ restoreFocus: true });
|
||||
else exitCanvasEditor();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!editorActive) return;
|
||||
applyCanvasView();
|
||||
applyAssistivePosition();
|
||||
});
|
||||
|
||||
document.addEventListener("matrix:composition-applied", clearUndoHistory);
|
||||
document.addEventListener("matrix:canvas-history-checkpoint", clearUndoHistory);
|
||||
document.addEventListener("matrix:scene-replaced", clearUndoHistory);
|
||||
|
||||
refreshColorTrigger(paintColor);
|
||||
refreshColorTrigger(backgroundColor);
|
||||
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
|
||||
renderBrushSize();
|
||||
persistTools();
|
||||
updateEditorModeControls();
|
||||
|
||||
registerWorkspace({
|
||||
id: "canvas",
|
||||
title: "像素画布",
|
||||
elementId: "workspace-canvas",
|
||||
order: 30,
|
||||
persistence: "local",
|
||||
capabilities: ["scene-template-save"],
|
||||
onEnter() {
|
||||
renderPixelDraft();
|
||||
updatePixelGridVisibility();
|
||||
},
|
||||
onLeave() {
|
||||
exitCanvasEditor({ returnToTools: false });
|
||||
finishDrawing();
|
||||
pixelGrid.hidden = true;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,960 @@
|
||||
import {
|
||||
announce,
|
||||
apiBlob,
|
||||
apiJson,
|
||||
registerWorkspace,
|
||||
setActiveButton,
|
||||
} from "../core.js";
|
||||
import { refreshCurrentDisplayPreview } from "../scene-board.js";
|
||||
import { refreshColorTrigger } from "../color-picker.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import { renderUiCopy, renderUiValue } from "../ui-copy-runtime.js";
|
||||
import {
|
||||
LatestSeekDispatcher,
|
||||
formatPlaybackTime,
|
||||
projectPlaybackPosition,
|
||||
} from "../current-display-playback.js";
|
||||
|
||||
const statusEls = {
|
||||
orientation: document.getElementById("status-orientation"),
|
||||
brightness: document.getElementById("status-brightness"),
|
||||
currentContent: document.getElementById("topbar-current-content"),
|
||||
cpu: document.getElementById("topbar-cpu"),
|
||||
memory: document.getElementById("topbar-memory"),
|
||||
lowVoltageWarning: document.getElementById("topbar-low-voltage-warning"),
|
||||
voltage: document.getElementById("topbar-voltage"),
|
||||
};
|
||||
const fillButtons = Array.from(document.querySelectorAll("[data-test-fill]"));
|
||||
const customFillButton = document.getElementById("custom-test-fill");
|
||||
const customColor = document.getElementById("test-color");
|
||||
const refreshStatusButton = document.getElementById("refresh-status");
|
||||
const displayTestControls = document.getElementById("display-test-controls");
|
||||
const testBrightness = document.getElementById("test-brightness");
|
||||
const testBrightnessValue = document.getElementById("test-brightness-value");
|
||||
const testBrightnessProtection = document.getElementById("test-brightness-protection");
|
||||
const displayTestFeedback = document.getElementById("display-test-feedback");
|
||||
const exitDisplayTest = document.getElementById("exit-display-test");
|
||||
const storageEls = {
|
||||
total: document.getElementById("device-total-space"),
|
||||
free: document.getElementById("device-free-space"),
|
||||
software: document.getElementById("device-software-size"),
|
||||
templates: document.getElementById("device-template-size"),
|
||||
feedback: document.getElementById("device-storage-feedback"),
|
||||
};
|
||||
const resourceDialog = document.getElementById("resource-details-dialog");
|
||||
const resourceDialogClose = document.getElementById("resource-details-close");
|
||||
const resourceDetailsMessage = document.getElementById("resource-details-message");
|
||||
const resourceCoreList = document.getElementById("resource-core-list");
|
||||
const resourceDetailEls = {
|
||||
cpuAppCores: document.getElementById("resource-cpu-app-cores"),
|
||||
cpuSinglePercent: document.getElementById("resource-cpu-single-percent"),
|
||||
cpuAppPercent: document.getElementById("resource-cpu-app-percent"),
|
||||
cpuTotalPercent: document.getElementById("resource-cpu-total-percent"),
|
||||
memoryApp: document.getElementById("resource-memory-app"),
|
||||
memoryAppPercent: document.getElementById("resource-memory-app-percent"),
|
||||
memoryTotalPercent: document.getElementById("resource-memory-total-percent"),
|
||||
};
|
||||
const currentDisplayDialog = document.getElementById("current-display-dialog");
|
||||
const currentDisplayDialogName = document.getElementById("current-display-dialog-name");
|
||||
const currentDisplayDialogClose = document.getElementById("current-display-dialog-close");
|
||||
const currentDisplayPreviewSurface = document.getElementById("current-display-preview-surface");
|
||||
const currentDisplayPreviewImage = document.getElementById("current-display-preview-image");
|
||||
const currentDisplayPreviewPlaceholder = document.getElementById("current-display-preview-placeholder");
|
||||
const currentDisplayPlaybackControls = document.getElementById("current-display-playback-controls");
|
||||
const currentDisplayProgress = document.getElementById("current-display-progress");
|
||||
const currentDisplayProgressTime = document.getElementById("current-display-progress-time");
|
||||
const currentDisplayPlayPause = document.getElementById("current-display-play-pause");
|
||||
const currentDisplaySpeedButtons = Array.from(document.querySelectorAll("[data-playback-speed]"));
|
||||
const currentDisplayPlaybackFeedback = document.getElementById("current-display-playback-feedback");
|
||||
const currentDisplaySeekUnavailableReason = document.getElementById("current-display-seek-unavailable-reason");
|
||||
let loadedOnce = false;
|
||||
let lastStatusIdentity = null;
|
||||
let latestResources = null;
|
||||
let latestProtection = null;
|
||||
let latestStatus = null;
|
||||
let currentDisplayOpenIdentity = null;
|
||||
let currentDisplayPlayback = null;
|
||||
let currentDisplayPlaybackSampledAt = 0;
|
||||
let currentDisplayPreviewTimer = null;
|
||||
let currentDisplayPreviewPending = false;
|
||||
let currentDisplayPreviewRequested = false;
|
||||
let currentDisplayPreviewSequence = 0;
|
||||
let currentDisplayPreviewUrl = null;
|
||||
let currentDisplayAnimationFrame = null;
|
||||
let currentDisplayDragging = false;
|
||||
let currentDisplayFinalSeekPosition = null;
|
||||
let currentDisplayControlPending = false;
|
||||
let customTestColor = "#40A0FF";
|
||||
let testActionPending = false;
|
||||
let desiredTestBrightness = null;
|
||||
let applyingTestBrightness = false;
|
||||
let storageTimer = null;
|
||||
const STORAGE_REFRESH_INTERVAL_MS = 300000;
|
||||
|
||||
const LOW_VOLTAGE_WARNING_TEXT = Object.freeze({
|
||||
limiting: "电压过低,屏幕亮度限制",
|
||||
critical: "电压过低,屏幕已经禁用,请充电",
|
||||
});
|
||||
|
||||
function finiteNumber(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const number = finiteNumber(value);
|
||||
return number === null ? "-" : `${number.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatMemoryDetailed(bytes) {
|
||||
const number = finiteNumber(bytes);
|
||||
if (number === null || number < 0) return "-";
|
||||
const mebibytes = number / (1024 * 1024);
|
||||
return `${mebibytes.toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function formatMemoryCompact(bytes) {
|
||||
const number = finiteNumber(bytes);
|
||||
if (number === null || number < 0) return "-";
|
||||
return `${Math.round(number / (1024 * 1024))}M`;
|
||||
}
|
||||
|
||||
function formatCoreEquivalent(value) {
|
||||
const number = finiteNumber(value);
|
||||
return number === null ? "-" : `${number.toFixed(1)}核`;
|
||||
}
|
||||
|
||||
function renderCoreMeters(cpu) {
|
||||
resourceCoreList.replaceChildren();
|
||||
const count = Number.isInteger(Number(cpu.logical_cpu_count))
|
||||
? Math.max(0, Number(cpu.logical_cpu_count))
|
||||
: 0;
|
||||
const values = Array.isArray(cpu.cores_percent) ? cpu.cores_percent : [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const value = finiteNumber(values[index]);
|
||||
const row = document.createElement("div");
|
||||
row.className = "resource-core-row";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `CPU ${index + 1}`;
|
||||
const progress = document.createElement("progress");
|
||||
progress.max = 100;
|
||||
progress.value = value === null ? 0 : Math.max(0, Math.min(100, value));
|
||||
progress.setAttribute("aria-label", `CPU ${index + 1} 占用`);
|
||||
const output = document.createElement("output");
|
||||
output.textContent = formatPercent(value);
|
||||
row.append(label, progress, output);
|
||||
resourceCoreList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateResourceDetails(resources) {
|
||||
const status = resources?.status || "starting";
|
||||
const cpu = resources?.cpu || {};
|
||||
const memory = resources?.memory || {};
|
||||
const coreCount = finiteNumber(cpu.logical_cpu_count);
|
||||
const coreEquivalent = finiteNumber(cpu.application_core_equivalent);
|
||||
resourceDetailEls.cpuAppCores.textContent = coreEquivalent === null
|
||||
? "-"
|
||||
: `${coreEquivalent.toFixed(2)} / ${coreCount ?? "-"} 核`;
|
||||
resourceDetailEls.cpuSinglePercent.textContent = coreEquivalent === null
|
||||
? "-"
|
||||
: formatPercent(coreEquivalent * 100);
|
||||
resourceDetailEls.cpuAppPercent.textContent = formatPercent(cpu.application_percent);
|
||||
resourceDetailEls.cpuTotalPercent.textContent = formatPercent(cpu.total_percent);
|
||||
resourceDetailEls.memoryApp.textContent = formatMemoryDetailed(memory.application_bytes);
|
||||
resourceDetailEls.memoryAppPercent.textContent = formatPercent(memory.application_percent);
|
||||
resourceDetailEls.memoryTotalPercent.textContent = formatPercent(memory.total_percent);
|
||||
renderCoreMeters(cpu);
|
||||
|
||||
if (status === "error") {
|
||||
resourceDetailsMessage.textContent = "资源读取异常,屏幕、电压和其他控制功能不受影响。";
|
||||
} else if (status !== "ok") {
|
||||
resourceDetailsMessage.textContent = "正在收集资源样本…";
|
||||
} else if (coreEquivalent === null) {
|
||||
resourceDetailsMessage.textContent = "正在等待第二个 CPU 差分样本;内存数据已可用。";
|
||||
} else {
|
||||
resourceDetailsMessage.textContent = "";
|
||||
}
|
||||
resourceDetailsMessage.hidden = resourceDetailsMessage.textContent === "";
|
||||
}
|
||||
|
||||
function showTopbarResources(resources) {
|
||||
latestResources = resources || null;
|
||||
const status = resources?.status || "starting";
|
||||
statusEls.cpu.dataset.status = status;
|
||||
statusEls.memory.dataset.status = status;
|
||||
if (status === "error") {
|
||||
statusEls.cpu.textContent = "CPU !";
|
||||
statusEls.memory.textContent = "RAM !";
|
||||
statusEls.cpu.setAttribute("aria-label", "CPU 读取异常,查看资源占用详情");
|
||||
statusEls.memory.setAttribute("aria-label", "内存读取异常,查看资源占用详情");
|
||||
updateResourceDetails(resources);
|
||||
return;
|
||||
}
|
||||
const cpu = resources?.cpu || {};
|
||||
const memory = resources?.memory || {};
|
||||
const cpuCompact = finiteNumber(cpu.application_core_equivalent) === null
|
||||
? "CPU -"
|
||||
: `CPU ${formatCoreEquivalent(cpu.application_core_equivalent)} · ${formatPercent(cpu.total_percent)}`;
|
||||
const memoryCompact = finiteNumber(memory.application_bytes) === null
|
||||
? "RAM -"
|
||||
: `RAM ${formatMemoryCompact(memory.application_bytes)} · ${formatPercent(memory.total_percent)}`;
|
||||
statusEls.cpu.textContent = cpuCompact;
|
||||
statusEls.memory.textContent = memoryCompact;
|
||||
statusEls.cpu.setAttribute("aria-label", `${cpuCompact},查看 CPU 占用详情`);
|
||||
statusEls.memory.setAttribute("aria-label", `${memoryCompact},查看内存占用详情`);
|
||||
updateResourceDetails(resources);
|
||||
}
|
||||
|
||||
function openResourceDetails() {
|
||||
updateResourceDetails(latestResources);
|
||||
if (!resourceDialog.open) resourceDialog.showModal();
|
||||
resourceDialogClose.focus();
|
||||
}
|
||||
|
||||
statusEls.cpu.addEventListener("click", openResourceDetails);
|
||||
statusEls.memory.addEventListener("click", openResourceDetails);
|
||||
|
||||
function activeCurrentDisplayPlayback(status = latestStatus) {
|
||||
const content = status?.state?.current_content;
|
||||
const playback = status?.state?.animation_playback;
|
||||
if (
|
||||
content?.category !== "animation"
|
||||
|| playback?.active !== true
|
||||
|| typeof playback.session_id !== "string"
|
||||
|| playback.animation_id !== content.id
|
||||
) return null;
|
||||
return playback;
|
||||
}
|
||||
|
||||
function currentDisplayIdentity(status = latestStatus) {
|
||||
const content = status?.state?.current_content || {};
|
||||
const playback = activeCurrentDisplayPlayback(status);
|
||||
return [
|
||||
content.category || "unsaved",
|
||||
content.id || "",
|
||||
content.name || "未保存内容",
|
||||
playback?.session_id || "",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function setCurrentDisplayPlaybackFeedback(copyId = "", defaultText = "", values = {}, state = "idle") {
|
||||
if (copyId) renderUiCopy(copyId, currentDisplayPlaybackFeedback, defaultText, values);
|
||||
else renderUiValue(currentDisplayPlaybackFeedback, "");
|
||||
currentDisplayPlaybackFeedback.dataset.state = state;
|
||||
currentDisplayPlaybackFeedback.hidden = !defaultText;
|
||||
}
|
||||
|
||||
function syncCurrentDisplayControlAvailability() {
|
||||
const controls = [currentDisplayPlayPause, ...currentDisplaySpeedButtons];
|
||||
if (currentDisplayControlPending) {
|
||||
[currentDisplayProgress, ...controls].forEach((control) => setControlBusy(control));
|
||||
return;
|
||||
}
|
||||
setControlReady(currentDisplayProgress);
|
||||
if (currentDisplayDragging) {
|
||||
const reason = currentDisplaySeekUnavailableReason.textContent || "请先完成当前进度跳转。";
|
||||
controls.forEach((control) => setControlUnavailable(control, reason));
|
||||
} else {
|
||||
controls.forEach((control) => setControlReady(control));
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentDisplayPosition(positionMs, { force = false } = {}) {
|
||||
if (!currentDisplayPlayback) return;
|
||||
const total = Math.max(1, Number(currentDisplayPlayback.total_duration_ms) || 1);
|
||||
const position = Math.max(0, Math.min(total - 1, Number(positionMs) || 0));
|
||||
if (force || !currentDisplayDragging) currentDisplayProgress.value = String(Math.floor(position));
|
||||
currentDisplayProgressTime.textContent = `${formatPlaybackTime(position)} / ${formatPlaybackTime(total)}`;
|
||||
}
|
||||
|
||||
function applyCurrentDisplayPlayback(playback, { preservePosition = currentDisplayDragging } = {}) {
|
||||
if (!playback?.active) return;
|
||||
currentDisplayPlayback = { ...playback };
|
||||
currentDisplayPlaybackSampledAt = performance.now();
|
||||
const total = Math.max(1, Number(playback.total_duration_ms) || 1);
|
||||
currentDisplayProgress.max = String(total - 1);
|
||||
renderUiCopy(
|
||||
playback.paused ? "copy.dynamic.current_display.resume" : "copy.dynamic.current_display.pause",
|
||||
currentDisplayPlayPause,
|
||||
playback.paused ? "继续播放" : "暂停",
|
||||
);
|
||||
currentDisplayPlayPause.setAttribute("aria-pressed", String(Boolean(playback.paused)));
|
||||
currentDisplaySpeedButtons.forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(Number(button.dataset.playbackSpeed) === Number(playback.speed)));
|
||||
});
|
||||
if (!preservePosition) renderCurrentDisplayPosition(playback.position_ms, { force: true });
|
||||
syncCurrentDisplayControlAvailability();
|
||||
if (currentDisplayAnimationFrame === null) {
|
||||
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
|
||||
}
|
||||
}
|
||||
|
||||
function animateCurrentDisplayProgress(now) {
|
||||
currentDisplayAnimationFrame = null;
|
||||
if (!currentDisplayDialog.open || currentDisplayPlaybackControls.hidden || !currentDisplayPlayback) return;
|
||||
if (!currentDisplayDragging) {
|
||||
renderCurrentDisplayPosition(
|
||||
projectPlaybackPosition(currentDisplayPlayback, now - currentDisplayPlaybackSampledAt),
|
||||
);
|
||||
}
|
||||
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
|
||||
}
|
||||
|
||||
function scheduleCurrentDisplayPreview(delay = null) {
|
||||
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
|
||||
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
|
||||
const configured = Number(latestStatus?.ui?.preview_refresh_interval_ms);
|
||||
const wait = delay === null ? Math.max(1, Number.isFinite(configured) ? configured : 1000) : delay;
|
||||
currentDisplayPreviewTimer = window.setTimeout(() => {
|
||||
currentDisplayPreviewTimer = null;
|
||||
refreshCurrentDisplayDialogFrame();
|
||||
}, wait);
|
||||
}
|
||||
|
||||
async function refreshCurrentDisplayDialogFrame() {
|
||||
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
|
||||
if (currentDisplayPreviewPending) {
|
||||
currentDisplayPreviewRequested = true;
|
||||
return;
|
||||
}
|
||||
currentDisplayPreviewPending = true;
|
||||
currentDisplayPreviewRequested = false;
|
||||
const sequence = currentDisplayPreviewSequence;
|
||||
try {
|
||||
const blob = await apiBlob("/api/display/current-frame");
|
||||
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
|
||||
const nextUrl = URL.createObjectURL(blob);
|
||||
const previousUrl = currentDisplayPreviewUrl;
|
||||
currentDisplayPreviewUrl = nextUrl;
|
||||
currentDisplayPreviewImage.src = nextUrl;
|
||||
currentDisplayPreviewImage.hidden = false;
|
||||
currentDisplayPreviewPlaceholder.hidden = true;
|
||||
if (previousUrl !== null) URL.revokeObjectURL(previousUrl);
|
||||
} catch (error) {
|
||||
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
|
||||
currentDisplayPreviewImage.hidden = true;
|
||||
currentDisplayPreviewPlaceholder.hidden = false;
|
||||
renderUiCopy(
|
||||
"copy.dynamic.current_display.load_failed",
|
||||
currentDisplayPreviewPlaceholder,
|
||||
"当前画面读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
);
|
||||
} finally {
|
||||
currentDisplayPreviewPending = false;
|
||||
if (!currentDisplayDialog.open) return;
|
||||
if (sequence !== currentDisplayPreviewSequence) {
|
||||
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
|
||||
return;
|
||||
}
|
||||
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
|
||||
else scheduleCurrentDisplayPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function requestCurrentDisplayPreviewRefresh() {
|
||||
if (currentDisplayPreviewPending) {
|
||||
currentDisplayPreviewRequested = true;
|
||||
return;
|
||||
}
|
||||
scheduleCurrentDisplayPreview(0);
|
||||
}
|
||||
|
||||
function cleanupCurrentDisplayDialog() {
|
||||
currentDisplayOpenIdentity = null;
|
||||
currentDisplayPlayback = null;
|
||||
currentDisplayDragging = false;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
currentDisplayControlPending = false;
|
||||
currentDisplayPreviewSequence += 1;
|
||||
currentDisplayPreviewRequested = false;
|
||||
seekDispatcher.clear();
|
||||
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
|
||||
currentDisplayPreviewTimer = null;
|
||||
if (currentDisplayAnimationFrame !== null) window.cancelAnimationFrame(currentDisplayAnimationFrame);
|
||||
currentDisplayAnimationFrame = null;
|
||||
if (currentDisplayPreviewUrl !== null) URL.revokeObjectURL(currentDisplayPreviewUrl);
|
||||
currentDisplayPreviewUrl = null;
|
||||
currentDisplayPreviewImage.removeAttribute("src");
|
||||
currentDisplayPreviewImage.hidden = true;
|
||||
currentDisplayPreviewPlaceholder.hidden = false;
|
||||
renderUiCopy(
|
||||
"copy.dynamic.current_display.loading",
|
||||
currentDisplayPreviewPlaceholder,
|
||||
"正在读取当前画面…",
|
||||
);
|
||||
setCurrentDisplayPlaybackFeedback();
|
||||
}
|
||||
|
||||
function closeCurrentDisplayDialog() {
|
||||
if (currentDisplayDialog.open) currentDisplayDialog.close();
|
||||
else cleanupCurrentDisplayDialog();
|
||||
}
|
||||
|
||||
async function openCurrentDisplayDialog() {
|
||||
if (currentDisplayDialog.open) return;
|
||||
setControlBusy(statusEls.currentContent);
|
||||
try {
|
||||
await refreshStatus({ source: "manual" });
|
||||
} catch (_error) {
|
||||
if (!latestStatus) return;
|
||||
} finally {
|
||||
setControlReady(statusEls.currentContent);
|
||||
}
|
||||
const content = latestStatus?.state?.current_content || { name: "未保存内容" };
|
||||
const playback = activeCurrentDisplayPlayback();
|
||||
currentDisplayOpenIdentity = currentDisplayIdentity();
|
||||
currentDisplayDialogName.textContent = content.name || "未保存内容";
|
||||
currentDisplayPlaybackControls.hidden = !playback;
|
||||
currentDisplayDialog.showModal();
|
||||
currentDisplayPreviewSequence += 1;
|
||||
if (playback) applyCurrentDisplayPlayback(playback, { preservePosition: false });
|
||||
refreshCurrentDisplayDialogFrame();
|
||||
if (playback) currentDisplayProgress.focus();
|
||||
else currentDisplayPreviewSurface.focus();
|
||||
}
|
||||
|
||||
function syncCurrentDisplayDialogStatus(status) {
|
||||
latestStatus = status;
|
||||
if (!currentDisplayDialog.open) return;
|
||||
const identity = currentDisplayIdentity(status);
|
||||
if (identity !== currentDisplayOpenIdentity) {
|
||||
closeCurrentDisplayDialog();
|
||||
return;
|
||||
}
|
||||
const playback = activeCurrentDisplayPlayback(status);
|
||||
if (playback) applyCurrentDisplayPlayback(playback);
|
||||
}
|
||||
|
||||
async function sendCurrentDisplayPlaybackPatch(fields) {
|
||||
const sessionId = currentDisplayPlayback?.session_id;
|
||||
if (!sessionId) throw new Error("当前动图播放会话已经结束");
|
||||
const result = await apiJson("/api/display/animation-playback", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ session_id: sessionId, ...fields }),
|
||||
});
|
||||
const playback = result.animation_playback;
|
||||
if (
|
||||
currentDisplayDialog.open
|
||||
&& currentDisplayPlayback?.session_id === sessionId
|
||||
&& playback?.session_id === sessionId
|
||||
) {
|
||||
if (latestStatus?.state) latestStatus.state.animation_playback = playback;
|
||||
if (Number.isInteger(result.state_revision) && latestStatus?.service?.instance_id) {
|
||||
lastStatusIdentity = `${latestStatus.service.instance_id}:${result.state_revision}`;
|
||||
}
|
||||
applyCurrentDisplayPlayback(playback);
|
||||
requestCurrentDisplayPreviewRefresh();
|
||||
}
|
||||
return playback;
|
||||
}
|
||||
|
||||
function handleCurrentDisplayPlaybackError(error) {
|
||||
const defaultText = "动图播放控制失败:{reason}";
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
"copy.dynamic.current_display.control_failed",
|
||||
defaultText,
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
announce(`动图播放控制失败:${error.message}`, true);
|
||||
if (error.status === 409) {
|
||||
closeCurrentDisplayDialog();
|
||||
refreshStatus({ source: "manual" }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCurrentDisplaySeek(positionMs) {
|
||||
const playback = await sendCurrentDisplayPlaybackPatch({ position_ms: Math.round(positionMs) });
|
||||
if (currentDisplayFinalSeekPosition === positionMs) {
|
||||
currentDisplayDragging = false;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
applyCurrentDisplayPlayback(playback, { preservePosition: false });
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
}
|
||||
|
||||
const seekDispatcher = new LatestSeekDispatcher(sendCurrentDisplaySeek, {
|
||||
intervalMs: 100,
|
||||
onError: (error, positionMs) => {
|
||||
if (currentDisplayFinalSeekPosition === positionMs) {
|
||||
currentDisplayDragging = false;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
handleCurrentDisplayPlaybackError(error);
|
||||
},
|
||||
});
|
||||
|
||||
statusEls.currentContent.addEventListener("click", openCurrentDisplayDialog);
|
||||
currentDisplayDialogClose.addEventListener("click", closeCurrentDisplayDialog);
|
||||
currentDisplayDialog.addEventListener("close", cleanupCurrentDisplayDialog);
|
||||
currentDisplayDialog.addEventListener("click", (event) => {
|
||||
if (currentDisplayPlaybackControls.hidden || event.target === currentDisplayDialog) {
|
||||
closeCurrentDisplayDialog();
|
||||
}
|
||||
});
|
||||
|
||||
currentDisplayProgress.addEventListener("pointerdown", () => {
|
||||
currentDisplayDragging = true;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
});
|
||||
currentDisplayProgress.addEventListener("input", () => {
|
||||
currentDisplayDragging = true;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
const position = Number(currentDisplayProgress.value);
|
||||
renderCurrentDisplayPosition(position, { force: true });
|
||||
syncCurrentDisplayControlAvailability();
|
||||
seekDispatcher.enqueue(position);
|
||||
});
|
||||
function flushCurrentDisplaySeek() {
|
||||
if (!currentDisplayDragging) return;
|
||||
const position = Number(currentDisplayProgress.value);
|
||||
currentDisplayFinalSeekPosition = position;
|
||||
seekDispatcher.enqueue(position, { flush: true });
|
||||
}
|
||||
currentDisplayProgress.addEventListener("change", flushCurrentDisplaySeek);
|
||||
currentDisplayProgress.addEventListener("pointerup", flushCurrentDisplaySeek);
|
||||
currentDisplayProgress.addEventListener("pointercancel", flushCurrentDisplaySeek);
|
||||
|
||||
currentDisplayPlayPause.addEventListener("click", async () => {
|
||||
if (!currentDisplayPlayback || currentDisplayControlPending || currentDisplayDragging) return;
|
||||
currentDisplayControlPending = true;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
currentDisplayPlayback.paused
|
||||
? "copy.dynamic.current_display.resuming"
|
||||
: "copy.dynamic.current_display.pausing",
|
||||
currentDisplayPlayback.paused ? "正在继续播放…" : "正在暂停…",
|
||||
{},
|
||||
"pending",
|
||||
);
|
||||
try {
|
||||
const playback = await sendCurrentDisplayPlaybackPatch({ paused: !currentDisplayPlayback.paused });
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
playback.paused
|
||||
? "copy.dynamic.current_display.paused"
|
||||
: "copy.dynamic.current_display.resumed",
|
||||
playback.paused ? "动图已暂停" : "动图继续播放",
|
||||
{},
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
handleCurrentDisplayPlaybackError(error);
|
||||
} finally {
|
||||
currentDisplayControlPending = false;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
});
|
||||
|
||||
currentDisplaySpeedButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const speed = Number(button.dataset.playbackSpeed);
|
||||
if (
|
||||
!currentDisplayPlayback
|
||||
|| currentDisplayControlPending
|
||||
|| currentDisplayDragging
|
||||
|| speed === Number(currentDisplayPlayback.speed)
|
||||
) return;
|
||||
currentDisplayControlPending = true;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
"copy.dynamic.current_display.speed_changing",
|
||||
"正在切换到 {speed}×…",
|
||||
{ speed },
|
||||
"pending",
|
||||
);
|
||||
try {
|
||||
await sendCurrentDisplayPlaybackPatch({ speed });
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
"copy.dynamic.current_display.speed_changed",
|
||||
"已切换到 {speed}×",
|
||||
{ speed },
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
handleCurrentDisplayPlaybackError(error);
|
||||
} finally {
|
||||
currentDisplayControlPending = false;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!currentDisplayDialog.open) return;
|
||||
if (document.visibilityState === "visible") requestCurrentDisplayPreviewRefresh();
|
||||
else if (currentDisplayPreviewTimer !== null) {
|
||||
window.clearTimeout(currentDisplayPreviewTimer);
|
||||
currentDisplayPreviewTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
function showTopbarVoltage(voltage) {
|
||||
const status = voltage?.status || "starting";
|
||||
statusEls.voltage.dataset.status = status;
|
||||
if (status === "ok" && Number.isFinite(Number(voltage.volts))) {
|
||||
renderUiCopy(
|
||||
"copy.dynamic.device.voltage_ok",
|
||||
statusEls.voltage,
|
||||
"屏幕输入电压 {volts} V",
|
||||
{ volts: Number(voltage.volts).toFixed(2) },
|
||||
);
|
||||
} else if (status === "disconnected") {
|
||||
renderUiCopy("copy.dynamic.device.voltage_disconnected", statusEls.voltage, "电压传感器断开");
|
||||
} else if (status === "error") {
|
||||
renderUiCopy("copy.dynamic.device.voltage_error", statusEls.voltage, "电压读取异常");
|
||||
} else {
|
||||
renderUiCopy("copy.dynamic.device.voltage_loading", statusEls.voltage, "正在读取电压…");
|
||||
}
|
||||
}
|
||||
|
||||
function showCurrentContent(state) {
|
||||
const name = typeof state?.current_content?.name === "string"
|
||||
? state.current_content.name
|
||||
: "未保存内容";
|
||||
renderUiCopy(
|
||||
"copy.dynamic.device.current_content",
|
||||
statusEls.currentContent,
|
||||
"当前画面:{name}",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
function showTopbarLowVoltageProtection(protection) {
|
||||
const requestedMode = typeof protection?.mode === "string" ? protection.mode : "unavailable";
|
||||
const warningMode = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, requestedMode);
|
||||
const mode = warningMode
|
||||
? requestedMode
|
||||
: ["disabled", "unavailable", "inactive"].includes(requestedMode)
|
||||
? requestedMode
|
||||
: "unavailable";
|
||||
const previousMode = statusEls.lowVoltageWarning.dataset.mode;
|
||||
const visible = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, mode);
|
||||
|
||||
statusEls.lowVoltageWarning.dataset.mode = mode;
|
||||
if (!visible) {
|
||||
statusEls.lowVoltageWarning.hidden = true;
|
||||
if (previousMode !== mode) statusEls.lowVoltageWarning.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousMode !== mode) {
|
||||
statusEls.lowVoltageWarning.textContent = LOW_VOLTAGE_WARNING_TEXT[mode];
|
||||
}
|
||||
statusEls.lowVoltageWarning.hidden = false;
|
||||
}
|
||||
|
||||
function showDeviceBrightness(state) {
|
||||
const userBrightness = finiteNumber(state?.brightness);
|
||||
const effectiveBrightness = finiteNumber(state?.effective_brightness);
|
||||
if (userBrightness === null) {
|
||||
statusEls.brightness.textContent = "-";
|
||||
return;
|
||||
}
|
||||
if (effectiveBrightness === null) {
|
||||
statusEls.brightness.textContent = `${userBrightness}%`;
|
||||
return;
|
||||
}
|
||||
statusEls.brightness.textContent = `用户 ${userBrightness}% · 实际 ${effectiveBrightness}%`;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = bytes / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && size >= 1024; index += 1) {
|
||||
size /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
function normalizedHex(value) {
|
||||
const match = /^#([0-9A-Fa-f]{6})$/.exec(String(value || "").trim());
|
||||
return match ? `#${match[1].toUpperCase()}` : null;
|
||||
}
|
||||
|
||||
function showTestColor(value, { editable = false } = {}) {
|
||||
const color = normalizedHex(value) || customTestColor;
|
||||
customColor.value = color;
|
||||
customColor.dataset.colorReadonly = String(!editable);
|
||||
customColor.setAttribute("aria-label", editable ? "编辑自定义测试颜色" : "查看测试颜色");
|
||||
refreshColorTrigger(customColor);
|
||||
}
|
||||
|
||||
function setDisplayTestFeedback(text = "", state = "idle") {
|
||||
displayTestFeedback.textContent = text;
|
||||
displayTestFeedback.dataset.state = state;
|
||||
displayTestFeedback.hidden = !text;
|
||||
}
|
||||
|
||||
function showTestProtection(testValue, effectiveValue) {
|
||||
const mode = latestProtection?.mode;
|
||||
if (mode !== "limiting" && mode !== "critical") {
|
||||
testBrightnessProtection.hidden = true;
|
||||
testBrightnessProtection.textContent = "";
|
||||
return;
|
||||
}
|
||||
testBrightnessProtection.dataset.mode = mode;
|
||||
if (mode === "critical") {
|
||||
testBrightnessProtection.textContent = "电压过低,纯色测试暂时由 35% 亮度的低电图标覆盖;退出或恢复电压前,测试颜色和亮度会保留。";
|
||||
} else {
|
||||
const limit = finiteNumber(latestProtection?.brightness_limit_percent);
|
||||
const effective = finiteNumber(effectiveValue);
|
||||
testBrightnessProtection.textContent = `电压过低,当前允许的最高亮度为 ${limit ?? "-"}%;测试设置 ${testValue}%,实际 ${effective ?? "-"}%。`;
|
||||
}
|
||||
testBrightnessProtection.hidden = false;
|
||||
}
|
||||
|
||||
function syncDisplayTest(state, protection = latestProtection) {
|
||||
latestProtection = protection || null;
|
||||
const displayTest = state?.display_test || {};
|
||||
const active = displayTest.active === true;
|
||||
displayTestControls.hidden = !active;
|
||||
if (!active) {
|
||||
setActiveButton(fillButtons, null);
|
||||
customFillButton.classList.remove("active");
|
||||
customFillButton.setAttribute("aria-pressed", "false");
|
||||
showTestColor(customTestColor);
|
||||
showTestProtection(50, state?.effective_brightness);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = normalizedHex(displayTest.color) || customTestColor;
|
||||
const brightness = Math.max(1, Math.min(100, Number(displayTest.brightness) || 50));
|
||||
const matchingFixed = fillButtons.find((button) => button.dataset.testFill === color) || null;
|
||||
setActiveButton(fillButtons, matchingFixed);
|
||||
customFillButton.classList.toggle("active", !matchingFixed);
|
||||
customFillButton.setAttribute("aria-pressed", String(!matchingFixed));
|
||||
testBrightness.value = String(brightness);
|
||||
testBrightnessValue.textContent = `${brightness}%`;
|
||||
showTestColor(color, { editable: !matchingFixed });
|
||||
showTestProtection(brightness, state?.effective_brightness);
|
||||
}
|
||||
|
||||
export async function refreshDeviceStorage() {
|
||||
storageEls.feedback.textContent = "正在读取设备容量…";
|
||||
storageEls.feedback.dataset.state = "pending";
|
||||
storageEls.feedback.hidden = false;
|
||||
try {
|
||||
const data = await apiJson("/api/device-storage");
|
||||
storageEls.total.textContent = formatBytes(data.device_total_bytes);
|
||||
storageEls.free.textContent = formatBytes(data.device_free_bytes);
|
||||
storageEls.software.textContent = formatBytes(data.software_bytes);
|
||||
storageEls.templates.textContent = formatBytes(data.templates_bytes);
|
||||
storageEls.feedback.textContent = "";
|
||||
storageEls.feedback.hidden = true;
|
||||
return data;
|
||||
} catch (error) {
|
||||
storageEls.feedback.textContent = `容量读取失败:${error.message}`;
|
||||
storageEls.feedback.dataset.state = "error";
|
||||
announce(storageEls.feedback.textContent, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshStatus({ source = "manual" } = {}) {
|
||||
try {
|
||||
const data = await apiJson("/api/status");
|
||||
syncCurrentDisplayDialogStatus(data);
|
||||
statusEls.orientation.textContent = `${data.state.orientation}°`;
|
||||
showDeviceBrightness(data.state);
|
||||
showCurrentContent(data.state);
|
||||
showTopbarResources(data.resources);
|
||||
showTopbarLowVoltageProtection(data.power?.low_voltage_protection);
|
||||
showTopbarVoltage(data.power?.screen_input_voltage);
|
||||
syncDisplayTest(data.state, data.power?.low_voltage_protection);
|
||||
const statusIdentity = `${data.service?.instance_id || "legacy"}:${data.state.revision ?? "legacy"}`;
|
||||
const externalChange = source === "poll" && lastStatusIdentity !== null && statusIdentity !== lastStatusIdentity;
|
||||
lastStatusIdentity = statusIdentity;
|
||||
document.dispatchEvent(new CustomEvent("matrix:status", {
|
||||
detail: { ...data, externalChange },
|
||||
}));
|
||||
if (externalChange) {
|
||||
announce("设备状态已由其他页面更新;本页画板未被自动应用");
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (source !== "poll") announce(`状态读取失败:${error.message}`, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
fillButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => startDisplayTest(
|
||||
button,
|
||||
button.dataset.testFill,
|
||||
button.textContent.trim(),
|
||||
));
|
||||
});
|
||||
|
||||
async function startDisplayTest(button, color, label, { openEditor = false } = {}) {
|
||||
if (testActionPending) return;
|
||||
testActionPending = true;
|
||||
[...fillButtons, customFillButton].forEach((control) => setControlBusy(control));
|
||||
setDisplayTestFeedback(`正在显示${label}…`, "pending");
|
||||
try {
|
||||
await apiJson("/api/display/test/fill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ color }),
|
||||
});
|
||||
showTestColor(color, { editable: openEditor });
|
||||
setDisplayTestFeedback(`${label}测试已开始`, "success");
|
||||
announce(`${label}测试已开始`);
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
if (openEditor) {
|
||||
showTestColor(color, { editable: true });
|
||||
customColor.click();
|
||||
}
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`纯色测试失败:${error.message}`, "error");
|
||||
announce(`纯色测试失败:${error.message}`, true);
|
||||
} finally {
|
||||
testActionPending = false;
|
||||
[...fillButtons, customFillButton].forEach((control) => setControlReady(control));
|
||||
}
|
||||
}
|
||||
|
||||
customFillButton.addEventListener("click", () => startDisplayTest(
|
||||
customFillButton,
|
||||
customTestColor,
|
||||
"自定义纯色",
|
||||
{ openEditor: true },
|
||||
));
|
||||
|
||||
customColor.addEventListener("change", async () => {
|
||||
if (customColor.dataset.colorReadonly === "true") return;
|
||||
const requested = normalizedHex(customColor.value);
|
||||
if (!requested || requested === customTestColor) return;
|
||||
setControlBusy(customColor, { message: "正在保存并应用自定义纯色…" });
|
||||
setDisplayTestFeedback("正在保存并应用自定义纯色…", "pending");
|
||||
try {
|
||||
const config = await apiJson("/api/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ custom_test_color: requested }),
|
||||
});
|
||||
customTestColor = normalizedHex(config.custom_test_color) || requested;
|
||||
await apiJson("/api/display/test/fill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ color: customTestColor }),
|
||||
});
|
||||
setDisplayTestFeedback(`自定义纯色 ${customTestColor} 已保存并应用`, "success");
|
||||
announce(`自定义纯色 ${customTestColor} 已保存并应用`);
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`自定义纯色保存或应用失败:${error.message}`, "error");
|
||||
announce(displayTestFeedback.textContent, true);
|
||||
await refreshStatus().catch(() => {});
|
||||
} finally {
|
||||
setControlReady(customColor);
|
||||
}
|
||||
});
|
||||
|
||||
async function processTestBrightnessQueue() {
|
||||
if (applyingTestBrightness) return;
|
||||
applyingTestBrightness = true;
|
||||
while (desiredTestBrightness !== null) {
|
||||
const requested = desiredTestBrightness;
|
||||
desiredTestBrightness = null;
|
||||
setDisplayTestFeedback(`正在应用测试亮度 ${requested}%…`, "pending");
|
||||
try {
|
||||
const response = await apiJson("/api/display/test/brightness", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ brightness: requested }),
|
||||
});
|
||||
const applied = Number(response.display_test?.brightness) || requested;
|
||||
testBrightness.value = String(applied);
|
||||
testBrightnessValue.textContent = `${applied}%`;
|
||||
showTestProtection(applied, response.effective_brightness);
|
||||
setDisplayTestFeedback(`测试亮度 ${applied}% 已生效,不会修改用户亮度`, "success");
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`测试亮度应用失败:${error.message}`, "error");
|
||||
announce(displayTestFeedback.textContent, true);
|
||||
if (desiredTestBrightness === null) await refreshStatus().catch(() => {});
|
||||
}
|
||||
}
|
||||
applyingTestBrightness = false;
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus().catch(() => {});
|
||||
}
|
||||
|
||||
function queueTestBrightness(value) {
|
||||
desiredTestBrightness = Math.max(1, Math.min(100, Number(value)));
|
||||
void processTestBrightnessQueue();
|
||||
}
|
||||
|
||||
testBrightness.addEventListener("input", () => {
|
||||
testBrightnessValue.textContent = `${testBrightness.value}%`;
|
||||
showTestProtection(testBrightness.value, null);
|
||||
queueTestBrightness(testBrightness.value);
|
||||
});
|
||||
testBrightness.addEventListener("change", () => queueTestBrightness(testBrightness.value));
|
||||
|
||||
exitDisplayTest.addEventListener("click", async () => {
|
||||
setControlBusy(exitDisplayTest, { label: "正在退出…", message: "正在退出纯色测试…" });
|
||||
setDisplayTestFeedback("正在退出纯色测试…", "pending");
|
||||
try {
|
||||
await apiJson("/api/display/test", { method: "DELETE" });
|
||||
setDisplayTestFeedback();
|
||||
announce("已退出纯色测试,恢复进入测试前的设备状态");
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`退出测试失败:${error.message}`, "error");
|
||||
announce(displayTestFeedback.textContent, true);
|
||||
} finally {
|
||||
setControlReady(exitDisplayTest);
|
||||
}
|
||||
});
|
||||
|
||||
refreshStatusButton.addEventListener("click", async () => {
|
||||
setControlBusy(refreshStatusButton, { label: "正在刷新…" });
|
||||
announce("正在刷新设备状态…");
|
||||
try {
|
||||
await refreshStatus();
|
||||
announce("设备状态已刷新");
|
||||
} catch (_error) {
|
||||
// refreshStatus already reports the failure.
|
||||
} finally {
|
||||
setControlReady(refreshStatusButton);
|
||||
}
|
||||
});
|
||||
|
||||
registerWorkspace({
|
||||
id: "device",
|
||||
title: "设备状态和测试",
|
||||
elementId: "workspace-device",
|
||||
order: 10,
|
||||
persistence: "none",
|
||||
async onEnter() {
|
||||
if (!loadedOnce) {
|
||||
loadedOnce = true;
|
||||
try {
|
||||
const config = await apiJson("/api/config");
|
||||
customTestColor = normalizedHex(config.custom_test_color) || customTestColor;
|
||||
showTestColor(customTestColor);
|
||||
} catch (error) {
|
||||
announce(`自定义纯色读取失败:${error.message}`, true);
|
||||
}
|
||||
}
|
||||
await refreshStatus();
|
||||
await refreshDeviceStorage().catch(() => {});
|
||||
window.clearInterval(storageTimer);
|
||||
storageTimer = window.setInterval(() => refreshDeviceStorage().catch(() => {}), STORAGE_REFRESH_INTERVAL_MS);
|
||||
},
|
||||
onLeave() {
|
||||
window.clearInterval(storageTimer);
|
||||
storageTimer = null;
|
||||
},
|
||||
});
|
||||
|
||||
document.addEventListener("matrix:composition-applied", () => refreshStatus().catch(() => {}));
|
||||
@@ -0,0 +1,322 @@
|
||||
import { announce, apiJson } from "../core.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
|
||||
const selectedName = document.getElementById("frp-selected-name");
|
||||
const serviceState = document.getElementById("frp-service-state");
|
||||
const feedback = document.getElementById("frp-feedback");
|
||||
const serviceToggle = document.getElementById("frp-service-toggle");
|
||||
const editButton = document.getElementById("frp-edit-config");
|
||||
const diagnosticsButton = document.getElementById("network-diagnostics-run");
|
||||
const dialog = document.getElementById("frp-config-dialog");
|
||||
const configSelect = document.getElementById("frp-config-select");
|
||||
const configName = document.getElementById("frp-config-name");
|
||||
const configFormat = document.getElementById("frp-config-format");
|
||||
const configContent = document.getElementById("frp-config-content");
|
||||
const configFile = document.getElementById("frp-config-file");
|
||||
const configFeedback = document.getElementById("frp-config-feedback");
|
||||
const newButton = document.getElementById("frp-config-new");
|
||||
const uploadButton = document.getElementById("frp-config-upload");
|
||||
const deleteButton = document.getElementById("frp-config-delete");
|
||||
const closeButton = document.getElementById("frp-config-close");
|
||||
const saveButton = document.getElementById("frp-config-save");
|
||||
const diagnosticsDialog = document.getElementById("network-diagnostics-dialog");
|
||||
const diagnosticsSummary = document.getElementById("network-diagnostics-summary");
|
||||
const diagnosticsChecks = document.getElementById("network-diagnostics-checks");
|
||||
const diagnosticsClose = document.getElementById("network-diagnostics-close");
|
||||
|
||||
const SUPPORTED_SUFFIXES = new Set([".toml", ".yaml", ".yml", ".json", ".ini"]);
|
||||
const MAX_CONFIG_BYTES = 1024 * 1024;
|
||||
let state = null;
|
||||
let editingId = null;
|
||||
let generation = 0;
|
||||
|
||||
function setMessage(element, text, kind = "idle") {
|
||||
element.textContent = text;
|
||||
element.dataset.state = kind;
|
||||
element.hidden = !text;
|
||||
}
|
||||
|
||||
function profileById(id) {
|
||||
return state?.profiles?.find((profile) => profile.id === id) || null;
|
||||
}
|
||||
|
||||
function serviceLabel(service) {
|
||||
if (!service?.installed) return "frpc 未安装";
|
||||
if (service.running) return service.enabled ? "运行中(开机自动启动)" : "运行中";
|
||||
if (service.state === "failed") return "启动失败";
|
||||
return service.enabled ? "等待启动" : "关闭";
|
||||
}
|
||||
|
||||
function renderState(next) {
|
||||
state = next;
|
||||
const selected = profileById(next?.selected_id);
|
||||
selectedName.textContent = selected?.name || "无";
|
||||
serviceState.textContent = serviceLabel(next?.service);
|
||||
const running = next?.service?.running === true;
|
||||
serviceToggle.textContent = running || next?.service?.enabled ? "关闭frp" : "启动frp";
|
||||
if (!next?.available) {
|
||||
setControlUnavailable(serviceToggle, next?.error || "FRP 配置目录不可用。");
|
||||
setMessage(feedback, next?.error || "FRP 配置目录不可用。", "error");
|
||||
} else if (!next?.service?.installed) {
|
||||
setControlUnavailable(serviceToggle, "frpc 系统软件尚未安装。");
|
||||
setMessage(feedback, "frpc 系统软件尚未安装。", "error");
|
||||
} else if (!selected && !running) {
|
||||
setControlUnavailable(serviceToggle, "请先导入或新建 FRP 配置。");
|
||||
setMessage(feedback, "尚未保存 FRP 配置。", "idle");
|
||||
} else {
|
||||
setControlReady(serviceToggle);
|
||||
setMessage(feedback, next?.service?.last_error || "", next?.service?.last_error ? "error" : "idle");
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfileOptions() {
|
||||
configSelect.replaceChildren();
|
||||
if (!state?.profiles?.length) {
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = "暂无配置";
|
||||
configSelect.append(option);
|
||||
configSelect.disabled = true;
|
||||
} else {
|
||||
configSelect.disabled = false;
|
||||
state.profiles.forEach((profile) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.id;
|
||||
option.textContent = profile.name;
|
||||
configSelect.append(option);
|
||||
});
|
||||
configSelect.value = editingId || state.selected_id || state.profiles[0].id;
|
||||
}
|
||||
deleteButton.disabled = !editingId;
|
||||
}
|
||||
|
||||
async function requestText(path, options = {}) {
|
||||
const response = await fetch(path, { ...options, cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
let message = response.statusText;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function loadProfile(id) {
|
||||
const currentGeneration = generation;
|
||||
const profile = profileById(id);
|
||||
if (!profile) {
|
||||
editingId = null;
|
||||
configName.value = "";
|
||||
configContent.value = "";
|
||||
renderProfileOptions();
|
||||
return;
|
||||
}
|
||||
setMessage(configFeedback, "正在读取配置…");
|
||||
const content = await requestText(`/api/frp/configs/${encodeURIComponent(id)}`);
|
||||
if (generation !== currentGeneration) return;
|
||||
editingId = id;
|
||||
configName.value = profile.name;
|
||||
const suffix = profile.filename.slice(profile.filename.lastIndexOf(".")).toLowerCase();
|
||||
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
|
||||
configContent.value = content;
|
||||
renderProfileOptions();
|
||||
setMessage(configFeedback, "");
|
||||
}
|
||||
|
||||
export async function loadFrpSettings() {
|
||||
const currentGeneration = ++generation;
|
||||
try {
|
||||
const next = await apiJson("/api/frp");
|
||||
if (generation !== currentGeneration) return;
|
||||
renderState(next);
|
||||
} catch (error) {
|
||||
setMessage(feedback, `FRP 状态读取失败:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export function discardFrpDrafts() {
|
||||
generation += 1;
|
||||
editingId = null;
|
||||
configName.value = "";
|
||||
configContent.value = "";
|
||||
configFile.value = "";
|
||||
setMessage(configFeedback, "");
|
||||
if (dialog.open) dialog.close();
|
||||
if (diagnosticsDialog.open) diagnosticsDialog.close();
|
||||
}
|
||||
|
||||
editButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loadFrpSettings();
|
||||
editingId = state?.selected_id || state?.profiles?.[0]?.id || null;
|
||||
renderProfileOptions();
|
||||
if (editingId) await loadProfile(editingId);
|
||||
else {
|
||||
configName.value = "";
|
||||
configFormat.value = ".toml";
|
||||
configContent.value = "";
|
||||
}
|
||||
dialog.showModal();
|
||||
(editingId ? configContent : configName).focus();
|
||||
} catch (error) {
|
||||
announce(`打开 FRP 配置失败:${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
closeButton.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("close", () => {
|
||||
configFile.value = "";
|
||||
setMessage(configFeedback, "");
|
||||
});
|
||||
|
||||
newButton.addEventListener("click", () => {
|
||||
editingId = null;
|
||||
configSelect.value = "";
|
||||
configName.value = "";
|
||||
configFormat.value = ".toml";
|
||||
configContent.value = "";
|
||||
deleteButton.disabled = true;
|
||||
setMessage(configFeedback, "请输入完整配置;保存前会使用 frpc 官方校验。", "idle");
|
||||
configName.focus();
|
||||
});
|
||||
|
||||
uploadButton.addEventListener("click", () => configFile.click());
|
||||
configFile.addEventListener("change", async () => {
|
||||
const file = configFile.files?.[0];
|
||||
if (!file) return;
|
||||
const suffix = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
|
||||
if (!SUPPORTED_SUFFIXES.has(suffix)) {
|
||||
setMessage(configFeedback, "请选择 TOML、YAML、JSON 或 INI 配置文件。", "error");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_CONFIG_BYTES) {
|
||||
setMessage(configFeedback, "配置文件不能超过 1 MiB。", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await file.text();
|
||||
editingId = null;
|
||||
configName.value = file.name.slice(0, -suffix.length);
|
||||
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
|
||||
configContent.value = content;
|
||||
deleteButton.disabled = true;
|
||||
setMessage(configFeedback, "文件已读取,点击保存后执行官方校验。", "idle");
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `读取文件失败:${error.message}`, "error");
|
||||
} finally {
|
||||
configFile.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
configSelect.addEventListener("change", async () => {
|
||||
const id = configSelect.value;
|
||||
if (!id) return;
|
||||
try {
|
||||
if (id !== state?.selected_id) {
|
||||
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(id)}/select`, { method: "POST" });
|
||||
renderState(result.frp);
|
||||
}
|
||||
await loadProfile(id);
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `切换配置失败:${error.message}`, "error");
|
||||
renderProfileOptions();
|
||||
}
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", async () => {
|
||||
const name = configName.value.trim();
|
||||
const content = configContent.value;
|
||||
if (!name || !content.trim()) {
|
||||
setMessage(configFeedback, "请输入配置名称和完整配置内容。", "error");
|
||||
return;
|
||||
}
|
||||
setControlBusy(saveButton, { label: "正在校验…", message: "正在使用 frpc 校验配置" });
|
||||
try {
|
||||
const suffix = configFormat.value;
|
||||
const path = editingId
|
||||
? `/api/frp/configs/${encodeURIComponent(editingId)}?name=${encodeURIComponent(name)}`
|
||||
: `/api/frp/configs?filename=${encodeURIComponent(`frpc${suffix}`)}&name=${encodeURIComponent(name)}`;
|
||||
const response = await fetch(path, {
|
||||
method: editingId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: content,
|
||||
cache: "no-store",
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || response.statusText);
|
||||
editingId = result.profile.id;
|
||||
renderState(result.frp);
|
||||
renderProfileOptions();
|
||||
setMessage(configFeedback, result.frp.service.running ? "配置已保存并重启 FRP。" : "配置已保存并通过校验。", "success");
|
||||
announce("FRP 配置已保存");
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `配置保存失败:${error.message}`, "error");
|
||||
} finally {
|
||||
setControlReady(saveButton);
|
||||
}
|
||||
});
|
||||
|
||||
deleteButton.addEventListener("click", async () => {
|
||||
if (!editingId) return;
|
||||
const profile = profileById(editingId);
|
||||
if (!window.confirm(`确认删除 FRP 配置“${profile?.name || "未命名"}”?`)) return;
|
||||
setControlBusy(deleteButton, { label: "正在删除…" });
|
||||
try {
|
||||
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(editingId)}`, { method: "DELETE" });
|
||||
renderState(result.frp);
|
||||
editingId = result.frp.selected_id || result.frp.profiles?.[0]?.id || null;
|
||||
renderProfileOptions();
|
||||
if (editingId) await loadProfile(editingId);
|
||||
else {
|
||||
configName.value = "";
|
||||
configContent.value = "";
|
||||
}
|
||||
announce("FRP 配置已删除");
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `删除失败:${error.message}`, "error");
|
||||
} finally {
|
||||
setControlReady(deleteButton);
|
||||
}
|
||||
});
|
||||
|
||||
serviceToggle.addEventListener("click", async () => {
|
||||
const stopping = state?.service?.running || state?.service?.enabled;
|
||||
setControlBusy(serviceToggle, { label: stopping ? "正在关闭…" : "正在启动…" });
|
||||
try {
|
||||
const result = await apiJson(`/api/frp/service/${stopping ? "stop" : "start"}`, { method: "POST" });
|
||||
renderState(result.frp);
|
||||
announce(stopping ? "FRP 已关闭" : "FRP 已启动");
|
||||
} catch (error) {
|
||||
setMessage(feedback, `${stopping ? "关闭" : "启动"} FRP 失败:${error.message}`, "error");
|
||||
await loadFrpSettings();
|
||||
}
|
||||
});
|
||||
|
||||
diagnosticsButton.addEventListener("click", async () => {
|
||||
diagnosticsSummary.textContent = "正在检测网络,请稍候…";
|
||||
diagnosticsSummary.dataset.state = "idle";
|
||||
diagnosticsChecks.replaceChildren();
|
||||
diagnosticsDialog.showModal();
|
||||
setControlBusy(diagnosticsButton, { label: "正在检测…" });
|
||||
try {
|
||||
const result = await apiJson("/api/network/diagnostics", { method: "POST" });
|
||||
diagnosticsSummary.textContent = result.diagnosis;
|
||||
diagnosticsSummary.dataset.state = result.overall === "ok" ? "success" : "error";
|
||||
result.checks.forEach((check) => {
|
||||
const item = document.createElement("li");
|
||||
item.dataset.state = check.ok ? "success" : (check.warning ? "warning" : "error");
|
||||
item.textContent = `${check.ok ? "通过" : (check.warning ? "提示" : "失败")}:${check.message}`;
|
||||
diagnosticsChecks.append(item);
|
||||
});
|
||||
} catch (error) {
|
||||
diagnosticsSummary.textContent = `网络检测失败:${error.message}`;
|
||||
diagnosticsSummary.dataset.state = "error";
|
||||
} finally {
|
||||
setControlReady(diagnosticsButton);
|
||||
}
|
||||
});
|
||||
|
||||
diagnosticsClose.addEventListener("click", () => diagnosticsDialog.close());
|
||||
|
||||
document.addEventListener("matrix:status", (event) => {
|
||||
if (event.detail?.frp) renderState(event.detail.frp);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { announce, apiJson, registerWorkspace } from "../core.js";
|
||||
import { clampCropCenter, cropCenterBounds, cropGeometry } from "../media-crop-geometry.js";
|
||||
import { shouldReuseMediaJobCard } from "../media-import-ui-model.js";
|
||||
import { renderUiCopy } from "../ui-copy-runtime.js";
|
||||
|
||||
const form = document.getElementById("media-upload-form");
|
||||
const fileInput = document.getElementById("media-source-file");
|
||||
const uploadButton = document.getElementById("media-upload-button");
|
||||
const uploadProgress = document.getElementById("media-upload-progress");
|
||||
const refreshButton = document.getElementById("refresh-media-imports");
|
||||
const feedback = document.getElementById("media-import-feedback");
|
||||
const jobsRoot = document.getElementById("media-import-jobs");
|
||||
const stateNames = {
|
||||
uploading: ["copy.dynamic.media.state_uploading", "上传中"],
|
||||
analyzing: ["copy.dynamic.media.state_analyzing", "分析中"],
|
||||
awaiting_settings: ["copy.dynamic.media.state_awaiting", "等待设置"],
|
||||
queued: ["copy.dynamic.media.state_queued", "排队中"],
|
||||
converting: ["copy.dynamic.media.state_converting", "转换中"],
|
||||
failed: ["copy.dynamic.media.state_failed", "失败"],
|
||||
};
|
||||
let initialized = false;
|
||||
let pollingTimer = 0;
|
||||
let priorJobs = new Map();
|
||||
let emptyState = null;
|
||||
|
||||
function setFeedback(copyId, defaultText, values = {}, error = false) {
|
||||
renderUiCopy(copyId, feedback, defaultText, values);
|
||||
feedback.dataset.state = error ? "error" : "success";
|
||||
}
|
||||
|
||||
function makeButton(copyId, text, className = "") {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
renderUiCopy(copyId, element, text);
|
||||
if (className) element.className = className;
|
||||
return element;
|
||||
}
|
||||
|
||||
function labelControl(copyId, text, control) {
|
||||
const label = document.createElement("label");
|
||||
const span = document.createElement("span");
|
||||
renderUiCopy(copyId, span, text);
|
||||
label.append(span, control);
|
||||
return label;
|
||||
}
|
||||
|
||||
async function cancelJob(id) {
|
||||
try {
|
||||
await apiJson("/api/media-imports/" + id, { method: "DELETE" });
|
||||
setFeedback("copy.dynamic.media.deleted", "任务和源文件已删除。");
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.delete_failed", "删除任务失败:{error}", { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
function settingsCard(job, card) {
|
||||
const markDirty = () => { card.dataset.localDraft = "true"; };
|
||||
const stage = document.createElement("div");
|
||||
stage.className = "media-crop-stage";
|
||||
stage.dataset.fit = job.settings.fit_mode;
|
||||
const sourcePlane = document.createElement("div");
|
||||
sourcePlane.className = "media-crop-source";
|
||||
const image = document.createElement("img");
|
||||
image.alt = job.filename + " 代表帧";
|
||||
image.src = job.previews[0] || "";
|
||||
sourcePlane.append(image);
|
||||
stage.append(sourcePlane);
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "media-preview-tabs";
|
||||
job.previews.forEach((url, index) => {
|
||||
const tab = makeButton("copy.dynamic.media.preview", "代表帧 " + (index + 1));
|
||||
renderUiCopy("copy.dynamic.media.preview", tab, "代表帧 {index}", { index: index + 1 });
|
||||
tab.setAttribute("aria-pressed", index === 0 ? "true" : "false");
|
||||
tab.addEventListener("click", () => {
|
||||
markDirty();
|
||||
image.src = url;
|
||||
tabs.querySelectorAll("button").forEach((item) => {
|
||||
item.setAttribute("aria-pressed", item === tab ? "true" : "false");
|
||||
});
|
||||
});
|
||||
tabs.append(tab);
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "media-settings-grid";
|
||||
const name = document.createElement("input");
|
||||
name.type = "text"; name.maxLength = 80; name.value = job.name;
|
||||
const fit = document.createElement("select");
|
||||
[
|
||||
["crop", "copy.dynamic.media.mode_crop", "自由取景"],
|
||||
["contain", "copy.dynamic.media.mode_contain", "完整包含"],
|
||||
["stretch", "copy.dynamic.media.mode_stretch", "拉伸"],
|
||||
].forEach((pair) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = pair[0];
|
||||
renderUiCopy(pair[1], option, pair[2]);
|
||||
option.selected = pair[0] === job.settings.fit_mode;
|
||||
fit.append(option);
|
||||
});
|
||||
const zoom = document.createElement("input");
|
||||
zoom.type = "range"; zoom.min = "1"; zoom.max = "16";
|
||||
zoom.step = "0.05"; zoom.value = job.settings.zoom;
|
||||
const transparency = document.createElement("input");
|
||||
transparency.type = "color"; transparency.value = job.settings.transparency_color;
|
||||
const padding = document.createElement("input");
|
||||
padding.type = "color"; padding.value = job.settings.padding_color;
|
||||
grid.append(
|
||||
labelControl("copy.dynamic.media.output_name", "保存名称", name),
|
||||
labelControl("copy.dynamic.media.fit_mode", "1:1 适配", fit),
|
||||
labelControl("copy.dynamic.media.crop_zoom", "取景缩放", zoom),
|
||||
labelControl("copy.dynamic.media.transparency", "透明像素填充色", transparency),
|
||||
labelControl("copy.dynamic.media.padding", "留白颜色", padding),
|
||||
);
|
||||
|
||||
let centerX = Number(job.settings.center_x);
|
||||
let centerY = Number(job.settings.center_y);
|
||||
function sourceSize() {
|
||||
return {
|
||||
width: image.naturalWidth || 1,
|
||||
height: image.naturalHeight || 1,
|
||||
};
|
||||
}
|
||||
function cropBounds() {
|
||||
const source = sourceSize();
|
||||
return cropCenterBounds({
|
||||
viewportWidth: stage.clientWidth || 1,
|
||||
viewportHeight: stage.clientHeight || 1,
|
||||
sourceWidth: source.width,
|
||||
sourceHeight: source.height,
|
||||
zoom: Number(zoom.value),
|
||||
});
|
||||
}
|
||||
function clampCenter() {
|
||||
const clamped = clampCropCenter({ centerX, centerY }, cropBounds());
|
||||
centerX = clamped.centerX;
|
||||
centerY = clamped.centerY;
|
||||
}
|
||||
function updateCropVisual() {
|
||||
stage.dataset.fit = fit.value;
|
||||
stage.style.backgroundColor = padding.value;
|
||||
sourcePlane.style.backgroundColor = transparency.value;
|
||||
const source = sourceSize();
|
||||
if (fit.value === "stretch") {
|
||||
sourcePlane.style.width = "100%";
|
||||
sourcePlane.style.height = "100%";
|
||||
sourcePlane.style.left = "0";
|
||||
sourcePlane.style.top = "0";
|
||||
return;
|
||||
}
|
||||
if (fit.value === "crop") clampCenter();
|
||||
const geometry = cropGeometry({
|
||||
viewportWidth: stage.clientWidth || 1,
|
||||
viewportHeight: stage.clientHeight || 1,
|
||||
sourceWidth: source.width,
|
||||
sourceHeight: source.height,
|
||||
zoom: fit.value === "crop" ? Number(zoom.value) : 1,
|
||||
centerX: fit.value === "crop" ? centerX : 0.5,
|
||||
centerY: fit.value === "crop" ? centerY : 0.5,
|
||||
});
|
||||
sourcePlane.style.width = geometry.width + "px";
|
||||
sourcePlane.style.height = geometry.height + "px";
|
||||
sourcePlane.style.left = geometry.left + "px";
|
||||
sourcePlane.style.top = geometry.top + "px";
|
||||
}
|
||||
name.addEventListener("input", markDirty);
|
||||
fit.addEventListener("change", () => { markDirty(); updateCropVisual(); });
|
||||
zoom.addEventListener("input", () => { markDirty(); updateCropVisual(); });
|
||||
transparency.addEventListener("input", () => { markDirty(); updateCropVisual(); });
|
||||
padding.addEventListener("input", () => { markDirty(); updateCropVisual(); });
|
||||
let drag = null;
|
||||
stage.addEventListener("pointerdown", (event) => {
|
||||
if (fit.value !== "crop") return;
|
||||
drag = { x: event.clientX, y: event.clientY, centerX, centerY };
|
||||
stage.setPointerCapture(event.pointerId);
|
||||
});
|
||||
stage.addEventListener("pointermove", (event) => {
|
||||
if (!drag) return;
|
||||
markDirty();
|
||||
const source = sourceSize();
|
||||
const geometry = cropGeometry({
|
||||
viewportWidth: stage.clientWidth || 1,
|
||||
viewportHeight: stage.clientHeight || 1,
|
||||
sourceWidth: source.width,
|
||||
sourceHeight: source.height,
|
||||
zoom: Number(zoom.value), centerX: drag.centerX, centerY: drag.centerY,
|
||||
});
|
||||
centerX = drag.centerX - (event.clientX - drag.x) / geometry.width;
|
||||
centerY = drag.centerY - (event.clientY - drag.y) / geometry.height;
|
||||
clampCenter();
|
||||
updateCropVisual();
|
||||
});
|
||||
stage.addEventListener("pointerup", () => { drag = null; });
|
||||
stage.addEventListener("pointercancel", () => { drag = null; });
|
||||
image.addEventListener("load", updateCropVisual);
|
||||
const cropResizeObserver = new ResizeObserver(updateCropVisual);
|
||||
cropResizeObserver.observe(stage);
|
||||
card.mediaCropDispose = () => cropResizeObserver.disconnect();
|
||||
updateCropVisual();
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "media-job-actions";
|
||||
const convert = makeButton("copy.dynamic.media.convert", "保存设置并开始转换", "primary-button");
|
||||
const cancel = makeButton("copy.dynamic.media.cancel_source", "取消并删除源文件", "danger-button");
|
||||
actions.append(convert, cancel);
|
||||
convert.addEventListener("click", async () => {
|
||||
convert.disabled = true;
|
||||
try {
|
||||
await apiJson("/api/media-imports/" + job.id + "/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: name.value, fit_mode: fit.value, center_x: centerX, center_y: centerY,
|
||||
zoom: Number(zoom.value), transparency_color: transparency.value,
|
||||
padding_color: padding.value,
|
||||
}),
|
||||
});
|
||||
await apiJson("/api/media-imports/" + job.id + "/convert", { method: "POST", body: "{}" });
|
||||
setFeedback("copy.dynamic.media.queued", "任务已加入转换队列。");
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
if (error.message === "output name already exists") {
|
||||
setFeedback("copy.dynamic.media.name_conflict", "保存名称已存在,请更换名称。", {}, true);
|
||||
} else {
|
||||
setFeedback("copy.dynamic.media.convert_failed", "无法开始转换:{error}", { error: error.message }, true);
|
||||
}
|
||||
convert.disabled = false;
|
||||
}
|
||||
});
|
||||
cancel.addEventListener("click", () => cancelJob(job.id));
|
||||
card.append(tabs, stage, grid, actions);
|
||||
}
|
||||
|
||||
function renderJob(job) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "media-job-card";
|
||||
card.dataset.jobId = job.id;
|
||||
card.dataset.jobState = job.state;
|
||||
card.dataset.serverUpdatedAt = job.updated_at;
|
||||
card.dataset.localDraft = "false";
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "media-job-heading";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = job.name;
|
||||
const state = document.createElement("p");
|
||||
const stateCopy = stateNames[job.state];
|
||||
if (job.queue_position) {
|
||||
renderUiCopy("copy.dynamic.media.queue_position", state, "{state} · 队列第 {position} 位", {
|
||||
state: stateCopy ? stateCopy[1] : job.state, position: job.queue_position,
|
||||
});
|
||||
} else if (stateCopy) {
|
||||
renderUiCopy(stateCopy[0], state, stateCopy[1]);
|
||||
} else {
|
||||
state.textContent = job.state;
|
||||
}
|
||||
heading.append(title, state);
|
||||
const source = document.createElement("p");
|
||||
source.textContent = job.filename + " · " + (job.source_size / 1024 / 1024).toFixed(1) + " MiB";
|
||||
card.append(heading, source);
|
||||
if (job.state === "awaiting_settings") {
|
||||
settingsCard(job, card);
|
||||
return card;
|
||||
}
|
||||
const progress = document.createElement("progress");
|
||||
progress.max = 100; progress.value = job.progress || 0;
|
||||
card.append(progress);
|
||||
if (job.error) {
|
||||
const error = document.createElement("p");
|
||||
error.textContent = job.error; error.className = "settings-feedback";
|
||||
error.dataset.state = "error"; card.append(error);
|
||||
}
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "media-job-actions";
|
||||
if (job.state === "failed") {
|
||||
const retry = makeButton("copy.dynamic.media.retry", "重试", "primary-button");
|
||||
retry.addEventListener("click", async () => {
|
||||
try {
|
||||
await apiJson("/api/media-imports/" + job.id + "/retry", { method: "POST", body: "{}" });
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.retry_failed", "重试失败:{error}", { error: error.message }, true);
|
||||
}
|
||||
});
|
||||
actions.append(retry);
|
||||
}
|
||||
const cancel = makeButton("copy.dynamic.media.cancel", "取消并删除", "danger-button");
|
||||
cancel.addEventListener("click", () => cancelJob(job.id));
|
||||
actions.append(cancel); card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderedCardState(card) {
|
||||
if (!card) return null;
|
||||
return {
|
||||
id: card.dataset.jobId,
|
||||
state: card.dataset.jobState,
|
||||
updatedAt: card.dataset.serverUpdatedAt,
|
||||
dirty: card.dataset.localDraft === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileJobCards(jobs) {
|
||||
const oldCards = new Map(
|
||||
Array.from(jobsRoot.querySelectorAll(":scope > .media-job-card"))
|
||||
.map((card) => [card.dataset.jobId, card]),
|
||||
);
|
||||
if (jobs.length) {
|
||||
emptyState?.remove();
|
||||
jobs.forEach((job, index) => {
|
||||
const oldCard = oldCards.get(job.id) || null;
|
||||
const card = shouldReuseMediaJobCard(renderedCardState(oldCard), job)
|
||||
? oldCard
|
||||
: renderJob(job);
|
||||
if (oldCard && card !== oldCard) {
|
||||
oldCard.mediaCropDispose?.();
|
||||
oldCard.replaceWith(card);
|
||||
}
|
||||
const position = jobsRoot.children[index] || null;
|
||||
if (position !== card) jobsRoot.insertBefore(card, position);
|
||||
oldCards.delete(job.id);
|
||||
});
|
||||
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
|
||||
return;
|
||||
}
|
||||
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
|
||||
if (!emptyState) {
|
||||
emptyState = document.createElement("p");
|
||||
emptyState.className = "media-job-empty";
|
||||
renderUiCopy("copy.dynamic.media.empty", emptyState, "当前没有等待或转换中的任务。");
|
||||
}
|
||||
if (!emptyState.isConnected) jobsRoot.append(emptyState);
|
||||
}
|
||||
|
||||
async function refreshJobs() {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const result = await apiJson("/api/media-imports");
|
||||
const next = new Map(result.jobs.map((job) => [job.id, job]));
|
||||
for (const pair of priorJobs) {
|
||||
const id = pair[0], job = pair[1];
|
||||
if (!next.has(id) && ["queued", "converting"].includes(job.state)) {
|
||||
const message = "“" + job.name + "”转换完成,已加入内容库。";
|
||||
setFeedback("copy.dynamic.media.completed", "“{name}”转换完成,已加入内容库。", { name: job.name });
|
||||
announce(message);
|
||||
document.getElementById("refresh-templates")?.click();
|
||||
document.getElementById("refresh-animations")?.click();
|
||||
}
|
||||
}
|
||||
priorJobs = next;
|
||||
reconcileJobCards(result.jobs);
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.list_failed", "任务列表读取失败:{error}", { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
function upload(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/media-imports?filename=" + encodeURIComponent(file.name));
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.responseType = "json";
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) uploadProgress.value = event.loaded * 100 / event.total;
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
|
||||
else reject(new Error(xhr.response?.detail || xhr.statusText || "上传失败"));
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("上传连接中断")));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadButton.disabled = true; uploadProgress.hidden = false; uploadProgress.value = 0;
|
||||
try {
|
||||
await upload(file);
|
||||
form.reset();
|
||||
setFeedback("copy.dynamic.media.uploaded", "上传完成,设备正在分析媒体。");
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.upload_failed", "上传失败:{error}", { error: error.message }, true);
|
||||
} finally {
|
||||
uploadButton.disabled = false; uploadProgress.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
refreshButton.addEventListener("click", refreshJobs);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
window.clearInterval(pollingTimer);
|
||||
if (!document.hidden && initialized) {
|
||||
refreshJobs(); pollingTimer = window.setInterval(refreshJobs, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
if (!initialized) {
|
||||
initialized = true; pollingTimer = window.setInterval(refreshJobs, 2000);
|
||||
}
|
||||
await refreshJobs();
|
||||
}
|
||||
|
||||
registerWorkspace({
|
||||
id: "media-import", title: "媒体内容转换", elementId: "workspace-media-import",
|
||||
order: 45, persistence: "server",
|
||||
capabilities: ["media-upload", "background-conversion"], onEnter: initialize,
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,627 @@
|
||||
import { announce, apiJson, registerWorkspace } from "../core.js";
|
||||
import {
|
||||
animationEditState,
|
||||
enterTemplateEdit,
|
||||
getScene,
|
||||
markTemplateSaved,
|
||||
ordinarySceneNeedsTemplateSave,
|
||||
refreshCurrentDisplayPreview,
|
||||
templateEditState,
|
||||
} from "../scene-board.js";
|
||||
import { sceneToStorageValue } from "../scene-model.js";
|
||||
import { protectCardDragStart, reorderByKey } from "../sortable-card.js";
|
||||
import { resolveAnimationEditBeforeNavigation } from "./animations.js";
|
||||
import { refreshStatus } from "./device.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import { renderUiCopy } from "../ui-copy-runtime.js";
|
||||
import {
|
||||
createAnimationPreviewGroup,
|
||||
createAnimationThumbnailBox,
|
||||
} from "../animation-thumbnail-preview.js";
|
||||
|
||||
const grid = document.getElementById("template-grid");
|
||||
const feedback = document.getElementById("template-feedback");
|
||||
const refreshButton = document.getElementById("refresh-templates");
|
||||
const selectDefaultButton = document.getElementById("select-default-content");
|
||||
const saveButton = document.getElementById("save-template");
|
||||
const nameDialog = document.getElementById("template-name-dialog");
|
||||
const nameDialogTitle = document.getElementById("template-dialog-title");
|
||||
const nameDialogNote = document.getElementById("template-dialog-note");
|
||||
const nameInput = document.getElementById("template-name-input");
|
||||
const confirmDialog = document.getElementById("template-confirm-dialog");
|
||||
const confirmMessage = document.getElementById("template-confirm-message");
|
||||
const saveChoiceDialog = document.getElementById("template-save-choice-dialog");
|
||||
const saveChoiceMessage = document.getElementById("template-save-choice-message");
|
||||
const saveCurrentButton = document.getElementById("template-save-current");
|
||||
const copyDialog = document.getElementById("library-copy-dialog");
|
||||
const copyTarget = document.getElementById("library-copy-target");
|
||||
|
||||
let loading = false;
|
||||
let animationList = [];
|
||||
let activeAnimation = null;
|
||||
let libraryOrder = { items: [], revision: "" };
|
||||
let draggingLibraryKey = null;
|
||||
let defaultContent = null;
|
||||
let selectingDefault = false;
|
||||
const demoRestrictedMessage = "演示案例不得编辑,请复制";
|
||||
const libraryDragType = "application/x-matrix-library-item";
|
||||
const templateAnimationPreviewGroup = createAnimationPreviewGroup();
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = bytes / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && size >= 1024; index += 1) {
|
||||
size /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
function askName(title, initial = "", { note = "" } = {}) {
|
||||
nameDialogTitle.textContent = title;
|
||||
nameDialogNote.textContent = note;
|
||||
nameDialogNote.hidden = !note;
|
||||
nameInput.value = initial;
|
||||
nameInput.required = true;
|
||||
nameDialog.returnValue = "";
|
||||
nameDialog.showModal();
|
||||
window.setTimeout(() => {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}, 0);
|
||||
return new Promise((resolve) => {
|
||||
nameDialog.addEventListener("close", () => {
|
||||
resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null);
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirmation(message, { copyId = "", values = {} } = {}) {
|
||||
if (copyId) renderUiCopy(copyId, confirmMessage, message, values);
|
||||
else confirmMessage.textContent = message;
|
||||
confirmDialog.returnValue = "";
|
||||
confirmDialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
confirmDialog.addEventListener("close", () => {
|
||||
resolve(confirmDialog.returnValue === "confirm");
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function actionButton(label, action, className = "") {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
if (className) button.className = className;
|
||||
button.addEventListener("click", () => action(button));
|
||||
return button;
|
||||
}
|
||||
|
||||
function restrictDemoAction(control, message = demoRestrictedMessage) {
|
||||
control.classList.add("demo-restricted-action");
|
||||
setControlUnavailable(control, message);
|
||||
return control;
|
||||
}
|
||||
|
||||
function syncSaveButton() {
|
||||
if (!animationEditState() && templateEditState()) {
|
||||
renderUiCopy("copy.dynamic.template.save_bound", saveButton, "保存模板");
|
||||
} else {
|
||||
renderUiCopy("copy.dynamic.template.save_new", saveButton, "保存为模板");
|
||||
}
|
||||
}
|
||||
|
||||
function askSaveChoice(context) {
|
||||
renderUiCopy(
|
||||
"copy.dynamic.template.save_choice",
|
||||
saveChoiceMessage,
|
||||
"请选择“{name}”的保存方式。",
|
||||
{ name: context.name },
|
||||
);
|
||||
saveCurrentButton.classList.remove("demo-restricted-action");
|
||||
setControlReady(saveCurrentButton);
|
||||
if (context.readOnly) {
|
||||
restrictDemoAction(saveCurrentButton, "演示案例不得覆盖,请另存为新模板");
|
||||
}
|
||||
saveChoiceDialog.returnValue = "";
|
||||
saveChoiceDialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
saveChoiceDialog.addEventListener("close", () => {
|
||||
resolve(["current", "new"].includes(saveChoiceDialog.returnValue)
|
||||
? saveChoiceDialog.returnValue
|
||||
: "cancel");
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmOrdinarySceneReplacement(name) {
|
||||
if (!ordinarySceneNeedsTemplateSave()) return true;
|
||||
return askConfirmation(
|
||||
"当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
|
||||
{
|
||||
copyId: "copy.dynamic.template.unsaved_replace",
|
||||
values: { name },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
loading = value;
|
||||
if (value) {
|
||||
setControlBusy(refreshButton);
|
||||
setControlBusy(selectDefaultButton);
|
||||
setControlBusy(saveButton);
|
||||
grid.setAttribute("aria-busy", "true");
|
||||
} else {
|
||||
setControlReady(refreshButton);
|
||||
setControlReady(selectDefaultButton);
|
||||
setControlReady(saveButton);
|
||||
grid.removeAttribute("aria-busy");
|
||||
}
|
||||
grid.toggleAttribute("inert", value);
|
||||
}
|
||||
|
||||
function isDefaultContent(type, id) {
|
||||
return defaultContent?.type === type && defaultContent?.id === id;
|
||||
}
|
||||
|
||||
function syncDefaultSelectionButton() {
|
||||
selectDefaultButton.setAttribute("aria-pressed", String(selectingDefault));
|
||||
selectDefaultButton.classList.toggle("active", selectingDefault);
|
||||
selectDefaultButton.textContent = selectingDefault ? "取消设置默认内容" : "设置默认显示内容";
|
||||
}
|
||||
|
||||
async function chooseDefaultContent(type, item) {
|
||||
await mutate(async () => {
|
||||
defaultContent = await apiJson("/api/display/default-content", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ type, id: item.id }),
|
||||
});
|
||||
selectingDefault = false;
|
||||
syncDefaultSelectionButton();
|
||||
await refreshStatus();
|
||||
}, `已将“${item.name}”设为默认显示内容并立即显示`);
|
||||
}
|
||||
|
||||
function decorateDefaultContent(card, type, item, { available = true } = {}) {
|
||||
if (isDefaultContent(type, item.id)) {
|
||||
card.classList.add("template-default-content");
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "template-default-badge";
|
||||
badge.textContent = "当前默认";
|
||||
card.insertBefore(badge, card.querySelector(".template-card-content-actions"));
|
||||
}
|
||||
if (!selectingDefault) return card;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "template-card-actions template-card-default-actions";
|
||||
const choose = actionButton(
|
||||
isDefaultContent(type, item.id) ? "当前默认" : "设为默认",
|
||||
() => chooseDefaultContent(type, item),
|
||||
"primary-button",
|
||||
);
|
||||
if (isDefaultContent(type, item.id)) {
|
||||
setControlUnavailable(choose, "这项已经是当前默认显示内容。");
|
||||
} else if (!available) {
|
||||
setControlUnavailable(choose, "空动图不能设为默认,请先添加至少一帧。");
|
||||
}
|
||||
actions.append(choose);
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
async function mutate(action, successMessage) {
|
||||
setBusy(true);
|
||||
feedback.textContent = "正在处理模板操作…";
|
||||
feedback.dataset.state = "pending";
|
||||
try {
|
||||
await action();
|
||||
announce(successMessage);
|
||||
setBusy(false);
|
||||
await refreshTemplates();
|
||||
} catch (error) {
|
||||
const conflict = error.code !== "APP_STALE" && (error.status === 409 || error.status === 428);
|
||||
feedback.textContent = conflict
|
||||
? "模板已被其他页面修改,列表已刷新;请检查最新内容后重新操作。"
|
||||
: error.message;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
if (conflict) {
|
||||
setBusy(false);
|
||||
await refreshTemplates();
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function appendContentActions(card, { play, edit, copy, rename, remove }) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "template-card-actions template-card-content-actions";
|
||||
remove.classList.add("template-card-delete-action");
|
||||
actions.append(play, edit, copy, rename, remove);
|
||||
card.append(actions);
|
||||
}
|
||||
|
||||
async function playStaticTemplate(template) {
|
||||
await mutate(async () => {
|
||||
await apiJson(`/api/templates/${template.id}/play`, {
|
||||
method: "POST",
|
||||
headers: { "If-Match": `"${template.revision}"` },
|
||||
body: "{}",
|
||||
});
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
}, `开始播放“${template.name}”`);
|
||||
}
|
||||
|
||||
async function editStaticTemplate(template) {
|
||||
if (!await resolveAnimationEditBeforeNavigation()) return;
|
||||
if (!await confirmOrdinarySceneReplacement(template.name)) return;
|
||||
setBusy(true);
|
||||
feedback.textContent = `正在读取“${template.name}”…`;
|
||||
feedback.dataset.state = "pending";
|
||||
try {
|
||||
const full = await apiJson(`/api/templates/${template.id}`);
|
||||
enterTemplateEdit(full);
|
||||
window.location.hash = "#canvas";
|
||||
announce(`正在编辑模板“${template.name}”`);
|
||||
} catch (error) {
|
||||
feedback.textContent = `模板读取失败:${error.message}`;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(template) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "template-card";
|
||||
|
||||
const imageBox = document.createElement("div");
|
||||
imageBox.className = "template-image-box";
|
||||
const image = document.createElement("img");
|
||||
image.className = "template-image";
|
||||
image.src = template.thumbnail_url;
|
||||
image.alt = `${template.name} 模板缩略图`;
|
||||
image.width = 64;
|
||||
image.height = 64;
|
||||
image.draggable = false;
|
||||
image.loading = "lazy";
|
||||
image.addEventListener("error", () => {
|
||||
image.hidden = true;
|
||||
imageBox.classList.add("template-image-error");
|
||||
imageBox.setAttribute("aria-label", "缩略图加载失败");
|
||||
});
|
||||
imageBox.appendChild(image);
|
||||
|
||||
const name = document.createElement("h3");
|
||||
name.className = "template-card-name";
|
||||
name.textContent = template.name;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "content-type-badge";
|
||||
badge.textContent = "静态";
|
||||
const size = document.createElement("p");
|
||||
size.className = "template-card-size";
|
||||
size.textContent = `占用 ${formatBytes(template.size_bytes)}`;
|
||||
|
||||
const play = actionButton("播放", () => playStaticTemplate(template), "primary-button");
|
||||
const edit = actionButton("编辑", () => editStaticTemplate(template));
|
||||
const copy = actionButton("复制", (control) => copyStaticTemplate(template, control));
|
||||
const rename = actionButton("重命名", async () => {
|
||||
const nextName = await askName("重命名模板", template.name);
|
||||
if (!nextName || nextName === template.name) return;
|
||||
mutate(() => apiJson(`/api/templates/${template.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "If-Match": `"${template.revision}"` },
|
||||
body: JSON.stringify({ name: nextName }),
|
||||
}), `模板已重命名为“${nextName}”`);
|
||||
});
|
||||
const remove = actionButton("删除", async () => {
|
||||
if (!await askConfirmation(`永久删除模板“${template.name}”及其缩略图吗?`)) return;
|
||||
mutate(() => apiJson(`/api/templates/${template.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "If-Match": `"${template.revision}"` },
|
||||
}), `模板“${template.name}”已删除`);
|
||||
}, "danger-button");
|
||||
if (template.read_only) {
|
||||
restrictDemoAction(rename);
|
||||
restrictDemoAction(remove);
|
||||
}
|
||||
|
||||
card.append(imageBox, badge, name, size);
|
||||
appendContentActions(card, { play, edit, copy, rename, remove });
|
||||
return decorateDefaultContent(card, "template", template);
|
||||
}
|
||||
|
||||
async function chooseCopyTarget() {
|
||||
const data = await apiJson("/api/animations");
|
||||
copyTarget.replaceChildren(new Option("静态模板", "static"), ...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}:${item.revision}`)));
|
||||
copyDialog.returnValue = ""; copyDialog.showModal();
|
||||
await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true }));
|
||||
if (copyDialog.returnValue !== "copy") return null;
|
||||
const [type, animationId, revision] = copyTarget.value.split(":");
|
||||
return { type, animationId, revision };
|
||||
}
|
||||
|
||||
async function copyStaticTemplate(template, control) {
|
||||
setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" });
|
||||
try {
|
||||
const target = await chooseCopyTarget(); if (!target) return;
|
||||
await mutate(() => apiJson("/api/library/copy", { method: "POST", body: JSON.stringify({
|
||||
source_type: "template", source_id: template.id, source_revision: template.revision,
|
||||
destination_type: target.type, destination_animation_id: target.animationId || null, destination_revision: target.revision || null,
|
||||
}) }), `模板“${template.name}”已复制`);
|
||||
} catch (error) {
|
||||
feedback.textContent = `复制目标读取失败:${error.message}`;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnimationCard(animation) {
|
||||
const card = document.createElement("article"); card.className = "template-card";
|
||||
const imageBox = createAnimationThumbnailBox(templateAnimationPreviewGroup, animation);
|
||||
const badge = document.createElement("span"); badge.className = "content-type-badge content-type-animation"; badge.textContent = "动图";
|
||||
const name = document.createElement("h3"); name.className = "template-card-name animation-folder-name"; name.textContent = animation.name;
|
||||
name.dataset.hoverTooltip = animation.name;
|
||||
const active = activeAnimation?.id === animation.id;
|
||||
const meta = document.createElement("p"); meta.className = "template-card-size"; meta.textContent = `${animation.frame_count} 帧 · ${(animation.total_duration_ms / 1000).toFixed(2)} 秒 · ${formatBytes(animation.size_bytes)}${active ? activeAnimation.revision === animation.revision ? " · 正在播放" : " · 正在播放旧版本" : ""}`;
|
||||
const play = actionButton("播放", () => mutate(async () => {
|
||||
await apiJson(`/api/animations/${animation.id}/play`, {
|
||||
method: "POST",
|
||||
headers: { "If-Match": `"${animation.revision}"` },
|
||||
body: "{}",
|
||||
});
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
}, `开始播放“${animation.name}”`), "primary-button");
|
||||
if (!animation.frame_count) setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。");
|
||||
const copy = actionButton("复制", () => mutate(() => apiJson(`/api/animations/${animation.id}/copy`, { method: "POST", body: "{}" }), `动图“${animation.name}”已复制`));
|
||||
const edit = actionButton("编辑", () => { window.location.hash = "#animations"; document.dispatchEvent(new CustomEvent("matrix:open-animation", { detail: { id: animation.id } })); });
|
||||
const rename = actionButton("重命名", async () => { const next = await askName("重命名动图", animation.name); if (next) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "PATCH", headers: { "If-Match": `"${animation.revision}"` }, body: JSON.stringify({ name: next }) }), "动图已重命名"); });
|
||||
const remove = actionButton("删除", async () => { if (await askConfirmation(`永久删除动图“${animation.name}”及全部帧吗?`)) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "DELETE", headers: { "If-Match": `"${animation.revision}"` } }), "动图已删除"); }, "danger-button");
|
||||
if (animation.read_only) {
|
||||
restrictDemoAction(edit);
|
||||
restrictDemoAction(rename);
|
||||
restrictDemoAction(remove);
|
||||
}
|
||||
card.append(imageBox, badge, name, meta);
|
||||
appendContentActions(card, { play, edit, copy, rename, remove });
|
||||
return decorateDefaultContent(card, "animation", animation, { available: animation.frame_count > 0 });
|
||||
}
|
||||
|
||||
function libraryItemKey(item) {
|
||||
return `${item.type}:${item.id}`;
|
||||
}
|
||||
|
||||
function clearLibraryDragIndicators() {
|
||||
grid.querySelectorAll(".library-drop-target").forEach((card) => card.classList.remove("library-drop-target"));
|
||||
}
|
||||
|
||||
function saveLibraryOrder(keys) {
|
||||
const byKey = new Map(libraryOrder.items.map((item) => [libraryItemKey(item), item]));
|
||||
const items = keys.map((key) => byKey.get(key)).filter(Boolean);
|
||||
if (items.length !== libraryOrder.items.length) return;
|
||||
mutate(() => apiJson("/api/library/order", {
|
||||
method: "PUT",
|
||||
headers: { "If-Match": `"${libraryOrder.revision}"` },
|
||||
body: JSON.stringify({ items }),
|
||||
}), "模板顺序已更新");
|
||||
}
|
||||
|
||||
function moveLibraryItem(index, offset) {
|
||||
const keys = libraryOrder.items.map(libraryItemKey);
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= keys.length) return;
|
||||
[keys[index], keys[target]] = [keys[target], keys[index]];
|
||||
saveLibraryOrder(keys);
|
||||
}
|
||||
|
||||
function prepareDemoCard(card) {
|
||||
card.classList.add("library-demo-fixed");
|
||||
card.querySelectorAll("img").forEach((image) => { image.draggable = false; });
|
||||
protectCardDragStart(card, { locked: true });
|
||||
const orderActions = document.createElement("div");
|
||||
orderActions.className = "template-card-actions template-card-order-actions";
|
||||
const moveUp = restrictDemoAction(
|
||||
actionButton("上移", () => {}),
|
||||
"演示案例固定在最前,不能移动。",
|
||||
);
|
||||
const moveDown = restrictDemoAction(
|
||||
actionButton("下移", () => {}),
|
||||
"演示案例固定在最前,不能移动。",
|
||||
);
|
||||
orderActions.append(moveUp, moveDown);
|
||||
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
|
||||
return card;
|
||||
}
|
||||
|
||||
function prepareSortableLibraryCard(card, item, index, total) {
|
||||
const key = libraryItemKey(item);
|
||||
card.classList.add("sortable-card", "library-sortable-card");
|
||||
card.dataset.libraryKey = key;
|
||||
protectCardDragStart(card);
|
||||
|
||||
const orderActions = document.createElement("div");
|
||||
orderActions.className = "template-card-actions template-card-order-actions";
|
||||
const moveUp = actionButton("上移", () => moveLibraryItem(index, -1));
|
||||
const moveDown = actionButton("下移", () => moveLibraryItem(index, 1));
|
||||
if (index === 0) setControlUnavailable(moveUp, "两个演示案例固定在最前,当前已经是第一项。");
|
||||
if (index === total - 1) setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
|
||||
orderActions.append(moveUp, moveDown);
|
||||
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
|
||||
|
||||
card.addEventListener("dragstart", (event) => {
|
||||
if (event.defaultPrevented) return;
|
||||
draggingLibraryKey = key;
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(libraryDragType, key);
|
||||
event.dataTransfer.setData("text/plain", key);
|
||||
});
|
||||
card.addEventListener("dragover", (event) => {
|
||||
if (!draggingLibraryKey || draggingLibraryKey === key) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
clearLibraryDragIndicators();
|
||||
card.classList.add("library-drop-target");
|
||||
});
|
||||
card.addEventListener("dragleave", (event) => {
|
||||
if (!card.contains(event.relatedTarget)) card.classList.remove("library-drop-target");
|
||||
});
|
||||
card.addEventListener("drop", (event) => {
|
||||
if (!draggingLibraryKey) return;
|
||||
event.preventDefault();
|
||||
const source = event.dataTransfer.getData(libraryDragType) || draggingLibraryKey;
|
||||
const keys = libraryOrder.items.map(libraryItemKey);
|
||||
const reordered = reorderByKey(keys, source, key);
|
||||
clearLibraryDragIndicators();
|
||||
if (reordered) saveLibraryOrder(reordered);
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
draggingLibraryKey = null;
|
||||
clearLibraryDragIndicators();
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
export async function refreshTemplates() {
|
||||
if (loading) return;
|
||||
setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取模板和动图…" });
|
||||
feedback.textContent = "正在读取模板…";
|
||||
feedback.dataset.state = "pending";
|
||||
try {
|
||||
const [data, animations, order, selectedDefault] = await Promise.all([
|
||||
apiJson("/api/templates?include_demo=true"),
|
||||
apiJson("/api/animations?include_demo=true"),
|
||||
apiJson("/api/library/order"),
|
||||
apiJson("/api/display/default-content"),
|
||||
]);
|
||||
defaultContent = selectedDefault;
|
||||
animationList = animations.animations;
|
||||
libraryOrder = order;
|
||||
const demoCards = [
|
||||
...data.templates.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "static" })),
|
||||
...animationList.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "animation" })),
|
||||
].sort((left, right) => left.demo_order - right.demo_order);
|
||||
const userItems = [
|
||||
...data.templates.filter((item) => !item.is_demo).map((item) => ({ type: "template", id: item.id, value: item })),
|
||||
...animationList.filter((item) => !item.is_demo).map((item) => ({ type: "animation", id: item.id, value: item })),
|
||||
];
|
||||
const userByKey = new Map(userItems.map((item) => [libraryItemKey(item), item]));
|
||||
const orderedUsers = order.items.map((item) => userByKey.get(libraryItemKey(item))).filter(Boolean);
|
||||
templateAnimationPreviewGroup.clear();
|
||||
const renderedDemos = demoCards.map((item) => prepareDemoCard(item.content_type === "static" ? renderCard(item) : renderAnimationCard(item)));
|
||||
const renderedUsers = orderedUsers.map((item, index) => prepareSortableLibraryCard(
|
||||
item.type === "template" ? renderCard(item.value) : renderAnimationCard(item.value),
|
||||
item,
|
||||
index,
|
||||
orderedUsers.length,
|
||||
));
|
||||
grid.replaceChildren(...renderedDemos, ...renderedUsers);
|
||||
if (!userItems.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "template-empty panel";
|
||||
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。";
|
||||
grid.appendChild(empty);
|
||||
}
|
||||
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
|
||||
feedback.dataset.state = "ready";
|
||||
announce(feedback.textContent);
|
||||
} catch (error) {
|
||||
feedback.textContent = `模板读取失败:${error.message}`;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
} finally {
|
||||
setControlReady(refreshButton);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
|
||||
const name = await askName("保存为模板", initial, { note });
|
||||
if (!name) return;
|
||||
await mutate(async () => {
|
||||
const result = await apiJson("/api/templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scene: sceneToStorageValue(getScene()) }),
|
||||
});
|
||||
if (bind) markTemplateSaved(result);
|
||||
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-save" } }));
|
||||
return result;
|
||||
}, `模板“${name}”已保存`);
|
||||
}
|
||||
|
||||
async function saveSceneToCurrentTemplate(context) {
|
||||
await mutate(async () => {
|
||||
const result = await apiJson(`/api/templates/${context.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "If-Match": `"${context.revision}"` },
|
||||
body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }),
|
||||
});
|
||||
markTemplateSaved(result);
|
||||
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-update" } }));
|
||||
return result;
|
||||
}, `模板“${context.name}”已保存`);
|
||||
}
|
||||
|
||||
saveButton.addEventListener("click", async () => {
|
||||
if (animationEditState()) {
|
||||
await saveSceneAsNew({
|
||||
note: "当前帧将被保存为一个静态图模板",
|
||||
bind: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const context = templateEditState();
|
||||
if (!context) {
|
||||
await saveSceneAsNew();
|
||||
return;
|
||||
}
|
||||
const choice = await askSaveChoice(context);
|
||||
if (choice === "current") {
|
||||
await saveSceneToCurrentTemplate(context);
|
||||
} else if (choice === "new") {
|
||||
await saveSceneAsNew({ initial: `${context.name} - 副本` });
|
||||
}
|
||||
});
|
||||
|
||||
refreshButton.addEventListener("click", refreshTemplates);
|
||||
selectDefaultButton.addEventListener("click", async () => {
|
||||
if (loading) return;
|
||||
selectingDefault = !selectingDefault;
|
||||
syncDefaultSelectionButton();
|
||||
announce(selectingDefault ? "请选择下方内容设为默认显示" : "已取消设置默认显示内容");
|
||||
await refreshTemplates();
|
||||
});
|
||||
document.addEventListener("matrix:library-change", refreshTemplates);
|
||||
document.addEventListener("matrix:template-edit-change", syncSaveButton);
|
||||
document.addEventListener("matrix:animation-edit-change", syncSaveButton);
|
||||
document.addEventListener("matrix:status", (event) => {
|
||||
const state = event.detail?.state;
|
||||
activeAnimation = state?.animation_active ? { id: state.animation_id, revision: state.animation_revision } : null;
|
||||
});
|
||||
|
||||
registerWorkspace({
|
||||
id: "templates",
|
||||
title: "模板管理",
|
||||
elementId: "workspace-templates",
|
||||
order: 60,
|
||||
persistence: "server",
|
||||
onEnter: async () => {
|
||||
templateAnimationPreviewGroup.setActive(true);
|
||||
await refreshTemplates();
|
||||
},
|
||||
onLeave: () => {
|
||||
templateAnimationPreviewGroup.setActive(false);
|
||||
templateAnimationPreviewGroup.clear();
|
||||
},
|
||||
});
|
||||
|
||||
syncDefaultSelectionButton();
|
||||
syncSaveButton();
|
||||
@@ -0,0 +1,349 @@
|
||||
import { announce, apiFile, apiJson, registerWorkspace } from "../core.js";
|
||||
import { refreshColorTrigger } from "../color-picker.js";
|
||||
import { createFontPicker } from "../font-picker.js";
|
||||
import { renderUiCopy } from "../ui-copy-runtime.js";
|
||||
import {
|
||||
runControlAction,
|
||||
setControlReady,
|
||||
setControlUnavailable,
|
||||
} from "../interaction-feedback.js";
|
||||
import {
|
||||
addTextElement,
|
||||
deleteTextElement,
|
||||
duplicateTextElement,
|
||||
getScene,
|
||||
getSelectedTextElement,
|
||||
selectTextElement,
|
||||
subscribeScene,
|
||||
updateTextElement,
|
||||
} from "../scene-board.js";
|
||||
|
||||
const form = document.getElementById("text-form");
|
||||
const fields = document.getElementById("text-fields");
|
||||
const contentFields = document.getElementById("text-content-fields");
|
||||
const list = document.getElementById("text-element-list");
|
||||
const selectionStatus = document.getElementById("text-selection-status");
|
||||
const duplicateButton = document.getElementById("duplicate-text");
|
||||
const deleteButton = document.getElementById("delete-text");
|
||||
const colorTrigger = document.getElementById("text-color");
|
||||
const fontInput = document.getElementById("text-font");
|
||||
const fontOptions = document.getElementById("text-font-options");
|
||||
const importFontButton = document.getElementById("import-font");
|
||||
const fontFileInput = document.getElementById("font-file");
|
||||
const fontFeedback = document.getElementById("font-feedback");
|
||||
let initialized = false;
|
||||
let suppressForm = false;
|
||||
let renderedSelectionId = null;
|
||||
let defaults = { font: "default", size: 12 };
|
||||
let fontCatalogReady = false;
|
||||
let fontMaxUploadBytes = 32 * 1024 * 1024;
|
||||
let acceptedFontExtensions = [".ttf", ".otf", ".ttc", ".otc"];
|
||||
|
||||
const fontPicker = createFontPicker({
|
||||
input: fontInput,
|
||||
listbox: fontOptions,
|
||||
onCommit(item) {
|
||||
const selected = getSelectedTextElement();
|
||||
if (!selected) return;
|
||||
updateTextElement(selected.id, { font: item.id }, "form");
|
||||
setFontFeedbackTemplate(
|
||||
"copy.dynamic.text.font_selected",
|
||||
"已选择字体“{name}”",
|
||||
{ name: item.label },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function field(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function setFontFeedback(message = "", isError = false) {
|
||||
fontFeedback.textContent = message;
|
||||
fontFeedback.hidden = !message;
|
||||
fontFeedback.dataset.state = isError ? "error" : "success";
|
||||
}
|
||||
|
||||
function setFontFeedbackTemplate(copyId, defaultText, values = {}, isError = false) {
|
||||
renderUiCopy(copyId, fontFeedback, defaultText, values);
|
||||
fontFeedback.hidden = false;
|
||||
fontFeedback.dataset.state = isError ? "error" : "success";
|
||||
}
|
||||
|
||||
function updateFontPickerAvailability(element = getSelectedTextElement()) {
|
||||
if (!fontCatalogReady) {
|
||||
fontPicker.setEnabled(false);
|
||||
setControlUnavailable(fontInput, "字体目录尚未加载完成。");
|
||||
} else if (!element) {
|
||||
fontPicker.setEnabled(false);
|
||||
setControlUnavailable(fontInput, "请先新建或选择一个文字元素;字体导入仍可使用。");
|
||||
} else {
|
||||
setControlReady(fontInput);
|
||||
fontPicker.setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
function applyElementToFields(element) {
|
||||
suppressForm = true;
|
||||
if (element) {
|
||||
field("text-value").value = element.text;
|
||||
fontPicker.setValue(element.font);
|
||||
field("text-size").value = String(element.size);
|
||||
field("text-x").value = String(element.x);
|
||||
field("text-y").value = String(element.y);
|
||||
field("text-align").value = element.align;
|
||||
colorTrigger.value = element.color;
|
||||
} else {
|
||||
field("text-value").value = "";
|
||||
fontPicker.setValue(defaults.font);
|
||||
field("text-size").value = String(defaults.size);
|
||||
field("text-x").value = "32";
|
||||
field("text-y").value = "26";
|
||||
field("text-align").value = "center";
|
||||
colorTrigger.value = "#FFFFFF";
|
||||
}
|
||||
refreshColorTrigger(colorTrigger);
|
||||
contentFields.toggleAttribute("disabled", !element);
|
||||
fields.toggleAttribute("disabled", !element);
|
||||
updateFontPickerAvailability(element);
|
||||
renderedSelectionId = element?.id || null;
|
||||
suppressForm = false;
|
||||
}
|
||||
|
||||
function displayName(element, index) {
|
||||
const text = element.text.trim() || "(空文字)";
|
||||
return `${index + 1}. ${text.replace(/\s+/g, " ").slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function renderElementList(scene, selectedId) {
|
||||
const items = scene.elements.map((element, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "text-element-item";
|
||||
button.textContent = displayName(element, index);
|
||||
button.title = `${element.text || "空文字"} · ${element.size}px · ${element.color}`;
|
||||
const selected = element.id === selectedId;
|
||||
button.classList.toggle("active", selected);
|
||||
button.setAttribute("aria-pressed", String(selected));
|
||||
button.addEventListener("click", () => selectTextElement(element.id));
|
||||
return button;
|
||||
});
|
||||
list.replaceChildren(...items);
|
||||
}
|
||||
|
||||
function renderSelection({ scene, selectedTextId, reason }) {
|
||||
const selected = scene.elements.find((element) => element.id === selectedTextId) || null;
|
||||
renderElementList(scene, selectedTextId);
|
||||
if (selected) {
|
||||
setControlReady(duplicateButton);
|
||||
setControlReady(deleteButton);
|
||||
} else {
|
||||
setControlUnavailable(duplicateButton, "请先新建或选择一个文字元素。");
|
||||
setControlUnavailable(deleteButton, "请先新建或选择一个文字元素。");
|
||||
}
|
||||
selectionStatus.textContent = selected
|
||||
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
|
||||
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
|
||||
const shouldRefreshFields = renderedSelectionId !== selectedTextId
|
||||
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
|
||||
if (shouldRefreshFields) applyElementToFields(selected);
|
||||
}
|
||||
|
||||
function numericValue(id, fallback) {
|
||||
const raw = field(id).value.trim();
|
||||
const value = Number(raw);
|
||||
return raw && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function formPatch(element) {
|
||||
return {
|
||||
text: field("text-value").value,
|
||||
font: fontPicker.getValue() || "default",
|
||||
size: numericValue("text-size", element.size),
|
||||
x: numericValue("text-x", element.x),
|
||||
y: numericValue("text-y", element.y),
|
||||
align: field("text-align").value,
|
||||
color: colorTrigger.value,
|
||||
};
|
||||
}
|
||||
|
||||
form.addEventListener("input", (event) => {
|
||||
if (event.target === fontInput || event.target === fontFileInput) return;
|
||||
if (suppressForm) return;
|
||||
const selected = getSelectedTextElement();
|
||||
if (!selected) return;
|
||||
updateTextElement(selected.id, formPatch(selected), "form");
|
||||
});
|
||||
|
||||
document.getElementById("create-text").addEventListener("click", () => {
|
||||
const created = addTextElement({
|
||||
text: "ok",
|
||||
font: defaults.font,
|
||||
size: defaults.size,
|
||||
x: 32,
|
||||
y: 26,
|
||||
align: "center",
|
||||
color: "#FFFFFF",
|
||||
});
|
||||
announce(`已创建文字“${created.text}”,编辑完成后再应用统一画板`);
|
||||
});
|
||||
|
||||
duplicateButton.addEventListener("click", () => {
|
||||
const duplicate = duplicateTextElement();
|
||||
if (duplicate) announce(`已复制文字“${duplicate.text}”并置于最上层`);
|
||||
});
|
||||
|
||||
deleteButton.addEventListener("click", () => {
|
||||
const selected = getSelectedTextElement();
|
||||
if (selected && deleteTextElement(selected.id)) announce(`已删除文字“${selected.text || "空文字"}”`);
|
||||
});
|
||||
|
||||
subscribeScene(renderSelection);
|
||||
|
||||
async function loadFontCatalog() {
|
||||
const catalog = await apiJson("/api/fonts");
|
||||
fontPicker.setItems(catalog.items);
|
||||
acceptedFontExtensions = Array.isArray(catalog.accepted_extensions)
|
||||
? catalog.accepted_extensions.map((value) => String(value).toLowerCase())
|
||||
: acceptedFontExtensions;
|
||||
fontMaxUploadBytes = Number(catalog.max_upload_bytes) || fontMaxUploadBytes;
|
||||
fontCatalogReady = true;
|
||||
updateFontPickerAvailability();
|
||||
if (catalog.warnings?.length) {
|
||||
setFontFeedback(catalog.warnings.join(";"), true);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function validateFontFile(file) {
|
||||
const extension = file.name.includes(".")
|
||||
? `.${file.name.split(".").at(-1).toLowerCase()}`
|
||||
: "";
|
||||
if (!acceptedFontExtensions.includes(extension)) {
|
||||
return "type";
|
||||
}
|
||||
if (file.size > fontMaxUploadBytes) {
|
||||
return "size";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function describeFontImportError(error) {
|
||||
if (error?.code === "APP_STALE") {
|
||||
return ["copy.dynamic.text.font_error_stale", "页面版本已过期,请刷新后再导入字体。"];
|
||||
}
|
||||
if (error?.code === "REQUEST_TIMEOUT") {
|
||||
return ["copy.dynamic.text.font_error_timeout", "字体导入超时,请检查核桃派网络或服务状态后重试。"];
|
||||
}
|
||||
const messages = {
|
||||
413: ["copy.dynamic.text.font_error_too_large", "字体导入失败:文件超过 32 MiB。"],
|
||||
415: ["copy.dynamic.text.font_error_type", "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。"],
|
||||
422: ["copy.dynamic.text.font_error_invalid", "字体导入失败:文件损坏或不包含可用字体。"],
|
||||
507: ["copy.dynamic.text.font_error_storage", "字体导入失败:设备存储空间不足。"],
|
||||
};
|
||||
if (messages[error?.status]) return messages[error.status];
|
||||
if (error instanceof TypeError) {
|
||||
return ["copy.dynamic.text.font_error_network", "字体导入失败:无法连接设备,请检查网络后重试。"];
|
||||
}
|
||||
return ["copy.dynamic.text.font_error_unknown", "字体导入失败:服务器未能完成导入,请稍后重试。"];
|
||||
}
|
||||
|
||||
importFontButton.addEventListener("click", () => fontFileInput.click());
|
||||
|
||||
fontFileInput.addEventListener("change", async () => {
|
||||
const file = fontFileInput.files?.[0];
|
||||
fontFileInput.value = "";
|
||||
if (!file) return;
|
||||
const validationError = validateFontFile(file);
|
||||
if (validationError) {
|
||||
const copy = validationError === "type"
|
||||
? ["copy.dynamic.text.font_type_error", "只支持 TTF、OTF、TTC、OTC 字体文件。"]
|
||||
: ["copy.dynamic.text.font_size_error", "字体文件不能超过 32 MiB。"];
|
||||
setFontFeedbackTemplate(copy[0], copy[1], {}, true);
|
||||
announce(fontFeedback.textContent, true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setFontFeedbackTemplate(
|
||||
"copy.dynamic.text.font_importing",
|
||||
"正在导入字体“{name}”…",
|
||||
{ name: file.name },
|
||||
);
|
||||
announce(fontFeedback.textContent);
|
||||
const result = await runControlAction(
|
||||
importFontButton,
|
||||
{ label: "正在导入…" },
|
||||
async () => apiFile(
|
||||
`/api/fonts/import?filename=${encodeURIComponent(file.name)}`,
|
||||
file,
|
||||
{ method: "POST" },
|
||||
),
|
||||
);
|
||||
await loadFontCatalog();
|
||||
const selected = getSelectedTextElement();
|
||||
const imported = result.fonts?.[0] || null;
|
||||
if (selected && imported) {
|
||||
fontPicker.setValue(imported.id);
|
||||
updateTextElement(selected.id, { font: imported.id }, "form");
|
||||
}
|
||||
const name = imported?.label || file.name;
|
||||
const feedback = result.created
|
||||
? (selected
|
||||
? ["copy.dynamic.text.font_imported_applied", "字体“{name}”已导入并应用到当前文字"]
|
||||
: ["copy.dynamic.text.font_imported", "字体“{name}”已导入"])
|
||||
: (selected
|
||||
? ["copy.dynamic.text.font_exists_applied", "字体“{name}”已经存在,已应用到当前文字"]
|
||||
: ["copy.dynamic.text.font_exists", "字体“{name}”已经存在"]);
|
||||
setFontFeedbackTemplate(feedback[0], feedback[1], { name });
|
||||
announce(fontFeedback.textContent);
|
||||
} catch (error) {
|
||||
const feedback = describeFontImportError(error);
|
||||
setFontFeedbackTemplate(feedback[0], feedback[1], {}, true);
|
||||
announce(fontFeedback.textContent, true);
|
||||
}
|
||||
});
|
||||
|
||||
async function initializeTextWorkspace() {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
const [configResult, fontsResult] = await Promise.allSettled([
|
||||
apiJson("/api/config"),
|
||||
loadFontCatalog(),
|
||||
]);
|
||||
if (configResult.status === "fulfilled") {
|
||||
const config = configResult.value;
|
||||
defaults = {
|
||||
font: config.default_font || "default",
|
||||
size: Number(config.default_text_size) || 12,
|
||||
};
|
||||
} else {
|
||||
announce(`默认文字设置读取失败,已使用内置默认值:${configResult.reason.message}`, true);
|
||||
}
|
||||
if (fontsResult.status === "rejected") {
|
||||
fontCatalogReady = false;
|
||||
updateFontPickerAvailability();
|
||||
setFontFeedbackTemplate(
|
||||
"copy.dynamic.text.font_catalog_failed",
|
||||
"字体目录读取失败,当前字体值已保留:{error}",
|
||||
{ error: fontsResult.reason.message },
|
||||
true,
|
||||
);
|
||||
announce(fontFeedback.textContent, true);
|
||||
}
|
||||
}
|
||||
if (!getSelectedTextElement()) {
|
||||
const topmost = getScene().elements.at(-1);
|
||||
if (topmost) selectTextElement(topmost.id);
|
||||
else applyElementToFields(null);
|
||||
}
|
||||
}
|
||||
|
||||
registerWorkspace({
|
||||
id: "text",
|
||||
title: "文字显示",
|
||||
elementId: "workspace-text",
|
||||
order: 40,
|
||||
persistence: "local",
|
||||
capabilities: ["scene-template-save"],
|
||||
onEnter: initializeTextWorkspace,
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { announce, apiJson } from "../core.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import { createLatchedDraftState } from "../wifi-draft-state.js";
|
||||
|
||||
const ssidInput = document.getElementById("wifi-ssid");
|
||||
const passwordInput = document.getElementById("wifi-password");
|
||||
const dhcpToggle = document.getElementById("wifi-dhcp-enabled");
|
||||
const dhcpState = document.getElementById("wifi-dhcp-state");
|
||||
const dhcpRow = dhcpToggle.closest(".wifi-dhcp-row");
|
||||
const manualFields = document.getElementById("wifi-manual-fields");
|
||||
const addressInput = document.getElementById("wifi-address");
|
||||
const gatewayInput = document.getElementById("wifi-gateway");
|
||||
const prefixInput = document.getElementById("wifi-prefix");
|
||||
const dnsInput = document.getElementById("wifi-dns");
|
||||
const immediateButton = document.getElementById("wifi-apply-immediate");
|
||||
const nextBootButton = document.getElementById("wifi-apply-next-boot");
|
||||
const activeStatus = document.getElementById("wifi-active-status");
|
||||
const feedback = document.getElementById("wifi-feedback");
|
||||
const delayInput = document.getElementById("wifi-prompt-delay");
|
||||
const delaySaveButton = document.getElementById("wifi-prompt-delay-save");
|
||||
const delayFeedback = document.getElementById("wifi-prompt-feedback");
|
||||
|
||||
const networkFields = new Map([
|
||||
["ssid", { control: ssidInput, marker: document.getElementById("wifi-ssid-modified"), highlight: ssidInput }],
|
||||
["password", { control: passwordInput, marker: document.getElementById("wifi-password-modified"), highlight: passwordInput }],
|
||||
["dhcp", { control: dhcpToggle, marker: document.getElementById("wifi-dhcp-modified"), highlight: dhcpRow }],
|
||||
["address", { control: addressInput, marker: document.getElementById("wifi-address-modified"), highlight: addressInput }],
|
||||
["gateway", { control: gatewayInput, marker: document.getElementById("wifi-gateway-modified"), highlight: gatewayInput }],
|
||||
["prefix", { control: prefixInput, marker: document.getElementById("wifi-prefix-modified"), highlight: prefixInput }],
|
||||
["dns", { control: dnsInput, marker: document.getElementById("wifi-dns-modified"), highlight: dnsInput }],
|
||||
]);
|
||||
const networkDraft = createLatchedDraftState(networkFields.keys());
|
||||
const delayDraft = createLatchedDraftState(["prompt_delay_seconds"]);
|
||||
|
||||
let wifiPending = false;
|
||||
let delayPending = false;
|
||||
let settingsLoaded = false;
|
||||
let loadGeneration = 0;
|
||||
let baseline = null;
|
||||
|
||||
function setFeedback(message, state = "idle") {
|
||||
feedback.textContent = message;
|
||||
feedback.dataset.state = state;
|
||||
feedback.hidden = !message;
|
||||
}
|
||||
|
||||
function setDelayFeedback(message, state = "idle") {
|
||||
delayFeedback.textContent = message;
|
||||
delayFeedback.dataset.state = state;
|
||||
delayFeedback.hidden = !message;
|
||||
}
|
||||
|
||||
function showDhcpMode() {
|
||||
const enabled = dhcpToggle.checked;
|
||||
dhcpState.textContent = enabled ? "开启" : "关闭";
|
||||
manualFields.hidden = enabled;
|
||||
addressInput.required = !enabled;
|
||||
gatewayInput.required = !enabled;
|
||||
}
|
||||
|
||||
function splitDns(value) {
|
||||
return value.split(/[,,\s]+/u).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function showStatus(data) {
|
||||
const active = data.active || {};
|
||||
if (active.connected) {
|
||||
activeStatus.textContent = `当前已连接:${active.ssid || "未知 WiFi"} · ${active.ipv4_address || "等待 IPv4"}`;
|
||||
} else if (data.available === false) {
|
||||
activeStatus.textContent = `网络状态不可用:${data.error || "NetworkManager 无响应"}`;
|
||||
} else {
|
||||
activeStatus.textContent = "当前未连接 WiFi";
|
||||
}
|
||||
}
|
||||
|
||||
function formStateFromResponse(data) {
|
||||
const saved = data.saved || {};
|
||||
return {
|
||||
ssid: saved.ssid || "",
|
||||
password: saved.password || "",
|
||||
passwordConfigured: Boolean(saved.password_configured),
|
||||
dhcp: (saved.ipv4_mode || "dhcp") === "dhcp",
|
||||
address: saved.address || "",
|
||||
gateway: saved.gateway || "",
|
||||
prefix: saved.prefix ?? "",
|
||||
dns: (saved.dns_servers || []).join(", "),
|
||||
promptDelay: data.prompt_delay_seconds ?? 30,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFormState(state) {
|
||||
if (!state) return;
|
||||
ssidInput.value = state.ssid;
|
||||
passwordInput.value = state.password;
|
||||
passwordInput.placeholder = state.passwordConfigured ? "" : "请输入 8..63 字节密码";
|
||||
dhcpToggle.checked = state.dhcp;
|
||||
addressInput.value = state.address;
|
||||
gatewayInput.value = state.gateway;
|
||||
prefixInput.value = state.prefix;
|
||||
dnsInput.value = state.dns;
|
||||
delayInput.value = String(state.promptDelay);
|
||||
showDhcpMode();
|
||||
}
|
||||
|
||||
function setNetworkMarker(key, modified) {
|
||||
const field = networkFields.get(key);
|
||||
field.marker.hidden = !modified;
|
||||
field.highlight.classList.toggle("wifi-field-modified", modified);
|
||||
}
|
||||
|
||||
function clearNetworkDraft() {
|
||||
networkDraft.clear();
|
||||
networkFields.forEach((_field, key) => setNetworkMarker(key, false));
|
||||
updateNetworkButtons();
|
||||
}
|
||||
|
||||
function clearDelayDraft() {
|
||||
delayDraft.clear();
|
||||
updateDelayButton();
|
||||
}
|
||||
|
||||
function updateNetworkButtons() {
|
||||
if (wifiPending) return;
|
||||
const ready = settingsLoaded && networkDraft.dirty;
|
||||
immediateButton.classList.toggle("wifi-action-ready", ready);
|
||||
nextBootButton.classList.toggle("wifi-action-ready", ready);
|
||||
if (ready) {
|
||||
setControlReady(immediateButton);
|
||||
setControlReady(nextBootButton);
|
||||
return;
|
||||
}
|
||||
const reason = settingsLoaded
|
||||
? "请先修改 WiFi 或 DHCP 设置,再选择生效方式。"
|
||||
: "请等待当前 WiFi 设置读取完成。";
|
||||
setControlUnavailable(immediateButton, reason);
|
||||
setControlUnavailable(nextBootButton, reason);
|
||||
}
|
||||
|
||||
function updateDelayButton() {
|
||||
if (delayPending) return;
|
||||
const ready = settingsLoaded && delayDraft.dirty;
|
||||
delaySaveButton.classList.toggle("wifi-action-ready", ready);
|
||||
if (ready) {
|
||||
setControlReady(delaySaveButton);
|
||||
return;
|
||||
}
|
||||
setControlUnavailable(
|
||||
delaySaveButton,
|
||||
settingsLoaded ? "请先修改网络提示等待时间。" : "请等待当前网络提示设置读取完成。",
|
||||
);
|
||||
}
|
||||
|
||||
function markNetworkField(key) {
|
||||
if (!settingsLoaded) return;
|
||||
networkDraft.mark(key);
|
||||
setNetworkMarker(key, true);
|
||||
updateNetworkButtons();
|
||||
}
|
||||
|
||||
function markDelayField() {
|
||||
if (!settingsLoaded) return;
|
||||
delayDraft.mark("prompt_delay_seconds");
|
||||
updateDelayButton();
|
||||
}
|
||||
|
||||
function syncForm(data) {
|
||||
baseline = formStateFromResponse(data);
|
||||
applyFormState(baseline);
|
||||
settingsLoaded = true;
|
||||
clearNetworkDraft();
|
||||
clearDelayDraft();
|
||||
showStatus(data);
|
||||
}
|
||||
|
||||
export async function loadWifiSettings() {
|
||||
const generation = ++loadGeneration;
|
||||
settingsLoaded = false;
|
||||
clearNetworkDraft();
|
||||
clearDelayDraft();
|
||||
try {
|
||||
const data = await apiJson("/api/network/wifi");
|
||||
if (generation !== loadGeneration) return;
|
||||
syncForm(data);
|
||||
setFeedback("");
|
||||
setDelayFeedback("");
|
||||
} catch (error) {
|
||||
if (generation !== loadGeneration) return;
|
||||
setFeedback(`WiFi 设置读取失败:${error.message}`, "error");
|
||||
announce(`WiFi 设置读取失败:${error.message}`, true);
|
||||
updateNetworkButtons();
|
||||
updateDelayButton();
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequest(activation) {
|
||||
const ssid = ssidInput.value.trim();
|
||||
if (!ssid) throw new Error("请输入 WiFi 名称(SSID)");
|
||||
const password = passwordInput.value;
|
||||
if (ssid !== baseline.ssid && !password) throw new Error("修改 WiFi 名称时必须填写新密码");
|
||||
if (!password && !baseline.passwordConfigured) throw new Error("当前没有已保存密码,请填写 WiFi 密码");
|
||||
|
||||
const manual = !dhcpToggle.checked;
|
||||
if (manual && !addressInput.value.trim()) throw new Error("关闭 DHCP 后必须填写静态 IPv4 地址");
|
||||
if (manual && !gatewayInput.value.trim()) throw new Error("关闭 DHCP 后必须填写网关");
|
||||
const rawPrefix = prefixInput.value.trim();
|
||||
const prefix = rawPrefix ? Number(rawPrefix) : null;
|
||||
if (rawPrefix && (!Number.isInteger(prefix) || prefix < 1 || prefix > 32)) {
|
||||
throw new Error("子网前缀必须是 1..32 的整数");
|
||||
}
|
||||
return {
|
||||
ssid,
|
||||
password: password || null,
|
||||
ipv4_mode: manual ? "manual" : "dhcp",
|
||||
address: manual ? addressInput.value.trim() : null,
|
||||
gateway: manual ? gatewayInput.value.trim() : null,
|
||||
prefix,
|
||||
dns_servers: manual ? splitDns(dnsInput.value) : [],
|
||||
activation,
|
||||
};
|
||||
}
|
||||
|
||||
function captureNetworkBaseline(request) {
|
||||
baseline = {
|
||||
...baseline,
|
||||
ssid: request.ssid,
|
||||
password: request.password || baseline.password,
|
||||
passwordConfigured: Boolean(request.password || baseline.passwordConfigured),
|
||||
dhcp: request.ipv4_mode === "dhcp",
|
||||
address: request.address || "",
|
||||
gateway: request.gateway || "",
|
||||
prefix: request.prefix ?? "",
|
||||
dns: (request.dns_servers || []).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyWifi(activation) {
|
||||
if (wifiPending || !networkDraft.dirty) return;
|
||||
let request;
|
||||
try {
|
||||
request = buildRequest(activation);
|
||||
} catch (error) {
|
||||
setFeedback(error.message, "error");
|
||||
announce(error.message, true);
|
||||
return;
|
||||
}
|
||||
wifiPending = true;
|
||||
setControlBusy(immediateButton, {
|
||||
label: activation === "immediate" ? "正在保存…" : undefined,
|
||||
message: "正在安全保存 WiFi 与 IPv4 配置…",
|
||||
});
|
||||
setControlBusy(nextBootButton, {
|
||||
label: activation === "next_boot" ? "正在保存…" : undefined,
|
||||
});
|
||||
setFeedback(
|
||||
activation === "immediate"
|
||||
? "正在保存;设备随后会切换网络,当前页面可能断开。"
|
||||
: "正在保存下次断电开机使用的网络配置…",
|
||||
"pending",
|
||||
);
|
||||
try {
|
||||
const result = await apiJson("/api/network/wifi", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
captureNetworkBaseline(request);
|
||||
clearNetworkDraft();
|
||||
setFeedback(result.message, "success");
|
||||
announce(result.message);
|
||||
if (result.network) showStatus(result.network);
|
||||
} catch (error) {
|
||||
setFeedback(`WiFi 设置保存失败:${error.message}`, "error");
|
||||
announce(`WiFi 设置保存失败:${error.message}`, true);
|
||||
} finally {
|
||||
wifiPending = false;
|
||||
updateNetworkButtons();
|
||||
}
|
||||
}
|
||||
|
||||
function validatedPromptDelay() {
|
||||
const delay = Number(delayInput.value);
|
||||
if (!Number.isInteger(delay) || delay < 1 || delay > 3600) {
|
||||
throw new Error("网络提示等待时间必须是 1..3600 秒的整数");
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
async function savePromptDelay() {
|
||||
if (delayPending || !delayDraft.dirty) return;
|
||||
let delay;
|
||||
try {
|
||||
delay = validatedPromptDelay();
|
||||
} catch (error) {
|
||||
setDelayFeedback(error.message, "error");
|
||||
announce(error.message, true);
|
||||
return;
|
||||
}
|
||||
delayPending = true;
|
||||
setControlBusy(delaySaveButton, {
|
||||
label: "正在保存…",
|
||||
message: "正在保存网络提示等待时间…",
|
||||
});
|
||||
setDelayFeedback("正在保存网络提示等待时间…", "pending");
|
||||
try {
|
||||
const result = await apiJson("/api/network/wifi/prompt-delay", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ prompt_delay_seconds: delay }),
|
||||
});
|
||||
baseline = { ...baseline, promptDelay: result.prompt_delay_seconds };
|
||||
delayInput.value = String(result.prompt_delay_seconds);
|
||||
clearDelayDraft();
|
||||
setDelayFeedback(result.message, "success");
|
||||
announce(result.message);
|
||||
} catch (error) {
|
||||
setDelayFeedback(`网络提示等待时间保存失败:${error.message}`, "error");
|
||||
announce(`网络提示等待时间保存失败:${error.message}`, true);
|
||||
} finally {
|
||||
delayPending = false;
|
||||
updateDelayButton();
|
||||
}
|
||||
}
|
||||
|
||||
export function discardWifiSettingsDrafts() {
|
||||
loadGeneration += 1;
|
||||
applyFormState(baseline);
|
||||
settingsLoaded = false;
|
||||
clearNetworkDraft();
|
||||
clearDelayDraft();
|
||||
setFeedback("");
|
||||
setDelayFeedback("");
|
||||
}
|
||||
|
||||
for (const [key, field] of networkFields) {
|
||||
if (key === "dhcp") continue;
|
||||
field.control.addEventListener("input", () => markNetworkField(key));
|
||||
}
|
||||
dhcpToggle.addEventListener("change", () => {
|
||||
showDhcpMode();
|
||||
markNetworkField("dhcp");
|
||||
});
|
||||
delayInput.addEventListener("input", markDelayField);
|
||||
immediateButton.addEventListener("click", () => applyWifi("immediate"));
|
||||
nextBootButton.addEventListener("click", () => applyWifi("next_boot"));
|
||||
delaySaveButton.addEventListener("click", savePromptDelay);
|
||||
updateNetworkButtons();
|
||||
updateDelayButton();
|
||||
@@ -0,0 +1,25 @@
|
||||
export function createLatchedDraftState(allowedKeys) {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const modified = new Set();
|
||||
|
||||
return Object.freeze({
|
||||
mark(key) {
|
||||
if (!allowed.has(key)) throw new Error(`Unknown draft field: ${key}`);
|
||||
const wasModified = modified.has(key);
|
||||
modified.add(key);
|
||||
return !wasModified;
|
||||
},
|
||||
clear() {
|
||||
modified.clear();
|
||||
},
|
||||
has(key) {
|
||||
return modified.has(key);
|
||||
},
|
||||
get dirty() {
|
||||
return modified.size > 0;
|
||||
},
|
||||
keys() {
|
||||
return [...modified];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export function resolveWorkspaceOrder(definitions, savedOrder) {
|
||||
const defaults = Array.from(definitions || []);
|
||||
const byId = new Map(defaults.map((definition) => [definition.id, definition]));
|
||||
const resolved = [];
|
||||
const seen = new Set();
|
||||
|
||||
if (Array.isArray(savedOrder)) {
|
||||
savedOrder.forEach((workspaceId) => {
|
||||
if (seen.has(workspaceId) || !byId.has(workspaceId)) return;
|
||||
seen.add(workspaceId);
|
||||
resolved.push(byId.get(workspaceId));
|
||||
});
|
||||
}
|
||||
|
||||
defaults.forEach((definition) => {
|
||||
if (seen.has(definition.id)) return;
|
||||
seen.add(definition.id);
|
||||
resolved.push(definition);
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function moveWorkspaceOrder(workspaceIds, index, offset) {
|
||||
if (!Array.isArray(workspaceIds) || !Number.isInteger(index) || !Number.isInteger(offset)) {
|
||||
return null;
|
||||
}
|
||||
const target = index + offset;
|
||||
if (index < 0 || index >= workspaceIds.length || target < 0 || target >= workspaceIds.length) {
|
||||
return null;
|
||||
}
|
||||
const moved = [...workspaceIds];
|
||||
[moved[index], moved[target]] = [moved[target], moved[index]];
|
||||
return moved;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Host-system controls used by the matrix service."""
|
||||
@@ -0,0 +1,257 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PerformanceModeError(RuntimeError):
|
||||
"""Raised when cpufreq policies cannot be changed safely."""
|
||||
|
||||
|
||||
class PerformanceModeManager:
|
||||
RECORD_NAME = "cpufreq-governors.original.json"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime_root: Path,
|
||||
*,
|
||||
sysfs_root: Path | None = None,
|
||||
) -> None:
|
||||
configured_root = os.environ.get("MATRIX_CPUFREQ_SYSFS_ROOT")
|
||||
self.sysfs_root = Path(
|
||||
sysfs_root
|
||||
if sysfs_root is not None
|
||||
else configured_root or "/sys/devices/system/cpu/cpufreq"
|
||||
)
|
||||
self.runtime_root = Path(runtime_root)
|
||||
self.record_path = self.runtime_root / self.RECORD_NAME
|
||||
self._lock = threading.RLock()
|
||||
self._restore_governors: dict[str, str] = {}
|
||||
self._requested = False
|
||||
self._last_error: str | None = None
|
||||
self._load_or_capture_baseline()
|
||||
|
||||
def _policy_paths(self) -> dict[str, Path]:
|
||||
if not self.sysfs_root.is_dir():
|
||||
return {}
|
||||
return {
|
||||
path.name: path
|
||||
for path in sorted(self.sysfs_root.glob("policy*"), key=lambda item: item.name)
|
||||
if path.is_dir()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="ascii").strip()
|
||||
|
||||
@staticmethod
|
||||
def _write_text(path: Path, value: str) -> None:
|
||||
path.write_text(f"{value}\n", encoding="ascii")
|
||||
|
||||
def _read_governors(self) -> dict[str, str]:
|
||||
return {
|
||||
name: self._read_text(path / "scaling_governor")
|
||||
for name, path in self._policy_paths().items()
|
||||
}
|
||||
|
||||
def _available_governors(self) -> dict[str, set[str]]:
|
||||
result: dict[str, set[str]] = {}
|
||||
for name, path in self._policy_paths().items():
|
||||
available_path = path / "scaling_available_governors"
|
||||
try:
|
||||
result[name] = set(self._read_text(available_path).split())
|
||||
except OSError:
|
||||
result[name] = set()
|
||||
return result
|
||||
|
||||
def _write_record(self, governors: dict[str, str]) -> None:
|
||||
self.runtime_root.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.record_path.with_suffix(".tmp")
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"governors": governors,
|
||||
}
|
||||
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, self.record_path)
|
||||
|
||||
def _read_record(self) -> dict[str, str] | None:
|
||||
if not self.record_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(self.record_path.read_text(encoding="utf-8"))
|
||||
governors = payload.get("governors")
|
||||
if payload.get("schema_version") != 1 or not isinstance(governors, dict):
|
||||
raise ValueError("invalid governor record")
|
||||
checked = {
|
||||
str(name): str(value)
|
||||
for name, value in governors.items()
|
||||
if str(name).startswith("policy") and str(value).strip()
|
||||
}
|
||||
if checked != governors:
|
||||
raise ValueError("invalid governor record fields")
|
||||
return checked
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
self._last_error = f"saved governor record is invalid: {exc}"
|
||||
return None
|
||||
|
||||
def _load_or_capture_baseline(self) -> None:
|
||||
with self._lock:
|
||||
policies = self._policy_paths()
|
||||
if not policies:
|
||||
return
|
||||
saved = self._read_record()
|
||||
if saved is not None:
|
||||
if set(saved) == set(policies):
|
||||
self._restore_governors = saved
|
||||
return
|
||||
self._last_error = "saved governor policies do not match current cpufreq policies"
|
||||
return
|
||||
try:
|
||||
current = self._read_governors()
|
||||
self._write_record(current)
|
||||
self._restore_governors = current
|
||||
except OSError as exc:
|
||||
self._last_error = f"could not capture original governors: {exc}"
|
||||
|
||||
def _set_governors(self, requested: dict[str, str]) -> None:
|
||||
policies = self._policy_paths()
|
||||
if set(requested) != set(policies):
|
||||
raise PerformanceModeError("cpufreq policies changed while applying governor")
|
||||
before = self._read_governors()
|
||||
changed: list[str] = []
|
||||
try:
|
||||
for name, value in requested.items():
|
||||
self._write_text(policies[name] / "scaling_governor", value)
|
||||
changed.append(name)
|
||||
actual = self._read_text(policies[name] / "scaling_governor")
|
||||
if actual != value:
|
||||
raise PerformanceModeError(
|
||||
f"{name} governor is {actual!r} after requesting {value!r}"
|
||||
)
|
||||
except Exception as exc:
|
||||
rollback_errors: list[str] = []
|
||||
for name in reversed(changed):
|
||||
try:
|
||||
self._write_text(policies[name] / "scaling_governor", before[name])
|
||||
except OSError as rollback_exc:
|
||||
rollback_errors.append(f"{name}: {rollback_exc}")
|
||||
suffix = f"; rollback failed for {', '.join(rollback_errors)}" if rollback_errors else ""
|
||||
raise PerformanceModeError(f"could not apply cpufreq governor: {exc}{suffix}") from exc
|
||||
|
||||
def available(self) -> bool:
|
||||
policies = self._policy_paths()
|
||||
if not policies or set(self._restore_governors) != set(policies):
|
||||
return False
|
||||
available = self._available_governors()
|
||||
return all("performance" in values for values in available.values())
|
||||
|
||||
def start(self, requested: bool) -> None:
|
||||
with self._lock:
|
||||
self._requested = requested
|
||||
if not requested:
|
||||
if self._restore_governors and self._read_governors() != self._restore_governors:
|
||||
try:
|
||||
self._set_governors(self._restore_governors)
|
||||
self._last_error = None
|
||||
except PerformanceModeError as exc:
|
||||
self._last_error = str(exc)
|
||||
return
|
||||
try:
|
||||
self._apply_requested(True)
|
||||
self._last_error = None
|
||||
except PerformanceModeError as exc:
|
||||
self._last_error = str(exc)
|
||||
|
||||
def _apply_requested(self, requested: bool) -> None:
|
||||
if requested:
|
||||
if not self.available():
|
||||
raise PerformanceModeError("performance governor is unavailable")
|
||||
target = {name: "performance" for name in self._policy_paths()}
|
||||
else:
|
||||
if not self._restore_governors:
|
||||
raise PerformanceModeError("original governors are unavailable")
|
||||
target = dict(self._restore_governors)
|
||||
self._set_governors(target)
|
||||
|
||||
def transact(self, requested: bool, persist: Callable[[bool], Any]) -> None:
|
||||
with self._lock:
|
||||
previous_requested = self._requested
|
||||
previous_governors = self._read_governors()
|
||||
try:
|
||||
self._apply_requested(requested)
|
||||
persist(requested)
|
||||
except Exception as exc:
|
||||
try:
|
||||
if previous_governors:
|
||||
self._set_governors(previous_governors)
|
||||
except Exception as rollback_exc:
|
||||
self._last_error = f"{exc}; governor rollback failed: {rollback_exc}"
|
||||
raise PerformanceModeError(self._last_error) from exc
|
||||
self._requested = previous_requested
|
||||
self._last_error = str(exc)
|
||||
if isinstance(exc, PerformanceModeError):
|
||||
raise
|
||||
raise PerformanceModeError(f"performance mode transaction failed: {exc}") from exc
|
||||
self._requested = requested
|
||||
self._last_error = None
|
||||
|
||||
def restore(self, *, remove_record: bool = False) -> None:
|
||||
with self._lock:
|
||||
if self._restore_governors:
|
||||
try:
|
||||
self._set_governors(self._restore_governors)
|
||||
self._last_error = None
|
||||
except PerformanceModeError as exc:
|
||||
self._last_error = str(exc)
|
||||
raise
|
||||
if remove_record and self.record_path.exists():
|
||||
self.record_path.unlink()
|
||||
|
||||
def status(self, requested: bool | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
current: dict[str, str] = {}
|
||||
try:
|
||||
current = self._read_governors()
|
||||
except OSError as exc:
|
||||
self._last_error = f"could not read current governors: {exc}"
|
||||
effective_requested = self._requested if requested is None else requested
|
||||
effective = bool(
|
||||
effective_requested
|
||||
and current
|
||||
and all(value == "performance" for value in current.values())
|
||||
)
|
||||
return {
|
||||
"requested": bool(effective_requested),
|
||||
"available": self.available(),
|
||||
"effective": effective,
|
||||
"current_governors": current,
|
||||
"restore_governors": dict(self._restore_governors),
|
||||
"last_error": self._last_error,
|
||||
}
|
||||
|
||||
|
||||
def _restore_cli() -> int:
|
||||
parser = argparse.ArgumentParser(description="Restore matrix service cpufreq governors")
|
||||
parser.add_argument("restore", nargs="?")
|
||||
parser.add_argument("--runtime-root", type=Path, required=True)
|
||||
parser.add_argument("--sysfs-root", type=Path)
|
||||
args = parser.parse_args()
|
||||
manager = PerformanceModeManager(args.runtime_root, sysfs_root=args.sysfs_root)
|
||||
try:
|
||||
manager.restore(remove_record=True)
|
||||
except PerformanceModeError as exc:
|
||||
parser.error(str(exc))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_restore_cli())
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Editable scene template persistence and thumbnail rendering."""
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.display.colors import parse_color
|
||||
from app.display.text_renderer import FontResolver, TextOptions, render_text_layer
|
||||
from app.persistence import atomic_write_bytes, atomic_write_many_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCENE_WIDTH = 64
|
||||
SCENE_HEIGHT = 64
|
||||
RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * 3
|
||||
NAME_LIMIT = 80
|
||||
TEMPLATE_SCHEMA_VERSION = 1
|
||||
TEMPLATE_RECORD_FIELDS = frozenset({
|
||||
"id",
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"digest",
|
||||
"thumbnail",
|
||||
"scene",
|
||||
})
|
||||
SCENE_FIELDS = frozenset({"version", "width", "height", "pixelRgb", "elements"})
|
||||
TEXT_ELEMENT_FIELDS = frozenset({
|
||||
"id",
|
||||
"type",
|
||||
"text",
|
||||
"font",
|
||||
"size",
|
||||
"x",
|
||||
"y",
|
||||
"align",
|
||||
"color",
|
||||
})
|
||||
HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
THUMBNAIL_RE = re.compile(
|
||||
r"^(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<digest>[0-9a-f]{16})\.png$"
|
||||
)
|
||||
|
||||
|
||||
class TemplateError(RuntimeError):
|
||||
"""Base error for template operations."""
|
||||
|
||||
|
||||
class TemplateValidationError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateConflictError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateNotFoundError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateStorageFullError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_template_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
|
||||
return dict(document)
|
||||
|
||||
|
||||
TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1}
|
||||
|
||||
|
||||
def _is_record(value: Any) -> bool:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def normalize_name(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TemplateValidationError("template name must be a string")
|
||||
name = value.strip()
|
||||
if not name or len(name) > NAME_LIMIT:
|
||||
raise TemplateValidationError(f"template name must contain 1..{NAME_LIMIT} characters")
|
||||
return name
|
||||
|
||||
|
||||
def normalize_template_id(value: Any) -> str:
|
||||
try:
|
||||
return str(UUID(str(value)))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise TemplateNotFoundError("template not found") from exc
|
||||
|
||||
|
||||
def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
|
||||
if not _is_record(value):
|
||||
raise TemplateValidationError("scene must be an object")
|
||||
if strict_schema:
|
||||
unknown = set(value) - SCENE_FIELDS
|
||||
missing = SCENE_FIELDS - set(value)
|
||||
if unknown or missing:
|
||||
raise TemplateValidationError("scene fields do not match the current schema")
|
||||
for field in ("version", "width", "height"):
|
||||
if type(value[field]) is not int:
|
||||
raise TemplateValidationError(f"scene {field} must be an integer")
|
||||
if value.get("version") != 1 or value.get("width") != 64 or value.get("height") != 64:
|
||||
raise TemplateValidationError("scene must be version 1 with a 64x64 frame")
|
||||
pixel_rgb = value.get("pixelRgb")
|
||||
if not isinstance(pixel_rgb, str):
|
||||
raise TemplateValidationError("scene pixelRgb must be base64 RGB888")
|
||||
try:
|
||||
decoded = base64.b64decode(pixel_rgb, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise TemplateValidationError("scene pixelRgb must be valid base64 RGB888") from exc
|
||||
if len(decoded) != RGB_BYTE_LENGTH:
|
||||
raise TemplateValidationError("scene pixelRgb must contain 64x64 RGB888 bytes")
|
||||
|
||||
raw_elements = value.get("elements")
|
||||
if not isinstance(raw_elements, list):
|
||||
raise TemplateValidationError("scene elements must be an array")
|
||||
elements: list[dict[str, Any]] = []
|
||||
ids: set[str] = set()
|
||||
for raw in raw_elements:
|
||||
if not _is_record(raw) or raw.get("type") != "text":
|
||||
raise TemplateValidationError("scene contains an unsupported element type")
|
||||
if strict_schema:
|
||||
unknown = set(raw) - TEXT_ELEMENT_FIELDS
|
||||
missing = TEXT_ELEMENT_FIELDS - set(raw)
|
||||
if unknown or missing:
|
||||
raise TemplateValidationError("text element fields do not match the current schema")
|
||||
for field in ("size", "x", "y"):
|
||||
if type(raw[field]) is not int:
|
||||
raise TemplateValidationError(f"text element {field} must be an integer")
|
||||
element_id = raw.get("id")
|
||||
if not isinstance(element_id, str) or not element_id.strip() or len(element_id.strip()) > 128:
|
||||
raise TemplateValidationError("scene element id must contain 1..128 characters")
|
||||
element_id = element_id.strip()
|
||||
if element_id in ids:
|
||||
raise TemplateValidationError("scene element ids must be unique")
|
||||
ids.add(element_id)
|
||||
text = raw.get("text")
|
||||
font = raw.get("font")
|
||||
align = raw.get("align")
|
||||
color = raw.get("color")
|
||||
if not isinstance(text, str) or len(text) > 512:
|
||||
raise TemplateValidationError("text element text must contain at most 512 characters")
|
||||
if not isinstance(font, str) or not font.strip() or len(font.strip()) > 512:
|
||||
raise TemplateValidationError("text element font must contain 1..512 characters")
|
||||
if align not in {"left", "center", "right"}:
|
||||
raise TemplateValidationError("text element align must be left, center, or right")
|
||||
if not isinstance(color, str) or not HEX_COLOR_RE.fullmatch(color.strip()):
|
||||
raise TemplateValidationError("text element color must be #RRGGBB")
|
||||
try:
|
||||
size = int(raw.get("size"))
|
||||
x = int(raw.get("x"))
|
||||
y = int(raw.get("y"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TemplateValidationError("text element size and coordinates must be integers") from exc
|
||||
if not 1 <= size <= 64:
|
||||
raise TemplateValidationError("text element size must be in 1..64")
|
||||
if not -128 <= x <= 128 or not -128 <= y <= 128:
|
||||
raise TemplateValidationError("text element coordinates must be in -128..128")
|
||||
elements.append({
|
||||
"id": element_id,
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"font": font.strip(),
|
||||
"size": size,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"align": align,
|
||||
"color": color.strip().upper(),
|
||||
})
|
||||
return {
|
||||
"version": 1,
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"pixelRgb": base64.b64encode(decoded).decode("ascii"),
|
||||
"elements": elements,
|
||||
}
|
||||
|
||||
|
||||
def scene_digest(scene: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def record_revision(record: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(
|
||||
{
|
||||
"id": record["id"],
|
||||
"name": record["name"],
|
||||
"created_at": record["created_at"],
|
||||
"updated_at": record["updated_at"],
|
||||
"digest": record["digest"],
|
||||
"scene": record["scene"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
ThumbnailRenderer = Callable[[Image.Image, dict[str, Any], FontResolver | None], Image.Image]
|
||||
|
||||
|
||||
def _render_text(
|
||||
base: Image.Image,
|
||||
element: dict[str, Any],
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
layer = render_text_layer(TextOptions(
|
||||
text=element["text"],
|
||||
font=element["font"],
|
||||
size=element["size"],
|
||||
x=element["x"],
|
||||
y=element["y"],
|
||||
align=element["align"],
|
||||
color=parse_color(element["color"]),
|
||||
), size=(SCENE_WIDTH, SCENE_HEIGHT), font_resolver=font_resolver)
|
||||
return Image.alpha_composite(base, layer)
|
||||
|
||||
|
||||
ELEMENT_RENDERERS: dict[str, ThumbnailRenderer] = {"text": _render_text}
|
||||
|
||||
|
||||
def render_scene_image(
|
||||
scene: dict[str, Any],
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
checked = validate_scene(scene)
|
||||
pixels = base64.b64decode(checked["pixelRgb"], validate=True)
|
||||
image = Image.frombytes("RGB", (SCENE_WIDTH, SCENE_HEIGHT), pixels).convert("RGBA")
|
||||
for element in checked["elements"]:
|
||||
renderer = ELEMENT_RENDERERS.get(element["type"])
|
||||
if renderer is None:
|
||||
raise TemplateValidationError(f"no thumbnail renderer for {element['type']}")
|
||||
image = renderer(image, element, font_resolver)
|
||||
return image.convert("RGB")
|
||||
|
||||
|
||||
def render_scene_thumbnail(
|
||||
scene: dict[str, Any],
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> bytes:
|
||||
image = render_scene_image(scene, font_resolver=font_resolver)
|
||||
output = BytesIO()
|
||||
image.save(output, format="PNG", optimize=True)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class TemplateStore:
|
||||
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None) -> None:
|
||||
self.root = Path(data_dir) / "templates"
|
||||
self._font_resolver = font_resolver
|
||||
self.records_dir = self.root / "records"
|
||||
self.thumbnails_dir = self.root / "thumbnails"
|
||||
self._lock = threading.RLock()
|
||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.thumbnails_dir.mkdir(parents=True, exist_ok=True)
|
||||
with self._lock:
|
||||
records, migrations = self._preflight_records()
|
||||
self._write_migrations_atomically(migrations)
|
||||
self._reconcile(records)
|
||||
|
||||
def _record_path(self, template_id: str) -> Path:
|
||||
return self.records_dir / f"{template_id}.json"
|
||||
|
||||
def _thumbnail_path(self, template_id: str, digest: str) -> Path:
|
||||
return self.thumbnails_dir / f"{template_id}-{digest}.png"
|
||||
|
||||
@staticmethod
|
||||
def _timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _json_bytes(record: dict[str, Any]) -> bytes:
|
||||
persisted = {"schema_version": TEMPLATE_SCHEMA_VERSION, **record}
|
||||
return (json.dumps(persisted, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _translate_write_error(exc: OSError) -> None:
|
||||
if exc.errno == errno.ENOSPC:
|
||||
raise TemplateStorageFullError("not enough disk space to save template") from exc
|
||||
raise TemplateError(str(exc)) from exc
|
||||
|
||||
def _write_bytes_atomic(self, path: Path, content: bytes) -> None:
|
||||
try:
|
||||
atomic_write_bytes(path, content)
|
||||
except OSError as exc:
|
||||
self._translate_write_error(exc)
|
||||
|
||||
def _write_migrations_atomically(
|
||||
self,
|
||||
migrations: list[tuple[Path, dict[str, Any]]],
|
||||
) -> None:
|
||||
if not migrations:
|
||||
return
|
||||
try:
|
||||
atomic_write_many_bytes(
|
||||
(path, self._json_bytes(record))
|
||||
for path, record in migrations
|
||||
)
|
||||
except OSError as exc:
|
||||
self._translate_write_error(exc)
|
||||
|
||||
def _write_thumbnail(self, template_id: str, digest: str, scene: dict[str, Any]) -> Path:
|
||||
path = self._thumbnail_path(template_id, digest)
|
||||
if path.exists():
|
||||
return path
|
||||
content = render_scene_thumbnail(scene, font_resolver=self._font_resolver)
|
||||
self._write_bytes_atomic(path, content)
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != (64, 64) or image.format != "PNG":
|
||||
raise TemplateError("generated thumbnail is invalid")
|
||||
except Exception:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
return path
|
||||
|
||||
def _parse_record_document(
|
||||
self,
|
||||
path: Path,
|
||||
raw: Any,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
if not _is_record(raw):
|
||||
raise TemplateValidationError("template record must be an object")
|
||||
document = dict(raw)
|
||||
if "schema_version" in document:
|
||||
version = document.pop("schema_version")
|
||||
if type(version) is not int:
|
||||
raise TemplateValidationError("template schema_version must be an integer")
|
||||
else:
|
||||
version = 0
|
||||
if version > TEMPLATE_SCHEMA_VERSION:
|
||||
raise TemplateValidationError(
|
||||
f"template schema version {version} is newer than supported version "
|
||||
f"{TEMPLATE_SCHEMA_VERSION}"
|
||||
)
|
||||
if version < 0:
|
||||
raise TemplateValidationError(f"unsupported template schema version {version}")
|
||||
source_version = version
|
||||
while version < TEMPLATE_SCHEMA_VERSION:
|
||||
migrator = TEMPLATE_MIGRATIONS.get(version)
|
||||
if migrator is None:
|
||||
raise TemplateValidationError(
|
||||
f"no template migration is registered for version {version}"
|
||||
)
|
||||
document = migrator(document)
|
||||
version += 1
|
||||
|
||||
unknown = set(document) - TEMPLATE_RECORD_FIELDS
|
||||
if unknown:
|
||||
raise TemplateValidationError(
|
||||
f"unknown template fields: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
missing = TEMPLATE_RECORD_FIELDS - set(document)
|
||||
if missing:
|
||||
raise TemplateValidationError(
|
||||
f"template record is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
|
||||
try:
|
||||
template_id = normalize_template_id(document.get("id"))
|
||||
if path.stem != template_id:
|
||||
raise TemplateValidationError("template id does not match filename")
|
||||
name = normalize_name(document.get("name"))
|
||||
scene = validate_scene(
|
||||
document.get("scene"),
|
||||
strict_schema=source_version == TEMPLATE_SCHEMA_VERSION,
|
||||
)
|
||||
digest = scene_digest(scene)
|
||||
if (
|
||||
document.get("digest") != digest
|
||||
or document.get("thumbnail") != f"{template_id}-{digest}.png"
|
||||
):
|
||||
raise TemplateValidationError("template digest is invalid")
|
||||
created_at = str(document.get("created_at", ""))
|
||||
updated_at = str(document.get("updated_at", ""))
|
||||
if not created_at or not updated_at:
|
||||
raise TemplateValidationError("template timestamps are missing")
|
||||
record = {
|
||||
"id": template_id,
|
||||
"name": name,
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
"digest": digest,
|
||||
"thumbnail": document["thumbnail"],
|
||||
"scene": scene,
|
||||
}
|
||||
except TemplateValidationError:
|
||||
raise
|
||||
except TemplateError as exc:
|
||||
raise TemplateValidationError(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise TemplateValidationError("template record is invalid") from exc
|
||||
|
||||
if source_version == TEMPLATE_SCHEMA_VERSION and record != document:
|
||||
raise TemplateValidationError(
|
||||
f"template record does not match schema version {TEMPLATE_SCHEMA_VERSION}"
|
||||
)
|
||||
return record, source_version != TEMPLATE_SCHEMA_VERSION
|
||||
|
||||
def _load_record_path(self, path: Path) -> tuple[dict[str, Any], bool]:
|
||||
try:
|
||||
raw = json.loads(path.read_bytes().decode("utf-8"))
|
||||
return self._parse_record_document(path, raw)
|
||||
except TemplateValidationError as exc:
|
||||
raise TemplateValidationError(f"invalid template record {path.name}: {exc}") from exc
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TemplateValidationError(f"invalid template record {path.name}: unreadable JSON") from exc
|
||||
|
||||
def _read_record_path(self, path: Path) -> dict[str, Any] | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
record, migrated = self._load_record_path(path)
|
||||
if migrated:
|
||||
self._write_bytes_atomic(path, self._json_bytes(record))
|
||||
return record
|
||||
|
||||
def _preflight_records(
|
||||
self,
|
||||
) -> tuple[list[dict[str, Any]], list[tuple[Path, dict[str, Any]]]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
migrations: list[tuple[Path, dict[str, Any]]] = []
|
||||
for path in sorted(self.records_dir.glob("*.json")):
|
||||
record, migrated = self._load_record_path(path)
|
||||
records.append(record)
|
||||
if migrated:
|
||||
migrations.append((path, record))
|
||||
return records, migrations
|
||||
|
||||
def _records(self) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for path in sorted(self.records_dir.glob("*.json")):
|
||||
record = self._read_record_path(path)
|
||||
if record is not None:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
def _reconcile(self, records: list[dict[str, Any]] | None = None) -> None:
|
||||
if records is None:
|
||||
records = self._records()
|
||||
expected: set[str] = set()
|
||||
for record in records:
|
||||
expected.add(record["thumbnail"])
|
||||
path = self.thumbnails_dir / record["thumbnail"]
|
||||
if not path.exists():
|
||||
try:
|
||||
self._write_thumbnail(record["id"], record["digest"], record["scene"])
|
||||
except Exception:
|
||||
logger.exception("Failed to rebuild template thumbnail: %s", record["id"])
|
||||
for path in self.thumbnails_dir.iterdir():
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name.startswith(".") and path.name.endswith(".tmp"):
|
||||
path.unlink(missing_ok=True)
|
||||
elif THUMBNAIL_RE.fullmatch(path.name) and path.name not in expected:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _ensure_unique_name(self, name: str, exclude_id: str | None = None) -> None:
|
||||
key = name.casefold()
|
||||
if any(record["id"] != exclude_id and record["name"].casefold() == key for record in self._records()):
|
||||
raise TemplateConflictError("template name already exists")
|
||||
|
||||
def _metadata(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
record_path = self._record_path(record["id"])
|
||||
thumbnail_path = self.thumbnails_dir / record["thumbnail"]
|
||||
size_bytes = (record_path.stat().st_size if record_path.exists() else 0)
|
||||
size_bytes += thumbnail_path.stat().st_size if thumbnail_path.exists() else 0
|
||||
return {
|
||||
"id": record["id"],
|
||||
"name": record["name"],
|
||||
"created_at": record["created_at"],
|
||||
"updated_at": record["updated_at"],
|
||||
"digest": record["digest"],
|
||||
"revision": record_revision(record),
|
||||
"thumbnail_url": f"/api/templates/{record['id']}/thumbnail?v={record['digest']}",
|
||||
"size_bytes": size_bytes,
|
||||
}
|
||||
|
||||
def list(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
records = sorted(self._records(), key=lambda item: item["updated_at"], reverse=True)
|
||||
templates = [self._metadata(record) for record in records]
|
||||
usage = shutil.disk_usage(self.root)
|
||||
return {
|
||||
"templates": templates,
|
||||
"storage": {
|
||||
"templates_bytes": sum(item["size_bytes"] for item in templates),
|
||||
"total_bytes": usage.total,
|
||||
"used_bytes": usage.used,
|
||||
"free_bytes": usage.free,
|
||||
},
|
||||
}
|
||||
|
||||
def get(self, template_id: Any) -> dict[str, Any]:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
with self._lock:
|
||||
record = self._read_record_path(self._record_path(normalized_id))
|
||||
if record is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
return {**self._metadata(record), "scene": record["scene"]}
|
||||
|
||||
def create(self, name: Any, scene: Any) -> dict[str, Any]:
|
||||
checked_name = normalize_name(name)
|
||||
checked_scene = validate_scene(scene)
|
||||
with self._lock:
|
||||
self._ensure_unique_name(checked_name)
|
||||
template_id = str(uuid4())
|
||||
timestamp = self._timestamp()
|
||||
digest = scene_digest(checked_scene)
|
||||
record = {
|
||||
"id": template_id,
|
||||
"name": checked_name,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
"digest": digest,
|
||||
"thumbnail": f"{template_id}-{digest}.png",
|
||||
"scene": checked_scene,
|
||||
}
|
||||
try:
|
||||
self._write_thumbnail(template_id, digest, checked_scene)
|
||||
self._write_bytes_atomic(self._record_path(template_id), self._json_bytes(record))
|
||||
except Exception:
|
||||
self._thumbnail_path(template_id, digest).unlink(missing_ok=True)
|
||||
raise
|
||||
self._reconcile()
|
||||
return {**self._metadata(record), "scene": record["scene"]}
|
||||
|
||||
@staticmethod
|
||||
def _ensure_revision(record: dict[str, Any], expected_revision: str) -> None:
|
||||
if record_revision(record) != expected_revision:
|
||||
raise TemplateConflictError("template was changed by another client")
|
||||
|
||||
def update_scene(self, template_id: Any, scene: Any, expected_revision: str) -> dict[str, Any]:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
checked_scene = validate_scene(scene)
|
||||
with self._lock:
|
||||
current = self._read_record_path(self._record_path(normalized_id))
|
||||
if current is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
self._ensure_revision(current, expected_revision)
|
||||
digest = scene_digest(checked_scene)
|
||||
updated = {
|
||||
**current,
|
||||
"updated_at": self._timestamp(),
|
||||
"digest": digest,
|
||||
"thumbnail": f"{normalized_id}-{digest}.png",
|
||||
"scene": checked_scene,
|
||||
}
|
||||
self._write_thumbnail(normalized_id, digest, checked_scene)
|
||||
try:
|
||||
self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated))
|
||||
except Exception:
|
||||
if digest != current["digest"]:
|
||||
self._thumbnail_path(normalized_id, digest).unlink(missing_ok=True)
|
||||
raise
|
||||
self._reconcile()
|
||||
return {**self._metadata(updated), "scene": updated["scene"]}
|
||||
|
||||
def rename(self, template_id: Any, name: Any, expected_revision: str) -> dict[str, Any]:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
checked_name = normalize_name(name)
|
||||
with self._lock:
|
||||
current = self._read_record_path(self._record_path(normalized_id))
|
||||
if current is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
self._ensure_revision(current, expected_revision)
|
||||
self._ensure_unique_name(checked_name, exclude_id=normalized_id)
|
||||
updated = {**current, "name": checked_name, "updated_at": self._timestamp()}
|
||||
self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated))
|
||||
self._reconcile()
|
||||
return {**self._metadata(updated), "scene": updated["scene"]}
|
||||
|
||||
def copy(self, template_id: Any) -> dict[str, Any]:
|
||||
current = self.get(template_id)
|
||||
with self._lock:
|
||||
base = f"{current['name']} - 副本"
|
||||
candidate = base
|
||||
sequence = 2
|
||||
names = {record["name"].casefold() for record in self._records()}
|
||||
while candidate.casefold() in names:
|
||||
candidate = f"{base} {sequence}"
|
||||
sequence += 1
|
||||
return self.create(candidate, current["scene"])
|
||||
|
||||
def delete(self, template_id: Any, expected_revision: str) -> None:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
with self._lock:
|
||||
path = self._record_path(normalized_id)
|
||||
current = self._read_record_path(path)
|
||||
if current is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
self._ensure_revision(current, expected_revision)
|
||||
try:
|
||||
path.unlink()
|
||||
for thumbnail in self.thumbnails_dir.glob(f"{normalized_id}-*.png"):
|
||||
if THUMBNAIL_RE.fullmatch(thumbnail.name):
|
||||
thumbnail.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
self._translate_write_error(exc)
|
||||
self._reconcile()
|
||||
|
||||
def thumbnail_path(self, template_id: Any, digest: str | None = None) -> Path:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
with self._lock:
|
||||
record = self._read_record_path(self._record_path(normalized_id))
|
||||
if record is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
if digest is not None and digest != record["digest"]:
|
||||
raise TemplateNotFoundError("template thumbnail not found")
|
||||
path = self.thumbnails_dir / record["thumbnail"]
|
||||
if not path.exists():
|
||||
raise TemplateNotFoundError("template thumbnail not found")
|
||||
return path
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Build-gated UI copy editor support."""
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""This flag is changed only when producing a dedicated editing build."""
|
||||
|
||||
UI_COPY_EDITOR_BUILD_ENABLED = False
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user