ima2-gen 3.12.2 → 3.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/commands/vectorize.js +108 -0
  2. package/bin/ima2.js +5 -1
  3. package/docs/API.md +1 -1
  4. package/docs/API.zh-CN.md +1 -1
  5. package/docs/API.zh-TW.md +1 -1
  6. package/docs/CLI.md +1 -0
  7. package/docs/migration/runtime-test-inventory.md +6 -1
  8. package/lib/agentImageVideoGen.js +15 -1
  9. package/lib/agentQueueStore.js +11 -3
  10. package/lib/agentQueueWorker.js +6 -1
  11. package/lib/agentRuntime.js +4 -0
  12. package/lib/db.js +3 -0
  13. package/lib/imageThumb.js +9 -2
  14. package/lib/vectorizeImage.js +132 -0
  15. package/package.json +4 -3
  16. package/routes/assetDerived.js +79 -2
  17. package/server.js +8 -0
  18. package/skills/ima2/SKILL.md +37 -0
  19. package/ui/dist/.vite/manifest.json +33 -33
  20. package/ui/dist/assets/{AgentWorkspace-CW1x4uOl.js → AgentWorkspace-AJkVTaU5.js} +2 -2
  21. package/ui/dist/assets/AssetGenWorkspace-Cguej69V.js +2 -0
  22. package/ui/dist/assets/AssetsWorkspace-DIQ9wBuD.js +1 -0
  23. package/ui/dist/assets/{CardNewsWorkspace-nBKX5TXr.js → CardNewsWorkspace-C_Eb2g7d.js} +1 -1
  24. package/ui/dist/assets/{GenerationRequestLogPanel-CWqbfNis.js → GenerationRequestLogPanel-giH1e4hp.js} +1 -1
  25. package/ui/dist/assets/{HomeWorkspace-CxqIGhb9.js → HomeWorkspace-DHmXGtqC.js} +1 -1
  26. package/ui/dist/assets/{NodeCanvas-m41ECrFJ.js → NodeCanvas-CF_NHudI.js} +1 -1
  27. package/ui/dist/assets/{PromptBuilderPanel-ChOoMAVA.js → PromptBuilderPanel-D7NJto-1.js} +2 -2
  28. package/ui/dist/assets/{PromptImportDialog-8hDG8MeK.js → PromptImportDialog-B40X9UAa.js} +2 -2
  29. package/ui/dist/assets/{PromptImportDiscoverySection-D2_TAuLm.js → PromptImportDiscoverySection-BWoUDHj1.js} +1 -1
  30. package/ui/dist/assets/{PromptImportFolderSection-qv0qhTwq.js → PromptImportFolderSection-DBSEyJb-.js} +1 -1
  31. package/ui/dist/assets/{PromptLibraryPanel-BPOD61FZ.js → PromptLibraryPanel-DLywJBpt.js} +2 -2
  32. package/ui/dist/assets/{SettingsWorkspace-BKgXN2l9.js → SettingsWorkspace-BawWId1t.js} +1 -1
  33. package/ui/dist/assets/SpriteRecipeWorkspace-Biy2yRAk.js +1 -0
  34. package/ui/dist/assets/VectorizePanel-DdlVvwV1.js +1 -0
  35. package/ui/dist/assets/{index-Bzt4YWub.js → index-D05Ong5g.js} +3 -3
  36. package/ui/dist/assets/{index-DB9EfKr6.css → index-DGRIdnsL.css} +1 -1
  37. package/ui/dist/assets/{index-DTEhasVd.js → index-Sch70vBs.js} +17 -17
  38. package/ui/dist/assets/{pptxgen.es-BEVsR2-i.js → pptxgen.es-CSd_gCsx.js} +1 -1
  39. package/ui/dist/assets/useAgentDialogFocus-DcWwFfYd.js +1 -0
  40. package/ui/dist/index.html +2 -2
  41. package/ui/dist/assets/AssetGenWorkspace-BQkdilsH.js +0 -2
  42. package/ui/dist/assets/AssetsWorkspace-CYSkmYU-.js +0 -1
  43. package/ui/dist/assets/KeyingPanel-CtGvO6-u.js +0 -1
  44. package/ui/dist/assets/SpriteRecipeWorkspace-D8gHgnt8.js +0 -1
  45. package/ui/dist/assets/useAgentDialogFocus-Dyj_bA5Q.js +0 -1
@@ -0,0 +1,108 @@
1
+ // wp5: ima2 vectorize - raster-to-SVG tracing. Runs fully local: unlike
2
+ // `upscale`, no server or provider is involved, so the command works offline.
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, extname, join, resolve } from "node:path";
5
+ import { parseArgs } from "../lib/args.js";
6
+ import { color, die, fail, json, out } from "../lib/output.js";
7
+ import { VECTOR_PRESETS, vectorizeImageBuffer, isVectorPreset } from "../../lib/vectorizeImage.js";
8
+ const SPEC = {
9
+ flags: {
10
+ preset: { type: "string" },
11
+ "color-precision": { type: "string" },
12
+ "filter-speckle": { type: "string" },
13
+ "corner-threshold": { type: "string" },
14
+ "no-optimize": { type: "boolean" },
15
+ out: { short: "o", type: "string" },
16
+ "out-dir": { short: "d", type: "string" },
17
+ json: { type: "boolean" },
18
+ help: { short: "h", type: "boolean" },
19
+ },
20
+ };
21
+ const HELP = `
22
+ ima2 vectorize <image> [options]
23
+
24
+ Trace a raster image (png/jpeg/webp) into a real SVG. Runs locally; no server needed.
25
+
26
+ Best on cutouts, icons, logos, and flat art. Photographs and small text will smear.
27
+
28
+ Options:
29
+ --preset <auto|flat|detailed|mono> Trace mode. Default: auto
30
+ --color-precision <1-8> Colour bits per channel
31
+ --filter-speckle <0-128> Discard patches smaller than N pixels
32
+ --corner-threshold <0-180> Minimum angle treated as a corner
33
+ --no-optimize Skip SVG optimization (larger output)
34
+ -o, --out <file> Output path. Default: <input>.svg
35
+ -d, --out-dir <dir> Output directory
36
+ --json Print one JSON result to stdout
37
+ `;
38
+ function parseRange(raw, label, min, max) {
39
+ if (raw === undefined)
40
+ return undefined;
41
+ const n = Number(raw);
42
+ if (!Number.isInteger(n) || n < min || n > max)
43
+ die(2, `${label} must be an integer ${min}-${max}`);
44
+ return n;
45
+ }
46
+ export default async function vectorizeCmd(argv) {
47
+ const args = parseArgs(argv, SPEC);
48
+ if (args.help) {
49
+ out(HELP);
50
+ return;
51
+ }
52
+ const input = args.positional[0];
53
+ if (!input)
54
+ die(2, "usage: ima2 vectorize <image> [options]");
55
+ const preset = args.preset === undefined ? "auto" : String(args.preset);
56
+ if (!isVectorPreset(preset)) {
57
+ die(2, `--preset must be one of: ${VECTOR_PRESETS.join(", ")}`);
58
+ }
59
+ const colorPrecision = parseRange(args["color-precision"], "--color-precision", 1, 8);
60
+ const filterSpeckle = parseRange(args["filter-speckle"], "--filter-speckle", 0, 128);
61
+ const cornerThreshold = parseRange(args["corner-threshold"], "--corner-threshold", 0, 180);
62
+ const inputPath = resolve(String(input));
63
+ const stem = basename(inputPath, extname(inputPath));
64
+ const target = args.out
65
+ ? resolve(String(args.out))
66
+ : args["out-dir"]
67
+ ? join(resolve(String(args["out-dir"])), `${stem}.svg`)
68
+ : join(dirname(inputPath), `${stem}.svg`);
69
+ try {
70
+ const source = await readFile(inputPath);
71
+ const result = await vectorizeImageBuffer(source, {
72
+ preset,
73
+ // Omit an unset knob: any value counts as an override and would bypass
74
+ // the tuned preset.
75
+ ...(colorPrecision !== undefined ? { colorPrecision } : {}),
76
+ ...(filterSpeckle !== undefined ? { filterSpeckle } : {}),
77
+ ...(cornerThreshold !== undefined ? { cornerThreshold } : {}),
78
+ optimize: !args["no-optimize"],
79
+ });
80
+ await mkdir(dirname(target), { recursive: true });
81
+ await writeFile(target, result.svg, "utf8");
82
+ if (args.json) {
83
+ json({
84
+ ok: true,
85
+ input: inputPath,
86
+ output: target,
87
+ preset: result.preset,
88
+ pathCount: result.pathCount,
89
+ bytes: result.bytes,
90
+ elapsedMs: result.elapsedMs,
91
+ width: result.width,
92
+ height: result.height,
93
+ });
94
+ }
95
+ else {
96
+ out(color.green("OK ") + `${target} (${result.pathCount} paths, ${Math.round(result.bytes / 1024)}KB)`);
97
+ }
98
+ }
99
+ catch (error) {
100
+ const typed = error;
101
+ fail({
102
+ json: Boolean(args.json),
103
+ code: typed.code ?? "VECTORIZE_FAILED",
104
+ message: typed.message,
105
+ exitCode: 1,
106
+ });
107
+ }
108
+ }
package/bin/ima2.js CHANGED
@@ -344,6 +344,9 @@ function showHelp() {
344
344
  stop Stop the running server safely (graceful, then signals)
345
345
  service Background service management (install/status/... ; -h)
346
346
 
347
+ Local commands (no running server needed):
348
+ vectorize <image> Trace a raster image into SVG (ima2 vectorize --help)
349
+
347
350
  Agent skills (SKILL.md + references/):
348
351
  skill ls List packaged skills (ima2, front, uiux)
349
352
  skill [front|uiux] Print a skill's SKILL.md
@@ -402,7 +405,7 @@ if (args.includes("-v") || args.includes("--version")) {
402
405
  console.log(pkg.version);
403
406
  exitFlushed(0);
404
407
  }
405
- const helpOwningCommands = ["doctor", "gen", "video", "edit", "ls", "show", "ps", "cancel", "session", "history", "prompt", "multimode", "node", "annotate", "canvas-versions", "metadata", "comfy", "cardnews", "inflight", "storage", "billing", "providers", "oauth", "grok", "config", "defaults", "models", "capabilities", "tools", "skill", "ping", "backfill-thumbs", "service"];
408
+ const helpOwningCommands = ["doctor", "gen", "video", "edit", "vectorize", "ls", "show", "ps", "cancel", "session", "history", "prompt", "multimode", "node", "annotate", "canvas-versions", "metadata", "comfy", "cardnews", "inflight", "storage", "billing", "providers", "oauth", "grok", "config", "defaults", "models", "capabilities", "tools", "skill", "ping", "backfill-thumbs", "service"];
406
409
  if (!command) {
407
410
  showHelp();
408
411
  exitFlushed(1);
@@ -468,6 +471,7 @@ switch (command) {
468
471
  case "video":
469
472
  case "upscale":
470
473
  case "edit":
474
+ case "vectorize":
471
475
  case "ls":
472
476
  case "show":
473
477
  case "ps":
package/docs/API.md CHANGED
@@ -600,7 +600,7 @@ files inside `generated/`; deleting an asset never deletes the file.
600
600
  | `GET` | `/api/assets/:id` | Fetch one asset by ID; returns `404 ASSET_NOT_FOUND` when absent |
601
601
  | `POST` | `/api/assets` | Promote/create an asset (`filePath`, `kind`, `name?`, `folderId?`, `tags?`, `metadata?`) |
602
602
  | `POST` | `/api/assets/promote-element` | Promote a gallery result to an `element` asset (`result.path` or `filePath`, `elementKind`, `name?`, `notes?`, `folderId?`, `tags?`) |
603
- | `POST` | `/api/assets/derived` | Save a derived asset (raw `image/png` body; query `source`, `kind=keyed-png`, `projectId?`, `name?`, `meta?` JSON) — writes `<src>-keyed-<ts>.png` + sidecar with `derivedFrom` and registers an asset record |
603
+ | `POST` | `/api/assets/derived` | Save a derived asset (raw `image/png` body; query `source`, `kind=keyed-png`, `projectId?`, `name?`, `meta?` JSON) — writes `<src>-keyed-<ts>.png` + sidecar with `derivedFrom` and registers an asset record `kind=vector-svg` sends NO body: the server reads the validated `source` from generated storage, traces it via `lib/vectorizeImage.ts`, and writes `<src>-vector-<ts>.svg` (query `preset?`, `colorPrecision?`, `filterSpeckle?`, `cornerThreshold?`; a non-raster source is refused with `DERIVED_SOURCE_NOT_RASTER`). Traced SVGs are served with a restrictive CSP and `nosniff`. |
604
604
  | `POST` | `/api/video/keying` | Derive an alpha WebM from a generated green-screen mp4 (`source`, `keyParams{tolerance,softness,keyColor?}`, `projectId?`, `name?`) — responds `202 {requestId, filePath}`, publishes `keying-start/progress/done/error` on the event bus, writes sidecar with `derivedFrom` and registers a video asset |
605
605
  | `PATCH` | `/api/assets/:id` | Update name/folder/notes/tags/metadata |
606
606
  | `POST` | `/api/assets/:id/test-sheet` | Run an element test sheet; currently returns `501 TEST_SHEET_NOT_IMPLEMENTED` after validating the element asset |
package/docs/API.zh-CN.md CHANGED
@@ -585,7 +585,7 @@ Grok视频 API 使用的提示界面:
585
585
  | `GET` | `/api/assets/:id` |通过ID获取一项资产;回报`404 ASSET_NOT_FOUND`当缺席时|
586
586
  | `POST` | `/api/assets` |推广/创建资产(`filePath`, `kind`, `name?`, `folderId?`, `tags?`, `metadata?`) |
587
587
  | `POST` | `/api/assets/promote-element` |将图库结果推广到`element`资产 (`result.path`或者`filePath`, `elementKind`, `name?`, `notes?`, `folderId?`, `tags?`) |
588
- | `POST` | `/api/assets/derived` |保存派生资产(原始资产`image/png`身体;询问`source`, `kind=keyed-png`, `projectId?`, `name?`, `meta?` JSON)——写道`<src>-keyed-<ts>.png`+ 边车与`derivedFrom`并登记资产记录|
588
+ | `POST` | `/api/assets/derived` |保存派生资产(原始资产`image/png`身体;询问`source`, `kind=keyed-png`, `projectId?`, `name?`, `meta?` JSON)——写道`<src>-keyed-<ts>.png`+ 边车与`derivedFrom`并登记资产记录 `kind=vector-svg` 不发送请求体:服务器直接读取已校验的 `source` 并通过 `lib/vectorizeImage.ts` 转换,写出 `<src>-vector-<ts>.svg`(查询参数 `preset?`、`colorPrecision?`、`filterSpeckle?`、`cornerThreshold?`;非栅格源以 `DERIVED_SOURCE_NOT_RASTER` 拒绝)。生成的 SVG 附带严格 CSP 与 `nosniff`。 |
589
589
  | `POST` | `/api/video/keying` |从生成的绿屏 mp4 导出 alpha WebM (`source`, `keyParams{tolerance,softness,keyColor?}`, `projectId?`, `name?`) — 回应`202 {requestId, filePath}`,发布`keying-start/progress/done/error`在事件总线上,写入 sidecar`derivedFrom`并注册视频资产|
590
590
  | `PATCH` | `/api/assets/:id` |更新名称/文件夹/注释/标签/元数据|
591
591
  | `POST` | `/api/assets/:id/test-sheet` |运行元素测试表;目前返回`501 TEST_SHEET_NOT_IMPLEMENTED`验证元素资产后|
package/docs/API.zh-TW.md CHANGED
@@ -585,7 +585,7 @@ Grok視訊 API 使用的提示介面:
585
585
  | `GET` | `/api/assets/:id` |透過ID獲取一項資產;回報`404 ASSET_NOT_FOUND`當缺席時|
586
586
  | `POST` | `/api/assets` |推廣/創建資產(`filePath`, `kind`, `name?`, `folderId?`, `tags?`, `metadata?`) |
587
587
  | `POST` | `/api/assets/promote-element` |將圖庫結果推廣到`element`資產 (`result.path`或者`filePath`, `elementKind`, `name?`, `notes?`, `folderId?`, `tags?`) |
588
- | `POST` | `/api/assets/derived` |保存派生資產(原始資產`image/png`身體;詢問`source`, `kind=keyed-png`, `projectId?`, `name?`, `meta?` JSON)——寫道`<src>-keyed-<ts>.png`+ 邊車與`derivedFrom`並登記資產記錄|
588
+ | `POST` | `/api/assets/derived` |保存派生資產(原始資產`image/png`身體;詢問`source`, `kind=keyed-png`, `projectId?`, `name?`, `meta?` JSON)——寫道`<src>-keyed-<ts>.png`+ 邊車與`derivedFrom`並登記資產記錄 `kind=vector-svg` 不傳送請求主體:伺服器直接讀取已驗證的 `source` 並透過 `lib/vectorizeImage.ts` 轉換,寫出 `<src>-vector-<ts>.svg`(查詢參數 `preset?`、`colorPrecision?`、`filterSpeckle?`、`cornerThreshold?`;非點陣來源以 `DERIVED_SOURCE_NOT_RASTER` 拒絕)。產生的 SVG 附帶嚴格 CSP 與 `nosniff`。 |
589
589
  | `POST` | `/api/video/keying` |從生成的綠幕 mp4 匯出 alpha WebM (`source`, `keyParams{tolerance,softness,keyColor?}`, `projectId?`, `name?`) — 回應`202 {requestId, filePath}`,發布`keying-start/progress/done/error`在事件總線上,寫入 sidecar`derivedFrom`並註冊視訊資產|
590
590
  | `PATCH` | `/api/assets/:id` |更新名稱/資料夾/註釋/標籤/元數據|
591
591
  | `POST` | `/api/assets/:id/test-sheet` |運行元素測試表;目前返回`501 TEST_SHEET_NOT_IMPLEMENTED`驗證元素資產後|
package/docs/CLI.md CHANGED
@@ -322,6 +322,7 @@ Windows DNS/fragmentation bypass tool such as SecretDNS is in use.
322
322
  | `ima2 history favorite <name>` | Toggle favorite (sends `X-Ima2-Browser-Id`) |
323
323
  | `ima2 history import <file>` | Import a local image (raw PNG/JPEG/WEBP) into history |
324
324
  | `ima2 metadata <file>` | Read embedded metadata from any local image (no server roundtrip needed for the read itself, but the route lives on the server) |
325
+ | `ima2 vectorize <image> [-o <file>] [--preset auto\|flat\|detailed\|mono] [--json]` | Trace a local raster (png/jpeg/webp) into a real SVG. Runs fully local. Best on cutouts, icons, logos and flat art; photographs and small text smear |
325
326
 
326
327
  ## Sessions and graphs
327
328
 
@@ -4,9 +4,10 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
4
4
 
5
5
  _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
6
6
 
7
- Total: 407 (runtime: 188, contract: 219)
7
+ Total: 412 (runtime: 191, contract: 221)
8
8
 
9
9
  ## Runtime-importing tests
10
+ - `tests/agent-image-reference-contract.test.ts`
10
11
  - `tests/agent-mode-auto-planner-contract.test.ts`
11
12
  - `tests/agent-mode-llm-planner-contract.test.ts`
12
13
  - `tests/agent-mode-parallel-contract.test.ts`
@@ -23,6 +24,7 @@ Total: 407 (runtime: 188, contract: 219)
23
24
  - `tests/api-cache-policy.test.ts`
24
25
  - `tests/api-provider-parity.test.ts`
25
26
  - `tests/asset-character-bindings.test.ts`
27
+ - `tests/asset-derived-vector-contract.test.ts`
26
28
  - `tests/asset-derived.test.ts`
27
29
  - `tests/asset-ref-contract.test.ts`
28
30
  - `tests/assets-routes-contract.test.ts`
@@ -181,6 +183,7 @@ Total: 407 (runtime: 188, contract: 219)
181
183
  - `tests/transparent-alpha-verification.test.ts`
182
184
  - `tests/transparent-background-contract.test.ts`
183
185
  - `tests/transparent-background-route.test.ts`
186
+ - `tests/vectorize-image-contract.test.ts`
184
187
  - `tests/video-chroma-key.test.ts`
185
188
  - `tests/video-concat.test.ts`
186
189
  - `tests/video-history-item.test.ts`
@@ -406,6 +409,8 @@ Total: 407 (runtime: 188, contract: 219)
406
409
  - `tests/ui-radius-scale-contract.test.ts`
407
410
  - `tests/ui-touch-target-contract.test.ts`
408
411
  - `tests/ui-typography-rules-contract.test.ts`
412
+ - `tests/vectorize-cli-contract.test.ts`
413
+ - `tests/vectorize-panel-contract.test.ts`
409
414
  - `tests/video-continuity-ui-contract.test.js`
410
415
  - `tests/video-defaults-persistence-contract.test.js`
411
416
  - `tests/video-extend-ui-contract.test.js`
@@ -123,7 +123,12 @@ async function generateAgentImage(ctx, sessionId, prompt, manifest, webSearchEna
123
123
  references: await loadAgentCurrentImageReferences(ctx, sessionId, options.sourceImagePolicy ?? "none"),
124
124
  plannerModel: grokPlannerModel,
125
125
  })
126
- : await generateViaResponses(activeProvider, `${manifest}\n\nUser request:\n${prompt}`, options.quality ?? "medium", providerOptions.size, options.moderation ?? "low", [], requestId, "auto", ctx, {
126
+ : await generateViaResponses(activeProvider, `${manifest}\n\nUser request:\n${prompt}`, options.quality ?? "medium", providerOptions.size, options.moderation ?? "low",
127
+ // The session image must reach the model here too. atlascloud/minimax/grok
128
+ // all forward it; this branch used to pass [], so on the default OAuth/API
129
+ // path the model was asked to edit an image it could not see and answered in
130
+ // prose - which classifies as a retryable no-image result (issue #192).
131
+ await loadAgentCurrentImageReferences(ctx, sessionId, options.sourceImagePolicy ?? "none"), requestId, "auto", ctx, {
127
132
  model: providerOptions.model,
128
133
  reasoningEffort: providerOptions.reasoningEffort,
129
134
  webSearchEnabled,
@@ -132,6 +137,15 @@ async function generateAgentImage(ctx, sessionId, prompt, manifest, webSearchEna
132
137
  const format = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "atlascloud" || activeProvider === "minimax" || activeProvider === "nai"
133
138
  ? imageFormatFromMime(("mime" in response ? response.mime : undefined) || detectImageMimeFromB64(response.b64) || "image/jpeg")
134
139
  : options.format ?? "png";
140
+ if (!response.b64) {
141
+ // agy and atlascloud can resolve with an empty payload (0-byte artifact or a
142
+ // 0-byte HTTP 200 body). Without this guard persistAgentImage writes a 0-byte
143
+ // file and registers it as a real image, which is worse than an error.
144
+ const emptyErr = new Error("Provider returned an empty image.");
145
+ emptyErr.code = "PROVIDER_EMPTY_IMAGE";
146
+ emptyErr.status = 502;
147
+ throw emptyErr;
148
+ }
135
149
  const image = await persistAgentImage(ctx, sessionId, prompt, format, providerOptions.size, requestId, response, {
136
150
  provider: String(activeProvider),
137
151
  model: String(effectiveModel),
@@ -32,6 +32,7 @@ export function getAgentQueueItem(id) {
32
32
  result_image_ids AS resultImageIds,
33
33
  error_code AS errorCode,
34
34
  error_class AS errorClass,
35
+ error_raw_code AS errorRawCode,
35
36
  error_message AS errorMessage,
36
37
  progress_stage AS progressStage,
37
38
  created_at AS createdAt,
@@ -57,6 +58,7 @@ export function listAgentQueueItems(sessionId) {
57
58
  result_image_ids AS resultImageIds,
58
59
  error_code AS errorCode,
59
60
  error_class AS errorClass,
61
+ error_raw_code AS errorRawCode,
60
62
  error_message AS errorMessage,
61
63
  progress_stage AS progressStage,
62
64
  created_at AS createdAt,
@@ -97,6 +99,7 @@ export function claimNextAgentQueueItem(limits) {
97
99
  result_image_ids AS resultImageIds,
98
100
  error_code AS errorCode,
99
101
  error_class AS errorClass,
102
+ error_raw_code AS errorRawCode,
100
103
  error_message AS errorMessage,
101
104
  progress_stage AS progressStage,
102
105
  created_at AS createdAt,
@@ -112,7 +115,7 @@ export function claimNextAgentQueueItem(limits) {
112
115
  continue;
113
116
  const res = getDb().prepare(`
114
117
  UPDATE agent_queue_items
115
- SET status = 'running', started_at = ?, progress_stage = NULL, error_code = NULL, error_class = NULL, error_message = NULL
118
+ SET status = 'running', started_at = ?, progress_stage = NULL, error_code = NULL, error_class = NULL, error_raw_code = NULL, error_message = NULL
116
119
  WHERE id = ? AND status = 'queued'
117
120
  `).run(Date.now(), row.id);
118
121
  if (res.changes > 0)
@@ -129,6 +132,7 @@ export function completeAgentQueueItem(id, imageIds) {
129
132
  progress_stage = NULL,
130
133
  error_code = NULL,
131
134
  error_class = NULL,
135
+ error_raw_code = NULL,
132
136
  error_message = NULL
133
137
  WHERE id = ? AND status = 'running'
134
138
  `).run(JSON.stringify([...imageIds]), Date.now(), id);
@@ -139,16 +143,17 @@ export function failAgentQueueItem(id, error) {
139
143
  SET status = 'failed',
140
144
  error_code = ?,
141
145
  error_class = ?,
146
+ error_raw_code = ?,
142
147
  error_message = ?,
143
148
  finished_at = ?,
144
149
  progress_stage = NULL
145
150
  WHERE id = ? AND status = 'running'
146
- `).run(error.code ?? "AGENT_QUEUE_FAILED", error.errorClass ?? null, error.message, Date.now(), id);
151
+ `).run(error.code ?? "AGENT_QUEUE_FAILED", error.errorClass ?? null, error.rawCode ?? null, error.message, Date.now(), id);
147
152
  }
148
153
  export function cancelAgentQueueItem(id, reason = "Canceled by user") {
149
154
  const res = getDb().prepare(`
150
155
  UPDATE agent_queue_items
151
- SET status = 'canceled', error_code = 'canceled', error_class = NULL, error_message = ?, finished_at = ?, progress_stage = NULL
156
+ SET status = 'canceled', error_code = 'canceled', error_class = NULL, error_raw_code = NULL, error_message = ?, finished_at = ?, progress_stage = NULL
152
157
  WHERE id = ? AND status IN ('queued', 'running')
153
158
  `).run(reason, Date.now(), id);
154
159
  return res.changes > 0;
@@ -220,6 +225,7 @@ export function retryAgentQueueItem(id) {
220
225
  result_image_ids = '[]',
221
226
  error_code = NULL,
222
227
  error_class = NULL,
228
+ error_raw_code = NULL,
223
229
  error_message = NULL,
224
230
  progress_stage = NULL,
225
231
  started_at = NULL,
@@ -243,6 +249,8 @@ function queueItemFromRow(row) {
243
249
  // (cancellation, timeout) must carry no class at all, matching the rule
244
250
  // that nothing invents a class for non-provider errors.
245
251
  ...(row.errorClass ? { errorClass: row.errorClass } : {}),
252
+ // Same omit-rather-than-null rule: only a real provider cause carries a rawCode.
253
+ ...(row.errorRawCode ? { errorRawCode: row.errorRawCode } : {}),
246
254
  errorMessage: row.errorMessage,
247
255
  progressStage: row.progressStage ?? null,
248
256
  createdAt: row.createdAt,
@@ -167,7 +167,12 @@ async function runClaimedQueueItem(ctx, itemId) {
167
167
  }
168
168
  const err = errInfo(error);
169
169
  const timedOut = timeoutController.signal.aborted;
170
- const failure = timedOut ? { code: "timeout", message: "timeout" } : { code: err.code, message: err.message, ...errorEnvelopeFields(err.raw) };
170
+ const envelope = errorEnvelopeFields(err.raw);
171
+ const failure = timedOut
172
+ ? { code: "timeout", message: "timeout" }
173
+ // rawCode must reach the DB: the Agent composer reads the QUEUE, so without it
174
+ // the user only ever sees the generic wrapper code (issue #192).
175
+ : { code: err.code, message: err.message, ...envelope };
171
176
  failAgentQueueItem(item.id, failure);
172
177
  // The chat pane must never fail silently — surface the failure as an
173
178
  // assistant error turn unless the runtime already recorded one.
@@ -303,6 +303,10 @@ const RETRYABLE_NO_IMAGE_CODES = new Set([
303
303
  "WEB_SEARCH_ONLY_RESPONSE",
304
304
  "IMAGE_TOOL_COMPLETED_WITHOUT_RESULT",
305
305
  "IMAGE_TOOL_FAILED",
306
+ // A 0-byte provider payload is the same class of transient as IMAGE_TOOL_FAILED:
307
+ // an empty CDN download usually succeeds on an immediate retry. If the second
308
+ // attempt is also empty the user still gets this specific code.
309
+ "PROVIDER_EMPTY_IMAGE",
306
310
  ]);
307
311
  export function isTextOnlyResult(error) {
308
312
  const err = errInfo(error);
package/lib/db.js CHANGED
@@ -236,6 +236,9 @@ function migrate(database) {
236
236
  addColumnIfMissing(database, agentQueueColumns, "agent_queue_items", "result_image_ids", "TEXT NOT NULL DEFAULT '[]'");
237
237
  addColumnIfMissing(database, agentQueueColumns, "agent_queue_items", "error_code", "TEXT");
238
238
  addColumnIfMissing(database, agentQueueColumns, "agent_queue_items", "error_class", "TEXT");
239
+ // The wrapper code alone (AGENT_TEXT_ONLY_RESULT) tells the user nothing about WHY.
240
+ // The worker already computes rawCode; without this column it was dropped here.
241
+ addColumnIfMissing(database, agentQueueColumns, "agent_queue_items", "error_raw_code", "TEXT");
239
242
  addColumnIfMissing(database, agentQueueColumns, "agent_queue_items", "error_message", "TEXT");
240
243
  addColumnIfMissing(database, agentQueueColumns, "agent_queue_items", "progress_stage", "TEXT");
241
244
  addColumnIfMissing(database, agentQueueColumns, "agent_queue_items", "started_at", "INTEGER");
package/lib/imageThumb.js CHANGED
@@ -4,11 +4,18 @@ const THUMB_WIDTH = 320;
4
4
  const THUMB_QUALITY = 70;
5
5
  // Guard against decompression-bomb memory exhaustion (default sharp limit is 268M px).
6
6
  const MAX_INPUT_PIXELS = 100_000_000; // 100MP (e.g. ~10000x10000)
7
+ const RASTERIZABLE = /\.(png|jpe?g|webp)$/i;
7
8
  export function thumbPathForImage(imagePath) {
8
- return imagePath.replace(/\.(png|jpe?g|webp)$/i, ".thumb.jpg");
9
+ // A non-raster path must never map onto itself: returning the input unchanged
10
+ // would make callers treat the original as its own thumbnail and overwrite it.
11
+ if (!RASTERIZABLE.test(imagePath))
12
+ return `${imagePath}.thumb.jpg`;
13
+ return imagePath.replace(RASTERIZABLE, ".thumb.jpg");
9
14
  }
10
15
  export function thumbUrlForImage(imageUrl) {
11
- return imageUrl.replace(/\.(png|jpe?g|webp)$/i, ".thumb.jpg");
16
+ if (!RASTERIZABLE.test(imageUrl))
17
+ return `${imageUrl}.thumb.jpg`;
18
+ return imageUrl.replace(RASTERIZABLE, ".thumb.jpg");
12
19
  }
13
20
  export async function generateImageThumbnail(imagePath) {
14
21
  const thumbPath = thumbPathForImage(imagePath);
@@ -0,0 +1,132 @@
1
+ import { ColorMode, Hierarchical, PathSimplifyMode, Preset, optimize, vectorize, } from "@neplex/vectorizer";
2
+ import sharp from "sharp";
3
+ /**
4
+ * Raster -> SVG tracing (VTracer core).
5
+ *
6
+ * Traces bitmap regions into real vector paths. This is the opposite of
7
+ * ui/src/lib/canvas/svgExport.ts, which embeds a raster <image> inside SVG
8
+ * chrome without converting any pixels.
9
+ *
10
+ * Quality boundary (measured, see devlog/_plan/260831_vectorize_assets/000_plan.md):
11
+ * keyed cutouts, flat art, logos and sprites trace cleanly; photographic gradients
12
+ * and small text degrade. Callers should present it as a cutout/flat-art tool.
13
+ */
14
+ export const VECTOR_PRESETS = ["auto", "flat", "detailed", "mono"];
15
+ /** Tracing cost scales with area; mirrors the decompression-bomb guard in imageThumb. */
16
+ const MAX_INPUT_BYTES = 40 * 1024 * 1024;
17
+ const MAX_INPUT_DIMENSION = 8000;
18
+ const MAX_OUTPUT_BYTES = 24 * 1024 * 1024;
19
+ const RASTER_INPUT = /\.(png|jpe?g|webp)$/i;
20
+ export function isRasterPath(path) {
21
+ return RASTER_INPUT.test(path);
22
+ }
23
+ export function isVectorPreset(value) {
24
+ return typeof value === "string" && VECTOR_PRESETS.includes(value);
25
+ }
26
+ function vectorizeError(status, code, message) {
27
+ const error = new Error(message);
28
+ error.status = status;
29
+ error.code = code;
30
+ return error;
31
+ }
32
+ function clamp(value, min, max) {
33
+ return Math.min(max, Math.max(min, value));
34
+ }
35
+ /**
36
+ * Base config per named preset. Numeric overrides promote a preset to an explicit
37
+ * Config so a caller can tune one axis without restating all nine fields.
38
+ */
39
+ function baseConfig(preset) {
40
+ const shared = {
41
+ hierarchical: Hierarchical.Stacked,
42
+ mode: PathSimplifyMode.Spline,
43
+ layerDifference: 5,
44
+ lengthThreshold: 5,
45
+ maxIterations: 2,
46
+ spliceThreshold: 45,
47
+ pathPrecision: 5,
48
+ };
49
+ if (preset === "mono") {
50
+ return { ...shared, colorMode: ColorMode.Binary, colorPrecision: 6, filterSpeckle: 4, cornerThreshold: 60 };
51
+ }
52
+ if (preset === "flat") {
53
+ return { ...shared, colorMode: ColorMode.Color, colorPrecision: 6, filterSpeckle: 8, cornerThreshold: 60 };
54
+ }
55
+ return { ...shared, colorMode: ColorMode.Color, colorPrecision: 8, filterSpeckle: 4, cornerThreshold: 60 };
56
+ }
57
+ function hasOverride(options) {
58
+ return options.colorPrecision !== undefined
59
+ || options.filterSpeckle !== undefined
60
+ || options.cornerThreshold !== undefined;
61
+ }
62
+ /**
63
+ * Without overrides we pass the library's own Preset enum: measured on a 1254x1254
64
+ * keyed asset, Preset.Photo produced 722 paths against Poster's 4993 at visually
65
+ * indistinguishable quality, so it is the default for colour work.
66
+ */
67
+ function resolveConfig(preset, options) {
68
+ if (!hasOverride(options)) {
69
+ if (preset === "mono")
70
+ return Preset.Bw;
71
+ if (preset === "flat")
72
+ return baseConfig("flat");
73
+ return Preset.Photo;
74
+ }
75
+ const config = baseConfig(preset);
76
+ if (options.colorPrecision !== undefined)
77
+ config.colorPrecision = clamp(Math.round(options.colorPrecision), 1, 8);
78
+ if (options.filterSpeckle !== undefined)
79
+ config.filterSpeckle = clamp(Math.round(options.filterSpeckle), 0, 128);
80
+ if (options.cornerThreshold !== undefined)
81
+ config.cornerThreshold = clamp(Math.round(options.cornerThreshold), 0, 180);
82
+ return config;
83
+ }
84
+ async function probeDimensions(input) {
85
+ try {
86
+ const meta = await sharp(input).metadata();
87
+ return { width: meta.width ?? 0, height: meta.height ?? 0 };
88
+ }
89
+ catch {
90
+ throw vectorizeError(400, "VECTORIZE_DECODE_FAILED", "input could not be decoded as a raster image");
91
+ }
92
+ }
93
+ function countPaths(svg) {
94
+ return (svg.match(/<path/g) || []).length;
95
+ }
96
+ /** Trace an encoded raster buffer into an SVG document. */
97
+ export async function vectorizeImageBuffer(input, options = {}) {
98
+ if (!Buffer.isBuffer(input) || input.length === 0) {
99
+ throw vectorizeError(400, "VECTORIZE_INPUT_EMPTY", "input buffer is empty");
100
+ }
101
+ if (input.length > MAX_INPUT_BYTES) {
102
+ throw vectorizeError(400, "VECTORIZE_INPUT_TOO_LARGE", "input exceeds 40MB");
103
+ }
104
+ const { width, height } = await probeDimensions(input);
105
+ if (width > MAX_INPUT_DIMENSION || height > MAX_INPUT_DIMENSION) {
106
+ throw vectorizeError(400, "VECTORIZE_DIMENSIONS_TOO_LARGE", "input exceeds " + MAX_INPUT_DIMENSION + "px on a side");
107
+ }
108
+ const preset = isVectorPreset(options.preset) ? options.preset : "auto";
109
+ const started = Date.now();
110
+ let svg;
111
+ try {
112
+ svg = await vectorize(input, resolveConfig(preset, options));
113
+ }
114
+ catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ throw vectorizeError(400, "VECTORIZE_DECODE_FAILED", "tracing failed: " + message);
117
+ }
118
+ if (options.optimize !== false) {
119
+ try {
120
+ svg = await optimize(svg, { multipass: true });
121
+ }
122
+ catch {
123
+ // Optimization is a size win, not a correctness requirement: a failure here
124
+ // must not discard an otherwise valid trace.
125
+ }
126
+ }
127
+ const bytes = Buffer.byteLength(svg, "utf8");
128
+ if (bytes > MAX_OUTPUT_BYTES) {
129
+ throw vectorizeError(413, "VECTORIZE_OUTPUT_TOO_LARGE", "traced SVG exceeds 24MB");
130
+ }
131
+ return { svg, bytes, pathCount: countPaths(svg), elapsedMs: Date.now() - started, preset, width, height };
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "3.12.2",
3
+ "version": "3.12.3",
4
4
  "packageManager": "npm@11.18.0",
5
5
  "description": "Local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.",
6
6
  "type": "module",
@@ -18,7 +18,7 @@
18
18
  "ui:build": "cd ui && npm run build",
19
19
  "build": "npm run ui:build",
20
20
  "test": "node scripts/run-tests.mjs",
21
- "test:native-deps": "node -e \"require('better-sqlite3'); require('sharp')\"",
21
+ "test:native-deps": "node -e \"require('better-sqlite3'); require('sharp'); require('@neplex/vectorizer')\"",
22
22
  "test:install-policy": "node scripts/check-install-policy.mjs",
23
23
  "test:install-policy:npm12": "node scripts/check-install-policy.mjs --npm-pending",
24
24
  "test:package-install": "node --test tests/package-install-smoke.mjs",
@@ -89,6 +89,7 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@modelcontextprotocol/sdk": "1.30.0",
92
+ "@neplex/vectorizer": "0.1.0",
92
93
  "@openai/codex": "0.149.1",
93
94
  "better-sqlite3": "^13.0.3",
94
95
  "dotenv": "^17.4.2",
@@ -120,5 +121,5 @@
120
121
  "typescript": "^5.9.3",
121
122
  "yaml": "2.9.0"
122
123
  },
123
- "gitHead": "7f7a581901cc4e91b6387e1dbc4e757d4b0f700f"
124
+ "gitHead": "9cd60ac1fe483887bd25147679f385c58209ed4f"
124
125
  }