dsh-artcraft-plugin-lightcraft 0.2.8 → 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-lightcraft",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH plugin: drive LightCraft (photo library and raw development) 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 ,
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The ArtCraft suite graph: which applications work together.
3
+ *
4
+ * A user who installs one craft has a working tool, not the best version of the
5
+ * workflow they are about to attempt: a report wants a spreadsheet and a word
6
+ * processor, a video wants slides and a mixer. This is the one place that
7
+ * knowledge lives, so the installer, the doctor and the plugins agree.
8
+ * @module @dsh-artcraft/craft-kit/suite
9
+ */
10
+
11
+ /** A workflow that spans several crafts. */
12
+
13
+
14
+
15
+
16
+
17
+
18
+ /** A package that is not a craft but completes the experience. */
19
+
20
+
21
+
22
+
23
+
24
+
25
+ /** Workflows that need more than one craft. */
26
+ export const SUITE_WORKFLOWS = [
27
+ { id: 'report', delivers: 'a data-backed report as DOCX and PDF', crafts: ['gridcraft', 'wordcraft', 'pdfcraft'] },
28
+ { id: 'deck', delivers: 'a presentation with native charts', crafts: ['gridcraft', 'deckcraft', 'pdfcraft'] },
29
+ { id: 'explainer-video', delivers: 'a narrated video from slides', crafts: ['deckcraft', 'filmcraft', 'soundcraft'] },
30
+ { id: 'motion', delivers: 'a motion-graphics piece cut with the video', crafts: ['effectcraft', 'filmcraft'] },
31
+ { id: 'brand-kit', delivers: 'a design system turned into a brand book', crafts: ['figcraft', 'vectorcraft', 'designcraft'] },
32
+ { id: 'photo-pipeline', delivers: 'raw files developed and retouched for web and print', crafts: ['lightcraft', 'photocraft'] },
33
+ { id: 'print-layout', delivers: 'a print-ready layout with images and graphics', crafts: ['photocraft', 'vectorcraft', 'designcraft'] },
34
+ { id: 'cad-set', delivers: 'a drawing set delivered as one PDF', crafts: ['cadcraft', 'pdfcraft'] },
35
+ { id: 'texture-kit', delivers: 'PBR maps baked and packed for a 3D asset', crafts: ['texturecraft', 'photocraft'] },
36
+ ]
37
+
38
+ /** Packages that improve every craft. */
39
+ export const SUITE_COMPANIONS = [
40
+ {
41
+ id: 'preview-panel',
42
+ package: 'dsh-artcraft-preview-panel',
43
+ why: 'shows each step of the document inside the DSH Web GUI, and starts the local preview server',
44
+ },
45
+ {
46
+ id: 'skills',
47
+ package: 'dsh-artcraft-skills',
48
+ why: 'the workflows themselves (report, brand kit, explainer video, drawing set, conversion) as agent skills',
49
+ },
50
+ ]
51
+
52
+ /**
53
+ * Missing crafts of the workflows a user has already started.
54
+ * @param installed - craft ids currently installed.
55
+ * @returns one entry per missing craft, best-completing workflow first.
56
+ */
57
+ export function suggestCrafts(installed ) {
58
+ const have = new Set(installed)
59
+ const missing = new Map ()
60
+ for (const workflow of SUITE_WORKFLOWS) {
61
+ const present = workflow.crafts.filter(craft => have.has(craft))
62
+ if (present.length === 0 || present.length === workflow.crafts.length) continue
63
+ for (const craft of workflow.crafts) {
64
+ if (have.has(craft)) continue
65
+ const existing = missing.get(craft)
66
+ const candidate = { craft, forWorkflow: workflow.id, delivers: workflow.delivers, has: present.length, needs: workflow.crafts.length }
67
+ if (existing === undefined || candidate.has / candidate.needs > existing.has / existing.needs) missing.set(craft, candidate)
68
+ }
69
+ }
70
+ return [...missing.values()].sort((a, b) => b.has / b.needs - a.has / a.needs || a.craft.localeCompare(b.craft))
71
+ }
72
+
73
+ /**
74
+ * The workflows one craft takes part in.
75
+ * @param craft - craft id.
76
+ * @returns the workflows containing it.
77
+ */
78
+ export function workflowsOf(craft ) {
79
+ return SUITE_WORKFLOWS.filter(workflow => workflow.crafts.includes(craft))
80
+ }
package/src/kit/tools.js CHANGED
@@ -9,7 +9,8 @@
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
+ import { workflowsOf } from './suite.js'
13
14
  import { appendJournal } from './journal.js'
14
15
  import { CraftMcpClient, renderSchema } from './mcp.js'
15
16
 
@@ -178,18 +179,38 @@ export function createRuntime(profile , packageRoot , config
178
179
  const dir = join(previewDir, profile.id)
179
180
  const out = join(dir, stepName(n))
180
181
  const scale = options.scale ?? config.previewScale ?? 1.5
181
- const rendered = await renderPreview(profile, binary, { file: options.file, dir, out, scale })
182
- 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
+ }
183
201
  recordPreview(previewDir, profile, {
184
202
  n,
185
203
  at: new Date().toISOString(),
186
204
  tool: options.tool,
187
205
  title: options.title,
188
206
  summary: options.summary,
189
- ...relative === undefined ? {} : { image: relative, thumb: relative },
207
+ image: stepName(n),
208
+ thumb: stepName(n),
209
+ hash: rendered.hash,
210
+ page: pages,
190
211
  ...options.durationMs === undefined ? {} : { durationMs: options.durationMs },
191
- }, options.file, 1)
192
- return rendered
212
+ }, options.file, pages)
213
+ return rendered.path
193
214
  },
194
215
  startJob(binary, argv) {
195
216
  jobCounter += 1
@@ -1157,7 +1178,14 @@ export function applyCraftPlugin(
1157
1178
  + (profile.mcpAttach === undefined
1158
1179
  ? ''
1159
1180
  : `When the user has ${profile.product} open, pass \`attach: <control port>\` to an mcp call to work in their window instead of a headless copy. `)
1160
- + `Images returned by a render or screenshot are saved and named by path; look at them with \`read_image\`.`,
1181
+ + `Images returned by a render or screenshot are saved and named by path; look at them with \`read_image\`.`
1182
+ + (() => {
1183
+ const partners = [...new Set(workflowsOf(profile.id).flatMap(workflow => workflow.crafts).filter(craft => craft !== profile.id))]
1184
+ if (partners.length === 0) return ''
1185
+ // A half-installed workflow is the common gap: name the partners so the
1186
+ // agent can offer the missing plugin instead of failing at the join.
1187
+ return ` Workflows that start in ${profile.product} usually also need ${partners.join(', ')}: if one of those tools is unavailable, say which plugin provides it and offer to install it.`
1188
+ })(),
1161
1189
  order: CRAFT_SECTION_ORDER,
1162
1190
  })
1163
1191
  return () => {