dsh-artcraft-plugin-vectorcraft 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 +1 -1
- package/src/kit/preview.js +76 -9
- package/src/kit/suite.js +80 -0
- package/src/kit/tools.js +35 -7
package/package.json
CHANGED
package/src/kit/preview.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* @module @dsh-artcraft/craft-kit/preview
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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/suite.js
ADDED
|
@@ -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
|
|
182
|
-
|
|
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
|
-
|
|
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,
|
|
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 () => {
|