diff --git a/docs/box-resources.md b/docs/box-resources.md new file mode 100644 index 0000000..6161688 --- /dev/null +++ b/docs/box-resources.md @@ -0,0 +1,175 @@ +# 框资源设计 + +框管理对标现有模板管理,分成两份资源。标注工作副本只给插件编辑。运行时副本是发布后的位置表:插件的框管理和 `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`。 + +## 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..a739d58 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit cb16c63efe10a11227463789c8beb91d216f9989 +Subproject commit a739d58e1da7b84c15b1a7e0f01af2dba1d12fa0 diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json index 741a4a3..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)", @@ -216,5 +217,8 @@ "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.", + "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 638a2b4..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} 件の画像を読み込みました", @@ -216,5 +217,8 @@ "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.": "ボックス資源を保存できませんでした。", + "Could not save annotations.": "注釈を保存できませんでした。", + "Publish": "公開" } diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 52a8fdd..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)", @@ -216,5 +217,8 @@ "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.", + "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 347927f..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}개를 가져왔습니다", @@ -216,5 +217,8 @@ "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.": "박스 리소스를 저장하지 못했습니다.", + "Could not save annotations.": "주석을 저장하지 못했습니다.", + "Publish": "게시" } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index afb65b0..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} 张图片", @@ -216,5 +217,8 @@ "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.": "无法保存框资源。", + "Could not save annotations.": "无法保存标注。", + "Publish": "发布" } diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index f2c3aeb..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} 張圖片", @@ -216,5 +217,8 @@ "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.": "無法儲存框資源。", + "Could not save annotations.": "無法儲存標註。", + "Publish": "發布" } diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index bb55803..71802a6 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -22,6 +22,20 @@ 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'); + 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; @@ -29,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; @@ -36,6 +51,9 @@ // 标注 let annotations = []; + /** 按图片文件名记住隐藏的分类。不进保存消息,撤销也不恢复。 */ + const hiddenByFile = new Map(); + let listSignature = ''; let nextId = 1; let selectedIdx = -1; let hoveredIdx = -1; @@ -132,6 +150,16 @@ 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'); + const generateBoxBtn = document.getElementById('generateBoxBtn'); + if (generateBoxBtn) generateBoxBtn.textContent = t('generateBox'); updateUndoRedoButtons(); } @@ -198,8 +226,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 +245,120 @@ } 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 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', () => { + if (input.checked) hidden.delete(ann.category); + else hidden.add(ann.category); + listSignature = ''; + paint(); + }); + const text = document.createElement('button'); + text.type = 'button'; + text.className = 'annotation-name'; + text.textContent = ann.category; + 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 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 = ''; + 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 +373,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); @@ -813,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; @@ -893,7 +1043,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(); } @@ -983,12 +1138,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); } }); @@ -1010,6 +1171,29 @@ 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(); /* ---------- 接收消息 ---------- */ window.addEventListener('message', (e) => { @@ -1022,14 +1206,24 @@ 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 || []; nextId = annotations.length ? Math.max(...annotations.map(a => a.id)) + 1 : 1; selectedIdx = -1; hoveredIdx = -1; + listSignature = ''; undoStack = []; redoStack = []; if (msg.imageBase64) { @@ -1046,6 +1240,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 623da08..544fb58 100644 --- a/media/annotationPanel/index.html +++ b/media/annotationPanel/index.html @@ -12,6 +12,7 @@ +
@@ -19,9 +20,20 @@ -
- -
+
+
+ +
+
+
@@ -47,6 +59,21 @@

Bounding Box

+ diff --git a/media/annotationPanel/style.css b/media/annotationPanel/style.css index 19423e8..53c6754 100644 --- a/media/annotationPanel/style.css +++ b/media/annotationPanel/style.css @@ -21,10 +21,91 @@ 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: 168px; + flex: 0 1 168px; + max-width: 40%; + 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); +} + +@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; + overflow: hidden; + 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 new file mode 100644 index 0000000..446277d --- /dev/null +++ b/media/boxPanel/app.js @@ -0,0 +1,79 @@ +(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' }); + + 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; + 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'; + 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); + 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) { + 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..8e4b1ee --- /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..3888a1b --- /dev/null +++ b/media/boxPanel/style.css @@ -0,0 +1,44 @@ +.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); + 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/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/package.json b/package.json index 5291272..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": [ @@ -335,7 +347,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 +365,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/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/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..4eaeca9 --- /dev/null +++ b/scripts/test_box_resource.js @@ -0,0 +1,205 @@ +#!/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 fs = require('fs'); +const os = require('os'); +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'), + '已声明时发布目标是首选,即使文件还没创建', +); +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 不合法'); +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 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 }, +]; +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')]); +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, '类型不对当没写'); + +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); +} +console.log('\nbox resource pure ok'); 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 ed64121..0f30dee 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'; @@ -100,7 +104,7 @@ class AnnotationController { } // 读取标注数据 - const annotations = this.data.getAnnotationsForImage(imagePath); + const annotations = this.data.getAnnotationsForImage(imagePath, true); // 获取所有分类名(用于验证唯一性) const allCategories: Record = {}; @@ -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': @@ -156,14 +164,32 @@ 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; } + case 'generateBox': { + if (!this._currentImage || !msg.path || !msg.boxes?.length) { + void this.webview.postMessage({ type: 'generateBoxResult', ok: false, error: 'path' }); + break; + } + 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 + ? 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]); @@ -171,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); @@ -202,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++; @@ -268,7 +310,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..6716a94 --- /dev/null +++ b/src/boxPanels.ts @@ -0,0 +1,212 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { annotationHtml } from './annotationPanel'; +import { + boxesForImage, + readAuthoringFile, + readRuntimeFile, + replaceImageBoxes, + EditedBox, +} from './boxResourceStore'; +import { BoxRect, rectToPixel } from './boxResourcePure'; +import { probedBoxesJson } from './cocoFeaturePath'; +import { injectWebviewLocalization, tr } 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 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, + private readonly rootDir: string, + private readonly templatesDir: string, + ) { + panel.webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }); + } + + open(imagePath: string, imageList: string[]): void { + this.image = imagePath; + this.images = imageList; + void this.load(); + } + + private async load(): Promise { + const root = this.rootDir; + const buf = fs.readFileSync(this.image); + const size = readImageSize(buf); + 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; + 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; + 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, + x: ann.x, + y: ann.y, + w: ann.w, + h: ann.h, + original: this.originals.get(ann.id), + })); + 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.')); + } +} + +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), + vscode.ViewColumn.Beside, + { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [extensionUri, vscode.Uri.file(data.templatesDir)] }, + ); + panel.webview.html = annotationHtml(panel.webview.cspSource, extensionUri, panel.webview); + 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 { + 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/boxResourcePure.ts b/src/boxResourcePure.ts new file mode 100644 index 0000000..46f77c0 --- /dev/null +++ b/src/boxResourcePure.ts @@ -0,0 +1,451 @@ +/** + * 框资源的纯数据契约。与 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]; +} + +/** 规范化后是否指向同一路径。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)] + .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); +} + +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 || !isStorableRect(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; + 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) => 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( + 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": [${quantizeRect(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": [${quantizeRect(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 } }; +} + +/** 与解析时的矩形约束一致。写盘前拒绝,避免下次读取把整个文件判为损坏。 */ +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; + 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/boxResourceStore.ts b/src/boxResourceStore.ts new file mode 100644 index 0000000..2fe919c --- /dev/null +++ b/src/boxResourceStore.ts @@ -0,0 +1,233 @@ +/** + * 框资源的读盘侧。纯规则在 `boxResourcePure.ts`。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { + AuthoringBox, + AuthoringFile, + BoxRect, + PixelBox, + RuntimeFile, + authoringFile, + emptyAuthoringFile, + emptyRuntimeFile, + imageFileName, + parseAuthoring, + parseRuntime, + isStorableRect, + pixelToRect, + publishBoxes, + resolveBoxRuntimePlan, + runtimeWriteTarget, + serializeAuthoring, + serializeRuntime, + unionOnImage, + boxPathError, + effectiveBoxRuntimeFile, + replaceAuthoringImages, + sameBoxFile, +} 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 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 }; + } catch { + return { error: true }; + } + try { + return { text: fs.readFileSync(file, 'utf8') }; + } catch { + return { error: true }; + } +} + +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 { + 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); + 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 { 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[] { + 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); + 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, + fileName: string, + width: number, + height: number, + boxes: EditedBox[], +): string | undefined { + const image = imageFileName(fileName); + if (!image || width <= 0 || height <= 0) return 'image'; + const parsed = readAuthoringResult(rootDir, templatesDirectory); + if (parsed.errors.length) return 'parse'; + 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'; +} + +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); + if (!fileName) return 'image'; + 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, + 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 parsed = readAuthoringResult(rootDir, templatesDirectory); + if (parsed.errors.length) return false; + const runtime = readRuntimeResult(rootDir, declared, fromConfigPy); + if (runtime.errors.length) return false; + 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); +} + +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..374e0fb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,8 @@ import { } from './templateAssetPanel'; import { TempScreenshotStore } from './tempScreenshotStore'; import { TempScreenshotViewProvider } from './tempScreenshotPanel'; +import { BoxGalleryViewProvider } from './boxPanels'; +import { BoxAssetViewProvider } from './templateAssetPanel'; /** 缩略图缓存 key 版本:内容 hash 化后旧命名(t_/a_)需要清理一次 */ const THUMB_KEY_VERSION = 'content-hash-v2'; @@ -193,7 +195,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 +396,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, thumbDir, context.extensionUri, tempScreenshotStore), + ), + 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 ab45cad..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: '暂无模板素材。', @@ -382,6 +383,20 @@ const ZH_CN: WebviewStrings = { skillTypeTalent: '天赋', skillTypePotential: '潜能', noWorkspaceFolder: '未打开工作区', + annotationListTitle: '标注', + showAllAnnotations: '显示全部', + hideAllAnnotations: '隐藏全部', + showOnlyCurrent: '只显示当前', + annotationListEmpty: '没有标注', + generateBox: '生成框', + generateBoxTitle: '生成框', + generatePath: '路径', + generateNeedSelection: '至少选择一个标注', + publish: '发布', + assetsHint: '双击原图编辑框。发布后才会进入运行时。', + galleryHint: '单击插入 to_box()。双击复制属性路径。', + assetsEmpty: '没有模板原图。请先在标注管理里添加。', + galleryEmpty: '还没有运行时框。请先在框资源管理里发布。', }; const EN: WebviewStrings = { @@ -499,6 +514,21 @@ 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', + 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.', + 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.', }; const ZH_TW: WebviewStrings = { @@ -586,6 +616,21 @@ const ZH_TW: WebviewStrings = { assetSwapEmpty: '沒有其他圖片可以交換', assetSwapBoxes: '{count} 個標註', assetSwapNoBoxes: '無標註', + annotationListTitle: '標註', + showAllAnnotations: '顯示全部', + hideAllAnnotations: '隱藏全部', + showOnlyCurrent: '只顯示目前', + annotationListEmpty: '沒有標註', + generateBox: '產生框', + generateBoxTitle: '產生框', + generatePath: '路徑', + generateNeedSelection: '至少選擇一個標註', + publish: '發布', + assetsHint: '雙擊原圖編輯框。發布後才會進入執行時。', + boxAssetsTitle: '框資源管理', + 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/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/providers.ts b/src/providers.ts index a6c2c25..f18b72c 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] || match[2] }; + } + 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()) { 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; diff --git a/src/templateAssetData.ts b/src/templateAssetData.ts index 8009c94..e587739 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 { captureAuthoring, removeImageBoxes, restoreAuthoring } 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 @@ -418,17 +449,55 @@ 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 (fs.existsSync(imagePath)) fs.unlinkSync(imagePath); - this.removeImageEntry(imagePath); - this.save(); + 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; + } + 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 { /* 原路径占着时留给下面的框恢复判断 */ } + } + if (fs.existsSync(imagePath)) restoreAuthoring(this.rootDir, templates, snapshot); + try { this.load(); } catch { /* 标注写盘没成功时,内存仍可能是删过的那份 */ } return false; } } + 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 c1d36d9..44bb891 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 { 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'; 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,26 @@ class AssetGalleryController { const imageFiles = this.data.listImages(); // 构建元数据 + 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 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 +144,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 +186,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 +202,8 @@ class AssetGalleryController { break; } case 'saveToAssets': { - await this.handleSaveToAssets(); + if (this.boxes) await this.publishBoxes(); + else await this.handleSaveToAssets(); break; } case 'deleteImage': { @@ -481,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 })); } @@ -503,6 +529,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 +542,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 +597,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 +615,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 +635,73 @@ class AssetGalleryController { ); } + private async publishBoxes(): Promise { + 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); + 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); + 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)); + 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 +767,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 +889,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 +900,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), )); }