From fa1a563d742a9a65141ab9c3542056d426eea2b9 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:53:18 +0800 Subject: [PATCH 01/19] feat(boxes): add the box resource contract and annotation visibility Authoring and runtime box files stay separate, matching the two COCO libraries. --- docs/box-resources.md | 175 +++++++++++ docs/ok-script-toolkit.example.json | 4 + docs/project-config.md | 11 +- jetbrains | 2 +- media/annotationPanel/app.js | 96 ++++++ media/annotationPanel/index.html | 17 +- media/annotationPanel/style.css | 53 ++++ package.json | 3 +- python/probe_window_config.py | 17 ++ python/tests/test_probe_window_config.py | 17 +- schemas/ok-script-toolkit.schema.json | 12 + scripts/test_box_resource.js | 147 +++++++++ src/boxResourcePure.ts | 369 +++++++++++++++++++++++ src/localization.ts | 15 + src/projectConfig.ts | 11 + src/projectConfigPure.ts | 23 ++ src/screenshotCapture.ts | 7 + 17 files changed, 969 insertions(+), 10 deletions(-) create mode 100644 docs/box-resources.md create mode 100644 scripts/test_box_resource.js create mode 100644 src/boxResourcePure.ts diff --git a/docs/box-resources.md b/docs/box-resources.md new file mode 100644 index 0000000..a80d22f --- /dev/null +++ b/docs/box-resources.md @@ -0,0 +1,175 @@ +# 框资源设计 + +框管理对标现有模板管理,分成两份资源。标注工作副本只给插件编辑;运行时副本才是游戏和代码补全读取的那份。图片、画布和显隐不另起一套。 + +本文是实现依据。ok-neverness-to-everness(下称 ok-nte)里的真实调用是 `self.pos.screen.main_viewport.to_box()`,不是 `self.pos.main_viewport`,也没有 `screen_pos`。 + +## 1. 现有能力 + +工具箱有两条已经分开的模板线。 + +| | 标注资源 | 运行时资源 | +|---|---|---| +| 文件 | `/coco_annotations.json`,默认目录 `ok_templates` | `config.py` 的 `template_matching.coco_feature_json`,缺省探测 `assets/coco_annotations.json` 与 `ok_tasks/assets/coco_annotations.json` | +| 谁读写 | 标注管理(素材面板) | 模板画廊、补全、Hover;ok 框架做模板匹配 | +| 如何过去 | 显式「导出到 assets」。编辑不会自动写运行时文件 | | + +标注编辑器(`AnnotationDialog` / `media/annotationPanel`)已有选择、拖动、八向缩放、增删、缩放、平移、撤销、复制粘贴和跨图导航。内存模型是像素 `xywh`。落盘是 COCO `bbox`。`NormalizedBox` 只服务坐标复制,不落盘,也不是 ok-script 的 `Box`。 + +编辑器此前没有标注列表,也没有显隐。隐藏和删除是同一件事。 + +模板画廊只读运行时 COCO。单击插入 `fL.<名称>`。区域预览是原图加像素 bbox 的动态裁剪,缓存键是图片内容哈希加 bbox。Quick Documentation 和 Hover 使用同一张裁剪图。JetBrains 查找项右侧是尺寸;VS Code 在补全项 resolve 时把图放进 documentation。 + +本仓库没有业务上的 Point 资源,也没有位置表。 + +## 2. ok-nte 的 pos / Box + +位置表在 `src/scene/`,手写 Python。 + +- `self.pos` 是 `PositionMap`,在 `BaseNTETask` 里创建。 +- `self.pos.screen` 是矩形。目前有 `center`、`dialog_icon`、`main_viewport`,四个数是相对整屏的 `(left, top, right, bottom)`。 +- `self.pos.panels.*` 是点击点,两个数,用 `*self.pos.panels.esc.mail` 解包。两点调用 `to_box()` 会抛错。 +- `ScreenRatio.to_box()` 调用 `box_of_screen(..., hcenter=True)`,按当前采集帧换成像素 `ok.Box`。`hcenter` 发生在调用时,不能写进资源。 +- `Box.name` 由描述符写成 `ScreenPosition.<字段>`,只用于调试。查找区域不按这个名字匹配。 +- 约 73 处任务私有 `box_of_screen(...)` 明确不进位置表。框资源不收编它们。 +- 运行时不依赖 IDE 插件。 + +## 3. 两份框资源 + +| | 模板 | 框 | +|---|---|---| +| 标注资源 | `<模板目录>/coco_annotations.json` | `<模板目录>/boxes.json` | +| 运行时资源 | `templates.cocoAnnotations` → `config.py` → 探测 | `boxes.runtime` → `config.py` 的 `boxes_json` → 探测 `src/scene/boxes.json` | +| 编辑入口 | 标注管理 | 框资源管理 | +| 浏览 / 补全 | 模板管理 | 框管理 | +| 从标注到运行时 | 显式导出 | 显式发布 | + +游戏加载器不读 `ok-script-toolkit.json`。插件索引运行时文件时,约定优先于 `config.py`,与模板相同。ok-nte 两处都不写时,两边都用 `src/scene/boxes.json`。 + +`panels` 前缀留给现有点击点。框路径不得占用。 + +## 4. 数据模型 + +标注资源带着原图,供编辑和裁剪: + +```json +{ + "version": 1, + "boxes": [ + { + "path": "screen.main_viewport", + "image": "12.png", + "rect": [0.098400, 0.104200, 0.896100, 0.894400] + } + ] +} +``` + +运行时资源只有几何。`to_box()` 不需要图片: + +```json +{ + "version": 1, + "boxes": [ + { + "path": "screen.main_viewport", + "rect": [0.098400, 0.104200, 0.896100, 0.894400] + } + ] +} +``` + +约定: + +- `path` 是 `self.pos.` 后面的属性路径。至少两段,每段是 Python 标识符。`screen.main_viewport` 对应 `self.pos.screen.main_viewport`。 +- `rect` 是 `[left, top, right, bottom]`,相对这张原图的整幅,范围 0–1。这就是 `ScreenRatio` 的四个数。 +- 序列化按 `path` 排序,矩形固定 6 位小数,两空格缩进,文件末尾换行。改一个框时 diff 停在那个对象。 +- `image` 是模板目录下的文件名,用现有文件名归一化规则对齐。不复制图片,不把裁剪 PNG 写进仓库。 +- 参照图必须是整屏截图。`assets/images` 里的打包裁切块不能当框的原图。 +- `to_box()` 的调试名由 path 推导:`screen` 下的叶子写成 `ScreenPosition.<叶子>`。它不是主键。 + +发布把标注条目投影成 `{path, rect}`,去掉 `image`。发布是整份快照:标注里删掉的框,发布后从运行时消失;若会删掉运行时已有 path,先确认。编辑器保存只写标注资源。 + +## 5. 图片与编辑器 + +框引用标注管理正在用的同一批原图。框资源管理不导入图片、不截图入库、不打包导出。 + +不新写画布。编辑器会话只认识图片和 `id + 标签 + 像素 xywh`。模板适配器的标签是分类名,保存仍写 COCO。框适配器的标签是 path,打开时把 `rect` 换成像素框,写回时: + +- 像素矩形没变的条目保留原来的浮点 `rect`。 +- 变过的条目才用 `left = x / 宽` 重新计算。 + +这样打开后直接保存不会改文件。 + +点不单独做编辑器。条目按 id 工作,以后的点走同一套会话和显隐。 + +## 6. 显隐 + +显隐是编辑器会话状态,按条目标记记在隐藏集合里。不进 COCO,不进两份框文件,不进撤销栈。 + +标注列表始终列出全部条目。画布和命中测试只使用未隐藏的条目。 + +| 操作 | 行为 | +|---|---| +| 显示全部 | 清空隐藏集合 | +| 隐藏全部 | 隐藏当前图上每一条 | +| 只显示当前 | 只留选中项 | +| 勾选 | 逐条改隐藏集合 | + +每张图一份隐藏集合,留在本次编辑会话里。关掉对话框就丢。新图默认全部显示。模板、框、点共用这一个过滤。 + +## 7. 预览、补全、生成 + +框管理和 `self.pos.screen.` 的补全只索引运行时资源。插入文本是 `self.pos.screen.main_viewport.to_box()`。另给一个只复制属性路径的动作。 + +预览图不进运行时文件。用 path 回查标注资源拿 `image`,再按原图和 `rect` 裁剪,复用模板的裁剪缓存。缓存键仍然是内容哈希加像素 bbox,框一改就自然失效。对不上原图时,文档只显示 path 和 rect。 + +文档内容沿用模板:裁剪图、表达式、path、归一化 rect、原图相对路径。JetBrains 可以加一行短坐标 inlay;VS Code 不加,与「模板不做幽灵注释」一致。 + +单个模板生成框:用该标注所在原图把像素 `xywh` 归一化,默认 path 建议 `screen.<分类名>`,已存在则要求换名。多个模板生成包围框只允许同一张原图: + +```text +left = min(x) / 宽 +top = min(y) / 高 +right = max(x + w) / 宽 +bottom = max(y + h) / 高 +``` + +生成结果是普通框,进入标注资源,发布后才进入运行时。 + +## 8. 运行时加载 + +加载放在业务项目的 `ScreenPosition`,不放进插件,也不改 `ok.Box`。 + +`ScreenRatio` 只有 `__get__`。实例上的同名属性会盖住类描述符。JSON 里有的名字用 JSON;没有的名字仍走手写类属性。`to_box()` 继续调用 `box_of_screen(..., hcenter=True)`。 + +现有三个矩形可以一次性导入标注资源并绑定整屏原图。发布前手写属性继续有效,插件的框管理和补全只认运行时 JSON。 + +## 9. 模块 + +| 位置 | 职责 | +|---|---| +| `src/boxResourcePure.ts` 与 `core/BoxResource.kt` | 路径、矩形、发布、显隐。两端语义一致 | +| `core/BoxRuntimePath.kt` 与纯模块中的路径函数 | 运行时文件取值链,对标 `CocoFeaturePath` | +| `schemas/ok-script-toolkit.schema.json` 的 `boxes.runtime` | 约定文件。没有个人偏好层 | +| `python/probe_window_config.py` 的 `boxes_json` | 从 `config.py` 顶层读出运行时路径 | +| 标注编辑器两侧 | 标注列表和显隐 | +| 后续:框资源管理、框管理、补全、从模板生成 | 按第 10 节往下接,复用上面的契约 | + +## 10. 实施顺序 + +1. 数据契约、路径解析、编辑器显隐。 +2. 框资源管理:按原图浏览,用现有编辑器改标注资源。 +3. 发布到运行时文件。 +4. 框管理卡片和文档预览。 +5. `self.pos` 的补全与 Hover。 +6. 单模板生成框、同图多模板包围框。 +7. ok-nte 的 `ScreenPosition` 加载器。三个矩形确认无误后,再手工去掉对应类属性。 + +## 11. 风险 + +- 编辑器改造不能改变模板标注的保存结果。显隐默认全显,撤销不含显隐。 +- 打开即保存不得改写未移动框的浮点。 +- 框若标在裁切块上,归一化结果不是屏幕比例。 +- JSON 与类属性同名时以 JSON 为准。导入并发布后,应在业务项目里删掉对应手写属性,避免两处各改各的。 +- 插件不得把 `hcenter` 算进 `rect`。 diff --git a/docs/ok-script-toolkit.example.json b/docs/ok-script-toolkit.example.json index 9afed48..6e9bcb8 100644 --- a/docs/ok-script-toolkit.example.json +++ b/docs/ok-script-toolkit.example.json @@ -24,6 +24,10 @@ "cocoAnnotations": "assets/coco_annotations.json" }, + "boxes": { + "runtime": "src/scene/boxes.json" + }, + "i18n": { "enabled": true, "langDirectory": "assets/lang", diff --git a/docs/project-config.md b/docs/project-config.md index 2ee358a..14a826c 100644 --- a/docs/project-config.md +++ b/docs/project-config.md @@ -168,12 +168,14 @@ | | `afterConfigImport` | **无此信息** | 按约定试 `src.patches.startup_patches:install_startup_patches` | | `templates` | `directory` | 无(插件侧约定) | IDE 设置 → `ok_templates` | | | `cocoAnnotations` | **6/6 有** | `config.py` 的 `template_matching.coco_feature_json` → 依次探测两个候选 | +| `boxes` | `runtime` | 新字段,现有项目还没有 | `config.py` 顶层 `boxes_json` → `src/scene/boxes.json` | | `i18n` | `enabled` / `langDirectory` / `poDirectory` / `poDomains` | 无 | IDE 设置 → 内置默认 | | `characters` | `projectPath` / `masterFile` / `skillsDirectory` / `localeFile` / `avatarTemplateRegex` | 无 | IDE 设置 → 内置默认 | | `effects` | `file` | 无 | IDE 设置 → `src/data/effects.py` | **接线状态**:全部字段已接线(`templates.directory` / `templates.cocoAnnotations` / -`labelEnum.*` / `i18n` / `characters` / `effects`)。 +`boxes.runtime` / `labelEnum.*` / `i18n` / `characters` / `effects`)。 +`boxes.runtime` 的后半段(读 `config.py`、定位文件)已接上;框管理界面仍按 `docs/box-resources.md` 继续。 **⚠️ 两个同名的 `coco_annotations.json` 不是一回事** —— 接错会静默指向错的文件: @@ -181,10 +183,11 @@ |---|---|---|---| | `assets/coco_annotations.json`(或 config.py 指的别处) | ok 框架加载的**运行时模板库** | `featureData` / `OkProjectDataService` 读,文件监听盯它 | `templates.cocoAnnotations` → config.py → 两个惯例位置 | | `<模板目录>/coco_annotations.json` | 素材面板自己的**标注工作文件** | `templateAssetData` / `TemplateAssetDataService` 读写 | `templates.directory`(**不受** `cocoAnnotations` 影响) | +| `src/scene/boxes.json`(或 `boxes_json` 指的别处) | 业务项目加载的**运行时框** | 路径已能解析;框管理 / 补全尚未读取。加载器在业务项目的 `ScreenPosition` | `boxes.runtime` → config.py → 探测位置 | +| `<模板目录>/boxes.json` | 框资源管理的**标注工作文件** | 契约已定,面板尚未接上 | `templates.directory`(**不受** `boxes.runtime` 影响) | -`cocoAnnotations` 是**唯一**一层"`config.py` 已声明的事实"真正落地的字段 —— -其余字段 `config.py` 要么不声明,要么(`labelEnum.path`)插件至今没读。 -它也是唯一**没有 IDE 设置**的链(没有"个人偏好"层),所以不进溯源面板。 +`cocoAnnotations` 与 `boxes.runtime` 是"`config.py` 已声明的事实"落地的两条链, +都**没有 IDE 设置**(没有"个人偏好"层)。`boxes_json` 在现有项目里还没有,缺席时探测 `src/scene/boxes.json`。 **按字段类型选归一化方式**(做错是**静默**的,所以这里写死): diff --git a/jetbrains b/jetbrains index cb16c63..eae80a0 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit cb16c63efe10a11227463789c8beb91d216f9989 +Subproject commit eae80a05839bb4f3c1d139d17d5ef0859789a2e7 diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index bb55803..5e9003e 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -22,6 +22,10 @@ document.getElementById('prevBtn').title = t('prevImage'); document.getElementById('nextBtn').title = t('nextImage'); document.getElementById('emptyMsg').textContent = t('noImageLoaded'); + document.getElementById('annotationListTitle').textContent = t('annotationListTitle'); + document.getElementById('showAllBtn').textContent = t('showAllAnnotations'); + document.getElementById('hideAllBtn').textContent = t('hideAllAnnotations'); + document.getElementById('onlyCurrentBtn').textContent = t('showOnlyCurrent'); // 归一化坐标(x,y,tox,toy)保留的小数位 const COORD_DECIMALS = 4; @@ -36,6 +40,9 @@ // 标注 let annotations = []; + /** 按图片文件名记住隐藏的分类。不进保存消息,撤销也不恢复。 */ + const hiddenByFile = new Map(); + let listSignature = ''; let nextId = 1; let selectedIdx = -1; let hoveredIdx = -1; @@ -132,6 +139,14 @@ if (drawBtn) { drawBtn.textContent = t('drawBbox'); drawBtn.title = t('drawBboxTooltip'); } if (coordBtn) { coordBtn.textContent = t('copyCoords'); coordBtn.title = t('copyCoordsTooltip') + ' (' + keybindings.copyCoords + ')'; } if (deleteBtn) { deleteBtn.textContent = t('deleteMode'); deleteBtn.title = t('deleteBboxTooltip'); } + const listTitle = document.getElementById('annotationListTitle'); + const showAllBtn = document.getElementById('showAllBtn'); + const hideAllBtn = document.getElementById('hideAllBtn'); + const onlyCurrentBtn = document.getElementById('onlyCurrentBtn'); + if (listTitle) listTitle.textContent = t('annotationListTitle'); + if (showAllBtn) showAllBtn.textContent = t('showAllAnnotations'); + if (hideAllBtn) hideAllBtn.textContent = t('hideAllAnnotations'); + if (onlyCurrentBtn) onlyCurrentBtn.textContent = t('showOnlyCurrent'); updateUndoRedoButtons(); } @@ -198,8 +213,18 @@ } /* ---------- 查找标注 ---------- */ + function hiddenSet() { + const key = imageData && imageData.filename; + if (!key) return new Set(); + if (!hiddenByFile.has(key)) hiddenByFile.set(key, new Set()); + return hiddenByFile.get(key); + } + function isShown(ann) { + return !hiddenSet().has(ann.category); + } function findAnnAt(px, py) { for (let i = annotations.length - 1; i >= 0; i--) { + if (!isShown(annotations[i])) continue; const r = annWidgetRect(annotations[i]); if (rectContains(r, px, py)) return i; } @@ -207,12 +232,71 @@ } function findHandleAt(px, py) { for (let i = annotations.length - 1; i >= 0; i--) { + if (!isShown(annotations[i])) continue; const r = annWidgetRect(annotations[i]); const h = detectHandle(px, py, r); if (h) return { idx: i, handle: h }; } return { idx: -1, handle: null }; } + function syncAnnotationList() { + const rows = document.getElementById('annotationRows'); + const onlyCurrentBtn = document.getElementById('onlyCurrentBtn'); + if (!rows) return; + const hidden = hiddenSet(); + const sig = annotations.map((ann) => ann.id + '\t' + ann.category + '\t' + (hidden.has(ann.category) ? '0' : '1')).join('\n') + '#' + selectedIdx; + if (onlyCurrentBtn) onlyCurrentBtn.disabled = selectedIdx < 0; + if (sig === listSignature) return; + listSignature = sig; + while (rows.firstChild) rows.removeChild(rows.firstChild); + if (!annotations.length) { + const empty = document.createElement('div'); + empty.className = 'panel-hint'; + empty.textContent = t('annotationListEmpty'); + rows.append(empty); + return; + } + annotations.forEach((ann, index) => { + const label = document.createElement('label'); + label.className = 'annotation-row' + (index === selectedIdx ? ' is-selected' : ''); + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = !hidden.has(ann.category); + input.addEventListener('change', () => { + selectedIdx = index; + if (input.checked) hidden.delete(ann.category); + else hidden.add(ann.category); + listSignature = ''; + paint(); + }); + const text = document.createElement('span'); + text.textContent = ann.category; + label.append(input, text); + rows.append(label); + }); + } + function showAllAnnotations() { + hiddenSet().clear(); + listSignature = ''; + paint(); + } + function hideAllAnnotations() { + const hidden = hiddenSet(); + hidden.clear(); + annotations.forEach((ann) => hidden.add(ann.category)); + listSignature = ''; + paint(); + } + function showOnlyCurrent() { + const hidden = hiddenSet(); + const current = annotations[selectedIdx]; + hidden.clear(); + annotations.forEach((ann) => { + if (!current || ann.category !== current.category) hidden.add(ann.category); + }); + listSignature = ''; + paint(); + } /* ---------- 绘制 ---------- */ function paint() { @@ -227,8 +311,11 @@ ctx.drawImage(img, offsetX, offsetY, img.width * scale, img.height * scale); } + syncAnnotationList(); + // 画标注 annotations.forEach((ann, i) => { + if (!isShown(ann)) return; const isSel = i === selectedIdx; const isHov = i === hoveredIdx; const r = annWidgetRect(ann); @@ -893,7 +980,12 @@ showBBoxDialog(ann.category, ann.x, ann.y, ann.w, ann.h, (cat, x, y, w, h) => { if (cat) { pushUndo(); + const previous = ann.category; ann.category = cat; ann.x = x; ann.y = y; ann.w = w; ann.h = h; + if (previous !== cat && hiddenSet().has(previous)) { + hiddenSet().delete(previous); + hiddenSet().add(cat); + } saveAnnotations(); paint(); updateUndoRedoButtons(); } @@ -1010,6 +1102,9 @@ document.getElementById('redoBtn').onclick = () => { redo(); updateUndoRedoButtons(); }; document.getElementById('prevBtn').onclick = () => navigate(-1); document.getElementById('nextBtn').onclick = () => navigate(1); + document.getElementById('showAllBtn').onclick = () => showAllAnnotations(); + document.getElementById('hideAllBtn').onclick = () => hideAllAnnotations(); + document.getElementById('onlyCurrentBtn').onclick = () => showOnlyCurrent(); /* ---------- 接收消息 ---------- */ window.addEventListener('message', (e) => { @@ -1030,6 +1125,7 @@ annotations = msg.annotations || []; nextId = annotations.length ? Math.max(...annotations.map(a => a.id)) + 1 : 1; selectedIdx = -1; hoveredIdx = -1; + listSignature = ''; undoStack = []; redoStack = []; if (msg.imageBase64) { diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html index 623da08..4da0a77 100644 --- a/media/annotationPanel/index.html +++ b/media/annotationPanel/index.html @@ -19,9 +19,20 @@ -
- -
+
+
+ +
+
+
diff --git a/media/annotationPanel/style.css b/media/annotationPanel/style.css index 19423e8..1b205b1 100644 --- a/media/annotationPanel/style.css +++ b/media/annotationPanel/style.css @@ -21,10 +21,63 @@ body { .toolbar .spacer { flex: 1; } .toolbar .info { color: var(--text-muted); font-size: var(--font-xs); white-space: nowrap; } +.editor-body { + flex: 1; + display: flex; + min-height: 0; +} + .canvas-wrap { flex: 1; position: relative; overflow: hidden; + min-width: 0; +} + +.annotation-list { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-sm); + border-left: var(--border-width) solid var(--border); + background: var(--bg-container); + min-height: 0; +} + +.annotation-list-title { + font-size: var(--font-sm); + font-weight: var(--weight-medium); +} + +.annotation-list-actions { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.annotation-list-rows { + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.annotation-row { + display: flex; + align-items: center; + gap: var(--space-sm); + min-height: var(--control-h-sm); + padding: 0 var(--space-xs); + background: var(--bg-row); + border-radius: var(--radius-sm); + font-size: var(--font-sm); +} + +.annotation-row.is-selected { + font-weight: var(--weight-medium); } canvas { diff --git a/package.json b/package.json index 5291272..f9b65d6 100644 --- a/package.json +++ b/package.json @@ -335,7 +335,7 @@ "verify:version": "node scripts/release/verify-version.js", "verify:l10n": "node scripts/release/verify-l10n.js", "release": "sh scripts/release.sh", - "test": "npm run verify:version && npm run verify:l10n && npm run compile && npm run test:webview-html && npm run test:task-launcher && npm run test:console-accountseg && npm run test:task-launcher-executor && npm run test:task-reconcile && npm run test:temp-screenshots && npm run test:annotation-coords && npm run test:annotation-swap && npm run test:asset-swap-picker && npm run test:thumb-content-hash && npm run test:list-editor && npm run test:project-config && npm run test:project-config-schema && npm run test:convention-sources && npm run test:label-enum-guard && npm run test:save-to-assets-flow && npm run test:save-to-assets && npm run test:asset-pack-worker && npm run test:coco-feature-path && npm run test:probe-schemas && npm run test:probe-gui-group-names && npm run test:probe-qt-layer && npm run test:probe-multi-account-path && npm run test:probe-window-config && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-project-config && npm run test:executor-gconfig && npm run test:project-store && npm run test:project-runtime", + "test": "npm run verify:version && npm run verify:l10n && npm run compile && npm run test:webview-html && npm run test:task-launcher && npm run test:console-accountseg && npm run test:task-launcher-executor && npm run test:task-reconcile && npm run test:temp-screenshots && npm run test:annotation-coords && npm run test:annotation-swap && npm run test:asset-swap-picker && npm run test:thumb-content-hash && npm run test:list-editor && npm run test:project-config && npm run test:project-config-schema && npm run test:convention-sources && npm run test:label-enum-guard && npm run test:save-to-assets-flow && npm run test:save-to-assets && npm run test:asset-pack-worker && npm run test:coco-feature-path && npm run test:box-resource && npm run test:probe-schemas && npm run test:probe-gui-group-names && npm run test:probe-qt-layer && npm run test:probe-multi-account-path && npm run test:probe-window-config && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-project-config && npm run test:executor-gconfig && npm run test:project-store && npm run test:project-runtime", "test:webview-html": "node scripts/test_webview_html.js", "test:task-launcher": "node scripts/test_task_launcher_subconfigs.js", "test:console-accountseg": "node scripts/test_console_accountseg.js", @@ -353,6 +353,7 @@ "test:label-enum-guard": "node scripts/test_label_enum_guard.js", "test:save-to-assets-flow": "node scripts/test_save_to_assets_flow.js", "test:coco-feature-path": "node scripts/test_coco_feature_path.js", + "test:box-resource": "node scripts/test_box_resource.js", "test:save-to-assets": "node scripts/test_save_to_assets.js", "test:asset-pack-worker": "node scripts/test_asset_pack_worker.js", "test:probe-schemas": "python python/tests/test_probe_pure_group_labels.py", diff --git a/python/probe_window_config.py b/python/probe_window_config.py index 127ccee..46cfe77 100644 --- a/python/probe_window_config.py +++ b/python/probe_window_config.py @@ -34,6 +34,8 @@ # `os.path.join("assets", "coco_annotations.json")`。 TEMPLATE_MATCHING_SUB_KEYS = ("coco_feature_json",) TEMPLATE_TAB_SUB_KEYS = ("label_enum_relative_path",) +# config 顶层的运行时框文件。现有项目还没有这项,缺席时插件探测 src/scene/boxes.json。 +ROOT_BOXES_KEY = "boxes_json" def _resolve_config_path_from_main(project_dir): @@ -147,6 +149,19 @@ def _extract_template_matching_keys(config_path): return _extract_sub_dict(_find_config_dict(tree), "template_matching", TEMPLATE_MATCHING_SUB_KEYS) +def _extract_root_key(config_path, name): + """从顶层 config dict 取出一个键。框文件不放进 template_matching。""" + with open(config_path, encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=config_path) + config_dict = _find_config_dict(tree) + if not config_dict: + return None + for key, value in zip(config_dict.keys, config_dict.values): + if isinstance(key, ast.Constant) and key.value == name: + return _extract_value(value) + return None + + def _extract_template_tab_keys(config_path): """Read the enum module path used by the project's own template tab.""" with open(config_path, encoding="utf-8") as f: @@ -241,6 +256,7 @@ def main(): window_config = {k: _clean(v) for k, v in window_config.items()} coco_feature_json = _clean(template_matching.get("coco_feature_json")) label_enum_relative_path = _clean(template_tab.get("label_enum_relative_path")) + boxes_json = _clean(_extract_root_key(config_path, ROOT_BOXES_KEY)) print(json.dumps({ "ok": True, @@ -248,6 +264,7 @@ def main(): **window_config, "coco_feature_json": coco_feature_json, "label_enum_relative_path": label_enum_relative_path, + "boxes_json": boxes_json, }, ensure_ascii=False)) sys.exit(0) diff --git a/python/tests/test_probe_window_config.py b/python/tests/test_probe_window_config.py index 3655f5b..e79aa8c 100644 --- a/python/tests/test_probe_window_config.py +++ b/python/tests/test_probe_window_config.py @@ -154,7 +154,22 @@ def probe(text, at="src/config.py"): no_config = probe('config = {"windows": {}}\n', at="nothing/here.py") check(no_config.get("ok") is False, "找不到 config.py 时 ok=false(插件据此走兜底,不报错)") -# ── 4. 找不到 config.py 时的路径解析 ──────────────────────────────── +# ── 4. 运行时框文件是顶层 boxes_json,不进 template_matching ──────── +print("\nboxes_json") + +boxes = probe( + 'import os\n' + 'config = {\n' + ' "boxes_json": os.path.join("src", "scene", "boxes.json"),\n' + ' "template_matching": {"coco_feature_json": "assets/coco_annotations.json"},\n' + '}\n' +) +check(boxes.get("boxes_json") == "src/scene/boxes.json", "顶层 boxes_json 用 os.path.join 能静态求值") +check(boxes.get("coco_feature_json") == "assets/coco_annotations.json", "读框路径时模板库路径仍在") +absent_boxes = probe('config = {"template_matching": {"coco_feature_json": "assets/c.json"}}\n') +check(absent_boxes.get("boxes_json") is None, "没写 boxes_json 时输出 null,插件退回 src/scene/boxes.json") + +# ── 5. 找不到 config.py 时的路径解析 ──────────────────────────────── print("\n找不到 config.py") empty_dir = make_tmp_dir("ok-probe-empty") diff --git a/schemas/ok-script-toolkit.schema.json b/schemas/ok-script-toolkit.schema.json index 39a59f5..e193b3b 100644 --- a/schemas/ok-script-toolkit.schema.json +++ b/schemas/ok-script-toolkit.schema.json @@ -125,6 +125,18 @@ } }, + "boxes": { + "type": "object", + "additionalProperties": false, + "description": "框资源。标注工作文件固定是 `/boxes.json`,不由本组控制。本组只声明游戏和补全读取的那份运行时位置表。见 docs/box-resources.md。", + "properties": { + "runtime": { + "type": "string", + "description": "运行时框文件,相对项目根。业务项目的 ScreenPosition 加载它,插件的框管理和补全也读它。\n\n⚠️ 别和 `<模板目录>/boxes.json` 搞混。那是框资源管理的标注工作文件,路径由 `templates.directory` 派生。\n\n没有对应的 IDE 设置。取值链:本字段 → config.py 顶层 `boxes_json` → `src/scene/boxes.json`。通常不必写。" + } + } + }, + "effects": { "type": "object", "additionalProperties": false, diff --git a/scripts/test_box_resource.js b/scripts/test_box_resource.js new file mode 100644 index 0000000..af83967 --- /dev/null +++ b/scripts/test_box_resource.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * 框资源纯契约测试(`src/boxResourcePure.ts`)。 + * + * 钉住和模板两份 COCO 同一类的不变量: + * 1. 运行时路径是 约定 > config.py > src/scene/boxes.json,首选存在时不合并探测位置; + * 2. 序列化按 path 排序、矩形 6 位小数,改一个框不会重排其余对象; + * 3. 像素没变时写回保留原浮点; + * 4. 发布丢掉 image,显隐不进资源。 + */ +const assert = require('assert'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const pure = require(path.join(root, 'out', 'boxResourcePure')); +const config = require(path.join(root, 'out', 'projectConfigPure')); + +const failures = []; +function check(condition, message) { + if (condition) console.log(` ok ${message}`); + else { + console.log(` FAIL ${message}`); + failures.push(message); + } +} + +const ROOT = path.join('X:', 'proj'); +const PROBE = path.join(ROOT, 'src', 'scene', 'boxes.json'); + +const plan = (declared, fromConfigPy) => pure.resolveBoxRuntimePlan(ROOT, declared, fromConfigPy); + +check(plan().layer === 'probe' && plan().preferred === undefined, '都没声明时来源是探测'); +check(plan().probeCandidates.length === 1 && plan().probeCandidates[0] === PROBE, '探测位置是 src/scene/boxes.json'); +check(plan('custom/boxes.json').layer === 'convention', '项目约定 boxes.runtime 生效'); +check(plan(undefined, 'assets/boxes.json').layer === 'configPy', '只有 config.py 的 boxes_json 时来源是 configPy'); +check(plan('custom/boxes.json', 'assets/boxes.json').layer === 'convention', '约定压过 config.py'); +check(plan(' ', ' ').layer === 'probe', '空白声明等同于没写'); + +const preferred = plan('custom/boxes.json'); +check( + pure.effectiveBoxRuntimeFile(preferred, () => true) === path.join(ROOT, 'custom', 'boxes.json'), + '首选存在时只用首选,不把探测位置并进来', +); +check(pure.effectiveBoxRuntimeFile(plan(), () => false) === undefined, '文件都不存在时读取结果为空'); +check(pure.runtimeWriteTarget(plan()) === PROBE, '未声明时发布目标就是探测位置'); +check( + pure.runtimeWriteTarget(preferred) === path.join(ROOT, 'custom', 'boxes.json'), + '已声明时发布目标是首选,即使文件还没创建', +); + +check(pure.boxPathError('screen.main_viewport') === undefined, 'screen.main_viewport 合法'); +check(pure.boxPathError('main_viewport') === 'shallow', '少一层 group 不合法'); +check(pure.boxPathError('panels.esc.mail') === 'reserved', 'panels 留给点击点'); +check(pure.boxPathError('screen.bad-name') === 'segment', '非法标识符拒绝'); +check(pure.boxPathError('') === 'empty', '空路径拒绝'); +check(pure.imageFileName('ok_templates/12.png') === '12.png', 'image 只留文件名'); + +const original = [0.0984, 0.1042, 0.8961, 0.8944]; +const pixel = pure.rectToPixel(original, 2560, 1440); +const kept = pure.rectForSave(original, pixel, 2560, 1440); +check(kept === original, '像素没变时保留原浮点数组,打开后保存不会重算'); +const moved = { ...pixel, x: pixel.x + 4 }; +const rewritten = pure.rectForSave(original, moved, 2560, 1440); +check(rewritten !== original && rewritten[0] !== original[0], '像素变了才重新归一化'); + +const union = pure.unionOnImage( + [{ x: 10, y: 20, w: 30, h: 40 }, { x: 50, y: 10, w: 20, h: 15 }], + 100, + 100, +); +check( + union[0] === 0.1 && union[1] === 0.1 && union[2] === 0.7 && union[3] === 0.6, + '同图包围框取 min left/top 与 max right/bottom', +); +check(pure.unionOnImage([], 100, 100) === undefined, '空列表没有包围框'); + +const authoring = { + version: 1, + boxes: [ + { path: 'screen.main_viewport', image: '12.png', rect: [0.0984, 0.1042, 0.8961, 0.8944] }, + { path: 'screen.dialog_icon', image: '3.png', rect: [0.845, 0.047, 0.975, 0.074] }, + ], +}; +const text = pure.serializeAuthoring(authoring); +const expected = [ + '{', + ' "version": 1,', + ' "boxes": [', + ' {', + ' "path": "screen.dialog_icon",', + ' "image": "3.png",', + ' "rect": [0.845000, 0.047000, 0.975000, 0.074000]', + ' },', + ' {', + ' "path": "screen.main_viewport",', + ' "image": "12.png",', + ' "rect": [0.098400, 0.104200, 0.896100, 0.894400]', + ' }', + ' ]', + '}', + '', +].join('\n'); +check(text === expected, '标注资源按 path 排序,矩形固定 6 位小数'); + +const parsed = pure.parseAuthoring(text); +check(parsed.errors.length === 0 && parsed.file.boxes.length === 2, '刚写出的文件能原样读回'); +check(parsed.file.boxes[0].path === 'screen.dialog_icon', '读回后仍按 path 排序'); + +const runtimeText = pure.serializeRuntime(pure.publishBoxes(parsed.file)); +check(!runtimeText.includes('"image"'), '发布结果不含 image'); +check(runtimeText.includes('"path": "screen.dialog_icon"'), '发布保留 path 和 rect'); + +const duplicate = pure.parseAuthoring(JSON.stringify({ + version: 1, + boxes: [ + { path: 'screen.a', image: '1.png', rect: [0, 0, 0.5, 0.5] }, + { path: 'screen.a', image: '2.png', rect: [0, 0, 0.2, 0.2] }, + ], +})); +check(duplicate.file.boxes.length === 1 && duplicate.file.boxes[0].image === '1.png', '重复 path 保留先出现的那条'); +check(duplicate.errors.some((item) => item.endsWith(':duplicate')), '重复 path 记一条错误'); + +const bad = pure.parseRuntime('{'); +check(bad.errors[0] === 'json' && bad.file.boxes.length === 0, '坏 JSON 给出空运行时文件而不是抛异常'); + +const hidden = pure.applyVisibility(['a', 'b'], new Set(), 'hideAll'); +check(hidden.has('a') && hidden.has('b'), '隐藏全部覆盖当前条目'); +const shown = pure.applyVisibility(['a', 'b'], hidden, 'showAll'); +check(shown.size === 0, '显示全部清空隐藏集合'); +const only = pure.applyVisibility(['a', 'b'], shown, 'only', 'b'); +check(only.has('a') && !only.has('b'), '只显示当前留下选中项'); +const toggled = pure.applyVisibility(['a', 'b'], only, 'toggle', 'a'); +check(!toggled.has('a') && !toggled.has('b'), '再勾一次把隐藏去掉'); +check(pure.isAnnotationVisible('b', toggled), '不在隐藏集合里的条目可见'); + +const statuses = pure.publishStatus(parsed.file, pure.parseRuntime(runtimeText).file); +check(statuses.every((item) => item.status === 'same'), '刚发布的运行时与标注几何一致'); + +const declared = config.boxesRuntimeOf({ boxes: { runtime: 'src/scene/boxes.json' } }); +check(declared === 'src/scene/boxes.json', '约定文件的 boxes.runtime 归一化后可读'); +check(config.boxesRuntimeOf({ boxes: { runtime: 42 } }) === undefined, '类型不对当没写'); + +if (failures.length) { + console.error(`\n${failures.length} failed`); + process.exit(1); +} +console.log('\nbox resource pure ok'); diff --git a/src/boxResourcePure.ts b/src/boxResourcePure.ts new file mode 100644 index 0000000..1c5315c --- /dev/null +++ b/src/boxResourcePure.ts @@ -0,0 +1,369 @@ +/** + * 框资源的纯数据契约。与 JetBrains 侧 `core/BoxResource.kt`、`core/BoxRuntimePath.kt` 一一对应。 + * + * 两份文件,关系对标模板的两份 COCO: + * + * | 文件 | 是什么 | 谁读写 | + * |---|---|---| + * | `<模板目录>/boxes.json` | 框标注资源(带原图文件名) | 框资源管理 | + * | `src/scene/boxes.json`(或约定 / config.py 指定的别处) | 运行时位置表 | 框管理、补全;业务项目 `ScreenPosition` 加载 | + * + * 设计见 `docs/box-resources.md`。本模块不读盘、不 import `vscode`。 + */ +import * as path from 'path'; + +export const BOX_FILE_VERSION = 1; +export const BOX_RECT_DECIMALS = 6; +export const AUTHORING_FILE_NAME = 'boxes.json'; + +/** 运行时文件的探测位置(相对项目根)。ok-nte 的位置表就在这里。 */ +export const PROBE_BOX_CANDIDATES = ['src/scene/boxes.json']; + +/** 留给现有点击点,框路径不能占用这个根。 */ +export const RESERVED_BOX_ROOTS = ['panels']; + +export type BoxRect = [number, number, number, number]; + +export interface AuthoringBox { + path: string; + image: string; + rect: BoxRect; +} + +export interface RuntimeBox { + path: string; + rect: BoxRect; +} + +export interface AuthoringFile { + version: number; + boxes: AuthoringBox[]; +} + +export interface RuntimeFile { + version: number; + boxes: RuntimeBox[]; +} + +export interface PixelBox { + x: number; + y: number; + w: number; + h: number; +} + +export interface BoxParseResult { + file: T; + errors: string[]; +} + +export type BoxRuntimeLayer = 'convention' | 'configPy' | 'probe'; + +export interface BoxRuntimePlan { + preferred?: string; + probeCandidates: string[]; + layer: BoxRuntimeLayer; +} + +const SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function emptyAuthoringFile(): AuthoringFile { + return { version: BOX_FILE_VERSION, boxes: [] }; +} + +export function emptyRuntimeFile(): RuntimeFile { + return { version: BOX_FILE_VERSION, boxes: [] }; +} + +/** 标注资源路径。跟随模板目录,与 `<模板目录>/coco_annotations.json` 同一规则。 */ +export function authoringFile(rootDir: string, templatesDirectory: string): string { + return path.join(rootDir, templatesDirectory, AUTHORING_FILE_NAME); +} + +function toAbsolute(rootDir: string, value: string): string { + return path.isAbsolute(value) ? value : path.join(rootDir, value); +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * 运行时框文件的取值链,对标 `resolveCocoFeaturePlan`: + * 项目约定 `boxes.runtime` > `config.py` 的 `boxes_json` > `src/scene/boxes.json`。 + */ +export function resolveBoxRuntimePlan( + rootDir: string, + declared?: string, + fromConfigPy?: string, +): BoxRuntimePlan { + const probeCandidates = PROBE_BOX_CANDIDATES.map((rel) => path.join(rootDir, rel)); + const declaredPath = nonEmpty(declared); + if (declaredPath) { + return { preferred: toAbsolute(rootDir, declaredPath), probeCandidates, layer: 'convention' }; + } + const fromPy = nonEmpty(fromConfigPy); + if (fromPy) { + return { preferred: toAbsolute(rootDir, fromPy), probeCandidates, layer: 'configPy' }; + } + return { probeCandidates, layer: 'probe' }; +} + +/** 首选存在时只用首选。都不存在时返回空,调用方改用 [runtimeWriteTarget] 创建。 */ +export function effectiveBoxRuntimeFile(plan: BoxRuntimePlan, exists: (file: string) => boolean): string | undefined { + if (plan.preferred && exists(plan.preferred)) return plan.preferred; + return plan.probeCandidates.find(exists); +} + +/** 发布时要写入的那个文件:已声明的首选,否则探测位置。文件可以尚不存在。 */ +export function runtimeWriteTarget(plan: BoxRuntimePlan): string { + return plan.preferred ?? plan.probeCandidates[0]; +} + +export function boxRuntimeRelPaths(plan: BoxRuntimePlan, rootDir: string): string[] { + const all = plan.preferred ? [plan.preferred, ...plan.probeCandidates] : plan.probeCandidates; + return [...new Set(all)] + .map((abs) => path.relative(rootDir, abs).replace(/\\/g, '/')) + .filter((rel) => rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel)); +} + +/** 合法返回 `undefined`。否则返回稳定错误码,供界面翻译。 */ +export function boxPathError(value: string): 'empty' | 'segment' | 'shallow' | 'reserved' | undefined { + const pathValue = value.trim(); + if (!pathValue) return 'empty'; + const segments = pathValue.split('.'); + if (segments.length < 2) return 'shallow'; + if (segments.some((segment) => !SEGMENT.test(segment))) return 'segment'; + if (RESERVED_BOX_ROOTS.includes(segments[0])) return 'reserved'; + return undefined; +} + +export function imageFileName(value: string): string { + const normalized = value.replace(/\\/g, '/').split('/').pop()?.trim() ?? ''; + return normalized; +} + +export function rectToPixel(rect: BoxRect, width: number, height: number): PixelBox | undefined { + if (width <= 0 || height <= 0) return undefined; + const [left, top, right, bottom] = rect; + const x = Math.round(left * width); + const y = Math.round(top * height); + const w = Math.round((right - left) * width); + const h = Math.round((bottom - top) * height); + if (w <= 0 || h <= 0) return undefined; + return { x, y, w, h }; +} + +export function pixelToRect(box: PixelBox, width: number, height: number): BoxRect | undefined { + if (width <= 0 || height <= 0 || box.w <= 0 || box.h <= 0) return undefined; + return [box.x / width, box.y / height, (box.x + box.w) / width, (box.y + box.h) / height]; +} + +export function samePixel(a: PixelBox, b: PixelBox): boolean { + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; +} + +/** + * 写回标注资源时用。像素框没变就保留原来的浮点,避免「打开后直接保存」把文件写脏。 + * 变过才从像素重新归一化。 + */ +export function rectForSave( + original: BoxRect | undefined, + pixel: PixelBox, + width: number, + height: number, +): BoxRect | undefined { + if (original) { + const quantized = rectToPixel(original, width, height); + if (quantized && samePixel(quantized, pixel)) return original; + } + return pixelToRect(pixel, width, height); +} + +/** 同一张原图上的像素框取最小包围矩形,再归一化。空列表返回 `undefined`。 */ +export function unionOnImage(boxes: readonly PixelBox[], width: number, height: number): BoxRect | undefined { + if (boxes.length === 0) return undefined; + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + for (const box of boxes) { + left = Math.min(left, box.x); + top = Math.min(top, box.y); + right = Math.max(right, box.x + box.w); + bottom = Math.max(bottom, box.y + box.h); + } + return pixelToRect({ x: left, y: top, w: right - left, h: bottom - top }, width, height); +} + +export function publishBoxes(file: AuthoringFile): RuntimeFile { + return { + version: BOX_FILE_VERSION, + boxes: file.boxes.map((box) => ({ path: box.path, rect: [...box.rect] as BoxRect })), + }; +} + +export type PublishStatus = 'same' | 'unpublished' | 'runtimeOnly'; + +export function publishStatus(authoring: AuthoringFile, runtime: RuntimeFile): Array<{ path: string; status: PublishStatus }> { + const runtimeByPath = new Map(runtime.boxes.map((box) => [box.path, box])); + const seen = new Set(); + const result: Array<{ path: string; status: PublishStatus }> = []; + for (const box of authoring.boxes) { + seen.add(box.path); + const published = runtimeByPath.get(box.path); + const same = published !== undefined && sameRect(published.rect, box.rect); + result.push({ path: box.path, status: same ? 'same' : 'unpublished' }); + } + for (const box of runtime.boxes) { + if (!seen.has(box.path)) result.push({ path: box.path, status: 'runtimeOnly' }); + } + return result; +} + +function sameRect(a: BoxRect, b: BoxRect): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +export function applyVisibility( + ids: readonly string[], + hidden: ReadonlySet, + action: 'showAll' | 'hideAll' | 'only' | 'toggle', + target?: string, +): Set { + if (action === 'showAll') return new Set(); + if (action === 'hideAll') return new Set(ids); + if (action === 'only') { + const next = new Set(ids); + if (target !== undefined) next.delete(target); + return next; + } + const live = new Set(ids); + const next = new Set([...hidden].filter((id) => live.has(id))); + if (target === undefined || !live.has(target)) return next; + if (next.has(target)) next.delete(target); + else next.add(target); + return next; +} + +export function isAnnotationVisible(id: string, hidden: ReadonlySet): boolean { + return !hidden.has(id); +} + +export function parseAuthoring(text: string): BoxParseResult { + return parseBoxes(text, true); +} + +export function parseRuntime(text: string): BoxParseResult { + const parsed = parseBoxes(text, false); + return { + file: { version: parsed.file.version, boxes: parsed.file.boxes.map(({ path: boxPath, rect }) => ({ path: boxPath, rect })) }, + errors: parsed.errors, + }; +} + +export function serializeAuthoring(file: AuthoringFile): string { + const boxes = uniqueAuthoring(file.boxes); + const body = boxes.map((box) => + [ + ' {', + ` "path": ${JSON.stringify(box.path)},`, + ` "image": ${JSON.stringify(box.image)},`, + ` "rect": [${box.rect.map(formatRectNumber).join(', ')}]`, + ' }', + ].join('\n'), + ).join(',\n'); + return `{\n "version": ${BOX_FILE_VERSION},\n "boxes": [${body ? `\n${body}\n ` : ''}]\n}\n`; +} + +export function serializeRuntime(file: RuntimeFile): string { + const boxes = uniqueRuntime(file.boxes); + const body = boxes.map((box) => + [ + ' {', + ` "path": ${JSON.stringify(box.path)},`, + ` "rect": [${box.rect.map(formatRectNumber).join(', ')}]`, + ' }', + ].join('\n'), + ).join(',\n'); + return `{\n "version": ${BOX_FILE_VERSION},\n "boxes": [${body ? `\n${body}\n ` : ''}]\n}\n`; +} + +function parseBoxes(text: string, requireImage: boolean): BoxParseResult { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return { file: emptyAuthoringFile(), errors: ['json'] }; + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { file: emptyAuthoringFile(), errors: ['root'] }; + } + const record = raw as { version?: unknown; boxes?: unknown }; + const errors: string[] = []; + if (record.version !== BOX_FILE_VERSION) errors.push('version'); + if (!Array.isArray(record.boxes)) { + errors.push('boxes'); + return { file: emptyAuthoringFile(), errors }; + } + const boxes: AuthoringBox[] = []; + const seen = new Set(); + record.boxes.forEach((entry, index) => { + const parsed = parseBoxEntry(entry, requireImage); + if (!parsed.box) { + errors.push(`${index}:${parsed.error}`); + return; + } + if (seen.has(parsed.box.path)) { + errors.push(`${index}:duplicate`); + return; + } + seen.add(parsed.box.path); + boxes.push(parsed.box); + }); + boxes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + return { file: { version: BOX_FILE_VERSION, boxes }, errors }; +} + +function parseBoxEntry(entry: unknown, requireImage: boolean): { box?: AuthoringBox; error?: string } { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { error: 'entry' }; + const record = entry as { path?: unknown; image?: unknown; rect?: unknown }; + if (typeof record.path !== 'string' || boxPathError(record.path)) return { error: 'path' }; + const image = typeof record.image === 'string' ? imageFileName(record.image) : ''; + if (requireImage && !image) return { error: 'image' }; + const rect = parseRect(record.rect); + if (!rect) return { error: 'rect' }; + return { box: { path: record.path.trim(), image, rect } }; +} + +function parseRect(value: unknown): BoxRect | undefined { + if (!Array.isArray(value) || value.length !== 4) return undefined; + if (!value.every((item) => typeof item === 'number' && Number.isFinite(item))) return undefined; + const rect = value as BoxRect; + const [left, top, right, bottom] = rect; + if (left < 0 || top < 0 || right > 1 || bottom > 1) return undefined; + if (left >= right || top >= bottom) return undefined; + return [left, top, right, bottom]; +} + +function uniqueAuthoring(boxes: readonly AuthoringBox[]): AuthoringBox[] { + const seen = new Set(); + return [...boxes] + .filter((box) => { + if (seen.has(box.path)) return false; + seen.add(box.path); + return true; + }) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +function uniqueRuntime(boxes: readonly RuntimeBox[]): RuntimeBox[] { + return uniqueAuthoring(boxes.map((box) => ({ ...box, image: '' }))).map(({ path: boxPath, rect }) => ({ path: boxPath, rect })); +} + +function formatRectNumber(value: number): string { + const rounded = Math.round(value * 1e6) / 1e6; + const normalized = Object.is(rounded, -0) ? 0 : rounded; + return normalized.toFixed(BOX_RECT_DECIMALS); +} diff --git a/src/localization.ts b/src/localization.ts index ab45cad..a5c0868 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -382,6 +382,11 @@ const ZH_CN: WebviewStrings = { skillTypeTalent: '天赋', skillTypePotential: '潜能', noWorkspaceFolder: '未打开工作区', + annotationListTitle: '标注', + showAllAnnotations: '显示全部', + hideAllAnnotations: '隐藏全部', + showOnlyCurrent: '只显示当前', + annotationListEmpty: '没有标注', }; const EN: WebviewStrings = { @@ -499,6 +504,11 @@ const EN: WebviewStrings = { accountStoreSeeOutput: 'See the "ok-script Console" output channel for details.', accountReadFailed: 'Store file could not be parsed (see executor output).', overriddenTasksLabel: 'Overridden tasks', + annotationListTitle: 'Annotations', + showAllAnnotations: 'Show all', + hideAllAnnotations: 'Hide all', + showOnlyCurrent: 'Only current', + annotationListEmpty: 'No annotations', }; const ZH_TW: WebviewStrings = { @@ -586,6 +596,11 @@ const ZH_TW: WebviewStrings = { assetSwapEmpty: '沒有其他圖片可以交換', assetSwapBoxes: '{count} 個標註', assetSwapNoBoxes: '無標註', + annotationListTitle: '標註', + showAllAnnotations: '顯示全部', + hideAllAnnotations: '隱藏全部', + showOnlyCurrent: '只顯示目前', + annotationListEmpty: '沒有標註', refresh: '重新整理', open: '開啟', add: '新增', modify: '修改', delete: '刪除', cancel: '取消', save: '儲存', copy: '複製', close: '關閉', search: '搜尋', loading: '載入中…', none: '無', current: '目前', enabled: '開啟', disabled: '關閉', error: '錯誤', warning: '警告', info: '資訊', charactersTitle: '角色技能管理', charactersTab: '角色與技能', effectsTab: '效果索引', localesTab: '名稱本地化', issuesTab: '資料診斷', searchCharacters: '搜尋角色、技能、效果、描述…', allStars: '全部星級', allElements: '全部元素', allProfessions: '全部職業', allSkillTypes: '全部技能類型', enhancementOnly: '僅顯示含強化組的角色', issueOnly: '僅顯示存在診斷的角色', characterCount: '{shown} / {total} 個角色', skillsEnhancements: '技能 / 強化', noCharacters: '沒有符合的角色', selectCharacter: '從左側選擇角色', openCharacterJson: '開啟角色 JSON', skillsAndEnhancements: '技能與強化效果', addSkill: '新增技能', modifySkill: '修改技能', addEnhancement: '新增強化組', modifyEnhancement: '修改強化組', baseEffects: '基礎效果', triggerEffects: '觸發依賴效果', outputEffects: '強化產出效果', visiblePulse: '可見脈衝', searchEffects: '搜尋效果 ID、描述、角色或技能…', allCategories: '全部分類', allEffects: '全部效果', usedOnly: '僅已引用', unusedOnly: '僅未引用', unknownOnly: '僅未知', addEffectCategory: '新增分類', addEffect: '新增效果', localizationMatrix: '角色名稱多語言矩陣', localizationHint: '空白儲存格表示缺失,可點擊角色開啟語言來源檔案。', missing: '缺失', searchIssues: '搜尋診斷資訊…', allSeverities: '全部級別', openSource: '開啟來源檔案', readingCharacterData: '正在讀取角色、技能、效果與多語言資料…', unknownCurrentEffect: '目前資料中的未知效果', inferredFromTriggerText: '由觸發文字推斷', clickCopySkillId: '點擊複製技能 ID', clickCopyEffectId: '點擊複製效果 ID', openCharacterLocaleFile: '開啟角色名稱語言檔案', loadFailed: '載入失敗', saveFailed: '儲存失敗', charactersMetric: '角色', skillsMetric: '技能', enhancementsMetric: '強化組', effectReferencesMetric: '效果引用', effectDefinitionsMetric: '效果定義', valueLabel: '值', taskTitle: 'ok-script 任務啟動', noTasks: '找不到任務。\n請先在設定中配置專案路徑。', parameters: '參數', launch: '啟動', stop: '停止', pause: '暫停', resume: '恢復', running: '執行中…', toolboxOpenCharacterManager: '開啟角色技能管理面板', templatesSearch: '搜尋模板名稱…', templatesTitle: '模板面板', noTemplates: '找不到任何模板。', noTemplatesWithHint: '找不到任何模板。\n請確認工作區存在 assets/coco_annotations.json\n(或 ok_tasks/assets/coco_annotations.json)。', thumbnailStats: '已載入 {loaded} 個縮圖', thumbnailStatsWithFailures: '已載入 {loaded} 個縮圖 / {failed} 個失敗', thumbnailLoadFailed: '縮圖載入失敗', templateSize: '尺寸:{width}×{height}', templateSource: '來源:{path}', unknownStar: '星級未定', unknownElement: '元素未定', unknownProfession: '職業未定', unknownWeapon: '武器未定', skillsCount: '{count} 個技能', enhancementsCount: '{count} 個強化組', syncedSkillLocked: '同步技能 · ID/名稱/類型/元素/描述已鎖定', enhancedState: '強化態', multiplier: '倍率', stagger: '失衡', cooldown: '冷卻', spiritCost: '技力', noSkills: '此角色尚無技能資料', effectsCount: '{shown} / {total} 個效果', noEffects: '沒有符合的效果', undefinedEffect: '未定義效果', openDefinition: '開啟定義', moreUsages: '另有 {count} 處引用', characterIdColumn: '角色 / ID', issuesCount: '{shown} / {total} 條診斷', noIssuesMatch: '沒有符合的診斷', noIssues: '未發現資料問題', skillId: '技能 ID', skillName: '技能名稱', skillType: '技能類型', element: '元素', damageMultiplier: '傷害倍率', staggerValue: '失衡值', cooldownField: '冷卻', spiritCostField: '技力消耗', skillDescription: '技能描述', baseEffectsMulti: '基礎效果(可多選)', enhancementName: '強化組名稱', visibleMarker: '顯示標記', triggerText: '觸發條件文字', enhancementDescription: '強化效果說明', triggerEffectsMulti: '觸發依賴效果(可多選)', outputEffectsMulti: '強化產出效果(可多選)', effectCategory: '效果分類', effectId: '效果 ID(大寫底線)', effectDescription: '效果描述', categoryName: '分類名稱', effectMultiHint: '按住 Ctrl / Cmd 可多選;選項來自 effects.py,並依效果分類分組。', selectedTriggerEffects: '已選擇 {count} 個觸發依賴效果', noTriggerEffects: '未選擇觸發依賴效果', noSelectedEffects: '未選擇效果', unknownEffect: '未知效果', confirmDelete: '確定要刪除「{name}」嗎?\n儲存前會自動建立 .bak 備份。', copied: '已複製:{text}', collapseParameters: '收合參數', oneTimeTask: '一次性', triggerTask: '觸發任務', enableTrigger: '啟用', triggerDisabled: '未啟用', triggerArmed: '已啟用', triggerEnqueued: '已入列', triggerPolling: '輪詢中', taskQueued: '排隊中', taskRunning: '執行中', executorIdle: '執行器未啟動', executorConnecting: '執行器啟動中…', executorRunning: '執行器執行中 · {count} 個觸發任務已入列', executorPaused: '已暫停(輪詢與任務皆暫停)', startExecutor: '啟動執行器', stopExecutor: '關閉執行器', stopCurrent: '停止目前任務', configGroup: '設定分組', commonParameters: '通用參數', groupParameters: '分組參數', childTaskConfig: '子任務設定', otherParameters: '其他參數', launchSettings: '啟動設定', saved: '已自動儲存', reset: '重設', noConfigParameters: '目前沒有可設定參數(schema 尚未就緒或任務沒有 default_config)。', schemaFailed: '此任務 schema 收集失敗,無法自動產生表單:{error}', holdCtrlMulti: '按住 Ctrl 多選', currentValue: '{value}(目前)', selectedOptionsHint: '可選值:{values}', structuredJsonHint: 'JSON 陣列;支援條件物件與動作序列。', debugOverlay: '除錯浮層', debugOverlayHint: '任務啟動時自動沿用;任務執行中可即時開關。開啟後 draw_boxes 的識別框會繪製到遊戲視窗上(GDI 浮層)。', toolboxGameSection: '遊戲連線', toolboxConnectGame: '連線遊戲', toolboxDisconnect: '中斷', toolboxGameConnected: '已連線:{title}(PID {pid})', toolboxGameNotConnected: '未連線遊戲。連線後任務啟動將優先複用該視窗;遊戲未執行時會自動啟動。', availableOptions: '可用選項', clickOptionToAdd: '點擊選項新增。', selectedOptions: '已選清單', searchOptions: '搜尋選項…', moveUp: '上移', moveDown: '下移', removeItem: '移除', confirm: '確認', addValue: '新增值', taskPaused: '任務已暫停,點擊恢復繼續執行', taskResumed: '任務已恢復執行', stopping: '正在停止任務…', taskStopped: '任務已停止,詳見輸出面板', taskCompleted: '任務完成,詳見輸出面板', taskFailed: '任務異常結束,詳見輸出面板', templatesHint: '單擊=插入 · 雙擊=複製 · 將游標移到縮圖並點擊 👁 檢視原圖', templatesCount: '{shown}/{total} 個模板', noTemplateMatch: '沒有符合「{query}」的模板。', viewOriginal: '檢視原圖(標示位置)', diff --git a/src/projectConfig.ts b/src/projectConfig.ts index 5cd746d..a7c0985 100644 --- a/src/projectConfig.ts +++ b/src/projectConfig.ts @@ -30,6 +30,7 @@ import { labelEnumPathInputError, labelEnumPathResolved, normalizeLabelEnumFile, + boxesRuntimeOf, normalizeRelPath, parseProjectConfig, templatesDirectoryOf, @@ -390,3 +391,13 @@ export function labelEnumClassName(filePath: string, projectDir?: string, scope? export function templatesCocoAnnotationsSetting(projectDir?: string): string | undefined { return normalizeRelPath(templatesOf(loadProjectConfig(projectDir)).cocoAnnotations); } + +/** + * 运行时框文件的项目约定(`boxes.runtime`),已归一化。没声明返回 `undefined`。 + * + * 它指向业务项目加载的那份位置表(`config.py` 的 `boxes_json`,缺省 `src/scene/boxes.json`), + * **不是**框资源管理自己的 `<模板目录>/boxes.json`。见 `docs/box-resources.md`。 + */ +export function boxesRuntimeSetting(projectDir?: string): string | undefined { + return boxesRuntimeOf(loadProjectConfig(projectDir)); +} diff --git a/src/projectConfigPure.ts b/src/projectConfigPure.ts index 9e08f83..410a47e 100644 --- a/src/projectConfigPure.ts +++ b/src/projectConfigPure.ts @@ -25,6 +25,14 @@ export interface ProjectTemplates { cocoAnnotations?: string; } +export interface ProjectBoxes { + /** + * 运行时框文件,相对项目根。 + * 不是 `<模板目录>/boxes.json`(那份由 templates.directory 派生)。 + */ + runtime?: string; +} + export interface ProjectConfig { labelEnum?: ProjectLabelEnum; executor?: { @@ -34,6 +42,7 @@ export interface ProjectConfig { }; }; templates?: ProjectTemplates; + boxes?: ProjectBoxes; i18n?: ProjectI18n; characters?: ProjectCharacters; effects?: ProjectEffects; @@ -151,6 +160,20 @@ export function templatesOf(config: ProjectConfig): ProjectTemplates { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } +/** `boxes` 一组(保证是对象)。 */ +export function boxesOf(config: ProjectConfig): ProjectBoxes { + const value = config.boxes; + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +/** + * 运行时框文件的项目约定(`boxes.runtime`),已归一化。没声明或类型不对返回 `undefined`。 + * 没有个人偏好层。链的后半段在 `boxResourcePure.resolveBoxRuntimePlan`。 + */ +export function boxesRuntimeOf(config: ProjectConfig): string | undefined { + return normalizeRelPath(boxesOf(config).runtime); +} + /** `i18n` 一组(保证是对象)。 */ export function i18nOf(config: ProjectConfig): ProjectI18n { const value = config.i18n; diff --git a/src/screenshotCapture.ts b/src/screenshotCapture.ts index f0230af..065c4b6 100644 --- a/src/screenshotCapture.ts +++ b/src/screenshotCapture.ts @@ -18,6 +18,11 @@ export interface WindowConfig { * 是两个不同的文件。 */ cocoFeatureJson?: string; + /** + * `config.py` 顶层 `boxes_json` —— 运行时框文件。 + * 和 `<模板目录>/boxes.json` 不是同一个文件。见 `docs/box-resources.md`。 + */ + boxesJson?: string; /** Project template tab's enum module path, without a required .py suffix. */ labelEnumRelativePath?: string; } @@ -33,6 +38,7 @@ interface ProbeWindowConfigResult { top_hwnd_class?: string; coco_feature_json?: string | null; label_enum_relative_path?: string | null; + boxes_json?: string | null; } /** 读取 okScriptToolkit 扩展配置中的项目路径和 Python 解释器 */ @@ -80,6 +86,7 @@ export async function probeWindowConfig(projectDir: string, pythonPath: string): // 探针解不出来时给的是 `null`(AST 里掺了变量),这里归一成 `undefined` cocoFeatureJson: parsed.coco_feature_json ?? undefined, labelEnumRelativePath: parsed.label_enum_relative_path ?? undefined, + boxesJson: parsed.boxes_json ?? undefined, }; } return undefined; From c82b6b0a9f7df43c6872cd17499c2bdd7f0bb64f Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:04:52 +0800 Subject: [PATCH 02/19] fix(annotations): select a box from its name and nudge it with arrows The checkbox only toggles visibility. Point the JetBrains submodule at the matching editor fix. --- jetbrains | 2 +- media/annotationPanel/app.js | 52 ++++++++++++++++++++++++++------- media/annotationPanel/style.css | 9 ++++++ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/jetbrains b/jetbrains index eae80a0..bd69fa6 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit eae80a05839bb4f3c1d139d17d5ef0859789a2e7 +Subproject commit bd69fa6922af6a46d3d988534fee4b800191448b diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index 5e9003e..25ab7f4 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -257,24 +257,48 @@ return; } annotations.forEach((ann, index) => { - const label = document.createElement('label'); - label.className = 'annotation-row' + (index === selectedIdx ? ' is-selected' : ''); + const row = document.createElement('div'); + row.className = 'annotation-row' + (index === selectedIdx ? ' is-selected' : ''); const input = document.createElement('input'); input.type = 'checkbox'; input.checked = !hidden.has(ann.category); + input.addEventListener('click', (event) => event.stopPropagation()); input.addEventListener('change', () => { - selectedIdx = index; if (input.checked) hidden.delete(ann.category); else hidden.add(ann.category); listSignature = ''; paint(); }); const text = document.createElement('span'); + text.className = 'annotation-name'; text.textContent = ann.category; - label.append(input, text); - rows.append(label); + text.addEventListener('click', () => { + selectedIdx = index; + listSignature = ''; + paint(); + }); + row.append(input, text); + rows.append(row); }); } + function nudgeSelected(dx, dy) { + const ann = annotations[selectedIdx]; + if (!ann || !isShown(ann)) return false; + let nx = ann.x + dx; + let ny = ann.y + dy; + if (img) { + nx = Math.max(0, Math.min(nx, img.width - ann.w)); + ny = Math.max(0, Math.min(ny, img.height - ann.h)); + } + if (nx === ann.x && ny === ann.y) return true; + pushUndo(); + ann.x = nx; + ann.y = ny; + saveAnnotations(); + paint(); + updateUndoRedoButtons(); + return true; + } function showAllAnnotations() { hiddenSet().clear(); listSignature = ''; @@ -1075,12 +1099,18 @@ } else if (matchKeybinding(e, keybindings.deleteSelected) && selectedIdx >= 0 && mode === 'none') { // 删除选中 deleteSelected(); - } else if (matchKeybinding(e, keybindings.prevImage)) { - // 上一张 - navigate(-1); - } else if (matchKeybinding(e, keybindings.nextImage)) { - // 下一张 - navigate(1); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (selectedIdx >= 0 && mode === 'none' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const step = e.shiftKey ? 10 : 1; + const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0; + const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0; + if (nudgeSelected(dx, dy)) { + e.preventDefault(); + return; + } + } + if (matchKeybinding(e, keybindings.prevImage)) navigate(-1); + else if (matchKeybinding(e, keybindings.nextImage)) navigate(1); } }); diff --git a/media/annotationPanel/style.css b/media/annotationPanel/style.css index 1b205b1..4b3226b 100644 --- a/media/annotationPanel/style.css +++ b/media/annotationPanel/style.css @@ -80,6 +80,15 @@ body { font-weight: var(--weight-medium); } +.annotation-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + canvas { display: block; width: 100%; From c6df741e6ef29cbdba2540e7a7adb35a648fa78d Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:34:13 +0800 Subject: [PATCH 03/19] feat(boxes): add box asset and gallery views The same template images are edited into an authoring file, published to the runtime file, and offered as self.pos completion with a cropped preview. --- jetbrains | 2 +- media/annotationPanel/app.js | 66 ++++++++ media/annotationPanel/index.html | 16 ++ media/boxPanel/app.js | 36 +++++ media/boxPanel/index.html | 21 +++ media/boxPanel/style.css | 13 ++ package.json | 12 ++ package.nls.es.json | 4 + package.nls.ja.json | 4 + package.nls.json | 4 + package.nls.ko.json | 4 + package.nls.zh-cn.json | 4 + package.nls.zh-tw.json | 4 + src/annotationPanel.ts | 24 ++- src/boxPanels.ts | 252 +++++++++++++++++++++++++++++++ src/boxResourceStore.ts | 139 +++++++++++++++++ src/cocoFeaturePath.ts | 9 +- src/extension.ts | 11 +- src/localization.ts | 27 ++++ src/providers.ts | 36 +++++ 20 files changed, 683 insertions(+), 5 deletions(-) create mode 100644 media/boxPanel/app.js create mode 100644 media/boxPanel/index.html create mode 100644 media/boxPanel/style.css create mode 100644 src/boxPanels.ts create mode 100644 src/boxResourceStore.ts diff --git a/jetbrains b/jetbrains index bd69fa6..852b329 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit bd69fa6922af6a46d3d988534fee4b800191448b +Subproject commit 852b3295d0c94cd934a7e825683162fc6125e692 diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index 25ab7f4..60bfdd3 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -26,6 +26,16 @@ document.getElementById('showAllBtn').textContent = t('showAllAnnotations'); document.getElementById('hideAllBtn').textContent = t('hideAllAnnotations'); document.getElementById('onlyCurrentBtn').textContent = t('showOnlyCurrent'); + const generateBoxBtn = document.getElementById('generateBoxBtn'); + if (generateBoxBtn) generateBoxBtn.textContent = t('generateBox'); + const generateTitle = document.getElementById('generateTitle'); + if (generateTitle) generateTitle.textContent = t('generateBoxTitle'); + const generatePathLabel = document.getElementById('generatePathLabel'); + if (generatePathLabel) generatePathLabel.textContent = t('generatePath'); + const generateCancel = document.getElementById('generateCancel'); + if (generateCancel) generateCancel.textContent = t('cancel'); + const generateOk = document.getElementById('generateOk'); + if (generateOk) generateOk.textContent = t('ok'); // 归一化坐标(x,y,tox,toy)保留的小数位 const COORD_DECIMALS = 4; @@ -33,6 +43,7 @@ // 状态 let imageData = null; // { imagePath, imageBase64, annotations, allCategories, filename } + let boxMode = false; let img = null; // HTMLImageElement let scale = 1.0; let fitScale = 1.0; @@ -147,6 +158,8 @@ if (showAllBtn) showAllBtn.textContent = t('showAllAnnotations'); if (hideAllBtn) hideAllBtn.textContent = t('hideAllAnnotations'); if (onlyCurrentBtn) onlyCurrentBtn.textContent = t('showOnlyCurrent'); + const generateBoxBtn = document.getElementById('generateBoxBtn'); + if (generateBoxBtn) generateBoxBtn.textContent = t('generateBox'); updateUndoRedoButtons(); } @@ -299,6 +312,30 @@ updateUndoRedoButtons(); return true; } + function openGenerateBox() { + const choices = document.getElementById('generateChoices'); + const error = document.getElementById('generateError'); + const pathInput = document.getElementById('generatePath'); + if (!choices || !pathInput) return; + while (choices.firstChild) choices.removeChild(choices.firstChild); + if (error) error.textContent = ''; + const selected = annotations[selectedIdx]; + annotations.forEach((ann, index) => { + const label = document.createElement('label'); + label.className = 'annotation-row'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = !selected || index === selectedIdx; + input.dataset.index = String(index); + const text = document.createElement('span'); + text.textContent = ann.category; + label.append(input, text); + choices.append(label); + }); + const seed = selected ? selected.category : (annotations[0] ? annotations[0].category : 'region'); + pathInput.value = 'screen.' + String(seed).replace(/[^A-Za-z0-9_]/g, '_'); + document.getElementById('generateModal').classList.add('visible'); + } function showAllAnnotations() { hiddenSet().clear(); listSignature = ''; @@ -1132,6 +1169,26 @@ document.getElementById('redoBtn').onclick = () => { redo(); updateUndoRedoButtons(); }; document.getElementById('prevBtn').onclick = () => navigate(-1); document.getElementById('nextBtn').onclick = () => navigate(1); + document.getElementById('generateBoxBtn').onclick = () => openGenerateBox(); + document.getElementById('generateCancel').onclick = () => { + document.getElementById('generateModal').classList.remove('visible'); + }; + document.getElementById('generateOk').onclick = () => { + const chosen = []; + document.querySelectorAll('#generateChoices input').forEach((input) => { + if (input.checked) chosen.push(annotations[Number(input.dataset.index)]); + }); + const error = document.getElementById('generateError'); + if (!chosen.length) { + if (error) error.textContent = t('generateNeedSelection'); + return; + } + vscode.postMessage({ + type: 'generateBox', + path: document.getElementById('generatePath').value.trim(), + boxes: chosen.map((ann) => ({ x: ann.x, y: ann.y, w: ann.w, h: ann.h })), + }); + }; document.getElementById('showAllBtn').onclick = () => showAllAnnotations(); document.getElementById('hideAllBtn').onclick = () => hideAllAnnotations(); document.getElementById('onlyCurrentBtn').onclick = () => showOnlyCurrent(); @@ -1147,9 +1204,18 @@ if (typeof msg.copyCoordsSpace === 'boolean') { copyCoordsSpace = msg.copyCoordsSpace; } + boxMode = msg.boxMode === true; + const generateBoxBtn = document.getElementById('generateBoxBtn'); + if (generateBoxBtn) generateBoxBtn.style.display = boxMode ? 'none' : ''; updateButtonTexts(); return; } + if (msg.type === 'generateBoxResult') { + const error = document.getElementById('generateError'); + if (msg.ok) document.getElementById('generateModal').classList.remove('visible'); + else if (error) error.textContent = msg.error || t('generateNeedSelection'); + return; + } if (msg.type === 'load') { imageData = msg; annotations = msg.annotations || []; diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html index 4da0a77..3c32f20 100644 --- a/media/annotationPanel/index.html +++ b/media/annotationPanel/index.html @@ -12,6 +12,7 @@ +
@@ -58,6 +59,21 @@

Bounding Box

+ diff --git a/media/boxPanel/app.js b/media/boxPanel/app.js new file mode 100644 index 0000000..8bd66c8 --- /dev/null +++ b/media/boxPanel/app.js @@ -0,0 +1,36 @@ +(function () { + const I18N = JSON.parse(document.getElementById('boxPanelI18n')?.textContent || '{}'); + const t = (key) => I18N[key] || key; + const vscode = acquireVsCodeApi(); + const mode = (document.getElementById('boxPanelMode')?.textContent || 'assets').trim(); + document.getElementById('refreshBtn').textContent = t('refresh'); + const publish = document.getElementById('publishBtn'); + publish.textContent = t('publish'); + publish.style.display = mode === 'assets' ? '' : 'none'; + document.getElementById('hint').textContent = mode === 'assets' ? t('assetsHint') : t('galleryHint'); + document.getElementById('refreshBtn').onclick = () => vscode.postMessage({ type: 'refresh' }); + publish.onclick = () => vscode.postMessage({ type: 'publish' }); + + window.addEventListener('message', (event) => { + const msg = event.data; + if (msg.type !== 'rows') return; + const rows = document.getElementById('rows'); + while (rows.firstChild) rows.removeChild(rows.firstChild); + (msg.rows || []).forEach((row) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'box-row'; + button.textContent = row.label; + button.onclick = () => vscode.postMessage({ type: 'activate', id: row.id, clicks: 1 }); + button.ondblclick = () => vscode.postMessage({ type: 'activate', id: row.id, clicks: 2 }); + rows.append(button); + }); + if (!(msg.rows || []).length) { + const empty = document.createElement('div'); + empty.className = 'panel-hint'; + empty.textContent = t(mode === 'assets' ? 'assetsEmpty' : 'galleryEmpty'); + rows.append(empty); + } + }); + vscode.postMessage({ type: 'ready' }); +})(); diff --git a/media/boxPanel/index.html b/media/boxPanel/index.html new file mode 100644 index 0000000..cb0bd55 --- /dev/null +++ b/media/boxPanel/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + +
+ + + +
+
+ + + + + diff --git a/media/boxPanel/style.css b/media/boxPanel/style.css new file mode 100644 index 0000000..82385ae --- /dev/null +++ b/media/boxPanel/style.css @@ -0,0 +1,13 @@ +.toolbar { border-bottom: var(--border-width) solid var(--border); } +.info { color: var(--text-muted); font-size: var(--font-xs); } +#rows { display: flex; flex-direction: column; gap: var(--space-xs); padding: var(--space-sm); } +.box-row { + text-align: left; + background: var(--bg-row); + border: none; + border-radius: var(--radius-sm); + color: var(--text-primary); + padding: var(--space-sm); + cursor: pointer; +} +.box-row:hover { background: var(--bg-row-hover); } diff --git a/package.json b/package.json index f9b65d6..603112b 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,18 @@ "webviewOptions": { "retainContextWhenHidden": true } + }, + { + "type": "webview", + "id": "okScriptToolkit.boxAssets", + "name": "%view.boxAssets%", + "title": "%view.boxAssets.title%" + }, + { + "type": "webview", + "id": "okScriptToolkit.boxGallery", + "name": "%view.boxGallery%", + "title": "%view.boxGallery.title%" } ], "okTempShots": [ diff --git a/package.nls.es.json b/package.nls.es.json index bda8794..c4614ae 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -21,6 +21,10 @@ "view.console.title": "Herramientas de ok-script: Consola", "view.templateGallery.title": "Plantillas de ok-script: Galería de plantillas", "view.templateAssets.title": "Plantillas de ok-script: Recursos de plantillas", + "view.boxAssets": "Recursos de cuadros", + "view.boxAssets.title": "Plantillas de ok-script: Recursos de cuadros", + "view.boxGallery": "Cuadros", + "view.boxGallery.title": "Plantillas de ok-script: Cuadros", "view.tempScreenshots": "Capturas temporales", "view.tempScreenshots.title": "Capturas temporales de ok-script", "configuration.title": "Sugerencias de idioma de ok-script", diff --git a/package.nls.ja.json b/package.nls.ja.json index a905440..01917fa 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -21,6 +21,10 @@ "view.console.title": "ok-script ツール: コンソール", "view.templateGallery.title": "ok-script テンプレート: テンプレートギャラリー", "view.templateAssets.title": "ok-script テンプレート: テンプレートアセット", + "view.boxAssets": "ボックス素材", + "view.boxAssets.title": "ok-script テンプレート: ボックス素材", + "view.boxGallery": "ボックス", + "view.boxGallery.title": "ok-script テンプレート: ボックス", "view.tempScreenshots": "一時スクリーンショット", "view.tempScreenshots.title": "ok-script 一時スクリーンショット", "configuration.title": "ok-script 言語ヒント", diff --git a/package.nls.json b/package.nls.json index fcbf0d8..91f2774 100644 --- a/package.nls.json +++ b/package.nls.json @@ -21,6 +21,10 @@ "view.console.title": "ok-script Tools: Console", "view.templateAssets": "Template Assets", "view.templateAssets.title": "ok-script Templates: Template Assets", + "view.boxAssets": "Box Assets", + "view.boxAssets.title": "ok-script Templates: Box Assets", + "view.boxGallery": "Boxes", + "view.boxGallery.title": "ok-script Templates: Boxes", "view.tempScreenshots": "Temp Screenshots", "view.tempScreenshots.title": "ok-script Temp Shots", "configuration.title": "ok-script Lang Hints", diff --git a/package.nls.ko.json b/package.nls.ko.json index 55f72c3..0dc8f7c 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -21,6 +21,10 @@ "view.console.title": "ok-script 도구: 콘솔", "view.templateGallery.title": "ok-script 템플릿: 템플릿 갤러리", "view.templateAssets.title": "ok-script 템플릿: 템플릿 에셋", + "view.boxAssets": "박스 에셋", + "view.boxAssets.title": "ok-script 템플릿: 박스 에셋", + "view.boxGallery": "박스", + "view.boxGallery.title": "ok-script 템플릿: 박스", "view.tempScreenshots": "임시 스크린샷", "view.tempScreenshots.title": "ok-script 임시 스크린샷", "configuration.title": "ok-script 언어 힌트", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index e9c2e22..f55d542 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -21,6 +21,10 @@ "view.console.title": "ok-script 工具: 控制台", "view.templateGallery.title": "ok-script 模板: 模板面板", "view.templateAssets.title": "ok-script 模板: 模板素材", + "view.boxAssets": "框资源", + "view.boxAssets.title": "ok-script 模板: 框资源", + "view.boxGallery": "框", + "view.boxGallery.title": "ok-script 模板: 框", "view.tempScreenshots": "临时截图", "view.tempScreenshots.title": "ok-script 临时截图", "configuration.title": "ok-script 语言提示", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 84eb04f..06aa241 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -21,6 +21,10 @@ "view.console.title": "ok-script 工具: 控制台", "view.templateGallery.title": "ok-script 模板: 模板面板", "view.templateAssets.title": "ok-script 模板: 模板素材", + "view.boxAssets": "框資源", + "view.boxAssets.title": "ok-script 模板: 框資源", + "view.boxGallery": "框", + "view.boxGallery.title": "ok-script 模板: 框", "view.tempScreenshots": "臨時截圖", "view.tempScreenshots.title": "ok-script 臨時截圖", "configuration.title": "ok-script 語言提示", diff --git a/src/annotationPanel.ts b/src/annotationPanel.ts index ed64121..fd61a16 100644 --- a/src/annotationPanel.ts +++ b/src/annotationPanel.ts @@ -2,6 +2,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { TemplateAssetData } from './templateAssetData'; +import { addBox, rectFromPixels } from './boxResourceStore'; +import { templatesDirectory } from './projectConfig'; +import { getProjectConfig } from './screenshotCapture'; +import { readImageSize } from './pngCrop'; import { injectWebviewLocalization, tr } from './localization'; import { applySharedAssets, getNonce } from './webviewHtml'; @@ -145,6 +149,10 @@ class AnnotationController { y?: number; w?: number; h?: number; + path?: string; + boxes?: Array<{ x: number; y: number; w: number; h: number }>; + ok?: boolean; + error?: string; }): Promise { switch (msg.type) { case 'ready': @@ -164,6 +172,20 @@ class AnnotationController { this.onSaved(this._currentImage); break; } + case 'generateBox': { + if (!this._currentImage || !msg.path || !msg.boxes?.length) { + void this.webview.postMessage({ type: 'generateBoxResult', ok: false, error: 'path' }); + break; + } + const size = readImageSize(fs.readFileSync(this._currentImage)); + const rect = size ? rectFromPixels(msg.boxes, size.width, size.height) : undefined; + const root = getProjectConfig().projectDir; + const error = rect && root + ? addBox(root, templatesDirectory(root), msg.path, path.basename(this._currentImage), rect) + : 'image'; + void this.webview.postMessage({ type: 'generateBoxResult', ok: !error, error: error || '' }); + break; + } case 'navigate': { if (msg.index === undefined || msg.index < 0 || msg.index >= this._imageList.length) break; await this.loadImage(this._imageList[msg.index]); @@ -268,7 +290,7 @@ export class AnnotationPanel { /* ---------------- HTML ---------------- */ -function annotationHtml(cspSource: string, extensionUri: vscode.Uri, webview: vscode.Webview): string { +export function annotationHtml(cspSource: string, extensionUri: vscode.Uri, webview: vscode.Webview): string { const file = path.join(extensionUri.fsPath, 'media', 'annotationPanel', 'index.html'); const nonce = getNonce(); const resource = (name: string) => webview.asWebviewUri( diff --git a/src/boxPanels.ts b/src/boxPanels.ts new file mode 100644 index 0000000..b0ef619 --- /dev/null +++ b/src/boxPanels.ts @@ -0,0 +1,252 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { annotationHtml } from './annotationPanel'; +import { + boxesForImage, + publishRuntime, + readAuthoringFile, + readRuntimeFile, + replaceImageBoxes, + runtimeOnlyPaths, + EditedBox, +} from './boxResourceStore'; +import { BoxRect, rectToPixel } from './boxResourcePure'; +import { probedBoxesJson } from './cocoFeaturePath'; +import { injectWebviewLocalization } from './localization'; +import { boxesRuntimeSetting, templatesDirectory } from './projectConfig'; +import { readImageSize } from './pngCrop'; +import { getProjectConfig } from './screenshotCapture'; +import { TemplateAssetData } from './templateAssetData'; +import { applySharedAssets, getNonce } from './webviewHtml'; + +function runtimeArgs(root: string): { declared?: string; fromConfig?: string } { + return { declared: boxesRuntimeSetting(root), fromConfig: probedBoxesJson(root) }; +} + +function panelHtml(webview: vscode.Webview, extensionUri: vscode.Uri, mode: string): string { + const file = path.join(extensionUri.fsPath, 'media', 'boxPanel', 'index.html'); + const nonce = getNonce(); + const resource = (name: string) => webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'media', 'boxPanel', name), + ).toString(true); + return injectWebviewLocalization(applySharedAssets(webview, extensionUri, + fs.readFileSync(file, 'utf8') + .split('__CSP_NONCE__').join(nonce) + .split('__CSP_SOURCE__').join(webview.cspSource) + .split('__STYLE_URI__').join(resource('style.css')) + .split('__APP_SCRIPT_URI__').join(resource('app.js')) + .split('__MODE__').join(mode), + )); +} + +export class BoxAssetViewProvider implements vscode.WebviewViewProvider { + static readonly viewType = 'okScriptToolkit.boxAssets'; + private view: vscode.WebviewView | undefined; + + constructor( + private readonly data: TemplateAssetData, + private readonly extensionUri: vscode.Uri, + ) {} + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; + webviewView.webview.html = panelHtml(webviewView.webview, this.extensionUri, 'assets'); + webviewView.webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }); + } + + private root(): string { + return getProjectConfig().projectDir; + } + + private async onMessage(msg: { type?: string; id?: string; clicks?: number }): Promise { + const root = this.root(); + if (!root || !this.view) return; + if (msg.type === 'ready' || msg.type === 'refresh') { + this.data.load(); + const authoring = readAuthoringFile(root, templatesDirectory(root)); + const rows = this.data.listImages().map((imagePath) => ({ + id: imagePath, + label: `${path.basename(imagePath)} (${boxesForImage(authoring, path.basename(imagePath)).length})`, + })); + void this.view.webview.postMessage({ type: 'rows', rows }); + return; + } + if (msg.type === 'activate' && msg.clicks === 2 && msg.id) { + openBoxEditor(this.extensionUri, this.data, msg.id); + return; + } + if (msg.type === 'publish') { + const templates = templatesDirectory(root); + const args = runtimeArgs(root); + const authoring = readAuthoringFile(root, templates); + const runtime = readRuntimeFile(root, args.declared, args.fromConfig); + const dropped = runtimeOnlyPaths(authoring, runtime); + if (dropped.length) { + const answer = await vscode.window.showWarningMessage( + dropped.join('\n'), + { modal: true }, + 'Publish', + ); + if (answer !== 'Publish') return; + } + publishRuntime(root, templates, args.declared, args.fromConfig); + void this.onMessage({ type: 'refresh' }); + } + } +} + +export class BoxGalleryViewProvider implements vscode.WebviewViewProvider { + static readonly viewType = 'okScriptToolkit.boxGallery'; + private view: vscode.WebviewView | undefined; + + constructor(private readonly extensionUri: vscode.Uri) {} + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; + webviewView.webview.html = panelHtml(webviewView.webview, this.extensionUri, 'gallery'); + webviewView.webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }); + } + + private async onMessage(msg: { type?: string; id?: string; clicks?: number }): Promise { + const root = getProjectConfig().projectDir; + if (!root || !this.view) return; + if (msg.type === 'ready' || msg.type === 'refresh') { + const args = runtimeArgs(root); + const rows = readRuntimeFile(root, args.declared, args.fromConfig).boxes.map((box) => ({ + id: box.path, + label: `self.pos.${box.path}.to_box()`, + })); + void this.view.webview.postMessage({ type: 'rows', rows }); + return; + } + if (msg.type !== 'activate' || !msg.id) return; + const text = msg.clicks === 2 ? `self.pos.${msg.id}` : `self.pos.${msg.id}.to_box()`; + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'python') { + await vscode.env.clipboard.writeText(text); + return; + } + await editor.edit((builder) => { + editor.selections.forEach((selection) => builder.insert(selection.active, text)); + }); + } +} + +class BoxEditor { + private image = ''; + private images: string[] = []; + private originals = new Map(); + + constructor(private readonly panel: vscode.WebviewPanel) { + panel.webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }); + } + + open(imagePath: string, imageList: string[]): void { + this.image = imagePath; + this.images = imageList; + void this.load(); + } + + private root(): string { return getProjectConfig().projectDir; } + + private async load(): Promise { + const root = this.root(); + const buf = fs.readFileSync(this.image); + const size = readImageSize(buf); + const authoring = readAuthoringFile(root, templatesDirectory(root)); + this.originals.clear(); + const annotations = boxesForImage(authoring, path.basename(this.image)).map((box, index) => { + const pixel = size ? rectToPixel(box.rect, size.width, size.height) : undefined; + const id = index + 1; + if (pixel) this.originals.set(id, box.rect); + return pixel ? { id, category: box.path, ...pixel } : undefined; + }).filter((item): item is { id: number; category: string; x: number; y: number; w: number; h: number } => !!item); + const mime = buf[0] === 0xff ? 'image/jpeg' : 'image/png'; + void this.panel.webview.postMessage({ + type: 'config', + boxMode: true, + keybindings: vscode.workspace.getConfiguration('okScriptToolkit').get('annotationKeybindings'), + copyCoordsSpace: vscode.workspace.getConfiguration('okScriptToolkit').get('copyCoordsSpace', true), + }); + void this.panel.webview.postMessage({ + type: 'load', + imagePath: this.image, + imageBase64: `data:${mime};base64,${buf.toString('base64')}`, + annotations, + allCategories: {}, + currentIndex: Math.max(0, this.images.indexOf(this.image)), + totalImages: this.images.length, + filename: path.basename(this.image), + }); + } + + private async onMessage(msg: { + type?: string; + annotations?: Array<{ id: number; category: string; x: number; y: number; w: number; h: number }>; + index?: number; + }): Promise { + if (msg.type === 'ready') { + await this.load(); + return; + } + if (msg.type === 'navigate' && msg.index !== undefined) { + const next = this.images[msg.index]; + if (next) { + this.image = next; + await this.load(); + } + return; + } + if (msg.type !== 'save' || !msg.annotations) return; + const root = this.root(); + const size = readImageSize(fs.readFileSync(this.image)); + if (!size) return; + const edited: EditedBox[] = msg.annotations.map((ann) => ({ + path: ann.category, + x: ann.x, + y: ann.y, + w: ann.w, + h: ann.h, + original: this.originals.get(ann.id), + })); + replaceImageBoxes(root, templatesDirectory(root), path.basename(this.image), size.width, size.height, edited); + } +} + +const editors = new Map(); + +export function openBoxEditor(extensionUri: vscode.Uri, data: TemplateAssetData, imagePath: string): void { + const images = data.listImages(); + const panel = vscode.window.createWebviewPanel( + 'okScriptToolkitBoxAnnotation', + path.basename(imagePath), + vscode.ViewColumn.Beside, + { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [extensionUri, vscode.Uri.file(data.templatesDir)] }, + ); + panel.webview.html = annotationHtml(panel.webview.cspSource, extensionUri, panel.webview); + const editor = new BoxEditor(panel); + editor.open(imagePath, images); + editors.set(imagePath, editor); + panel.onDidDispose(() => editors.delete(imagePath)); +} + +export function previewRectForPath(root: string, boxPath: string): { imagePath: string; bbox: [number, number, number, number] } | undefined { + const authoring = readAuthoringFile(root, templatesDirectory(root)).boxes.find((box) => box.path === boxPath); + const runtime = readRuntimeFile(root, runtimeArgs(root).declared, runtimeArgs(root).fromConfig).boxes.find((box) => box.path === boxPath); + const rect = runtime?.rect || authoring?.rect; + if (!authoring || !rect) return undefined; + const imagePath = path.join(root, templatesDirectory(root), authoring.image); + if (!fs.existsSync(imagePath)) return undefined; + const size = readImageSize(fs.readFileSync(imagePath)); + const pixel = size ? rectToPixel(rect, size.width, size.height) : undefined; + if (!pixel) return undefined; + return { imagePath, bbox: [pixel.x, pixel.y, pixel.w, pixel.h] }; +} + +export function posPaths(root: string): string[] { + const args = runtimeArgs(root); + return readRuntimeFile(root, args.declared, args.fromConfig).boxes.map((box) => box.path); +} diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts new file mode 100644 index 0000000..7472f8e --- /dev/null +++ b/src/boxResourceStore.ts @@ -0,0 +1,139 @@ +/** + * 框资源的读盘侧。纯规则在 `boxResourcePure.ts`。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { + AuthoringBox, + AuthoringFile, + BoxRect, + PixelBox, + RuntimeFile, + authoringFile, + emptyAuthoringFile, + emptyRuntimeFile, + imageFileName, + parseAuthoring, + parseRuntime, + pixelToRect, + publishBoxes, + rectForSave, + resolveBoxRuntimePlan, + runtimeWriteTarget, + serializeAuthoring, + serializeRuntime, + unionOnImage, + boxPathError, + effectiveBoxRuntimeFile, +} from './boxResourcePure'; + +export interface EditedBox { + path: string; + x: number; + y: number; + w: number; + h: number; + original?: BoxRect; +} + +function sameImage(a: string, b: string): boolean { + return imageFileName(a).toLowerCase() === imageFileName(b).toLowerCase(); +} + +function readText(file: string): string | undefined { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + return undefined; + } +} + +function writeText(file: string, text: string): boolean { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(temp, text, 'utf8'); + fs.renameSync(temp, file); + return true; + } catch { + return false; + } +} + +export function readAuthoringFile(rootDir: string, templatesDirectory: string): AuthoringFile { + const file = authoringFile(rootDir, templatesDirectory); + const text = readText(file); + return text ? parseAuthoring(text).file : emptyAuthoringFile(); +} + +export function readRuntimeFile(rootDir: string, declared?: string, fromConfigPy?: string): RuntimeFile { + const plan = resolveBoxRuntimePlan(rootDir, declared, fromConfigPy); + const file = effectiveBoxRuntimeFile(plan, (candidate) => { + try { return fs.existsSync(candidate); } catch { return false; } + }); + if (!file) return emptyRuntimeFile(); + const text = readText(file); + return text ? parseRuntime(text).file : emptyRuntimeFile(); +} + +export function boxesForImage(file: AuthoringFile, fileName: string): AuthoringBox[] { + return file.boxes.filter((box) => sameImage(box.image, fileName)); +} + +export function replaceImageBoxes( + rootDir: string, + templatesDirectory: string, + fileName: string, + width: number, + height: number, + boxes: EditedBox[], +): string | undefined { + const image = imageFileName(fileName); + if (!image || width <= 0 || height <= 0) return 'image'; + const current = readAuthoringFile(rootDir, templatesDirectory); + const kept = current.boxes.filter((box) => !sameImage(box.image, image)); + const taken = new Set(kept.map((box) => box.path)); + const next: AuthoringBox[] = []; + for (const box of boxes) { + const pathError = boxPathError(box.path); + if (pathError) return pathError; + if (taken.has(box.path)) return 'duplicate'; + taken.add(box.path); + const rect = rectForSave(box.original, { x: box.x, y: box.y, w: box.w, h: box.h }, width, height); + if (!rect) return 'rect'; + next.push({ path: box.path, image, rect }); + } + const text = serializeAuthoring({ version: 1, boxes: [...kept, ...next] }); + return writeText(authoringFile(rootDir, templatesDirectory), text) ? undefined : 'write'; +} + +export function addBox(rootDir: string, templatesDirectory: string, boxPath: string, image: string, rect: BoxRect): string | undefined { + const pathError = boxPathError(boxPath); + if (pathError) return pathError; + const fileName = imageFileName(image); + if (!fileName) return 'image'; + const current = readAuthoringFile(rootDir, templatesDirectory); + if (current.boxes.some((box) => box.path === boxPath)) return 'duplicate'; + const text = serializeAuthoring({ + version: 1, + boxes: [...current.boxes, { path: boxPath, image: fileName, rect }], + }); + return writeText(authoringFile(rootDir, templatesDirectory), text) ? undefined : 'write'; +} + +export function publishRuntime(rootDir: string, templatesDirectory: string, declared?: string, fromConfigPy?: string): boolean { + const authoring = readAuthoringFile(rootDir, templatesDirectory); + const text = serializeRuntime(publishBoxes(authoring)); + const target = runtimeWriteTarget(resolveBoxRuntimePlan(rootDir, declared, fromConfigPy)); + return writeText(target, text); +} + +export function runtimeOnlyPaths(authoring: AuthoringFile, runtime: RuntimeFile): string[] { + const live = new Set(authoring.boxes.map((box) => box.path)); + return runtime.boxes.map((box) => box.path).filter((boxPath) => !live.has(boxPath)); +} + +export function rectFromPixels(boxes: PixelBox[], width: number, height: number): BoxRect | undefined { + if (boxes.length === 1) return pixelToRect(boxes[0], width, height); + return unionOnImage(boxes, width, height); +} diff --git a/src/cocoFeaturePath.ts b/src/cocoFeaturePath.ts index 7513177..c4bdd7d 100644 --- a/src/cocoFeaturePath.ts +++ b/src/cocoFeaturePath.ts @@ -22,7 +22,7 @@ import { templatesCocoAnnotationsSetting } from './projectConfig'; import { getProjectConfig, probeWindowConfig } from './screenshotCapture'; /** 探到的值 + 它是为哪个项目根探的(换项目后必须重探,否则会套用上一个项目的路径)。 */ -let probed: { rootDir: string; value?: string } | undefined; +let probed: { rootDir: string; value?: string; boxes?: string } | undefined; /** 并发去重:激活与配置变更可能同时触发,别拉起两个 Python 进程。 */ let inFlight: Promise | undefined; @@ -37,7 +37,7 @@ export function refreshCocoFeaturePath(rootDir?: string): Promise { try { const { pythonPath } = getProjectConfig(); const config = dir ? await probeWindowConfig(dir, pythonPath) : undefined; - probed = { rootDir: dir, value: config?.cocoFeatureJson }; + probed = { rootDir: dir, value: config?.cocoFeatureJson, boxes: config?.boxesJson }; } catch { probed = { rootDir: dir }; } finally { @@ -64,6 +64,11 @@ export function probedCocoFeatureJson(rootDir: string): string | undefined { return probed.value; } +export function probedBoxesJson(rootDir: string): string | undefined { + if (!rootDir || !probed || probed.rootDir !== rootDir) return undefined; + return probed.boxes; +} + /** * 运行时模板库的候选计划(同步)。 * diff --git a/src/extension.ts b/src/extension.ts index e8c9d4b..3fb34e9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,7 @@ import { } from './templateAssetPanel'; import { TempScreenshotStore } from './tempScreenshotStore'; import { TempScreenshotViewProvider } from './tempScreenshotPanel'; +import { BoxAssetViewProvider, BoxGalleryViewProvider } from './boxPanels'; /** 缩略图缓存 key 版本:内容 hash 化后旧命名(t_/a_)需要清理一次 */ const THUMB_KEY_VERSION = 'content-hash-v2'; @@ -193,7 +194,7 @@ export function activate(context: vscode.ExtensionContext): void { // 末尾的 `config.py`:它决定运行时模板库放在哪,改了要重探 + 重建监听。 // 放在 `**/{...}` 里等价于 `**/config.py`(任意深度的同名文件都会派发进来, // `getAffectedSources` 再按路径筛一次)。 - return `**/{${langGlob}/*.json,${poGlob}/**/*.po,${cocoGlobs},assets/images/*.png,ok_tasks/assets/images/*.png,${tplGlob}/*.png,${effectsFile},config.py}`; + return `**/{${langGlob}/*.json,${poGlob}/**/*.po,${cocoGlobs},${tplGlob}/boxes.json,src/scene/boxes.json,assets/images/*.png,ok_tasks/assets/images/*.png,${tplGlob}/*.png,${effectsFile},config.py}`; }; /** @@ -394,6 +395,14 @@ export function activate(context: vscode.ExtensionContext): void { TemplateAssetViewProvider.viewType, new TemplateAssetViewProvider(templateAssetData, thumbDir, context.extensionUri, tempScreenshotStore), ), + vscode.window.registerWebviewViewProvider( + BoxAssetViewProvider.viewType, + new BoxAssetViewProvider(templateAssetData, context.extensionUri), + ), + vscode.window.registerWebviewViewProvider( + BoxGalleryViewProvider.viewType, + new BoxGalleryViewProvider(context.extensionUri), + ), vscode.window.registerWebviewViewProvider( TempScreenshotViewProvider.viewType, new TempScreenshotViewProvider(tempScreenshotStore, templateAssetData, tempThumbDir, context.extensionUri), diff --git a/src/localization.ts b/src/localization.ts index a5c0868..c901703 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -387,6 +387,15 @@ const ZH_CN: WebviewStrings = { hideAllAnnotations: '隐藏全部', showOnlyCurrent: '只显示当前', annotationListEmpty: '没有标注', + generateBox: '生成框', + generateBoxTitle: '生成框', + generatePath: '路径', + generateNeedSelection: '至少选择一个标注', + publish: '发布', + assetsHint: '双击原图编辑框。发布后才会进入运行时。', + galleryHint: '单击插入 to_box()。双击复制属性路径。', + assetsEmpty: '没有模板原图。请先在标注管理里添加。', + galleryEmpty: '还没有运行时框。请先在框资源管理里发布。', }; const EN: WebviewStrings = { @@ -509,6 +518,15 @@ const EN: WebviewStrings = { hideAllAnnotations: 'Hide all', showOnlyCurrent: 'Only current', annotationListEmpty: 'No annotations', + generateBox: 'Create box', + generateBoxTitle: 'Create box', + generatePath: 'Path', + generateNeedSelection: 'Select at least one annotation', + publish: 'Publish', + assetsHint: 'Double-click an image to edit boxes. Publish writes the runtime file.', + galleryHint: 'Click inserts to_box(). Double-click copies the attribute path.', + assetsEmpty: 'No template images yet. Add them in Template Assets.', + galleryEmpty: 'No runtime boxes yet. Publish them from Box Assets.', }; const ZH_TW: WebviewStrings = { @@ -601,6 +619,15 @@ const ZH_TW: WebviewStrings = { hideAllAnnotations: '隱藏全部', showOnlyCurrent: '只顯示目前', annotationListEmpty: '沒有標註', + generateBox: '產生框', + generateBoxTitle: '產生框', + generatePath: '路徑', + generateNeedSelection: '至少選擇一個標註', + publish: '發布', + assetsHint: '雙擊原圖編輯框。發布後才會進入執行時。', + galleryHint: '單擊插入 to_box()。雙擊複製屬性路徑。', + assetsEmpty: '沒有模板原圖。請先在標註管理裡新增。', + galleryEmpty: '還沒有執行時框。請先在框資源管理裡發布。', refresh: '重新整理', open: '開啟', add: '新增', modify: '修改', delete: '刪除', cancel: '取消', save: '儲存', copy: '複製', close: '關閉', search: '搜尋', loading: '載入中…', none: '無', current: '目前', enabled: '開啟', disabled: '關閉', error: '錯誤', warning: '警告', info: '資訊', charactersTitle: '角色技能管理', charactersTab: '角色與技能', effectsTab: '效果索引', localesTab: '名稱本地化', issuesTab: '資料診斷', searchCharacters: '搜尋角色、技能、效果、描述…', allStars: '全部星級', allElements: '全部元素', allProfessions: '全部職業', allSkillTypes: '全部技能類型', enhancementOnly: '僅顯示含強化組的角色', issueOnly: '僅顯示存在診斷的角色', characterCount: '{shown} / {total} 個角色', skillsEnhancements: '技能 / 強化', noCharacters: '沒有符合的角色', selectCharacter: '從左側選擇角色', openCharacterJson: '開啟角色 JSON', skillsAndEnhancements: '技能與強化效果', addSkill: '新增技能', modifySkill: '修改技能', addEnhancement: '新增強化組', modifyEnhancement: '修改強化組', baseEffects: '基礎效果', triggerEffects: '觸發依賴效果', outputEffects: '強化產出效果', visiblePulse: '可見脈衝', searchEffects: '搜尋效果 ID、描述、角色或技能…', allCategories: '全部分類', allEffects: '全部效果', usedOnly: '僅已引用', unusedOnly: '僅未引用', unknownOnly: '僅未知', addEffectCategory: '新增分類', addEffect: '新增效果', localizationMatrix: '角色名稱多語言矩陣', localizationHint: '空白儲存格表示缺失,可點擊角色開啟語言來源檔案。', missing: '缺失', searchIssues: '搜尋診斷資訊…', allSeverities: '全部級別', openSource: '開啟來源檔案', readingCharacterData: '正在讀取角色、技能、效果與多語言資料…', unknownCurrentEffect: '目前資料中的未知效果', inferredFromTriggerText: '由觸發文字推斷', clickCopySkillId: '點擊複製技能 ID', clickCopyEffectId: '點擊複製效果 ID', openCharacterLocaleFile: '開啟角色名稱語言檔案', loadFailed: '載入失敗', saveFailed: '儲存失敗', charactersMetric: '角色', skillsMetric: '技能', enhancementsMetric: '強化組', effectReferencesMetric: '效果引用', effectDefinitionsMetric: '效果定義', valueLabel: '值', taskTitle: 'ok-script 任務啟動', noTasks: '找不到任務。\n請先在設定中配置專案路徑。', parameters: '參數', launch: '啟動', stop: '停止', pause: '暫停', resume: '恢復', running: '執行中…', toolboxOpenCharacterManager: '開啟角色技能管理面板', templatesSearch: '搜尋模板名稱…', templatesTitle: '模板面板', noTemplates: '找不到任何模板。', noTemplatesWithHint: '找不到任何模板。\n請確認工作區存在 assets/coco_annotations.json\n(或 ok_tasks/assets/coco_annotations.json)。', thumbnailStats: '已載入 {loaded} 個縮圖', thumbnailStatsWithFailures: '已載入 {loaded} 個縮圖 / {failed} 個失敗', thumbnailLoadFailed: '縮圖載入失敗', templateSize: '尺寸:{width}×{height}', templateSource: '來源:{path}', unknownStar: '星級未定', unknownElement: '元素未定', unknownProfession: '職業未定', unknownWeapon: '武器未定', skillsCount: '{count} 個技能', enhancementsCount: '{count} 個強化組', syncedSkillLocked: '同步技能 · ID/名稱/類型/元素/描述已鎖定', enhancedState: '強化態', multiplier: '倍率', stagger: '失衡', cooldown: '冷卻', spiritCost: '技力', noSkills: '此角色尚無技能資料', effectsCount: '{shown} / {total} 個效果', noEffects: '沒有符合的效果', undefinedEffect: '未定義效果', openDefinition: '開啟定義', moreUsages: '另有 {count} 處引用', characterIdColumn: '角色 / ID', issuesCount: '{shown} / {total} 條診斷', noIssuesMatch: '沒有符合的診斷', noIssues: '未發現資料問題', skillId: '技能 ID', skillName: '技能名稱', skillType: '技能類型', element: '元素', damageMultiplier: '傷害倍率', staggerValue: '失衡值', cooldownField: '冷卻', spiritCostField: '技力消耗', skillDescription: '技能描述', baseEffectsMulti: '基礎效果(可多選)', enhancementName: '強化組名稱', visibleMarker: '顯示標記', triggerText: '觸發條件文字', enhancementDescription: '強化效果說明', triggerEffectsMulti: '觸發依賴效果(可多選)', outputEffectsMulti: '強化產出效果(可多選)', effectCategory: '效果分類', effectId: '效果 ID(大寫底線)', effectDescription: '效果描述', categoryName: '分類名稱', effectMultiHint: '按住 Ctrl / Cmd 可多選;選項來自 effects.py,並依效果分類分組。', selectedTriggerEffects: '已選擇 {count} 個觸發依賴效果', noTriggerEffects: '未選擇觸發依賴效果', noSelectedEffects: '未選擇效果', unknownEffect: '未知效果', confirmDelete: '確定要刪除「{name}」嗎?\n儲存前會自動建立 .bak 備份。', copied: '已複製:{text}', collapseParameters: '收合參數', oneTimeTask: '一次性', triggerTask: '觸發任務', enableTrigger: '啟用', triggerDisabled: '未啟用', triggerArmed: '已啟用', triggerEnqueued: '已入列', triggerPolling: '輪詢中', taskQueued: '排隊中', taskRunning: '執行中', executorIdle: '執行器未啟動', executorConnecting: '執行器啟動中…', executorRunning: '執行器執行中 · {count} 個觸發任務已入列', executorPaused: '已暫停(輪詢與任務皆暫停)', startExecutor: '啟動執行器', stopExecutor: '關閉執行器', stopCurrent: '停止目前任務', configGroup: '設定分組', commonParameters: '通用參數', groupParameters: '分組參數', childTaskConfig: '子任務設定', otherParameters: '其他參數', launchSettings: '啟動設定', saved: '已自動儲存', reset: '重設', noConfigParameters: '目前沒有可設定參數(schema 尚未就緒或任務沒有 default_config)。', schemaFailed: '此任務 schema 收集失敗,無法自動產生表單:{error}', holdCtrlMulti: '按住 Ctrl 多選', currentValue: '{value}(目前)', selectedOptionsHint: '可選值:{values}', structuredJsonHint: 'JSON 陣列;支援條件物件與動作序列。', debugOverlay: '除錯浮層', debugOverlayHint: '任務啟動時自動沿用;任務執行中可即時開關。開啟後 draw_boxes 的識別框會繪製到遊戲視窗上(GDI 浮層)。', toolboxGameSection: '遊戲連線', toolboxConnectGame: '連線遊戲', toolboxDisconnect: '中斷', toolboxGameConnected: '已連線:{title}(PID {pid})', toolboxGameNotConnected: '未連線遊戲。連線後任務啟動將優先複用該視窗;遊戲未執行時會自動啟動。', availableOptions: '可用選項', clickOptionToAdd: '點擊選項新增。', selectedOptions: '已選清單', searchOptions: '搜尋選項…', moveUp: '上移', moveDown: '下移', removeItem: '移除', confirm: '確認', addValue: '新增值', taskPaused: '任務已暫停,點擊恢復繼續執行', taskResumed: '任務已恢復執行', stopping: '正在停止任務…', taskStopped: '任務已停止,詳見輸出面板', taskCompleted: '任務完成,詳見輸出面板', taskFailed: '任務異常結束,詳見輸出面板', templatesHint: '單擊=插入 · 雙擊=複製 · 將游標移到縮圖並點擊 👁 檢視原圖', templatesCount: '{shown}/{total} 個模板', noTemplateMatch: '沒有符合「{query}」的模板。', viewOriginal: '檢視原圖(標示位置)', diff --git a/src/providers.ts b/src/providers.ts index a6c2c25..85b64ba 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -12,11 +12,24 @@ import { EffectData, EffectEntry } from './effectData'; import { DEFAULT_FEATURE_ALIASES, ideSetting, labelEnumAliases, loadProjectConfig } from './projectConfig'; import { cropTemplateToDataUrlCached } from './pngCrop'; import { selectedProjectLocale, tr } from './localization'; +import { getProjectConfig } from './screenshotCapture'; +import { posPaths, previewRectForPath } from './boxPanels'; /** 匹配 self.lang.<模块>.(支持 Unicode 标识符,如中文 OCR 文本;负向后视避免匹配 self.langx 之类) */ const EXPR_RE = /(?= match.index && character <= match.index + match[0].length) return { path: match[1] }; + } + return undefined; +} + function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -407,6 +420,17 @@ export class LangHoverProvider implements vscode.HoverProvider { if (entry) return new vscode.Hover(formatEntry(entry, currentLocale())); } } + const pos = findPosMatch(line, position.character); + if (pos) { + const preview = previewRectForPath(getProjectConfig().projectDir, pos.path); + const md = new vscode.MarkdownString(undefined, true); + md.appendCodeblock(`self.pos.${pos.path}.to_box()`, 'python'); + if (preview) { + const img = cropTemplateToDataUrlCached(preview.imagePath, preview.bbox); + if (img) md.appendMarkdown(`\n![box](${img})\n`); + } + return new vscode.Hover(md); + } for (const mf of findFeatureMatches(line)) { if (position.character >= mf.start && position.character <= mf.end) { const ft = this.features.entry(mf.name); @@ -486,6 +510,18 @@ export class LangCompletionProvider implements vscode.CompletionItemProvider { }); } + const posMatch = /(? item.startsWith(prefix)) + .map((item) => item.slice(prefix.length).split('.')[0]) + .filter(Boolean))]; + return segments.map((segment) => new vscode.CompletionItem(segment, vscode.CompletionItemKind.Field)); + } + // 别名. -> 补全模板名(如 fL. / FeatureList.;缩略图懒加载) // 使用极小 sortText,并默认选中模板项;Pylance 的同名枚举项仍保留在列表中。 for (const alias of featureAliases()) { From dcdbaedeb9d5b19404cf708934b005388abcd5ef Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:33:19 +0800 Subject: [PATCH 04/19] fix(boxes): keep authoring files intact and fix editor completion Save failures stay visible, self.pos.to_box() is not treated as part of the path, and the JetBrains submodule points at the compile fix. --- docs/box-resources.md | 2 +- jetbrains | 2 +- l10n/bundle.l10n.es.json | 3 +- l10n/bundle.l10n.ja.json | 3 +- l10n/bundle.l10n.json | 3 +- l10n/bundle.l10n.ko.json | 3 +- l10n/bundle.l10n.zh-cn.json | 3 +- l10n/bundle.l10n.zh-tw.json | 3 +- media/annotationPanel/app.js | 4 ++- media/annotationPanel/index.html | 2 +- media/annotationPanel/style.css | 23 ++++++++++++++-- media/boxPanel/app.js | 11 ++++++-- src/annotationPanel.ts | 8 +++++- src/boxPanels.ts | 47 ++++++++++++++++++++++---------- src/boxResourcePure.ts | 22 +++++++++++++-- src/boxResourceStore.ts | 19 ++++++++++--- src/providers.ts | 6 ++-- 17 files changed, 125 insertions(+), 39 deletions(-) diff --git a/docs/box-resources.md b/docs/box-resources.md index a80d22f..6161688 100644 --- a/docs/box-resources.md +++ b/docs/box-resources.md @@ -1,6 +1,6 @@ # 框资源设计 -框管理对标现有模板管理,分成两份资源。标注工作副本只给插件编辑;运行时副本才是游戏和代码补全读取的那份。图片、画布和显隐不另起一套。 +框管理对标现有模板管理,分成两份资源。标注工作副本只给插件编辑。运行时副本是发布后的位置表:插件的框管理和 `self.pos` 补全读它。游戏进程要等业务项目的 `ScreenPosition` 加载这份文件之后才会用到它;ok 框架现在的模板匹配仍然只读 `template_matching.coco_feature_json`。图片、画布和显隐不另起一套。 本文是实现依据。ok-neverness-to-everness(下称 ok-nte)里的真实调用是 `self.pos.screen.main_viewport.to_box()`,不是 `self.pos.main_viewport`,也没有 `screen_pos`。 diff --git a/jetbrains b/jetbrains index 852b329..8340c97 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 852b3295d0c94cd934a7e825683162fc6125e692 +Subproject commit 8340c97abebcf4cf299bbd8c826a0aa967012a95 diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json index 741a4a3..e613def 100644 --- a/l10n/bundle.l10n.es.json +++ b/l10n/bundle.l10n.es.json @@ -216,5 +216,6 @@ "Failed to swap annotations.": "No se pudieron intercambiar las anotaciones.", "Annotations changed while confirming. Retry the swap.": "Las anotaciones cambiaron durante la confirmación. Vuelve a intentar el intercambio.", "Cannot read image size, so annotations cannot be swapped.": "No se pudo leer el tamaño de la imagen, así que no se pueden intercambiar las anotaciones.", - "Neither image has annotations, nothing to swap.": "Ninguna de las dos imágenes tiene anotaciones, no hay nada que intercambiar." + "Neither image has annotations, nothing to swap.": "Ninguna de las dos imágenes tiene anotaciones, no hay nada que intercambiar.", + "Could not save the box resource.": "No se pudo guardar el recurso de cuadros." } diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index 638a2b4..f25b5e6 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -216,5 +216,6 @@ "Failed to swap annotations.": "アノテーションの交換に失敗しました。", "Annotations changed while confirming. Retry the swap.": "確認中にアノテーションが変更されました。交換をやり直してください。", "Cannot read image size, so annotations cannot be swapped.": "画像サイズを読み取れないため、アノテーションを交換できません。", - "Neither image has annotations, nothing to swap.": "どちらの画像にもアノテーションがないため、交換するものはありません。" + "Neither image has annotations, nothing to swap.": "どちらの画像にもアノテーションがないため、交換するものはありません。", + "Could not save the box resource.": "ボックス資源を保存できませんでした。" } diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 52a8fdd..4ff9bfd 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -216,5 +216,6 @@ "Failed to swap annotations.": "Failed to swap annotations.", "Annotations changed while confirming. Retry the swap.": "Annotations changed while confirming. Retry the swap.", "Cannot read image size, so annotations cannot be swapped.": "Cannot read image size, so annotations cannot be swapped.", - "Neither image has annotations, nothing to swap.": "Neither image has annotations, nothing to swap." + "Neither image has annotations, nothing to swap.": "Neither image has annotations, nothing to swap.", + "Could not save the box resource.": "Could not save the box resource." } diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index 347927f..699c165 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -216,5 +216,6 @@ "Failed to swap annotations.": "어노테이션 교환에 실패했습니다.", "Annotations changed while confirming. Retry the swap.": "확인하는 동안 어노테이션이 변경되었습니다. 교환을 다시 시도하세요.", "Cannot read image size, so annotations cannot be swapped.": "이미지 크기를 읽을 수 없어 어노테이션을 교환할 수 없습니다.", - "Neither image has annotations, nothing to swap.": "두 이미지 모두 어노테이션이 없어 교환할 것이 없습니다." + "Neither image has annotations, nothing to swap.": "두 이미지 모두 어노테이션이 없어 교환할 것이 없습니다.", + "Could not save the box resource.": "박스 리소스를 저장하지 못했습니다." } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index afb65b0..11da23e 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -216,5 +216,6 @@ "Failed to swap annotations.": "交换标注失败。", "Annotations changed while confirming. Retry the swap.": "确认期间标注已更改,请重新交换。", "Cannot read image size, so annotations cannot be swapped.": "读不出图片尺寸,无法交换标注。", - "Neither image has annotations, nothing to swap.": "两张图都没有标注,无需交换。" + "Neither image has annotations, nothing to swap.": "两张图都没有标注,无需交换。", + "Could not save the box resource.": "无法保存框资源。" } diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index f2c3aeb..651ce5f 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -216,5 +216,6 @@ "Failed to swap annotations.": "交換標註失敗。", "Annotations changed while confirming. Retry the swap.": "確認期間標註已變更,請重新交換。", "Cannot read image size, so annotations cannot be swapped.": "讀不出圖片尺寸,無法交換標註。", - "Neither image has annotations, nothing to swap.": "兩張圖都沒有標註,無需交換。" + "Neither image has annotations, nothing to swap.": "兩張圖都沒有標註,無需交換。", + "Could not save the box resource.": "無法儲存框資源。" } diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index 60bfdd3..ee3091a 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -282,7 +282,8 @@ listSignature = ''; paint(); }); - const text = document.createElement('span'); + const text = document.createElement('button'); + text.type = 'button'; text.className = 'annotation-name'; text.textContent = ann.category; text.addEventListener('click', () => { @@ -1238,6 +1239,7 @@ img = null; imgData = null; canvas.style.display = 'none'; emptyMsg.style.display = 'flex'; + syncAnnotationList(); } // 更新导航 diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html index 3c32f20..544fb58 100644 --- a/media/annotationPanel/index.html +++ b/media/annotationPanel/index.html @@ -64,7 +64,7 @@

Bounding Box

Create box

- +
diff --git a/media/annotationPanel/style.css b/media/annotationPanel/style.css index 4b3226b..53c6754 100644 --- a/media/annotationPanel/style.css +++ b/media/annotationPanel/style.css @@ -35,8 +35,9 @@ body { } .annotation-list { - width: 220px; - flex-shrink: 0; + width: 168px; + flex: 0 1 168px; + max-width: 40%; display: flex; flex-direction: column; gap: var(--space-sm); @@ -80,6 +81,18 @@ body { font-weight: var(--weight-medium); } +@media (max-width: 640px) { + .editor-body { flex-direction: column; } + .annotation-list { + width: auto; + max-width: none; + flex: 0 0 auto; + max-height: 180px; + border-left: none; + border-top: var(--border-width) solid var(--border); + } +} + .annotation-name { flex: 1; min-width: 0; @@ -87,6 +100,12 @@ body { text-overflow: ellipsis; white-space: nowrap; cursor: pointer; + background: none; + border: none; + color: inherit; + font: inherit; + text-align: left; + padding: 0; } canvas { diff --git a/media/boxPanel/app.js b/media/boxPanel/app.js index 8bd66c8..a606bcd 100644 --- a/media/boxPanel/app.js +++ b/media/boxPanel/app.js @@ -21,8 +21,15 @@ button.type = 'button'; button.className = 'box-row'; button.textContent = row.label; - button.onclick = () => vscode.postMessage({ type: 'activate', id: row.id, clicks: 1 }); - button.ondblclick = () => vscode.postMessage({ type: 'activate', id: row.id, clicks: 2 }); + let clickTimer = 0; + button.onclick = () => { + window.clearTimeout(clickTimer); + clickTimer = window.setTimeout(() => vscode.postMessage({ type: 'activate', id: row.id, clicks: 1 }), 250); + }; + button.ondblclick = () => { + window.clearTimeout(clickTimer); + vscode.postMessage({ type: 'activate', id: row.id, clicks: 2 }); + }; rows.append(button); }); if (!(msg.rows || []).length) { diff --git a/src/annotationPanel.ts b/src/annotationPanel.ts index fd61a16..eecee91 100644 --- a/src/annotationPanel.ts +++ b/src/annotationPanel.ts @@ -177,7 +177,13 @@ class AnnotationController { void this.webview.postMessage({ type: 'generateBoxResult', ok: false, error: 'path' }); break; } - const size = readImageSize(fs.readFileSync(this._currentImage)); + let size: { width: number; height: number } | undefined; + try { + size = readImageSize(fs.readFileSync(this._currentImage)); + } catch { + void this.webview.postMessage({ type: 'generateBoxResult', ok: false, error: 'image' }); + break; + } const rect = size ? rectFromPixels(msg.boxes, size.width, size.height) : undefined; const root = getProjectConfig().projectDir; const error = rect && root diff --git a/src/boxPanels.ts b/src/boxPanels.ts index b0ef619..bf50ce8 100644 --- a/src/boxPanels.ts +++ b/src/boxPanels.ts @@ -13,7 +13,7 @@ import { } from './boxResourceStore'; import { BoxRect, rectToPixel } from './boxResourcePure'; import { probedBoxesJson } from './cocoFeaturePath'; -import { injectWebviewLocalization } from './localization'; +import { injectWebviewLocalization, tr } from './localization'; import { boxesRuntimeSetting, templatesDirectory } from './projectConfig'; import { readImageSize } from './pngCrop'; import { getProjectConfig } from './screenshotCapture'; @@ -140,7 +140,11 @@ class BoxEditor { private images: string[] = []; private originals = new Map(); - constructor(private readonly panel: vscode.WebviewPanel) { + constructor( + private readonly panel: vscode.WebviewPanel, + private readonly rootDir: string, + private readonly templatesDir: string, + ) { panel.webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }); } @@ -150,13 +154,11 @@ class BoxEditor { void this.load(); } - private root(): string { return getProjectConfig().projectDir; } - private async load(): Promise { - const root = this.root(); + const root = this.rootDir; const buf = fs.readFileSync(this.image); const size = readImageSize(buf); - const authoring = readAuthoringFile(root, templatesDirectory(root)); + const authoring = readAuthoringFile(root, this.templatesDir); this.originals.clear(); const annotations = boxesForImage(authoring, path.basename(this.image)).map((box, index) => { const pixel = size ? rectToPixel(box.rect, size.width, size.height) : undefined; @@ -201,8 +203,13 @@ class BoxEditor { return; } if (msg.type !== 'save' || !msg.annotations) return; - const root = this.root(); - const size = readImageSize(fs.readFileSync(this.image)); + let size: { width: number; height: number } | undefined; + try { + size = readImageSize(fs.readFileSync(this.image)); + } catch { + void vscode.window.showErrorMessage(tr('Could not save the box resource.')); + return; + } if (!size) return; const edited: EditedBox[] = msg.annotations.map((ann) => ({ path: ann.category, @@ -212,14 +219,23 @@ class BoxEditor { h: ann.h, original: this.originals.get(ann.id), })); - replaceImageBoxes(root, templatesDirectory(root), path.basename(this.image), size.width, size.height, edited); + const error = replaceImageBoxes(this.rootDir, this.templatesDir, path.basename(this.image), size.width, size.height, edited); + if (error) void vscode.window.showErrorMessage(tr('Could not save the box resource.')); } } -const editors = new Map(); +let boxEditorPanel: vscode.WebviewPanel | undefined; +let boxEditor: BoxEditor | undefined; export function openBoxEditor(extensionUri: vscode.Uri, data: TemplateAssetData, imagePath: string): void { const images = data.listImages(); + const root = getProjectConfig().projectDir; + const templatesDir = templatesDirectory(root); + if (boxEditorPanel && boxEditor) { + boxEditorPanel.reveal(); + boxEditor.open(imagePath, images); + return; + } const panel = vscode.window.createWebviewPanel( 'okScriptToolkitBoxAnnotation', path.basename(imagePath), @@ -227,10 +243,13 @@ export function openBoxEditor(extensionUri: vscode.Uri, data: TemplateAssetData, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [extensionUri, vscode.Uri.file(data.templatesDir)] }, ); panel.webview.html = annotationHtml(panel.webview.cspSource, extensionUri, panel.webview); - const editor = new BoxEditor(panel); - editor.open(imagePath, images); - editors.set(imagePath, editor); - panel.onDidDispose(() => editors.delete(imagePath)); + boxEditor = new BoxEditor(panel, root, templatesDir); + boxEditorPanel = panel; + boxEditor.open(imagePath, images); + panel.onDidDispose(() => { + boxEditorPanel = undefined; + boxEditor = undefined; + }); } export function previewRectForPath(root: string, boxPath: string): { imagePath: string; bbox: [number, number, number, number] } | undefined { diff --git a/src/boxResourcePure.ts b/src/boxResourcePure.ts index 1c5315c..17ee8da 100644 --- a/src/boxResourcePure.ts +++ b/src/boxResourcePure.ts @@ -223,7 +223,23 @@ export function publishStatus(authoring: AuthoringFile, runtime: RuntimeFile): A } function sameRect(a: BoxRect, b: BoxRect): boolean { - return a.length === b.length && a.every((value, index) => value === b[index]); + return a.length === b.length && a.every((value, index) => formatRectNumber(value) === formatRectNumber(b[index])); +} + +function quantizeRect(rect: BoxRect): BoxRect { + let left = Number(formatRectNumber(rect[0])); + let top = Number(formatRectNumber(rect[1])); + let right = Number(formatRectNumber(rect[2])); + let bottom = Number(formatRectNumber(rect[3])); + if (left >= right) { + if (right < 1) right = Math.min(1, left + 0.000001); + if (left >= right) left = Math.max(0, right - 0.000001); + } + if (top >= bottom) { + if (bottom < 1) bottom = Math.min(1, top + 0.000001); + if (top >= bottom) top = Math.max(0, bottom - 0.000001); + } + return [left, top, right, bottom]; } export function applyVisibility( @@ -270,7 +286,7 @@ export function serializeAuthoring(file: AuthoringFile): string { ' {', ` "path": ${JSON.stringify(box.path)},`, ` "image": ${JSON.stringify(box.image)},`, - ` "rect": [${box.rect.map(formatRectNumber).join(', ')}]`, + ` "rect": [${quantizeRect(box.rect).map(formatRectNumber).join(', ')}]`, ' }', ].join('\n'), ).join(',\n'); @@ -283,7 +299,7 @@ export function serializeRuntime(file: RuntimeFile): string { [ ' {', ` "path": ${JSON.stringify(box.path)},`, - ` "rect": [${box.rect.map(formatRectNumber).join(', ')}]`, + ` "rect": [${quantizeRect(box.rect).map(formatRectNumber).join(', ')}]`, ' }', ].join('\n'), ).join(',\n'); diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts index 7472f8e..7a312f7 100644 --- a/src/boxResourceStore.ts +++ b/src/boxResourceStore.ts @@ -61,9 +61,14 @@ function writeText(file: string, text: string): boolean { } export function readAuthoringFile(rootDir: string, templatesDirectory: string): AuthoringFile { + return readAuthoringResult(rootDir, templatesDirectory).file; +} + +function readAuthoringResult(rootDir: string, templatesDirectory: string): { file: AuthoringFile; errors: string[] } { const file = authoringFile(rootDir, templatesDirectory); const text = readText(file); - return text ? parseAuthoring(text).file : emptyAuthoringFile(); + if (text === undefined) return { file: emptyAuthoringFile(), errors: [] }; + return parseAuthoring(text); } export function readRuntimeFile(rootDir: string, declared?: string, fromConfigPy?: string): RuntimeFile { @@ -90,7 +95,9 @@ export function replaceImageBoxes( ): string | undefined { const image = imageFileName(fileName); if (!image || width <= 0 || height <= 0) return 'image'; - const current = readAuthoringFile(rootDir, templatesDirectory); + const parsed = readAuthoringResult(rootDir, templatesDirectory); + if (parsed.errors.length) return 'parse'; + const current = parsed.file; const kept = current.boxes.filter((box) => !sameImage(box.image, image)); const taken = new Set(kept.map((box) => box.path)); const next: AuthoringBox[] = []; @@ -112,7 +119,9 @@ export function addBox(rootDir: string, templatesDirectory: string, boxPath: str if (pathError) return pathError; const fileName = imageFileName(image); if (!fileName) return 'image'; - const current = readAuthoringFile(rootDir, templatesDirectory); + const parsed = readAuthoringResult(rootDir, templatesDirectory); + if (parsed.errors.length) return 'parse'; + const current = parsed.file; if (current.boxes.some((box) => box.path === boxPath)) return 'duplicate'; const text = serializeAuthoring({ version: 1, @@ -122,7 +131,9 @@ export function addBox(rootDir: string, templatesDirectory: string, boxPath: str } export function publishRuntime(rootDir: string, templatesDirectory: string, declared?: string, fromConfigPy?: string): boolean { - const authoring = readAuthoringFile(rootDir, templatesDirectory); + const parsed = readAuthoringResult(rootDir, templatesDirectory); + if (parsed.errors.length) return false; + const authoring = parsed.file; const text = serializeRuntime(publishBoxes(authoring)); const target = runtimeWriteTarget(resolveBoxRuntimePlan(rootDir, declared, fromConfigPy)); return writeText(target, text); diff --git a/src/providers.ts b/src/providers.ts index 85b64ba..f18b72c 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -19,13 +19,13 @@ import { posPaths, previewRectForPath } from './boxPanels'; const EXPR_RE = /(?= match.index && character <= match.index + match[0].length) return { path: match[1] }; + if (character >= match.index && character <= match.index + match[0].length) return { path: match[1] || match[2] }; } return undefined; } @@ -510,7 +510,7 @@ export class LangCompletionProvider implements vscode.CompletionItemProvider { }); } - const posMatch = /(? Date: Tue, 29 Sep 2026 05:29:47 +0800 Subject: [PATCH 05/19] fix(boxes): do not overwrite a box file that could not be read A missing file is still an empty catalog. A read error blocks both authoring writes and publish. --- src/boxPanels.ts | 6 +++++- src/boxResourceStore.ts | 32 +++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/boxPanels.ts b/src/boxPanels.ts index bf50ce8..9c4083c 100644 --- a/src/boxPanels.ts +++ b/src/boxPanels.ts @@ -91,7 +91,11 @@ export class BoxAssetViewProvider implements vscode.WebviewViewProvider { ); if (answer !== 'Publish') return; } - publishRuntime(root, templates, args.declared, args.fromConfig); + const published = publishRuntime(root, templates, args.declared, args.fromConfig); + if (!published) { + void vscode.window.showErrorMessage(tr('Could not save the box resource.')); + return; + } void this.onMessage({ type: 'refresh' }); } } diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts index 7a312f7..ed320ae 100644 --- a/src/boxResourceStore.ts +++ b/src/boxResourceStore.ts @@ -40,11 +40,16 @@ function sameImage(a: string, b: string): boolean { return imageFileName(a).toLowerCase() === imageFileName(b).toLowerCase(); } -function readText(file: string): string | undefined { +function readText(file: string): { text?: string; missing?: boolean; error?: boolean } { try { - return fs.readFileSync(file, 'utf8'); + if (!fs.existsSync(file)) return { missing: true }; } catch { - return undefined; + return { error: true }; + } + try { + return { text: fs.readFileSync(file, 'utf8') }; + } catch { + return { error: true }; } } @@ -66,19 +71,26 @@ export function readAuthoringFile(rootDir: string, templatesDirectory: string): function readAuthoringResult(rootDir: string, templatesDirectory: string): { file: AuthoringFile; errors: string[] } { const file = authoringFile(rootDir, templatesDirectory); - const text = readText(file); - if (text === undefined) return { file: emptyAuthoringFile(), errors: [] }; - return parseAuthoring(text); + const read = readText(file); + if (read.missing) return { file: emptyAuthoringFile(), errors: [] }; + if (read.error || read.text === undefined) return { file: emptyAuthoringFile(), errors: ['read'] }; + return parseAuthoring(read.text); } export function readRuntimeFile(rootDir: string, declared?: string, fromConfigPy?: string): RuntimeFile { + return readRuntimeResult(rootDir, declared, fromConfigPy).file; +} + +function readRuntimeResult(rootDir: string, declared?: string, fromConfigPy?: string): { file: RuntimeFile; errors: string[] } { const plan = resolveBoxRuntimePlan(rootDir, declared, fromConfigPy); const file = effectiveBoxRuntimeFile(plan, (candidate) => { try { return fs.existsSync(candidate); } catch { return false; } }); - if (!file) return emptyRuntimeFile(); - const text = readText(file); - return text ? parseRuntime(text).file : emptyRuntimeFile(); + if (!file) return { file: emptyRuntimeFile(), errors: [] }; + const read = readText(file); + if (read.missing) return { file: emptyRuntimeFile(), errors: [] }; + if (read.error || read.text === undefined) return { file: emptyRuntimeFile(), errors: ['read'] }; + return parseRuntime(read.text); } export function boxesForImage(file: AuthoringFile, fileName: string): AuthoringBox[] { @@ -133,6 +145,8 @@ export function addBox(rootDir: string, templatesDirectory: string, boxPath: str export function publishRuntime(rootDir: string, templatesDirectory: string, declared?: string, fromConfigPy?: string): boolean { const parsed = readAuthoringResult(rootDir, templatesDirectory); if (parsed.errors.length) return false; + const runtime = readRuntimeResult(rootDir, declared, fromConfigPy); + if (runtime.errors.length) return false; const authoring = parsed.file; const text = serializeRuntime(publishBoxes(authoring)); const target = runtimeWriteTarget(resolveBoxRuntimePlan(rootDir, declared, fromConfigPy)); From d18b5e20e5f956fdf9db4969c476256a211be7b9 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:40:49 +0800 Subject: [PATCH 06/19] fix(boxes): refuse a publish that would overwrite the authoring file Image replacements now fail as a batch before any write, Delete ignores a hidden box, and the JetBrains submodule picks up the same save rules. --- jetbrains | 2 +- media/annotationPanel/app.js | 3 +- scripts/test_box_resource.js | 20 ++++++++++++ src/boxResourcePure.ts | 59 ++++++++++++++++++++++++++++++++++++ src/boxResourceStore.ts | 35 ++++++++++----------- 5 files changed, 100 insertions(+), 19 deletions(-) diff --git a/jetbrains b/jetbrains index 8340c97..a5513ad 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 8340c97abebcf4cf299bbd8c826a0aa967012a95 +Subproject commit a5513adf3cc95fac6bfeec46836dd754246e50d7 diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index ee3091a..71802a6 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -962,7 +962,8 @@ /* ---------- 删除 ---------- */ function deleteSelected() { - if (selectedIdx < 0) return; + const ann = annotations[selectedIdx]; + if (!ann || !isShown(ann)) return; pushUndo(); annotations.splice(selectedIdx, 1); selectedIdx = -1; diff --git a/scripts/test_box_resource.js b/scripts/test_box_resource.js index af83967..f10b33a 100644 --- a/scripts/test_box_resource.js +++ b/scripts/test_box_resource.js @@ -47,6 +47,9 @@ check( pure.runtimeWriteTarget(preferred) === path.join(ROOT, 'custom', 'boxes.json'), '已声明时发布目标是首选,即使文件还没创建', ); +const authoringPath = path.join(ROOT, 'ok_templates', 'boxes.json'); +check(pure.sameBoxFile(authoringPath, path.join(ROOT, 'ok_templates', '.', 'boxes.json')), '规范化后同一路径视为同一文件'); +check(!pure.sameBoxFile(authoringPath, PROBE), '标注文件和运行时探测位置不是同一个文件'); check(pure.boxPathError('screen.main_viewport') === undefined, 'screen.main_viewport 合法'); check(pure.boxPathError('main_viewport') === 'shallow', '少一层 group 不合法'); @@ -136,6 +139,23 @@ check(pure.isAnnotationVisible('b', toggled), '不在隐藏集合里的条目可 const statuses = pure.publishStatus(parsed.file, pure.parseRuntime(runtimeText).file); check(statuses.every((item) => item.status === 'same'), '刚发布的运行时与标注几何一致'); +const batchRect = [0, 0, 0.5, 0.5]; +const batchPixel = pure.rectToPixel(batchRect, 100, 100); +const edit = (fileName, boxPath) => ({ + fileName, + width: 100, + height: 100, + boxes: [{ path: boxPath, x: batchPixel.x, y: batchPixel.y, w: batchPixel.w, h: batchPixel.h, original: batchRect }], +}); +const existing = [ + { path: 'screen.a', image: '1.png', rect: batchRect }, + { path: 'screen.b', image: '2.png', rect: batchRect }, +]; +const conflict = pure.replaceAuthoringImages(existing, [edit('1.png', 'screen.a'), edit('2.png', 'screen.a')]); +check(conflict.error === 'duplicate', '后一张图路径冲突时整批替换失败'); +const applied = pure.replaceAuthoringImages(existing, [edit('1.png', 'screen.a'), edit('2.png', 'screen.b')]); +check(!applied.error && applied.boxes.map((box) => box.path).sort().join() === 'screen.a,screen.b', '两张图都合法时一起替换'); + const declared = config.boxesRuntimeOf({ boxes: { runtime: 'src/scene/boxes.json' } }); check(declared === 'src/scene/boxes.json', '约定文件的 boxes.runtime 归一化后可读'); check(config.boxesRuntimeOf({ boxes: { runtime: 42 } }) === undefined, '类型不对当没写'); diff --git a/src/boxResourcePure.ts b/src/boxResourcePure.ts index 17ee8da..22f4879 100644 --- a/src/boxResourcePure.ts +++ b/src/boxResourcePure.ts @@ -121,6 +121,13 @@ export function runtimeWriteTarget(plan: BoxRuntimePlan): string { return plan.preferred ?? plan.probeCandidates[0]; } +/** 规范化后是否指向同一路径。Windows 上大小写不计。符号链接由读盘侧再比一次真实路径。 */ +export function sameBoxFile(a: string, b: string): boolean { + const left = path.resolve(a); + const right = path.resolve(b); + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + export function boxRuntimeRelPaths(plan: BoxRuntimePlan, rootDir: string): string[] { const all = plan.preferred ? [plan.preferred, ...plan.probeCandidates] : plan.probeCandidates; return [...new Set(all)] @@ -181,6 +188,58 @@ export function rectForSave( return pixelToRect(pixel, width, height); } +export interface AuthoringEditBox { + path: string; + x: number; + y: number; + w: number; + h: number; + original?: BoxRect; + unchanged?: boolean; +} + +export interface AuthoringImageEdit { + fileName: string; + width: number; + height: number; + boxes: readonly AuthoringEditBox[]; +} + +/** + * 在内存里依次替换多张图的框。任一图不合法就整批失败,调用方此时还不能写盘。 + * 这样一次确认里的多张图要么一起留下,要么保持原文件。 + */ +export function replaceAuthoringImages( + existing: readonly AuthoringBox[], + edits: readonly AuthoringImageEdit[], +): { boxes: AuthoringBox[]; error?: string } { + let current = existing.slice(); + for (const edit of edits) { + const image = imageFileName(edit.fileName); + if (!image || edit.width <= 0 || edit.height <= 0) return { boxes: existing.slice(), error: 'image' }; + const kept = current.filter((box) => !sameImage(box.image, image)); + const taken = new Set(kept.map((box) => box.path)); + const next: AuthoringBox[] = []; + for (const box of edit.boxes) { + const pathError = boxPathError(box.path); + if (pathError) return { boxes: existing.slice(), error: pathError }; + if (taken.has(box.path)) return { boxes: existing.slice(), error: 'duplicate' }; + taken.add(box.path); + const rect = box.unchanged && box.original + ? box.original + : rectForSave(box.original, { x: box.x, y: box.y, w: box.w, h: box.h }, edit.width, edit.height); + if (!rect) return { boxes: existing.slice(), error: 'rect' }; + next.push({ path: box.path, image, rect }); + } + current = [...kept, ...next]; + } + return { boxes: current }; +} + +function sameImage(a: string, b: string): boolean { + return imageFileName(a).toLowerCase() === imageFileName(b).toLowerCase(); +} + /** 同一张原图上的像素框取最小包围矩形,再归一化。空列表返回 `undefined`。 */ export function unionOnImage(boxes: readonly PixelBox[], width: number, height: number): BoxRect | undefined { if (boxes.length === 0) return undefined; diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts index ed320ae..617f94c 100644 --- a/src/boxResourceStore.ts +++ b/src/boxResourceStore.ts @@ -17,7 +17,6 @@ import { parseRuntime, pixelToRect, publishBoxes, - rectForSave, resolveBoxRuntimePlan, runtimeWriteTarget, serializeAuthoring, @@ -25,6 +24,8 @@ import { unionOnImage, boxPathError, effectiveBoxRuntimeFile, + replaceAuthoringImages, + sameBoxFile, } from './boxResourcePure'; export interface EditedBox { @@ -40,6 +41,16 @@ function sameImage(a: string, b: string): boolean { return imageFileName(a).toLowerCase() === imageFileName(b).toLowerCase(); } +/** 发布目标和标注文件是否是同一个文件。已存在的符号链接按真实路径比较。 */ +function sameStoredFile(a: string, b: string): boolean { + try { + if (fs.existsSync(a) && fs.existsSync(b)) return sameBoxFile(fs.realpathSync(a), fs.realpathSync(b)); + } catch { + // 读真实路径失败时退回规范化路径,避免把标注文件当成可写的运行时目标。 + } + return sameBoxFile(a, b); +} + function readText(file: string): { text?: string; missing?: boolean; error?: boolean } { try { if (!fs.existsSync(file)) return { missing: true }; @@ -109,20 +120,9 @@ export function replaceImageBoxes( if (!image || width <= 0 || height <= 0) return 'image'; const parsed = readAuthoringResult(rootDir, templatesDirectory); if (parsed.errors.length) return 'parse'; - const current = parsed.file; - const kept = current.boxes.filter((box) => !sameImage(box.image, image)); - const taken = new Set(kept.map((box) => box.path)); - const next: AuthoringBox[] = []; - for (const box of boxes) { - const pathError = boxPathError(box.path); - if (pathError) return pathError; - if (taken.has(box.path)) return 'duplicate'; - taken.add(box.path); - const rect = rectForSave(box.original, { x: box.x, y: box.y, w: box.w, h: box.h }, width, height); - if (!rect) return 'rect'; - next.push({ path: box.path, image, rect }); - } - const text = serializeAuthoring({ version: 1, boxes: [...kept, ...next] }); + const merged = replaceAuthoringImages(parsed.file.boxes, [{ fileName, width, height, boxes }]); + if (merged.error) return merged.error; + const text = serializeAuthoring({ version: 1, boxes: merged.boxes }); return writeText(authoringFile(rootDir, templatesDirectory), text) ? undefined : 'write'; } @@ -147,9 +147,10 @@ export function publishRuntime(rootDir: string, templatesDirectory: string, decl if (parsed.errors.length) return false; const runtime = readRuntimeResult(rootDir, declared, fromConfigPy); if (runtime.errors.length) return false; - const authoring = parsed.file; - const text = serializeRuntime(publishBoxes(authoring)); + const authoringPath = authoringFile(rootDir, templatesDirectory); const target = runtimeWriteTarget(resolveBoxRuntimePlan(rootDir, declared, fromConfigPy)); + if (sameStoredFile(authoringPath, target)) return false; + const text = serializeRuntime(publishBoxes(parsed.file)); return writeText(target, text); } From c213b01af5dee2fc652181dd909b61b1da66a1e2 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:07:08 +0800 Subject: [PATCH 07/19] chore(jetbrains): point the submodule at the conflict resolution The JetBrains box branch now includes the dialog-width fix from its main, and box mode still uses its own hint. --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index a5513ad..ab148e7 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit a5513adf3cc95fac6bfeec46836dd754246e50d7 +Subproject commit ab148e7893ec1e57afc8c39eb4a32c29d71ff178 From cf2bc18914436d6f850117dbec43ba71ef22104c Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:12:31 +0800 Subject: [PATCH 08/19] chore(jetbrains): point the submodule at the locale fix The JetBrains branch now merges main and includes the missing box translations. --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index ab148e7..07d1563 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit ab148e7893ec1e57afc8c39eb4a32c29d71ff178 +Subproject commit 07d1563f07ecae704b5dd8815f8d3b79bba9ad09 From ce2aad8b793460613fd8a36e6136a4f35779e356 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:28:33 +0800 Subject: [PATCH 09/19] fix(boxes): refuse a rectangle that the next read would reject Keep the authoring file loadable, and point the JetBrains submodule at the same check. --- jetbrains | 2 +- scripts/test_box_resource.js | 11 +++++++++++ src/boxResourcePure.ts | 9 ++++++++- src/boxResourceStore.ts | 2 ++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/jetbrains b/jetbrains index 07d1563..0686c0a 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 07d1563f07ecae704b5dd8815f8d3b79bba9ad09 +Subproject commit 0686c0a7c713923c81c14372943213a539dcf46a diff --git a/scripts/test_box_resource.js b/scripts/test_box_resource.js index f10b33a..3f02461 100644 --- a/scripts/test_box_resource.js +++ b/scripts/test_box_resource.js @@ -151,6 +151,17 @@ const existing = [ { path: 'screen.a', image: '1.png', rect: batchRect }, { path: 'screen.b', image: '2.png', rect: batchRect }, ]; +check(pure.isStorableRect([0, 0, 1, 1]), '贴边矩形可以保存'); +check(!pure.isStorableRect([-0.1, 0, 0.5, 0.5]), '越出左边界的矩形拒绝写入'); +check(!pure.isStorableRect([0, 0, 1.1, 0.5]), '越出右边界的矩形拒绝写入'); +const outside = pure.replaceAuthoringImages(existing, [{ + fileName: '1.png', + width: 100, + height: 100, + boxes: [{ path: 'screen.a', x: -10, y: 0, w: 20, h: 20 }], +}]); +check(outside.error === 'rect', '越界像素框整批替换失败,原文件不会被写坏'); + const conflict = pure.replaceAuthoringImages(existing, [edit('1.png', 'screen.a'), edit('2.png', 'screen.a')]); check(conflict.error === 'duplicate', '后一张图路径冲突时整批替换失败'); const applied = pure.replaceAuthoringImages(existing, [edit('1.png', 'screen.a'), edit('2.png', 'screen.b')]); diff --git a/src/boxResourcePure.ts b/src/boxResourcePure.ts index 22f4879..46f77c0 100644 --- a/src/boxResourcePure.ts +++ b/src/boxResourcePure.ts @@ -228,7 +228,7 @@ export function replaceAuthoringImages( const rect = box.unchanged && box.original ? box.original : rectForSave(box.original, { x: box.x, y: box.y, w: box.w, h: box.h }, edit.width, edit.height); - if (!rect) return { boxes: existing.slice(), error: 'rect' }; + if (!rect || !isStorableRect(rect)) return { boxes: existing.slice(), error: 'rect' }; next.push({ path: box.path, image, rect }); } current = [...kept, ...next]; @@ -412,6 +412,13 @@ function parseBoxEntry(entry: unknown, requireImage: boolean): { box?: Authoring return { box: { path: record.path.trim(), image, rect } }; } +/** 与解析时的矩形约束一致。写盘前拒绝,避免下次读取把整个文件判为损坏。 */ +export function isStorableRect(rect: readonly number[]): boolean { + if (rect.length !== 4 || !rect.every((item) => Number.isFinite(item))) return false; + const [left, top, right, bottom] = rect; + return left >= 0 && top >= 0 && right <= 1 && bottom <= 1 && left < right && top < bottom; +} + function parseRect(value: unknown): BoxRect | undefined { if (!Array.isArray(value) || value.length !== 4) return undefined; if (!value.every((item) => typeof item === 'number' && Number.isFinite(item))) return undefined; diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts index 617f94c..c963dbf 100644 --- a/src/boxResourceStore.ts +++ b/src/boxResourceStore.ts @@ -15,6 +15,7 @@ import { imageFileName, parseAuthoring, parseRuntime, + isStorableRect, pixelToRect, publishBoxes, resolveBoxRuntimePlan, @@ -127,6 +128,7 @@ export function replaceImageBoxes( } export function addBox(rootDir: string, templatesDirectory: string, boxPath: string, image: string, rect: BoxRect): string | undefined { + if (!isStorableRect(rect)) return 'rect'; const pathError = boxPathError(boxPath); if (pathError) return pathError; const fileName = imageFileName(image); From 7f1037f4a115490273aa021a996c98820468357f Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:29:05 +0800 Subject: [PATCH 10/19] chore(jetbrains): point the submodule at the unsaved path fix A path released by a rename in this dialog can now be used on another image before save. --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index 0686c0a..d39a5bf 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 0686c0a7c713923c81c14372943213a539dcf46a +Subproject commit d39a5bf0702f0fab0322e37bd75f601180d69878 From 4c0fd04c11d85e416d7f3902141304a3f1113703 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:40:50 +0800 Subject: [PATCH 11/19] fix(boxes): keep box and annotation management on the same page An image that is only on disk can be edited, swapped, and deleted without either JSON file already existing, and records match the full file name. --- jetbrains | 2 +- l10n/bundle.l10n.es.json | 4 +- l10n/bundle.l10n.ja.json | 4 +- l10n/bundle.l10n.json | 4 +- l10n/bundle.l10n.ko.json | 4 +- l10n/bundle.l10n.zh-cn.json | 4 +- l10n/bundle.l10n.zh-tw.json | 4 +- media/boxPanel/app.js | 42 +++++++- media/boxPanel/index.html | 2 +- media/boxPanel/style.css | 31 ++++++ media/templateAssetPanel/app.js | 4 +- media/templateAssetPanel/index.html | 1 + scripts/test_save_to_assets.js | 90 +++++++++++++++++ src/annotationPanel.ts | 34 +++++-- src/boxPanels.ts | 63 ------------ src/boxResourceStore.ts | 34 +++++++ src/extension.ts | 5 +- src/localization.ts | 3 + src/templateAssetData.ts | 60 ++++++++--- src/templateAssetPanel.ts | 150 ++++++++++++++++++++++++---- 20 files changed, 426 insertions(+), 119 deletions(-) diff --git a/jetbrains b/jetbrains index d39a5bf..00b404a 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit d39a5bf0702f0fab0322e37bd75f601180d69878 +Subproject commit 00b404ad728aa543bc30291af6d5b19e03a3fe35 diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json index e613def..3111913 100644 --- a/l10n/bundle.l10n.es.json +++ b/l10n/bundle.l10n.es.json @@ -217,5 +217,7 @@ "Annotations changed while confirming. Retry the swap.": "Las anotaciones cambiaron durante la confirmación. Vuelve a intentar el intercambio.", "Cannot read image size, so annotations cannot be swapped.": "No se pudo leer el tamaño de la imagen, así que no se pueden intercambiar las anotaciones.", "Neither image has annotations, nothing to swap.": "Ninguna de las dos imágenes tiene anotaciones, no hay nada que intercambiar.", - "Could not save the box resource.": "No se pudo guardar el recurso de cuadros." + "Could not save the box resource.": "No se pudo guardar el recurso de cuadros.", + "Could not save annotations.": "No se pudieron guardar las anotaciones.", + "Publish": "Publicar" } diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index f25b5e6..19ad77e 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -217,5 +217,7 @@ "Annotations changed while confirming. Retry the swap.": "確認中にアノテーションが変更されました。交換をやり直してください。", "Cannot read image size, so annotations cannot be swapped.": "画像サイズを読み取れないため、アノテーションを交換できません。", "Neither image has annotations, nothing to swap.": "どちらの画像にもアノテーションがないため、交換するものはありません。", - "Could not save the box resource.": "ボックス資源を保存できませんでした。" + "Could not save the box resource.": "ボックス資源を保存できませんでした。", + "Could not save annotations.": "注釈を保存できませんでした。", + "Publish": "公開" } diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 4ff9bfd..134db26 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -217,5 +217,7 @@ "Annotations changed while confirming. Retry the swap.": "Annotations changed while confirming. Retry the swap.", "Cannot read image size, so annotations cannot be swapped.": "Cannot read image size, so annotations cannot be swapped.", "Neither image has annotations, nothing to swap.": "Neither image has annotations, nothing to swap.", - "Could not save the box resource.": "Could not save the box resource." + "Could not save the box resource.": "Could not save the box resource.", + "Could not save annotations.": "Could not save annotations.", + "Publish": "Publish" } diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index 699c165..9ed8e89 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -217,5 +217,7 @@ "Annotations changed while confirming. Retry the swap.": "확인하는 동안 어노테이션이 변경되었습니다. 교환을 다시 시도하세요.", "Cannot read image size, so annotations cannot be swapped.": "이미지 크기를 읽을 수 없어 어노테이션을 교환할 수 없습니다.", "Neither image has annotations, nothing to swap.": "두 이미지 모두 어노테이션이 없어 교환할 것이 없습니다.", - "Could not save the box resource.": "박스 리소스를 저장하지 못했습니다." + "Could not save the box resource.": "박스 리소스를 저장하지 못했습니다.", + "Could not save annotations.": "주석을 저장하지 못했습니다.", + "Publish": "게시" } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 11da23e..b6ce51a 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -217,5 +217,7 @@ "Annotations changed while confirming. Retry the swap.": "确认期间标注已更改,请重新交换。", "Cannot read image size, so annotations cannot be swapped.": "读不出图片尺寸,无法交换标注。", "Neither image has annotations, nothing to swap.": "两张图都没有标注,无需交换。", - "Could not save the box resource.": "无法保存框资源。" + "Could not save the box resource.": "无法保存框资源。", + "Could not save annotations.": "无法保存标注。", + "Publish": "发布" } diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index 651ce5f..8f2d5f3 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -217,5 +217,7 @@ "Annotations changed while confirming. Retry the swap.": "確認期間標註已變更,請重新交換。", "Cannot read image size, so annotations cannot be swapped.": "讀不出圖片尺寸,無法交換標註。", "Neither image has annotations, nothing to swap.": "兩張圖都沒有標註,無需交換。", - "Could not save the box resource.": "無法儲存框資源。" + "Could not save the box resource.": "無法儲存框資源。", + "Could not save annotations.": "無法儲存標註。", + "Publish": "發布" } diff --git a/media/boxPanel/app.js b/media/boxPanel/app.js index a606bcd..446277d 100644 --- a/media/boxPanel/app.js +++ b/media/boxPanel/app.js @@ -11,16 +11,52 @@ document.getElementById('refreshBtn').onclick = () => vscode.postMessage({ type: 'refresh' }); publish.onclick = () => vscode.postMessage({ type: 'publish' }); + const cards = new Map(); + function fillThumb(card, url) { + const box = card.querySelector('.thumb-box'); + if (!box || box.querySelector('img')) return; + const img = document.createElement('img'); + img.alt = ''; + img.addEventListener('error', () => img.remove()); + box.textContent = ''; + box.append(img); + img.src = url; + } window.addEventListener('message', (event) => { const msg = event.data; - if (msg.type !== 'rows') return; const rows = document.getElementById('rows'); + if (msg.type === 'thumbs' && mode === 'assets') { + (msg.items || []).forEach((item) => { + const card = cards.get(item.id); + if (card) fillThumb(card, item.url); + }); + return; + } + if (msg.type !== 'rows') return; + cards.clear(); while (rows.firstChild) rows.removeChild(rows.firstChild); + rows.className = mode === 'assets' ? 'asset-grid' : ''; (msg.rows || []).forEach((row) => { const button = document.createElement('button'); button.type = 'button'; - button.className = 'box-row'; - button.textContent = row.label; + if (mode === 'assets') { + button.className = 'card asset-card'; + button.title = row.name; + const box = document.createElement('div'); + box.className = 'thumb-box'; + box.textContent = '…'; + const name = document.createElement('div'); + name.className = 'asset-name'; + name.textContent = row.name; + const count = document.createElement('div'); + count.className = 'asset-count'; + count.textContent = String(row.count); + button.append(box, name, count); + cards.set(row.id, button); + } else { + button.className = 'box-row'; + button.textContent = row.label; + } let clickTimer = 0; button.onclick = () => { window.clearTimeout(clickTimer); diff --git a/media/boxPanel/index.html b/media/boxPanel/index.html index cb0bd55..8e4b1ee 100644 --- a/media/boxPanel/index.html +++ b/media/boxPanel/index.html @@ -2,7 +2,7 @@ - + diff --git a/media/boxPanel/style.css b/media/boxPanel/style.css index 82385ae..3888a1b 100644 --- a/media/boxPanel/style.css +++ b/media/boxPanel/style.css @@ -1,6 +1,37 @@ .toolbar { border-bottom: var(--border-width) solid var(--border); } .info { color: var(--text-muted); font-size: var(--font-xs); } #rows { display: flex; flex-direction: column; gap: var(--space-xs); padding: var(--space-sm); } +#rows.asset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + gap: var(--space-sm); +} +.asset-card { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + cursor: pointer; + text-align: center; + color: var(--text-primary); +} +.thumb-box { + height: 96px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-row); +} +.thumb-box img { max-width: 100%; max-height: 100%; image-rendering: pixelated; } +.asset-name { + font-size: var(--font-xs); + font-weight: 600; + padding: 4px 6px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.asset-count { font-size: var(--font-xs); color: var(--text-muted); padding: 0 6px 6px; } .box-row { text-align: left; background: var(--bg-row); diff --git a/media/templateAssetPanel/app.js b/media/templateAssetPanel/app.js index e22baa4..c62cd0d 100644 --- a/media/templateAssetPanel/app.js +++ b/media/templateAssetPanel/app.js @@ -22,7 +22,9 @@ hardForegroundChk.addEventListener('change', () => { vscode.setState(Object.assign({}, vscode.getState() || {}, { hardForeground: hardForegroundChk.checked })); }); - document.getElementById('saveBtn').textContent = t('saveToAssetsTitle'); + const assetMode = (document.getElementById('assetMode')?.textContent || 'annotations').trim(); + document.getElementById('saveBtn').textContent = assetMode === 'boxes' ? t('publish') : t('saveToAssetsTitle'); + if (assetMode === 'boxes') document.title = t('boxAssetsTitle'); function updateCount() { const shown = shownCount(); diff --git a/media/templateAssetPanel/index.html b/media/templateAssetPanel/index.html index d4011e4..6c7dc98 100644 --- a/media/templateAssetPanel/index.html +++ b/media/templateAssetPanel/index.html @@ -34,6 +34,7 @@

+ diff --git a/scripts/test_save_to_assets.js b/scripts/test_save_to_assets.js index f204c3f..584b805 100644 --- a/scripts/test_save_to_assets.js +++ b/scripts/test_save_to_assets.js @@ -709,6 +709,30 @@ async function test_annotationSwapUsesExactFileName() { 'an unregistered same-stem file must not resolve to another image'); assert(data.swapAnnotationsForImages(path.join(templateDir, 'same.bmp'), png, [], []) === false, 'an unregistered same-stem file must not write annotations'); + const bmp = path.join(templateDir, 'same.bmp'); + fs.writeFileSync(bmp, createPng(20, 10, 1, 2, 3)); + assert(data.setAnnotationsForImage(bmp, [ + { category: 'bmp-only', x: 1, y: 1, w: 2, h: 2 }, + ]), 'same stem with another extension must be registered on its own'); + data.save(); + const afterDraw = JSON.parse(fs.readFileSync(cocoPath, 'utf-8')); + assert(afterDraw.annotations.some((ann) => ann.image_id === 1 && ann.category_id === 1), + 'drawing on another extension must not replace the PNG boxes'); + const bmpEntry = afterDraw.images.find((img) => img.file_name === 'same.bmp'); + assert(bmpEntry && afterDraw.annotations.some((ann) => ann.image_id === bmpEntry.id), + 'the new extension keeps its own box'); + data.deleteImage(bmp); + const afterDelete = JSON.parse(fs.readFileSync(cocoPath, 'utf-8')); + assert(afterDelete.images.some((img) => img.file_name === 'same.png'), + 'deleting one extension must leave the other image record'); + assert(afterDelete.annotations.some((ann) => ann.image_id === 1), + 'deleting one extension must leave the other image boxes'); + assert(!afterDelete.images.some((img) => img.file_name === 'same.bmp'), + 'deleting the new extension removes only that record'); + assert(afterDelete.images.some((img) => img.file_name === 'same.jpg'), + 'deleting the bmp must leave the JPG record'); + assert(afterDelete.annotations.some((ann) => ann.image_id === 1), + 'the PNG boxes survive deleting another extension'); console.log('[PASS] test_annotationSwapUsesExactFileName'); } finally { teardown(); @@ -752,6 +776,70 @@ async function test_annotationSwapFallbackRequiresSameFile() { } } +/* ========== 测试 13:没登记进标注文件的图也能交换,空的一侧交换后仍是空的 ========== */ + +async function test_annotationSwapRegistersImageWithoutBoxes() { + setup(); + const cocoPath = path.join(templateDir, 'coco_annotations.json'); + try { + fs.writeFileSync(cocoPath, JSON.stringify({ + images: [{ id: 1, file_name: 'a.png', width: 100, height: 50 }], + annotations: [ + { id: 1, image_id: 1, category_id: 1, bbox: [1, 2, 3, 4], area: 12, iscrowd: 0 }, + ], + categories: [{ id: 1, name: 'mark', supercategory: '' }], + })); + const data = new TemplateAssetData(tmpDir); + data.load(); + const registered = path.join(templateDir, 'a.png'); + const fresh = path.join(templateDir, 'fish_ok_page.png'); + const swapped = data.swapAnnotationsForImages( + fresh, + registered, + [{ category: 'mark', x: 10, y: 20, w: 30, h: 40 }], + [], + { width: 1920, height: 1080 }, + { width: 100, height: 50 }, + ); + assert(swapped, 'an image that is only on disk should still swap'); + const saved = JSON.parse(fs.readFileSync(cocoPath, 'utf-8')); + const fish = saved.images.find((img) => img.file_name === 'fish_ok_page.png'); + assert(fish && fish.width === 1920 && fish.height === 1080, 'the new image is recorded with its real size'); + assert(saved.annotations.length === 1 && saved.annotations[0].image_id === fish.id, + 'the boxes move onto the previously empty image'); + assert(!saved.annotations.some((ann) => ann.image_id === 1), 'the other image is left with no boxes'); + console.log('[PASS] test_annotationSwapRegistersImageWithoutBoxes'); + } finally { + teardown(); + } +} + +/* ========== 测试 14:没登记的图上画框会补登记并写入标注 ========== */ + +async function test_setAnnotationsRegistersMissingImage() { + setup(); + const cocoPath = path.join(templateDir, 'coco_annotations.json'); + try { + fs.writeFileSync(cocoPath, JSON.stringify({ images: [], annotations: [], categories: [] })); + fs.writeFileSync(path.join(templateDir, 'fish_ok_page.png'), createPng(80, 40, 10, 20, 30)); + const data = new TemplateAssetData(tmpDir); + data.load(); + const image = path.join(templateDir, 'fish_ok_page.png'); + assert(data.setAnnotationsForImage(image, [{ category: 'hook', x: 1, y: 2, w: 3, h: 4 }]), + 'drawing on an unregistered image should save'); + data.save(); + const saved = JSON.parse(fs.readFileSync(cocoPath, 'utf-8')); + const fish = saved.images.find((img) => img.file_name === 'fish_ok_page.png'); + assert(fish && fish.width === 80 && fish.height === 40, 'the image is registered from its header'); + assert(saved.annotations.length === 1 && saved.annotations[0].image_id === fish.id, + 'the new box is stored on that image'); + assert(saved.categories.some((cat) => cat.name === 'hook'), 'the category is created'); + console.log('[PASS] test_setAnnotationsRegistersMissingImage'); + } finally { + teardown(); + } +} + /* ========== 运行所有测试 ========== */ const tests = [ @@ -767,6 +855,8 @@ const tests = [ test_annotationSwapPersistsOrRollsBack, test_annotationSwapUsesExactFileName, test_annotationSwapFallbackRequiresSameFile, + test_annotationSwapRegistersImageWithoutBoxes, + test_setAnnotationsRegistersMissingImage, ]; let passed = 0; diff --git a/src/annotationPanel.ts b/src/annotationPanel.ts index eecee91..0f30dee 100644 --- a/src/annotationPanel.ts +++ b/src/annotationPanel.ts @@ -104,7 +104,7 @@ class AnnotationController { } // 读取标注数据 - const annotations = this.data.getAnnotationsForImage(imagePath); + const annotations = this.data.getAnnotationsForImage(imagePath, true); // 获取所有分类名(用于验证唯一性) const allCategories: Record = {}; @@ -164,11 +164,9 @@ class AnnotationController { break; case 'save': { if (!this._currentImage || !msg.annotations) break; - this.data.setAnnotationsForImage( - this._currentImage, - msg.annotations.map((a) => ({ category: a.category, x: a.x, y: a.y, w: a.w, h: a.h })), - ); - this.data.save(); + if (!this.persistAnnotations(this._currentImage, msg.annotations.map((a) => ({ + category: a.category, x: a.x, y: a.y, w: a.w, h: a.h, + })))) break; this.onSaved(this._currentImage); break; } @@ -199,14 +197,13 @@ class AnnotationController { } case 'deleteAnnotation': { if (!this._currentImage || !msg.annotation) break; - const annotations = this.data.getAnnotationsForImage(this._currentImage); + const annotations = this.data.getAnnotationsForImage(this._currentImage, true); const annId = (msg.annotation as unknown as { id: number }).id; const filtered = annotations.filter((a) => a.id !== annId); - this.data.setAnnotationsForImage( + if (!this.persistAnnotations( this._currentImage, filtered.map((a) => ({ category: a.categoryName, x: a.bbox[0], y: a.bbox[1], w: a.bbox[2], h: a.bbox[3] })), - ); - this.data.save(); + )) break; this.onSaved(this._currentImage); // 重新加载 await this.loadImage(this._currentImage); @@ -230,6 +227,23 @@ class AnnotationController { } } + private persistAnnotations( + imagePath: string, + annotations: Array<{ category: string; x: number; y: number; w: number; h: number }>, + ): boolean { + try { + if (!this.data.setAnnotationsForImage(imagePath, annotations)) { + void vscode.window.showErrorMessage(tr('Could not save annotations.')); + return false; + } + this.data.save(); + return true; + } catch { + void vscode.window.showErrorMessage(tr('Could not save annotations.')); + return false; + } + } + dispose(): void { this.disposed = true; this.generation++; diff --git a/src/boxPanels.ts b/src/boxPanels.ts index 9c4083c..6716a94 100644 --- a/src/boxPanels.ts +++ b/src/boxPanels.ts @@ -4,11 +4,9 @@ import * as vscode from 'vscode'; import { annotationHtml } from './annotationPanel'; import { boxesForImage, - publishRuntime, readAuthoringFile, readRuntimeFile, replaceImageBoxes, - runtimeOnlyPaths, EditedBox, } from './boxResourceStore'; import { BoxRect, rectToPixel } from './boxResourcePure'; @@ -40,67 +38,6 @@ function panelHtml(webview: vscode.Webview, extensionUri: vscode.Uri, mode: stri )); } -export class BoxAssetViewProvider implements vscode.WebviewViewProvider { - static readonly viewType = 'okScriptToolkit.boxAssets'; - private view: vscode.WebviewView | undefined; - - constructor( - private readonly data: TemplateAssetData, - private readonly extensionUri: vscode.Uri, - ) {} - - resolveWebviewView(webviewView: vscode.WebviewView): void { - this.view = webviewView; - webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; - webviewView.webview.html = panelHtml(webviewView.webview, this.extensionUri, 'assets'); - webviewView.webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }); - } - - private root(): string { - return getProjectConfig().projectDir; - } - - private async onMessage(msg: { type?: string; id?: string; clicks?: number }): Promise { - const root = this.root(); - if (!root || !this.view) return; - if (msg.type === 'ready' || msg.type === 'refresh') { - this.data.load(); - const authoring = readAuthoringFile(root, templatesDirectory(root)); - const rows = this.data.listImages().map((imagePath) => ({ - id: imagePath, - label: `${path.basename(imagePath)} (${boxesForImage(authoring, path.basename(imagePath)).length})`, - })); - void this.view.webview.postMessage({ type: 'rows', rows }); - return; - } - if (msg.type === 'activate' && msg.clicks === 2 && msg.id) { - openBoxEditor(this.extensionUri, this.data, msg.id); - return; - } - if (msg.type === 'publish') { - const templates = templatesDirectory(root); - const args = runtimeArgs(root); - const authoring = readAuthoringFile(root, templates); - const runtime = readRuntimeFile(root, args.declared, args.fromConfig); - const dropped = runtimeOnlyPaths(authoring, runtime); - if (dropped.length) { - const answer = await vscode.window.showWarningMessage( - dropped.join('\n'), - { modal: true }, - 'Publish', - ); - if (answer !== 'Publish') return; - } - const published = publishRuntime(root, templates, args.declared, args.fromConfig); - if (!published) { - void vscode.window.showErrorMessage(tr('Could not save the box resource.')); - return; - } - void this.onMessage({ type: 'refresh' }); - } - } -} - export class BoxGalleryViewProvider implements vscode.WebviewViewProvider { static readonly viewType = 'okScriptToolkit.boxGallery'; private view: vscode.WebviewView | undefined; diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts index c963dbf..b0b2c4a 100644 --- a/src/boxResourceStore.ts +++ b/src/boxResourceStore.ts @@ -109,6 +109,40 @@ export function boxesForImage(file: AuthoringFile, fileName: string): AuthoringB return file.boxes.filter((box) => sameImage(box.image, fileName)); } +/** 删图时去掉它的框。文件还不存在就什么都不写。 */ +export function removeImageBoxes(rootDir: string, templatesDirectory: string, fileName: string): boolean { + const target = authoringFile(rootDir, templatesDirectory); + const parsed = readAuthoringResult(rootDir, templatesDirectory); + if (parsed.errors.length) return false; + if (!fs.existsSync(target)) return true; + const next = parsed.file.boxes.filter((box) => !sameImage(box.image, fileName)); + if (next.length === parsed.file.boxes.length) return true; + return writeText(target, serializeAuthoring({ version: 1, boxes: next })); +} + +/** 两张图的框整套对调。框坐标是相对整图的,不按像素再缩放。文件还不存在且两边都没有框时不创建文件。 */ +export function swapImageBoxes(rootDir: string, templatesDirectory: string, fileA: string, fileB: string): boolean { + const target = authoringFile(rootDir, templatesDirectory); + const parsed = readAuthoringResult(rootDir, templatesDirectory); + if (parsed.errors.length) return false; + const nameA = imageFileName(fileA); + const nameB = imageFileName(fileB); + let changed = false; + const next = parsed.file.boxes.map((box) => { + if (sameImage(box.image, nameA)) { + changed = true; + return { ...box, image: nameB }; + } + if (sameImage(box.image, nameB)) { + changed = true; + return { ...box, image: nameA }; + } + return box; + }); + if (!changed) return true; + return writeText(target, serializeAuthoring({ version: 1, boxes: next })); +} + export function replaceImageBoxes( rootDir: string, templatesDirectory: string, diff --git a/src/extension.ts b/src/extension.ts index 3fb34e9..374e0fb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,7 +32,8 @@ import { } from './templateAssetPanel'; import { TempScreenshotStore } from './tempScreenshotStore'; import { TempScreenshotViewProvider } from './tempScreenshotPanel'; -import { BoxAssetViewProvider, BoxGalleryViewProvider } from './boxPanels'; +import { BoxGalleryViewProvider } from './boxPanels'; +import { BoxAssetViewProvider } from './templateAssetPanel'; /** 缩略图缓存 key 版本:内容 hash 化后旧命名(t_/a_)需要清理一次 */ const THUMB_KEY_VERSION = 'content-hash-v2'; @@ -397,7 +398,7 @@ export function activate(context: vscode.ExtensionContext): void { ), vscode.window.registerWebviewViewProvider( BoxAssetViewProvider.viewType, - new BoxAssetViewProvider(templateAssetData, context.extensionUri), + new BoxAssetViewProvider(templateAssetData, thumbDir, context.extensionUri, tempScreenshotStore), ), vscode.window.registerWebviewViewProvider( BoxGalleryViewProvider.viewType, diff --git a/src/localization.ts b/src/localization.ts index c901703..f29da9f 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -300,6 +300,7 @@ const ZH_CN: WebviewStrings = { templateSize: '尺寸: {width}×{height}', templateSource: '来源: {path}', templateAssetsTitle: '模板素材管理', + boxAssetsTitle: '框资源管理', templateAssetsHint: '单击/双击=打开标注编辑器', noTemplateAssets: '暂无模板素材。\n点击 Import 导入图片。', noTemplateAssetsHint: '暂无模板素材。', @@ -524,6 +525,7 @@ const EN: WebviewStrings = { generateNeedSelection: 'Select at least one annotation', publish: 'Publish', assetsHint: 'Double-click an image to edit boxes. Publish writes the runtime file.', + boxAssetsTitle: 'Box Assets', galleryHint: 'Click inserts to_box(). Double-click copies the attribute path.', assetsEmpty: 'No template images yet. Add them in Template Assets.', galleryEmpty: 'No runtime boxes yet. Publish them from Box Assets.', @@ -625,6 +627,7 @@ const ZH_TW: WebviewStrings = { generateNeedSelection: '至少選擇一個標註', publish: '發布', assetsHint: '雙擊原圖編輯框。發布後才會進入執行時。', + boxAssetsTitle: '框資源管理', galleryHint: '單擊插入 to_box()。雙擊複製屬性路徑。', assetsEmpty: '沒有模板原圖。請先在標註管理裡新增。', galleryEmpty: '還沒有執行時框。請先在框資源管理裡發布。', diff --git a/src/templateAssetData.ts b/src/templateAssetData.ts index 8009c94..c8913c1 100644 --- a/src/templateAssetData.ts +++ b/src/templateAssetData.ts @@ -11,6 +11,7 @@ import { tr } from './localization'; import { labelEnumNameSetting, templatesDirectory } from './projectConfig'; import { PYTHON_KEYWORDS, writableClassName } from './labelEnumGuard'; import { isPathInsideRoot } from './saveToAssetsPure'; +import { removeImageBoxes } from './boxResourceStore'; /* ---------------- COCO 数据类型 ---------------- */ @@ -250,9 +251,29 @@ export class TemplateAssetData { return undefined; } + /** + * 交换落盘前补一条图片记录。已有精确记录就用它。 + * 同名不同扩展名不能借别人的记录,必须按这次的文件名新建。 + */ + private ensureSwapImage(filePath: string, size?: { width: number; height: number }): number | undefined { + const existing = this.getSwapImageEntry(filePath); + if (existing) return existing.id; + if (!size || size.width <= 0 || size.height <= 0) return undefined; + const fileName = path.basename(filePath); + const located = path.resolve(this.templateFolder, fileName); + if (path.resolve(filePath) !== located && !sameFile(filePath, located)) return undefined; + let maxId = 0; + for (const img of this.cocoData.images) { + if (img.id > maxId) maxId = img.id; + } + const id = maxId + 1; + this.cocoData.images.push({ id, file_name: fileName, width: size.width, height: size.height }); + return id; + } + addImageEntry(imagePath: string, width: number, height: number): void { const filename = path.basename(imagePath); - if (this.getImageEntryForPath(imagePath)) return; + if (this.getSwapImageEntry(imagePath)) return; let maxId = 0; for (const img of this.cocoData.images) { if (img.id > maxId) maxId = img.id; @@ -262,7 +283,7 @@ export class TemplateAssetData { } removeImageEntry(imagePath: string): void { - const imageId = this.getImageId(imagePath); + const imageId = this.getSwapImageEntry(imagePath)?.id; if (imageId === undefined) return; this.cocoData.images = this.cocoData.images.filter((img) => img.id !== imageId); this.cocoData.annotations = this.cocoData.annotations.filter((ann) => ann.image_id !== imageId); @@ -283,9 +304,12 @@ export class TemplateAssetData { })); } - setAnnotationsForImage(imagePath: string, annotations: Array<{ category: string; x: number; y: number; w: number; h: number }>): void { - const imageId = this.getImageId(imagePath); - if (imageId === undefined) return; + setAnnotationsForImage(imagePath: string, annotations: Array<{ category: string; x: number; y: number; w: number; h: number }>): boolean { + let imageId = this.getSwapImageEntry(imagePath)?.id; + if (imageId === undefined) { + imageId = this.ensureSwapImage(imagePath, this.resolveImageSize(imagePath)); + } + if (imageId === undefined) return false; // 移除旧标注 this.cocoData.annotations = this.cocoData.annotations.filter((ann) => ann.image_id !== imageId); // 添加新标注 @@ -307,6 +331,7 @@ export class TemplateAssetData { } this._cleanupCategories(); this._dirty = true; + return true; } /** @@ -321,19 +346,18 @@ export class TemplateAssetData { * 在副本上先摘掉旧标注、写回两边、清理分类,再一次性保存。 * 保存失败时恢复原内存数据,磁盘文件也不会被截断。 * - * 返回 false 只表示"这两张图没法交换"(任一未登记进 COCO,或指向同一张图), - * 此时**什么都没改** —— 调用方据此报错,不要报"已交换"。 + * 返回 false 只表示这两张图没法交换(读不出尺寸,或指向同一张图)。 + * 磁盘上有、但还没写进标注文件的图会在这次保存里补登记;它原来没有框, + * 交换后拿到的就是对方的框,对方则变成没有框。失败时什么都不改。 */ swapAnnotationsForImages( pathA: string, pathB: string, boxesForA: Array<{ category: string; x: number; y: number; w: number; h: number }>, boxesForB: Array<{ category: string; x: number; y: number; w: number; h: number }>, + sizeA?: { width: number; height: number }, + sizeB?: { width: number; height: number }, ): boolean { - const idA = this.getSwapImageEntry(pathA)?.id; - const idB = this.getSwapImageEntry(pathB)?.id; - if (idA === undefined || idB === undefined || idA === idB) return false; - const previousData = this.cocoData; const previousDirty = this._dirty; this.cocoData = { @@ -341,6 +365,13 @@ export class TemplateAssetData { annotations: [...previousData.annotations], categories: [...previousData.categories], }; + const idA = this.ensureSwapImage(pathA, sizeA); + const idB = this.ensureSwapImage(pathB, sizeB); + if (idA === undefined || idB === undefined || idA === idB) { + this.cocoData = previousData; + this._dirty = previousDirty; + return false; + } try { this.cocoData.annotations = this.cocoData.annotations.filter( (ann) => ann.image_id !== idA && ann.image_id !== idB, @@ -404,7 +435,7 @@ export class TemplateAssetData { /* ---------- 获取图片关联的分类名 ---------- */ getCategoriesForImage(imagePath: string): string[] { - const imageId = this.getImageId(imagePath); + const imageId = this.getSwapImageEntry(imagePath)?.id; if (imageId === undefined) return []; const catIds = new Set( this.cocoData.annotations @@ -421,9 +452,10 @@ export class TemplateAssetData { deleteImage(imagePath: string): boolean { try { if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath); + const hadEntry = this.getSwapImageEntry(imagePath) !== undefined; this.removeImageEntry(imagePath); - this.save(); - return true; + if (hadEntry || fs.existsSync(this.cocoPath)) this.save(); + return removeImageBoxes(this.rootDir, templatesDirectory(this.rootDir), path.basename(imagePath)); } catch { return false; } diff --git a/src/templateAssetPanel.ts b/src/templateAssetPanel.ts index c1d36d9..5013976 100644 --- a/src/templateAssetPanel.ts +++ b/src/templateAssetPanel.ts @@ -3,12 +3,15 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { TemplateAssetData } from './templateAssetData'; import { AnnotationPanel } from './annotationPanel'; +import { openBoxEditor } from './boxPanels'; +import { boxesForImage, publishRuntime, readAuthoringFile, readRuntimeFile, runtimeOnlyPaths, swapImageBoxes } from './boxResourceStore'; +import { probedBoxesJson } from './cocoFeaturePath'; import { cropTemplateThumbFileAsync, THUMB_HEIGHT } from './pngCrop'; import { injectWebviewLocalization, tr } from './localization'; import { TempScreenshotStore } from './tempScreenshotStore'; import { captureGameWindow, getProjectConfig, probeWindowConfig } from './screenshotCapture'; import { takePendingDrag } from './tempDrag'; -import { currentWorkspaceFolderUri, ideSetting, labelEnumClassName, labelEnumPathInputError, labelEnumPathSetting, normalizeLabelEnumPathInput, setIdeSetting, templatesDirectory } from './projectConfig'; +import { boxesRuntimeSetting, currentWorkspaceFolderUri, ideSetting, labelEnumClassName, labelEnumPathInputError, labelEnumPathSetting, normalizeLabelEnumPathInput, setIdeSetting, templatesDirectory } from './projectConfig'; import { labelEnumRenameImpact, labelEnumRenameMessage, referencingFiles, writableClassName } from './labelEnumGuard'; import { derivedEnumPath, isPathInsideRoot, needsEnumPathPrompt, SaveTarget, saveToAssetsItems } from './saveToAssetsPure'; import { isSameSize, scaleBoxes, SwapBox } from './annotationSwapPure'; @@ -72,6 +75,7 @@ class AssetGalleryController { private readonly isVisible: () => boolean, private readonly extensionUri: vscode.Uri, private readonly tempStore?: TempScreenshotStore, + private readonly boxes = false, ) { liveControllers.add(this); this.disposables.push( @@ -80,7 +84,7 @@ class AssetGalleryController { } attachHtml(): void { - this.webview.html = assetGalleryHtml(this.webview, this.extensionUri); + this.webview.html = assetGalleryHtml(this.webview, this.extensionUri, this.boxes ? 'boxes' : 'annotations'); } async update(): Promise { @@ -91,18 +95,22 @@ class AssetGalleryController { const imageFiles = this.data.listImages(); // 构建元数据 + const authoring = this.boxes + ? readAuthoringFile(this.data.root, templatesDirectory(this.data.root)) + : undefined; const metas = imageFiles.map((imgPath) => { - const cats = this.data.getCategoriesForImage(imgPath); - const entry = this.data.getImageEntryForPath(imgPath); + const size = this.imagePixelSize(imgPath); + const boxPaths = authoring ? boxesForImage(authoring, path.basename(imgPath)).map((box) => box.path) : []; + const cats = this.boxes ? boxPaths : this.data.getCategoriesForImage(imgPath); return { name: path.basename(imgPath), imagePath: imgPath, - width: entry?.width ?? 0, - height: entry?.height ?? 0, + width: size.width, + height: size.height, categories: cats, // 交换目标选择器要显示"这张图上有几个框",而分类名是去重后的 // (同一张图上两个同名按钮共用一个分类)⇒ 数量必须单独给。 - annotations: this.data.getAnnotationsForImage(imgPath, true).length, + annotations: this.boxes ? boxPaths.length : this.data.getAnnotationsForImage(imgPath, true).length, }; }); @@ -132,12 +140,17 @@ class AssetGalleryController { * 为什么需要按需补推这个入口:上面那轮批量推送是**异步分批**的,用户完全可能在推完 * 之前就打开了选择器;而裁剪失败的那几张更是整轮都不会再推。 */ + /** 用这张文件自己的尺寸。不能拿同名不同后缀的标注记录来顶。 */ + private imagePixelSize(imagePath: string): { width: number; height: number } { + return this.data.resolveImageSize(imagePath) ?? { width: 0, height: 0 }; + } + private async pushThumbs(imagePaths: readonly string[], generation = this.generation): Promise { const items: { name: string; url: string }[] = []; for (const imagePath of imagePaths) { if (generation !== this.generation || this.disposed) return 0; - const entry = this.data.getImageEntryForPath(imagePath); - const bbox: [number, number, number, number] = [0, 0, entry?.width ?? 100, entry?.height ?? 100]; + const size = this.imagePixelSize(imagePath); + const bbox: [number, number, number, number] = [0, 0, size.width > 0 ? size.width : 100, size.height > 0 ? size.height : 100]; const file = await cropTemplateThumbFileAsync(imagePath, bbox, this.thumbDir, THUMB_HEIGHT); if (generation !== this.generation || this.disposed) return 0; if (!file) continue; @@ -169,9 +182,13 @@ class AssetGalleryController { case 'openAnnotation': { if (msg.imagePath) { const imageList = this.data.listImages(); - AnnotationPanel.show(this.extensionUri, this.data, this.thumbDir, msg.imagePath, imageList, () => { - void this.update(); - }); + if (this.boxes) { + openBoxEditor(this.extensionUri, this.data, msg.imagePath); + } else { + AnnotationPanel.show(this.extensionUri, this.data, this.thumbDir, msg.imagePath, imageList, () => { + void this.update(); + }); + } } break; } @@ -181,7 +198,8 @@ class AssetGalleryController { break; } case 'saveToAssets': { - await this.handleSaveToAssets(); + if (this.boxes) await this.publishBoxes(); + else await this.handleSaveToAssets(); break; } case 'deleteImage': { @@ -503,6 +521,10 @@ class AssetGalleryController { * 所以必须整体成功或整体不动 —— 半交换的 coco 比不交换更难收拾。 */ private async handleSwapAnnotations(sourcePath: string, targetPath: string): Promise { + if (this.boxes) { + await this.handleSwapBoxes(sourcePath, targetPath); + return; + } if (!sourcePath || !targetPath || sourcePath === targetPath) return; // Webview 消息携带的路径须来自当前模板集,再读取图片或 COCO 数据。 const allowed = new Set(this.data.listImages()); @@ -512,7 +534,7 @@ class AssetGalleryController { } const sourceEntry = this.data.getSwapImageEntry(sourcePath); const targetEntry = this.data.getSwapImageEntry(targetPath); - if (!sourceEntry || !targetEntry || sourceEntry.id === targetEntry.id) { + if (sourceEntry && targetEntry && sourceEntry.id === targetEntry.id) { void vscode.window.showErrorMessage(tr('Failed to swap annotations.')); return; } @@ -567,8 +589,8 @@ class AssetGalleryController { } const currentSourceSize = this.data.resolveImageSize(sourcePath); const currentTargetSize = this.data.resolveImageSize(targetPath); - if (this.data.getSwapImageEntry(sourcePath)?.id !== sourceEntry.id - || this.data.getSwapImageEntry(targetPath)?.id !== targetEntry.id + if (this.data.getSwapImageEntry(sourcePath)?.id !== sourceEntry?.id + || this.data.getSwapImageEntry(targetPath)?.id !== targetEntry?.id || !currentSourceSize || !currentTargetSize || !isSameSize(currentSourceSize, sourceSize) || !isSameSize(currentTargetSize, targetSize) || JSON.stringify(this.boxesOf(sourcePath)) !== JSON.stringify(sourceBoxes) @@ -585,6 +607,8 @@ class AssetGalleryController { // 参数顺序是"写到哪张图":B 的框(按 A 的尺寸映射后)写进 A,反之亦然 scaleBoxes(targetBoxes, targetSize, sourceSize), scaleBoxes(sourceBoxes, sourceSize, targetSize), + sourceSize, + targetSize, ); } catch { void vscode.window.showErrorMessage(tr('Failed to swap annotations.')); @@ -603,6 +627,65 @@ class AssetGalleryController { ); } + private async publishBoxes(): Promise { + const root = this.data.root; + if (!root) return; + const templates = templatesDirectory(root); + const declared = boxesRuntimeSetting(root); + const fromConfig = probedBoxesJson(root); + const authoring = readAuthoringFile(root, templates); + const runtime = readRuntimeFile(root, declared, fromConfig); + const dropped = runtimeOnlyPaths(authoring, runtime); + if (dropped.length) { + const answer = await vscode.window.showWarningMessage(dropped.join('\n'), { modal: true }, tr('Publish')); + if (answer !== tr('Publish')) return; + } + if (!publishRuntime(root, templates, declared, fromConfig)) { + void vscode.window.showErrorMessage(tr('Could not save the box resource.')); + return; + } + await this.update(); + } + + /** 框坐标相对整张原图,交换只改所属图片,不按像素再缩放。缺 boxes.json 且两边都没有框时不创建文件。 */ + private async handleSwapBoxes(sourcePath: string, targetPath: string): Promise { + if (!sourcePath || !targetPath || sourcePath === targetPath) return; + const allowed = new Set(this.data.listImages()); + if (!allowed.has(sourcePath) || !allowed.has(targetPath)) { + void vscode.window.showErrorMessage(tr('Failed to swap annotations.')); + return; + } + const root = this.data.root; + const templates = templatesDirectory(root); + const authoring = readAuthoringFile(root, templates); + const sourceBoxes = boxesForImage(authoring, path.basename(sourcePath)); + const targetBoxes = boxesForImage(authoring, path.basename(targetPath)); + if (sourceBoxes.length === 0 && targetBoxes.length === 0) { + void vscode.window.showInformationMessage(tr('Neither image has annotations, nothing to swap.')); + return; + } + const first = path.basename(sourcePath); + const second = path.basename(targetPath); + const detail = tr('{first}: {firstCount} boxes, {second}: {secondCount} boxes', { + first, second, firstCount: String(sourceBoxes.length), secondCount: String(targetBoxes.length), + }); + const swap = tr('Swap'); + const choice = await vscode.window.showWarningMessage( + tr("Swap annotations between '{first}' and '{second}'?", { first, second }), + { modal: true, detail }, + swap, + ); + if (choice !== swap) return; + if (!swapImageBoxes(root, templates, first, second)) { + void vscode.window.showErrorMessage(tr('Failed to swap annotations.')); + return; + } + await this.update(); + void vscode.window.showInformationMessage( + tr("Swapped annotations between '{first}' and '{second}'.", { first, second }), + ); + } + /** 读某张图的标注,转成 `SwapBox` 形状(纯逻辑与数据层共用的入参) */ private boxesOf(imagePath: string): SwapBox[] { return this.data.getAnnotationsForImage(imagePath, true).map((ann) => ({ @@ -668,6 +751,36 @@ class AssetGalleryController { /* ---------------- 侧边栏视图 ---------------- */ +export class BoxAssetViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'okScriptToolkit.boxAssets'; + + constructor( + private readonly data: TemplateAssetData, + private readonly thumbDir: string, + private readonly extensionUri: vscode.Uri, + private readonly tempStore?: TempScreenshotStore, + ) { } + + resolveWebviewView(view: vscode.WebviewView): void { + view.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.file(this.thumbDir), this.extensionUri], + }; + const controller = new AssetGalleryController( + view.webview, + this.data, + this.thumbDir, + () => view.visible, + this.extensionUri, + this.tempStore, + true, + ); + controller.attachHtml(); + view.onDidChangeVisibility(() => { if (view.visible) void controller.update(); }); + view.onDidDispose(() => controller.dispose()); + } +} + export class TemplateAssetViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'okScriptToolkit.templateAssets'; @@ -760,7 +873,7 @@ export class TemplateAssetPanel { } } -function assetGalleryHtml(webview: vscode.Webview, extensionUri: vscode.Uri): string { +function assetGalleryHtml(webview: vscode.Webview, extensionUri: vscode.Uri, mode = 'annotations'): string { const file = path.join(extensionUri.fsPath, 'media', 'templateAssetPanel', 'index.html'); const nonce = getNonce(); const resource = (name: string) => webview.asWebviewUri( @@ -771,6 +884,7 @@ function assetGalleryHtml(webview: vscode.Webview, extensionUri: vscode.Uri): st .split('__CSP_NONCE__').join(nonce) .split('__CSP_SOURCE__').join(webview.cspSource) .split('__STYLE_URI__').join(resource('style.css')) - .split('__APP_SCRIPT_URI__').join(resource('app.js')), + .split('__APP_SCRIPT_URI__').join(resource('app.js')) + .split('__ASSET_MODE__').join(mode), )); } From 5a5239c15df1d3d64a21471776472555ed624240 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:01:32 +0800 Subject: [PATCH 12/19] fix(boxes): remove box records before deleting the image file If boxes.json cannot be updated, the picture stays so the delete can be retried. --- jetbrains | 2 +- src/templateAssetData.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/jetbrains b/jetbrains index 00b404a..1435c58 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 00b404ad728aa543bc30291af6d5b19e03a3fe35 +Subproject commit 1435c58bcdfcf5c2cb357cae6ffb05e0dda6c530 diff --git a/src/templateAssetData.ts b/src/templateAssetData.ts index c8913c1..230ca24 100644 --- a/src/templateAssetData.ts +++ b/src/templateAssetData.ts @@ -451,11 +451,12 @@ export class TemplateAssetData { deleteImage(imagePath: string): boolean { try { + if (!removeImageBoxes(this.rootDir, templatesDirectory(this.rootDir), path.basename(imagePath))) return false; if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath); const hadEntry = this.getSwapImageEntry(imagePath) !== undefined; this.removeImageEntry(imagePath); if (hadEntry || fs.existsSync(this.cocoPath)) this.save(); - return removeImageBoxes(this.rootDir, templatesDirectory(this.rootDir), path.basename(imagePath)); + return true; } catch { return false; } From 86a490cfbfde39e784cd4ac38676332457d513a7 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:05:49 +0800 Subject: [PATCH 13/19] fix(boxes): do not treat an unreadable boxes.json as an empty catalog Swap and publish now stop when the box file cannot be read, instead of reporting that neither image has boxes. --- src/boxResourceStore.ts | 9 +++++++++ src/templateAssetPanel.ts | 18 +++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts index b0b2c4a..d2b4e09 100644 --- a/src/boxResourceStore.ts +++ b/src/boxResourceStore.ts @@ -81,6 +81,15 @@ export function readAuthoringFile(rootDir: string, templatesDirectory: string): return readAuthoringResult(rootDir, templatesDirectory).file; } +/** 缺文件不是错误。读失败或解析失败时返回错误码,调用方不能把结果当成空目录。 */ +export function authoringReadErrors(rootDir: string, templatesDirectory: string): string[] { + return readAuthoringResult(rootDir, templatesDirectory).errors; +} + +export function runtimeReadErrors(rootDir: string, declared?: string, fromConfigPy?: string): string[] { + return readRuntimeResult(rootDir, declared, fromConfigPy).errors; +} + function readAuthoringResult(rootDir: string, templatesDirectory: string): { file: AuthoringFile; errors: string[] } { const file = authoringFile(rootDir, templatesDirectory); const read = readText(file); diff --git a/src/templateAssetPanel.ts b/src/templateAssetPanel.ts index 5013976..f55a48c 100644 --- a/src/templateAssetPanel.ts +++ b/src/templateAssetPanel.ts @@ -4,7 +4,7 @@ import * as vscode from 'vscode'; import { TemplateAssetData } from './templateAssetData'; import { AnnotationPanel } from './annotationPanel'; import { openBoxEditor } from './boxPanels'; -import { boxesForImage, publishRuntime, readAuthoringFile, readRuntimeFile, runtimeOnlyPaths, swapImageBoxes } from './boxResourceStore'; +import { authoringReadErrors, boxesForImage, publishRuntime, readAuthoringFile, readRuntimeFile, runtimeOnlyPaths, runtimeReadErrors, swapImageBoxes } from './boxResourceStore'; import { probedBoxesJson } from './cocoFeaturePath'; import { cropTemplateThumbFileAsync, THUMB_HEIGHT } from './pngCrop'; import { injectWebviewLocalization, tr } from './localization'; @@ -95,8 +95,12 @@ class AssetGalleryController { const imageFiles = this.data.listImages(); // 构建元数据 - const authoring = this.boxes - ? readAuthoringFile(this.data.root, templatesDirectory(this.data.root)) + const templates = templatesDirectory(this.data.root); + if (this.boxes && authoringReadErrors(this.data.root, templates).length) { + void vscode.window.showErrorMessage(tr('Could not save the box resource.')); + } + const authoring = this.boxes && !authoringReadErrors(this.data.root, templates).length + ? readAuthoringFile(this.data.root, templates) : undefined; const metas = imageFiles.map((imgPath) => { const size = this.imagePixelSize(imgPath); @@ -631,6 +635,10 @@ class AssetGalleryController { const root = this.data.root; if (!root) return; const templates = templatesDirectory(root); + if (authoringReadErrors(root, templates).length || runtimeReadErrors(root, boxesRuntimeSetting(root), probedBoxesJson(root)).length) { + void vscode.window.showErrorMessage(tr('Could not save the box resource.')); + return; + } const declared = boxesRuntimeSetting(root); const fromConfig = probedBoxesJson(root); const authoring = readAuthoringFile(root, templates); @@ -657,6 +665,10 @@ class AssetGalleryController { } const root = this.data.root; const templates = templatesDirectory(root); + if (authoringReadErrors(root, templates).length) { + void vscode.window.showErrorMessage(tr('Could not save the box resource.')); + return; + } const authoring = readAuthoringFile(root, templates); const sourceBoxes = boxesForImage(authoring, path.basename(sourcePath)); const targetBoxes = boxesForImage(authoring, path.basename(targetPath)); From 137aafd907dd85e02e8b1c0a4c7e6155a15caa6b Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:12:21 +0800 Subject: [PATCH 14/19] chore(jetbrains): point the submodule at the filename-matching test The plugin test now treats a different image extension as a different record, matching the lookup used by the box and annotation pages. --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index 1435c58..09bfc7c 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 1435c58bcdfcf5c2cb357cae6ffb05e0dda6c530 +Subproject commit 09bfc7c8fff568ed0f63dc528962ce99a67dfa91 From b5ba3dbdaa904cb4e08949ceadc15721b0ef9277 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:01:08 +0800 Subject: [PATCH 15/19] fix(boxes): put box records back when the image file stays A failed delete no longer leaves the picture without its boxes. The JetBrains submodule points at the same restore. --- jetbrains | 2 +- scripts/test_box_resource.js | 27 +++++++++++++++++++++++++++ src/boxResourceStore.ts | 23 +++++++++++++++++++++++ src/templateAssetData.ts | 8 ++++++-- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/jetbrains b/jetbrains index 09bfc7c..c3bb1b2 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 09bfc7c8fff568ed0f63dc528962ce99a67dfa91 +Subproject commit c3bb1b28feb06adac5880acf78b23be38534b17d diff --git a/scripts/test_box_resource.js b/scripts/test_box_resource.js index 3f02461..4eaeca9 100644 --- a/scripts/test_box_resource.js +++ b/scripts/test_box_resource.js @@ -9,6 +9,8 @@ * 4. 发布丢掉 image,显隐不进资源。 */ const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); const path = require('path'); const root = path.resolve(__dirname, '..'); @@ -171,6 +173,31 @@ const declared = config.boxesRuntimeOf({ boxes: { runtime: 'src/scene/boxes.json check(declared === 'src/scene/boxes.json', '约定文件的 boxes.runtime 归一化后可读'); check(config.boxesRuntimeOf({ boxes: { runtime: 42 } }) === undefined, '类型不对当没写'); +const store = require(path.join(root, 'out', 'boxResourceStore')); +const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ok-boxes-')); +try { + const templates = 'ok_templates'; + fs.mkdirSync(path.join(snapshotDir, templates)); + const boxesPath = path.join(snapshotDir, templates, 'boxes.json'); + const original = '{"version":1,"boxes":[{"path":"screen.a","image":"shot.png","rect":[0,0,0.5,0.5]}]}'; + fs.writeFileSync(boxesPath, original); + const snapshot = store.captureAuthoring(snapshotDir, templates); + check(snapshot && snapshot.text === original, '删图前能记下 boxes.json 原文'); + check(store.removeImageBoxes(snapshotDir, templates, 'shot.png'), '先去掉这张图的框'); + check(!fs.readFileSync(boxesPath, 'utf8').includes('shot.png'), '框记录已经从文件里消失'); + check(store.restoreAuthoring(snapshotDir, templates, snapshot), '图片还在时写回原文件'); + check(fs.readFileSync(boxesPath, 'utf8') === original, '删除失败不会丢掉框记录'); + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'ok-boxes-empty-')); + try { + const absent = store.captureAuthoring(empty, templates); + check(absent && absent.text === null, '没有 boxes.json 时快照是空的'); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } +} finally { + fs.rmSync(snapshotDir, { recursive: true, force: true }); +} + if (failures.length) { console.error(`\n${failures.length} failed`); process.exit(1); diff --git a/src/boxResourceStore.ts b/src/boxResourceStore.ts index d2b4e09..2fe919c 100644 --- a/src/boxResourceStore.ts +++ b/src/boxResourceStore.ts @@ -118,6 +118,29 @@ export function boxesForImage(file: AuthoringFile, fileName: string): AuthoringB return file.boxes.filter((box) => sameImage(box.image, fileName)); } +/** 删图前记下 boxes.json。缺文件是空快照;读失败返回 null,调用方应停止删除。 */ +export function captureAuthoring(rootDir: string, templatesDirectory: string): { text: string | null } | null { + const read = readText(authoringFile(rootDir, templatesDirectory)); + if (read.error) return null; + if (read.missing) return { text: null }; + return { text: read.text ?? '' }; +} + +/** 图片文件还在时,把删图前的 boxes.json 写回去。快照为空就删掉这次多出来的文件。 */ +export function restoreAuthoring(rootDir: string, templatesDirectory: string, snapshot: { text: string | null }): boolean { + const target = authoringFile(rootDir, templatesDirectory); + if (snapshot.text === null) { + if (!fs.existsSync(target)) return true; + try { + fs.unlinkSync(target); + return true; + } catch { + return false; + } + } + return writeText(target, snapshot.text); +} + /** 删图时去掉它的框。文件还不存在就什么都不写。 */ export function removeImageBoxes(rootDir: string, templatesDirectory: string, fileName: string): boolean { const target = authoringFile(rootDir, templatesDirectory); diff --git a/src/templateAssetData.ts b/src/templateAssetData.ts index 230ca24..3f15ea7 100644 --- a/src/templateAssetData.ts +++ b/src/templateAssetData.ts @@ -11,7 +11,7 @@ import { tr } from './localization'; import { labelEnumNameSetting, templatesDirectory } from './projectConfig'; import { PYTHON_KEYWORDS, writableClassName } from './labelEnumGuard'; import { isPathInsideRoot } from './saveToAssetsPure'; -import { removeImageBoxes } from './boxResourceStore'; +import { captureAuthoring, removeImageBoxes, restoreAuthoring } from './boxResourceStore'; /* ---------------- COCO 数据类型 ---------------- */ @@ -450,14 +450,18 @@ export class TemplateAssetData { /* ---------- 删除图片文件和COCO数据 ---------- */ deleteImage(imagePath: string): boolean { + const templates = templatesDirectory(this.rootDir); + const snapshot = captureAuthoring(this.rootDir, templates); + if (!snapshot) return false; try { - if (!removeImageBoxes(this.rootDir, templatesDirectory(this.rootDir), path.basename(imagePath))) return false; + if (!removeImageBoxes(this.rootDir, templates, path.basename(imagePath))) return false; if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath); const hadEntry = this.getSwapImageEntry(imagePath) !== undefined; this.removeImageEntry(imagePath); if (hadEntry || fs.existsSync(this.cocoPath)) this.save(); return true; } catch { + if (fs.existsSync(imagePath)) restoreAuthoring(this.rootDir, templates, snapshot); return false; } } From 13fa13ad16893b3f5322f6b142fd4a27478678b7 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:01:52 +0800 Subject: [PATCH 16/19] fix(boxes): put the image back when saving annotations fails The file is moved aside until the annotation write succeeds. A failed save restores both the image and the previous boxes. --- jetbrains | 2 +- src/templateAssetData.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/jetbrains b/jetbrains index c3bb1b2..2520b89 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit c3bb1b28feb06adac5880acf78b23be38534b17d +Subproject commit 2520b890f9e233dd43a1f8dae23fb88687f3ac0c diff --git a/src/templateAssetData.ts b/src/templateAssetData.ts index 3f15ea7..fb53551 100644 --- a/src/templateAssetData.ts +++ b/src/templateAssetData.ts @@ -453,15 +453,27 @@ export class TemplateAssetData { const templates = templatesDirectory(this.rootDir); const snapshot = captureAuthoring(this.rootDir, templates); if (!snapshot) return false; + const staged = `${imagePath}.${process.pid}.ok-delete`; + let moved = false; try { if (!removeImageBoxes(this.rootDir, templates, path.basename(imagePath))) return false; - if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath); + if (fs.existsSync(imagePath)) { + fs.renameSync(imagePath, staged); + moved = true; + } const hadEntry = this.getSwapImageEntry(imagePath) !== undefined; this.removeImageEntry(imagePath); if (hadEntry || fs.existsSync(this.cocoPath)) this.save(); + if (moved) { + try { fs.unlinkSync(staged); } catch { /* 图已经不在模板目录,框和标注已落盘 */ } + } return true; } catch { + if (moved && fs.existsSync(staged) && !fs.existsSync(imagePath)) { + try { fs.renameSync(staged, imagePath); } catch { /* 原路径占着时留给下面的框恢复判断 */ } + } if (fs.existsSync(imagePath)) restoreAuthoring(this.rootDir, templates, snapshot); + try { this.load(); } catch { /* 标注写盘没成功时,内存仍可能是删过的那份 */ } return false; } } From bcfffcdedd66d8e2dae457ec8f21b2961a689e04 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:58:34 +0800 Subject: [PATCH 17/19] chore(jetbrains): point the submodule at the delete exception restore A thrown image delete now puts the previous boxes back when the file is still there. --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index 2520b89..709331a 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 2520b890f9e233dd43a1f8dae23fb88687f3ac0c +Subproject commit 709331afa44679ec97c2a79c831380b0c68035b0 From e964b247b554bce98d214e491501018cd5ac78eb Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:19:52 +0800 Subject: [PATCH 18/19] fix(boxes): keep image deletion from losing a case-different record Remember the annotation id before moving the file, and do not report success when the temporary file is still on disk. --- l10n/bundle.l10n.es.json | 1 + l10n/bundle.l10n.ja.json | 1 + l10n/bundle.l10n.json | 1 + l10n/bundle.l10n.ko.json | 1 + l10n/bundle.l10n.zh-cn.json | 1 + l10n/bundle.l10n.zh-tw.json | 1 + src/templateAssetData.ts | 32 ++++++++++++++++++++++++++------ src/templateAssetPanel.ts | 6 +++++- 8 files changed, 37 insertions(+), 7 deletions(-) diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json index 3111913..6625dd2 100644 --- a/l10n/bundle.l10n.es.json +++ b/l10n/bundle.l10n.es.json @@ -159,6 +159,7 @@ "Enter game window title pattern (regex), or leave empty for all windows": "Introduce el patrón de título de la ventana (regex), o déjalo vacío para todas las ventanas", "Failed to add to template assets: {error}": "No se pudo añadir a recursos de plantillas: {error}", "Failed to delete: {name}": "No se pudo eliminar: {name}", + "Deleted the record for {name}, but the temporary file is still at {path}.": "Se eliminó el registro de {name}, pero el archivo temporal sigue en {path}.", "Failed to save screenshot.": "No se pudo guardar la captura.", "Import images to {dir}": "Importar imágenes a {dir}", "Imported {count} image(s)": "Se importaron {count} imagen(es)", diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index 19ad77e..b72566f 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -159,6 +159,7 @@ "Enter game window title pattern (regex), or leave empty for all windows": "ゲームウィンドウのタイトル正規表現を入力(空欄ですべてのウィンドウ)", "Failed to add to template assets: {error}": "テンプレートアセットへの追加に失敗: {error}", "Failed to delete: {name}": "削除に失敗: {name}", + "Deleted the record for {name}, but the temporary file is still at {path}.": "{name} の記録は削除しましたが、一時ファイルが {path} に残っています。", "Failed to save screenshot.": "スクリーンショットの保存に失敗しました。", "Import images to {dir}": "{dir} に画像を読み込む", "Imported {count} image(s)": "{count} 件の画像を読み込みました", diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 134db26..f3068c9 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -168,6 +168,7 @@ "Enter game window title pattern (regex), or leave empty for all windows": "Enter game window title pattern (regex), or leave empty for all windows", "Failed to add to template assets: {error}": "Failed to add to template assets: {error}", "Failed to delete: {name}": "Failed to delete: {name}", + "Deleted the record for {name}, but the temporary file is still at {path}.": "Deleted the record for {name}, but the temporary file is still at {path}.", "Failed to save screenshot.": "Failed to save screenshot.", "Import images to {dir}": "Import images to {dir}", "Imported {count} image(s)": "Imported {count} image(s)", diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index 9ed8e89..0bc3d12 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -159,6 +159,7 @@ "Enter game window title pattern (regex), or leave empty for all windows": "게임 창 제목 정규식을 입력하세요(비우면 모든 창)", "Failed to add to template assets: {error}": "템플릿 에셋 추가 실패: {error}", "Failed to delete: {name}": "삭제 실패: {name}", + "Deleted the record for {name}, but the temporary file is still at {path}.": "{name} 기록은 삭제했지만 임시 파일이 {path}에 남아 있습니다.", "Failed to save screenshot.": "스크린샷 저장에 실패했습니다.", "Import images to {dir}": "{dir} 로 이미지 가져오기", "Imported {count} image(s)": "이미지 {count}개를 가져왔습니다", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index b6ce51a..11ebba9 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -159,6 +159,7 @@ "Enter game window title pattern (regex), or leave empty for all windows": "输入游戏窗口标题正则,留空表示匹配所有窗口", "Failed to add to template assets: {error}": "添加到模板素材失败: {error}", "Failed to delete: {name}": "删除失败: {name}", + "Deleted the record for {name}, but the temporary file is still at {path}.": "已删除 {name} 的记录,但临时文件还在 {path}。", "Failed to save screenshot.": "截图保存失败。", "Import images to {dir}": "导入图片到 {dir}", "Imported {count} image(s)": "已导入 {count} 张图片", diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index 8f2d5f3..43fc94e 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -159,6 +159,7 @@ "Enter game window title pattern (regex), or leave empty for all windows": "輸入遊戲視窗標題正則,留空表示符合所有視窗", "Failed to add to template assets: {error}": "加入模板素材失敗: {error}", "Failed to delete: {name}": "刪除失敗: {name}", + "Deleted the record for {name}, but the temporary file is still at {path}.": "已刪除 {name} 的記錄,但暫存檔還在 {path}。", "Failed to save screenshot.": "截圖儲存失敗。", "Import images to {dir}": "匯入圖片到 {dir}", "Imported {count} image(s)": "已匯入 {count} 張圖片", diff --git a/src/templateAssetData.ts b/src/templateAssetData.ts index fb53551..e587739 100644 --- a/src/templateAssetData.ts +++ b/src/templateAssetData.ts @@ -449,26 +449,33 @@ export class TemplateAssetData { /* ---------- 删除图片文件和COCO数据 ---------- */ - deleteImage(imagePath: string): boolean { + deleteImage(imagePath: string): true | false | string { const templates = templatesDirectory(this.rootDir); const snapshot = captureAuthoring(this.rootDir, templates); if (!snapshot) return false; const staged = `${imagePath}.${process.pid}.ok-delete`; let moved = false; + let committed = false; try { if (!removeImageBoxes(this.rootDir, templates, path.basename(imagePath))) return false; + // 挪走文件后,大小写不同的记录不能再靠 realpath 对上,所以先记下 id。 + const imageId = this.getSwapImageEntry(imagePath)?.id; if (fs.existsSync(imagePath)) { fs.renameSync(imagePath, staged); moved = true; } - const hadEntry = this.getSwapImageEntry(imagePath) !== undefined; - this.removeImageEntry(imagePath); - if (hadEntry || fs.existsSync(this.cocoPath)) this.save(); - if (moved) { - try { fs.unlinkSync(staged); } catch { /* 图已经不在模板目录,框和标注已落盘 */ } + if (imageId !== undefined) { + this.cocoData.images = this.cocoData.images.filter((img) => img.id !== imageId); + this.cocoData.annotations = this.cocoData.annotations.filter((ann) => ann.image_id !== imageId); + this._cleanupCategories(); + this._dirty = true; } + if (imageId !== undefined || fs.existsSync(this.cocoPath)) this.save(); + committed = true; + if (moved && !this.removeStagedImage(staged)) return staged; return true; } catch { + if (committed) return moved && fs.existsSync(staged) ? staged : false; if (moved && fs.existsSync(staged) && !fs.existsSync(imagePath)) { try { fs.renameSync(staged, imagePath); } catch { /* 原路径占着时留给下面的框恢复判断 */ } } @@ -478,6 +485,19 @@ export class TemplateAssetData { } } + private removeStagedImage(staged: string): boolean { + for (let attempt = 0; ; attempt++) { + try { + fs.unlinkSync(staged); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (attempt >= 3 || !['EPERM', 'EACCES', 'EBUSY'].includes(code ?? '')) return false; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } + } + /* ---------- 保存到项目assets(bin-packing + 枚举) ---------- */ /** diff --git a/src/templateAssetPanel.ts b/src/templateAssetPanel.ts index f55a48c..44bb891 100644 --- a/src/templateAssetPanel.ts +++ b/src/templateAssetPanel.ts @@ -503,9 +503,13 @@ class AssetGalleryController { ); if (confirm !== tr('Delete')) return; - if (this.data.deleteImage(imagePath)) { + const deleted = this.data.deleteImage(imagePath); + if (deleted === true) { void vscode.window.showInformationMessage(tr('Deleted: {name}', { name })); await this.update(); + } else if (typeof deleted === 'string') { + void vscode.window.showErrorMessage(tr('Deleted the record for {name}, but the temporary file is still at {path}.', { name, path: deleted })); + await this.update(); } else { void vscode.window.showErrorMessage(tr('Failed to delete: {name}', { name })); } From 2cff566adb63b1fbef8af18c6183d0815a641aea Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:39:45 +0800 Subject: [PATCH 19/19] chore(jetbrains): point the submodule at the merged box resource PR (jetbrains#13) --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index 709331a..a739d58 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 709331afa44679ec97c2a79c831380b0c68035b0 +Subproject commit a739d58e1da7b84c15b1a7e0f01af2dba1d12fa0