Improve Android app UI and controller flows

This commit is contained in:
2026-10-09 16:43:58 +08:00
parent c0e26b97fe
commit 608229a497
90 changed files with 8103 additions and 1133 deletions
@@ -0,0 +1,40 @@
# 网页图层UI修正验收(2026-10-09)
本轮修正颜色区布局、背景/帧复选框尺寸、不透明复制与脏目标提示、退出图层移动及模式提示。scene/模板/动图/配置格式、REST、手机公共协议与驱动均不变。VERSION保持1.1.3,FEATURE_UPDATED_AT为2026-10-09T16:12+08:00,未导出OTA/IMG,无App更新、摄像头、实屏视觉和Android真机测试。
## 验收清单
| 用户行为 | 功能与视觉验证 |
| --- | --- |
| 画笔/背景颜色选择、背景开关 | 桌面对齐,390/320上下排列;开关20×20,标签可点,无其他表单回归 |
| 复制弹窗、长帧名称和多选 | 两种弹窗计算背景rgb(23,28,36),原生checkbox20×20;合法长中文及无空格长名称换行,窄屏无横向溢出 |
| 复制当前板、模板和动图多帧 | 扩展浏览器入口通过;独立静态目标4层、两帧各2层,100/300ms间隔不变 |
| 脏目标保存后复制 | 独立目标正常提示、保存、追加及重新载入,不透明提示截图 |
| 画笔/文字移动、退出与继续编辑 | 已提交偏移保留,全屏/扩展/视线保持、面板隐藏、撤销可恢复 |
| 切到视线再退出 | 保留视线平移/滚轮缩放,草稿及撤销状态不变 |
| 未提交拖动预览退出 | 鼠标未松手时用键盘激活退出,候选取消,后续鼠标抬起不提交;一次撤销直接恢复上一已提交移动 |
| 文字退出后直接拖动 | x32→37、y26→30,文字工具恢复,可正常继续编辑 |
复制与脏目标写入只针对隔离mock的独立测试ID;设备既有目标只打开检查后取消,避免修改用户内容。截图已独立目视检查;窄屏颜色截图先将标题滚动到固定顶栏下,避免错误地把遮挡截图作为布局证据。
## 本机与隔离回归
本机全套513通过/9跳过;全部Node87通过。隔离中文/空格目录首次503通过/1失败/11跳过:未改变的显示配置原子替换发生WinError5。单测重测1通过,关闭本轮mock/浏览器后完整重测504通过/11跳过;隔离Node87通过。失败与成功日志均保留,不认定未证实的文件占用根因。
默认环境跳过6项FFmpeg格式、1项HEIC编码器、1项Windows目录symlink及1项仅用于旧OTA子进程的probe;隔离目录另跳过完整Git1项和大型镜像依赖1项。这些媒体功能本轮未改。隔离目录未复制凭据、venv、运行数据,使用已有解释器测试复制源码。
## 经验及剩余边界
稳定需求、TEST-LAYERS-UI测试映射及CSS/布局/截图经验已补到整体开发需求和测试流程。CSS未定义变量使dialog背景失效,使用已有surface;checkbox必须用局部高优先级样式覆盖全局input尺寸;模式出口不应清空撤销。运行mock时应使用准确的源码相对路径定位任务目录,并让环境初始化失败立即停止。本次首次启动环境路径写错,产生源码data下5个默认mock文件后已停止该进程,后续全部测试改用独立数据根。
自动安全审批拒绝了清理上述本轮生成目录(仅返回blocked by policy);未更换手段重试。核桃派软件源代码/data中的5个默认文件在本轮启动16:04:51至停止16:05:23之间生成,确认不是用户内容,已无进程使用,可手动删除该目录。本机一次性目录测试相关资料/如何测试/本机测试环境/.local/work/layers-ui-20261009在本轮结束后同样可手动清理;持续使用的venv、私有凭据和主机密钥保留。
## 设备部署与交付
凭据门禁与远端工具检查通过,离线载荷SHA-256通过;原子更新在持久数据副本上准备后才切换,完整预检508通过/7跳过,首次启动和重启健康检查通过。7项跳过为源载荷没有完整Git1项、本机harness3项、大型镜像构建依赖1项、板端没有Node的跨语言对比1项、旧OTA子进程probe1项;没有新增板端依赖。
已安装8个本轮源码/元数据文件SHA-256与本机一致(installed.log),VERSION1.1.3,功能时间2026-10-09T16:12+08:00。实际网页1280/320两种宽度的完整图层检查均通过、各6次原生确认且无pageerror,退出移动和撤销正常(production-browser.json)。390px检查整行背景标签可点、复选框20×20、弹窗不透明、帧列表405px内滚动(内容1032px)、文档宽度等于视口(production-dialog.json)。设备已有目标仅打开帧列表并取消,未复制或修改用户内容。
部署后与基线逐项比较9模板、6动图共195帧及全部持久文件SHA-256,像素、名称、帧顺序/间隔和配置均不变(verification.log、data-preservation.json)。真实驱动运行正常:deadline_misses=0、oe_pulse_faults=0、last_error=null。既有BlueZ TxPower属性告警仍在,未在UI修正范围处理;服务启动及健康通过。
已清理远端 /run/matrix-screen-controller-layers-ui-20261009 内离线载荷及文件校验清单,并确认本轮10个精确载荷、stage、rollback和迁移/测试路径均不存在(cleanup.log)。本轮mock服务、浏览器及SSH本地转发关闭。本机两处可清理目录因删除被自动审批拒绝而保留,见上面的经验及剩余边界;未改用其他删除手段。
@@ -0,0 +1,248 @@
requirements 未变化,跳过依赖安装。
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pip check
No broken requirements found.
已清理:<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pytest -q -p no:cacheprovider --basetemp=<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 41%]
....................................sssssss.......s..................... [ 55%]
........................................................................ [ 68%]
.......s................................................................ [ 82%]
........................................................................ [ 96%]
.................. [100%]
============================== warnings summary ===============================
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: 10 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: 83 warnings
tests/test_animations.py: 913 warnings
tests/test_api.py: 2075 warnings
tests/test_fonts.py: 249 warnings
tests/test_frp.py: 332 warnings
tests/test_library_order.py: 332 warnings
tests/test_media_conversion.py: 830 warnings
tests/test_mobile_control.py: 1162 warnings
tests/test_ota.py: 249 warnings
tests/test_performance_mode.py: 83 warnings
tests/test_scene_layers.py: 166 warnings
tests/test_templates.py: 664 warnings
tests/test_ui_copy_editor.py: 599 warnings
tests/test_wifi.py: 249 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
app\main.py:2049: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2049: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: 2 warnings
tests/test_animations.py: 22 warnings
tests/test_api.py: 50 warnings
tests/test_fonts.py: 6 warnings
tests/test_frp.py: 8 warnings
tests/test_library_order.py: 8 warnings
tests/test_media_conversion.py: 20 warnings
tests/test_mobile_control.py: 28 warnings
tests/test_ota.py: 6 warnings
tests/test_performance_mode.py: 2 warnings
tests/test_scene_layers.py: 4 warnings
tests/test_templates.py: 16 warnings
tests/test_ui_copy_editor.py: 14 warnings
tests/test_wifi.py: 6 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type)
app\main.py:2079: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2079: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("shutdown")
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
<仓库根目录>\核桃派软件源代码\tests\test_media_conversion.py:134: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1
tests/test_templates.py::test_template_crud_copy_persistence_and_thumbnail_cleanup
<仓库根目录>\核桃派软件源代码\tests\test_templates.py:165: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert len(set(image.getdata())) > 1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
513 passed, 9 skipped, 8390 warnings in 87.13s (0:01:27)
> "C:\Program Files\nodejs\node.EXE" --test tests/test_animation_ui.mjs tests/test_canvas_tools.mjs tests/test_color_math.mjs tests/test_current_display_playback_ui.mjs tests/test_device_ui.mjs tests/test_font_picker.mjs tests/test_frp_ui.mjs tests/test_interaction_feedback.mjs tests/test_scene_model.mjs tests/test_scene_layers.mjs tests/test_settings_ui.mjs tests/test_template_management_ui.mjs tests/test_ui_copy_editor.mjs tests/test_wifi_ui.mjs tests/test_workspace_order_ui.mjs
✔ derives automatic frame names from current animation name and position (0.6874ms)
✔ keeps custom frame names and restores automatic names when cleared (0.0966ms)
✔ builds non-repeating animation edit context names (0.1028ms)
✔ selects saved animation preview frames by ordered durations (0.5996ms)
✔ loads every preview frame through revision-checked pagination (0.6345ms)
✔ validates configurable animation preview concurrency (0.4131ms)
✔ rejects mixed revisions instead of showing a partial animation (0.4235ms)
✔ validates frame duration only when explicitly called (0.3479ms)
✔ moves selected frames as one ordered group (0.1972ms)
✔ protects an eight pixel ring around interactive drag controls (0.2474ms)
✔ reorders mixed library keys without mutating the source list (0.1172ms)
✔ restores drag eligibility after protected pointer cancellation and drag end (0.3627ms)
✔ animation workspace exposes two previews and the simplified control order (6.0767ms)
✔ normal and pixel modes expose independent ranges and presets (1.0924ms)
✔ pixel stamps are exact squares from 1x1 through 4x4 (0.2203ms)
✔ pixel stamps clip at every canvas edge (0.1736ms)
✔ normal circle geometry remains unchanged (0.1043ms)
✔ line interpolation includes endpoints without coordinate gaps (0.199ms)
✔ older tools migrate while v3 restores the coordinate guide choice (0.205ms)
✔ coordinate guide spacing adapts to the transformed logical pixel size (0.1349ms)
✔ coordinate guide ticks preserve logical values through zoom and pan (0.1674ms)
✔ pixel mode markup and grid stay accessible and non-interactive (7.3324ms)
✔ pixel undo history stores changed snapshots, caps entries, and clears (0.339ms)
✔ canvas view clamps scale and keeps a visible section in the viewport (0.1618ms)
✔ zooming around an anchor preserves its logical position (0.1217ms)
✔ floating editor button stays inside its safe viewport bounds (0.0699ms)
✔ canvas editor markup, guarded input, and checkpoint events stay wired (2.9591ms)
✔ normalizes and converts hexadecimal colors (1.1604ms)
✔ converts RGB and HSV boundaries (0.2164ms)
✔ round trips representative colors within 8-bit rounding (0.243ms)
✔ recent colors deduplicate, normalize, and evict the oldest (0.6482ms)
✔ formats and projects looping playback without moving paused content (1.4035ms)
✔ seek dispatcher keeps one request in flight and flushes the newest position (2.5348ms)
✔ topbar current display dialog exposes static close and animation controls (4.9926ms)
✔ device workspace exposes the compact immediate pure-color test flow (5.5196ms)
✔ font catalog normalization, grouping, and search are stable (1.3207ms)
✔ font picker implements commit-only searchable combobox keyboard behavior (4.6556ms)
✔ settings places unrestricted FRP maintenance at the bottom (6.1934ms)
✔ FRP editor uses raw server storage and official validation errors (1.3256ms)
✔ network diagnostics renders layered evidence (1.803ms)
✔ unavailable controls never masquerade as indefinite loading (7.5008ms)
✔ action controls do not bypass the shared state module (12.1379ms)
✔ HTTP requests time out with a stable user-facing error (17.1637ms)
✔ demo library actions stay black, explain restrictions, and preserve copy (1.7199ms)
✔ sortable cards protect controls and keep demo cards fixed (1.3723ms)
✔ old scene migration rejects unknown fields and duplicate text without filtering (56.87ms)
✔ PNG compression round trips sparse and full brush pixels without DOM (8.0872ms)
✔ blank and black are distinct; independent backgrounds follow layer order (5.9144ms)
✔ disabled background colors stay independent through storage and copy (22.0436ms)
✔ extended pixels survive storage but never enter the central output (6.1956ms)
✔ move preview does not mutate source; confirmed clipping removes only outside ink (13.438ms)
✔ text clipping follows anchor and scale and is retained after edit/reload/copy (8.8565ms)
✔ multiple text groups isolate edits, copies and IDs (0.5382ms)
✔ strict v2 storage protects unknown data and the final brush invariant (5.9174ms)
✔ creates a fixed 64x64 RGB scene (7.6661ms)
✔ serializes pixelRgb as base64 and round trips an independent scene (36.8527ms)
✔ moves a valid v1 draft to the stable key before removing exact legacy keys (47.4139ms)
✔ keeps the v1 draft and all legacy keys when stable-key migration cannot write (21.6215ms)
✔ preserves and blocks a future stable draft instead of falling back or writing blank (8.9167ms)
✔ preserves and blocks a damaged stable draft (0.3938ms)
✔ does not discard a damaged higher-priority legacy key during fallback migration (2.492ms)
✔ rejects damaged scene roots while skipping only damaged elements (11.8415ms)
✔ normalizes text fields and creates a centered lowercase ok element (0.5473ms)
✔ migrates the first valid canvas by v3, v2, legacy priority and merges text v1 (13.6859ms)
✔ supports independent add, update, and delete operations for multiple text elements (0.8036ms)
✔ duplicates with a deep copy, new id, 2px offset, and topmost order (0.4624ms)
✔ alpha-composites full RGBA layers in array order without mutating inputs (1.9702ms)
✔ settings brightness explains an active temporary display-test override (8.6144ms)
✔ settings exposes full OTA upload progress and service recovery polling (4.2243ms)
✔ settings automatically opens and safely copies a detailed OTA failure log (3.456ms)
✔ settings exposes a transactional performance-mode switch with the fixed warning (1.8978ms)
✔ settings exposes persisted animation preview concurrency below performance mode (2.5657ms)
✔ template edit baseline distinguishes clean, dirty, and unbound scenes (1.772ms)
✔ template manager uses a monitor and unified four-row card actions (15.8254ms)
✔ animation editing backs up and restores template edit ownership (2.8231ms)
✔ special editor exposes the agreed modes and guarded editing flow (10.9369ms)
✔ latched WiFi draft state remains dirty until explicitly cleared (2.0158ms)
✔ WiFi settings expose independent network and prompt-delay controls (3.9931ms)
✔ workspace order resolves saved ids and appends new registrations (1.7676ms)
✔ workspace order movement is immutable and respects both boundaries (0.329ms)
✔ drawer exposes one editable list and commits only from the save action (7.9569ms)
ℹ tests 81
ℹ suites 0
ℹ pass 81
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 614.8645
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\animation-thumbnail-preview.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\animation-ui-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\app.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\canvas-tools-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\color-math.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\color-picker.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\core.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\current-display-playback.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\font-picker.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\interaction-feedback.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\media-crop-geometry.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\media-import-ui-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\pixel-png.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-board.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-model-v1.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\sortable-card.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\template-edit-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\ui-copy-runtime.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\vendor\fflate\fflate.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\animations.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\canvas.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\device.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\frp-settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\layers.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\media-import.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\templates.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\text.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\wifi-settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\wifi-draft-state.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\workspace-order.js
> "C:\Program Files\nodejs\node.EXE" --check app/ui_copy_editor/static/editor.js
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe scripts/build_ui_copy_catalog.py --check
ui-copy.json is current
本机测试套件通过:all
@@ -0,0 +1,10 @@
{
"candidateCanceled": true,
"undoX": 0,
"continueMode": "draw",
"textDraggedAfterExit": [
37,
30
],
"errors": []
}
@@ -0,0 +1,51 @@
{
"browserResult": {
"passed": true,
"nativeConfirmations": 6,
"layers": 3,
"errors": []
},
"narrowResults": [
{
"width": 390,
"bounds": {
"x": 19,
"y": 198.484375,
"width": 352,
"height": 447.015625
},
"checkbox": {
"x": 36,
"y": 410.640625,
"width": 20,
"height": 20
},
"metrics": {
"width": 390,
"scroll": 390
},
"background": "rgb(23, 28, 36)"
},
{
"width": 320,
"bounds": {
"x": 19,
"y": 169.09375,
"width": 282,
"height": 505.8125
},
"checkbox": {
"x": 36,
"y": 386.96875,
"width": 20,
"height": 20
},
"metrics": {
"width": 320,
"scroll": 320
},
"background": "rgb(23, 28, 36)"
}
],
"errors": []
}
@@ -0,0 +1,3 @@
私有凭据文件可用;未输出任何凭据值。
All 10 exact remote task temporary paths absent.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,8 @@
{
"templates": 9,
"animations": 6,
"frames": 195,
"persistent_files": 430,
"persistent_baseline_sha256": "cbda03ce858f01ce0d025a4ef0de679dac00d328b7a159be14a05c40fbcb592a",
"unchanged_after_deployment": true
}
@@ -0,0 +1,130 @@
annotated_types-0.8.0-py3-none-any.whl: OK
anyio-4.14.2-py3-none-any.whl: OK
certifi-2026.7.22-py3-none-any.whl: OK
cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl: OK
click-8.4.2-py3-none-any.whl: OK
colorama-0.4.6-py2.py3-none-any.whl: OK
cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl: OK
dbus_next-0.2.3-py3-none-any.whl: OK
fastapi-0.116.1-py3-none-any.whl: OK
fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
h11-0.16.0-py3-none-any.whl: OK
httpcore-1.0.9-py3-none-any.whl: OK
httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
httpx-0.28.1-py3-none-any.whl: OK
idna-3.18-py3-none-any.whl: OK
iniconfig-2.3.0-py3-none-any.whl: OK
packaging-26.3-py3-none-any.whl: OK
pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl: OK
pluggy-1.6.0-py3-none-any.whl: OK
pycparser-3.0-py3-none-any.whl: OK
pydantic-2.13.4-py3-none-any.whl: OK
pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl: OK
pygments-2.20.0-py3-none-any.whl: OK
pytest-8.4.1-py3-none-any.whl: OK
python_dotenv-1.2.2-py3-none-any.whl: OK
pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
starlette-0.47.3-py3-none-any.whl: OK
typing_extensions-4.16.0-py3-none-any.whl: OK
typing_inspection-0.4.2-py3-none-any.whl: OK
uvicorn-0.35.0-py3-none-any.whl: OK
uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl: OK
websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
Looking in links: /run/[private]-screen-controller-layers-ui-20261009/wheels
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/fastapi-0.116.1-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/uvicorn-0.35.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/dbus_next-0.2.3-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pytest-8.4.1-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/httpx-0.28.1-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/starlette-0.47.3-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pydantic-2.13.4-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/typing_extensions-4.16.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/click-8.4.2-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/h11-0.16.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/python_dotenv-1.2.2-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/iniconfig-2.3.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/packaging-26.3-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pluggy-1.6.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pygments-2.20.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/anyio-4.14.2-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/certifi-2026.7.22-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/httpcore-1.0.9-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/idna-3.18-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pycparser-3.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/annotated_types-0.8.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Processing /run/[private]-screen-controller-layers-ui-20261009/wheels/typing_inspection-0.4.2-py3-none-any.whl
Installing collected packages: websockets, uvloop, typing-extensions, pyyaml, python-dotenv, pygments, pycparser, pluggy, pillow, packaging, iniconfig, idna, httptools, h11, fonttools, dbus-next, click, certifi, annotated-types, uvicorn, typing-inspection, pytest, pydantic-core, httpcore, cffi, anyio, watchfiles, starlette, pydantic, httpx, cryptography, fastapi
Successfully installed annotated-types-0.8.0 anyio-4.14.2 certifi-2026.7.22 cffi-2.1.1 click-8.4.2 cryptography-46.0.5 dbus-next-0.2.3 fastapi-0.116.1 fonttools-4.63.0 h11-0.16.0 httpcore-1.0.9 httptools-0.8.0 httpx-0.28.1 idna-3.18 iniconfig-2.3.0 packaging-26.3 pillow-12.3.0 pluggy-1.6.0 pycparser-3.0 pydantic-2.13.4 pydantic-core-2.46.4 pygments-2.20.0 pytest-8.4.1 python-dotenv-1.2.2 pyyaml-6.0.3 starlette-0.47.3 typing-extensions-4.16.0 typing-inspection-0.4.2 uvicorn-0.35.0 uvloop-0.22.1 watchfiles-1.2.0 websockets-17.0.1
make: Entering directory '/opt/[private]-screen-controller.stage.1.1.3-layers-ui-20261009/app/display/native'
rm -f -- libh618_hub75.so hub75_benchmark hub75_native_test hub75_safeoff
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread -shared h618_hub75.c -o libh618_hub75.so -pthread
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread benchmark.c h618_hub75.c -o hub75_benchmark -pthread
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread native_test.c h618_hub75.c -o hub75_native_test -pthread
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread safeoff.c h618_hub75.c -o hub75_safeoff -pthread
./hub75_native_test
native_bitplane_tests=PASS
./hub75_safeoff --fake
HUB75 safeoff complete
make: Leaving directory '/opt/[private]-screen-controller.stage.1.1.3-layers-ui-20261009/app/display/native'
........................................................................ [ 14%]
........................................................................ [ 28%]
.............................................................s.......... [ 42%]
........................................................................ [ 56%]
........................................................................ [ 70%]
.......s.......................................................s........ [ 84%]
........................................................................ [ 98%]
.......sss [100%]
508 passed, 7 skipped, 389 warnings in 403.15s (0:06:43)
PASS: H618 PI pinctrl: allwinner,sun50i-h616-pinctrl
PASS: /dev/mem accessible: /dev/mem
PASS: /dev/i2c-1 present: /dev/i2c-1
PASS: CPU3 present: /sys/devices/system/cpu/cpu3
PASS: native HUB75 library: /opt/[private]-screen-controller.stage.1.1.3-layers-ui-20261009/app/display/native/libh618_hub75.so
PASS: AArch64 userspace: aarch64
PASS: default network route: default via <设备网络地址> dev wlan0 proto static metric 600
PASS: NetworkManager active: active
PASS: lightdm.service inactive: inactive
PASS: gpioc-server.service inactive: inactive
PASS: map_device.service inactive: inactive
Persistent data prepared successfully: schema=config.json
frpc: OK
LICENSE: OK
frpc binary already verified; skipped
frpc 0.71.0 installed as a system component
● [private]-screen-controller.service - WalnutPi H618 Matrix Screen Controller
Loaded: loaded (/etc/systemd/system/[private]-screen-controller.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-10-09 08:23:44 UTC; 9s ago
Process: 333499 ExecStartPre=/bin/sh -c cat /proc/sys/kernel/sched_rt_runtime_us > /run/[private]-screen-controller/sched_rt_runtime_us.original && printf "%s
" -1 > /proc/sys/kernel/sched_rt_runtime_us (code=exited, status=0/SUCCESS)
Process: 333501 ExecStartPre=/opt/[private]-screen-controller/.venv/bin/python /opt/[private]-screen-controller/scripts/dedicated_host.py check --service (code=exited, status=0/SUCCESS)
Process: 333507 ExecStartPre=/opt/[private]-screen-controller/app/display/native/hub75_safeoff (code=exited, status=0/SUCCESS)
Main PID: 333508 (python)
Tasks: 14 (limit: 1021)
Memory: 137.5M
CPU: 11.733s
CGroup: /system.slice/[private]-screen-controller.service
└─333508 /opt/[private]-screen-controller/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8080
Oct 09 08:23:51 [private] python[333508]: File "/opt/[private]-screen-controller/.venv/lib/python3.11/site-packages/dbus_next/message_bus.py", line 712, in _process_message
Oct 09 08:23:51 [private] python[333508]: handler(msg, send_reply)
Oct 09 08:23:51 [private] python[333508]: File "/opt/[private]-screen-controller/.venv/lib/python3.11/site-packages/dbus_next/message_bus.py", line 898, in _default_properties_handler
Oct 09 08:23:51 [private] python[333508]: raise DBusError(
Oct 09 08:23:51 [private] python[333508]: dbus_next.errors.DBusError: interface "org.bluez.LEAdvertisement1" does not have property "TxPower"
Oct 09 08:23:52 [private] python[333508]: 2026-10-09 08:23:52,019 INFO app.power.voltage: Screen voltage sensor is available
Oct 09 08:23:52 [private] python[333508]: 2026-10-09 08:23:52,049 INFO app.monitoring.resources: System resource monitor is available
Oct 09 08:23:52 [private] python[333508]: INFO: Application startup complete.
Oct 09 08:23:52 [private] python[333508]: INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
Oct 09 08:23:53 [private] python[333508]: INFO: 127.0.0.1:51326 - "GET /api/status HTTP/1.1" 200 OK
WalnutPi Matrix Screen Controller updated successfully: 1.1.3-layers-ui-20261009
WARNING: The directory '/home/[private]/.cache/pip' or its parent directory is not owned or is not writable by the current user. The cache has been disabled. Check the permissions and owner of that directory. If executing pip with sudo, you should use sudo's -H flag.
@@ -0,0 +1 @@
仓库卫生检查通过:已检查 733 个 Git 候选文件,未输出任何秘密值。
@@ -0,0 +1 @@
仓库卫生检查通过:已检查 725 个 Git 候选文件,未输出任何秘密值。
@@ -0,0 +1 @@
{"verified_files": 8, "version": "1.1.3", "feature_updated_at": "2026-10-09T16:12+08:00"}
@@ -0,0 +1,95 @@
✔ derives automatic frame names from current animation name and position (0.808ms)
✔ keeps custom frame names and restores automatic names when cleared (0.1255ms)
✔ builds non-repeating animation edit context names (0.1235ms)
✔ selects saved animation preview frames by ordered durations (0.7779ms)
✔ loads every preview frame through revision-checked pagination (0.6472ms)
✔ validates configurable animation preview concurrency (0.2981ms)
✔ rejects mixed revisions instead of showing a partial animation (0.3864ms)
✔ validates frame duration only when explicitly called (0.4326ms)
✔ moves selected frames as one ordered group (0.235ms)
✔ protects an eight pixel ring around interactive drag controls (0.1911ms)
✔ reorders mixed library keys without mutating the source list (0.12ms)
✔ restores drag eligibility after protected pointer cancellation and drag end (0.497ms)
✔ animation workspace exposes two previews and the simplified control order (6.8882ms)
✔ normal and pixel modes expose independent ranges and presets (1.2686ms)
✔ pixel stamps are exact squares from 1x1 through 4x4 (0.2839ms)
✔ pixel stamps clip at every canvas edge (0.2249ms)
✔ normal circle geometry remains unchanged (0.0917ms)
✔ line interpolation includes endpoints without coordinate gaps (0.2369ms)
✔ older tools migrate while v3 restores the coordinate guide choice (0.2616ms)
✔ coordinate guide spacing adapts to the transformed logical pixel size (0.1598ms)
✔ coordinate guide ticks preserve logical values through zoom and pan (0.2017ms)
✔ pixel mode markup and grid stay accessible and non-interactive (7.2448ms)
✔ pixel undo history stores changed snapshots, caps entries, and clears (0.3945ms)
✔ canvas view clamps scale and keeps a visible section in the viewport (0.1984ms)
✔ zooming around an anchor preserves its logical position (0.1317ms)
✔ floating editor button stays inside its safe viewport bounds (0.0831ms)
✔ canvas editor markup, guarded input, and checkpoint events stay wired (3.1863ms)
✔ normalizes and converts hexadecimal colors (1.5345ms)
✔ converts RGB and HSV boundaries (0.9382ms)
✔ round trips representative colors within 8-bit rounding (0.2933ms)
✔ recent colors deduplicate, normalize, and evict the oldest (0.3496ms)
✔ formats and projects looping playback without moving paused content (1.2628ms)
✔ seek dispatcher keeps one request in flight and flushes the newest position (2.7001ms)
✔ topbar current display dialog exposes static close and animation controls (3.7402ms)
✔ device workspace exposes the compact immediate pure-color test flow (6.3507ms)
✔ font catalog normalization, grouping, and search are stable (2.0276ms)
✔ font picker implements commit-only searchable combobox keyboard behavior (3.427ms)
✔ settings places unrestricted FRP maintenance at the bottom (5.1351ms)
✔ FRP editor uses raw server storage and official validation errors (0.9648ms)
✔ network diagnostics renders layered evidence (1.527ms)
✔ unavailable controls never masquerade as indefinite loading (6.4433ms)
✔ action controls do not bypass the shared state module (6.9201ms)
✔ HTTP requests time out with a stable user-facing error (10.9783ms)
✔ demo library actions stay black, explain restrictions, and preserve copy (1.3072ms)
✔ sortable cards protect controls and keep demo cards fixed (1.2485ms)
✔ media import workspace exposes streaming upload, crop controls, and queue polling (1.6826ms)
✔ free framing starts from the complete source and keeps one pixel visible (0.8104ms)
✔ free framing copy is stable and catalogued (0.1191ms)
✔ upload action has visible ordinary-build copy (0.4106ms)
✔ media naming happens after analysis and conflicts keep the draft (0.2591ms)
✔ awaiting-settings cards survive polling without hiding server transitions (0.1535ms)
✔ old scene migration rejects unknown fields and duplicate text without filtering (47.9235ms)
✔ PNG compression round trips sparse and full brush pixels without DOM (6.3474ms)
✔ blank and black are distinct; independent backgrounds follow layer order (3.1552ms)
✔ disabled background colors stay independent through storage and copy (15.4641ms)
✔ extended pixels survive storage but never enter the central output (5.6959ms)
✔ move preview does not mutate source; confirmed clipping removes only outside ink (9.8782ms)
✔ text clipping follows anchor and scale and is retained after edit/reload/copy (7.7534ms)
✔ multiple text groups isolate edits, copies and IDs (0.7824ms)
✔ strict v2 storage protects unknown data and the final brush invariant (4.1681ms)
✔ creates a fixed 64x64 RGB scene (5.3096ms)
✔ serializes pixelRgb as base64 and round trips an independent scene (25.3775ms)
✔ moves a valid v1 draft to the stable key before removing exact legacy keys (27.5528ms)
✔ keeps the v1 draft and all legacy keys when stable-key migration cannot write (13.5803ms)
✔ preserves and blocks a future stable draft instead of falling back or writing blank (5.8011ms)
✔ preserves and blocks a damaged stable draft (0.4566ms)
✔ does not discard a damaged higher-priority legacy key during fallback migration (1.941ms)
✔ rejects damaged scene roots while skipping only damaged elements (11.0519ms)
✔ normalizes text fields and creates a centered lowercase ok element (0.337ms)
✔ migrates the first valid canvas by v3, v2, legacy priority and merges text v1 (9.0729ms)
✔ supports independent add, update, and delete operations for multiple text elements (0.6678ms)
✔ duplicates with a deep copy, new id, 2px offset, and topmost order (0.3218ms)
✔ alpha-composites full RGBA layers in array order without mutating inputs (1.4592ms)
✔ settings brightness explains an active temporary display-test override (7.2325ms)
✔ settings exposes full OTA upload progress and service recovery polling (2.9749ms)
✔ settings automatically opens and safely copies a detailed OTA failure log (1.7251ms)
✔ settings exposes a transactional performance-mode switch with the fixed warning (2.449ms)
✔ settings exposes persisted animation preview concurrency below performance mode (1.8539ms)
✔ template edit baseline distinguishes clean, dirty, and unbound scenes (1.1902ms)
✔ template manager uses a monitor and unified four-row card actions (7.157ms)
✔ animation editing backs up and restores template edit ownership (1.9429ms)
✔ special editor exposes the agreed modes and guarded editing flow (6.9422ms)
✔ latched WiFi draft state remains dirty until explicitly cleared (2.0016ms)
✔ WiFi settings expose independent network and prompt-delay controls (1.8652ms)
✔ workspace order resolves saved ids and appends new registrations (1.0842ms)
✔ workspace order movement is immutable and respects both boundaries (0.2086ms)
✔ drawer exposes one editable list and commits only from the save action (4.9742ms)
ℹ tests 87
ℹ suites 0
ℹ pass 87
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 650.3177
@@ -0,0 +1,125 @@
........................................................................ [ 14%]
........................................................................ [ 28%]
.............................................................s.......... [ 42%]
....................................sssssss.......s..................... [ 56%]
........................................................................ [ 70%]
.......s................................................................ [ 84%]
........................................................................ [ 98%]
.......... [100%]
============================== warnings summary ===============================
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: 10 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: 83 warnings
tests/test_animations.py: 913 warnings
tests/test_api.py: 2075 warnings
tests/test_fonts.py: 249 warnings
tests/test_frp.py: 332 warnings
tests/test_library_order.py: 332 warnings
tests/test_media_conversion.py: 830 warnings
tests/test_mobile_control.py: 1162 warnings
tests/test_ota.py: 249 warnings
tests/test_performance_mode.py: 83 warnings
tests/test_scene_layers.py: 166 warnings
tests/test_templates.py: 664 warnings
tests/test_ui_copy_editor.py: 599 warnings
tests/test_wifi.py: 249 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2049: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2049: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: 2 warnings
tests/test_animations.py: 22 warnings
tests/test_api.py: 50 warnings
tests/test_fonts.py: 6 warnings
tests/test_frp.py: 8 warnings
tests/test_library_order.py: 8 warnings
tests/test_media_conversion.py: 20 warnings
tests/test_mobile_control.py: 28 warnings
tests/test_ota.py: 6 warnings
tests/test_performance_mode.py: 2 warnings
tests/test_scene_layers.py: 4 warnings
tests/test_templates.py: 16 warnings
tests/test_ui_copy_editor.py: 14 warnings
tests/test_wifi.py: 6 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type)
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2079: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2079: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("shutdown")
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_conversion.py:134: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1
tests/test_templates.py::test_template_crud_copy_persistence_and_thumbnail_cleanup
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_templates.py:165: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert len(set(image.getdata())) > 1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ===========================
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_repository_hygiene.py:17: repository hygiene tests require a complete Git workspace
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_kernel_artifact_dependency.py:103: source-only update payload intentionally omits image build dependencies
SKIPPED [5] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_formats.py:19: FFmpeg is not installed
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_formats.py:43: FFmpeg is not installed
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_formats.py:64: libheif examples are not installed
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_migrate_state_root.py:139: directory symlinks are unavailable on this host
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_ota_isolation_probe.py:10: only executed by the isolated legacy-worker compatibility test
504 passed, 11 skipped, 8390 warnings in 83.88s (0:01:23)
@@ -0,0 +1,2 @@
. [100%]
1 passed in 0.18s
@@ -0,0 +1,184 @@
........................................................................ [ 14%]
..........................................................F............. [ 28%]
.............................................................s.......... [ 42%]
....................................sssssss.......s..................... [ 56%]
........................................................................ [ 70%]
.......s................................................................ [ 84%]
........................................................................ [ 98%]
.......... [100%]
================================== FAILURES ===================================
_____ test_refresh_rate_config_write_failure_rolls_back_driver_and_frame ______
tmp_path = WindowsPath('<仓库根目录>/测试相关资料/如何测试/本机测试环境/.local/work/layers-ui-20261009/isolation-pytest/test_refresh_rate_config_write0')
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x00000163445B46D0>
def test_refresh_rate_config_write_failure_rolls_back_driver_and_frame(
tmp_path,
monkeypatch,
):
store = ConfigStore(project_root=tmp_path)
service = DisplayService(store, driver_kind="mock")
image = Image.new("RGB", (64, 64), (9, 8, 7))
> service.show_image(image, mode="composition")
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_display_service.py:566:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\display\service.py:237: in show_image
self.config_store.set_last_mode(mode)
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\config\store.py:189: in set_last_mode
self.update({"last_mode": mode})
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\config\store.py:159: in update
self._write(config)
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\config\store.py:193: in _write
atomic_write_bytes(
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\persistence.py:14: in atomic_write_bytes
atomic_write_many_bytes(((Path(path), content),))
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
writes = ((WindowsPath('<仓库根目录>/测试相关资料/如何测试/本机测试环境/.local/work/layers-ui-20261009...ations",\n "templates"\n ],\n "performance_mode_enabled": false,\n "animation_preview_max_concurrent": 2\n}\n'),)
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)
E PermissionError: [WinError 5] 拒绝访问。: '<仓库根目录>\\测试相关资料\\如何测试\\本机测试环境\\.local\\work\\layers-ui-20261009\\isolation-pytest\\test_refresh_rate_config_write0\\data\\.config.json.30b22bb7a1fe4136ba5d2ae99b94f117.tmp' -> '<仓库根目录>\\测试相关资料\\如何测试\\本机测试环境\\.local\\work\\layers-ui-20261009\\isolation-pytest\\test_refresh_rate_config_write0\\data\\config.json'
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\persistence.py:41: PermissionError
============================== warnings summary ===============================
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: 10 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: 83 warnings
tests/test_animations.py: 913 warnings
tests/test_api.py: 2075 warnings
tests/test_fonts.py: 249 warnings
tests/test_frp.py: 332 warnings
tests/test_library_order.py: 332 warnings
tests/test_media_conversion.py: 830 warnings
tests/test_mobile_control.py: 1162 warnings
tests/test_ota.py: 249 warnings
tests/test_performance_mode.py: 83 warnings
tests/test_scene_layers.py: 166 warnings
tests/test_templates.py: 664 warnings
tests/test_ui_copy_editor.py: 599 warnings
tests/test_wifi.py: 249 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2049: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2049: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: 2 warnings
tests/test_animations.py: 22 warnings
tests/test_api.py: 50 warnings
tests/test_fonts.py: 6 warnings
tests/test_frp.py: 8 warnings
tests/test_library_order.py: 8 warnings
tests/test_media_conversion.py: 20 warnings
tests/test_mobile_control.py: 28 warnings
tests/test_ota.py: 6 warnings
tests/test_performance_mode.py: 2 warnings
tests/test_scene_layers.py: 4 warnings
tests/test_templates.py: 16 warnings
tests/test_ui_copy_editor.py: 14 warnings
tests/test_wifi.py: 6 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type)
测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2079: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\app\main.py:2079: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("shutdown")
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_conversion.py:134: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1
tests/test_templates.py::test_template_crud_copy_persistence_and_thumbnail_cleanup
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_templates.py:165: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert len(set(image.getdata())) > 1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ===========================
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_repository_hygiene.py:17: repository hygiene tests require a complete Git workspace
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_kernel_artifact_dependency.py:103: source-only update payload intentionally omits image build dependencies
SKIPPED [5] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_formats.py:19: FFmpeg is not installed
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_formats.py:43: FFmpeg is not installed
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_media_formats.py:64: libheif examples are not installed
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_migrate_state_root.py:139: directory symlinks are unavailable on this host
SKIPPED [1] 测试相关资料\如何测试\本机测试环境\.local\work\layers-ui-20261009\独立 工作区\核桃派软件源代码\tests\test_ota_isolation_probe.py:10: only executed by the isolated legacy-worker compatibility test
1 failed, 503 passed, 11 skipped, 8390 warnings in 86.07s (0:01:26)
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,95 @@
✔ derives automatic frame names from current animation name and position (0.8344ms)
✔ keeps custom frame names and restores automatic names when cleared (0.1109ms)
✔ builds non-repeating animation edit context names (0.0981ms)
✔ selects saved animation preview frames by ordered durations (0.609ms)
✔ loads every preview frame through revision-checked pagination (0.555ms)
✔ validates configurable animation preview concurrency (1.0187ms)
✔ rejects mixed revisions instead of showing a partial animation (0.4183ms)
✔ validates frame duration only when explicitly called (0.4254ms)
✔ moves selected frames as one ordered group (0.2261ms)
✔ protects an eight pixel ring around interactive drag controls (0.1889ms)
✔ reorders mixed library keys without mutating the source list (0.1202ms)
✔ restores drag eligibility after protected pointer cancellation and drag end (0.4112ms)
✔ animation workspace exposes two previews and the simplified control order (6.6239ms)
✔ normal and pixel modes expose independent ranges and presets (1.1758ms)
✔ pixel stamps are exact squares from 1x1 through 4x4 (0.2195ms)
✔ pixel stamps clip at every canvas edge (0.1762ms)
✔ normal circle geometry remains unchanged (0.0804ms)
✔ line interpolation includes endpoints without coordinate gaps (0.2975ms)
✔ older tools migrate while v3 restores the coordinate guide choice (0.2289ms)
✔ coordinate guide spacing adapts to the transformed logical pixel size (0.1372ms)
✔ coordinate guide ticks preserve logical values through zoom and pan (0.1836ms)
✔ pixel mode markup and grid stay accessible and non-interactive (6.5794ms)
✔ pixel undo history stores changed snapshots, caps entries, and clears (0.3632ms)
✔ canvas view clamps scale and keeps a visible section in the viewport (0.1771ms)
✔ zooming around an anchor preserves its logical position (0.121ms)
✔ floating editor button stays inside its safe viewport bounds (0.0683ms)
✔ canvas editor markup, guarded input, and checkpoint events stay wired (2.1547ms)
✔ normalizes and converts hexadecimal colors (1.0959ms)
✔ converts RGB and HSV boundaries (0.2125ms)
✔ round trips representative colors within 8-bit rounding (0.2085ms)
✔ recent colors deduplicate, normalize, and evict the oldest (0.2279ms)
✔ formats and projects looping playback without moving paused content (1.0028ms)
✔ seek dispatcher keeps one request in flight and flushes the newest position (2.277ms)
✔ topbar current display dialog exposes static close and animation controls (2.8138ms)
✔ device workspace exposes the compact immediate pure-color test flow (5.2814ms)
✔ font catalog normalization, grouping, and search are stable (1.2077ms)
✔ font picker implements commit-only searchable combobox keyboard behavior (3.0832ms)
✔ settings places unrestricted FRP maintenance at the bottom (4.0794ms)
✔ FRP editor uses raw server storage and official validation errors (0.9054ms)
✔ network diagnostics renders layered evidence (1.276ms)
✔ unavailable controls never masquerade as indefinite loading (5.2608ms)
✔ action controls do not bypass the shared state module (6.9201ms)
✔ HTTP requests time out with a stable user-facing error (22.2398ms)
✔ demo library actions stay black, explain restrictions, and preserve copy (0.9731ms)
✔ sortable cards protect controls and keep demo cards fixed (0.9556ms)
✔ media import workspace exposes streaming upload, crop controls, and queue polling (1.1751ms)
✔ free framing starts from the complete source and keeps one pixel visible (0.6278ms)
✔ free framing copy is stable and catalogued (0.0933ms)
✔ upload action has visible ordinary-build copy (0.2064ms)
✔ media naming happens after analysis and conflicts keep the draft (0.1499ms)
✔ awaiting-settings cards survive polling without hiding server transitions (0.1005ms)
✔ old scene migration rejects unknown fields and duplicate text without filtering (49.3698ms)
✔ PNG compression round trips sparse and full brush pixels without DOM (4.3169ms)
✔ blank and black are distinct; independent backgrounds follow layer order (3.1915ms)
✔ disabled background colors stay independent through storage and copy (12.446ms)
✔ extended pixels survive storage but never enter the central output (5.3709ms)
✔ move preview does not mutate source; confirmed clipping removes only outside ink (9.928ms)
✔ text clipping follows anchor and scale and is retained after edit/reload/copy (7.5961ms)
✔ multiple text groups isolate edits, copies and IDs (0.9609ms)
✔ strict v2 storage protects unknown data and the final brush invariant (3.8003ms)
✔ creates a fixed 64x64 RGB scene (6.227ms)
✔ serializes pixelRgb as base64 and round trips an independent scene (30.277ms)
✔ moves a valid v1 draft to the stable key before removing exact legacy keys (30.4664ms)
✔ keeps the v1 draft and all legacy keys when stable-key migration cannot write (16.3615ms)
✔ preserves and blocks a future stable draft instead of falling back or writing blank (6.9734ms)
✔ preserves and blocks a damaged stable draft (0.2876ms)
✔ does not discard a damaged higher-priority legacy key during fallback migration (1.4569ms)
✔ rejects damaged scene roots while skipping only damaged elements (10.0087ms)
✔ normalizes text fields and creates a centered lowercase ok element (0.4748ms)
✔ migrates the first valid canvas by v3, v2, legacy priority and merges text v1 (8.6045ms)
✔ supports independent add, update, and delete operations for multiple text elements (0.4692ms)
✔ duplicates with a deep copy, new id, 2px offset, and topmost order (0.2556ms)
✔ alpha-composites full RGBA layers in array order without mutating inputs (1.8318ms)
✔ settings brightness explains an active temporary display-test override (5.5205ms)
✔ settings exposes full OTA upload progress and service recovery polling (3.2091ms)
✔ settings automatically opens and safely copies a detailed OTA failure log (1.5628ms)
✔ settings exposes a transactional performance-mode switch with the fixed warning (1.5066ms)
✔ settings exposes persisted animation preview concurrency below performance mode (2.5054ms)
✔ template edit baseline distinguishes clean, dirty, and unbound scenes (1.984ms)
✔ template manager uses a monitor and unified four-row card actions (8.406ms)
✔ animation editing backs up and restores template edit ownership (1.8903ms)
✔ special editor exposes the agreed modes and guarded editing flow (5.876ms)
✔ latched WiFi draft state remains dirty until explicitly cleared (1.131ms)
✔ WiFi settings expose independent network and prompt-delay controls (2.1816ms)
✔ workspace order resolves saved ids and appends new registrations (0.8661ms)
✔ workspace order movement is immutable and respects both boundaries (0.1794ms)
✔ drawer exposes one editable list and commits only from the save action (5.1424ms)
ℹ tests 87
ℹ suites 0
ℹ pass 87
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 544.9717
@@ -0,0 +1,14 @@
私有凭据文件可用;未输出任何凭据值。
/usr/bin/python3
/usr/bin/git
/usr/bin/curl
/usr/bin/tar
/usr/bin/sha256sum
/usr/bin/make
/usr/bin/gcc
Python 3.11.2
aarch64
Baseline content: 9 templates, 6 animations, 195 frames
deployment.tar.gz: OK
@@ -0,0 +1,20 @@
[
{
"width": 1280,
"result": {
"passed": true,
"nativeConfirmations": 6,
"layers": 3,
"errors": []
}
},
{
"width": 320,
"result": {
"passed": true,
"nativeConfirmations": 6,
"layers": 3,
"errors": []
}
}
]
@@ -0,0 +1,19 @@
{
"width": 390,
"labelToggle": true,
"background": "rgb(23, 28, 36)",
"checkbox": {
"x": 36,
"y": 300.203125,
"width": 20,
"height": 20
},
"frameList": {
"clientHeight": 405,
"scrollHeight": 1032
},
"pageWidth": {
"viewport": 390,
"scroll": 390
}
}
@@ -0,0 +1,4 @@
私有凭据文件可用;未输出任何凭据值。
All persistent files, pixels, names, frame order and durations match baseline.
{"service": {"app_version": "017ed103e3aef71c", "software_version": "1.1.3", "feature_updated_at": "2026-10-09T16:12+08:00"}, "driver": {"actual_refresh_rate_hz": 97.412, "panel_scan_rate_hz": 97.412, "completed_frames": 25279, "completed_scans": 25279, "scans_per_frame": 1, "deadline_misses": 0, "buffer_swaps": 647, "submitted_frames": 647, "oe_timing_backend": "h618-pwm4", "oe_pulse_faults": 0, "oe_forced_blanks": 0, "max_programmed_oe_ns": 128000, "oe_timing_error": null, "safeoff_error": null, "cpu_affinity": 3, "cpu_affinity_active": true, "cpu_affinity_error": null, "realtime_priority": 50, "realtime_priority_active": true, "realtime_priority_error": null, "memory_locked": true, "memory_lock_error": null, "running": true, "governor": "ondemand", "last_error": null}}
@@ -0,0 +1,52 @@
# 网页多图层与扩展画布实施验收(2026-10-09)
实施范围:scene v2、模板schema v2、动图schema v3、配置v11;192×192逻辑编辑区和64×64输出;画笔/多段文字图层、背景、复制/排序/命名、位移和持久裁切。VERSION保持1.1.3,功能时间2026-10-09T15:27+08:00。未导出OTA/IMG。
## 验收覆盖
| 行为 | 证据 |
| --- | --- |
| 图层增删、至少一画笔、多段文字隔离、复制/排序/重命名 | Node模型测试;桌面浏览器入口check_layers_browser.mjs |
| 透明空白、黑色遮挡、各层背景/关闭颜色记忆、擦除/清空 | Node/Pillow合成;浏览器背景操作 |
| 192外围、扩展开关记忆、中央64边框、64输出 | 浏览器/节点;Python PNG逐像素对比 |
| 图层移动、偏移提交/步进/限位;视线不改内容 | 浏览器桌面鼠标和移动触摸;模型测试 |
| 越界确认取消、文字部分裁切/全删、移回不恢复、缩放与重载、撤销 | 6次原生确认的浏览器验收及模型/持久化测试 |
| 保存/复制静态与单帧/多帧/全部帧、冲突、脏目标保存 | 浏览器操作及REST/存储测试 |
| 场景/缩略图/索引写入故障、重启无半提交 | 动图存储事务故障注入测试 |
| 旧草稿/模板/动图迁移中央输出一致、损坏保全 | Node/Python严格迁移测试;设备原数据摘要比对 |
| 手机共享内容库/播放接口兼容 | 相关Python协议测试;App无需更新 |
| 桌面/390px/320px悬浮菜单及面板、文字拖动/字号、触摸/视线缩放 | 本目录截图、浏览器正常UI输入验证 |
绕开正常路径的验证包含:确认取消后草稿完全一致、移回后不恢复已裁内容、迟到文字请求不覆盖新状态、If-Match过期冲突、两帧部分写入失败全部保全,以及旧草稿未知字段/不合法数字拒绝迁移。
## 已完成本机检查
最后版本本机全套513通过/9跳过(all-ready.log),上一轮512通过/9跳过(all-final-retest.log);最新迁移边界59通过;全部Node87通过/无跳过。隔离中文空格目录全套499通过/11跳过,最新相关59通过。隔离副本未复制凭据、旧venv或运行数据,使用现有解释器运行其源码。各结果存在重叠,不相加。
本机9跳过:6个视频格式测试的默认环境没有FFmpeg、libheif编码器1项、Windows目录symlink1项、只供旧OTA隔离子进程的probe1项。随后复用已有FFmpeg单独执行6项视频格式并通过,HEIC编码器仍未提供。隔离目录另有完整Git工作区和发行构建依赖缺省导致的2项跳过。无需摄像头、实屏视觉、Android真机回归。
Windows一次媒体任务原子替换WinError5已保留失败日志all-final.log;单测重测与关闭本轮浏览器/mock后的完整回归通过。较早隔离迁移目录rename及WiFi替换也曾偶发拒绝访问;未证实根因,未修改无关持久化实现。经验已登记测试流程。
## 部署验证
通过私有凭据门禁和远端工具检查,上传离线载荷并核验SHA-256,使用既有update_walnutpi.sh在数据副本上迁移及完整预检,原子切换后健康检查并重启。本轮未更新驱动源码,也未发送实屏诊断图案。
部署前基线9个模板、6个动图、195帧。既有名称/顺序/间隔、64输出摘要、配置和其余持久文件逐项对比;允许清理13个不属于当前修订的严格命名手机缩略图缓存,未泛化忽略mobile目录。旧缓存失效是scene摘要改变后既有PreviewCache.reconcile的预期清理。
板端存在原有BlueZ LEAdvertisement1 TxPower属性告警,服务启动及健康通过;此告警未在本轮修改范围处理,不写为已消除。相关共享协议回归通过。
## 可重复入口和经验
稳定需求见整体开发需求/01_网页配置端需求.md;测试映射与经验见测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md;跨端兼容见移动端相关内容/开发要求/跨端兼容与变更记录.md。浏览器入口为核桃派软件源代码/scripts/check_layers_browser.mjs,调用方提供已有Playwright page与隔离mock URL。一次性SSH载荷、设备测试脚本和隔离测试数据在本轮完成后精确清理,持续使用的测试环境、凭据与主机密钥保留。
最后部署版本的板端预检508通过/7跳过(device-deployment-deploy-ready.log),完整重启和健康检查通过。7跳过对应源载荷没有完整Git1项、没有本机harness3项、没有大型镜像构建依赖1项、板端没有Node的跨语言对比1项,以及旧OTA子进程probe1项;Node/Pillow对比已在本机执行。
设备实际REST保存重载、中央PNG输出、文字裁切状态、独立图层ID、单帧/全部帧追加、原间隔及If-Match冲突、64/192文字预览全部通过(device-rest.log)。测试创建的独立模板与两帧动图均删除;之后基线复核9模板/6动图/195帧以及其他业务持久文件全部保持一致(device-ready-verification.log)。
最终网页桌面验收通过、6次原生确认、无pageerror;320px窄屏触摸整层X移动10像素、中央屏幕边框可见,切到视线模式后重新打开菜单仍可使用偏移面板。菜单坐标x74/y394、尺寸224×438,在320×844视口内,更多控件可滚动。截图见device-desktop-menu.png和device-mobile-menu.png;结果见device-browser.json。一次性浏览器操作曾引用错误的canvas选择器及错误地二次切换已打开菜单,导致自动化超时;改为实际DOM选择器和逐步检查后通过,应用源码无需修改。
校验已安装的33个变更/新增源码文件SHA-256与本机一致;VERSION为1.1.3、FEATURE_UPDATED_AT为2026-10-09T15:27+08:00(installed-source.log)。驱动截止丢失0、OE脉冲故障0、最后错误null;未改变显示模式进行视觉测试。
已清理远端本轮独立目录 `/run/matrix-screen-controller-layers-20261009`,其中包括离线部署tar、wheel副本、frpc副本、测试脚本和文件校验清单(device-cleanup.log);更新脚本的stage/data副本由既有事务清理。浏览器与本轮SSH本地转发已关闭。本机一次性目录 `测试相关资料/如何测试/本机测试环境/.local/work/layers-implementation` 的精确删除被自动安全审批拒绝(仅返回 blocked by policy),未更换手段重试。该目录已无使用进程、所需证据已归档,可由用户手动删除;测试venv、私有凭据及主机密钥继续保留。
最后再次比对所有设备用户内容并确认37个本轮远端载荷、stage、rollback、迁移及测试绝对路径均不存在(device-cleanup-confirmed.log)。本机安全拦截使删除临时目录与随后检查的组合命令整体未执行;本轮仅重新执行证据复制及只读检查,未再次请求删除。
@@ -0,0 +1,248 @@
requirements 未变化,跳过依赖安装。
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pip check
No broken requirements found.
已清理:<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pytest -q -p no:cacheprovider --basetemp=<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 41%]
....................................sssssss.......s..................... [ 55%]
........................................................................ [ 69%]
.......s................................................................ [ 82%]
........................................................................ [ 96%]
................. [100%]
============================== warnings summary ===============================
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: 10 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: 83 warnings
tests/test_animations.py: 913 warnings
tests/test_api.py: 2075 warnings
tests/test_fonts.py: 249 warnings
tests/test_frp.py: 332 warnings
tests/test_library_order.py: 332 warnings
tests/test_media_conversion.py: 830 warnings
tests/test_mobile_control.py: 1162 warnings
tests/test_ota.py: 249 warnings
tests/test_performance_mode.py: 83 warnings
tests/test_scene_layers.py: 166 warnings
tests/test_templates.py: 664 warnings
tests/test_ui_copy_editor.py: 599 warnings
tests/test_wifi.py: 249 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
app\main.py:2049: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2049: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: 2 warnings
tests/test_animations.py: 22 warnings
tests/test_api.py: 50 warnings
tests/test_fonts.py: 6 warnings
tests/test_frp.py: 8 warnings
tests/test_library_order.py: 8 warnings
tests/test_media_conversion.py: 20 warnings
tests/test_mobile_control.py: 28 warnings
tests/test_ota.py: 6 warnings
tests/test_performance_mode.py: 2 warnings
tests/test_scene_layers.py: 4 warnings
tests/test_templates.py: 16 warnings
tests/test_ui_copy_editor.py: 14 warnings
tests/test_wifi.py: 6 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type)
app\main.py:2079: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2079: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("shutdown")
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
<仓库根目录>\核桃派软件源代码\tests\test_media_conversion.py:134: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1
tests/test_templates.py::test_template_crud_copy_persistence_and_thumbnail_cleanup
<仓库根目录>\核桃派软件源代码\tests\test_templates.py:165: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert len(set(image.getdata())) > 1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
512 passed, 9 skipped, 8390 warnings in 162.85s (0:02:42)
> "C:\Program Files\nodejs\node.EXE" --test tests/test_animation_ui.mjs tests/test_canvas_tools.mjs tests/test_color_math.mjs tests/test_current_display_playback_ui.mjs tests/test_device_ui.mjs tests/test_font_picker.mjs tests/test_frp_ui.mjs tests/test_interaction_feedback.mjs tests/test_scene_model.mjs tests/test_scene_layers.mjs tests/test_settings_ui.mjs tests/test_template_management_ui.mjs tests/test_ui_copy_editor.mjs tests/test_wifi_ui.mjs tests/test_workspace_order_ui.mjs
✔ derives automatic frame names from current animation name and position (0.8905ms)
✔ keeps custom frame names and restores automatic names when cleared (0.1147ms)
✔ builds non-repeating animation edit context names (0.1237ms)
✔ selects saved animation preview frames by ordered durations (0.764ms)
✔ loads every preview frame through revision-checked pagination (0.6488ms)
✔ validates configurable animation preview concurrency (0.3821ms)
✔ rejects mixed revisions instead of showing a partial animation (1.5654ms)
✔ validates frame duration only when explicitly called (0.4885ms)
✔ moves selected frames as one ordered group (0.2723ms)
✔ protects an eight pixel ring around interactive drag controls (0.2357ms)
✔ reorders mixed library keys without mutating the source list (0.1522ms)
✔ restores drag eligibility after protected pointer cancellation and drag end (0.5359ms)
✔ animation workspace exposes two previews and the simplified control order (7.0698ms)
✔ normal and pixel modes expose independent ranges and presets (1.3121ms)
✔ pixel stamps are exact squares from 1x1 through 4x4 (0.2387ms)
✔ pixel stamps clip at every canvas edge (0.1973ms)
✔ normal circle geometry remains unchanged (0.0999ms)
✔ line interpolation includes endpoints without coordinate gaps (0.2263ms)
✔ older tools migrate while v3 restores the coordinate guide choice (0.2476ms)
✔ coordinate guide spacing adapts to the transformed logical pixel size (0.1444ms)
✔ coordinate guide ticks preserve logical values through zoom and pan (0.2051ms)
✔ pixel mode markup and grid stay accessible and non-interactive (9.0143ms)
✔ pixel undo history stores changed snapshots, caps entries, and clears (0.5226ms)
✔ canvas view clamps scale and keeps a visible section in the viewport (0.2232ms)
✔ zooming around an anchor preserves its logical position (0.2588ms)
✔ floating editor button stays inside its safe viewport bounds (0.1569ms)
✔ canvas editor markup, guarded input, and checkpoint events stay wired (5.1933ms)
✔ normalizes and converts hexadecimal colors (1.3708ms)
✔ converts RGB and HSV boundaries (0.2687ms)
✔ round trips representative colors within 8-bit rounding (0.2699ms)
✔ recent colors deduplicate, normalize, and evict the oldest (0.3041ms)
✔ formats and projects looping playback without moving paused content (1.121ms)
✔ seek dispatcher keeps one request in flight and flushes the newest position (4.2348ms)
✔ topbar current display dialog exposes static close and animation controls (6.5266ms)
✔ device workspace exposes the compact immediate pure-color test flow (6.1077ms)
✔ font catalog normalization, grouping, and search are stable (1.2991ms)
✔ font picker implements commit-only searchable combobox keyboard behavior (4.3202ms)
✔ settings places unrestricted FRP maintenance at the bottom (4.7159ms)
✔ FRP editor uses raw server storage and official validation errors (1.1139ms)
✔ network diagnostics renders layered evidence (1.7464ms)
✔ unavailable controls never masquerade as indefinite loading (6.3779ms)
✔ action controls do not bypass the shared state module (7.9189ms)
✔ HTTP requests time out with a stable user-facing error (15.7531ms)
✔ demo library actions stay black, explain restrictions, and preserve copy (1.5646ms)
✔ sortable cards protect controls and keep demo cards fixed (1.4711ms)
✔ old scene migration rejects unknown fields and duplicate text without filtering (9.4326ms)
✔ PNG compression round trips sparse and full brush pixels without DOM (18.4773ms)
✔ blank and black are distinct; independent backgrounds follow layer order (5.5719ms)
✔ disabled background colors stay independent through storage and copy (18.2751ms)
✔ extended pixels survive storage but never enter the central output (5.6184ms)
✔ move preview does not mutate source; confirmed clipping removes only outside ink (20.7736ms)
✔ text clipping follows anchor and scale and is retained after edit/reload/copy (6.3203ms)
✔ multiple text groups isolate edits, copies and IDs (0.5384ms)
✔ strict v2 storage protects unknown data and the final brush invariant (2.9911ms)
✔ creates a fixed 64x64 RGB scene (8.3545ms)
✔ serializes pixelRgb as base64 and round trips an independent scene (30.0145ms)
✔ moves a valid v1 draft to the stable key before removing exact legacy keys (22.8032ms)
✔ keeps the v1 draft and all legacy keys when stable-key migration cannot write (6.6069ms)
✔ preserves and blocks a future stable draft instead of falling back or writing blank (7.6802ms)
✔ preserves and blocks a damaged stable draft (0.9426ms)
✔ does not discard a damaged higher-priority legacy key during fallback migration (2.7322ms)
✔ rejects damaged scene roots while skipping only damaged elements (5.4182ms)
✔ normalizes text fields and creates a centered lowercase ok element (0.2989ms)
✔ migrates the first valid canvas by v3, v2, legacy priority and merges text v1 (8.9943ms)
✔ supports independent add, update, and delete operations for multiple text elements (1.1051ms)
✔ duplicates with a deep copy, new id, 2px offset, and topmost order (0.324ms)
✔ alpha-composites full RGBA layers in array order without mutating inputs (2.0929ms)
✔ settings brightness explains an active temporary display-test override (6.587ms)
✔ settings exposes full OTA upload progress and service recovery polling (4.7051ms)
✔ settings automatically opens and safely copies a detailed OTA failure log (2.3833ms)
✔ settings exposes a transactional performance-mode switch with the fixed warning (1.496ms)
✔ settings exposes persisted animation preview concurrency below performance mode (1.9836ms)
✔ template edit baseline distinguishes clean, dirty, and unbound scenes (2.2967ms)
✔ template manager uses a monitor and unified four-row card actions (6.6472ms)
✔ animation editing backs up and restores template edit ownership (1.4212ms)
✔ special editor exposes the agreed modes and guarded editing flow (5.8489ms)
✔ latched WiFi draft state remains dirty until explicitly cleared (1.2836ms)
✔ WiFi settings expose independent network and prompt-delay controls (2.6322ms)
✔ workspace order resolves saved ids and appends new registrations (1.1186ms)
✔ workspace order movement is immutable and respects both boundaries (0.2128ms)
✔ drawer exposes one editable list and commits only from the save action (5.9252ms)
ℹ tests 81
ℹ suites 0
ℹ pass 81
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 594.5022
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\animation-thumbnail-preview.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\animation-ui-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\app.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\canvas-tools-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\color-math.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\color-picker.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\core.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\current-display-playback.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\font-picker.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\interaction-feedback.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\media-crop-geometry.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\media-import-ui-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\pixel-png.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-board.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-model-v1.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\sortable-card.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\template-edit-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\ui-copy-runtime.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\vendor\fflate\fflate.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\animations.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\canvas.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\device.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\frp-settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\layers.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\media-import.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\templates.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\text.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\wifi-settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\wifi-draft-state.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\workspace-order.js
> "C:\Program Files\nodejs\node.EXE" --check app/ui_copy_editor/static/editor.js
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe scripts/build_ui_copy_catalog.py --check
ui-copy.json is current
本机测试套件通过:all
@@ -0,0 +1,679 @@
requirements 未变化,跳过依赖安装。
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pip check
No broken requirements found.
已清理:<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pytest -q -p no:cacheprovider --basetemp=<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 41%]
...................................Fsssssss.......s..................... [ 55%]
........................................................................ [ 69%]
.......s................................................................ [ 82%]
........................................................................ [ 96%]
................. [100%]
================================== FAILURES ===================================
__ test_media_settings_api_accepts_outside_centers_but_rejects_an_empty_view __
+ Exception Group Traceback (most recent call last):
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_utils.py", line 79, in collapse_excgroups
| yield
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 183, in __call__
| async with anyio.create_task_group() as task_group:
| ~~~~~~~~~~~~~~~~~~~~~~~^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 815, in __aexit__
| raise BaseExceptionGroup(
| "unhandled errors in a TaskGroup", self._exceptions
| ) from None
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\runner.py", line 344, in from_call
| result: TResult | None = func()
| ~~~~^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\runner.py", line 246, in <lambda>
| lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
| ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_hooks.py", line 512, in __call__
| return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
| ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_manager.py", line 120, in _hookexec
| return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
| ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_callers.py", line 167, in _multicall
| raise exception
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_callers.py", line 139, in _multicall
| teardown.throw(exception)
| ~~~~~~~~~~~~~~^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\logging.py", line 850, in pytest_runtest_call
| yield
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_callers.py", line 139, in _multicall
| teardown.throw(exception)
| ~~~~~~~~~~~~~~^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\capture.py", line 900, in pytest_runtest_call
| return (yield)
| ^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_callers.py", line 139, in _multicall
| teardown.throw(exception)
| ~~~~~~~~~~~~~~^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\skipping.py", line 263, in pytest_runtest_call
| return (yield)
| ^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_callers.py", line 121, in _multicall
| res = hook_impl.function(*args)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\runner.py", line 178, in pytest_runtest_call
| item.runtest()
| ~~~~~~~~~~~~^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\python.py", line 1671, in runtest
| self.ihook.pytest_pyfunc_call(pyfuncitem=self)
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_hooks.py", line 512, in __call__
| return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
| ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_manager.py", line 120, in _hookexec
| return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
| ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_callers.py", line 167, in _multicall
| raise exception
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\pluggy\_callers.py", line 121, in _multicall
| res = hook_impl.function(*args)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\_pytest\python.py", line 157, in pytest_pyfunc_call
| result = testfunction(**testargs)
| File "<仓库根目录>\核桃派软件源代码\tests\test_media_conversion.py", line 347, in test_media_settings_api_accepts_outside_centers_but_rejects_an_empty_view
| accepted = client.put(
| "/api/media-imports/" + job_id + "/settings",
| json={"center_x": -31 / 64, "center_y": -31 / 32},
| )
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 583, in put
| return super().put(
| ~~~~~~~~~~~^
| url,
| ^^^^
| ...<10 lines>...
| extensions=extensions,
| ^^^^^^^^^^^^^^^^^^^^^^
| )
| ^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 1181, in put
| return self.request(
| ~~~~~~~~~~~~^
| "PUT",
| ^^^^^^
| ...<11 lines>...
| extensions=extensions,
| ^^^^^^^^^^^^^^^^^^^^^^
| )
| ^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 451, in request
| return super().request(
| ~~~~~~~~~~~~~~~^
| method,
| ^^^^^^^
| ...<11 lines>...
| extensions=extensions,
| ^^^^^^^^^^^^^^^^^^^^^^
| )
| ^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 825, in request
| return self.send(request, auth=auth, follow_redirects=follow_redirects)
| ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 914, in send
| response = self._send_handling_auth(
| request,
| ...<2 lines>...
| history=[],
| )
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 942, in _send_handling_auth
| response = self._send_handling_redirects(
| request,
| follow_redirects=follow_redirects,
| history=history,
| )
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 979, in _send_handling_redirects
| response = self._send_single_request(request)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 1014, in _send_single_request
| response = transport.handle_request(request)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 354, in handle_request
| raise exc
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 351, in handle_request
| portal.call(self.app, scope, receive, send)
| ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\from_thread.py", line 338, in call
| return cast(T_Retval, self.start_task_soon(func, *args).result())
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
| File "<开发机用户目录>\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\_base.py", line 450, in result
| return self.__get_result()
| ~~~~~~~~~~~~~~~~~^^
| File "<开发机用户目录>\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\_base.py", line 395, in __get_result
| raise self._exception
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\from_thread.py", line 263, in _call_func
| retval = await retval_or_awaitable
| ^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
| await super().__call__(scope, receive, send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
| await self.middleware_stack(scope, receive, send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\errors.py", line 186, in __call__
| raise exc
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
| await self.app(scope, receive, _send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 182, in __call__
| with recv_stream, send_stream, collapse_excgroups():
| ~~~~~~~~~~~~~~~~~~^^
| File "<开发机用户目录>\AppData\Local\Programs\Python\Python314\Lib\contextlib.py", line 162, in __exit__
| self.gen.throw(value)
| ~~~~~~~~~~~~~~^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_utils.py", line 85, in collapse_excgroups
| raise exc
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 184, in __call__
| response = await self.dispatch_func(request, call_next)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\核桃派软件源代码\app\main.py", line 760, in cache_policy
| response = await call_next(request)
| ^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 159, in call_next
| raise app_exc
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
| await self.app(scope, receive_or_disconnect, send_no_error)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
| raise exc
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
| await app(scope, receive, sender)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 716, in __call__
| await self.middleware_stack(scope, receive, send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 736, in app
| await route.handle(scope, receive, send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 290, in handle
| await self.app(scope, receive, send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 78, in app
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
| raise exc
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
| await app(scope, receive, sender)
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 75, in app
| response = await f(request)
| ^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py", line 302, in app
| raw_response = await run_endpoint_function(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| ...<3 lines>...
| )
| ^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py", line 215, in run_endpoint_function
| return await run_in_threadpool(dependant.call, **values)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\concurrency.py", line 38, in run_in_threadpool
| return await anyio.to_thread.run_sync(func)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\to_thread.py", line 65, in run_sync
| return await get_async_backend().run_sync_in_worker_thread(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| )
| ^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2641, in run_sync_in_worker_thread
| return await future
| ^^^^^^^^^^^^
| File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1033, in run
| result = context.run(func, *args)
| File "<仓库根目录>\核桃派软件源代码\app\main.py", line 1527, in update_media_settings
| return media_imports.update_settings(
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
| job_id, request.model_dump(exclude_unset=True, exclude_none=True),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| )
| ^
| File "<仓库根目录>\核桃派软件源代码\app\media\manager.py", line 463, in update_settings
| self._write(record)
| ~~~~~~~~~~~^^^^^^^^
| File "<仓库根目录>\核桃派软件源代码\app\media\manager.py", line 137, in _write
| atomic_write_bytes(self._path(record["id"]), payload)
| ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\核桃派软件源代码\app\persistence.py", line 14, in atomic_write_bytes
| atomic_write_many_bytes(((Path(path), content),))
| ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "<仓库根目录>\核桃派软件源代码\app\persistence.py", line 41, in atomic_write_many_bytes
| os.replace(temporaries[target], target)
| ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| PermissionError: [WinError 5] 拒绝访问。: 'C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\work\\pytest\\test_media_settings_api_accept0\\data\\media-import\\jobs\\9905d4cf-ffe2-4270-91a2-8fd6579b252a\\.job.json.45cf7fdfecf34a80a4164eb8f5171514.tmp' -> 'C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\work\\pytest\\test_media_settings_api_accept0\\data\\media-import\\jobs\\9905d4cf-ffe2-4270-91a2-8fd6579b252a\\job.json'
+------------------------------------
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<仓库根目录>\核桃派软件源代码\tests\test_media_conversion.py", line 347, in test_media_settings_api_accepts_outside_centers_but_rejects_an_empty_view
accepted = client.put(
"/api/media-imports/" + job_id + "/settings",
json={"center_x": -31 / 64, "center_y": -31 / 32},
)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 583, in put
return super().put(
~~~~~~~~~~~^
url,
^^^^
...<10 lines>...
extensions=extensions,
^^^^^^^^^^^^^^^^^^^^^^
)
^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 1181, in put
return self.request(
~~~~~~~~~~~~^
"PUT",
^^^^^^
...<11 lines>...
extensions=extensions,
^^^^^^^^^^^^^^^^^^^^^^
)
^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 451, in request
return super().request(
~~~~~~~~~~~~~~~^
method,
^^^^^^^
...<11 lines>...
extensions=extensions,
^^^^^^^^^^^^^^^^^^^^^^
)
^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 825, in request
return self.send(request, auth=auth, follow_redirects=follow_redirects)
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 914, in send
response = self._send_handling_auth(
request,
...<2 lines>...
history=[],
)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 942, in _send_handling_auth
response = self._send_handling_redirects(
request,
follow_redirects=follow_redirects,
history=history,
)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 979, in _send_handling_redirects
response = self._send_single_request(request)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py", line 1014, in _send_single_request
response = transport.handle_request(request)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 354, in handle_request
raise exc
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py", line 351, in handle_request
portal.call(self.app, scope, receive, send)
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\from_thread.py", line 338, in call
return cast(T_Retval, self.start_task_soon(func, *args).result())
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "<开发机用户目录>\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\_base.py", line 450, in result
return self.__get_result()
~~~~~~~~~~~~~~~~~^^
File "<开发机用户目录>\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\_base.py", line 395, in __get_result
raise self._exception
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\from_thread.py", line 263, in _call_func
retval = await retval_or_awaitable
^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\errors.py", line 186, in __call__
raise exc
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 182, in __call__
with recv_stream, send_stream, collapse_excgroups():
~~~~~~~~~~~~~~~~~~^^
File "<开发机用户目录>\AppData\Local\Programs\Python\Python314\Lib\contextlib.py", line 162, in __exit__
self.gen.throw(value)
~~~~~~~~~~~~~~^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_utils.py", line 85, in collapse_excgroups
raise exc
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 184, in __call__
response = await self.dispatch_func(request, call_next)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\核桃派软件源代码\app\main.py", line 760, in cache_policy
response = await call_next(request)
^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 159, in call_next
raise app_exc
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py", line 144, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
raise exc
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 716, in __call__
await self.middleware_stack(scope, receive, send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 736, in app
await route.handle(scope, receive, send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 290, in handle
await self.app(scope, receive, send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 78, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
raise exc
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py", line 75, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py", line 302, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py", line 215, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\concurrency.py", line 38, in run_in_threadpool
return await anyio.to_thread.run_sync(func)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\to_thread.py", line 65, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2641, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1033, in run
result = context.run(func, *args)
File "<仓库根目录>\核桃派软件源代码\app\main.py", line 1527, in update_media_settings
return media_imports.update_settings(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
job_id, request.model_dump(exclude_unset=True, exclude_none=True),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "<仓库根目录>\核桃派软件源代码\app\media\manager.py", line 463, in update_settings
self._write(record)
~~~~~~~~~~~^^^^^^^^
File "<仓库根目录>\核桃派软件源代码\app\media\manager.py", line 137, in _write
atomic_write_bytes(self._path(record["id"]), payload)
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\核桃派软件源代码\app\persistence.py", line 14, in atomic_write_bytes
atomic_write_many_bytes(((Path(path), content),))
~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<仓库根目录>\核桃派软件源代码\app\persistence.py", line 41, in atomic_write_many_bytes
os.replace(temporaries[target], target)
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PermissionError: [WinError 5] 拒绝访问。: 'C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\work\\pytest\\test_media_settings_api_accept0\\data\\media-import\\jobs\\9905d4cf-ffe2-4270-91a2-8fd6579b252a\\.job.json.45cf7fdfecf34a80a4164eb8f5171514.tmp' -> 'C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\work\\pytest\\test_media_settings_api_accept0\\data\\media-import\\jobs\\9905d4cf-ffe2-4270-91a2-8fd6579b252a\\job.json'
During handling of the above exception, another exception occurred:
tmp_path = WindowsPath('<仓库根目录>/测试相关资料/如何测试/本机测试环境/work/pytest/test_media_settings_api_accept0')
def test_media_settings_api_accepts_outside_centers_but_rejects_an_empty_view(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
payload = png_bytes(Image.new("RGB", (2, 1), "white"))
with TestClient(app) as client:
uploaded = client.post(
"/api/media-imports?filename=a.png&name=自由取景", content=payload,
headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))},
)
job_id = uploaded.json()["id"]
wait_for(client, job_id, "awaiting_settings")
> accepted = client.put(
"/api/media-imports/" + job_id + "/settings",
json={"center_x": -31 / 64, "center_y": -31 / 32},
)
tests\test_media_conversion.py:347:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py:583: in put
return super().put(
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py:1181: in put
return self.request(
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py:451: in request
return super().request(
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py:825: in request
return self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py:914: in send
response = self._send_handling_auth(
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py:942: in _send_handling_auth
response = self._send_handling_redirects(
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py:979: in _send_handling_redirects
response = self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\httpx\_client.py:1014: in _send_single_request
response = transport.handle_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py:354: in handle_request
raise exc
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\testclient.py:351: in handle_request
portal.call(self.app, scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\from_thread.py:338: in call
return cast(T_Retval, self.start_task_soon(func, *args).result())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\..\..\..\..\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\_base.py:450: in result
return self.__get_result()
^^^^^^^^^^^^^^^^^^^
..\..\..\..\..\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\_base.py:395: in __get_result
raise self._exception
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\from_thread.py:263: in _call_func
retval = await retval_or_awaitable
^^^^^^^^^^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:1054: in __call__
await super().__call__(scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\applications.py:113: in __call__
await self.middleware_stack(scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\errors.py:186: in __call__
raise exc
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\errors.py:164: in __call__
await self.app(scope, receive, _send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py:182: in __call__
with recv_stream, send_stream, collapse_excgroups():
^^^^^^^^^^^^^^^^^^^^
..\..\..\..\..\AppData\Local\Programs\Python\Python314\Lib\contextlib.py:162: in __exit__
self.gen.throw(value)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_utils.py:85: in collapse_excgroups
raise exc
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py:184: in __call__
response = await self.dispatch_func(request, call_next)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
app\main.py:760: in cache_policy
response = await call_next(request)
^^^^^^^^^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py:159: in call_next
raise app_exc
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\base.py:144: in coro
await self.app(scope, receive_or_disconnect, send_no_error)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\middleware\exceptions.py:63: in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py:53: in wrapped_app
raise exc
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py:42: in wrapped_app
await app(scope, receive, sender)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py:716: in __call__
await self.middleware_stack(scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py:736: in app
await route.handle(scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py:290: in handle
await self.app(scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py:78: in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py:53: in wrapped_app
raise exc
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\_exception_handler.py:42: in wrapped_app
await app(scope, receive, sender)
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\routing.py:75: in app
response = await f(request)
^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:302: in app
raw_response = await run_endpoint_function(
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:215: in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\starlette\concurrency.py:38: in run_in_threadpool
return await anyio.to_thread.run_sync(func)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\to_thread.py:65: in run_sync
return await get_async_backend().run_sync_in_worker_thread(
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\_backends\_asyncio.py:2641: in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\anyio\_backends\_asyncio.py:1033: in run
result = context.run(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^
app\main.py:1527: in update_media_settings
return media_imports.update_settings(
app\media\manager.py:463: in update_settings
self._write(record)
app\media\manager.py:137: in _write
atomic_write_bytes(self._path(record["id"]), payload)
app\persistence.py:14: in atomic_write_bytes
atomic_write_many_bytes(((Path(path), content),))
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
writes = ((WindowsPath('<仓库根目录>/测试相关资料/如何测试/本机测试环境/work/pytest/test_media_setting... },\n "source_size": 72,\n "state": "awaiting_settings",\n "updated_at": "2026-10-09T07:13:23.922089+00:00"\n}\n'),)
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)
E PermissionError: [WinError 5] 拒绝访问。: 'C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\work\\pytest\\test_media_settings_api_accept0\\data\\media-import\\jobs\\9905d4cf-ffe2-4270-91a2-8fd6579b252a\\.job.json.45cf7fdfecf34a80a4164eb8f5171514.tmp' -> 'C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\work\\pytest\\test_media_settings_api_accept0\\data\\media-import\\jobs\\9905d4cf-ffe2-4270-91a2-8fd6579b252a\\job.json'
app\persistence.py:41: PermissionError
------------------------------ Captured log call ------------------------------
WARNING app.power.voltage:voltage.py:601 Screen voltage sensor status=disconnected error_code=unsupported_platform
WARNING app.monitoring.resources:resources.py:249 System resource monitor is unavailable: proc_read_error
============================== warnings summary ===============================
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: 10 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: 83 warnings
tests/test_animations.py: 913 warnings
tests/test_api.py: 2075 warnings
tests/test_fonts.py: 249 warnings
tests/test_frp.py: 332 warnings
tests/test_library_order.py: 332 warnings
tests/test_media_conversion.py: 830 warnings
tests/test_mobile_control.py: 1162 warnings
tests/test_ota.py: 249 warnings
tests/test_performance_mode.py: 83 warnings
tests/test_scene_layers.py: 166 warnings
tests/test_templates.py: 664 warnings
tests/test_ui_copy_editor.py: 599 warnings
tests/test_wifi.py: 249 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
app\main.py:2049: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2049: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: 2 warnings
tests/test_animations.py: 22 warnings
tests/test_api.py: 50 warnings
tests/test_fonts.py: 6 warnings
tests/test_frp.py: 8 warnings
tests/test_library_order.py: 8 warnings
tests/test_media_conversion.py: 20 warnings
tests/test_mobile_control.py: 28 warnings
tests/test_ota.py: 6 warnings
tests/test_performance_mode.py: 2 warnings
tests/test_scene_layers.py: 4 warnings
tests/test_templates.py: 16 warnings
tests/test_ui_copy_editor.py: 14 warnings
tests/test_wifi.py: 6 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type)
app\main.py:2079: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2079: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("shutdown")
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
<仓库根目录>\核桃派软件源代码\tests\test_media_conversion.py:134: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1
tests/test_templates.py::test_template_crud_copy_persistence_and_thumbnail_cleanup
<仓库根目录>\核桃派软件源代码\tests\test_templates.py:165: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert len(set(image.getdata())) > 1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ===========================
FAILED tests/test_media_conversion.py::test_media_settings_api_accepts_outside_centers_but_rejects_an_empty_view
1 failed, 511 passed, 9 skipped, 8390 warnings in 133.37s (0:02:13)
本机测试工具失败:Command '['C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\.venv\\Scripts\\python.exe', '-m', 'pytest', '-q', '-p', 'no:cacheprovider', '--basetemp=C:\\Users\\<开发机用户名>\\Documents\\wenjian\\1.work\\奇妙小屏幕控制器-核桃派\\测试相关资料\\如何测试\\本机测试环境\\work\\pytest']' returned non-zero exit status 1.
@@ -0,0 +1,248 @@
requirements 未变化,跳过依赖安装。
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pip check
No broken requirements found.
已清理:<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe -m pytest -q -p no:cacheprovider --basetemp=<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\pytest
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 41%]
....................................sssssss.......s..................... [ 55%]
........................................................................ [ 68%]
.......s................................................................ [ 82%]
........................................................................ [ 96%]
.................. [100%]
============================== warnings summary ===============================
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: 10 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: 83 warnings
tests/test_animations.py: 913 warnings
tests/test_api.py: 2075 warnings
tests/test_fonts.py: 249 warnings
tests/test_frp.py: 332 warnings
tests/test_library_order.py: 332 warnings
tests/test_media_conversion.py: 830 warnings
tests/test_mobile_control.py: 1162 warnings
tests/test_ota.py: 249 warnings
tests/test_performance_mode.py: 83 warnings
tests/test_scene_layers.py: 166 warnings
tests/test_templates.py: 664 warnings
tests/test_ui_copy_editor.py: 599 warnings
tests/test_wifi.py: 249 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
app\main.py:2049: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2049: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
..\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: 2 warnings
tests/test_animations.py: 22 warnings
tests/test_api.py: 50 warnings
tests/test_fonts.py: 6 warnings
tests/test_frp.py: 8 warnings
tests/test_library_order.py: 8 warnings
tests/test_media_conversion.py: 20 warnings
tests/test_mobile_control.py: 28 warnings
tests/test_ota.py: 6 warnings
tests/test_performance_mode.py: 2 warnings
tests/test_scene_layers.py: 4 warnings
tests/test_templates.py: 16 warnings
tests/test_ui_copy_editor.py: 14 warnings
tests/test_wifi.py: 6 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type)
app\main.py:2079: 1 warning
tests/test_animations.py: 11 warnings
tests/test_api.py: 25 warnings
tests/test_fonts.py: 3 warnings
tests/test_frp.py: 4 warnings
tests/test_library_order.py: 4 warnings
tests/test_media_conversion.py: 10 warnings
tests/test_mobile_control.py: 14 warnings
tests/test_ota.py: 3 warnings
tests/test_performance_mode.py: 1 warning
tests/test_scene_layers.py: 2 warnings
tests/test_templates.py: 8 warnings
tests/test_ui_copy_editor.py: 7 warnings
tests/test_wifi.py: 3 warnings
<仓库根目录>\核桃派软件源代码\app\main.py:2079: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("shutdown")
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner
<仓库根目录>\核桃派软件源代码\tests\test_media_conversion.py:134: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1
tests/test_templates.py::test_template_crud_copy_persistence_and_thumbnail_cleanup
<仓库根目录>\核桃派软件源代码\tests\test_templates.py:165: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
assert len(set(image.getdata())) > 1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
513 passed, 9 skipped, 8390 warnings in 80.59s (0:01:20)
> "C:\Program Files\nodejs\node.EXE" --test tests/test_animation_ui.mjs tests/test_canvas_tools.mjs tests/test_color_math.mjs tests/test_current_display_playback_ui.mjs tests/test_device_ui.mjs tests/test_font_picker.mjs tests/test_frp_ui.mjs tests/test_interaction_feedback.mjs tests/test_scene_model.mjs tests/test_scene_layers.mjs tests/test_settings_ui.mjs tests/test_template_management_ui.mjs tests/test_ui_copy_editor.mjs tests/test_wifi_ui.mjs tests/test_workspace_order_ui.mjs
✔ derives automatic frame names from current animation name and position (0.9515ms)
✔ keeps custom frame names and restores automatic names when cleared (0.1245ms)
✔ builds non-repeating animation edit context names (0.261ms)
✔ selects saved animation preview frames by ordered durations (1.2698ms)
✔ loads every preview frame through revision-checked pagination (0.9708ms)
✔ validates configurable animation preview concurrency (0.3327ms)
✔ rejects mixed revisions instead of showing a partial animation (0.5586ms)
✔ validates frame duration only when explicitly called (0.4824ms)
✔ moves selected frames as one ordered group (0.2395ms)
✔ protects an eight pixel ring around interactive drag controls (0.2648ms)
✔ reorders mixed library keys without mutating the source list (0.1429ms)
✔ restores drag eligibility after protected pointer cancellation and drag end (0.4837ms)
✔ animation workspace exposes two previews and the simplified control order (7.7914ms)
✔ normal and pixel modes expose independent ranges and presets (1.4623ms)
✔ pixel stamps are exact squares from 1x1 through 4x4 (0.2562ms)
✔ pixel stamps clip at every canvas edge (0.2164ms)
✔ normal circle geometry remains unchanged (0.1049ms)
✔ line interpolation includes endpoints without coordinate gaps (0.2417ms)
✔ older tools migrate while v3 restores the coordinate guide choice (0.2446ms)
✔ coordinate guide spacing adapts to the transformed logical pixel size (0.1592ms)
✔ coordinate guide ticks preserve logical values through zoom and pan (0.2081ms)
✔ pixel mode markup and grid stay accessible and non-interactive (7.7742ms)
✔ pixel undo history stores changed snapshots, caps entries, and clears (0.4387ms)
✔ canvas view clamps scale and keeps a visible section in the viewport (0.188ms)
✔ zooming around an anchor preserves its logical position (0.13ms)
✔ floating editor button stays inside its safe viewport bounds (0.0947ms)
✔ canvas editor markup, guarded input, and checkpoint events stay wired (3.4305ms)
✔ normalizes and converts hexadecimal colors (1.9821ms)
✔ converts RGB and HSV boundaries (0.5703ms)
✔ round trips representative colors within 8-bit rounding (0.5308ms)
✔ recent colors deduplicate, normalize, and evict the oldest (0.5626ms)
✔ formats and projects looping playback without moving paused content (1.1975ms)
✔ seek dispatcher keeps one request in flight and flushes the newest position (4.4609ms)
✔ topbar current display dialog exposes static close and animation controls (6.2645ms)
✔ device workspace exposes the compact immediate pure-color test flow (9.8628ms)
✔ font catalog normalization, grouping, and search are stable (1.5036ms)
✔ font picker implements commit-only searchable combobox keyboard behavior (5.6835ms)
✔ settings places unrestricted FRP maintenance at the bottom (6.9486ms)
✔ FRP editor uses raw server storage and official validation errors (2.167ms)
✔ network diagnostics renders layered evidence (2.1028ms)
✔ unavailable controls never masquerade as indefinite loading (10.083ms)
✔ action controls do not bypass the shared state module (11.6691ms)
✔ HTTP requests time out with a stable user-facing error (22.3421ms)
✔ demo library actions stay black, explain restrictions, and preserve copy (3.2743ms)
✔ sortable cards protect controls and keep demo cards fixed (2.9684ms)
✔ old scene migration rejects unknown fields and duplicate text without filtering (64.7563ms)
✔ PNG compression round trips sparse and full brush pixels without DOM (6.196ms)
✔ blank and black are distinct; independent backgrounds follow layer order (6.6852ms)
✔ disabled background colors stay independent through storage and copy (22.0249ms)
✔ extended pixels survive storage but never enter the central output (9.2346ms)
✔ move preview does not mutate source; confirmed clipping removes only outside ink (19.7505ms)
✔ text clipping follows anchor and scale and is retained after edit/reload/copy (13.7559ms)
✔ multiple text groups isolate edits, copies and IDs (1.2854ms)
✔ strict v2 storage protects unknown data and the final brush invariant (8.3614ms)
✔ creates a fixed 64x64 RGB scene (10.9456ms)
✔ serializes pixelRgb as base64 and round trips an independent scene (37.0409ms)
✔ moves a valid v1 draft to the stable key before removing exact legacy keys (49.945ms)
✔ keeps the v1 draft and all legacy keys when stable-key migration cannot write (24.5337ms)
✔ preserves and blocks a future stable draft instead of falling back or writing blank (8.204ms)
✔ preserves and blocks a damaged stable draft (0.4319ms)
✔ does not discard a damaged higher-priority legacy key during fallback migration (2.5171ms)
✔ rejects damaged scene roots while skipping only damaged elements (14.877ms)
✔ normalizes text fields and creates a centered lowercase ok element (0.6562ms)
✔ migrates the first valid canvas by v3, v2, legacy priority and merges text v1 (21.5273ms)
✔ supports independent add, update, and delete operations for multiple text elements (0.9531ms)
✔ duplicates with a deep copy, new id, 2px offset, and topmost order (0.6196ms)
✔ alpha-composites full RGBA layers in array order without mutating inputs (3.6943ms)
✔ settings brightness explains an active temporary display-test override (9.7684ms)
✔ settings exposes full OTA upload progress and service recovery polling (8.1602ms)
✔ settings automatically opens and safely copies a detailed OTA failure log (8.1723ms)
✔ settings exposes a transactional performance-mode switch with the fixed warning (5.1003ms)
✔ settings exposes persisted animation preview concurrency below performance mode (4.0705ms)
✔ template edit baseline distinguishes clean, dirty, and unbound scenes (3.5809ms)
✔ template manager uses a monitor and unified four-row card actions (14.6767ms)
✔ animation editing backs up and restores template edit ownership (2.2034ms)
✔ special editor exposes the agreed modes and guarded editing flow (14.5121ms)
✔ latched WiFi draft state remains dirty until explicitly cleared (4.2347ms)
✔ WiFi settings expose independent network and prompt-delay controls (6.8702ms)
✔ workspace order resolves saved ids and appends new registrations (1.8016ms)
✔ workspace order movement is immutable and respects both boundaries (0.3724ms)
✔ drawer exposes one editable list and commits only from the save action (10.7414ms)
ℹ tests 81
ℹ suites 0
ℹ pass 81
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 693.5062
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\animation-thumbnail-preview.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\animation-ui-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\app.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\canvas-tools-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\color-math.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\color-picker.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\core.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\current-display-playback.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\font-picker.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\interaction-feedback.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\media-crop-geometry.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\media-import-ui-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\pixel-png.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-board.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-model-v1.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\scene-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\sortable-card.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\template-edit-model.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\ui-copy-runtime.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\vendor\fflate\fflate.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\animations.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\canvas.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\device.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\frp-settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\layers.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\media-import.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\templates.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\text.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\views\wifi-settings.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\wifi-draft-state.js
> "C:\Program Files\nodejs\node.EXE" --check <仓库根目录>\核桃派软件源代码\app\static\workspace-order.js
> "C:\Program Files\nodejs\node.EXE" --check app/ui_copy_editor/static/editor.js
> <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Scripts\python.exe scripts/build_ui_copy_catalog.py --check
ui-copy.json is current
本机测试套件通过:all
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -0,0 +1,15 @@
{
"desktop": {
"passed": true,
"nativeConfirmations": 6,
"layers": 3,
"errors": []
},
"narrowMenu": {
"x": 74,
"y": 394,
"width": 224,
"height": 438
},
"touchLayerX": "10"
}
@@ -0,0 +1 @@
Verified all 37 exact task payload/stage/rollback/test paths absent; user content unchanged.
@@ -0,0 +1,2 @@
私有凭据文件可用;未输出任何凭据值。
Removed exact remote temporary payload: /run/matrix-screen-controller-layers-20261009
@@ -0,0 +1,130 @@
annotated_types-0.8.0-py3-none-any.whl: OK
anyio-4.14.2-py3-none-any.whl: OK
certifi-2026.7.22-py3-none-any.whl: OK
cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl: OK
click-8.4.2-py3-none-any.whl: OK
colorama-0.4.6-py2.py3-none-any.whl: OK
cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl: OK
dbus_next-0.2.3-py3-none-any.whl: OK
fastapi-0.116.1-py3-none-any.whl: OK
fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
h11-0.16.0-py3-none-any.whl: OK
httpcore-1.0.9-py3-none-any.whl: OK
httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
httpx-0.28.1-py3-none-any.whl: OK
idna-3.18-py3-none-any.whl: OK
iniconfig-2.3.0-py3-none-any.whl: OK
packaging-26.3-py3-none-any.whl: OK
pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl: OK
pluggy-1.6.0-py3-none-any.whl: OK
pycparser-3.0-py3-none-any.whl: OK
pydantic-2.13.4-py3-none-any.whl: OK
pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl: OK
pygments-2.20.0-py3-none-any.whl: OK
pytest-8.4.1-py3-none-any.whl: OK
python_dotenv-1.2.2-py3-none-any.whl: OK
pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
starlette-0.47.3-py3-none-any.whl: OK
typing_extensions-4.16.0-py3-none-any.whl: OK
typing_inspection-0.4.2-py3-none-any.whl: OK
uvicorn-0.35.0-py3-none-any.whl: OK
uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl: OK
websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl: OK
Looking in links: /run/[private]-screen-controller-layers-20261009/wheels
Processing /run/[private]-screen-controller-layers-20261009/wheels/fastapi-0.116.1-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/uvicorn-0.35.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/dbus_next-0.2.3-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pytest-8.4.1-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/httpx-0.28.1-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/starlette-0.47.3-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pydantic-2.13.4-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/typing_extensions-4.16.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/click-8.4.2-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/h11-0.16.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/python_dotenv-1.2.2-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/iniconfig-2.3.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/packaging-26.3-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pluggy-1.6.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pygments-2.20.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/anyio-4.14.2-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/certifi-2026.7.22-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/httpcore-1.0.9-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/idna-3.18-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pycparser-3.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/annotated_types-0.8.0-py3-none-any.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Processing /run/[private]-screen-controller-layers-20261009/wheels/typing_inspection-0.4.2-py3-none-any.whl
Installing collected packages: websockets, uvloop, typing-extensions, pyyaml, python-dotenv, pygments, pycparser, pluggy, pillow, packaging, iniconfig, idna, httptools, h11, fonttools, dbus-next, click, certifi, annotated-types, uvicorn, typing-inspection, pytest, pydantic-core, httpcore, cffi, anyio, watchfiles, starlette, pydantic, httpx, cryptography, fastapi
Successfully installed annotated-types-0.8.0 anyio-4.14.2 certifi-2026.7.22 cffi-2.1.1 click-8.4.2 cryptography-46.0.5 dbus-next-0.2.3 fastapi-0.116.1 fonttools-4.63.0 h11-0.16.0 httpcore-1.0.9 httptools-0.8.0 httpx-0.28.1 idna-3.18 iniconfig-2.3.0 packaging-26.3 pillow-12.3.0 pluggy-1.6.0 pycparser-3.0 pydantic-2.13.4 pydantic-core-2.46.4 pygments-2.20.0 pytest-8.4.1 python-dotenv-1.2.2 pyyaml-6.0.3 starlette-0.47.3 typing-extensions-4.16.0 typing-inspection-0.4.2 uvicorn-0.35.0 uvloop-0.22.1 watchfiles-1.2.0 websockets-17.0.1
make: Entering directory '/opt/[private]-screen-controller.stage.1.1.3-layers-20261009-ready/app/display/native'
rm -f -- libh618_hub75.so hub75_benchmark hub75_native_test hub75_safeoff
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread -shared h618_hub75.c -o libh618_hub75.so -pthread
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread benchmark.c h618_hub75.c -o hub75_benchmark -pthread
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread native_test.c h618_hub75.c -o hub75_native_test -pthread
cc -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread safeoff.c h618_hub75.c -o hub75_safeoff -pthread
./hub75_native_test
native_bitplane_tests=PASS
./hub75_safeoff --fake
HUB75 safeoff complete
make: Leaving directory '/opt/[private]-screen-controller.stage.1.1.3-layers-20261009-ready/app/display/native'
........................................................................ [ 14%]
........................................................................ [ 28%]
.............................................................s.......... [ 42%]
........................................................................ [ 56%]
........................................................................ [ 70%]
.......s.......................................................s........ [ 84%]
........................................................................ [ 98%]
.......sss [100%]
508 passed, 7 skipped, 389 warnings in 401.84s (0:06:41)
PASS: H618 PI pinctrl: allwinner,sun50i-h616-pinctrl
PASS: /dev/mem accessible: /dev/mem
PASS: /dev/i2c-1 present: /dev/i2c-1
PASS: CPU3 present: /sys/devices/system/cpu/cpu3
PASS: native HUB75 library: /opt/[private]-screen-controller.stage.1.1.3-layers-20261009-ready/app/display/native/libh618_hub75.so
PASS: AArch64 userspace: aarch64
PASS: default network route: default via <设备网络地址> dev wlan0 proto static metric 600
PASS: NetworkManager active: active
PASS: lightdm.service inactive: inactive
PASS: gpioc-server.service inactive: inactive
PASS: map_device.service inactive: inactive
Persistent data prepared successfully: schema=config.json
frpc: OK
LICENSE: OK
frpc binary already verified; skipped
frpc 0.71.0 installed as a system component
● [private]-screen-controller.service - WalnutPi H618 Matrix Screen Controller
Loaded: loaded (/etc/systemd/system/[private]-screen-controller.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-10-09 07:36:54 UTC; 9s ago
Process: 292616 ExecStartPre=/bin/sh -c cat /proc/sys/kernel/sched_rt_runtime_us > /run/[private]-screen-controller/sched_rt_runtime_us.original && printf "%s
" -1 > /proc/sys/kernel/sched_rt_runtime_us (code=exited, status=0/SUCCESS)
Process: 292618 ExecStartPre=/opt/[private]-screen-controller/.venv/bin/python /opt/[private]-screen-controller/scripts/dedicated_host.py check --service (code=exited, status=0/SUCCESS)
Process: 292624 ExecStartPre=/opt/[private]-screen-controller/app/display/native/hub75_safeoff (code=exited, status=0/SUCCESS)
Main PID: 292625 (python)
Tasks: 14 (limit: 1021)
Memory: 137.5M
CPU: 11.644s
CGroup: /system.slice/[private]-screen-controller.service
└─292625 /opt/[private]-screen-controller/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8080
Oct 09 07:37:01 [private] python[292625]: File "/opt/[private]-screen-controller/.venv/lib/python3.11/site-packages/dbus_next/message_bus.py", line 712, in _process_message
Oct 09 07:37:01 [private] python[292625]: handler(msg, send_reply)
Oct 09 07:37:01 [private] python[292625]: File "/opt/[private]-screen-controller/.venv/lib/python3.11/site-packages/dbus_next/message_bus.py", line 898, in _default_properties_handler
Oct 09 07:37:01 [private] python[292625]: raise DBusError(
Oct 09 07:37:01 [private] python[292625]: dbus_next.errors.DBusError: interface "org.bluez.LEAdvertisement1" does not have property "TxPower"
Oct 09 07:37:01 [private] python[292625]: 2026-10-09 07:37:01,456 INFO app.power.voltage: Screen voltage sensor is available
Oct 09 07:37:01 [private] python[292625]: 2026-10-09 07:37:01,489 INFO app.monitoring.resources: System resource monitor is available
Oct 09 07:37:02 [private] python[292625]: INFO: Application startup complete.
Oct 09 07:37:02 [private] python[292625]: INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
Oct 09 07:37:02 [private] python[292625]: INFO: 127.0.0.1:47456 - "GET /api/status HTTP/1.1" 200 OK
WalnutPi Matrix Screen Controller updated successfully: 1.1.3-layers-20261009-ready
WARNING: The directory '/home/[private]/.cache/pip' or its parent directory is not owned or is not writable by the current user. The cache has been disabled. Check the permissions and owner of that directory. If executing pip with sudo, you should use sudo's -H flag.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,5 @@
私有凭据文件可用;未输出任何凭据值。
Preserved all user content/settings/files; obsolete mobile preview caches removed: 13
All persistent logical frames, order, durations, metadata and other files unchanged.
{"service": {"app_version": "a412c79f4887320b", "software_version": "1.1.3", "feature_updated_at": "2026-10-09T15:27+08:00", "instance_id": "<本次服务实例>"}, "driver": {"actual_refresh_rate_hz": 97.36, "panel_scan_rate_hz": 97.36, "completed_frames": 14796, "completed_scans": 14796, "scans_per_frame": 1, "deadline_misses": 0, "buffer_swaps": 379, "submitted_frames": 379, "oe_timing_backend": "h618-pwm4", "oe_pulse_faults": 0, "oe_forced_blanks": 0, "max_programmed_oe_ns": 128000, "oe_timing_error": null, "safeoff_error": null, "cpu_affinity": 3, "cpu_affinity_active": true, "cpu_affinity_error": null, "realtime_priority": 50, "realtime_priority_active": true, "realtime_priority_error": null, "memory_locked": true, "memory_lock_error": null, "running": true, "governor": "ondemand", "last_error": null}}
@@ -0,0 +1,2 @@
{"passed": true, "checks": "save/reload, central output, clip, single/all frames, independent IDs, durations, revision conflicts, 64/192 preview", "driver": {"actual_refresh_rate_hz": 97.377, "panel_scan_rate_hz": 97.377, "completed_frames": 11365, "completed_scans": 11365, "scans_per_frame": 1, "deadline_misses": 0, "buffer_swaps": 291, "submitted_frames": 291, "oe_timing_backend": "h618-pwm4", "oe_pulse_faults": 0, "oe_forced_blanks": 0, "max_programmed_oe_ns": 128000, "oe_timing_error": null, "safeoff_error": null, "cpu_affinity": 3, "cpu_affinity_active": true, "cpu_affinity_error": null, "realtime_priority": 50, "realtime_priority_active": true, "realtime_priority_error": null, "memory_locked": true, "memory_lock_error": null, "running": true, "governor": "ondemand", "last_error": null}}
Removed all independent REST verification content IDs.
@@ -0,0 +1 @@
仓库卫生检查通过:已检查 706 个 Git 候选文件,未输出任何秘密值。
@@ -0,0 +1 @@
仓库卫生检查通过:已检查 694 个 Git 候选文件,未输出任何秘密值。
@@ -0,0 +1 @@
{"verified_source_files": 33, "version": "1.1.3", "feature_updated_at": "2026-10-09T15:27+08:00"}
@@ -0,0 +1,9 @@
........................................................................ [ 14%]
........................................................................ [ 28%]
.............................................................s.......... [ 42%]
....................................sssssss.......s..................... [ 56%]
........................................................................ [ 70%]
.......s................................................................ [ 84%]
........................................................................ [ 99%]
..... [100%]
499 passed, 11 skipped, 8390 warnings in 85.15s (0:01:25)
@@ -0,0 +1,57 @@
........................................................... [100%]
============================== warnings summary ===============================
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:87: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: 10 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\dbus_next\service.py:293: DeprecationWarning: 'typing.no_type_check_decorator' is deprecated and slated for removal in Python 3.15
@no_type_check_decorator
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: 83 warnings
tests/test_scene_layers.py: 166 warnings
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
测试相关资料\如何测试\本机测试环境\.local\work\layers-implementation\独立 工作区\核桃派软件源代码\app\main.py:2049
tests/test_scene_layers.py::test_append_layers_conflict_batch_failure_and_restart
tests/test_scene_layers.py::test_text_extended_preview_default_size_and_persistent_clip
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-implementation\独立 工作区\核桃派软件源代码\app\main.py:2049: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495
测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495
tests/test_scene_layers.py::test_append_layers_conflict_batch_failure_and_restart
tests/test_scene_layers.py::test_append_layers_conflict_batch_failure_and_restart
tests/test_scene_layers.py::test_text_extended_preview_default_size_and_persistent_clip
tests/test_scene_layers.py::test_text_extended_preview_default_size_and_persistent_clip
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type)
测试相关资料\如何测试\本机测试环境\.local\work\layers-implementation\独立 工作区\核桃派软件源代码\app\main.py:2079
tests/test_scene_layers.py::test_append_layers_conflict_batch_failure_and_restart
tests/test_scene_layers.py::test_text_extended_preview_default_size_and_persistent_clip
<仓库根目录>\测试相关资料\如何测试\本机测试环境\.local\work\layers-implementation\独立 工作区\核桃派软件源代码\app\main.py:2079: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("shutdown")
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
59 passed, 276 warnings in 3.45s
@@ -0,0 +1,16 @@
ffmpeg version N-125146-gc6bb22dea0-20260621 Copyright (c) 2000-2026 the FFmpeg developers
built with gcc 15.2.0 (crosstool-NG 1.28.0.23_185f348)
configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --pkg-config=pkg-config --cross-prefix=x86_64-w64-mingw32- --arch=x86_64 --target-os=mingw32 --enable-gpl --enable-version3 --disable-debug --enable-shared --disable-static --disable-w32threads --enable-pthreads --enable-iconv --enable-zlib --enable-libxml2 --enable-libvmaf --enable-fontconfig --enable-libharfbuzz --enable-libfreetype --enable-libfribidi --enable-vulkan --enable-libshaderc --enable-libvorbis --disable-libxcb --disable-xlib --disable-libpulse --enable-gmp --enable-lzma --enable-liblcevc-dec --enable-opencl --enable-amf --enable-libaom --enable-libaribb24 --enable-avisynth --enable-chromaprint --enable-libdav1d --enable-libdavs2 --enable-libdvdread --enable-libdvdnav --disable-libfdk-aac --enable-ffnvcodec --enable-cuda-llvm --enable-frei0r --enable-libgme --enable-libkvazaar --enable-libaribcaption --enable-libass --enable-libbluray --enable-libjxl --enable-libmp3lame --enable-libopus --enable-libplacebo --enable-librist --enable-libssh --enable-libtheora --enable-libvpx --enable-libwebp --enable-libzmq --enable-lv2 --enable-libvpl --enable-openal --enable-liboapv --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopenmpt --enable-librav1e --enable-librubberband --enable-schannel --enable-sdl2 --enable-libsnappy --enable-libsoxr --enable-libsrt --enable-libsvtav1 --enable-libtwolame --enable-libuavs3d --disable-libdrm --enable-vaapi --enable-libvidstab --enable-libvvenc --disable-whisper --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxvid --enable-libzimg --enable-libzvbi --extra-cflags=-DLIBTWOLAME_STATIC --extra-cxxflags= --extra-libs=-lgomp --extra-ldflags=-pthread --extra-ldexeflags= --cc=x86_64-w64-mingw32-gcc --cxx=x86_64-w64-mingw32-g++ --ar=x86_64-w64-mingw32-gcc-ar --ranlib=x86_64-w64-mingw32-gcc-ranlib --nm=x86_64-w64-mingw32-gcc-nm --extra-version=20260621
libavutil 60. 33.101 / 60. 33.101
libavcodec 62. 36.101 / 62. 36.101
libavformat 62. 19.101 / 62. 19.101
libavdevice 62. 4.100 / 62. 4.100
libavfilter 11. 17.100 / 11. 17.100
libswscale 9. 8.100 / 9. 8.100
libswresample 6. 4.100 / 6. 4.100
Exiting with exit code 0
......s [100%]
=========================== short test summary info ===========================
SKIPPED [1] tests\test_media_formats.py:64: libheif examples are not installed
6 passed, 1 skipped in 1.83s
@@ -0,0 +1,2 @@
........................................................... [100%]
59 passed, 276 warnings in 3.05s
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,95 @@
✔ derives automatic frame names from current animation name and position (1.2763ms)
✔ keeps custom frame names and restores automatic names when cleared (0.1825ms)
✔ builds non-repeating animation edit context names (0.181ms)
✔ selects saved animation preview frames by ordered durations (0.9958ms)
✔ loads every preview frame through revision-checked pagination (0.7561ms)
✔ validates configurable animation preview concurrency (1.9627ms)
✔ rejects mixed revisions instead of showing a partial animation (1.0326ms)
✔ validates frame duration only when explicitly called (1.176ms)
✔ moves selected frames as one ordered group (0.5676ms)
✔ protects an eight pixel ring around interactive drag controls (0.4859ms)
✔ reorders mixed library keys without mutating the source list (0.3325ms)
✔ restores drag eligibility after protected pointer cancellation and drag end (1.1104ms)
✔ animation workspace exposes two previews and the simplified control order (23.9095ms)
✔ normal and pixel modes expose independent ranges and presets (3.1862ms)
✔ pixel stamps are exact squares from 1x1 through 4x4 (0.4014ms)
✔ pixel stamps clip at every canvas edge (0.2968ms)
✔ normal circle geometry remains unchanged (0.1377ms)
✔ line interpolation includes endpoints without coordinate gaps (0.3125ms)
✔ older tools migrate while v3 restores the coordinate guide choice (0.3512ms)
✔ coordinate guide spacing adapts to the transformed logical pixel size (0.2046ms)
✔ coordinate guide ticks preserve logical values through zoom and pan (0.2702ms)
✔ pixel mode markup and grid stay accessible and non-interactive (14.9772ms)
✔ pixel undo history stores changed snapshots, caps entries, and clears (0.9471ms)
✔ canvas view clamps scale and keeps a visible section in the viewport (0.4943ms)
✔ zooming around an anchor preserves its logical position (0.2473ms)
✔ floating editor button stays inside its safe viewport bounds (0.1309ms)
✔ canvas editor markup, guarded input, and checkpoint events stay wired (4.5038ms)
✔ normalizes and converts hexadecimal colors (3.1945ms)
✔ converts RGB and HSV boundaries (0.699ms)
✔ round trips representative colors within 8-bit rounding (0.9168ms)
✔ recent colors deduplicate, normalize, and evict the oldest (0.4437ms)
✔ formats and projects looping playback without moving paused content (1.752ms)
✔ seek dispatcher keeps one request in flight and flushes the newest position (3.93ms)
✔ topbar current display dialog exposes static close and animation controls (8.6886ms)
✔ device workspace exposes the compact immediate pure-color test flow (12.5038ms)
✔ font catalog normalization, grouping, and search are stable (3.6219ms)
✔ font picker implements commit-only searchable combobox keyboard behavior (6.5188ms)
✔ settings places unrestricted FRP maintenance at the bottom (9.7463ms)
✔ FRP editor uses raw server storage and official validation errors (2.0804ms)
✔ network diagnostics renders layered evidence (4.4397ms)
✔ unavailable controls never masquerade as indefinite loading (9.4291ms)
✔ action controls do not bypass the shared state module (14.0548ms)
✔ HTTP requests time out with a stable user-facing error (10.9642ms)
✔ demo library actions stay black, explain restrictions, and preserve copy (1.7026ms)
✔ sortable cards protect controls and keep demo cards fixed (1.9263ms)
✔ media import workspace exposes streaming upload, crop controls, and queue polling (2.0486ms)
✔ free framing starts from the complete source and keeps one pixel visible (1.1908ms)
✔ free framing copy is stable and catalogued (0.1702ms)
✔ upload action has visible ordinary-build copy (0.3722ms)
✔ media naming happens after analysis and conflicts keep the draft (0.2871ms)
✔ awaiting-settings cards survive polling without hiding server transitions (0.2058ms)
✔ old scene migration rejects unknown fields and duplicate text without filtering (71.3308ms)
✔ PNG compression round trips sparse and full brush pixels without DOM (8.2936ms)
✔ blank and black are distinct; independent backgrounds follow layer order (5.6738ms)
✔ disabled background colors stay independent through storage and copy (21.0577ms)
✔ extended pixels survive storage but never enter the central output (9.418ms)
✔ move preview does not mutate source; confirmed clipping removes only outside ink (25.3666ms)
✔ text clipping follows anchor and scale and is retained after edit/reload/copy (9.4652ms)
✔ multiple text groups isolate edits, copies and IDs (0.5377ms)
✔ strict v2 storage protects unknown data and the final brush invariant (7.1636ms)
✔ creates a fixed 64x64 RGB scene (11.4014ms)
✔ serializes pixelRgb as base64 and round trips an independent scene (54.9539ms)
✔ moves a valid v1 draft to the stable key before removing exact legacy keys (45.076ms)
✔ keeps the v1 draft and all legacy keys when stable-key migration cannot write (20.6105ms)
✔ preserves and blocks a future stable draft instead of falling back or writing blank (8.6419ms)
✔ preserves and blocks a damaged stable draft (0.4874ms)
✔ does not discard a damaged higher-priority legacy key during fallback migration (3.3709ms)
✔ rejects damaged scene roots while skipping only damaged elements (8.7487ms)
✔ normalizes text fields and creates a centered lowercase ok element (0.4622ms)
✔ migrates the first valid canvas by v3, v2, legacy priority and merges text v1 (17.8965ms)
✔ supports independent add, update, and delete operations for multiple text elements (0.8215ms)
✔ duplicates with a deep copy, new id, 2px offset, and topmost order (0.502ms)
✔ alpha-composites full RGBA layers in array order without mutating inputs (2.4435ms)
✔ settings brightness explains an active temporary display-test override (12.3309ms)
✔ settings exposes full OTA upload progress and service recovery polling (6.7881ms)
✔ settings automatically opens and safely copies a detailed OTA failure log (4.2111ms)
✔ settings exposes a transactional performance-mode switch with the fixed warning (2.3383ms)
✔ settings exposes persisted animation preview concurrency below performance mode (2.3375ms)
✔ template edit baseline distinguishes clean, dirty, and unbound scenes (1.8657ms)
✔ template manager uses a monitor and unified four-row card actions (18.9569ms)
✔ animation editing backs up and restores template edit ownership (3.8261ms)
✔ special editor exposes the agreed modes and guarded editing flow (9.4519ms)
✔ latched WiFi draft state remains dirty until explicitly cleared (3.7375ms)
✔ WiFi settings expose independent network and prompt-delay controls (5.8772ms)
✔ workspace order resolves saved ids and appends new registrations (1.7218ms)
✔ workspace order movement is immutable and respects both boundaries (0.3358ms)
✔ drawer exposes one editable list and commits only from the save action (8.5284ms)
ℹ tests 87
ℹ suites 0
ℹ pass 87
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 866.8466
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -0,0 +1,2 @@
. [100%]
1 passed, 189 warnings in 2.27s
+69 -44
View File
@@ -15,7 +15,11 @@
| `WEB-ENTRY` | 网页入口和整体页面 | 局域网设备访问移动端优先的控制面板,首页直接进入可操作界面。 | | `WEB-ENTRY` | 网页入口和整体页面 | 局域网设备访问移动端优先的控制面板,首页直接进入可操作界面。 |
| `WEB-WORKSPACES` | 可扩展工作区和导航 | 工作区通过注册信息生成单层侧栏和 URL hash;用户可在内存草稿中上移、下移并显式保存设备共享顺序。 | | `WEB-WORKSPACES` | 可扩展工作区和导航 | 工作区通过注册信息生成单层侧栏和 URL hash;用户可在内存草稿中上移、下移并显式保存设备共享顺序。 |
| `WEB-PREVIEW` | 共享正向预览与设备监视器 | 普通编辑工作区共用页面顶部的逻辑 `0°` 组合画板;设备状态、系统设置和模板管理在同一位置显示当前设备逻辑帧的只读监视画面。 | | `WEB-PREVIEW` | 共享正向预览与设备监视器 | 普通编辑工作区共用页面顶部的逻辑 `0°` 组合画板;设备状态、系统设置和模板管理在同一位置显示当前设备逻辑帧的只读监视画面。 |
| `WEB-COMPOSITION` | 统一场景与图层合成 | 像素层固定在最底部,有序元素依次叠加;设备测试独占覆盖但不得销毁场景草稿。 | | `WEB-COMPOSITION` | 统一场景与图层合成 | 有序画笔和多文字图层依次覆盖;中央64×64输出,设备测试不得销毁草稿。 |
| `WEB-LAYERS` | 多图层编辑与复制 | 至少一个画笔层,文字层多元素;置顶复制、命名、排序及跨内容追加。 |
| `WEB-EXTENDED-CANVAS` | 可切换192画布与整层移动 | 扩展显示按浏览器记忆,偏移±64,视线和图层移动分离。 |
| `WEB-LAYER-OVERFLOW` | 越界确认与永久裁切 | 确认前不保存,取消恢复,背景不报警;文字裁切持久化且可撤销。 |
| `CONFIG-SCENE-V2` | 场景及内容迁移 | scene v2、模板v2、动图v3、配置v11;摘要校验和安全迁移。 |
| `WEB-BACKEND` | FastAPI、REST、WebSocket、静态文件托管 | 服务监听 `0.0.0.0:8080`,页面通过 API/WS 调用显示服务。 | | `WEB-BACKEND` | FastAPI、REST、WebSocket、静态文件托管 | 服务监听 `0.0.0.0:8080`,页面通过 API/WS 调用显示服务。 |
| `WEB-STARTUP-INDICATOR` | 开机运行指示兼容资源 | 旧开机笑脸不再作为独立启动覆盖层;其两态像素资源继续供只读演示案例和默认演示动图复用。 | | `WEB-STARTUP-INDICATOR` | 开机运行指示兼容资源 | 旧开机笑脸不再作为独立启动覆盖层;其两态像素资源继续供只读演示案例和默认演示动图复用。 |
| `WEB-DEFAULT-DISPLAY` | 默认显示内容 | 模板管理可把演示案例、用户静态模板或非空用户动图设为默认;开机和启动提示结束后显示该内容,失效时回退演示动图。 | | `WEB-DEFAULT-DISPLAY` | 默认显示内容 | 模板管理可把演示案例、用户静态模板或非空用户动图设为默认;开机和启动提示结束后显示该内容,失效时回退演示动图。 |
@@ -58,7 +62,7 @@
| `WEB-TEXT-I18N` | 多语言静态文字 | `default` 自动选择能覆盖全文的字体,支持常见现代语言且不以缺字方框代替失败。 | | `WEB-TEXT-I18N` | 多语言静态文字 | `default` 自动选择能覆盖全文的字体,支持常见现代语言且不以缺字方框代替失败。 |
| `WEB-TEXT-FONTS` | 字体选择与导入 | 文字编辑器使用可搜索的设备字体目录,允许导入受校验的字体文件且不要求用户输入服务器路径。 | | `WEB-TEXT-FONTS` | 字体选择与导入 | 文字编辑器使用可搜索的设备字体目录,允许导入受校验的字体文件且不要求用户输入服务器路径。 |
| `WEB-TEMPLATES` | 可编辑场景模板 | 内容工作区可保存或更新完整场景模板;模板管理统一提供播放、编辑、复制、重命名、删除和混合排序,动图卡片按完整帧序列动态预览,播放与编辑草稿互不影响;动态预览与动图管理共享设备配置的并发上限。 | | `WEB-TEMPLATES` | 可编辑场景模板 | 内容工作区可保存或更新完整场景模板;模板管理统一提供播放、编辑、复制、重命名、删除和混合排序,动图卡片按完整帧序列动态预览,播放与编辑草稿互不影响;动态预览与动图管理共享设备配置的并发上限。 |
| `WEB-ANIMATIONS` | 动图文件夹与逐帧编辑 | 把有序 scene v1 帧按独立间隔循环播放;网页负责文件夹、帧顺序、逐帧编辑、复制目标和播放入口,卡片名称单行省略并提供非触摸悬停全名,缩略图按完整帧序列动态预览;动态预览与模板管理共享设备配置的并发上限。 | | `WEB-ANIMATIONS` | 动图文件夹与逐帧编辑 | 把有序 scene v2 帧按独立间隔循环播放;网页负责文件夹、帧顺序、逐帧编辑、复制目标和播放入口,卡片名称单行省略并提供非触摸悬停全名,缩略图按完整帧序列动态预览;动态预览与模板管理共享设备配置的并发上限。 |
| `WEB-MEDIA-IMPORT` | 媒体内容转换 | 流式导入常见图片、动图和视频,在设备后台统一裁切、缩放、透明合成和逐帧转换为静态模板或动图。 | | `WEB-MEDIA-IMPORT` | 媒体内容转换 | 流式导入常见图片、动图和视频,在设备后台统一裁切、缩放、透明合成和逐帧转换为静态模板或动图。 |
| `WEB-API` | REST/WS 契约 | 定义 `/api/status`、`/api/config`、`/api/display/*`、`/api/preview/*`、`/ws/canvas`,包括带会话防护的活动动图运行时控制。 | | `WEB-API` | REST/WS 契约 | 定义 `/api/status`、`/api/config`、`/api/display/*`、`/api/preview/*`、`/ws/canvas`,包括带会话防护的活动动图运行时控制。 |
| `CONFIG-DATA-LIFECYCLE` | 持久、可再生、运行期与更新暂存数据 | 生产持久根固定为 `/var/lib/matrix-screen-controller`,运行根固定为 `/run/matrix-screen-controller`;格式只向唯一当前 schema 迁移,失败时保留原件并拒绝启动。 | | `CONFIG-DATA-LIFECYCLE` | 持久、可再生、运行期与更新暂存数据 | 生产持久根固定为 `/var/lib/matrix-screen-controller`,运行根固定为 `/run/matrix-screen-controller`;格式只向唯一当前 schema 迁移,失败时保留原件并拒绝启动。 |
@@ -68,8 +72,8 @@
| `CONFIG-FRP` | FRP 配置目录 | 持久根 `frp/` 以严格 v1 目录保存多个 UUID 配置和唯一选中项;默认无配置、服务关闭,损坏时保留原件并只关闭 FRP 子系统。 | | `CONFIG-FRP` | FRP 配置目录 | 持久根 `frp/` 以严格 v1 目录保存多个 UUID 配置和唯一选中项;默认无配置、服务关闭,损坏时保留原件并只关闭 FRP 子系统。 |
| `CONFIG-PREVIEW-REFRESH` | 当前帧监视刷新间隔 | 按设备保存 `preview_refresh_interval_ms`,默认 `1000ms`;允许 `1..60000ms`,低于 `100ms` 时提示负载风险但不阻止保存。 | | `CONFIG-PREVIEW-REFRESH` | 当前帧监视刷新间隔 | 按设备保存 `preview_refresh_interval_ms`,默认 `1000ms`;允许 `1..60000ms`,低于 `100ms` 时提示负载风险但不阻止保存。 |
| `CONFIG-UI-STATE` | 浏览器场景草稿与编辑工具状态 | 场景使用稳定键 `matrixController:scene`;编辑工具使用独立带版本键,并兼容迁移已有画布、文字和工具状态。 | | `CONFIG-UI-STATE` | 浏览器场景草稿与编辑工具状态 | 场景使用稳定键 `matrixController:scene`;编辑工具使用独立带版本键,并兼容迁移已有画布、文字和工具状态。 |
| `CONFIG-TEMPLATES` | 核桃派模板持久化 | 完整 scene v1 和有效后端缩略图保存在持久根的 `templates/`;部署、重启和一般清理不得删除。 | | `CONFIG-TEMPLATES` | 核桃派模板持久化 | 完整 scene v2 和有效后端缩略图保存在持久根的 `templates/`;部署、重启和一般清理不得删除。 |
| `CONFIG-ANIMATIONS` | 核桃派动图持久化 | 动图 v2 元数据、独立 scene v1 帧和有效缩略图保存在持久根的 `animations/`;播放按不可变快照懒加载,升级不得删除。 | | `CONFIG-ANIMATIONS` | 核桃派动图持久化 | 动图 v3 元数据、独立 scene v2 帧和有效缩略图保存在持久根的 `animations/`;播放按不可变快照懒加载,升级不得删除。 |
| `CONFIG-MEDIA-JOBS` | 媒体转换任务 | 持久根保存活动任务、源文件、预览和暂存输出;成功或取消立即清理,失败与待设置任务最多保留 24 小时。 | | `CONFIG-MEDIA-JOBS` | 媒体转换任务 | 持久根保存活动任务、源文件、预览和暂存输出;成功或取消立即清理,失败与待设置任务最多保留 24 小时。 |
| `CONFIG-FONTS` | 用户导入字体持久化 | 导入字体按内容摘要保存在持久根的 `fonts/`,重启、更新、回滚和一般清理不得删除。 | | `CONFIG-FONTS` | 用户导入字体持久化 | 导入字体按内容摘要保存在持久根的 `fonts/`,重启、更新、回滚和一般清理不得删除。 |
| `CONFIG-LIBRARY-ORDER` | 模板管理混合顺序 | 用户静态模板和用户动图的统一顺序保存在持久根的 `library/order.json` v1;演示案例不进入记录。 | | `CONFIG-LIBRARY-ORDER` | 模板管理混合顺序 | 用户静态模板和用户动图的统一顺序保存在持久根的 `library/order.json` v1;演示案例不进入记录。 |
@@ -81,7 +85,7 @@
| `CONFIG-DEFAULT-CONTENT` | 默认显示内容引用 | 配置 v6 严格保存 `{type,id}`,只引用静态模板或非空动图;v5 升级默认引用只读“演示动图”。 | | `CONFIG-DEFAULT-CONTENT` | 默认显示内容引用 | 配置 v6 严格保存 `{type,id}`,只引用静态模板或非空动图;v5 升级默认引用只读“演示动图”。 |
| `CONFIG-WORKSPACE-ORDER` | 设备共享工作区顺序 | 配置 v7 严格保存唯一工作区 ID 数组;保存后跨浏览器和重启生效,未保存编辑不持久化。 | | `CONFIG-WORKSPACE-ORDER` | 设备共享工作区顺序 | 配置 v7 严格保存唯一工作区 ID 数组;保存后跨浏览器和重启生效,未保存编辑不持久化。 |
| `CONFIG-PERFORMANCE-MODE` | CPU 性能模式请求 | 配置 v9 严格保存布尔字段 `performance_mode_enabled`,v8 升级与新设备均默认关闭;请求状态与实际 governor 分开报告。 | | `CONFIG-PERFORMANCE-MODE` | CPU 性能模式请求 | 配置 v9 严格保存布尔字段 `performance_mode_enabled`,v8 升级与新设备均默认关闭;请求状态与实际 governor 分开报告。 |
| `CONFIG-ANIMATION-PREVIEW-CONCURRENCY` | 动态缩略图并发上限 | 配置 v10 严格保存整数 `animation_preview_max_concurrent`,v9 升级与新设备均默认 `2`,只允许 `1..50`;系统设置在性能模式下方保存该值,并明确提示高并发会造成严重性能问题。 | | `CONFIG-ANIMATION-PREVIEW-CONCURRENCY` | 动态缩略图并发上限 | 配置 v11严格保存整数(v10引入) `animation_preview_max_concurrent`,v9 升级与新设备均默认 `2`,只允许 `1..50`;系统设置在性能模式下方保存该值,并明确提示高并发会造成严重性能问题。 |
| `DEPLOY-KERNEL-ROLLBACK` | AXP313A 候选内核与自动回滚 | 已验证候选以带摘要的预编译离线载荷交付;原内核、DTB、模块和 boot 脚本保持可启动,未通过内核、cpufreq、服务和 GPIO 健康检查时下一次自动回到原内核。 | | `DEPLOY-KERNEL-ROLLBACK` | AXP313A 候选内核与自动回滚 | 已验证候选以带摘要的预编译离线载荷交付;原内核、DTB、模块和 boot 脚本保持可启动,未通过内核、cpufreq、服务和 GPIO 健康检查时下一次自动回到原内核。 |
| `DEPLOY-OTA-KERNEL-RECOVERY` | OTA 内核能力守护与轻量恢复 | OTA 前后比较内核、cpufreq、性能模式和候选启动标记;正常能力退化时回滚应用。已回退设备可更新应用,只有本机候选内核、DTB、模块及回滚设施通过固定摘要校验时,更新完成后才独立重启尝试恢复一次;失败不得自动重试,缺失或损坏材料只报告需要专用修复包,普通 OTA 不携带内核归档。 | | `DEPLOY-OTA-KERNEL-RECOVERY` | OTA 内核能力守护与轻量恢复 | OTA 前后比较内核、cpufreq、性能模式和候选启动标记;正常能力退化时回滚应用。已回退设备可更新应用,只有本机候选内核、DTB、模块及回滚设施通过固定摘要校验时,更新完成后才独立重启尝试恢复一次;失败不得自动重试,缺失或损坏材料只报告需要专用修复包,普通 OTA 不携带内核归档。 |
| `DEPLOY-MEDIA-DECODERS` | 媒体解码依赖与隔离 | Debian 提供 FFmpeg/ffprobe/libheif;独立转换 unit 限制 CPU、内存、IO、设备和网络访问。 | | `DEPLOY-MEDIA-DECODERS` | 媒体解码依赖与隔离 | Debian 提供 FFmpeg/ffprobe/libheif;独立转换 unit 限制 CPU、内存、IO、设备和网络访问。 |
@@ -159,7 +163,7 @@
- `GET /api/status` 提供应用版本、当前服务实例标识和设备状态修订号;成功改变显示帧、方向或亮度时修订号递增,只读与预览请求不得递增。 - `GET /api/status` 提供应用版本、当前服务实例标识和设备状态修订号;成功改变显示帧、方向或亮度时修订号递增,只读与预览请求不得递增。
- 页面可见时每 5 秒读取一次状态,重新获得焦点时立即读取;检测到另一页面改变实屏状态时更新模式、方向和亮度展示并提示,但不得自动覆盖或发送本地画板。 - 页面可见时每 5 秒读取一次状态,重新获得焦点时立即读取;检测到另一页面改变实屏状态时更新模式、方向和亮度展示并提示,但不得自动覆盖或发送本地画板。
- 检测到应用版本变化后持续提示刷新;本地画板编辑和导出仍可用,但旧页面不得继续提交设备、配置、色板、模板或 WebSocket 写操作。 - 检测到应用版本变化后持续提示刷新;本地画板编辑和导出仍可用,但旧页面不得继续提交设备、配置、色板、模板或 WebSocket 写操作。
- 不同浏览器或设备的 scene v1 草稿继续相互独立。相同来源的多个标签页写入同一草稿时,收到 `storage` 变化的标签页必须暂停继续持久化,并让用户明确选择载入外部草稿或以当前草稿覆盖。 - 不同浏览器或设备的 scene v2 草稿继续相互独立。相同来源的多个标签页写入同一草稿时,收到 `storage` 变化的标签页必须暂停继续持久化,并让用户明确选择载入外部草稿或以当前草稿覆盖。
- 共享物理屏幕仍采用最后一次成功命令生效,不引入用户账户、编辑锁或实时协作画板。 - 共享物理屏幕仍采用最后一次成功命令生效,不引入用户账户、编辑锁或实时协作画板。
`WEB-CURRENT-DISPLAY` 把顶栏当前画面改为可聚焦按钮。点击后使用原生对话框读取 `GET /api/display/current-frame`:静态模板、未保存内容和系统覆盖只显示当前逻辑帧,点击预览任意位置、遮罩或按 `Escape` 关闭;活动动图显示同一预览、整段循环进度、播放/暂停以及 `0.5x/1x/1.5x/2x` 四档倍速。动图对话框只允许遮罩、关闭按钮或 `Escape` 关闭,操作控件不得误触关闭。 `WEB-CURRENT-DISPLAY` 把顶栏当前画面改为可聚焦按钮。点击后使用原生对话框读取 `GET /api/display/current-frame`:静态模板、未保存内容和系统覆盖只显示当前逻辑帧,点击预览任意位置、遮罩或按 `Escape` 关闭;活动动图显示同一预览、整段循环进度、播放/暂停以及 `0.5x/1x/1.5x/2x` 四档倍速。动图对话框只允许遮罩、关闭按钮或 `Escape` 关闭,操作控件不得误触关闭。
@@ -365,10 +369,22 @@ WantedBy=multi-user.target
### 2.1.2 统一场景合成(`WEB-COMPOSITION`) ### 2.1.2 统一场景合成(`WEB-COMPOSITION`)
`WEB-LAYERS`:scene v2 使用有序画笔/文字图层,至少一个画笔层;每个文字层包含多个文字元素。画图页清空按钮下提供新建、删除、复制、命名、上移和下移,移除独立文字导航。默认图层名称依次编号,新建及复制置顶。画笔空白透出下层,黑色笔迹遮挡;各画笔层可独立开启192×192背景,橡皮和清空仅移除笔迹,文字层无背景。
`WEB-EXTENDED-CANVAS`:素材和编辑边界为192×192(-64..127),输出仅中央64×64(0..63)。编辑小圆球提供默认关闭、本浏览器记忆的扩展显示开关及移动当前层入口;移动提供X/Y输入、各轴±1/±10步进和拖动,偏移限于-64..64,保留视线平移缩放。显示扩展区或移动时标注实际屏幕边框。
`WEB-LAYERS` UI验收:画笔/背景颜色使用对齐的标题行和颜色按钮,桌面并列、窄屏上下排列;背景开关及复制目标帧复选框不得继承普通文本输入框的全宽与48px高度。标签整体可点选,长帧名称换行。复制及脏目标确认弹窗使用不透明表面背景,列表独立滚动,窄屏无横向溢出且操作按钮可达。
`WEB-EXTENDED-CANVAS` 模式退出:偏移面板顶部提供“退出图层移动”,切换到视线模式后仍可操作。退出仅返回当前画笔/文字编辑,保留全屏、扩展显示及视线位置,隐藏偏移面板;已提交位移保留,未提交拖动预览取消,不增减撤销历史。图层移动时“继续编辑”必须可用,不能误提示已经绘画。
`WEB-LAYER-OVERFLOW`:移动整层或文字后,有非背景可见笔迹超出192边界则在提交前确认;取消恢复,确定清除越界笔迹,部分文字累计裁切、整段越界删除。裁切随对象移动/缩放并持久化,移回不恢复;操作可作为一次会话撤销,最多50步,提交成功清空。背景及仅超出实际64屏幕不触发警告。
`CONFIG-SCENE-V2`:旧场景迁移为一个画笔层,有文字时额外一个文字层,中央输出保持一致。模板schema v2、动图schema v3、配置v11移除text导航;坏数据/未知字段/未来版本保全。画笔RGBA以PNG压缩持久化。导入静态一个画笔层,动图/视频逐帧一个画笔层。复制当前画板或已有模板/动图选定帧,副本独立并置顶;跨内容追加使用If-Match,批量动图索引一次提交,失败原帧不变。文字预览可选192尺寸,默认64;驱动RGB888及手机协议不变。
- 场景模型不得依赖 DOM,固定包含一个 `64x64 RGB888` 像素底层和一个有序 `elements` 数组;数组从前到后绘制,后面的元素覆盖前面的元素。 - 场景模型不得依赖 DOM,固定包含一个 `64x64 RGB888` 像素底层和一个有序 `elements` 数组;数组从前到后绘制,后面的元素覆盖前面的元素。
- 像素底层始终位于最底部。文字元素使用独立 `64x64 RGBA` 图层,透明区域必须保留下方像素和其他元素。 - 画笔运行时使用192×192 RGBA,持久化为压缩PNG;文字渲染使用192×192透明层,并在最终合成时取中央区域。透明区域露出下层,黑色笔迹遮挡下层。
- 新建或复制的元素追加到数组末尾并置顶;当前阶段不提供手动前移或后移。 - 新建或复制图层追加到末尾、置顶并选中;图层可上下排序,重排不改名称。默认名称取当前未占用的最小图层编号。
- 每个元素具有稳定 `id`,运行时另有递增修订号。透明层缓存以元素 `id + revision` 识别,旧请求晚于新请求返回时必须丢弃,不能覆盖当前元素;修订号是缓存状态,不属于 scene v1 持久化字段。 - 每个元素具有稳定 `id`,运行时另有递增修订号。透明层缓存以元素 `id + revision` 识别,旧请求晚于新请求返回时必须丢弃,不能覆盖当前元素;全场景元素ID唯一,所属层和裁切参与签名;修订号不持久化。
- 元素存在待渲染或渲染失败状态时,页面必须明确提示并禁用“应用统一画板”,不得提交缺少该元素的不完整帧。 - 元素存在待渲染或渲染失败状态时,页面必须明确提示并禁用“应用统一画板”,不得提交缺少该元素的不完整帧。
- 场景最终合成为单张 `64x64 RGB` 帧后再交给 `DisplayService`;显示底层不保存、选择或编辑单个元素。 - 场景最终合成为单张 `64x64 RGB` 帧后再交给 `DisplayService`;显示底层不保存、选择或编辑单个元素。
- 后续动画、图片、时钟、天气等普通内容功能必须注册为新的元素或图层提供者,并参与同一有序合成;不得新增直接覆盖屏幕且无法与现有内容叠加的普通显示路径。 - 后续动画、图片、时钟、天气等普通内容功能必须注册为新的元素或图层提供者,并参与同一有序合成;不得新增直接覆盖屏幕且无法与现有内容叠加的普通显示路径。
@@ -376,8 +392,8 @@ WantedBy=multi-user.target
### 2.1.3 模板管理(`WEB-TEMPLATES`、`WEB-DEMO-TEMPLATES`) ### 2.1.3 模板管理(`WEB-TEMPLATES`、`WEB-DEMO-TEMPLATES`)
- 工作区注册信息可以声明“支持保存场景模板”;当前像素画布和文字显示声明该能力,后续内容工作区声明后自动出现同一个“保存为模板”入口。 - 工作区注册信息可以声明“支持保存场景模板”;画图工作区声明该能力,后续内容工作区声明后自动出现同一个“保存为模板”入口。
- 保存内容必须是完整、可编辑的 scene v1,保留像素底层、有序元素、稳定元素 ID 和层叠顺序;不得只保存合成 PNG,也不得保存运行时 revision、选择、请求状态或图层缓存。 - 保存内容必须是完整、可编辑的 scene v2,保留图层、偏移、PNG笔迹、背景、文字裁切和稳定ID;不得只保存合成 PNG,也不得保存运行时 revision、选择、请求状态或图层缓存。
- 保存时输入 `1..80` 个字符的模板名称;忽略首尾空白并按 Unicode 大小写不敏感规则保持唯一,重名不得覆盖已有模板。 - 保存时输入 `1..80` 个字符的模板名称;忽略首尾空白并按 Unicode 大小写不敏感规则保持唯一,重名不得覆盖已有模板。
- 从动图帧编辑上下文点击“保存为模板”时,名称弹窗必须在“保存为模板”标题右侧显示“当前帧将被保存为一个静态图模板”;普通场景保存以及共享弹窗用于新建、重命名时不得显示或残留该提示。 - 从动图帧编辑上下文点击“保存为模板”时,名称弹窗必须在“保存为模板”标题右侧显示“当前帧将被保存为一个静态图模板”;普通场景保存以及共享弹窗用于新建、重命名时不得显示或残留该提示。
- 左侧栏新增“模板管理”。模板使用购物软件式响应式卡片网格:正方形缩略图在上、名称在下,并显示占用空间;手机默认两列,宽屏自动增加列数。静态模板和动图统一使用两列四行操作:第一行“播放、编辑”,第二行“复制、重命名”,第三行横跨两列显示“删除”,第四行“上移、下移”。默认内容选择模式可以在上述常态操作后额外显示单列“设为默认”。 - 左侧栏新增“模板管理”。模板使用购物软件式响应式卡片网格:正方形缩略图在上、名称在下,并显示占用空间;手机默认两列,宽屏自动增加列数。静态模板和动图统一使用两列四行操作:第一行“播放、编辑”,第二行“复制、重命名”,第三行横跨两列显示“删除”,第四行“上移、下移”。默认内容选择模式可以在上述常态操作后额外显示单列“设为默认”。
@@ -395,7 +411,7 @@ WantedBy=multi-user.target
### 2.1.4 动图管理(`WEB-ANIMATIONS`、`CONFIG-ANIMATIONS`) ### 2.1.4 动图管理(`WEB-ANIMATIONS`、`CONFIG-ANIMATIONS`)
- 单层侧栏中动图管理的默认位置在文字显示之后、模板管理之前;保存自定义工作区顺序后允许改变该位置。一个动图是带稳定 UUID、名称和修订值的文件夹,内部保存按顺序排列的完整 scene v1 帧。 - 单层侧栏中动图管理的默认位置在媒体转换之后、模板管理之前;保存自定义工作区顺序后允许改变该位置。一个动图是带稳定 UUID、名称和修订值的文件夹,内部保存按顺序排列的完整 scene v2 帧。
- 动图管理页面内容顺序固定为“新建动图及从当前画板新建帧操作行、当前打开动图的帧卡片和单帧时长区、已有动图列表”。用户界面统一使用“动图”称呼;持久实现仍可把一个动图保存为带稳定 UUID、名称和修订值的文件夹。 - 动图管理页面内容顺序固定为“新建动图及从当前画板新建帧操作行、当前打开动图的帧卡片和单帧时长区、已有动图列表”。用户界面统一使用“动图”称呼;持久实现仍可把一个动图保存为带稳定 UUID、名称和修订值的文件夹。
- 已有动图卡片在名称上方显示正方形动态缩略图。名称固定单行尾部省略,任何中文、英文或无空格长名称都不得撑宽网格;完整名称只在重命名输入框以及确实发生截断时的细指针鼠标悬停提示中显示,触摸、点击和长按不得展开名称。 - 已有动图卡片在名称上方显示正方形动态缩略图。名称固定单行尾部省略,任何中文、英文或无空格长名称都不得撑宽网格;完整名称只在重命名输入框以及确实发生截断时的细指针鼠标悬停提示中显示,触摸、点击和长按不得展开名称。
- 服务启动时对动图元数据、scene 与缩略图完成严格校验后,必须复用同一批已校验记录建立首次列表缓存;首次 `GET /api/animations` 不得再次遍历和校验全部帧文件。任一动图写入后缓存失效并按当前持久数据重建,REST 响应字段和错误语义不变。 - 服务启动时对动图元数据、scene 与缩略图完成严格校验后,必须复用同一批已校验记录建立首次列表缓存;首次 `GET /api/animations` 不得再次遍历和校验全部帧文件。任一动图写入后缓存失效并按当前持久数据重建,REST 响应字段和错误语义不变。
@@ -404,7 +420,7 @@ WantedBy=multi-user.target
- 时长输入在用户键入过程中不校验、不提交,只在失焦、按 Enter 或 `change` 时校验;非法值保留并显示字段错误,不调用 API。时长数字必须能用鼠标或触摸长按选择且不得触发拖动,全部交互控件及其 `8 CSS px` 保护区遵守 `WEB-INTERACTION-FEEDBACK`。 - 时长输入在用户键入过程中不校验、不提交,只在失焦、按 Enter 或 `change` 时校验;非法值保留并显示字段错误,不调用 API。时长数字必须能用鼠标或触摸长按选择且不得触发拖动,全部交互控件及其 `8 CSS px` 保护区遵守 `WEB-INTERACTION-FEEDBACK`。
- 帧列表上方固定提供批量复制、批量移动和批量删除。勾选任意帧即进入选择模式:复选框、单位、时长、复制到已选帧和三个批量入口保持可用,拖动、编辑、重命名、上下移、单帧复制/删除以及切换动图或上下文的操作显示明确不可用原因。修改任意时长或复制时长到已选帧前必须二次确认;取消不发送请求并恢复发起输入,确认后原子应用到全部已选帧。批量时长成功后保留选择;复制、移动或删除成功后清空选择。 - 帧列表上方固定提供批量复制、批量移动和批量删除。勾选任意帧即进入选择模式:复选框、单位、时长、复制到已选帧和三个批量入口保持可用,拖动、编辑、重命名、上下移、单帧复制/删除以及切换动图或上下文的操作显示明确不可用原因。修改任意时长或复制时长到已选帧前必须二次确认;取消不发送请求并恢复发起输入,确认后原子应用到全部已选帧。批量时长成功后保留选择;复制、移动或删除成功后清空选择。
- 批量移动仅限当前动图,保持所选帧当前相对顺序,只能在弹窗中选择“插入到最前”或某个未选帧之后;已选帧在目标下拉中以黑色不可用项显示。批量复制先选择当前或其他普通动图,再复用相同位置选择,但复制目标不因来源已选而禁用。普通单帧复制为动图时也必须选择插入位置,复制为静态模板时保持现有流程。批量删除二次确认后允许留下空动图。 - 批量移动仅限当前动图,保持所选帧当前相对顺序,只能在弹窗中选择“插入到最前”或某个未选帧之后;已选帧在目标下拉中以黑色不可用项显示。批量复制先选择当前或其他普通动图,再复用相同位置选择,但复制目标不因来源已选而禁用。普通单帧复制为动图时也必须选择插入位置,复制为静态模板时保持现有流程。批量删除二次确认后允许留下空动图。
- 点击“编辑此帧”进入独立编辑上下文,普通浏览器 scene 草稿保持不变。像素画布和文字显示共同编辑该帧,并在“合成预览”右侧只显示一处编辑注释和已保存/尚未保存状态;自动帧为“动图名-第N帧”,自定义帧为“动图名-自定义帧名”,不得重复动图名。该注释不得出现在其他工作区;正在编辑的帧卡片同步高亮,顶部主按钮改为“保存当前动图帧”,保存不改变实屏,保存后继续编辑并刷新缩略图。 - 点击“编辑此帧”进入独立编辑上下文,普通浏览器 scene 草稿保持不变。画图工作区内的画笔和文字图层共同编辑该帧,并在“合成预览”右侧只显示一处编辑注释和已保存/尚未保存状态;自动帧为“动图名-第N帧”,自定义帧为“动图名-自定义帧名”,不得重复动图名。该注释不得出现在其他工作区;正在编辑的帧卡片同步高亮,顶部主按钮改为“保存当前动图帧”,保存不改变实屏,保存后继续编辑并刷新缩略图。
- 切换帧、打开其他动图、退出动图编辑、载入模板或外部浏览器草稿以及其他会替换当前画板的操作,必须先经过同一个未保存修改守卫。存在修改时提供“应用修改、否并放弃修改、取消”;保存失败或修订冲突必须取消后续操作并保留本页画面。刷新或关闭页面时仍有未保存帧修改则触发浏览器离开提示。 - 切换帧、打开其他动图、退出动图编辑、载入模板或外部浏览器草稿以及其他会替换当前画板的操作,必须先经过同一个未保存修改守卫。存在修改时提供“应用修改、否并放弃修改、取消”;保存失败或修订冲突必须取消后续操作并保留本页画面。刷新或关闭页面时仍有未保存帧修改则触发浏览器离开提示。
- 模板管理同时显示带“静态”或“动图”标记的卡片。动图卡片先显示第一帧,再在完整时间表就绪后按全部帧和原时长循环,并显示帧数、总时长和空间;空动图显示明确占位且不可播放。静态模板和单帧复制必须弹出目标选择,可深复制为静态模板或追加到指定动图。 - 模板管理同时显示带“静态”或“动图”标记的卡片。动图卡片先显示第一帧,再在完整时间表就绪后按全部帧和原时长循环,并显示帧数、总时长和空间;空动图显示明确占位且不可播放。静态模板和单帧复制必须弹出目标选择,可深复制为静态模板或追加到指定动图。
- 动图卡片提供整项深复制;普通动图与演示动图复制后都生成包含全体帧、顺序、名称和时长的独立普通动图。 - 动图卡片提供整项深复制;普通动图与演示动图复制后都生成包含全体帧、顺序、名称和时长的独立普通动图。
@@ -564,17 +580,17 @@ WebSocket 消息示例:
- 画布和文字都受同一方向规则影响。 - 画布和文字都受同一方向规则影响。
- 后续新增任何显示功能,都不需要重新实现方向逻辑。 - 后续新增任何显示功能,都不需要重新实现方向逻辑。
### 2.5 文字显示(`WEB-TEXT`、`WEB-TEXT-I18N`、`WEB-TEXT-FONTS`) ### 2.5 文字图层(`WEB-TEXT`、`WEB-TEXT-I18N`、`WEB-TEXT-FONTS`)
目标:允许用户在统一场景中创建多组互相独立、可叠加的静态文字元素。 目标:允许用户在统一场景中创建多组互相独立、可叠加的静态文字元素。
页面功能: 页面功能:
- 文字工作区提供元素列表,以及“新建文字”“复制”“删除”按钮;点击画板文字或列表项后切换当前选中元素。 - 选中文字图层时,上方工具区提供该层的元素列表,以及“新建文字”“复制”“删除”按钮;点击画板文字或列表项后切换当前选中元素。
- 现有文本、字体、字号、文字颜色、`x` / `y` 坐标和左/中/右对齐编辑器始终绑定当前选中元素,选中变化时立即显示该元素的值。 - 现有文本、字体、字号、文字颜色、`x` / `y` 坐标和左/中/右对齐编辑器始终绑定当前选中元素,选中变化时立即显示该元素的值。
- “新建文字”创建内容为小写 `ok` 的元素,使用配置中的默认字体和字号、白色文字并默认位于画板中央;新元素追加到场景顶层并自动选中。 - “新建文字”创建内容为小写 `ok` 的元素,使用配置中的默认字体和字号、白色文字并默认位于画板中央;新元素追加到当前文字层末尾并自动选中。
- “复制”创建新 `id`,复制当前元素属性,将 `x` 和 `y` 各偏移 `2` 个逻辑像素,追加到顶层并选中新副本;超出边界的部分沿用裁切规则。 - “复制”创建新 `id`,复制当前元素属性,将 `x` 和 `y` 各偏移 `2` 个逻辑像素,追加到顶层并选中新副本;复制保留裁切约束。
- “删除”只删除当前元素,不影响像素底层或其他元素;没有选中元素时复制和删除按钮禁用。 - “删除”只删除当前元素,不影响其他图层或元素;没有选中元素时复制和删除按钮禁用。
- 每个文字元素只包含文字本身,不提供背景颜色或全帧背景字段。 - 每个文字元素只包含文字本身,不提供背景颜色或全帧背景字段。
- 画板上的文字可用 Pointer Events 拖动;选中框角点提供类似演示文稿文本框的等比缩放,缩放只改变字号,不增加自由宽高或自动换行。 - 画板上的文字可用 Pointer Events 拖动;选中框角点提供类似演示文稿文本框的等比缩放,缩放只改变字号,不增加自由宽高或自动换行。
- 拖动和缩放过程中使用已缓存的透明层做连续视觉变换;交互结束后提交整数 `x`、`y` 和 `1..64` 的整数字号,再调用后端 Pillow 刷新精确图层。 - 拖动和缩放过程中使用已缓存的透明层做连续视觉变换;交互结束后提交整数 `x`、`y` 和 `1..64` 的整数字号,再调用后端 Pillow 刷新精确图层。
@@ -589,7 +605,7 @@ WebSocket 消息示例:
后端行为: 后端行为:
- `POST /api/preview/text-layer` 用 Pillow 生成一张 `64x64 RGBA` 透明图层,不调用显示驱动。 - `POST /api/preview/text-layer` 用 Pillow 生成透明图层,`edit_size` 可选64或192(默认64),不调用显示驱动。
- 使用 `ImageDraw.text()` 绘制文字,透明区域 alpha 为 `0`,文字颜色区域保留有效 alpha。 - 使用 `ImageDraw.text()` 绘制文字,透明区域 alpha 为 `0`,文字颜色区域保留有效 alpha。
- 使用 `ImageFont.truetype()` 加载字体;没有指定字体时使用默认字体。 - 使用 `ImageFont.truetype()` 加载字体;没有指定字体时使用默认字体。
- `font:"default"` 表示自动多语言字体,不再表示某一个固定英文字体。渲染器必须选择一张能覆盖当前完整文本的字体,以保留复杂文字塑形和双向排版。 - `font:"default"` 表示自动多语言字体,不再表示某一个固定英文字体。渲染器必须选择一张能覆盖当前完整文本的字体,以保留复杂文字塑形和双向排版。
@@ -776,7 +792,9 @@ WebSocket 消息示例:
"voltage_calibrated_at": null, "voltage_calibrated_at": null,
"voltage_calibration_reference": null, "voltage_calibration_reference": null,
"voltage_calibration_uncalibrated": null, "voltage_calibration_uncalibrated": null,
"workspace_order": ["device", "settings", "canvas", "text", "animations", "templates"] "workspace_order": ["device", "settings", "canvas", "media-import", "animations", "templates"],
"performance_mode_enabled": false,
"animation_preview_max_concurrent": 2
} }
``` ```
@@ -793,7 +811,9 @@ WebSocket 消息示例:
"default_font": "default", "default_font": "default",
"default_text_size": 12, "default_text_size": 12,
"low_voltage_protection_enabled": true, "low_voltage_protection_enabled": true,
"workspace_order": ["device", "settings", "canvas", "text", "animations", "templates"] "workspace_order": ["device", "settings", "canvas", "media-import", "animations", "templates"],
"performance_mode_enabled": false,
"animation_preview_max_concurrent": 2
} }
``` ```
@@ -1070,7 +1090,7 @@ WebSocket 消息示例:
```json ```json
{ {
"schema_version": 7, "schema_version": 11,
"orientation": 0, "orientation": 0,
"brightness": 40, "brightness": 40,
"default_font": "default", "default_font": "default",
@@ -1089,7 +1109,9 @@ WebSocket 消息示例:
"type": "animation", "type": "animation",
"id": "00000000-0000-4000-8000-000000000102" "id": "00000000-0000-4000-8000-000000000102"
}, },
"workspace_order": ["device", "settings", "canvas", "text", "animations", "templates"] "workspace_order": ["device", "settings", "canvas", "media-import", "animations", "templates"],
"performance_mode_enabled": false,
"animation_preview_max_concurrent": 2
} }
``` ```
@@ -1098,7 +1120,7 @@ WebSocket 消息示例:
- 写入 JSON 时使用 UTF-8。 - 写入 JSON 时使用 UTF-8。
- 配置文件确实不存在时才创建当前 schema 的默认配置;已存在文件不能因读取、迁移或校验失败被默认值替换。 - 配置文件确实不存在时才创建当前 schema 的默认配置;已存在文件不能因读取、迁移或校验失败被默认值替换。
- 缺少 `schema_version` 的现有格式只按 legacy v0 处理;存储层通过登记的 `vN → vN+1` 函数逐级迁移,全部步骤和当前 schema 校验成功后才写回。业务层只接收当前格式,不保留多版本分支。 - 缺少 `schema_version` 的现有格式只按 legacy v0 处理;存储层通过登记的 `vN → vN+1` 函数逐级迁移,全部步骤和当前 schema 校验成功后才写回。业务层只接收当前格式,不保留多版本分支。
- 配置字段集必须按版本显式维护:v1 是原有方向、亮度、字体、模式、色板与四个电压校准字段;v2 增加 `preview_refresh_interval_ms`;v3 增加严格布尔字段 `low_voltage_protection_enabled`;v4 增加 `matrix_refresh_rate_limit_hz`;v5 增加严格大写 `#RRGGBB` 字段 `custom_test_color`;v6 增加 `default_display`;当前 v7 增加 `workspace_order`。逐级迁移分别只增加对应默认值并完整保留已有业务值;模板当前 schema 仍为 v1,不随配置版本同步升级。 - 配置字段集必须按版本显式维护:v1 是原有方向、亮度、字体、模式、色板与四个电压校准字段;v2 增加 `preview_refresh_interval_ms`;v3 增加严格布尔字段 `low_voltage_protection_enabled`;v4 增加 `matrix_refresh_rate_limit_hz`;v5 增加严格大写 `#RRGGBB` 字段 `custom_test_color`;v6 增加 `default_display`;v7增加 `workspace_order`,v8插入媒体转换,v9增加性能模式,v10增加动图预览并发,v11仅移除导航中的 `text` 并保留其余顺序;旧 `#text` 入口跳转画图页。模板当前schema v2,动图v3。
- 损坏 JSON、布尔值冒充数值、未知字段、未知更高版本或无法安全迁移时,保持原文件字节不变、记录明确错误并阻止服务启动,不得跳过字段、猜测修复或自动另建配置。 - 损坏 JSON、布尔值冒充数值、未知字段、未知更高版本或无法安全迁移时,保持原文件字节不变、记录明确错误并阻止服务启动,不得跳过字段、猜测修复或自动另建配置。
- 写入前校验当前 schema 的完整字段集合和范围。电压校准系数必须是 `0.8..1.2` 的有限数值;四个校准字段作为一组校验,缺失或不一致时属于无效现有配置,不能静默回落为未校准状态。 - 写入前校验当前 schema 的完整字段集合和范围。电压校准系数必须是 `0.8..1.2` 的有限数值;四个校准字段作为一组校验,缺失或不一致时属于无效现有配置,不能静默回落为未校准状态。
- 所有配置和模板写入共用耐久原子写入:在目标同目录创建临时文件,写完后刷新并 `fsync` 文件,使用 `os.replace` 替换,再同步父目录;任一步失败都清理本次临时文件并保留旧文件,不累计滚动备份。 - 所有配置和模板写入共用耐久原子写入:在目标同目录创建临时文件,写完后刷新并 `fsync` 文件,使用 `os.replace` 替换,再同步父目录;任一步失败都清理本次临时文件并保留旧文件,不累计滚动备份。
@@ -1128,39 +1150,31 @@ WebSocket 消息示例:
```json ```json
{ {
"version": 1, "version": 2, "width": 64, "height": 64,
"width": 64, "layers": [
"height": 64, {"id": "layer-1", "name": "图层1", "type": "brush", "x": 0, "y": 0,
"pixelRgb": "<base64 RGB888>", "background": null, "backgroundColor": "#000000", "pixelPng": "<base64 compressed 192×192 RGBA PNG>"},
"elements": [ {"id": "layer-2", "name": "图层2", "type": "text", "x": 0, "y": 0,
{ "elements": [{"id": "text-1", "type": "text", "text": "ok", "font": "default",
"id": "<stable id>", "size": 12, "x": 32, "y": 26, "align": "center", "color": "#FFFFFF", "clip": null}]}
"type": "text",
"text": "ok",
"font": "default",
"size": 12,
"x": 4,
"y": 24,
"align": "left",
"color": "#FFFFFF"
}
] ]
} }
``` ```
- `pixelRgb` 解码后必须正好是 `64 * 64 * 3` 字节 RGB888;`elements` 数组顺序就是从底到顶的绘制顺序。文字元素只持久化示例所列字段,不得保存 `background`、运行时 `revision`、渲染状态或图层缓存。 - `layers` 从底到顶,ID唯一,至少一个画笔层;层偏移为−64..64整数。文字元素ID全场景唯一,坐标、大小沿用原范围。`clip` 为null或相对文字锚点、以字号为单位的 `[left, top, right, bottom]` 矩形;累计交集永久保留,随锚点和字号变换。图层和文字只保存已列字段。旧v1先严格校验,再把中央RGB写入一个画笔层,全部旧文字写入一个上方文字层,外围为空白。
- 整层拖动松手、单段位置拖动松手、位置输入回车/失焦和每次步进均为一次提交;候选越界先确认,确认前不得写草稿、保存或应用。背景不计入越界;确认裁切和位置变化是一个撤销步骤,最多50步。
- 选中元素、渲染中/失败状态、透明 PNG 缓存、状态消息、设备状态、测试选择和错误不持久化。 - 选中元素、渲染中/失败状态、透明 PNG 缓存、状态消息、设备状态、测试选择和错误不持久化。
- 稳定键不存在时,先读取并验证所有实际存在的旧键;任一旧键损坏或无法识别都必须保留全部原值并停止迁移,不能跳过高优先级损坏值后拿低优先级草稿覆盖。全部验证通过后,先迁移现有 `matrixController:scene:v1`;没有该键时再按 `matrixController:canvas:v3`、`matrixController:canvas:v2`、旧 `matrixCanvasRgb` 的优先顺序选择像素草稿,同时独立读取 `matrixController:text:v1` 并合并为文字元素。迁移保留能验证的旧像素、画笔/背景颜色、画笔粗细及文字属性,丢弃旧文字背景。 - 稳定键不存在时,先读取并验证所有实际存在的旧键;任一旧键损坏或无法识别都必须保留全部原值并停止迁移,不能跳过高优先级损坏值后拿低优先级草稿覆盖。全部验证通过后,先迁移现有 `matrixController:scene:v1`;没有该键时再按 `matrixController:canvas:v3`、`matrixController:canvas:v2`、旧 `matrixCanvasRgb` 的优先顺序选择像素草稿,同时独立读取 `matrixController:text:v1` 并合并为文字元素。迁移保留能验证的旧像素、画笔/背景颜色、画笔粗细及文字属性,丢弃旧文字背景。
- 旧文字草稿迁移为一个稳定 `id` 的 `type:"text"` 元素;损坏文字元素单独跳过,不能连带清空有效像素或其他元素。缺少画笔粗细时回落为 `1`。 - 旧文字草稿迁移为一个稳定 `id` 的 `type:"text"` 元素;损坏文字元素单独跳过,不能连带清空有效像素或其他元素。缺少画笔粗细时回落为 `1`。
- 只有新内容已完整校验并成功写入稳定键后,才精确删除本次已迁移的旧键;写入失败时旧键和值必须保留。 - 只有新内容已完整校验并成功写入稳定键后,才精确删除本次已迁移的旧键;写入失败时旧键和值必须保留。
- 稳定键内容损坏、出现未知更高版本或迁移失败时,页面保留原值和内存草稿、停止自动写回并明确提示用户;不得用空白场景覆盖,不得自动上传到核桃派。场景只保存在当前浏览器,不写入设备 `config.json`,不要求跨浏览器共享,也不保证核桃派重启后自动恢复实屏。 - 稳定键内容损坏、出现未知更高版本或迁移失败时,页面保留原值和内存草稿、停止自动写回并明确提示用户;不得用空白场景覆盖,不得自动上传到核桃派。场景只保存在当前浏览器,不写入设备 `config.json`,不要求跨浏览器共享,也不保证核桃派重启后自动恢复实屏。
- 同一来源的其他标签页修改稳定场景键时不得直接替换当前内存草稿;冲突解决前当前标签页的后续编辑继续保留在内存但暂停写回 `localStorage`。用户选择“载入其他标签草稿”后采用最后收到的有效外部值,选择“保留当前草稿”后才显式覆盖共享键。 - 同一来源的其他标签页修改稳定场景键时不得直接替换当前内存草稿;冲突解决前当前标签页的后续编辑继续保留在内存但暂停写回 `localStorage`。用户选择“载入其他标签草稿”后采用最后收到的有效外部值,选择“保留当前草稿”后才显式覆盖共享键。
- 画笔颜色、像素层背景颜色、画笔模式、两种模式各自的笔尖粗细、当前画笔工具和坐标系开关使用独立带版本号的本地 UI 状态;它们不是场景元素,也不改变 scene v1 的最终像素和元素契约。当前键为 `matrixController:canvas-tools:v3`:刷新后恢复模式、两套尺寸和坐标系选择;首次从 v2/v1 读取时保留已有工具值、把旧 `brushSize` 迁入普通模式,像素模式默认 `1x1` 且默认关闭,坐标系默认关闭,损坏或缺失值安全回落到默认值。 - 画笔颜色、像素层背景颜色、画笔模式、两种模式各自的笔尖粗细、当前画笔工具和坐标系开关使用独立带版本号的本地 UI 状态;它们不是场景元素,扩展显示开关同样仅属于浏览器工具状态。当前键为 `matrixController:canvas-tools:v3`:刷新后恢复模式、两套尺寸和坐标系选择;首次从 v2/v1 读取时保留已有工具值、把旧 `brushSize` 迁入普通模式,像素模式默认 `1x1` 且默认关闭,坐标系默认关闭,损坏或缺失值安全回落到默认值。
- 每个新工作区显式选择 `none`、`local` 或 `server`;普通显示功能必须说明怎样接入 scene v1,不得把未来场景大对象无条件塞入服务端配置。 - 每个新工作区显式选择 `none`、`local` 或 `server`;普通显示功能必须说明怎样接入 scene v2,不得把未来场景大对象无条件塞入服务端配置。
核桃派模板(`CONFIG-TEMPLATES`)使用持久根下独立的 `templates/` 数据区: 核桃派模板(`CONFIG-TEMPLATES`)使用持久根下独立的 `templates/` 数据区:
- 每个模板 JSON 使用不可变 UUID 文件名,用户名称不参与路径拼接;落盘记录包含仅供存储层使用的 `schema_version: 1`,API 中的模板 ID、名称、创建/修改时间、场景摘要、内容和修订语义保持不变。 - 每个模板 JSON 使用不可变 UUID 文件名,用户名称不参与路径拼接;落盘记录包含仅供存储层使用的 `schema_version: 2`,API 中的模板 ID、名称、创建/修改时间、场景摘要、内容和修订语义保持不变。
- 缺少版本的现有模板按 legacy v0 逐级迁移到唯一当前 schema。服务启动必须先迁移并校验全部模板记录,再执行任何缩略图协调或清理;任一模板损坏、含未知字段、版本过高或迁移失败时,所有原件保持不变并阻止服务启动。 - 缺少版本的现有模板按 legacy v0 逐级迁移到唯一当前 schema。服务启动必须先迁移并校验全部模板记录,再执行任何缩略图协调或清理;任一模板损坏、含未知字段、版本过高或迁移失败时,所有原件保持不变并阻止服务启动。
- 缩略图文件名包含模板 UUID 和规范场景内容摘要。更新时先生成并验证新图,再提交模板 JSON,最后删除旧摘要图片;重命名不重复生成内容未变化的缩略图。 - 缩略图文件名包含模板 UUID 和规范场景内容摘要。更新时先生成并验证新图,再提交模板 JSON,最后删除旧摘要图片;重命名不重复生成内容未变化的缩略图。
- 服务启动和每次模板变更后只在专用缩略图目录内按严格 UUID/摘要规则清理临时文件、孤立图片和过期图片;读取或迁移阶段存在任何不可读记录时不得开始清理,也不得扫描或删除配置、运行期最新帧、模板 JSON 或其他用户数据。 - 服务启动和每次模板变更后只在专用缩略图目录内按严格 UUID/摘要规则清理临时文件、孤立图片和过期图片;读取或迁移阶段存在任何不可读记录时不得开始清理,也不得扫描或删除配置、运行期最新帧、模板 JSON 或其他用户数据。
@@ -1244,3 +1258,14 @@ WebSocket 消息示例:
- MDN Canvas API:https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API - MDN Canvas API:https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
- Pillow ImageDraw:https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html - Pillow ImageDraw:https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html
- systemd service:https://manpages.debian.org/bookworm/systemd/systemd.service.5.en.html - systemd service:https://manpages.debian.org/bookworm/systemd/systemd.service.5.en.html
### 图层追加接口(`WEB-LAYERS`)
- `POST /api/templates/{id}/layers`:`{layer: <完整图层>}`,要求 `If-Match`,副本独立ID并置顶。
- `POST /api/animations/{id}/frames/layers`:`{layer: <完整图层>, frame_ids: [<稳定帧ID>]}`,要求 `If-Match`;单帧、多选或全部帧共用此接口。全部新场景和缩略图成功后才一次提交索引;失败不改变任何原帧。时长及帧顺序不变。
- 正在编辑的目标有修改时,先保存/放弃/取消;保存失败和冲突停止复制。演示目标只读。
- `/api/preview/text-layer` 新增 `edit_size: 64|192`,默认64;192模式坐标为图像坐标(逻辑坐标加64)。输出仍64×64 RGB888,手机内容库与播放契约不变。
画笔层 `background` 为当前启用的颜色字符串或null(关闭);`backgroundColor` 保存该层在关闭状态下的颜色选择。此已登记可选字段缺省时读取为现有背景颜色或黑色,兼容早期scene v2;新网页保存时包含该值,切层、复制及重载不得借用其他图层颜色。关闭背景不绘制该颜色,擦除/清空/裁切不改变它。
迁移边界:旧导航只有 `text` 时,移除后使用当前默认导航顺序;其余非空顺序保持不变。旧浏览器草稿同样先严格检查字段、整数坐标及范围;未知字段、损坏或未来格式必须保留旧键并禁用覆盖保存,不能由旧宽松读取器丢弃字段后继续迁移。
+1 -1
View File
@@ -1 +1 @@
2026-10-09T10:26+08:00 2026-10-09T16:12+08:00
+7 -1
View File
@@ -70,7 +70,7 @@ Unit ADC 仅使用物理脚 3/5 的 `/dev/i2c-1`(PI8/PI7),默认地址 `0x
## 数据安全 ## 数据安全
配置 schema 为 v8,包含设备共享的 `workspace_order`,并在 v7→v8 时把 `media-import` 插入文字与动图工作区之间。媒体源文件和严格 schema v1 任务记录位于 `/var/lib/matrix-screen-controller/media-import/jobs`;成功或取消立即清理,失败及等待设置保留 24 小时。只有数据文件不存在时才能创建默认值;损坏、未知更高版本或迁移失败时保留原文件并拒绝启动。更新程序必须先克隆 `/var/lib/matrix-screen-controller`,在副本上完成登记迁移并验证后再原子切换,不得把设备凭据、主机密钥、旧设备配置或测试数据打进部署载荷。 配置 schema 为 v11,包含设备共享的 `workspace_order`,v10→v11仅移除文字导航,其余顺序不变;旧文字入口跳转画图页。媒体源文件和严格 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 内存、无交换区和私有网络限制下串行执行;主服务只负责流式上传、任务管理与结果展示。 媒体转换依赖 Debian 的 `ffmpeg`、`ffprobe`、`heif-convert`(`libheif-examples`)以及带 `zscale`、`tonemap` 的 FFmpeg 构建。分析和转换由 `matrix-screen-converter@.service` 在 CPU0–1、150% CPU、384 MiB 内存、无交换区和私有网络限制下串行执行;主服务只负责流式上传、任务管理与结果展示。
@@ -185,3 +185,9 @@ python "核桃派软件源代码/scripts/export_release.py" ota --repair-current
``` ```
此入口校验原产物及记录、候选摘要、最终源码一致性和实机验收结果;完整归档原包与记录后,按原字节晋升候选包,不再构建、不推进 VERSION、不刷新功能时间。验证或提交失败保留原产物与记录。 此入口校验原产物及记录、候选摘要、最终源码一致性和实机验收结果;完整归档原包与记录后,按原字节晋升候选包,不再构建、不推进 VERSION、不刷新功能时间。验证或提交失败保留原产物与记录。
## 多图层及扩展编辑
画图页清空按钮下管理画笔/文字层;至少保留一个画笔层。每个文字层可编辑多段文字。小圆球菜单切换192×192扩展区、移动当前层及视线模式;实际输出始终中央64×64。移动越界确认后永久裁切,可通过会话撤销恢复。
scene v2使用有序图层、±64偏移、192 RGBA压缩PNG和文字相对裁切矩形。模板schema v2、动图schema v3;迁移先校验旧摘要,在数据副本上生成新场景/缩略图后原子切换。损坏/未知/未来版本保留原件并拒绝启动。无需新板端依赖;PNG编解码复用Pillow,浏览器使用仓库内fflate(MIT)。RGB888及手机内容库/播放协议保持不变。
@@ -19,10 +19,10 @@ from app.persistence import atomic_write_bytes
from app.templates.store import ( from app.templates.store import (
TemplateConflictError, TemplateError, TemplateNotFoundError, TemplateConflictError, TemplateError, TemplateNotFoundError,
TemplateStorageFullError, TemplateValidationError, TemplateStorageFullError, TemplateValidationError,
render_scene_thumbnail, scene_digest, validate_scene, render_scene_thumbnail, scene_digest, validate_scene, prepare_scene,
) )
ANIMATION_SCHEMA_VERSION = 2 ANIMATION_SCHEMA_VERSION = 3
NAME_LIMIT = 80 NAME_LIMIT = 80
RECORD_FIELDS = frozenset({"id", "name", "created_at", "updated_at", "frames"}) RECORD_FIELDS = frozenset({"id", "name", "created_at", "updated_at", "frames"})
FRAME_FIELDS = frozenset({ FRAME_FIELDS = frozenset({
@@ -74,7 +74,7 @@ def _revision(record: dict[str, Any]) -> str:
class AnimationStore: class AnimationStore:
"""Schema-v2 animations: compact metadata plus one strict scene per frame.""" """Schema-v3 animations: compact metadata plus one scene-v2 file per frame."""
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None, def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None,
cleanup_playback: bool = True) -> None: cleanup_playback: bool = True) -> None:
@@ -131,7 +131,7 @@ class AnimationStore:
def _write_at(self, record: dict[str, Any], directory_id: str) -> None: def _write_at(self, record: dict[str, Any], directory_id: str) -> None:
self._dir(directory_id).mkdir(parents=True, exist_ok=True) self._dir(directory_id).mkdir(parents=True, exist_ok=True)
data = json.dumps( data = json.dumps(
{"schema_version": 2, **_persisted(record)}, {"schema_version": 3, **_persisted(record)},
ensure_ascii=False, sort_keys=True, indent=2, ensure_ascii=False, sort_keys=True, indent=2,
).encode("utf-8") + b"\n" ).encode("utf-8") + b"\n"
try: try:
@@ -210,22 +210,54 @@ class AnimationStore:
"updated_at": str(document["updated_at"]), "frames": frames, "updated_at": str(document["updated_at"]), "frames": frames,
} }
def _migrate_v1(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]: def _migrate_legacy(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
if value["schema_version"] == 1:
record = self._v1_record(value, expected_id) record = self._v1_record(value, expected_id)
# The entire v1 document is validated before authoritative metadata changes. else:
# Validate all old paths, digests and metadata before creating candidates.
document = dict(value)
document.pop("schema_version")
if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
raise TemplateValidationError("legacy animation fields are invalid")
frames, ids = [], set()
if not isinstance(document["frames"], list) or not document["created_at"] or not document["updated_at"]:
raise TemplateValidationError("legacy animation metadata is invalid")
_name(document["name"])
for raw in document["frames"]:
if not isinstance(raw, dict) or set(raw) != FRAME_FIELDS:
raise TemplateValidationError("legacy frame fields are invalid")
ident = _uuid(raw["id"], "animation frame not found")
if ident in ids or raw["scene_file"] != f"{ident}-{raw['digest']}.json" or raw["thumbnail"] != f"{ident}-{raw['digest']}.png":
raise TemplateValidationError("legacy frame paths or ids are invalid")
ids.add(ident); _duration(raw["duration_ms"]); _name(raw["name"], optional=True)
frames.append(dict(raw, scene=self._load_scene(expected_id, raw)))
record = {**document, "frames": frames}
created = []
try:
for frame in record["frames"]: for frame in record["frames"]:
frame["scene"] = prepare_scene(frame["scene"])
frame["digest"] = scene_digest(frame["scene"])
frame["scene_file"] = f"{frame['id']}-{frame['digest']}.json"
frame["thumbnail"] = f"{frame['id']}-{frame['digest']}.png"
for path in (self._scene_dir(expected_id)/frame["scene_file"], self._thumb_dir(expected_id)/frame["thumbnail"]):
if not path.exists(): created.append(path)
self._write_scene(expected_id, frame) self._write_scene(expected_id, frame)
self._write_thumbnail(expected_id, frame) self._write_thumbnail(expected_id, frame)
self._write(record) self._write(record)
return {"schema_version": 2, **_persisted(record)} except Exception:
for path in created: path.unlink(missing_ok=True)
raise
return {"schema_version": 3, **_persisted(record)}
def _validate_record(self, value: Any, expected_id: str) -> dict[str, Any]: def _validate_record(self, value: Any, expected_id: str) -> dict[str, Any]:
if not isinstance(value, dict): if not isinstance(value, dict):
raise TemplateValidationError("animation record must be an object") raise TemplateValidationError("animation record must be an object")
if value.get("schema_version") == 1: if type(value.get("schema_version")) is not int:
value = self._migrate_v1(value, expected_id) raise TemplateValidationError("animation schema_version must be an integer")
if value.get("schema_version") in (1, 2):
value = self._migrate_legacy(value, expected_id)
document = dict(value) document = dict(value)
if document.pop("schema_version", None) != 2: if document.pop("schema_version", None) != 3:
raise TemplateValidationError("animation schema_version is unsupported") raise TemplateValidationError("animation schema_version is unsupported")
if set(document) != RECORD_FIELDS or document.get("id") != expected_id: if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
raise TemplateValidationError("animation fields do not match the current schema") raise TemplateValidationError("animation fields do not match the current schema")
@@ -252,7 +284,8 @@ class AnimationStore:
"updated_at": str(raw.get("updated_at") or ""), "updated_at": str(raw.get("updated_at") or ""),
"digest": digest, "thumbnail": thumbnail, "scene_file": scene_file, "digest": digest, "thumbnail": thumbnail, "scene_file": scene_file,
} }
self._load_scene(expected_id, frame) if self._load_scene(expected_id, frame)["version"] != 2:
raise TemplateValidationError("animation schema v3 requires scene v2")
if not (self._thumb_dir(expected_id) / thumbnail).is_file(): if not (self._thumb_dir(expected_id) / thumbnail).is_file():
self._write_thumbnail(expected_id, frame) self._write_thumbnail(expected_id, frame)
frames.append(frame) frames.append(frame)
@@ -435,7 +468,7 @@ class AnimationStore:
def _new_frame(self, source: dict[str, Any], now: str) -> dict[str, Any]: def _new_frame(self, source: dict[str, Any], now: str) -> dict[str, Any]:
frame_id = str(uuid4()) frame_id = str(uuid4())
scene = validate_scene(source.get("scene"), strict_schema=True) scene = prepare_scene(source.get("scene"))
digest = scene_digest(scene) digest = scene_digest(scene)
return { return {
"id": frame_id, "name": _name(source.get("name"), optional=True), "id": frame_id, "name": _name(source.get("name"), optional=True),
@@ -499,7 +532,7 @@ class AnimationStore:
def add_frame(self, animation_id: Any, scene: Any, expected: str, *, def add_frame(self, animation_id: Any, scene: Any, expected: str, *,
name: Any = None, duration_ms: Any = DEFAULT_DURATION_MS) -> dict[str, Any]: name: Any = None, duration_ms: Any = DEFAULT_DURATION_MS) -> dict[str, Any]:
source = { source = {
"scene": validate_scene(scene), "name": _name(name, optional=True), "scene": prepare_scene(scene), "name": _name(name, optional=True),
"duration_ms": _duration(duration_ms), "duration_ms": _duration(duration_ms),
} }
with self._lock: with self._lock:
@@ -549,7 +582,8 @@ class AnimationStore:
frame = self._frame(record, frame_id); now = self._now() frame = self._frame(record, frame_id); now = self._now()
old_thumb, old_scene = frame["thumbnail"], frame["scene_file"] old_thumb, old_scene = frame["thumbnail"], frame["scene_file"]
if scene is not None: if scene is not None:
checked, digest = validate_scene(scene), scene_digest(validate_scene(scene)) checked = prepare_scene(scene)
digest = scene_digest(checked)
frame.update({ frame.update({
"scene": checked, "digest": digest, "scene": checked, "digest": digest,
"thumbnail": f"{frame['id']}-{digest}.png", "thumbnail": f"{frame['id']}-{digest}.png",
@@ -614,6 +648,37 @@ class AnimationStore:
def set_all_durations(self, animation_id: Any, duration_ms: Any, expected: str) -> dict[str, Any]: 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) return self.set_durations(animation_id, duration_ms, expected)
def append_layer(self, animation_id: Any, layer: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
from app.scene_layers import copy_layer, validate_layer
from app.templates.store import _validate_scene_v1
try:
checked = validate_layer(layer, _validate_scene_v1)
except (ValueError, TypeError, KeyError) as exc:
raise TemplateValidationError(str(exc)) from exc
with self._lock:
record = self._read(animation_id); self._match(record, expected)
targets = self._frames(record, frame_ids)
created, now = [], self._now()
try:
for frame in targets:
scene = prepare_scene(self._load_scene(record["id"], frame))
scene["layers"].append(copy_layer(checked, scene["layers"]))
digest = scene_digest(scene)
frame.update(scene=scene, digest=digest, updated_at=now,
scene_file=f"{frame['id']}-{digest}.json",
thumbnail=f"{frame['id']}-{digest}.png")
for path in (self._scene_dir(record["id"])/frame["scene_file"], self._thumb_dir(record["id"])/frame["thumbnail"]):
if not path.exists(): created.append(path)
self._write_scene(record["id"], frame)
self._write_thumbnail(record["id"], frame)
record["updated_at"] = now
self._write(record)
except Exception:
for path in created: path.unlink(missing_ok=True)
raise
self._cleanup_orphans(record)
return self._detail(record)
def copy_frames(self, source_animation_id: Any, frame_ids: Any, def copy_frames(self, source_animation_id: Any, frame_ids: Any,
source_expected: str, destination_animation_id: Any, source_expected: str, destination_animation_id: Any,
destination_expected: str, insert_after_frame_id: Any = None) -> dict[str, Any]: destination_expected: str, insert_after_frame_id: Any = None) -> dict[str, Any]:
+8 -2
View File
@@ -35,7 +35,6 @@ DEFAULT_WORKSPACE_ORDER = [
"device", "device",
"settings", "settings",
"canvas", "canvas",
"text",
"media-import", "media-import",
"animations", "animations",
"templates", "templates",
@@ -66,7 +65,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT, "animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
} }
CONFIG_SCHEMA_VERSION = 10 CONFIG_SCHEMA_VERSION = 11
CONFIG_V1_FIELDS = frozenset({ CONFIG_V1_FIELDS = frozenset({
"orientation", "orientation",
"brightness", "brightness",
@@ -455,6 +454,12 @@ def _migrate_config_v9_to_v10(document: dict[str, Any]) -> dict[str, Any]:
} }
def _migrate_config_v10_to_v11(document: dict[str, Any]) -> dict[str, Any]:
checked = validate_config(document)
checked["workspace_order"] = [item for item in checked["workspace_order"] if item != "text"] or list(DEFAULT_WORKSPACE_ORDER)
return checked
CONFIG_MIGRATIONS = { CONFIG_MIGRATIONS = {
0: _migrate_config_v0_to_v1, 0: _migrate_config_v0_to_v1,
1: _migrate_config_v1_to_v2, 1: _migrate_config_v1_to_v2,
@@ -466,6 +471,7 @@ CONFIG_MIGRATIONS = {
7: _migrate_config_v7_to_v8, 7: _migrate_config_v7_to_v8,
8: _migrate_config_v8_to_v9, 8: _migrate_config_v8_to_v9,
9: _migrate_config_v9_to_v10, 9: _migrate_config_v9_to_v10,
10: _migrate_config_v10_to_v11,
} }
+34 -4
View File
@@ -249,6 +249,15 @@ class PaletteColorRequest(BaseModel):
color: str color: str
class LayerAppendRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
layer: dict[str, Any]
class FrameLayerAppendRequest(LayerAppendRequest):
frame_ids: list[str] = Field(min_length=1)
class TemplateCreateRequest(BaseModel): class TemplateCreateRequest(BaseModel):
name: str name: str
scene: dict[str, Any] scene: dict[str, Any]
@@ -353,6 +362,7 @@ class TextRequest(BaseModel):
class TextLayerRequest(BaseModel): class TextLayerRequest(BaseModel):
edit_size: Literal[64, 192] = 64
text: str = Field(min_length=0, max_length=512) text: str = Field(min_length=0, max_length=512)
font: str = "default" font: str = "default"
size: int = 12 size: int = 12
@@ -1337,6 +1347,23 @@ def create_app(
except TemplateError as exc: except TemplateError as exc:
raise template_error(exc) from exc raise template_error(exc) from exc
@app.post("/api/templates/{template_id}/layers")
def append_template_layer(template_id: str, request: LayerAppendRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict:
try:
if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
return templates.append_layer(template_id, request.layer, normalize_if_match(if_match))
except TemplateError as exc:
raise template_error(exc) from exc
@app.post("/api/animations/{animation_id}/frames/layers")
def append_animation_layer(animation_id: str, request: FrameLayerAppendRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict:
try:
reject_demo_animation(animation_id)
return animations.append_layer(animation_id, request.layer, request.frame_ids, normalize_if_match(if_match))
except TemplateError as exc:
raise template_error(exc) from exc
@app.put("/api/templates/{template_id}") @app.put("/api/templates/{template_id}")
def update_template( def update_template(
template_id: str, template_id: str,
@@ -1344,7 +1371,8 @@ def create_app(
if_match: str | None = Header(default=None, alias="If-Match"), if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict: ) -> dict:
try: try:
reject_demo_template(template_id) if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
return templates.update_scene(template_id, request.scene, normalize_if_match(if_match)) return templates.update_scene(template_id, request.scene, normalize_if_match(if_match))
except TemplateError as exc: except TemplateError as exc:
raise template_error(exc) from exc raise template_error(exc) from exc
@@ -1356,7 +1384,8 @@ def create_app(
if_match: str | None = Header(default=None, alias="If-Match"), if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict: ) -> dict:
try: try:
reject_demo_template(template_id) if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
return templates.rename(template_id, request.name, normalize_if_match(if_match)) return templates.rename(template_id, request.name, normalize_if_match(if_match))
except TemplateError as exc: except TemplateError as exc:
raise template_error(exc) from exc raise template_error(exc) from exc
@@ -1408,7 +1437,8 @@ def create_app(
if_match: str | None = Header(default=None, alias="If-Match"), if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict: ) -> dict:
try: try:
reject_demo_template(template_id) if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
expected = normalize_if_match(if_match) expected = normalize_if_match(if_match)
current = templates.get(template_id) current = templates.get(template_id)
if current["revision"] != expected: if current["revision"] != expected:
@@ -1909,7 +1939,7 @@ def create_app(
try: try:
image = render_text_layer( image = render_text_layer(
text_layer_options_from_request(request), text_layer_options_from_request(request),
size=(display.width, display.height), size=(request.edit_size, request.edit_size),
font_resolver=fonts.resolve, font_resolver=fonts.resolve,
) )
return image_png_response(image) return image_png_response(image)
@@ -0,0 +1,168 @@
"""Scene-v2 validation, legacy upgrade and logical composition. No driver I/O."""
from __future__ import annotations
import base64
import binascii
import copy
import math
from io import BytesIO
from uuid import uuid4
from PIL import Image
from app.display.text_renderer import TextOptions, render_text_layer
from app.display.colors import parse_color
SIDE = 192
ORIGIN = 64
def decode_pixels(value):
if not isinstance(value, str) or len(value) > 300000:
raise ValueError("brush PNG must be bounded base64")
try:
data = base64.b64decode(value, validate=True)
if (data[:8] != b"\x89PNG\r\n\x1a\n" or data[8:16] != b"\x00\x00\x00\rIHDR"
or data[24:29] != b"\x08\x06\x00\x00\x00"):
raise ValueError("brush PNG must be noninterlaced RGBA8")
with Image.open(BytesIO(data)) as image:
image.verify()
with Image.open(BytesIO(data)) as image:
if image.format != "PNG" or image.size != (SIDE, SIDE) or image.mode != "RGBA":
raise ValueError("brush PNG must be 192x192 RGBA")
image.load()
return image.copy()
except (OSError, binascii.Error) as exc:
raise ValueError("invalid brush PNG") from exc
def encode_pixels(image):
out = BytesIO()
image.save(out, format="PNG", compress_level=6)
return base64.b64encode(out.getvalue()).decode("ascii")
def upgrade_scene(scene):
if scene["version"] == 2:
return copy.deepcopy(scene)
pixels = Image.frombytes("RGB", (64, 64), base64.b64decode(scene["pixelRgb"])).convert("RGBA")
canvas = Image.new("RGBA", (SIDE, SIDE))
canvas.paste(pixels, (ORIGIN, ORIGIN))
layers = [dict(id="layer-1", name="图层1", type="brush", x=0, y=0,
background=None, pixelPng=encode_pixels(canvas))]
if scene["elements"]:
layers.append(dict(id="layer-2", name="图层2", type="text", x=0, y=0,
elements=[dict(element, clip=None) for element in scene["elements"]]))
return dict(version=2, width=64, height=64, layers=layers)
def validate_v2(value, validate_legacy):
if set(value) != {"version", "width", "height", "layers"} or any(type(value[k]) is not int for k in ("version", "width", "height")) or (value["version"], value["width"], value["height"]) != (2, 64, 64):
raise ValueError("scene v2 fields or dimensions are invalid")
if not isinstance(value["layers"], list) or not value["layers"]:
raise ValueError("scene must contain a brush layer")
ids, element_ids, layers = set(), set(), []
for layer in value["layers"]:
layers.append(validate_layer(layer, validate_legacy, ids, element_ids))
if not any(layer["type"] == "brush" for layer in layers):
raise ValueError("scene must retain at least one brush layer")
return dict(version=2, width=64, height=64, layers=layers)
def validate_layer(layer, validate_legacy, ids=None, element_ids=None):
ids = ids if ids is not None else set()
element_ids = element_ids if element_ids is not None else set()
if not isinstance(layer, dict) or layer.get("type") not in {"brush", "text"}:
raise ValueError("unsupported layer type")
fields = {"id", "name", "type", "x", "y"} | ({"background", "pixelPng"} if layer["type"] == "brush" else {"elements"})
if layer["type"] == "brush" and "backgroundColor" in layer:
fields.add("backgroundColor")
if set(layer) != fields:
raise ValueError("layer fields do not match schema")
ident = layer["id"]
if not isinstance(ident, str) or not ident.strip() or len(ident) > 128 or ident in ids:
raise ValueError("layer ids must be unique nonempty strings")
ids.add(ident)
if not isinstance(layer["name"], str) or not layer["name"].strip() or len(layer["name"]) > 80:
raise ValueError("layer name must contain 1..80 characters")
if any(type(layer[k]) is not int or not -64 <= layer[k] <= 64 for k in ("x", "y")):
raise ValueError("layer offset must be an integer in -64..64")
result = copy.deepcopy(layer)
if layer["type"] == "brush":
decode_pixels(layer["pixelPng"])
if "backgroundColor" in layer:
color = layer["backgroundColor"]
if not isinstance(color, str) or len(color) != 7 or not color.startswith("#"):
raise ValueError("backgroundColor must be #RRGGBB")
parse_color(color)
if layer["background"] is not None:
if not isinstance(layer["background"], str) or len(layer["background"]) != 7 or not layer["background"].startswith("#"):
raise ValueError("background must be #RRGGBB or null")
parse_color(layer["background"])
else:
if not isinstance(layer["elements"], list):
raise ValueError("text elements must be an array")
checked = []
for element in layer["elements"]:
if not isinstance(element, dict) or "clip" not in element:
raise ValueError("text clip is required")
raw = {k: v for k, v in element.items() if k != "clip"}
legacy = dict(version=1, width=64, height=64, pixelRgb=base64.b64encode(bytes(12288)).decode("ascii"), elements=[raw])
text = validate_legacy(legacy, strict_schema=True)["elements"][0]
if text["id"] in element_ids:
raise ValueError("text ids must be globally unique")
element_ids.add(text["id"])
clip = element["clip"]
if clip is not None and (not isinstance(clip, list) or len(clip) != 4 or any(type(n) not in (int, float) or not math.isfinite(n) or abs(n) > 4096 for n in clip) or clip[2] < clip[0] or clip[3] < clip[1]):
raise ValueError("text clip must be a finite rectangle or null")
checked.append(dict(text, clip=clip))
result["elements"] = checked
return result
def copy_layer(layer, existing):
result = copy.deepcopy(layer)
result["id"] = str(uuid4())
sequence = 1
names = {item["name"] for item in existing}
while f"图层{sequence}" in names:
sequence += 1
result["name"] = f"图层{sequence}"
for element in result.get("elements", []):
element["id"] = str(uuid4())
return result
def render_element(element, font_resolver=None):
options = TextOptions(**{k: element[k] for k in ("text", "font", "size", "align")},
x=element["x"] + ORIGIN, y=element["y"] + ORIGIN,
color=parse_color(element["color"]))
image = render_text_layer(options, size=(SIDE, SIDE), font_resolver=font_resolver)
if element.get("clip") is not None:
# Rectangle is relative to the text anchor, in units of its font size.
left, top, right, bottom = element["clip"]
pixels = image.load()
for y in range(SIDE):
for x in range(SIDE):
u=(x-ORIGIN-element["x"])/element["size"]
v=(y-ORIGIN-element["y"])/element["size"]
if not left <= u < right or not top <= v < bottom:
pixels[x,y]=(0,0,0,0)
return image
def render_v2(scene, font_resolver=None):
image = Image.new("RGBA", (SIDE, SIDE), (0, 0, 0, 255))
def paint(source, layer):
nonlocal image
shifted = Image.new("RGBA", (SIDE, SIDE))
shifted.paste(source, (layer["x"], layer["y"]))
image = Image.alpha_composite(image, shifted)
for layer in scene["layers"]:
if layer["type"] == "brush":
if layer["background"]:
paint(Image.new("RGBA", (SIDE, SIDE), parse_color(layer["background"]) + (255,)), layer)
paint(decode_pixels(layer["pixelPng"]), layer)
else:
# Match browser source-over rounding after every text element.
for element in layer["elements"]:
paint(render_element(element, font_resolver), layer)
return image.crop((ORIGIN, ORIGIN, ORIGIN+64, ORIGIN+64)).convert("RGB")
@@ -12,6 +12,7 @@ import "./color-picker.js";
import "./views/canvas.js"; import "./views/canvas.js";
import { refreshStatus } from "./views/device.js"; import { refreshStatus } from "./views/device.js";
import "./views/text.js"; import "./views/text.js";
import "./views/layers.js";
import "./views/media-import.js"; import "./views/media-import.js";
import "./views/animations.js"; import "./views/animations.js";
import "./views/templates.js"; import "./views/templates.js";
@@ -377,3 +378,6 @@ document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") pollStatus(); if (document.visibilityState === "visible") pollStatus();
}); });
pollStatus(); pollStatus();
if(window.location.hash==="#text")window.location.hash="#canvas";
window.addEventListener("hashchange",()=>{if(window.location.hash==="#text")window.location.hash="#canvas";});
@@ -62,13 +62,15 @@ export function coordinateGuideTicks({
visibleStart = Number.NEGATIVE_INFINITY, visibleStart = Number.NEGATIVE_INFINITY,
visibleEnd = Number.POSITIVE_INFINITY, visibleEnd = Number.POSITIVE_INFINITY,
step, step,
count = 64,
origin = 0,
}) { }) {
const start = Number(boardStart) || 0; const start = Number(boardStart) || 0;
const size = Math.max(0, Number(boardSize) || 0); const size = Math.max(0, Number(boardSize) || 0);
const interval = COORDINATE_GUIDE_STEPS.includes(step) ? step : COORDINATE_GUIDE_STEPS[0]; const interval = COORDINATE_GUIDE_STEPS.includes(step) ? step : COORDINATE_GUIDE_STEPS[0];
const ticks = []; const ticks = [];
for (let value = 0; value < 64; value += interval) { for (let value = origin; value < origin + count; value += interval) {
const position = start + (value / 64) * size; const position = start + ((value-origin) / count) * size;
if (position >= visibleStart && position <= visibleEnd) ticks.push({ value, position }); if (position >= visibleStart && position <= visibleEnd) ticks.push({ value, position });
} }
return ticks; return ticks;
+38 -15
View File
@@ -103,6 +103,7 @@
<div id="board-stage" class="board-stage"> <div id="board-stage" class="board-stage">
<canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas> <canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas>
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas> <canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
<div id="actual-screen-frame" aria-hidden="true" hidden><span>实际屏幕 64×64</span></div>
<div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div> <div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div>
</div> </div>
<canvas id="canvas-coordinate-guide" class="canvas-coordinate-guide" aria-hidden="true" hidden></canvas> <canvas id="canvas-coordinate-guide" class="canvas-coordinate-guide" aria-hidden="true" hidden></canvas>
@@ -120,6 +121,16 @@
</button> </button>
<div id="canvas-assistive-menu" class="canvas-assistive-menu" role="menu" aria-label="画布编辑操作" hidden> <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> <p id="canvas-edit-mode-hint" role="status" aria-live="polite">绘画模式:直接在画布上绘制</p>
<button id="toggle-expanded-board" type="button" role="menuitem" aria-pressed="false">显示扩展画面</button>
<button id="move-current-layer" type="button" role="menuitem" aria-pressed="false">移动当前图层</button>
<section id="layer-move-controls" class="layer-move-controls" aria-label="图层偏移" hidden>
<button id="exit-layer-move" type="button">退出图层移动</button>
<p>移动图层 · 范围 −64..64</p>
<label>X <input id="layer-offset-x" type="number" min="-64" max="64" value="0" step="1"></label>
<div class="offset-steps" data-axis="x"><button type="button" data-offset="-10">−10</button><button type="button" data-offset="-1">−1</button><button type="button" data-offset="1">+1</button><button type="button" data-offset="10">+10</button></div>
<label>Y <input id="layer-offset-y" type="number" min="-64" max="64" value="0" step="1"></label>
<div class="offset-steps" data-axis="y"><button type="button" data-offset="-10">−10</button><button type="button" data-offset="-1">−1</button><button type="button" data-offset="1">+1</button><button type="button" data-offset="10">+10</button></div>
</section>
<button id="toggle-canvas-coordinates" type="button" role="menuitem" aria-pressed="false">显示坐标系</button> <button id="toggle-canvas-coordinates" type="button" role="menuitem" aria-pressed="false">显示坐标系</button>
<button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true" <button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true"
data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button> data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button>
@@ -218,13 +229,14 @@
</div> </div>
<article class="panel canvas-workbench"> <article class="panel canvas-workbench">
<div id="brush-layer-tools">
<div class="canvas-toolbar"> <div class="canvas-toolbar">
<div class="color-field"><span>画笔颜色</span> <div class="color-field"><div class="canvas-color-heading"><span>画笔颜色</span></div>
<button id="paint-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择画笔颜色"> <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> <span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
</button> </button>
</div> </div>
<div class="color-field"><span>背景颜色</span> <div class="color-field"><div class="canvas-color-heading"><span>背景颜色</span><label class="layer-background-toggle"><input id="layer-background-enabled" type="checkbox"><span>开启图层背景</span></label></div>
<button id="background-color" class="color-trigger" type="button" value="#000000" data-color-picker aria-label="选择画布背景颜色"> <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> <span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#000000</span>
</button> </button>
@@ -252,20 +264,8 @@
<button id="fill-canvas" type="button">填充背景</button> <button id="fill-canvas" type="button">填充背景</button>
<button id="clear-canvas" type="button">清空像素层</button> <button id="clear-canvas" type="button">清空像素层</button>
</div> </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> </div>
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span> <div id="text-layer-tools" hidden>
</div>
<article class="panel text-elements-panel"> <article class="panel text-elements-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
@@ -329,6 +329,22 @@
</div> </div>
</fieldset> </fieldset>
</form> </form>
</div>
<section id="layer-panel" class="layer-panel" aria-labelledby="layer-panel-title">
<h3 id="layer-panel-title">图层</h3>
<div class="layer-actions">
<button id="new-brush-layer" type="button">新建画笔层</button><button id="new-text-layer" type="button">新建文字层</button>
<button id="copy-layer" type="button">复制图层…</button><button id="rename-layer" type="button">重命名</button>
<button id="raise-layer" type="button">上移</button><button id="lower-layer" type="button">下移</button>
<button id="delete-layer" class="danger-button" type="button">删除图层</button>
</div>
<div id="layer-list" class="layer-list" role="group" aria-label="图层顺序,上方覆盖下方"></div>
<p class="hint">上方图层覆盖下方。至少保留一个画笔层。</p>
</section>
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
</article>
</section> </section>
<section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden> <section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden>
@@ -942,5 +958,12 @@
<div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div> <div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<noscript>此控制台需要启用 JavaScript。</noscript> <noscript>此控制台需要启用 JavaScript。</noscript>
<script type="module" src="/static/app.js"></script> <script type="module" src="/static/app.js"></script>
<dialog id="layer-copy-dialog" class="layer-copy-dialog">
<h2>复制当前图层</h2>
<label>目标画面 <select id="layer-copy-target"><option value="current">当前画板</option></select></label>
<section id="layer-copy-frames" hidden><p>选择目标帧</p><button id="layer-copy-all" type="button">全选</button><button id="layer-copy-none" type="button">清空选择</button><div id="layer-copy-frame-list"></div></section>
<p id="layer-copy-feedback" role="status" aria-live="polite"></p>
<div class="layer-actions"><button id="layer-copy-submit" type="button">复制到最上方</button><button id="layer-copy-cancel" type="button">取消</button></div>
</dialog>
</body> </body>
</html> </html>
@@ -0,0 +1,54 @@
// Fixed 192x192 RGBA PNG codec, independent of DOM and shared by Node tests.
import { zlibSync, unzlibSync } from "./vendor/fflate/fflate.js";
const SIDE = 192, STRIDE = SIDE * 4;
const SIGNATURE = [137,80,78,71,13,10,26,10];
const crcTable = Uint32Array.from({ length: 256 }, (_, n) => {
for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
return n >>> 0;
});
function crc(bytes) { let n = 0xffffffff; for (const b of bytes) n = crcTable[(n ^ b) & 255] ^ (n >>> 8); return (n ^ 0xffffffff) >>> 0; }
function chunk(name, data) {
const out = new Uint8Array(data.length + 12), view = new DataView(out.buffer);
view.setUint32(0, data.length); out.set(Array.from(name, c => c.charCodeAt(0)), 4); out.set(data, 8);
view.setUint32(out.length - 4, crc(out.subarray(4, out.length - 4))); return out;
}
export function encodePixels(rgba) {
if (rgba.length !== SIDE * STRIDE) throw new Error("画笔像素长度无效");
const ihdr = new Uint8Array(13), v = new DataView(ihdr.buffer);
v.setUint32(0, SIDE); v.setUint32(4, SIDE); ihdr[8] = 8; ihdr[9] = 6;
const raw = new Uint8Array((STRIDE + 1) * SIDE);
for (let y = 0; y < SIDE; y++) raw.set(rgba.subarray(y * STRIDE, (y + 1) * STRIDE), y * (STRIDE + 1) + 1);
const parts = [new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", zlibSync(raw, { level: 6 })), chunk("IEND", new Uint8Array())];
const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let at = 0;
for (const p of parts) { png.set(p, at); at += p.length; }
let binary = ""; for (const b of png) binary += String.fromCharCode(b);
return btoa(binary);
}
function paeth(a,b,c) { const p=a+b-c, x=Math.abs(p-a), y=Math.abs(p-b), z=Math.abs(p-c); return x<=y && x<=z ? a : y<=z ? b : c; }
export function decodePixels(value) {
if (typeof value !== "string" || value.length > 300000 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error("画笔PNG无效");
const bytes = Uint8Array.from(atob(value), c => c.charCodeAt(0)), view = new DataView(bytes.buffer);
if (!SIGNATURE.every((b,i) => bytes[i] === b)) throw new Error("画笔PNG签名无效");
const parts=[]; let header=false, ended=false;
for (let at=8; at+12<=bytes.length;) {
const n=view.getUint32(at); if (n>bytes.length-at-12) throw new Error("PNG截断");
const name=String.fromCharCode(...bytes.subarray(at+4,at+8)), data=bytes.subarray(at+8,at+8+n);
if (crc(bytes.subarray(at+4,at+8+n))!==view.getUint32(at+8+n)) throw new Error("PNG校验失败");
if (name==="IHDR") { if (header || n!==13 || view.getUint32(at+8)!==SIDE || view.getUint32(at+12)!==SIDE || data[8]!==8 || data[9]!==6 || data[10] || data[11] || data[12]) throw new Error("PNG必须为192×192 RGBA8"); header=true; }
else if (name==="IDAT") { if (!header || ended) throw new Error("PNG顺序无效"); parts.push(data); }
else if (name==="IEND") { if (n || at+12!==bytes.length) throw new Error("PNG结尾无效"); ended=true; }
else if (name[0]===name[0].toUpperCase()) throw new Error("PNG不支持此块");
at+=n+12;
}
if (!header || !ended || !parts.length) throw new Error("PNG不完整");
const compressed=new Uint8Array(parts.reduce((n,p)=>n+p.length,0)); let at=0; for (const p of parts) { compressed.set(p,at); at+=p.length; }
const expected=(STRIDE+1)*SIDE, raw=unzlibSync(compressed,{out:new Uint8Array(expected+1)});
if (raw.length!==expected) throw new Error("PNG展开长度无效");
const out=new Uint8ClampedArray(SIDE*STRIDE);
for (let y=0;y<SIDE;y++) { const filter=raw[y*(STRIDE+1)]; if (filter>4) throw new Error("PNG过滤器无效");
for (let x=0;x<STRIDE;x++) { const i=y*STRIDE+x, a=x>=4?out[i-4]:0, b=y?out[i-STRIDE]:0, c=y&&x>=4?out[i-STRIDE-4]:0;
out[i]=(raw[y*(STRIDE+1)+1+x]+([0,a,b,Math.floor((a+b)/2),paeth(a,b,c)][filter]))&255;
}
}
return out;
}
+181 -108
View File
@@ -1,3 +1,4 @@
import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js";
import { announce, apiBlob, isAppStale } from "./core.js"; import { announce, apiBlob, isAppStale } from "./core.js";
import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js"; import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js";
import { import {
@@ -34,8 +35,8 @@ compositionCanvas.width = SCENE_WIDTH;
compositionCanvas.height = SCENE_HEIGHT; compositionCanvas.height = SCENE_HEIGHT;
const compositionContext = compositionCanvas.getContext("2d", { willReadFrequently: true }); const compositionContext = compositionCanvas.getContext("2d", { willReadFrequently: true });
const scratchCanvas = document.createElement("canvas"); const scratchCanvas = document.createElement("canvas");
scratchCanvas.width = SCENE_WIDTH; scratchCanvas.width = 192;
scratchCanvas.height = SCENE_HEIGHT; scratchCanvas.height = 192;
const scratchContext = scratchCanvas.getContext("2d", { willReadFrequently: true }); const scratchContext = scratchCanvas.getContext("2d", { willReadFrequently: true });
const boardTitle = document.getElementById("board-title"); const boardTitle = document.getElementById("board-title");
const boardEyebrow = document.getElementById("board-eyebrow"); const boardEyebrow = document.getElementById("board-eyebrow");
@@ -76,6 +77,11 @@ const draftStorage = browserStorage();
const draftLoad = restoreSceneDraft(draftStorage); const draftLoad = restoreSceneDraft(draftStorage);
let scene = draftLoad.scene; let scene = draftLoad.scene;
let selectedTextId = null; let selectedTextId = null;
let selectedLayerId = scene.layers[0].id;
let expandedBoard = false;
let layerMovePreview = null;
let moveFrameVisible = false;
const editHistory = [];
let interactionMode = "readonly"; let interactionMode = "readonly";
let activeWorkspace = "device"; let activeWorkspace = "device";
let previewKind = "scene"; let previewKind = "scene";
@@ -175,6 +181,8 @@ keepCurrentDraftButton.addEventListener("click", () => {
function elementSignature(element) { function elementSignature(element) {
return JSON.stringify({ return JSON.stringify({
layerId: scene.layers.find(layer => layer.elements?.some(item => item.id === element.id))?.id,
elementId: element.id,
text: element.text, text: element.text,
font: element.font, font: element.font,
size: element.size, size: element.size,
@@ -182,19 +190,13 @@ function elementSignature(element) {
y: element.y, y: element.y,
align: element.align, align: element.align,
color: element.color, color: element.color,
clip: element.clip,
}); });
} }
function textPayload(element) { function textPayload(element) {
return { return {text:element.text,font:element.font,size:element.size,x:element.x+64,y:element.y+64,
text: element.text, align:element.align,color:element.color,edit_size:192};
font: element.font,
size: element.size,
x: element.x,
y: element.y,
align: element.align,
color: element.color,
};
} }
function elementById(id) { function elementById(id) {
@@ -221,13 +223,13 @@ async function imageFromBlob(blob) {
} }
function alphaBounds(rgba) { function alphaBounds(rgba) {
let left = SCENE_WIDTH; let left = 192;
let top = SCENE_HEIGHT; let top = 192;
let right = -1; let right = -1;
let bottom = -1; let bottom = -1;
for (let y = 0; y < SCENE_HEIGHT; y += 1) { for (let y = 0; y < 192; y += 1) {
for (let x = 0; x < SCENE_WIDTH; x += 1) { for (let x = 0; x < 192; x += 1) {
if (rgba[(y * SCENE_WIDTH + x) * 4 + 3] === 0) continue; if (rgba[(y * 192 + x) * 4 + 3] === 0) continue;
left = Math.min(left, x); left = Math.min(left, x);
top = Math.min(top, y); top = Math.min(top, y);
right = Math.max(right, x); right = Math.max(right, x);
@@ -235,7 +237,7 @@ function alphaBounds(rgba) {
} }
} }
if (right < left || bottom < top) return null; if (right < left || bottom < top) return null;
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 }; return { x: left-64, y: top-64, width: right - left + 1, height: bottom - top + 1 };
} }
function layerEntry(id) { function layerEntry(id) {
@@ -318,15 +320,15 @@ async function loadTextLayer(id, requestedSignature, requestedRevision) {
closeImage(image); closeImage(image);
return; return;
} }
scratchContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); scratchContext.clearRect(0, 0, 192, 192);
scratchContext.drawImage(image, 0, 0); scratchContext.drawImage(image, 0, 0);
const rgba = new Uint8ClampedArray( const rgba = new Uint8ClampedArray(
scratchContext.getImageData(0, 0, SCENE_WIDTH, SCENE_HEIGHT).data, scratchContext.getImageData(0, 0, 192, 192).data,
); );
closeImage(entry.image); closeImage(entry.image);
entry.image = image; entry.image = image;
entry.rgba = rgba; entry.rgba = clipTextPixels(rgba, current);
entry.bounds = alphaBounds(rgba); entry.bounds = alphaBounds(entry.rgba);
entry.signature = requestedSignature; entry.signature = requestedSignature;
entry.revision = requestedRevision; entry.revision = requestedRevision;
entry.targetSignature = requestedSignature; entry.targetSignature = requestedSignature;
@@ -369,14 +371,9 @@ function layerState() {
} }
function putRgb(context, rgb) { function putRgb(context, rgb) {
const image = context.createImageData(SCENE_WIDTH, SCENE_HEIGHT); const side=Math.sqrt(rgb.length/3), image=context.createImageData(side,side);
for (let source = 0, target = 0; source < rgb.length; source += 3, target += 4) { for(let i=0,j=0;i<rgb.length;i+=3,j+=4) {image.data.set(rgb.subarray(i,i+3),j);image.data[j+3]=255;}
image.data[target] = rgb[source]; context.putImageData(image,0,0);
image.data[target + 1] = rgb[source + 1];
image.data[target + 2] = rgb[source + 2];
image.data[target + 3] = 255;
}
context.putImageData(image, 0, 0);
} }
function drawTransformedLayer(context, element, entry) { function drawTransformedLayer(context, element, entry) {
@@ -397,34 +394,35 @@ function drawTransformedLayer(context, element, entry) {
context.restore(); context.restore();
} }
function previewTextPixels() {
const result=new Map();
for(const e of scene.elements) {
let rgba=textLayers.get(e.id)?.rgba;
if(!rgba) continue;
if(activeTransform?.id===e.id) {
const out=new Uint8ClampedArray(rgba.length), t=activeTransform;
for(let y=0;y<192;y++) for(let x=0;x<192;x++) {
const sx=Math.floor(t.kind==="drag"?x-t.dx:e.x+64+(x-e.x-64)/t.scale);
const sy=Math.floor(t.kind==="drag"?y-t.dy:e.y+64+(y-e.y-64)/t.scale);
if(sx>=0&&sx<192&&sy>=0&&sy<192) out.set(rgba.subarray((sy*192+sx)*4,(sy*192+sx)*4+4),(y*192+x)*4);
}
rgba=out;
}
result.set(e.id,rgba);
}
return result;
}
function renderComposition() { function renderComposition() {
removeUnusedLayers(); removeUnusedLayers();
scene.elements.forEach((element) => scheduleTextLayerIfMissing(element)); scene.elements.forEach(scheduleTextLayerIfMissing);
const state = layerState(); const state=layerState(), pixels=previewTextPixels();
if (activeTransform) { const rgb=composeScene(scene,pixels,64,layerMovePreview);
putRgb(compositionContext, scene.pixels); putRgb(compositionContext,rgb);
scene.elements.forEach((element) => drawTransformedLayer( lastExactRgb=state.ready&&!activeTransform&&!layerMovePreview?rgb:null;
compositionContext, if(previewKind==="scene") putRgb(visibleContext,expandedBoard?composeScene(scene,pixels,192,layerMovePreview):rgb);
element, animationBoardPreviewContext.clearRect(0,0,64,64);
textLayers.get(element.id), animationBoardPreviewContext.drawImage(compositionCanvas,0,0);
)); drawSelectionOverlay();updateBoardUi(state);
lastExactRgb = null;
} else {
const rgbaLayers = scene.elements
.map((element) => textLayers.get(element.id)?.rgba)
.filter(Boolean);
const rgb = compositeRgbaLayers(scene.pixels, rgbaLayers);
putRgb(compositionContext, rgb);
lastExactRgb = state.ready ? rgb : null;
}
if (previewKind === "scene") {
visibleContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
visibleContext.drawImage(compositionCanvas, 0, 0);
}
animationBoardPreviewContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
animationBoardPreviewContext.drawImage(compositionCanvas, 0, 0);
drawSelectionOverlay();
updateBoardUi(state);
} }
function scheduleTextLayerIfMissing(element) { function scheduleTextLayerIfMissing(element) {
@@ -455,7 +453,7 @@ function updateBoardUi(state = layerState()) {
setControlUnavailable(applyButton, "浏览器无法安全保存当前草稿,请先处理页面提示后再应用。"); setControlUnavailable(applyButton, "浏览器无法安全保存当前草稿,请先处理页面提示后再应用。");
} else if (draftConflict) { } else if (draftConflict) {
setControlUnavailable(applyButton, "请先解决另一标签页的草稿冲突,再应用画板。"); setControlUnavailable(applyButton, "请先解决另一标签页的草稿冲突,再应用画板。");
} else if (activeTransform) { } else if (activeTransform || layerMovePreview) {
setControlUnavailable(applyButton, "请先完成当前文字拖动或缩放操作。"); setControlUnavailable(applyButton, "请先完成当前文字拖动或缩放操作。");
} else if (!state.ready) { } else if (!state.ready) {
setControlUnavailable(applyButton, state.errors setControlUnavailable(applyButton, state.errors
@@ -464,7 +462,7 @@ function updateBoardUi(state = layerState()) {
} else { } else {
setControlReady(applyButton); setControlReady(applyButton);
} }
if (activeTransform) { if (activeTransform || layerMovePreview) {
setControlUnavailable(exportButton, "请先完成当前文字拖动或缩放操作。"); setControlUnavailable(exportButton, "请先完成当前文字拖动或缩放操作。");
} else if (!state.ready) { } else if (!state.ready) {
setControlUnavailable(exportButton, state.errors setControlUnavailable(exportButton, state.errors
@@ -504,8 +502,8 @@ function updateBoardUi(state = layerState()) {
boardStatus.dataset.state = "pending"; boardStatus.dataset.state = "pending";
} else { } else {
boardStatus.textContent = animationEdit boardStatus.textContent = animationEdit
? `像素底层 + ${scene.elements.length} 个文字元素 · 动图帧编辑中` ? ` ${scene.layers.length} 个图层 · ${scene.elements.length} 个文字元素 · 动图帧编辑中`
: `像素底层 + ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`; : ` ${scene.layers.length} 个图层 · ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`;
boardStatus.dataset.state = "ready"; boardStatus.dataset.state = "ready";
} }
} }
@@ -520,7 +518,9 @@ function fallbackBounds(element) {
} }
function transformedBounds(element) { function transformedBounds(element) {
const source = textLayers.get(element.id)?.bounds || fallbackBounds(element); const local = textLayers.get(element.id)?.bounds || fallbackBounds(element);
const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===element.id));
const source={...local,x:local.x+(layer?.x||0),y:local.y+(layer?.y||0)};
if (!activeTransform || activeTransform.id !== element.id) return source; if (!activeTransform || activeTransform.id !== element.id) return source;
if (activeTransform.kind === "drag") { if (activeTransform.kind === "drag") {
return { return {
@@ -530,42 +530,45 @@ function transformedBounds(element) {
}; };
} }
return { return {
x: element.x + (source.x - element.x) * activeTransform.scale, x: element.x+(getSelectedLayer().x||0)+(source.x-element.x-(getSelectedLayer().x||0))*activeTransform.scale,
y: element.y + (source.y - element.y) * activeTransform.scale, y: element.y+(getSelectedLayer().y||0)+(source.y-element.y-(getSelectedLayer().y||0))*activeTransform.scale,
width: source.width * activeTransform.scale, width: source.width * activeTransform.scale,
height: source.height * activeTransform.scale, height: source.height * activeTransform.scale,
}; };
} }
function drawSelectionOverlay() { function drawSelectionOverlay() {
overlayContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
const origin=expandedBoard?-64:0;
document.getElementById("actual-screen-frame").hidden=!(expandedBoard||moveFrameVisible);
if (interactionMode !== "text" || previewKind !== "scene" || !selectedTextId) return; if (interactionMode !== "text" || previewKind !== "scene" || !selectedTextId) return;
const element = elementById(selectedTextId); const element = elementById(selectedTextId);
if (!element) return; if (!element) return;
const bounds = transformedBounds(element); const bounds = transformedBounds(element);
overlayContext.save(); overlayContext.save();
overlayContext.translate(-origin,-origin);
overlayContext.strokeStyle = "#72E0C5"; overlayContext.strokeStyle = "#72E0C5";
overlayContext.fillStyle = "#0D1015"; overlayContext.fillStyle = "#0D1015";
overlayContext.lineWidth = 0.55; overlayContext.lineWidth = 0.55;
overlayContext.setLineDash([1.2, 0.8]); overlayContext.setLineDash([1.2, 0.8]);
overlayContext.strokeRect(bounds.x - 0.4, bounds.y - 0.4, bounds.width + 0.8, bounds.height + 0.8); overlayContext.strokeRect(bounds.x - 0.4, bounds.y - 0.4, bounds.width + 0.8, bounds.height + 0.8);
overlayContext.setLineDash([]); overlayContext.setLineDash([]);
const handleX = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width)); const handleX = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.x + bounds.width));
const handleY = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height)); const handleY = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.y + bounds.height));
overlayContext.fillRect(handleX - 1.5, handleY - 1.5, 3, 3); overlayContext.fillRect(handleX - 1.5, handleY - 1.5, 3, 3);
overlayContext.strokeRect(handleX - 1.5, handleY - 1.5, 3, 3); overlayContext.strokeRect(handleX - 1.5, handleY - 1.5, 3, 3);
overlayContext.restore(); overlayContext.restore();
} }
function emitSceneChange(reason) { function emitSceneChange(reason) {
const detail = { scene, selectedTextId, reason }; const detail = { scene, selectedTextId, selectedLayerId, reason };
listeners.forEach((listener) => listener(detail)); listeners.forEach((listener) => listener(detail));
document.dispatchEvent(new CustomEvent("matrix:scene-change", { detail })); document.dispatchEvent(new CustomEvent("matrix:scene-change", { detail }));
} }
export function subscribeScene(listener) { export function subscribeScene(listener) {
listeners.add(listener); listeners.add(listener);
listener({ scene, selectedTextId, reason: "subscribe" }); listener({ scene, selectedTextId, selectedLayerId, reason: "subscribe" });
return () => listeners.delete(listener); return () => listeners.delete(listener);
} }
@@ -621,6 +624,7 @@ export function enterAnimationEdit(frame) {
export function markAnimationFrameSaved(detail) { export function markAnimationFrameSaved(detail) {
if (!animationEdit) return; if (!animationEdit) return;
clearEditHistory();
animationEdit.revision = detail.revision; animationEdit.revision = detail.revision;
if (Object.hasOwn(detail, "name")) animationEdit.frameName = detail.name; if (Object.hasOwn(detail, "name")) animationEdit.frameName = detail.name;
if (detail.position) animationEdit.position = detail.position; if (detail.position) animationEdit.position = detail.position;
@@ -684,7 +688,9 @@ export function replaceSceneFromStorage(
templateEditContext.baseline || serializeScene(restored), templateEditContext.baseline || serializeScene(restored),
) )
: null; : null;
selectedTextId = scene.elements.at(-1)?.id || null; selectedLayerId = scene.layers[0].id;
selectedTextId = getSelectedLayer()?.elements?.at(-1)?.id || null;
syncInteractionMode();
if (persist) persistScene(); if (persist) persistScene();
scene.elements.forEach((element) => scheduleTextLayer(element, true)); scene.elements.forEach((element) => scheduleTextLayer(element, true));
showCompositionPreview(); showCompositionPreview();
@@ -710,6 +716,7 @@ export function enterTemplateEdit(template) {
export function markTemplateSaved(template) { export function markTemplateSaved(template) {
if (animationEdit) return false; if (animationEdit) return false;
clearEditHistory();
templateEdit = createTemplateEditContext(template, serializeScene(scene)); templateEdit = createTemplateEditContext(template, serializeScene(scene));
sceneSource = { type: "template", id: template.id, revision: template.revision }; sceneSource = { type: "template", id: template.id, revision: template.revision };
updateBoardUi(); updateBoardUi();
@@ -717,22 +724,75 @@ export function markTemplateSaved(template) {
return true; return true;
} }
export function getPixelBuffer() { export function getSelectedLayer() {return scene.layers.find(l=>l.id===selectedLayerId)||scene.layers[0];}
return scene.pixels; export function getPixelBuffer() {return getSelectedLayer().pixels;}
export function getInteractionMode() {return interactionMode;}
export function boardExtent() {return {size:expandedBoard?192:64,origin:expandedBoard?-64:0};}
export function boardPoint(event,{round=true}={}) {
const rect=overlayCanvas.getBoundingClientRect(), {size,origin}=boardExtent();
const x=((event.clientX-rect.left)/rect.width)*size+origin, y=((event.clientY-rect.top)/rect.height)*size+origin;
return {x:round?Math.max(origin,Math.min(origin+size-1,Math.floor(x))):x,y:round?Math.max(origin,Math.min(origin+size-1,Math.floor(y))):y};
} }
export function setExpandedBoard(value) {
export function getInteractionMode() { expandedBoard=Boolean(value); const size=expandedBoard?192:64;
return interactionMode; visibleCanvas.width=visibleCanvas.height=overlayCanvas.width=overlayCanvas.height=size;
visibleContext.imageSmoothingEnabled=false;
document.getElementById("board-stage").classList.toggle("board-expanded",expandedBoard);
renderComposition();
} }
export function setMoveFrameVisible(value) {moveFrameVisible=value;drawSelectionOverlay();}
export function boardPoint(event, { round = true } = {}) { function syncInteractionMode() {
const rect = overlayCanvas.getBoundingClientRect(); interactionMode=activeWorkspace==="canvas"?(getSelectedLayer().type==="text"?"text":"pixels"):"readonly";
const x = ((event.clientX - rect.left) / rect.width) * SCENE_WIDTH; overlayCanvas.dataset.interaction=interactionMode;
const y = ((event.clientY - rect.top) / rect.height) * SCENE_HEIGHT; }
return { export function selectLayer(id) {
x: round ? Math.max(0, Math.min(63, Math.floor(x))) : Math.max(0, Math.min(64, x)), if(!scene.layers.some(l=>l.id===id)) return;
y: round ? Math.max(0, Math.min(63, Math.floor(y))) : Math.max(0, Math.min(64, y)), selectedLayerId=id;selectedTextId=getSelectedLayer().elements?.at(-1)?.id||null;
}; syncInteractionMode();renderComposition();emitSceneChange("selection");
}
export function rememberEdit(before=serializeScene(scene)) {
if(editHistory.at(-1)!==before) editHistory.push(before);
if(editHistory.length>50)editHistory.shift();
document.dispatchEvent(new CustomEvent("matrix:edit-history"));
}
export function editSnapshot() {return serializeScene(scene);}
export function editHistorySize() {return editHistory.length;}
export function clearEditHistory() {editHistory.length=0;document.dispatchEvent(new CustomEvent("matrix:edit-history"));}
export function undoSceneEdit() {
const value=editHistory.pop();if(!value)return false;
const selected=selectedLayerId;scene=deserializeScene(value);
if(scene.layers.some(l=>l.id===selected))selectedLayerId=selected;else selectedLayerId=scene.layers[0].id;
selectedTextId=getSelectedLayer().elements?.at(-1)?.id||null;
syncInteractionMode();scene.elements.forEach(e=>scheduleTextLayer(e,true));commitPixelChanges("undo");
document.dispatchEvent(new CustomEvent("matrix:edit-history"));return true;
}
export function changeLayer(action,value) {
const layer=getSelectedLayer(),before=editSnapshot();
if(action==="create") {const next=createLayer(value,scene.layers);scene.layers.push(next);selectLayer(next.id);}
if(action==="duplicate") {const next=cloneLayer(layer,scene.layers);scene.layers.push(next);selectLayer(next.id);}
if(action==="delete") {
if(layer.type==="brush"&&scene.layers.filter(l=>l.type==="brush").length===1) {announce("必须保留一个画笔图层",true);return;}
scene=attachScene(scene.layers.filter(l=>l.id!==layer.id));selectLayer(scene.layers.at(-1).id);
}
if(action==="rename") {const name=value.trim();if(!name||name.length>80)throw Error("图层名称需为1..80个字符");layer.name=name;}
if(action==="up"||action==="down") {const index=scene.layers.indexOf(layer),target=index+(action==="up"?1:-1);if(target<0||target>=scene.layers.length)return;[scene.layers[index],scene.layers[target]]=[scene.layers[target],scene.layers[index]];}
if(before!==editSnapshot())rememberEdit(before);commitPixelChanges("layers");
}
export function previewLayerMove(x,y) {const l=getSelectedLayer();layerMovePreview={layerId:l.id,x:Math.max(-64,Math.min(64,Math.round(x))),y:Math.max(-64,Math.min(64,Math.round(y)))};renderComposition();}
export function cancelLayerPreview() {layerMovePreview=null;renderComposition();}
export function moveSelectedLayer(x,y) {
const layer=getSelectedLayer();if(!layerState().ready)throw Error("请等待文字渲染完成后移动图层");
const nextX=Math.max(-64,Math.min(64,Math.round(x))),nextY=Math.max(-64,Math.min(64,Math.round(y)));
if(!Number.isFinite(nextX)||!Number.isFinite(nextY))throw Error("偏移必须为整数");
if(nextX!==Math.round(x)||nextY!==Math.round(y))announce("图层偏移限制为−64到64;黄色框为实际屏幕");
const checks=layer.type==="brush"?[translatedOverflow(layer.pixels,nextX,nextY)]:layer.elements.map(e=>translatedOverflow(textLayers.get(e.id).rgba,nextX,nextY));
const overflow=checks.some(c=>c.outside);previewLayerMove(nextX,nextY);
if(overflow&&!window.confirm("有背景以外的内容移出192×192编辑区域。确定后将清除越界部分;取消恢复本次移动。")) {cancelLayerPreview();emitSceneChange("move-cancel");return false;}
const before=editSnapshot();layer.x=nextX;layer.y=nextY;
if(overflow&&layer.type==="brush") {layer.pixels=clipBrushPixels(layer.pixels,nextX,nextY);layer.revision++;}
if(overflow&&layer.type==="text") layer.elements=layer.elements.flatMap((e,i)=>!checks[i].inside?[]:[checks[i].outside?{...e,clip:textClipAt(e,nextX,nextY),revision:e.revision+1}:e]);
layerMovePreview=null;if(before!==editSnapshot())rememberEdit(before);
scene.elements.forEach(e=>scheduleTextLayer(e,true));commitPixelChanges("layer-move");return true;
} }
export function getBoardOverlay() { export function getBoardOverlay() {
@@ -745,17 +805,21 @@ export function renderPixelDraft() {
export function commitPixelChanges(reason = "pixels") { export function commitPixelChanges(reason = "pixels") {
sceneSource = null; sceneSource = null;
if(getSelectedLayer().type==="brush")getSelectedLayer().revision++;
persistScene(); persistScene();
renderComposition(); renderComposition();
emitSceneChange(reason); emitSceneChange(reason);
} }
export function fillPixelLayer(rgb) { export function fillPixelLayer(rgb) {
scene = createScene({ pixels: createRgbPixels(rgb), elements: scene.elements }); const l=getSelectedLayer();l.backgroundColor="#"+rgb.map(n=>n.toString(16).padStart(2,"0")).join("").toUpperCase();l.background=l.backgroundColor;commitPixelChanges("pixel-fill");
commitPixelChanges("pixel-fill");
} }
export function clearPixelLayer() {const l=getSelectedLayer();l.pixels.fill(0);commitPixelChanges("pixel-clear");}
export function setLayerBackground(enabled,color) {const l=getSelectedLayer();if(l.type!=="brush")return;const before=editSnapshot();l.backgroundColor=color;l.background=enabled?color:null;if(before!==editSnapshot())rememberEdit(before);commitPixelChanges("background");}
export function selectTextElement(id, reason = "selection") { export function selectTextElement(id, reason = "selection") {
const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===id));
if(layer)selectedLayerId=layer.id;
selectedTextId = elementById(id)?.id || null; selectedTextId = elementById(id)?.id || null;
drawSelectionOverlay(); drawSelectionOverlay();
emitSceneChange(reason); emitSceneChange(reason);
@@ -768,8 +832,8 @@ export function getSelectedTextElement() {
export function addTextElement(overrides = {}) { export function addTextElement(overrides = {}) {
sceneSource = null; sceneSource = null;
scene = addTextToScene(scene, overrides); scene = addTextToScene(scene, overrides, selectedLayerId);
const created = scene.elements.at(-1); const created = getSelectedLayer().elements.at(-1);
selectedTextId = created.id; selectedTextId = created.id;
persistScene(); persistScene();
scheduleTextLayer(created, true); scheduleTextLayer(created, true);
@@ -778,17 +842,25 @@ export function addTextElement(overrides = {}) {
return created; return created;
} }
export function updateTextElement(id, patch, reason = "update") { export function updateTextElement(id, patch, reason="update") {
const previous = scene; const current=elementById(id);if(!current)return null;
scene = updateTextInScene(scene, id, patch); const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===id));
if (scene === previous) return null; const updated=normalizeTextElement({...current,...patch}),before=editSnapshot();
sceneSource = null; const dx=updated.x-current.x,dy=updated.y-current.y;
const updated = elementById(id); if(dx||dy) {
persistScene(); const rgba=textLayers.get(id)?.rgba;
if (updated) scheduleTextLayer(updated); if(!rgba||!layerState().ready){announce("请等待文字渲染完成后移动",true);emitSceneChange("move-cancel");return current;}
renderComposition(); const check=translatedOverflow(rgba,dx+layer.x,dy+layer.y);
emitSceneChange(reason); if(check.outside) {
return updated; if(!window.confirm("文字移出192×192编辑区域。确定后清除越界部分;取消恢复本次移动。")){emitSceneChange("move-cancel");return current;}
if(!check.inside) {scene=deleteTextFromScene(scene,id);selectedTextId=layer.elements.find(e=>e.id!==id)?.id||null;rememberEdit(before);commitPixelChanges("gesture");return null;}
patch={...patch,clip:textClipAt(updated,layer.x,layer.y)};
}
}
scene=updateTextInScene(scene,id,patch);sceneSource=null;
if(dx||dy||reason==="gesture")rememberEdit(before);
const target=elementById(id);persistScene();if(target)scheduleTextLayer(target);
renderComposition();emitSceneChange(reason);return target;
} }
export function duplicateTextElement(id = selectedTextId) { export function duplicateTextElement(id = selectedTextId) {
@@ -796,7 +868,7 @@ export function duplicateTextElement(id = selectedTextId) {
scene = duplicateTextInScene(scene, id); scene = duplicateTextInScene(scene, id);
if (scene.elements.length === previousLength) return null; if (scene.elements.length === previousLength) return null;
sceneSource = null; sceneSource = null;
const duplicate = scene.elements.at(-1); const duplicate = getSelectedLayer().elements.at(-1);
selectedTextId = duplicate.id; selectedTextId = duplicate.id;
persistScene(); persistScene();
scheduleTextLayer(duplicate, true); scheduleTextLayer(duplicate, true);
@@ -810,7 +882,7 @@ export function deleteTextElement(id = selectedTextId) {
scene = deleteTextFromScene(scene, id); scene = deleteTextFromScene(scene, id);
if (scene.elements.length === previousLength) return false; if (scene.elements.length === previousLength) return false;
sceneSource = null; sceneSource = null;
selectedTextId = scene.elements.at(-1)?.id || null; selectedTextId = getSelectedLayer().elements?.at(-1)?.id || null;
persistScene(); persistScene();
renderComposition(); renderComposition();
emitSceneChange("delete"); emitSceneChange("delete");
@@ -818,8 +890,9 @@ export function deleteTextElement(id = selectedTextId) {
} }
function hitTestText(point) { function hitTestText(point) {
for (let index = scene.elements.length - 1; index >= 0; index -= 1) { const elements=getSelectedLayer().elements||[];
const element = scene.elements[index]; for (let index = elements.length - 1; index >= 0; index -= 1) {
const element = elements[index];
const bounds = transformedBounds(element); const bounds = transformedBounds(element);
if ( if (
point.x >= bounds.x - 1 point.x >= bounds.x - 1
@@ -833,17 +906,17 @@ function hitTestText(point) {
function resizeHandleHit(point, element) { function resizeHandleHit(point, element) {
const bounds = transformedBounds(element); const bounds = transformedBounds(element);
const x = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width)); const x = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.x + bounds.width));
const y = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height)); const y = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.y + bounds.height));
return Math.abs(point.x - x) <= 3.5 && Math.abs(point.y - y) <= 3.5; return Math.abs(point.x - x) <= 3.5 && Math.abs(point.y - y) <= 3.5;
} }
function startTextGesture(event) { function startTextGesture(event) {
if (interactionMode !== "text" || previewKind !== "scene") return; if (interactionMode !== "text" || previewKind !== "scene" || (document.body.dataset.canvasEditorMode && document.body.dataset.canvasEditorMode!=="draw")) return;
const point = boardPoint(event, { round: false }); const point = boardPoint(event, { round: false });
let element = getSelectedTextElement(); let element = getSelectedTextElement();
if (element && resizeHandleHit(point, element)) { if (element && resizeHandleHit(point, element)) {
const distance = Math.hypot(point.x - element.x, point.y - element.y); const distance = Math.hypot(point.x-element.x-getSelectedLayer().x,point.y-element.y-getSelectedLayer().y);
activeGesture = { activeGesture = {
kind: "resize", kind: "resize",
id: element.id, id: element.id,
@@ -885,7 +958,7 @@ function moveTextGesture(event) {
dy: point.y - activeGesture.startPoint.y, dy: point.y - activeGesture.startPoint.y,
}; };
} else { } else {
const distance = Math.hypot(point.x - element.x, point.y - element.y); const distance = Math.hypot(point.x-element.x-getSelectedLayer().x,point.y-element.y-getSelectedLayer().y);
const minimum = 1 / activeGesture.startSize; const minimum = 1 / activeGesture.startSize;
const maximum = 64 / activeGesture.startSize; const maximum = 64 / activeGesture.startSize;
activeTransform = { activeTransform = {
@@ -928,7 +1001,7 @@ overlayCanvas.addEventListener("pointercancel", (event) => finishTextGesture(eve
export function setBoardWorkspace(workspaceId) { export function setBoardWorkspace(workspaceId) {
const wasMonitorWorkspace = MONITOR_WORKSPACES.has(activeWorkspace); const wasMonitorWorkspace = MONITOR_WORKSPACES.has(activeWorkspace);
activeWorkspace = workspaceId; activeWorkspace = workspaceId;
interactionMode = workspaceId === "canvas" ? "pixels" : workspaceId === "text" ? "text" : "readonly"; syncInteractionMode();
overlayCanvas.dataset.interaction = interactionMode; overlayCanvas.dataset.interaction = interactionMode;
const isMonitor = MONITOR_WORKSPACES.has(workspaceId); const isMonitor = MONITOR_WORKSPACES.has(workspaceId);
const isAnimationWorkspace = workspaceId === "animations"; const isAnimationWorkspace = workspaceId === "animations";
@@ -1142,7 +1215,7 @@ export async function applyComposition() {
export function exportCompositionPng() { export function exportCompositionPng() {
const state = layerState(); const state = layerState();
if (!state.ready || activeTransform) throw new Error("请等待文字图层完成后再导出"); if (!state.ready || activeTransform || layerMovePreview) throw new Error("请等待文字图层完成后再导出");
compositionCanvas.toBlob((blob) => { compositionCanvas.toBlob((blob) => {
if (!blob) { if (!blob) {
announce("合成 PNG 生成失败", true); announce("合成 PNG 生成失败", true);
@@ -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;
}
+117 -569
View File
@@ -1,577 +1,125 @@
export const SCENE_VERSION = 1; import * as legacy from "./scene-model-v1.js";
export const SCENE_WIDTH = 64; import { encodePixels, decodePixels } from "./pixel-png.js";
export const SCENE_HEIGHT = 64; export const SCENE_VERSION=2, EDIT_SIZE=192, EDIT_MIN=-64, EDIT_MAX=128;
export const RGB_CHANNELS = 3; export const {SCENE_WIDTH,SCENE_HEIGHT,RGB_BYTE_LENGTH,RGBA_BYTE_LENGTH,RGB_CHANNELS,RGBA_CHANNELS,SCENE_STORAGE_KEY,LEGACY_STORAGE_KEYS,DEFAULT_TEXT_ELEMENT,createRgbPixels,createTextElement,nextTextElementId,compositeRgbaLayer,compositeRgbaLayers,migrateLegacyScene}=legacy;
export const RGBA_CHANNELS = 4; const fields=(v,keys)=>v&&Object.keys(v).sort().join()===[...keys].sort().join();
export const RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGB_CHANNELS; const uid=()=>globalThis.crypto?.randomUUID?.()||`layer-${Date.now()}-${Math.random().toString(36).slice(2)}`;
export const RGBA_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGBA_CHANNELS; export function normalizeTextElement(v={},options={}) {const e=legacy.normalizeTextElement(v,options);
return e?{...e,clip:v.clip?[...v.clip]:null}:null;}
export const SCENE_STORAGE_KEY = "matrixController:scene"; export function attachScene(layers) {
export const LEGACY_STORAGE_KEYS = Object.freeze({ const scene={version:2,width:64,height:64,layers};
sceneV1: "matrixController:scene:v1", Object.defineProperty(scene,"elements",{get:()=>scene.layers.flatMap(l=>l.elements||[])});
canvasV3: "matrixController:canvas:v3", Object.defineProperty(scene,"pixels",{get:()=>{const rgba=scene.layers.find(l=>l.type==="brush").pixels,rgb=new Uint8ClampedArray(RGB_BYTE_LENGTH);
canvasV2: "matrixController:canvas:v2", for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=((y+64)*192+x+64)*4;rgb.set(rgba.subarray(i,i+3),(y*64+x)*3);}
canvasRgb: "matrixCanvasRgb", return rgb;}});
textV1: "matrixController:text:v1", return scene;
});
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);
} }
export function nextLayerName(layers){let n=1;
function finiteInteger(value, fallback, minimum, maximum) { const names=new Set(layers.map(l=>l.name));while(names.has(`图层${n}`))n++;
const number = Number(value); return `图层${n}`;}
if (!Number.isFinite(number)) return fallback; export function createLayer(type,layers=[]) {return {id:uid(),name:nextLayerName(layers),type,x:0,y:0,...(type==="brush"?{background:null,backgroundColor:"#000000",pixels:new Uint8ClampedArray(192*192*4),revision:0}:{elements:[]})};}
return Math.max(minimum, Math.min(maximum, Math.round(number))); export function createScene({pixels=null,elements=[],layers=null}={}) {
if(layers)return attachScene(layers);
const brush=createLayer("brush");brush.id="layer-1";
if(pixels)for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=(y*64+x)*3,j=((y+64)*192+x+64)*4;brush.pixels.set([pixels[i],pixels[i+1],pixels[i+2],255],j);}
const result=[brush];
if(elements.length)result.push({id:"layer-2",name:"图层2",type:"text",x:0,y:0,elements:elements.map(e=>normalizeTextElement(e,{strict:true})).filter(Boolean)});
return attachScene(result);
} }
export function blankScene(){return attachScene([createLayer("brush")]);}
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) { export function layerToStorage(layer){const common={id:layer.id,name:layer.name,type:layer.type,x:layer.x,y:layer.y};
return typeof value === "string" && HEX_COLOR.test(value.trim()) if(layer.type==="brush"){if(layer._encodedRevision!==layer.revision||!layer._png){layer._png=encodePixels(layer.pixels);layer._encodedRevision=layer.revision;}
? value.trim().toUpperCase() return {...common,background:layer.background,backgroundColor:layer.backgroundColor||layer.background||"#000000",pixelPng:layer._png};}
: fallback; return {...common,elements:layer.elements.map(({revision,...e})=>({...e,clip:e.clip||null}))};
} }
export function sceneToStorageValue(scene){return {version:2,width:64,height:64,layers:scene.layers.map(layerToStorage)};}
function normalizeId(value) { export function serializeScene(scene){return JSON.stringify(sceneToStorageValue(scene));}
if (typeof value !== "string") return null; export function deserializeScene(value){try{
const id = value.trim(); const raw=typeof value==="string"?JSON.parse(value):value;
return id && id.length <= 128 ? id : null; if(raw?.version===1){
} if(!fields(raw,["version","width","height","pixelRgb","elements"])||!Array.isArray(raw.elements))return null;
const old=legacy.deserializeScene(raw);
function byteArray(value, expectedLength) { if(!old||old.elements.length!==raw.elements.length||raw.elements.some(e=>!fields(e,["id","type","text","font","size","x","y","align","color"])))return null;
if (value === null || value === undefined || typeof value.length !== "number") return null; const upgraded=sceneToStorageValue(createScene(old));
if (value.length !== expectedLength) return null; if(raw.elements.length)upgraded.layers[1].elements=raw.elements.map(element=>({...element,clip:null}));
const result = new Uint8ClampedArray(expectedLength); return deserializeScene(upgraded);
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; if(!fields(raw,["version","width","height","layers"])||raw.version!==2||raw.width!==64||raw.height!==64||!Array.isArray(raw.layers))return null;
} const ids=new Set(),elements=new Set();
const layers=raw.layers.map(l=>{
function encodeBase64(bytes) { if(!fields(l,["id","name","type","x","y",...(l.type==="brush"?["background","pixelPng",...(Object.hasOwn(l,"backgroundColor")?["backgroundColor"]:[])]:["elements"])])||!["brush","text"].includes(l.type)||typeof l.id!=="string"||!l.id.trim()||l.id.length>128||ids.has(l.id)||typeof l.name!=="string"||!l.name.trim()||l.name.length>80||![l.x,l.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=64))throw Error("invalid layer");ids.add(l.id);
let encoded = ""; if(l.type==="brush"){if(l.background!==null&&!/^#[0-9a-f]{6}$/i.test(l.background))throw Error("invalid background");
for (let index = 0; index < bytes.length; index += 3) { if(Object.hasOwn(l,"backgroundColor")&&!/^#[0-9a-f]{6}$/i.test(l.backgroundColor))throw Error("invalid background color");
const first = bytes[index]; const {pixelPng,...rest}=l;
const hasSecond = index + 1 < bytes.length; return {...rest,backgroundColor:l.backgroundColor||l.background||"#000000",pixels:decodePixels(pixelPng),revision:0,_png:pixelPng,_encodedRevision:0};}
const hasThird = index + 2 < bytes.length; if(!Array.isArray(l.elements))throw Error("invalid text layer");
const second = hasSecond ? bytes[index + 1] : 0; return {...l,elements:l.elements.map(e=>{
const third = hasThird ? bytes[index + 2] : 0; if(!fields(e,["id","type","text","font","size","x","y","align","color","clip"])||elements.has(e.id)||typeof e.id!=="string"||!e.id.trim()||e.id.length>128||e.type!=="text"||typeof e.text!=="string"||e.text.length>512||typeof e.font!=="string"||!e.font.trim()||e.font.length>512||!Number.isInteger(e.size)||e.size<1||e.size>64||![e.x,e.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=128)||!["left","center","right"].includes(e.align)||!/^#[0-9a-f]{6}$/i.test(e.color))throw Error("invalid text");
encoded += BASE64_ALPHABET[first >> 2]; if(e.clip!==null&&(!Array.isArray(e.clip)||e.clip.length!==4||!e.clip.every(n=>Number.isFinite(n)&&Math.abs(n)<=4096)||e.clip[2]<e.clip[0]||e.clip[3]<e.clip[1]))throw Error("invalid clip");elements.add(e.id);
encoded += BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)]; return normalizeTextElement(e,{strict:true});
encoded += hasSecond ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)] : "="; })};});
encoded += hasThird ? BASE64_ALPHABET[third & 63] : "="; return layers.some(l=>l.type==="brush")?attachScene(layers):null;
} }
return encoded; catch{return null;}}
} export function inspectSceneStorageValue(value){if(value==null)return {status:"missing",scene:null};try{const raw=typeof value==="string"?JSON.parse(value):value;
if(Number.isInteger(raw?.version)&&raw.version>2)return {status:"future",scene:null};
function decodeBase64(value) { const scene=deserializeScene(raw);
if (typeof value !== "string" || value.length % 4 !== 0) return null; return {status:scene?"valid":"damaged",scene};}
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null; catch{return {status:"damaged",scene:null};}}
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; export function restoreSceneDraft(storage){let raw;try{raw=storage?.getItem(SCENE_STORAGE_KEY);}
const result = new Uint8ClampedArray((value.length / 4) * 3 - padding); catch{return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"浏览器草稿存储不可用;原草稿已保留。"};}
let output = 0; if(raw){const result=inspectSceneStorageValue(raw);
for (let index = 0; index < value.length; index += 4) { if(result.status!=="valid")return {...result,status:result.status==="future"?"future-version":"damaged-current",scene:blankScene(),canPersist:false,notice:"草稿损坏或来自未来版本;原值已保留。"};
const first = BASE64_ALPHABET.indexOf(value[index]); if(JSON.parse(raw).version===1){try{storage.setItem(SCENE_STORAGE_KEY,serializeScene(result.scene));}
const second = BASE64_ALPHABET.indexOf(value[index + 1]); catch{return {...result,canPersist:false,status:"write-failed",notice:"无法保存升级草稿;原值已保留。"};}}
const third = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2]); return {...result,canPersist:true,status:"current"};}
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 { try {
return JSON.parse(value); const oldValue=storage?.getItem(LEGACY_STORAGE_KEYS.sceneV1);
} catch (_error) { if(oldValue!=null&&!deserializeScene(oldValue))return {scene:blankScene(),canPersist:false,status:"damaged-legacy",notice:"旧场景草稿无法安全迁移;全部原值已保留。"};
return null; } catch {return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"旧草稿读取失败;全部原值已保留。"};}
const proxy=storage?{getItem:k=>storage.getItem(k),removeItem:k=>storage.removeItem(k),setItem:(k,v)=>{
const oldScene=legacy.deserializeScene(v);storage.setItem(k,oldScene?serializeScene(createScene(oldScene)):v);
}}:null;
const old=legacy.restoreSceneDraft(proxy);
const scene=old.status==="created"?blankScene():createScene(old.scene);
if(old.status==="created"&&old.canPersist){
try {storage?.setItem(SCENE_STORAGE_KEY,serializeScene(scene));}
catch {return {...old,scene,canPersist:false,status:"write-failed",notice:"无法保存新草稿。"};}
} }
return {...old,scene};
} }
export function cloneScene(scene){return deserializeScene(serializeScene(scene));}
function sourceValue(source, alias, storageKey) { export function addTextElement(scene,overrides={},layerId=null){const target=scene.layers.find(l=>l.type==="text"&&(layerId?l.id===layerId:true))||createLayer("text",scene.layers),element=normalizeTextElement({...legacy.createTextElement(scene.elements,overrides),clip:overrides.clip||null});
if (!isRecord(source)) return null; return attachScene(scene.layers.some(l=>l.id===target.id)?scene.layers.map(l=>l.id===target.id?{...l,elements:[...l.elements,element]}:l):[...scene.layers,{...target,elements:[element]}]);}
if (Object.prototype.hasOwnProperty.call(source, alias)) return source[alias]; export function updateTextElement(scene,id,patch={}){if(!scene.elements.some(e=>e.id===id))return scene;
if (Object.prototype.hasOwnProperty.call(source, storageKey)) return source[storageKey]; return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.map(e=>e.id===id?normalizeTextElement({...e,...patch,id:e.id,revision:e.revision+1}):e)}));}
return null; export function deleteTextElement(scene,id){if(!scene.elements.some(e=>e.id===id))return scene;
} return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.filter(e=>e.id!==id)}));}
export function duplicateTextElement(scene,id){const l=scene.layers.find(l=>l.elements?.some(e=>e.id===id)),e=l?.elements.find(e=>e.id===id);
function copyTextElement(element) { return e?addTextElement(scene,{...e,x:e.x+2,y:e.y+2},l.id):scene;}
return { export function cloneLayer(layer,layers){const copy=layer.type==="brush"?{...layer,pixels:layer.pixels.slice()}:{...layer,elements:layer.elements.map(e=>({...e,id:uid(),clip:e.clip?[...e.clip]:null}))};
id: element.id, return {...copy,id:uid(),name:nextLayerName(layers)};}
type: "text", export function clipTextPixels(rgba,element){if(!element.clip)return rgba;
text: element.text, const out=rgba.slice(),[a,b,c,d]=element.clip;
font: element.font, for(let y=0;y<192;y++)for(let x=0;x<192;x++){const u=(x-64-element.x)/element.size,v=(y-64-element.y)/element.size;
size: element.size, if(u<a||u>=c||v<b||v>=d)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);}
x: element.x, return out;}
y: element.y, export function translatedOverflow(rgba,dx,dy){let outside=false,inside=false;
align: element.align, for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(rgba[(y*192+x)*4+3]){if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)outside=true;else inside=true;}
color: element.color, return {outside,inside};}
revision: element.revision, export function clipBrushPixels(rgba,dx,dy){const out=rgba.slice();
}; for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);
} return out;}
export function textClipAt(e,dx,dy){const n=[(-64-dx-e.x)/e.size,(-64-dy-e.y)/e.size,(128-dx-e.x)/e.size,(128-dy-e.y)/e.size];
function storageTextElement(element) { return e.clip?[Math.max(n[0],e.clip[0]),Math.max(n[1],e.clip[1]),Math.min(n[2],e.clip[2]),Math.min(n[3],e.clip[3])]:n;}
const { revision: _revision, ...stored } = copyTextElement(element); export function composeScene(scene,textPixels=new Map(),size=64,preview=null){const origin=size===192?-64:0,out=new Uint8ClampedArray(size*size*3);
return stored; function paint(rgba,dx,dy){for(let y=0;y<size;y++)for(let x=0;x<size;x++){const sx=x+origin+64-dx,sy=y+origin+64-dy;
} if(sx<0||sx>=192||sy<0||sy>=192)continue;
const i=(sy*192+sx)*4,j=(y*size+x)*3,a=rgba[i+3]/255;
function normalizedElementList(elements) { for(let c=0;c<3;c++)out[j+c]=Math.floor(rgba[i+c]*a+out[j+c]*(1-a)+0.5);}}
if (!Array.isArray(elements)) return []; for(const l of scene.layers){const dx=preview?.layerId===l.id?preview.x:l.x,dy=preview?.layerId===l.id?preview.y:l.y;
const normalized = []; if(l.type==="brush"){if(l.background){const color=l.background.slice(1).match(/../g).map(v=>parseInt(v,16));
const usedIds = new Set(); for(let y=0;y<size;y++)for(let x=0;x<size;x++)if(x+origin>=-64+dx&&x+origin<128+dx&&y+origin>=-64+dy&&y+origin<128+dy)out.set(color,(y*size+x)*3);}paint(l.pixels,dx,dy);}
for (const value of elements) { else for(const e of l.elements){const p=textPixels.get(e.id);
const element = normalizeTextElement(value, { strict: true }); if(p)paint(p,dx,dy);}}
if (!element || usedIds.has(element.id)) continue; return out;}
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;
}
@@ -905,6 +905,28 @@ h3 {
display: none; display: none;
} }
.board-expanded .pixel-grid { background-size: calc(100% / 192) calc(100% / 192); }
.layer-panel { margin-block: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.layer-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: .75rem; }
.layer-actions button { flex: 1 1 7rem; }
.layer-list { display: grid; gap: .4rem; max-height: 18rem; overflow: auto; }
.layer-item { text-align: left; overflow-wrap: anywhere; }
.layer-item[aria-pressed="true"] { border-color: #72E0C5; background: #163832; }
.layer-move-controls { display: grid; gap: .35rem; }
.layer-move-controls[hidden], #brush-layer-tools[hidden], #text-layer-tools[hidden] { display: none; }
.layer-move-controls label { display: flex; align-items: center; gap: .5rem; }
.layer-move-controls input { min-width: 0; width: 100%; }
.offset-steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: .2rem; }
.offset-steps button { padding: .2rem; text-align: center; }
.layer-copy-dialog { width: min(36rem, calc(100vw - 2rem)); max-height: 85dvh; overflow: auto; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 1rem; padding: 1rem; box-shadow: var(--shadow); }
.layer-copy-dialog::backdrop { background: rgb(0 0 0 / 65%); }
.layer-copy-dialog select { width: 100%; }
#layer-copy-frame-list { display: grid; gap: .5rem; max-height: 45dvh; overflow: auto; }
#layer-copy-frame-list label { display: flex; align-items: center; gap: .6rem; min-width: 0; min-height: 44px; cursor: pointer; }
#layer-copy-frame-list label span { min-width: 0; overflow-wrap: anywhere; }
#layer-copy-frame-list input[type="checkbox"], .layer-background-toggle input[type="checkbox"] { flex: 0 0 20px; width: 20px; height: 20px; min-height: 20px; padding: 0; margin: 0; accent-color: var(--accent); cursor: pointer; }
body.canvas-edit-active[data-canvas-editor-mode="layer"] .board-stage { cursor: move; }
.canvas-coordinate-guide { .canvas-coordinate-guide {
display: none; display: none;
pointer-events: none; pointer-events: none;
@@ -1028,6 +1050,8 @@ body.canvas-edit-active[data-canvas-editor-mode="move"] .board-stage:active {
position: absolute; position: absolute;
top: -28px; top: -28px;
left: 38px; left: 38px;
max-height: var(--assistive-menu-height, min(85dvh, calc(100dvh - 32px)));
overflow-y: auto;
display: grid; display: grid;
width: min(14rem, calc(100vw - 88px)); width: min(14rem, calc(100vw - 88px));
gap: 8px; gap: 8px;
@@ -1919,8 +1943,15 @@ textarea {
.canvas-toolbar .color-field { .canvas-toolbar .color-field {
margin-top: 0; margin-top: 0;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
} }
.canvas-color-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; min-height: 44px; }
.layer-background-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; }
.canvas-toolbar .color-trigger { width: 100%; min-width: 0; grid-template-columns: 34px minmax(0, 1fr); }
.segmented { .segmented {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3121,3 +3152,8 @@ noscript {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
#actual-screen-frame { position:absolute; inset:0; z-index:4; border:2px solid #FFCC66; pointer-events:none; }
#actual-screen-frame[hidden] { display:none; }
#actual-screen-frame span { position:absolute; top:2px; left:3px; font-size:11px; color:#FFCC66; background:#000b; padding:2px; white-space:nowrap; }
.board-expanded #actual-screen-frame { inset:33.333333%; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Arjun Barrett
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,7 @@
fflate 0.8.2, MIT.
Source: https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz
Tarball SHA-256: 61fd5061e2fc8e5e3e3129f7f2fec7bd78a313e1bf4becbf1cc1cc9998d141dc
Bundled offline PNG compression; no CDN or npm runtime dependency.
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import { getSelectedLayer, boardExtent, editSnapshot, rememberEdit, editHistorySize, clearEditHistory, undoSceneEdit, setExpandedBoard, setMoveFrameVisible, moveSelectedLayer, previewLayerMove, cancelLayerPreview, clearPixelLayer, subscribeScene } from "../scene-board.js";
import { import {
announce, announce,
createLocalState, createLocalState,
@@ -81,7 +82,13 @@ let suppressAssistiveClick = false;
let coordinateGuideFrame = null; let coordinateGuideFrame = null;
let assistivePosition = { x: 36, y: window.innerHeight / 2 }; let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map(); const viewPointers = new Map();
const undoHistory = new PixelUndoHistory(); let expandedEnabled=Boolean(localState.load({}).expandedEnabled);
let layerDrag=null;
const expandedToggle=document.getElementById("toggle-expanded-board");
const moveLayerButton=document.getElementById("move-current-layer");
const exitLayerMoveButton=document.getElementById("exit-layer-move");
const moveControls=document.getElementById("layer-move-controls");
const offsetX=document.getElementById("layer-offset-x"),offsetY=document.getElementById("layer-offset-y");
paintColor.value = savedTools.paintColor || paintColor.value; paintColor.value = savedTools.paintColor || paintColor.value;
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value; backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
@@ -94,21 +101,19 @@ function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize; return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
} }
function setPixel(x, y, rgb) { function setPixel(x,y,rgb) {
if (x < 0 || x > 63 || y < 0 || y > 63) return; const layer=getSelectedLayer(),{origin,size}=boardExtent(),worldX=x-64+layer.x,worldY=y-64+layer.y;
const offset = (y * 64 + x) * 3; if(x<0||x>=192||y<0||y>=192||worldX<origin||worldX>=origin+size||worldY<origin||worldY>=origin+size)return;
const pixels = getPixelBuffer(); const i=(y*192+x)*4;
pixels[offset] = rgb.r; layer.pixels.set(tool==="eraser"?[0,0,0,0]:[rgb.r,rgb.g,rgb.b,255],i);
pixels[offset + 1] = rgb.g;
pixels[offset + 2] = rgb.b;
} }
function activeRgb() { function activeRgb() {
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value); return hexToRgb(paintColor.value);
} }
function stampBrush(centerX, centerY, rgb) { function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode()) brushStampPoints(centerX+64-getSelectedLayer().x, centerY+64-getSelectedLayer().y, currentBrushSize(), brushMode(),192,192)
.forEach((point) => setPixel(point.x, point.y, rgb)); .forEach((point) => setPixel(point.x, point.y, rgb));
} }
@@ -127,24 +132,14 @@ function paintEvent(event) {
renderPixelDraft(); renderPixelDraft();
} }
function pixelSnapshot() { function pixelSnapshot() {return {scene:editSnapshot(),pixels:getSelectedLayer().pixels?.slice()};}
return Uint8Array.from(getPixelBuffer()); function updateUndoControl() {if(editHistorySize()>0)setControlReady(undoButton);else setControlUnavailable(undoButton,"还没有可以撤销的编辑操作。");}
} function clearUndoHistory() {clearEditHistory();updateUndoControl();}
function updateUndoControl() {
if (undoHistory.size > 0) setControlReady(undoButton);
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
}
function clearUndoHistory() {
undoHistory.clear();
updateUndoControl();
}
function recordPixelOperation(before) { function recordPixelOperation(before) {
const recorded = undoHistory.record(before, getPixelBuffer()); if(!before)return false;
updateUndoControl(); const l=getSelectedLayer(),changed=before.pixels?.some((v,i)=>v!==l.pixels[i]);
return recorded; if(changed)l.revision++;
if(before.scene!==editSnapshot()){rememberEdit(before.scene);updateUndoControl();return true;}return false;
} }
function viewportBounds() { function viewportBounds() {
@@ -191,8 +186,10 @@ function renderCoordinateGuide() {
const bottom = Math.min(height, rect.bottom); const bottom = Math.min(height, rect.bottom);
if (right <= left || bottom <= top || rect.width <= 0) return; if (right <= left || bottom <= top || rect.width <= 0) return;
const step = chooseCoordinateGuideStep(rect.width / 64); const extent=boardExtent();
const step = chooseCoordinateGuideStep(rect.width / extent.size);
const xTicks = coordinateGuideTicks({ const xTicks = coordinateGuideTicks({
count: extent.size, origin: extent.origin,
boardStart: rect.left, boardStart: rect.left,
boardSize: rect.width, boardSize: rect.width,
visibleStart: left, visibleStart: left,
@@ -200,6 +197,7 @@ function renderCoordinateGuide() {
step, step,
}); });
const yTicks = coordinateGuideTicks({ const yTicks = coordinateGuideTicks({
count: extent.size, origin: extent.origin,
boardStart: rect.top, boardStart: rect.top,
boardSize: rect.height, boardSize: rect.height,
visibleStart: top, visibleStart: top,
@@ -296,6 +294,8 @@ function applyAssistivePosition(next = assistivePosition) {
}); });
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`); assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`); assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
const room=assistivePosition.y>window.innerHeight/2?assistivePosition.y:window.innerHeight-assistivePosition.y;
assistive.style.setProperty("--assistive-menu-height",`${Math.max(120,room+16)}px`);
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right"; assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down"; assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
} }
@@ -327,17 +327,20 @@ function toggleAssistiveMenu() {
function updateEditorModeControls() { function updateEditorModeControls() {
const moving = editorMode === "move"; const moving = editorMode === "move";
const textEditing = getSelectedLayer().type === "text";
moveLayerButton.setAttribute("aria-pressed",String(editorMode==="layer"));
if (editorActive) document.body.dataset.canvasEditorMode = editorMode; if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
else delete document.body.dataset.canvasEditorMode; else delete document.body.dataset.canvasEditorMode;
editorModeHint.textContent = moving editorModeHint.textContent = editorMode==="layer"?"移动图层:拖动或使用偏移坐标;黄色框为实际屏幕":moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。" ? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: "绘画模式:直接在画布上绘制。"; : textEditing ? "文字编辑模式:拖动文字或使用缩放手柄。" : "绘画模式:直接在画布上绘制。";
if (moving) { if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。"); setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton); setControlReady(continueEditButton);
} else { } else {
setControlReady(moveViewButton); setControlReady(moveViewButton);
setControlUnavailable(continueEditButton, "当前已经是绘画模式。"); if (editorMode === "layer") setControlReady(continueEditButton);
else setControlUnavailable(continueEditButton, textEditing ? "当前已经是文字编辑模式。" : "当前已经是绘画模式。");
} }
updateUndoControl(); updateUndoControl();
} }
@@ -361,24 +364,36 @@ function resetViewGesture() {
function setEditorMode(mode) { function setEditorMode(mode) {
if (!editorActive) return; if (!editorActive) return;
if (mode === "move") finishDrawing(); const pointerIds = [...viewPointers.keys()];
editorMode = mode === "move" ? "move" : "draw"; if (layerDrag) {
pointerIds.push(layerDrag.id);
layerDrag = null;
cancelLayerPreview();
}
viewPointers.clear(); viewPointers.clear();
viewGesture = null; viewGesture = null;
for (const id of pointerIds) {
if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
}
if (mode === "move") finishDrawing();
editorMode = ["move","layer"].includes(mode)?mode:"draw";
if (editorMode === "draw") moveControls.hidden = true;
setMoveFrameVisible(editorMode==="layer" || !moveControls.hidden);
updateEditorModeControls(); updateEditorModeControls();
closeAssistiveMenu({ restoreFocus: true }); closeAssistiveMenu({ restoreFocus: true });
announce(editorMode === "move" announce(editorMode === "move"
? "已进入移动画布视线模式:单指拖动,双指缩放" ? "已进入移动画布视线模式:单指拖动,双指缩放"
: "已继续编辑画布"); : editorMode === "layer" ? "已进入移动当前图层模式:拖动图层或输入偏移" : "已继续编辑画布");
} }
function enterCanvasEditor() { function enterCanvasEditor() {
if (editorActive || getInteractionMode() !== "pixels") return; if (editorActive || getInteractionMode() === "readonly") return;
editorActive = true; editorActive = true;
editorMode = "draw"; editorMode = "draw";
canvas.dataset.canvasEditing = "true"; canvas.dataset.canvasEditing = "true";
document.body.classList.add("canvas-edit-active"); document.body.classList.add("canvas-edit-active");
assistive.hidden = false; assistive.hidden = false;
setExpandedBoard(expandedEnabled);
closeAssistiveMenu(); closeAssistiveMenu();
updateEditorModeControls(); updateEditorModeControls();
updateCoordinateGuideControl(); updateCoordinateGuideControl();
@@ -400,6 +415,7 @@ function exitCanvasEditor({ returnToTools = true } = {}) {
viewGesture = null; viewGesture = null;
closeAssistiveMenu(); closeAssistiveMenu();
assistive.hidden = true; assistive.hidden = true;
moveControls.hidden=true;setMoveFrameVisible(false);setExpandedBoard(false);cancelLayerPreview();layerDrag=null;
hideCoordinateGuide(); hideCoordinateGuide();
delete canvas.dataset.canvasEditing; delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active"); document.body.classList.remove("canvas-edit-active");
@@ -417,17 +433,7 @@ function exitCanvasEditor({ returnToTools = true } = {}) {
announce("已退出画布编辑,可以重新选择颜色和画笔工具"); announce("已退出画布编辑,可以重新选择颜色和画笔工具");
} }
function undoPixelOperation() { function undoPixelOperation() {if(undoSceneEdit())announce("已撤销上一个编辑操作");updateUndoControl();}
const previous = undoHistory.undo();
if (!previous) {
updateUndoControl();
return;
}
getPixelBuffer().set(previous);
commitPixelChanges("pixel-undo");
updateUndoControl();
announce("已撤销上一个像素操作");
}
function persistTools() { function persistTools() {
localState.save({ localState.save({
@@ -438,6 +444,7 @@ function persistTools() {
normalBrushSize, normalBrushSize,
pixelBrushSize, pixelBrushSize,
coordinateGuideEnabled, coordinateGuideEnabled,
expandedEnabled,
}); });
} }
@@ -516,7 +523,10 @@ function selectTool(button) {
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button))); toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => { canvas.addEventListener("pointerdown", (event) => {
if (!editorActive || getInteractionMode() !== "pixels") return; if (!editorActive || getInteractionMode() === "readonly") return;
if (editorMode === "layer") {
event.preventDefault();const l=getSelectedLayer();layerDrag={id:event.pointerId,start:boardPoint(event,{round:false}),x:l.x,y:l.y};canvas.setPointerCapture(event.pointerId);return;
}
if (editorMode === "move") { if (editorMode === "move") {
event.preventDefault(); event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -524,10 +534,11 @@ canvas.addEventListener("pointerdown", (event) => {
resetViewGesture(); resetViewGesture();
return; return;
} }
if (drawing) return; if (getInteractionMode()!=="pixels" || drawing) return;
if (tool === "eyedropper") { if (tool === "eyedropper") {
const point = boardPoint(event); const point = boardPoint(event);
const offset = (point.y * 64 + point.x) * 3; const l=getSelectedLayer();
const offset = ((point.y+64-l.y)*192+point.x+64-l.x)*4;
const pixels = getPixelBuffer(); const pixels = getPixelBuffer();
setColorValue(paintColor, rgbToHex({ setColorValue(paintColor, rgbToHex({
r: pixels[offset], r: pixels[offset],
@@ -550,6 +561,7 @@ canvas.addEventListener("pointerdown", (event) => {
}); });
canvas.addEventListener("pointermove", (event) => { canvas.addEventListener("pointermove", (event) => {
if(layerDrag?.id===event.pointerId) {const p=boardPoint(event,{round:false});previewLayerMove(layerDrag.x+p.x-layerDrag.start.x,layerDrag.y+p.y-layerDrag.start.y);return;}
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) { if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
event.preventDefault(); event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -594,6 +606,12 @@ function finishDrawing() {
} }
function finishCanvasPointer(event) { function finishCanvasPointer(event) {
if(layerDrag?.id===event.pointerId) {
const drag=layerDrag;layerDrag=null;
if(event.type==="pointercancel"||event.type==="lostpointercapture")cancelLayerPreview();
else {const p=boardPoint(event,{round:false});try{moveSelectedLayer(drag.x+p.x-drag.start.x,drag.y+p.y-drag.start.y);}catch(e){cancelLayerPreview();announce(e.message,true);}}
syncOffsets();return;
}
if (viewPointers.delete(event.pointerId)) { if (viewPointers.delete(event.pointerId)) {
resetViewGesture(); resetViewGesture();
return; return;
@@ -625,13 +643,13 @@ document.getElementById("fill-canvas").addEventListener("click", () => {
const rgb = hexToRgb(backgroundColor.value); const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]); fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before); recordPixelOperation(before);
announce("背景颜色已填充到像素层,文字元素保持不变"); announce("已开启当前画笔图层的背景");
}); });
document.getElementById("clear-canvas").addEventListener("click", () => { document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot(); const before = pixelSnapshot();
fillPixelLayer([0, 0, 0]); clearPixelLayer();
recordPixelOperation(before); recordPixelOperation(before);
announce("像素层已清空,文字元素保持不变"); announce("当前图层笔迹已清空,背景保持不变");
}); });
startEditButton.addEventListener("click", enterCanvasEditor); startEditButton.addEventListener("click", enterCanvasEditor);
@@ -639,6 +657,7 @@ undoButton.addEventListener("click", undoPixelOperation);
exitEditButton.addEventListener("click", () => exitCanvasEditor()); exitEditButton.addEventListener("click", () => exitCanvasEditor());
moveViewButton.addEventListener("click", () => setEditorMode("move")); moveViewButton.addEventListener("click", () => setEditorMode("move"));
continueEditButton.addEventListener("click", () => setEditorMode("draw")); continueEditButton.addEventListener("click", () => setEditorMode("draw"));
exitLayerMoveButton.addEventListener("click", () => setEditorMode("draw"));
assistiveToggle.addEventListener("pointerdown", (event) => { assistiveToggle.addEventListener("pointerdown", (event) => {
if (!editorActive) return; if (!editorActive) return;
@@ -728,3 +747,20 @@ registerWorkspace({
hideCoordinateGuide(); hideCoordinateGuide();
}, },
}); });
function syncOffsets(){const l=getSelectedLayer();offsetX.value=l.x;offsetY.value=l.y;}
function commitOffsets(){try{moveSelectedLayer(Number(offsetX.value),Number(offsetY.value));}catch(e){announce(e.message,true);}syncOffsets();}
expandedToggle.setAttribute("aria-pressed",String(expandedEnabled));
expandedToggle.textContent=expandedEnabled?"隐藏扩展画面":"显示扩展画面";
expandedToggle.addEventListener("click",()=>{finishDrawing();expandedEnabled=!expandedEnabled;setExpandedBoard(expandedEnabled);expandedToggle.setAttribute("aria-pressed",String(expandedEnabled));expandedToggle.textContent=expandedEnabled?"隐藏扩展画面":"显示扩展画面";persistTools();updatePixelGridVisibility();applyCanvasView();});
moveLayerButton.addEventListener("click",()=>{finishDrawing();moveControls.hidden=false;syncOffsets();setEditorMode("layer");openAssistiveMenu();});
for(const input of [offsetX,offsetY]){
input.addEventListener("change",commitOffsets);
input.addEventListener("keydown",e=>{if(e.key==="Enter"){e.preventDefault();input.blur();}});
}
for(const button of document.querySelectorAll("[data-offset]"))button.addEventListener("click",()=>{
const l=getSelectedLayer(),axis=button.parentElement.dataset.axis,delta=Number(button.dataset.offset);
try{moveSelectedLayer(l.x+(axis==="x"?delta:0),l.y+(axis==="y"?delta:0));}catch(e){announce(e.message,true);}syncOffsets();
});
document.addEventListener("matrix:edit-history",updateUndoControl);
subscribeScene(({reason})=>{if(["selection","layer-move","undo","move-cancel"].includes(reason)){syncOffsets();updatePixelGridVisibility();}});
@@ -0,0 +1,121 @@
import { announce, apiJson } from "../core.js";
import { runControlAction, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { layerToStorage, sceneToStorageValue } from "../scene-model.js";
import { getScene, getSelectedLayer, subscribeScene, selectLayer, changeLayer, setLayerBackground,
animationEditState, templateEditState, enterTemplateEdit, enterAnimationEdit, markTemplateSaved,
markAnimationFrameSaved } from "../scene-board.js";
import { initializeTextWorkspace } from "./text.js";
import { refreshTemplates } from "./templates.js";
import { refreshAnimations } from "./animations.js";
const byId=id=>document.getElementById(id);
const list=byId("layer-list"), background=byId("layer-background-enabled"), color=byId("background-color");
const dialog=byId("layer-copy-dialog"),target=byId("layer-copy-target"),feedback=byId("layer-copy-feedback");
const submit=byId("layer-copy-submit"),frames=byId("layer-copy-frame-list");
let source=null,targets=new Map(),targetDetail=null,loadSequence=0,busy=false;
function render({scene}) {
const selected=getSelectedLayer(),brush=selected.type==="brush";
byId("brush-layer-tools").hidden=!brush;byId("text-layer-tools").hidden=brush;
background.checked=Boolean(selected.background);
if(brush){color.value=selected.backgroundColor||selected.background||"#000000";color.dispatchEvent(new Event("input"));}
list.replaceChildren(...[...scene.layers].reverse().map(l=>{
const button=document.createElement("button");button.type="button";button.className="layer-item";
button.textContent=`${l.name} · ${l.type==="brush"?"画笔":"文字"}`;button.setAttribute("aria-pressed",String(l.id===selected.id));
button.addEventListener("click",()=>selectLayer(l.id));return button;
}));
const index=scene.layers.indexOf(selected);
for(const [id,allowed,reason] of [["delete-layer",!brush||scene.layers.filter(l=>l.type==="brush").length>1,"必须保留一个画笔图层。"],
["raise-layer",index<scene.layers.length-1,"已经在最上方。"],["lower-layer",index>0,"已经在最下方。"]]) {
if(allowed)setControlReady(byId(id));else setControlUnavailable(byId(id),reason);
}
if(!brush)initializeTextWorkspace().catch(e=>announce(e.message,true));
}
subscribeScene(render);
for(const [id,action,value] of [["new-brush-layer","create","brush"],["new-text-layer","create","text"],
["delete-layer","delete"],["raise-layer","up"],["lower-layer","down"]])byId(id).addEventListener("click",()=>changeLayer(action,value));
byId("rename-layer").addEventListener("click",()=>{
const name=window.prompt("图层名称(1..80个字符)",getSelectedLayer().name);
if(name!==null)try{changeLayer("rename",name);}catch(e){announce(e.message,true);}
});
background.addEventListener("change",()=>setLayerBackground(background.checked,color.value));
color.addEventListener("input",()=>{if(getSelectedLayer().type==="brush"&&getSelectedLayer().backgroundColor!==color.value)setLayerBackground(background.checked,color.value);});
function dirtyChoice(){
const d=document.createElement("dialog");d.className="layer-copy-dialog";
const title=document.createElement("h3");title.textContent="目标画面正在编辑,存在未保存修改";d.append(title);
for(const [value,label] of [["save","保存后复制"],["discard","放弃修改后复制"],["cancel","取消"]]){
const b=document.createElement("button");b.type="button";b.textContent=label;b.addEventListener("click",()=>d.close(value));d.append(b);
}
document.body.append(d);d.showModal();return new Promise(resolve=>d.addEventListener("close",()=>{const choice=d.returnValue||"cancel";d.remove();resolve(choice);},{once:true}));
}
async function loadFrames(){
const sequence=++loadSequence;targetDetail=null;frames.replaceChildren();byId("layer-copy-frames").hidden=true;
const ref=targets.get(target.value);
if(!ref){setControlReady(submit);return;}
setControlUnavailable(submit,"正在读取目标帧。");feedback.textContent="正在读取目标帧…";
try{
const detail=await apiJson(ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames`);if(sequence!==loadSequence)return;
targetDetail=detail;
if(ref.type==="template"){feedback.textContent="副本默认置顶。";setControlReady(submit);return;}
byId("layer-copy-frames").hidden=false;
for(const [i,frame] of detail.frames.entries()) {
const label=document.createElement("label"),input=document.createElement("input");input.type="checkbox";input.value=frame.id;
const name=document.createElement("span");name.textContent=`${i+1}. ${frame.name||"未命名帧"}`;
label.append(input,name);frames.append(label);
}
feedback.textContent=detail.frames.length?"选择一个或多个目标帧,复制后置顶。":"目标动图没有帧,请先添加帧。";
if(detail.frames.length)setControlReady(submit);else setControlUnavailable(submit,"目标动图没有帧。");
}catch(e){if(sequence===loadSequence){feedback.textContent=e.message;setControlUnavailable(submit,"读取失败,请重新选择目标。");}}
}
byId("copy-layer").addEventListener("click",async()=>{
const sequence=++loadSequence;
source=layerToStorage(getSelectedLayer());targets=new Map();target.replaceChildren(new Option("当前画板","current"));
feedback.textContent="正在读取可复制目标…";dialog.showModal();setControlUnavailable(submit,"正在读取目标。");
try{
const [templates,animations]=await Promise.all([apiJson("/api/templates"),apiJson("/api/animations")]);
if(sequence!==loadSequence)return;
for(const [type,items] of [["template",templates.templates],["animation",animations.animations]])for(const item of items.filter(i=>!i.is_demo&&!i.read_only)){
const key=`${type}:${item.id}`;targets.set(key,{...item,type});target.append(new Option(`${type==="template"?"静态":"动图"} · ${item.name}`,key));
}
feedback.textContent="副本默认置顶,不改变原图层。";await loadFrames();
}catch(e){feedback.textContent=e.message;setControlReady(submit);}
});
target.addEventListener("change",loadFrames);
for(const [id,checked] of [["layer-copy-all",true],["layer-copy-none",false]])byId(id).addEventListener("click",()=>frames.querySelectorAll("input").forEach(input=>input.checked=checked));
byId("layer-copy-cancel").addEventListener("click",()=>{if(!busy){loadSequence++;dialog.close();}});
dialog.addEventListener("cancel",e=>{if(busy)e.preventDefault();else loadSequence++;});
submit.addEventListener("click",()=>runControlAction(submit,{message:"正在复制图层…"},async()=>{
busy=true;target.disabled=true;frames.inert=true;
try{
if(target.value==="current"){changeLayer("duplicate");dialog.close();announce("图层已复制到当前画板最上方");return;}
const ref=targets.get(target.value);if(!ref)throw Error("请选择有效目标");
const edit=ref.type==="template"?templateEditState():animationEditState();
// An ordinary template parked behind animation editing is not the active board.
const editing=edit&&(ref.type==="template"?edit.id===ref.id&&!animationEditState():edit.animationId===ref.id);
let savedRevision=null;
if(editing&&edit.dirty){
const choice=await dirtyChoice();if(choice==="cancel")return;
if(choice==="save"){
const path=ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames/${edit.frameId}`;
const saved=await apiJson(path,{method:"PUT",headers:{"If-Match":`"${edit.revision}"`},body:JSON.stringify({scene:sceneToStorageValue(getScene())})});
if(ref.type==="template")markTemplateSaved(saved);else markAnimationFrameSaved(saved);
savedRevision=saved.revision;
}
}
const current=targetDetail;
if(!current)throw Error("目标状态已失效,请重新选择");
// Preserve the revision from frame selection: intervening writes must conflict.
const frameIds=Array.from(frames.querySelectorAll("input:checked"),input=>input.value);
if(ref.type==="animation"&&!frameIds.length)throw Error("请至少选择一个目标帧");
const revision=savedRevision||current.revision;
const result=await apiJson(ref.type==="template"?`/api/templates/${ref.id}/layers`:`/api/animations/${ref.id}/frames/layers`,{
method:"POST",headers:{"If-Match":`"${revision}"`},body:JSON.stringify({layer:source,...(ref.type==="animation"?{frame_ids:frameIds}:{})})});
if(editing){if(ref.type==="template")enterTemplateEdit(result);else{
const frame=await apiJson(`/api/animations/${ref.id}/frames/${edit.frameId}`);
enterAnimationEdit({...frame,animation_id:ref.id,animation_name:result.name,animation_revision:result.revision});
}}
dialog.close();announce("图层已复制到目标最上方");
await Promise.all([refreshTemplates(),refreshAnimations()]);
}catch(e){feedback.textContent=`复制失败:${e.message}`;announce(feedback.textContent,true);}
finally{busy=false;target.disabled=false;frames.inert=false;}
}));
@@ -528,7 +528,7 @@ export async function refreshTemplates() {
if (!userItems.length) { if (!userItems.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "template-empty panel"; empty.className = "template-empty panel";
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。"; empty.textContent = "还没有模板。请在画图页保存当前统一画板。";
grid.appendChild(empty); grid.appendChild(empty);
} }
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`; feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
@@ -12,6 +12,7 @@ import {
deleteTextElement, deleteTextElement,
duplicateTextElement, duplicateTextElement,
getScene, getScene,
getSelectedLayer,
getSelectedTextElement, getSelectedTextElement,
selectTextElement, selectTextElement,
subscribeScene, subscribeScene,
@@ -116,7 +117,7 @@ function displayName(element, index) {
} }
function renderElementList(scene, selectedId) { function renderElementList(scene, selectedId) {
const items = scene.elements.map((element, index) => { const items = (getSelectedLayer().elements||[]).map((element, index) => {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "text-element-item"; button.className = "text-element-item";
@@ -144,7 +145,7 @@ function renderSelection({ scene, selectedTextId, reason }) {
selectionStatus.textContent = selected selectionStatus.textContent = selected
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放` ? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。"; : "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
const shouldRefreshFields = renderedSelectionId !== selectedTextId const shouldRefreshFields = reason === "selection" || reason === "undo" || reason === "move-cancel" || reason === "layer-move" || renderedSelectionId !== selectedTextId
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason); || ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
if (shouldRefreshFields) applyElementToFields(selected); if (shouldRefreshFields) applyElementToFields(selected);
} }
@@ -169,12 +170,28 @@ function formPatch(element) {
form.addEventListener("input", (event) => { form.addEventListener("input", (event) => {
if (event.target === fontInput || event.target === fontFileInput) return; if (event.target === fontInput || event.target === fontFileInput) return;
if (["text-x", "text-y"].includes(event.target.id)) return;
if (suppressForm) return; if (suppressForm) return;
const selected = getSelectedTextElement(); const selected = getSelectedTextElement();
if (!selected) return; if (!selected) return;
updateTextElement(selected.id, formPatch(selected), "form"); const patch = formPatch(selected);
delete patch.x;
delete patch.y;
updateTextElement(selected.id, patch, "form");
}); });
for (const id of ["text-x", "text-y"]) {
field(id).addEventListener("change", () => {
const selected = getSelectedTextElement();
if (selected) updateTextElement(selected.id, {
x: numericValue("text-x", selected.x), y: numericValue("text-y", selected.y),
}, "gesture");
});
field(id).addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); field(id).blur(); }
});
}
document.getElementById("create-text").addEventListener("click", () => { document.getElementById("create-text").addEventListener("click", () => {
const created = addTextElement({ const created = addTextElement({
text: "ok", text: "ok",
@@ -332,18 +349,10 @@ async function initializeTextWorkspace() {
} }
} }
if (!getSelectedTextElement()) { if (!getSelectedTextElement()) {
const topmost = getScene().elements.at(-1); const topmost = getSelectedLayer().elements?.at(-1);
if (topmost) selectTextElement(topmost.id); if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null); else applyElementToFields(null);
} }
} }
registerWorkspace({ export { initializeTextWorkspace };
id: "text",
title: "文字显示",
elementId: "workspace-text",
order: 40,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter: initializeTextWorkspace,
});
@@ -27,7 +27,7 @@ SCENE_WIDTH = 64
SCENE_HEIGHT = 64 SCENE_HEIGHT = 64
RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * 3 RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * 3
NAME_LIMIT = 80 NAME_LIMIT = 80
TEMPLATE_SCHEMA_VERSION = 1 TEMPLATE_SCHEMA_VERSION = 2
TEMPLATE_RECORD_FIELDS = frozenset({ TEMPLATE_RECORD_FIELDS = frozenset({
"id", "id",
"name", "name",
@@ -79,7 +79,7 @@ def _migrate_template_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
return dict(document) return dict(document)
TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1} TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1, 1: lambda document: dict(document)}
def _is_record(value: Any) -> bool: def _is_record(value: Any) -> bool:
@@ -102,7 +102,7 @@ def normalize_template_id(value: Any) -> str:
raise TemplateNotFoundError("template not found") from exc raise TemplateNotFoundError("template not found") from exc
def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]: def _validate_scene_v1(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
if not _is_record(value): if not _is_record(value):
raise TemplateValidationError("scene must be an object") raise TemplateValidationError("scene must be an object")
if strict_schema: if strict_schema:
@@ -190,6 +190,25 @@ def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]
} }
def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
if strict_schema and isinstance(value, dict):
for field in ("version", "width", "height"):
if field in value and type(value[field]) is not int:
raise TemplateValidationError(f"scene {field} must be an integer")
if isinstance(value, dict) and value.get("version") == 2:
from app.scene_layers import validate_v2
try:
return validate_v2(value, _validate_scene_v1)
except (ValueError, TypeError, KeyError) as exc:
raise TemplateValidationError(str(exc)) from exc
return _validate_scene_v1(value, strict_schema=strict_schema)
def prepare_scene(value: Any) -> dict[str, Any]:
from app.scene_layers import upgrade_scene
return upgrade_scene(validate_scene(value, strict_schema=True))
def scene_digest(scene: dict[str, Any]) -> str: def scene_digest(scene: dict[str, Any]) -> str:
canonical = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":")) canonical = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@@ -241,6 +260,9 @@ def render_scene_image(
font_resolver: FontResolver | None = None, font_resolver: FontResolver | None = None,
) -> Image.Image: ) -> Image.Image:
checked = validate_scene(scene) checked = validate_scene(scene)
if checked["version"] == 2:
from app.scene_layers import render_v2
return render_v2(checked, font_resolver)
pixels = base64.b64decode(checked["pixelRgb"], validate=True) pixels = base64.b64decode(checked["pixelRgb"], validate=True)
image = Image.frombytes("RGB", (SCENE_WIDTH, SCENE_HEIGHT), pixels).convert("RGBA") image = Image.frombytes("RGB", (SCENE_WIDTH, SCENE_HEIGHT), pixels).convert("RGBA")
for element in checked["elements"]: for element in checked["elements"]:
@@ -382,7 +404,7 @@ class TemplateStore:
name = normalize_name(document.get("name")) name = normalize_name(document.get("name"))
scene = validate_scene( scene = validate_scene(
document.get("scene"), document.get("scene"),
strict_schema=source_version == TEMPLATE_SCHEMA_VERSION, strict_schema=True,
) )
digest = scene_digest(scene) digest = scene_digest(scene)
if ( if (
@@ -414,6 +436,12 @@ class TemplateStore:
raise TemplateValidationError( raise TemplateValidationError(
f"template record does not match schema version {TEMPLATE_SCHEMA_VERSION}" f"template record does not match schema version {TEMPLATE_SCHEMA_VERSION}"
) )
if source_version != TEMPLATE_SCHEMA_VERSION:
record["scene"] = prepare_scene(record["scene"])
record["digest"] = scene_digest(record["scene"])
record["thumbnail"] = f"{template_id}-{record['digest']}.png"
elif record["scene"]["version"] != 2:
raise TemplateValidationError("current template requires scene v2")
return record, source_version != TEMPLATE_SCHEMA_VERSION return record, source_version != TEMPLATE_SCHEMA_VERSION
def _load_record_path(self, path: Path) -> tuple[dict[str, Any], bool]: def _load_record_path(self, path: Path) -> tuple[dict[str, Any], bool]:
@@ -519,7 +547,7 @@ class TemplateStore:
def create(self, name: Any, scene: Any) -> dict[str, Any]: def create(self, name: Any, scene: Any) -> dict[str, Any]:
checked_name = normalize_name(name) checked_name = normalize_name(name)
checked_scene = validate_scene(scene) checked_scene = prepare_scene(scene)
with self._lock: with self._lock:
self._ensure_unique_name(checked_name) self._ensure_unique_name(checked_name)
template_id = str(uuid4()) template_id = str(uuid4())
@@ -550,7 +578,7 @@ class TemplateStore:
def update_scene(self, template_id: Any, scene: Any, expected_revision: str) -> dict[str, Any]: def update_scene(self, template_id: Any, scene: Any, expected_revision: str) -> dict[str, Any]:
normalized_id = normalize_template_id(template_id) normalized_id = normalize_template_id(template_id)
checked_scene = validate_scene(scene) checked_scene = prepare_scene(scene)
with self._lock: with self._lock:
current = self._read_record_path(self._record_path(normalized_id)) current = self._read_record_path(self._record_path(normalized_id))
if current is None: if current is None:
@@ -574,6 +602,18 @@ class TemplateStore:
self._reconcile() self._reconcile()
return {**self._metadata(updated), "scene": updated["scene"]} return {**self._metadata(updated), "scene": updated["scene"]}
def append_layer(self, template_id: Any, layer: Any, expected_revision: str) -> dict[str, Any]:
from app.scene_layers import copy_layer, validate_layer
try:
checked = validate_layer(layer, _validate_scene_v1)
except (ValueError, TypeError, KeyError) as exc:
raise TemplateValidationError(str(exc)) from exc
with self._lock:
current = self.get(template_id)
scene = prepare_scene(current["scene"])
scene["layers"].append(copy_layer(checked, scene["layers"]))
return self.update_scene(template_id, scene, expected_revision)
def rename(self, template_id: Any, name: Any, expected_revision: str) -> dict[str, Any]: def rename(self, template_id: Any, name: Any, expected_revision: str) -> dict[str, Any]:
normalized_id = normalize_template_id(template_id) normalized_id = normalize_template_id(template_id)
checked_name = normalize_name(name) checked_name = normalize_name(name)
@@ -0,0 +1,155 @@
// Run against an isolated mock data root and a fresh Playwright browser context.
// The caller supplies its existing page; this script installs no browser tools.
import assert from "node:assert/strict";
export async function checkLayersBrowser(page, baseUrl) {
const errors = [], dialogs = [], policy = { value: "dismiss" };
const onError = error => errors.push(error.message);
const onDialog = async dialog => {
dialogs.push(dialog.message());
try {
if (policy.value === "dismiss") await dialog.dismiss();
else await dialog.accept(policy.value === "accept" ? undefined : policy.value);
} catch (error) { errors.push(error.message); }
};
const click = id => page.locator(`#${id}`).click();
const draft = async () => JSON.parse(await page.evaluate(() => localStorage.getItem("matrixController:scene")));
const ready = () => page.locator('#board-status[data-state="ready"]').waitFor();
page.on("pageerror", onError);
page.on("dialog", onDialog);
try {
await page.goto(`${baseUrl}/#canvas`);
await ready();
const backgroundBox = await page.locator("#layer-background-enabled").boundingBox();
assert.equal(backgroundBox.width, 20);
assert.equal(backgroundBox.height, 20);
const paintBox = await page.locator("#paint-color").boundingBox();
const colorBox = await page.locator("#background-color").boundingBox();
if (page.viewportSize().width >= 900) assert.ok(Math.abs(paintBox.y - colorBox.y) < 1);
assert.equal(await page.locator("#layer-list button").count(), 1);
assert.equal(await page.locator("#delete-layer").getAttribute("aria-disabled"), "true");
await click("new-text-layer");
await click("create-text");
await click("create-text");
await ready();
assert.equal(await page.locator("#text-element-list button").count(), 2);
await click("new-text-layer");
assert.equal(await page.locator("#text-element-list button").count(), 0);
await click("delete-layer");
assert.equal(await page.locator("#text-element-list button").count(), 2);
policy.value = "浏览器图层验收";
await click("rename-layer");
assert.match(await page.locator("#layer-list button").first().innerText(), /浏览器图层验收/);
await click("lower-layer");
await click("raise-layer");
await click("copy-layer");
assert.match(await page.locator("#layer-copy-dialog").evaluate(el => getComputedStyle(el).backgroundColor), /^rgb\(/);
await page.locator('#layer-copy-submit:not([aria-disabled])').waitFor();
await click("layer-copy-submit");
assert.equal(await page.locator("#layer-list button").count(), 3);
await click("delete-layer");
await page.locator("#text-element-list button").first().click();
await ready();
const before = await draft();
await page.locator("#text-x").fill("127");
assert.deepEqual(await draft(), before);
policy.value = "dismiss";
await page.locator("#text-x").press("Enter");
assert.equal(await page.locator("#text-x").inputValue(), "32");
policy.value = "accept";
await page.locator("#text-x").fill("127");
await page.locator("#text-x").press("Enter");
await ready();
const clip = (await draft()).layers[1].elements[0].clip;
assert.ok(clip);
await page.locator("#text-x").fill("32");
await page.locator("#text-x").press("Enter");
await ready();
assert.deepEqual((await draft()).layers[1].elements[0].clip, clip);
await page.locator("#text-y").fill("128");
await page.locator("#text-y").press("Enter");
assert.equal(await page.locator("#text-element-list button").count(), 1);
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
await click("undo-canvas-edit");
assert.equal(await page.locator("#text-element-list button").count(), 2);
await click("exit-canvas-edit");
await click("new-brush-layer");
await page.locator("#layer-background-enabled").check();
await click("clear-canvas");
assert.equal(await page.locator("#layer-background-enabled").isChecked(), true);
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
assert.equal(await page.locator("#toggle-expanded-board").getAttribute("aria-pressed"), "false");
await click("toggle-expanded-board");
await click("canvas-assistive-toggle");
const rect = await page.locator("#scene-overlay").boundingBox();
await page.mouse.click(rect.x + rect.width * 190 / 192, rect.y + rect.height * 80 / 192);
await click("canvas-assistive-toggle");
await click("move-current-layer");
const step = page.locator('.offset-steps[data-axis="x"] [data-offset="10"]');
policy.value = "dismiss";
const uncommitted = await draft();
await step.click();
assert.deepEqual(await draft(), uncommitted);
policy.value = "accept";
await step.click();
assert.equal(await page.locator("#layer-offset-x").inputValue(), "10");
await click("undo-canvas-edit");
assert.deepEqual(await draft(), uncommitted);
await click("move-canvas-view");
await click("canvas-assistive-toggle");
assert.equal(await page.locator("#layer-move-controls").isVisible(), true);
await click("canvas-assistive-toggle");
const viewBefore = await draft();
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await page.mouse.down();
await page.mouse.move(rect.x + rect.width / 2 + 20, rect.y + rect.height / 2 + 12);
await page.mouse.up();
await page.mouse.wheel(0, -80);
assert.deepEqual(await draft(), viewBefore);
await click("canvas-assistive-toggle");
const viewStyle = await page.locator("#board-stage").getAttribute("style");
const undoBefore = await page.locator("#undo-canvas-edit").getAttribute("aria-disabled");
await click("exit-layer-move");
assert.equal(await page.locator("body").getAttribute("data-canvas-editor-mode"), "draw");
assert.equal(await page.locator("body").evaluate(el => el.classList.contains("canvas-edit-active")), true);
assert.equal(await page.locator("#layer-move-controls").getAttribute("hidden"), "");
assert.equal(await page.locator("#board-stage").getAttribute("style"), viewStyle);
assert.deepEqual(await draft(), viewBefore);
assert.equal(await page.locator("#undo-canvas-edit").getAttribute("aria-disabled"), undoBefore);
await click("canvas-assistive-toggle");
await click("exit-canvas-edit");
const saved = await draft();
await page.reload();
await ready();
assert.deepEqual(await draft(), saved);
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
assert.equal(await page.locator("#toggle-expanded-board").getAttribute("aria-pressed"), "true");
await click("toggle-expanded-board");
assert.deepEqual(await draft(), saved);
await click("exit-canvas-edit");
await page.locator("#layer-list button").filter({ hasText: "浏览器图层验收" }).click();
await ready();
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
await click("move-current-layer");
assert.equal(await page.locator("#continue-canvas-edit").getAttribute("aria-disabled"), null);
const textBeforeMove = await draft();
await page.locator('.offset-steps[data-axis="y"] [data-offset="1"]').click();
const textMoved = await draft();
await click("exit-layer-move");
assert.deepEqual(await draft(), textMoved);
assert.match(await page.locator("#canvas-edit-mode-hint").innerText(), /文字编辑模式/);
await click("canvas-assistive-toggle");
await click("undo-canvas-edit");
assert.deepEqual(await draft(), textBeforeMove);
await click("exit-canvas-edit");
assert.deepEqual(errors, []);
return { passed: true, nativeConfirmations: dialogs.length, layers: saved.layers.length, errors };
} finally {
page.off("pageerror", onError);
page.off("dialog", onDialog);
}
}
@@ -60,4 +60,4 @@ def test_fixture_generates_distinct_complete_animations(tmp_path):
document = json.loads( document = json.loads(
(root / "animations" / item["id"] / "animation.json").read_text(encoding="utf-8") (root / "animations" / item["id"] / "animation.json").read_text(encoding="utf-8")
) )
assert document["schema_version"] == 2 assert document["schema_version"] == 3
@@ -188,7 +188,7 @@ def test_animation_crud_reorder_duration_thumbnail_and_persistence(tmp_path):
persisted = TestClient(restarted).get(f"/api/animations/{created['id']}").json() persisted = TestClient(restarted).get(f"/api/animations/{created['id']}").json()
assert persisted["frame_count"] == 2 assert persisted["frame_count"] == 2
document = json.loads((tmp_path / "data" / "animations" / created["id"] / "animation.json").read_text(encoding="utf-8")) document = json.loads((tmp_path / "data" / "animations" / created["id"] / "animation.json").read_text(encoding="utf-8"))
assert document["schema_version"] == 2 assert document["schema_version"] == 3
assert all("scene" not in frame and "scene_file" in frame for frame in document["frames"]) assert all("scene" not in frame and "scene_file" in frame for frame in document["frames"])
assert len(list((tmp_path / "data" / "animations" / created["id"] / "scenes").glob("*.json"))) == 2 assert len(list((tmp_path / "data" / "animations" / created["id"] / "scenes").glob("*.json"))) == 2
@@ -231,8 +231,8 @@ test("canvas editor markup, guarded input, and checkpoint events stay wired", as
assert.match(style, /\.canvas-coordinate-guide\s*\{[^}]*pointer-events:\s*none/s); assert.match(style, /\.canvas-coordinate-guide\s*\{[^}]*pointer-events:\s*none/s);
assert.match(style, /body\.canvas-edit-active \.canvas-coordinate-guide:not\(\[hidden\]\)\s*\{[^}]*z-index:\s*1005/s); assert.match(style, /body\.canvas-edit-active \.canvas-coordinate-guide:not\(\[hidden\]\)\s*\{[^}]*z-index:\s*1005/s);
assert.match(style, /#scene-overlay\[data-interaction="pixels"\]:not\(\[data-canvas-editing="true"\]\)\s*\{[^}]*pointer-events:\s*none/s); assert.match(style, /#scene-overlay\[data-interaction="pixels"\]:not\(\[data-canvas-editing="true"\]\)\s*\{[^}]*pointer-events:\s*none/s);
assert.match(canvasView, /if \(!editorActive \|\| getInteractionMode\(\) !== "pixels"\) return/); assert.match(canvasView, /getInteractionMode\(\) === "readonly"/);
assert.match(canvasView, /new PixelUndoHistory\(\)/); assert.match(canvasView, /undoSceneEdit\(\)/);
assert.match(canvasView, /createLocalState\("canvas-tools", 3\)/); assert.match(canvasView, /createLocalState\("canvas-tools", 3\)/);
assert.match(canvasView, /coordinateGuide\.hidden = true/); assert.match(canvasView, /coordinateGuide\.hidden = true/);
assert.match(sceneBoard, /matrix:scene-replaced/); assert.match(sceneBoard, /matrix:scene-replaced/);
@@ -231,7 +231,7 @@ def test_v7_config_inserts_media_workspace_without_reordering_existing_items(tmp
migrated = ConfigStore(project_root=tmp_path) migrated = ConfigStore(project_root=tmp_path)
assert migrated.config["workspace_order"] == [ assert migrated.config["workspace_order"] == [
"settings", "text", "templates", "media-import", "animations", "device", "canvas", "settings", "templates", "media-import", "animations", "device", "canvas",
] ]
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
@@ -263,7 +263,7 @@ def test_v9_config_migrates_animation_preview_concurrency_without_losing_values(
assert migrated.config["performance_mode_enabled"] is True assert migrated.config["performance_mode_enabled"] is True
assert migrated.config["animation_preview_max_concurrent"] == 2 assert migrated.config["animation_preview_max_concurrent"] == 2
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 10 assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 11
def test_config_schema_field_sets_are_explicit_and_incremental(): def test_config_schema_field_sets_are_explicit_and_incremental():
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as legacy from "../app/static/scene-model-v1.js";
import { encodePixels, decodePixels } from "../app/static/pixel-png.js";
import { attachScene, blankScene, createLayer, cloneLayer, composeScene, serializeScene, deserializeScene,
clipBrushPixels, translatedOverflow, textClipAt, clipTextPixels, addTextElement, updateTextElement } from "../app/static/scene-model.js";
import { restoreSceneDraft,LEGACY_STORAGE_KEYS } from "../app/static/scene-model.js";
function pixel(layer,x,y,color){layer.pixels.set(color,((y+64)*192+x+64)*4);layer.revision++;}
test("old scene migration rejects unknown fields and duplicate text without filtering",()=>{
const raw=legacy.sceneToStorageValue(legacy.addTextElement(legacy.createScene(),{text:"preserved"}));
assert.ok(deserializeScene(raw));
assert.equal(deserializeScene({...raw,unknown:true}),null);
assert.equal(deserializeScene({...raw,elements:[...raw.elements,...raw.elements]}),null);
assert.equal(deserializeScene({...raw,elements:[{...raw.elements[0],x:999}]}),null);
const values=new Map([[LEGACY_STORAGE_KEYS.sceneV1,JSON.stringify({...raw,unknown:true})]]);
const storage={getItem:key=>values.get(key)??null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)};
const before=[...values];
assert.equal(restoreSceneDraft(storage).canPersist,false);
assert.deepEqual([...values],before);
});
test("PNG compression round trips sparse and full brush pixels without DOM",()=>{
const data=new Uint8ClampedArray(192*192*4);data.set([1,2,3,255],400);data.set([5,6,7,128],data.length-4);
const png=encodePixels(data);assert.ok(png.length<3000);assert.deepEqual(decodePixels(png),data);
const corrupt=png.slice(0,-8)+"AAAAAAAA";assert.throws(()=>decodePixels(corrupt));
});
test("blank and black are distinct; independent backgrounds follow layer order",()=>{
const bottom=createLayer("brush"),top=createLayer("brush",[bottom]);bottom.background="#FF0000";
let scene=attachScene([bottom,top]);assert.deepEqual([...composeScene(scene).slice(0,3)],[255,0,0]);
pixel(top,0,0,[0,0,0,255]);assert.deepEqual([...composeScene(scene).slice(0,6)],[0,0,0,255,0,0]);
top.background="#00FF00";assert.deepEqual([...composeScene(scene).slice(3,6)],[0,255,0]);
top.pixels.fill(0);top.background=null;assert.deepEqual([...composeScene(scene).slice(0,3)],[255,0,0]);
});
test("disabled background colors stay independent through storage and copy",()=>{
const bottom=createLayer("brush"),top=createLayer("brush",[bottom]);
bottom.backgroundColor="#FF0000"; top.backgroundColor="#0000FF";
const restored=deserializeScene(serializeScene(attachScene([bottom,top])));
assert.equal(restored.layers[0].background,null);
assert.equal(restored.layers[0].backgroundColor,"#FF0000");
assert.equal(restored.layers[1].backgroundColor,"#0000FF");
assert.equal(cloneLayer(restored.layers[0],restored.layers).backgroundColor,"#FF0000");
});
test("extended pixels survive storage but never enter the central output",()=>{
const scene=blankScene(),l=scene.layers[0];pixel(l,-64,-64,[255,255,255,255]);pixel(l,127,127,[0,255,0,255]);
const rgb=composeScene(scene);assert.equal(rgb.some(Boolean),false);
const restored=deserializeScene(serializeScene(scene));assert.ok(restored);assert.equal(composeScene(restored,new Map(),192)[0],255);
assert.equal(restored.layers[0].pixels.at(-3),255);
});
test("move preview does not mutate source; confirmed clipping removes only outside ink",()=>{
const scene=blankScene(),l=scene.layers[0];l.background="#123456";pixel(l,127,0,[0,0,0,255]);pixel(l,0,0,[255,0,0,255]);
const before=serializeScene(scene);composeScene(scene,new Map(),192,{layerId:l.id,x:1,y:0});assert.equal(serializeScene(scene),before);
assert.deepEqual(translatedOverflow(l.pixels,1,0),{outside:true,inside:true});
l.pixels=clipBrushPixels(l.pixels,1,0);l.revision++;assert.equal(l.background,"#123456");
assert.deepEqual(translatedOverflow(l.pixels,1,0),{outside:false,inside:true});
const saved=deserializeScene(serializeScene(scene));assert.equal(saved.layers[0].pixels[((64)*192+191)*4+3],0);
});
test("text clipping follows anchor and scale and is retained after edit/reload/copy",()=>{
let scene=addTextElement(blankScene(),{text:"two",x:126,y:0,size:10});const element=scene.elements[0];element.clip=textClipAt(element,1,0);
const rgba=new Uint8ClampedArray(192*192*4);rgba.set([255,0,0,255],(64*192+191)*4);
assert.equal(clipTextPixels(rgba,element)[(64*192+191)*4+3],0);
const moved=updateTextElement(scene,element.id,{x:0,size:20});assert.deepEqual(moved.elements[0].clip,element.clip);
const restored=deserializeScene(serializeScene(moved));assert.deepEqual(restored.elements[0].clip,element.clip);
const copy=cloneLayer(restored.layers[1],restored.layers);assert.notEqual(copy.id,restored.layers[1].id);assert.notEqual(copy.elements[0].id,element.id);
copy.elements[0].clip[0]-=1;assert.notDeepEqual(copy.elements[0].clip,element.clip);
});
test("multiple text groups isolate edits, copies and IDs",()=>{
const a=createLayer("text"),b=createLayer("text",[a]);let scene=attachScene([createLayer("brush"),a,b]);
scene=addTextElement(scene,{text:"A"},a.id);scene=addTextElement(scene,{text:"B"},b.id);scene=addTextElement(scene,{text:"A2"},a.id);
assert.deepEqual(scene.layers[1].elements.map(e=>e.text),["A","A2"]);assert.deepEqual(scene.layers[2].elements.map(e=>e.text),["B"]);
assert.equal(new Set(scene.elements.map(e=>e.id)).size,3);
});
test("strict v2 storage protects unknown data and the final brush invariant",()=>{
const raw=JSON.parse(serializeScene(blankScene()));raw.layers=[];assert.equal(deserializeScene(raw),null);
const text=createLayer("text");assert.equal(deserializeScene({version:2,width:64,height:64,layers:[text]}),null);
const original=JSON.parse(serializeScene(blankScene()));original.layers[0].surprise=true;assert.equal(deserializeScene(original),null);
});
@@ -0,0 +1,189 @@
import base64
import copy
import json
import subprocess
import shutil
from pathlib import Path
from io import BytesIO
import pytest
from PIL import Image
from fastapi.testclient import TestClient
from app.main import create_app
from app.scene_layers import encode_pixels, decode_pixels, upgrade_scene, render_v2, render_element
from app.templates.store import TemplateStore, TemplateValidationError, prepare_scene, render_scene_image, scene_digest
from app.animations.store import AnimationStore
def legacy_scene(text=False):
elements = [dict(id="text-1", type="text", text="Hello", font="default", size=12, x=32, y=26, align="center", color="#FFFFFF")] if text else []
return dict(version=1, width=64, height=64, pixelRgb=base64.b64encode(bytes([30,60,90])*4096).decode(), elements=elements)
def brush(color=(0,0,0,0)):
return dict(id="source",name="图层1",type="brush",x=0,y=0,background=None,pixelPng=encode_pixels(Image.new("RGBA",(192,192),color)))
def test_migration_preserves_pixels_text_and_multilayer_render():
for text in (False,True):
old=legacy_scene(text);new=prepare_scene(old)
assert new["version"]==2
assert len(new["layers"])==(2 if text else 1)
assert render_scene_image(old).tobytes()==render_scene_image(new).tobytes()
layer=brush();image=decode_pixels(layer["pixelPng"]);image.putpixel((64,64),(0,0,0,255));layer["pixelPng"]=encode_pixels(image)
new=prepare_scene(legacy_scene());new["layers"].append(layer)
assert render_scene_image(new).getpixel((0,0))==(0,0,0)
assert render_scene_image(new).getpixel((1,0))==(30,60,90)
def test_disabled_background_color_is_saved_copied_and_does_not_paint(tmp_path):
layer = brush()
layer["backgroundColor"] = "#FF0000"
scene = dict(version=2,width=64,height=64,layers=[layer])
assert render_scene_image(scene).getpixel((0,0)) == (0,0,0)
store = TemplateStore(tmp_path)
saved = store.create("disabled background", scene)
copied = store.append_layer(saved["id"], layer, saved["revision"])
assert [item["backgroundColor"] for item in copied["scene"]["layers"]] == ["#FF0000"]*2
assert TemplateStore(tmp_path).get(saved["id"])["scene"] == copied["scene"]
def test_template_schema_migration_validates_old_digest_and_preserves_bad_original(tmp_path):
store=TemplateStore(tmp_path);created=store.create("legacy",legacy_scene(True))
path=tmp_path/"templates"/"records"/f"{created['id']}.json"
record=json.loads(path.read_text(encoding="utf-8"));old=legacy_scene(True);record.update(schema_version=1,scene=old,digest=scene_digest(old),thumbnail=f"{created['id']}-{scene_digest(old)}.png")
path.write_text(json.dumps(record),encoding="utf-8")
migrated=TemplateStore(tmp_path).get(created["id"])
assert migrated["scene"]["version"]==2
assert json.loads(path.read_text(encoding="utf-8"))["schema_version"]==2
record["digest"]="0000000000000000";bad=json.dumps(record).encode();path.write_bytes(bad)
with pytest.raises(TemplateValidationError):TemplateStore(tmp_path)
assert path.read_bytes()==bad
def test_animation_v2_migration_preserves_frame_ids_order_duration(tmp_path):
store=AnimationStore(tmp_path);record=store.create("old")
record=store.add_frame(record["id"],legacy_scene(),record["revision"],duration_ms=120)
root=tmp_path/"animations"/record["id"];index=root/"animation.json";raw=json.loads(index.read_text(encoding="utf-8"));raw["schema_version"]=2
old=legacy_scene();digest=scene_digest(old);frame=raw["frames"][0]
frame.update(digest=digest,scene_file=f"{frame['id']}-{digest}.json",thumbnail=f"{frame['id']}-{digest}.png")
(root/"scenes"/frame["scene_file"]).write_text(json.dumps(old),encoding="utf-8");index.write_text(json.dumps(raw),encoding="utf-8")
migrated=AnimationStore(tmp_path).get(record["id"])
assert migrated["frames"][0]["id"]==frame["id"]
assert migrated["frames"][0]["duration_ms"]==120
assert json.loads(index.read_text(encoding="utf-8"))["schema_version"]==3
@pytest.mark.parametrize("version", [True, 3.0, 4])
def test_animation_invalid_or_future_version_preserves_original(tmp_path, version):
store = AnimationStore(tmp_path)
record = store.create("protected")
path = tmp_path / "animations" / record["id"] / "animation.json"
document = json.loads(path.read_text(encoding="utf-8"))
document["schema_version"] = version
payload = json.dumps(document).encode("utf-8")
path.write_bytes(payload)
with pytest.raises(TemplateValidationError):
AnimationStore(tmp_path)
assert path.read_bytes() == payload
def test_append_layers_conflict_batch_failure_and_restart(tmp_path,monkeypatch):
app=create_app(project_root=tmp_path,driver_kind="mock");client=TestClient(app)
template=client.post("/api/templates",json=dict(name="target",scene=legacy_scene())).json();path=f"/api/templates/{template['id']}/layers"
assert client.post(path,json=dict(layer=brush())).status_code==428
copied=client.post(path,headers={"If-Match":template["revision"]},json=dict(layer=brush())).json()
assert len(copied["scene"]["layers"])==2
assert copied["scene"]["layers"][-1]["id"]!="source"
assert client.post(path,headers={"If-Match":template["revision"]},json=dict(layer=brush())).status_code==409
animations=app.state.animation_store
record=animations.create("batch")
for _ in range(3):record=animations.add_frame(record["id"],legacy_scene(),record["revision"],duration_ms=250)
ids=[f["id"] for f in record["frames"]]
root=tmp_path/"data"/"animations"/record["id"];index=root/"animation.json";before=index.read_bytes()
original=animations._write_thumbnail;count=0
def fail_second(*args):
nonlocal count
count+=1
if count==2:raise OSError("simulated full disk")
return original(*args)
monkeypatch.setattr(animations,"_write_thumbnail",fail_second)
with pytest.raises(OSError):animations.append_layer(record["id"],brush(),ids,record["revision"])
assert index.read_bytes()==before
monkeypatch.setattr(animations,"_write_thumbnail",original)
AnimationStore(tmp_path/"data")
write_index = animations._write
def fail_index(*args):
raise OSError("simulated index write failure")
monkeypatch.setattr(animations, "_write", fail_index)
with pytest.raises(OSError):
animations.append_layer(record["id"], brush(), ids, record["revision"])
assert index.read_bytes() == before
assert all(len(frame["scene"]["layers"]) == 1
for frame in AnimationStore(tmp_path/"data").get(record["id"], scenes=True)["frames"])
monkeypatch.setattr(animations, "_write", write_index)
endpoint=f"/api/animations/{record['id']}/frames/layers"
result=client.post(endpoint,headers={"If-Match":record["revision"]},json=dict(layer=brush(),frame_ids=ids[:2]))
assert result.status_code==200
restarted=AnimationStore(tmp_path/"data");detail=restarted.get(record["id"],scenes=True)
assert [len(f["scene"]["layers"]) for f in detail["frames"]]==[2,2,1]
assert [f["duration_ms"] for f in detail["frames"]]==[250]*3
assert detail["frames"][0]["scene"]["layers"][-1]["id"]!=detail["frames"][1]["scene"]["layers"][-1]["id"]
def test_config_v10_migration_only_removes_text_navigation(tmp_path):
from app.config.store import ConfigStore
store = ConfigStore(tmp_path)
path = store.path
original = json.loads(path.read_text(encoding="utf-8"))
original.update(schema_version=10, workspace_order=["templates", "text", "canvas", "animations"], brightness=27)
path.write_text(json.dumps(original), encoding="utf-8")
ConfigStore(tmp_path)
actual = json.loads(path.read_text(encoding="utf-8"))
assert actual == {**original, "schema_version": 11, "workspace_order": ["templates", "canvas", "animations"]}
def test_config_text_only_navigation_migrates_to_registered_defaults(tmp_path):
from app.config.store import ConfigStore, DEFAULT_WORKSPACE_ORDER
store=ConfigStore(tmp_path)
original=json.loads(store.path.read_text(encoding="utf-8"))
original.update(schema_version=10,workspace_order=["text"])
store.path.write_text(json.dumps(original),encoding="utf-8")
actual=ConfigStore(tmp_path)
assert actual.config["workspace_order"]==list(DEFAULT_WORKSPACE_ORDER)
def test_text_extended_preview_default_size_and_persistent_clip(tmp_path):
app=create_app(project_root=tmp_path,driver_kind="mock");client=TestClient(app)
for size in (64,192):
result=client.post("/api/preview/text-layer",json=dict(text="hello",edit_size=size))
assert result.status_code==200
assert Image.open(BytesIO(result.content)).size==(size,size)
element=upgrade_scene(legacy_scene(True))["layers"][1]["elements"][0]
element["clip"]=[-1,0,0,2]
image=render_element(element)
assert image.getpixel((element["x"]+64,element["y"]+64))[3]==0
scene=prepare_scene(legacy_scene());scene["layers"].append(dict(id="text-layer",name="图层2",type="text",x=0,y=0,elements=[element]))
store=TemplateStore(tmp_path/"clip");saved=store.create("clipped",scene)
assert TemplateStore(tmp_path/"clip").get(saved["id"])["scene"]==scene
def test_browser_python_composition_and_png_are_identical(tmp_path):
node=shutil.which("node")
if not node:pytest.skip("Node is only required for local cross-language parity")
scene=prepare_scene(legacy_scene(True));top=brush();top.update(x=-10,y=10,background="#123456")
image=decode_pixels(top["pixelPng"])
for y in range(64):
for x in range(64):
image.putpixel((64+x,64+y), ((x*19)%256,(y*31)%256,(x+y)*2,(x*7+y*13)%256))
top["pixelPng"]=encode_pixels(image)
scene["layers"].insert(1,top);scene["layers"][2]["x"]=5
element=scene["layers"][2]["elements"][0];element["clip"]=[-1,0,1,2]
overlapping = copy.deepcopy(element)
overlapping.update(id="overlapping",color="#22BBFF",x=30,clip=None)
scene["layers"][2]["elements"].append(overlapping)
text = {e["id"]:base64.b64encode(render_element(e).tobytes()).decode() for e in (element,overlapping)}
fixture=tmp_path/"parity.json";fixture.write_text(json.dumps(dict(scene=scene,text=text)),encoding="utf-8")
module=(Path(__file__).parents[1]/"app/static/scene-model.js").as_uri()
code='import fs from "node:fs";import {deserializeScene,composeScene} from '+json.dumps(module)+';const f=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const m=new Map(Object.entries(f.text).map(([k,v])=>[k,new Uint8ClampedArray(Buffer.from(v,"base64"))]));console.log(Buffer.from(composeScene(deserializeScene(f.scene),m)).toString("base64"));'
result=subprocess.run([node,"--input-type=module","-e",code,str(fixture)],capture_output=True,text=True,encoding="utf-8",check=True)
assert base64.b64decode(result.stdout.strip())==render_v2(scene).tobytes()
@@ -1,3 +1,4 @@
import * as v1 from "../app/static/scene-model-v1.js";
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
@@ -78,23 +79,23 @@ test("serializes pixelRgb as base64 and round trips an independent scene", () =>
color: "#12abef", color: "#12abef",
}); });
const stored = sceneToStorageValue(original); const stored = sceneToStorageValue(original);
assert.equal(typeof stored.pixelRgb, "string"); assert.equal(typeof stored.layers[0].pixelPng, "string");
assert.equal("pixels" in stored, false); assert.equal("pixels" in stored, false);
assert.equal("pixelsRgb" in stored, false); assert.equal("pixelsRgb" in stored, false);
assert.equal("revision" in stored.elements[0], false); assert.equal("revision" in stored.layers[1].elements[0], false);
const restored = deserializeScene(serializeScene(original)); const restored = deserializeScene(serializeScene(original));
assert.ok(restored); assert.ok(restored);
assert.deepEqual(Array.from(restored.pixels), Array.from(original.pixels)); assert.deepEqual(Array.from(restored.pixels), Array.from(original.pixels));
assert.deepEqual(restored.elements, original.elements); assert.deepEqual(restored.elements, original.elements);
assert.notStrictEqual(restored.pixels, original.pixels); assert.notStrictEqual(restored.pixels, original.pixels);
assert.notStrictEqual(restored.elements[0], original.elements[0]); assert.notStrictEqual(restored.layers[1].elements[0], original.elements[0]);
assert.equal(restored.elements[0].text, "你好 · مرحبا · नमस्ते · ไทย"); assert.equal(restored.layers[1].elements[0].text, "你好 · مرحبا · नमस्ते · ไทย");
}); });
test("moves a valid v1 draft to the stable key before removing exact legacy keys", () => { test("moves a valid v1 draft to the stable key before removing exact legacy keys", () => {
const original = addTextElement(createScene({ pixels: patternedPixels(23) }), { text: "keep me" }); const original = addTextElement(createScene({ pixels: patternedPixels(23) }), { text: "keep me" });
const storage = memoryStorage({ const storage = memoryStorage({
[LEGACY_STORAGE_KEYS.sceneV1]: serializeScene(original), [LEGACY_STORAGE_KEYS.sceneV1]: v1.serializeScene(v1.createScene({pixels:original.pixels,elements:original.elements})),
[LEGACY_STORAGE_KEYS.canvasV3]: JSON.stringify({ pixels: Array.from(patternedPixels(2)) }), [LEGACY_STORAGE_KEYS.canvasV3]: JSON.stringify({ pixels: Array.from(patternedPixels(2)) }),
[LEGACY_STORAGE_KEYS.textV1]: JSON.stringify({ "text-value": "older text" }), [LEGACY_STORAGE_KEYS.textV1]: JSON.stringify({ "text-value": "older text" }),
"unrelated:key": "keep", "unrelated:key": "keep",
@@ -117,7 +118,7 @@ test("moves a valid v1 draft to the stable key before removing exact legacy keys
}); });
test("keeps the v1 draft and all legacy keys when stable-key migration cannot write", () => { test("keeps the v1 draft and all legacy keys when stable-key migration cannot write", () => {
const originalRaw = serializeScene(addTextElement(createScene(), { text: "do not delete" })); const originalRaw = v1.serializeScene(v1.addTextElement(v1.createScene(), { text: "do not delete" }));
const storage = memoryStorage({ const storage = memoryStorage({
[LEGACY_STORAGE_KEYS.sceneV1]: originalRaw, [LEGACY_STORAGE_KEYS.sceneV1]: originalRaw,
[LEGACY_STORAGE_KEYS.canvasV2]: JSON.stringify({ pixels: Array.from(patternedPixels(4)) }), [LEGACY_STORAGE_KEYS.canvasV2]: JSON.stringify({ pixels: Array.from(patternedPixels(4)) }),
@@ -184,23 +185,14 @@ test("does not discard a damaged higher-priority legacy key during fallback migr
test("rejects damaged scene roots while skipping only damaged elements", () => { test("rejects damaged scene roots while skipping only damaged elements", () => {
const valid = sceneToStorageValue(addTextElement(createScene(), { text: "keep" })); const valid = sceneToStorageValue(addTextElement(createScene(), { text: "keep" }));
assert.equal(deserializeScene({ ...valid, version: 2 }), null); assert.equal(deserializeScene({ ...valid, version: 3 }), null);
assert.equal(deserializeScene({ ...valid, width: 32 }), null); assert.equal(deserializeScene({ ...valid, width: 32 }), null);
assert.equal(deserializeScene({ ...valid, pixelRgb: "not base64" }), null); assert.equal(deserializeScene({ ...valid, pixelRgb: "not base64" }), null);
assert.equal(deserializeScene("{broken"), null); assert.equal(deserializeScene("{broken"), null);
const recovered = deserializeScene({ const invalid = {...valid,layers:valid.layers.map((l,i)=>i===1?{...l,elements:[...l.elements,{...l.elements[0]}]}:l)};
...valid, assert.equal(deserializeScene(invalid), null, "duplicate ids must not be silently dropped");
elements: [
valid.elements[0],
{ id: "bad-type", type: "image", text: "skip" },
{ id: "bad-text", type: "text", text: 123 },
{ ...valid.elements[0], text: "duplicate id" },
],
});
assert.ok(recovered);
assert.equal(recovered.elements.length, 1);
assert.equal(recovered.elements[0].text, "keep");
}); });
test("normalizes text fields and creates a centered lowercase ok element", () => { test("normalizes text fields and creates a centered lowercase ok element", () => {
@@ -235,6 +227,7 @@ test("normalizes text fields and creates a centered lowercase ok element", () =>
align: DEFAULT_TEXT_ELEMENT.align, align: DEFAULT_TEXT_ELEMENT.align,
color: "#12ABEF", color: "#12ABEF",
revision: 0, revision: 0,
clip: null,
}); });
assert.equal("background" in normalized, false); assert.equal("background" in normalized, false);
}); });
@@ -270,7 +270,7 @@ def test_template_preserves_multilingual_text(tmp_path):
assert created.status_code == 201 assert created.status_code == 201
stored = client.get(f"/api/templates/{created.json()['id']}") stored = client.get(f"/api/templates/{created.json()['id']}")
assert stored.status_code == 200 assert stored.status_code == 200
assert stored.json()["scene"]["elements"][0]["text"] == multilingual assert stored.json()["scene"]["layers"][1]["elements"][0]["text"] == multilingual
def test_template_mutations_require_current_revision(tmp_path): def test_template_mutations_require_current_revision(tmp_path):
@@ -294,7 +294,7 @@ def test_template_mutations_require_current_revision(tmp_path):
assert client.delete(path, headers=stale_headers).status_code == 409 assert client.delete(path, headers=stale_headers).status_code == 409
preserved = client.get(path).json() preserved = client.get(path).json()
assert preserved["name"] == "shared" assert preserved["name"] == "shared"
assert preserved["scene"]["elements"][0]["text"] == "first" assert preserved["scene"]["layers"][1]["elements"][0]["text"] == "first"
assert preserved["revision"] == current["revision"] assert preserved["revision"] == current["revision"]
@@ -467,7 +467,7 @@ def test_template_migration_write_failure_rolls_back_every_record(tmp_path, monk
[ [
(("scene", "version"), True), (("scene", "version"), True),
(("scene", "width"), 64.0), (("scene", "width"), 64.0),
(("scene", "elements", 0, "size"), True), (("scene", "layers", 1, "elements", 0, "size"), True),
], ],
) )
def test_current_template_schema_rejects_noncanonical_json_types( def test_current_template_schema_rejects_noncanonical_json_types(
@@ -492,7 +492,7 @@ def test_current_template_schema_rejects_noncanonical_json_types(
original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8") original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
record_path.write_bytes(original) record_path.write_bytes(original)
with pytest.raises(TemplateValidationError, match="must be an integer"): with pytest.raises(TemplateValidationError, match="integer|fields or dimensions"):
TemplateStore(data_dir) TemplateStore(data_dir)
assert record_path.read_bytes() == original assert record_path.read_bytes() == original
@@ -70,17 +70,18 @@ def test_generated_ui_copy_catalog_is_current_and_covers_static_and_dynamic_copy
"copy.ff765a69c8b49cee": "全白(高亮度会导致严重耗电)", "copy.ff765a69c8b49cee": "全白(高亮度会导致严重耗电)",
} }
finalized_hidden = { finalized_hidden = {
"copy.0a2bacf6cf154355", "copy.18abf73b22c104c1", "copy.209c8a51007fadfb", "copy.0a2bacf6cf154355", "copy.18abf73b22c104c1", "copy.a44cae35bcb5eebe",
"copy.4a2ff24bc62e26ef", "copy.55db93bad76b7e74", "copy.56f1193ee4a7192d", "copy.55db93bad76b7e74", "copy.56f1193ee4a7192d",
"copy.5e26a038197f59b8", "copy.62029c969ffb9c9d", "copy.670c95eddb9bac43", "copy.62029c969ffb9c9d", "copy.670c95eddb9bac43",
"copy.99050fd7437476f6", "copy.b0e7bd7e96cbe4d3", "copy.d9541ebf887002a5", "copy.99050fd7437476f6", "copy.b0e7bd7e96cbe4d3", "copy.d9541ebf887002a5",
"copy.dynamic.board.composition_eyebrow", "copy.dynamic.board.composition_mode", "copy.dynamic.board.composition_eyebrow", "copy.dynamic.board.composition_mode",
"copy.dynamic.board.composition_title", "copy.dynamic.board.monitor_eyebrow", "copy.dynamic.board.composition_title", "copy.dynamic.board.monitor_eyebrow",
"copy.dynamic.board.monitor_mode", "copy.efdd13bfd5037544", "copy.dynamic.board.monitor_mode",
} }
assert {copy_id: catalog["items"][copy_id]["text"] for copy_id in finalized_text} == finalized_text assert {copy_id: catalog["items"][copy_id]["text"] for copy_id in finalized_text} == finalized_text
assert all(catalog["items"][copy_id]["render"].get("default_hidden") is True for copy_id in finalized_hidden) assert all(catalog["items"][copy_id]["render"].get("default_hidden") is True for copy_id in finalized_hidden)
assert len(finalized_text) + len(finalized_hidden) == 34 # The separate text workspace's title, eyebrow and angle were removed.
assert len(finalized_text) + len(finalized_hidden) == 31
def test_normal_build_has_no_editor_entrypoints(tmp_path): def test_normal_build_has_no_editor_entrypoints(tmp_path):
@@ -211,6 +211,7 @@ def _run_frontend_tests(env: dict[str, str]) -> None:
"tests/test_frp_ui.mjs", "tests/test_frp_ui.mjs",
"tests/test_interaction_feedback.mjs", "tests/test_interaction_feedback.mjs",
"tests/test_scene_model.mjs", "tests/test_scene_model.mjs",
"tests/test_scene_layers.mjs",
"tests/test_settings_ui.mjs", "tests/test_settings_ui.mjs",
"tests/test_template_management_ui.mjs", "tests/test_template_management_ui.mjs",
"tests/test_ui_copy_editor.mjs", "tests/test_ui_copy_editor.mjs",
@@ -65,6 +65,9 @@ NetworkManager DNS 往返:`tests/test_wifi.py::test_nmcli_multiple_dns_can_rou
| `TEST-ADC-HARDWARE` | `HW-SCREEN-VOLTAGE`、校准、故障恢复 | 核桃派 + ADC | 接线/万用表停顿 | | `TEST-ADC-HARDWARE` | `HW-SCREEN-VOLTAGE`、校准、故障恢复 | 核桃派 + ADC | 接线/万用表停顿 |
| `TEST-LOW-VOLTAGE` | 限亮、图标、恢复 | 实屏 + ADC + 可调电源 | 每一档停顿 | | `TEST-LOW-VOLTAGE` | 限亮、图标、恢复 | 实屏 + ADC + 可调电源 | 每一档停顿 |
| `TEST-CLEANUP` | 临时项、持久数据、GPIO 安全状态 | 本机和核桃派 | 否 | | `TEST-CLEANUP` | 临时项、持久数据、GPIO 安全状态 | 本机和核桃派 | 否 |
| `TEST-LAYERS` | WEB-LAYERS、扩展画布、图层复制与跨端输出 | Node/Python/浏览器/核桃派 | 否 |
| `TEST-LAYER-OVERFLOW` | 黑色笔迹、背景、候选确认/取消、文字裁切与撤销 | Node/Python/浏览器 | 否 |
| `TEST-SCENE-V2-MIGRATION` | 草稿/模板/动图迁移、摘要、原子批量提交 | Node/Python及持久副本 | 否 |
| `TEST-INDEPENDENCE` | 单目录可拿走、无外部依赖 | 隔离临时副本 | 否 | | `TEST-INDEPENDENCE` | 单目录可拿走、无外部依赖 | 隔离临时副本 | 否 |
| `TEST-LOCAL-ENV` | `DEPLOY-WORKSPACE-LAYOUT` 项目专用环境、路径刷新和 Git 边界 | Windows 本机及中文/空格隔离副本 | 否 | | `TEST-LOCAL-ENV` | `DEPLOY-WORKSPACE-LAYOUT` 项目专用环境、路径刷新和 Git 边界 | Windows 本机及中文/空格隔离副本 | 否 |
@@ -315,3 +318,37 @@ TEST-MOBILE-INTEGRATION 增补 MOBILE-STATUS/DISCOVERY/PREVIEW 对应的 AND
## Android 选测入口 ## Android 选测入口
遵循 [按影响选测](../../移动端相关内容/安卓app/如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。 遵循 [按影响选测](../../移动端相关内容/安卓app/如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。
## 多图层与扩展画布(TEST-LAYERS / TEST-LAYER-OVERFLOW / TEST-SCENE-V2-MIGRATION)
直接影响画图工具、文字编辑、草稿、模板/动图存储和共享库渲染。运行完整 `local_test.py test --suite all`、全部Node `tests/*.mjs`;定向文件为 `test_scene_layers.py`、`test_scene_layers.mjs`、场景/画笔模型、模板、动图、配置、媒体导入和手机业务契约测试。驱动、BLE线格式和App未改:不选Android真机、摄像头、实屏图案或ADC。
浏览器使用隔离mock数据及新context,按以下状态核验:
1. 清空按钮下图层面板可达;新建/复制置顶并选中,命名和排序互不干扰;至少一个画笔层,文字层可删空。每个文字层可有多段文字,切层工具和选中元素隔离。
2. 空白透出下层,黑色遮挡;背景各层独立且随层位移,擦除/清空只删笔迹。导入每帧仅一个画笔层。
3. 扩展开关默认关闭、刷新记忆,开关往返不改内容;192外围可编辑,实际64边框清晰。普通预览、保存后缩略图、播放、导出/应用只取中央区域。
4. 图层移动输入只在回车/失焦提交,各轴±1/±10逐次提交,±64限位提示;拖动预览不写草稿,松手确认。切到视线模式仍可操作移动面板,平移/捏合/滚轮不改偏移或草稿。
5. 背景越界不报警、黑色笔迹报警、移出中央不报警;越界取消保留原稿,确认只清越界内容。文字部分裁切、整段删除、移回不恢复,字号/编辑/保存重载保留裁切,撤销完整恢复。切层后撤销对象正确,成功保存按既有检查点规则清空历史。
6. 复制当前板、静态模板、动图单帧/多帧/全帧,副本独立置顶;未选帧/原层/帧序/时长不变。脏目标先保存/放弃/取消;过期If-Match冲突,模拟场景/缩略图/索引写入失败后重启检查原帧均不变。
7. 桌面、390px和320px窄屏分别检查工具、文字字段、复制对话框、悬浮菜单滚动及触摸输入;菜单不能溢出视口,所有按钮通过滚动可达。PNG及叠层像素由Node/Pillow逐字节对比;迁移前后64输出相同。
部署使用凭据门禁、远端工具检查及 `update_walnutpi.sh`。在持久数据副本迁移后才切换,健康失败恢复原程序/数据;比对所有既有内容中央输出、帧序和时长。部署后只用接口/网页逻辑验证,读取真实驱动统计,不发送诊断图案。测试创建内容仅用独立ID,验证后删除并核验;临时载荷精确清理。保持VERSION,不导出OTA/IMG。
排障经验:合并文字工作区后文案选择器会改变,重建目录并更新测试中实际迁移的选择器,不能绕过目录校验。文字位置输入必须与字号/文本的input即时更新分开,位置仅change/回车提交。复制控件遵守 `runControlAction(control, options, action)`,目标修订号在选择时捕获,不能在提交前重新读取而悄悄接受并发修改。浏览器原生确认测试使用单一dialog处理器,每步后移除或切换策略;未触发的一次性监听器会残留,不得让两个处理器同时确认同一弹窗。Windows曾出现一次无关WiFi原子替换PermissionError,单测及完整重测通过,根因未证实,不改业务逻辑。
浏览器自动化入口:`核桃派软件源代码/scripts/check_layers_browser.mjs` 导出 `checkLayersBrowser(page, baseUrl)`。在Playwright交互REPL中使用已有浏览器的新context及隔离mock服务,动态导入该文件并调用;不安装或固定开发机浏览器路径。该入口覆盖图层增删/复制/排序/命名、多文字隔离、背景、位置输入提交、部分裁切/整段删除/撤销、笔迹越界确认/取消、视线不改草稿、扩展开关记忆及重载。触摸和320/390布局另用已有Playwright context验证。
独立副本验证:复制源码、需求及本机harness到隔离中文/空格目录,不复制venv/缓存/数据或凭据;以现有解释器运行复制目录中的pytest和全部Node测试,设置 `MATRIX_SOURCE_ONLY_UPDATE_TESTS=1` 仅表示不含发行载荷,跳过项逐项记录。隔离环境不可作为便携venv复用。出现Windows原子替换/目录rename的偶发WinError5时保留首轮证据,排除并发和进程占用后重测;仅在完整重测通过后签收,不把未证实原因固化成根因。
scene迁移比对经验:内容摘要/修订改变会让手机 `mobile/previews/<64位SHA256>.png` 缓存过期,既有 `PreviewCache.reconcile` 在新内容列表建立后清理。比对时先逐帧核验64×64像素、名称、顺序/时长、业务配置和其余持久文件,缓存例外只允许严格命名且不属于当前有效修订的旧PNG;不能泛化忽略整个mobile目录或把用户数据丢失视为缓存变化。
HTML验证经验:服务启动时读取首页模板;修改HTML后重启隔离mock再检查实际DOM,避免把旧服务缓存误判成布局失败。旧导航仅含文字入口、旧草稿未知字段及越界数字均纳入迁移边界测试。
### TEST-LAYERS-UI:颜色区、复制弹窗与移动退出
对应WEB-LAYERS、WEB-EXTENDED-CANVAS。桌面及390/320px分别检查颜色标题与按钮对齐、背景开关20px且整标签可点;复制帧复选框20px、合法长名称换行、弹窗不透明,帧列表独立滚动、操作按钮可达且不横向溢出。复制当前板/独立模板/独立动图多帧,并在脏目标“保存后复制”提示中检查背景与操作。
画笔及文字层移动后分别点击“退出图层移动”,检查仍处于全屏编辑、扩展显示和视线位置不变、面板隐藏、已提交偏移保留且撤销仍能恢复。切到视线后退出同样成立;拖动尚未松手时用键盘激活退出,候选取消且不增加历史。原来的“继续编辑”在移动图层时也应可用。可复用scripts/check_layers_browser.mjs;有持久化目标的UI操作只能针对独立测试ID,设备已有目标仅打开检查并取消,不能修改用户内容。
经验:全局input的width/min-height会作用于原生checkbox,必须以局部高优先级样式指定尺寸、padding及margin;不要改全局表单样式。CSS未定义变量且无fallback会使dialog背景声明失效,应复用已定义的surface变量。DOM颜色区新增子项需显式行列结构,不能依赖原两列grid自动排放。文案目录同时更新并执行--check。截图需避开固定顶栏遮挡,先将颜色标题滚动到顶栏下再裁取颜色区域。独立测试长名称遵守现有长度上限,测试数据创建后立即记录ID,以便后续失败也可精确清理。
@@ -80,3 +80,11 @@ DEVICE-MOBILE-LIBRARY-CACHE/MOBILE-LIBRARY-CACHE:设备协议 1.1 在主版
## 2026-10-01 本机测试环境归属 ## 2026-10-01 本机测试环境归属
项目专用 SSH/编辑器环境、Android 测试系统镜像与 AVD 迁入本机测试目录 `.local/`,搭建入口及锁定依赖进入源码管理,私有主机状态/临时材料不提交。Android 构建入口支持指向项目存储的短期 ASCII 联接;SSH 和 WiFi 测试私有路径改为工作区内。只改变测试基础设施;App、设备公共接口、协议、驱动和部署契约均不变,不发布产品 APK、不部署设备、不推进版本和 FEATURE_UPDATED_AT。 项目专用 SSH/编辑器环境、Android 测试系统镜像与 AVD 迁入本机测试目录 `.local/`,搭建入口及锁定依赖进入源码管理,私有主机状态/临时材料不提交。Android 构建入口支持指向项目存储的短期 ASCII 联接;SSH 和 WiFi 测试私有路径改为工作区内。只改变测试基础设施;App、设备公共接口、协议、驱动和部署契约均不变,不发布产品 APK、不部署设备、不推进版本和 FEATURE_UPDATED_AT。
## 2026-10-09 网页多图层与扩展画布
需求:WEB-LAYERS、WEB-EXTENDED-CANVAS、WEB-LAYER-OVERFLOW、CONFIG-SCENE-V2。设备端内容场景升v2、模板schema v2、动图schema v3、配置v11;新增网页图层追加接口及可选192文字预览。公共库列表、缩略图、默认内容、播放和frame.get仍为64×64;RGB888和BLE/WiFi加密协议1.2不变。App不解析网页编辑场景,无需更新App,无Android真机回归。受影响的共享内容库、播放和手机协议由Python契约回归覆盖;具体通过数量及设备结果见本轮归档。无新板端依赖,不改驱动,不推进VERSION,不导出OTA/IMG。
## 2026-10-09 网页图层UI修正
修正颜色/复选框布局、不透明复制提示和退出图层移动;只影响网页DOM/CSS与编辑交互。scene/模板/动图/配置schema、设备REST/手机协议及驱动边界不变,App无需更新,无Android真机回归。沿用原子部署,保持VERSION,更新FEATURE_UPDATED_AT;验收证据单独归档。