dsh-artcraft-plugin-vectorcraft 0.2.9 → 0.2.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artcraft-plugin-vectorcraft",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH plugin: drive VectorCraft (vector art) headlessly through its own CLI and MCP server.",
@@ -10,7 +10,8 @@
10
10
  * @module @dsh-artcraft/craft-kit/preview
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { createHash } from 'node:crypto'
14
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
14
15
  import { homedir } from 'node:os'
15
16
  import { basename, dirname, join } from 'node:path'
16
17
  import { runCraft } from './exec.js'
@@ -32,19 +33,19 @@ export function previewRoot(configured ) {
32
33
  * picture (SoundCraft has no page, and the MCP-only crafts need their server).
33
34
  */
34
35
 
35
-
36
-
36
+
37
+
37
38
 
38
39
 
39
40
  /** Per-craft render recipes, verified against the 2026-11 CLI releases. */
40
41
  const RECIPES = {
41
42
  // `render FILE OUT [--page N] [--scale S]` draws a page as a PNG.
42
- wordcraft: { argv: (file, out, scale) => ['render', file, out, '--page', '1', '--scale', String(scale)] },
43
+ wordcraft: { argv: (file, out, scale, index) => ['render', file, out, '--page', String(index + 1), '--scale', String(scale)] },
43
44
  // `convert` picks the format from the output extension.
44
45
  cadcraft: { argv: (file, out) => ['convert', file, out] },
45
46
  figcraft: { argv: (file, out) => ['convert', file, out] },
46
47
  // `render FILE [--slide N] [--scale S] OUT` draws one slide as a PNG.
47
- deckcraft: { argv: (file, out, scale) => ['render', file, '--slide', '0', '--scale', String(scale), out] },
48
+ deckcraft: { argv: (file, out, scale, index) => ['render', file, '--slide', String(index), '--scale', String(scale), out] },
48
49
  // VectorCraft exports the active artboard through `run --export`.
49
50
  vectorcraft: { argv: (file, out) => ['run', file, '--export', out] },
50
51
  // TextureCraft renders the viewport (2D view; the 3D one needs a GPU).
@@ -73,6 +74,10 @@ export function canPreview(profile ) {
73
74
 
74
75
 
75
76
 
77
+
78
+
79
+
80
+
76
81
 
77
82
 
78
83
 
@@ -103,6 +108,35 @@ export function canPreview(profile ) {
103
108
 
104
109
 
105
110
 
111
+ /**
112
+ * How many pages or slides a document has, for the crafts that can report it.
113
+ * @param profile - the craft.
114
+ * @param binary - its binary.
115
+ * @param file - the document.
116
+ * @returns the count, or 1 when unknown (rendering the first page is always safe).
117
+ */
118
+ export async function pageCount(profile , binary , file , timeoutMs = 30_000) {
119
+ if (profile.id !== 'wordcraft' && profile.id !== 'deckcraft') return 1
120
+ try {
121
+ const result = await runCraft({ profile, binary, argv: ['info', file], cwd: process.cwd(), timeoutMs })
122
+ if (!result.ok) return 1
123
+ const parsed = JSON.parse(result.stdout)
124
+ const count = profile.id === 'wordcraft' ? parsed.pages : parsed.slides
125
+ return typeof count === 'number' && count > 0 ? count : 1
126
+ } catch {
127
+ return 1
128
+ }
129
+ }
130
+
131
+ /** Content hash of a rendered image, so an unchanged page is not a new step. */
132
+ export function imageHash(path ) {
133
+ try {
134
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
135
+ } catch {
136
+ return undefined
137
+ }
138
+ }
139
+
106
140
  /**
107
141
  * Render one document to `<dir>/<craft>/step-NNN.png`.
108
142
  *
@@ -116,16 +150,18 @@ export function canPreview(profile ) {
116
150
  export async function renderPreview(
117
151
  profile ,
118
152
  binary ,
119
- options ,
120
- ) {
153
+ options ,
154
+ ) {
121
155
  const recipe = RECIPES[profile.id]
122
156
  if (recipe === undefined) return undefined
123
157
  if (!existsSync(options.file)) return undefined
124
158
  try {
125
159
  mkdirSync(options.dir, { recursive: true })
126
- const argv = recipe.argv(options.file, options.out, options.scale ?? 1.5)
160
+ const argv = recipe.argv(options.file, options.out, options.scale ?? 1.5, options.page ?? 0)
127
161
  const result = await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs: options.timeoutMs ?? 60_000 })
128
- return result.ok && existsSync(options.out) ? options.out : undefined
162
+ if (!result.ok || !existsSync(options.out)) return undefined
163
+ const hash = imageHash(options.out)
164
+ return hash === undefined ? undefined : { path: options.out, hash }
129
165
  } catch {
130
166
  return undefined
131
167
  }
@@ -150,6 +186,37 @@ export function nextStepNumber(root , craft ) {
150
186
  }
151
187
  }
152
188
 
189
+ /**
190
+ * Remove a render that matched the previous step's image.
191
+ * @param path - the rendered file.
192
+ * @returns nothing; a missing file is not an error.
193
+ */
194
+ export function discardRender(path ) {
195
+ try {
196
+ unlinkSync(path)
197
+ } catch {
198
+ // Already gone.
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The hash of the last rendered step, for skipping a render that repeats it.
204
+ * @param root - preview root.
205
+ * @param craft - craft id.
206
+ * @returns the hash, or `undefined` when there is no earlier render.
207
+ */
208
+ export function lastStepHash(root , craft ) {
209
+ try {
210
+ const state = JSON.parse(readFileSync(join(root, craft, 'state.json'), 'utf8'))
211
+ const last = state.steps[state.steps.length - 1]
212
+ if (last === undefined) return undefined
213
+ if (last.hash !== undefined) return last.hash
214
+ return last.image === undefined ? undefined : imageHash(join(root, craft, last.image))
215
+ } catch {
216
+ return undefined
217
+ }
218
+ }
219
+
153
220
  /** Read-modify-write one craft's state and refresh the index. */
154
221
  export function recordPreview(
155
222
  root ,
package/src/kit/tools.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { craftCallView, craftResultView } from './present.js'
12
- import { canPreview, nextStepNumber, previewRoot, recordPreview, renderPreview, stepName } from './preview.js'
12
+ import { canPreview, discardRender, lastStepHash, nextStepNumber, pageCount, previewRoot, recordPreview, renderPreview, stepName } from './preview.js'
13
13
  import { workflowsOf } from './suite.js'
14
14
  import { appendJournal } from './journal.js'
15
15
  import { CraftMcpClient, renderSchema } from './mcp.js'
@@ -179,18 +179,38 @@ export function createRuntime(profile , packageRoot , config
179
179
  const dir = join(previewDir, profile.id)
180
180
  const out = join(dir, stepName(n))
181
181
  const scale = options.scale ?? config.previewScale ?? 1.5
182
- const rendered = await renderPreview(profile, binary, { file: options.file, dir, out, scale })
183
- const relative = rendered === undefined ? undefined : stepName(n)
182
+ const previous = lastStepHash(previewDir, profile.id)
183
+
184
+ // Render the first page, and when it did not change look at the last page:
185
+ // appending a slide or typing on page 2 leaves page 1 identical, and a wall
186
+ // of identical thumbnails is worse than no step at all.
187
+ let rendered = await renderPreview(profile, binary, { file: options.file, dir, out, scale, page: 0 })
188
+ let pages = 1
189
+ if (rendered !== undefined && previous !== undefined && rendered.hash === previous) {
190
+ pages = await pageCount(profile, binary, options.file)
191
+ rendered = pages > 1
192
+ ? await renderPreview(profile, binary, { file: options.file, dir, out, scale, page: pages - 1 })
193
+ : undefined
194
+ if (rendered !== undefined && rendered.hash === previous) rendered = undefined
195
+ }
196
+ if (rendered === undefined) {
197
+ // Nothing visible changed; keep the step list meaningful by not adding one.
198
+ discardRender(out)
199
+ return undefined
200
+ }
184
201
  recordPreview(previewDir, profile, {
185
202
  n,
186
203
  at: new Date().toISOString(),
187
204
  tool: options.tool,
188
205
  title: options.title,
189
206
  summary: options.summary,
190
- ...relative === undefined ? {} : { image: relative, thumb: relative },
207
+ image: stepName(n),
208
+ thumb: stepName(n),
209
+ hash: rendered.hash,
210
+ page: pages,
191
211
  ...options.durationMs === undefined ? {} : { durationMs: options.durationMs },
192
- }, options.file, 1)
193
- return rendered
212
+ }, options.file, pages)
213
+ return rendered.path
194
214
  },
195
215
  startJob(binary, argv) {
196
216
  jobCounter += 1