dsh-artcraft-plugin-lightcraft 0.2.10 → 0.2.12

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 (2) hide show
  1. package/package.json +8 -2
  2. package/src/kit/preview.js +115 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artcraft-plugin-lightcraft",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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.",
@@ -45,5 +45,11 @@
45
45
  },
46
46
  "engines": {
47
47
  "dsh": "^0.2.1-rc.3"
48
- }
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/lznnnnn1998/dsh-artcraft.git",
52
+ "directory": "dsh-plugins/plugin-lightcraft"
53
+ },
54
+ "homepage": "https://github.com/lznnnnn1998/dsh-artcraft/tree/main/dsh-plugins/plugin-lightcraft"
49
55
  }
@@ -11,10 +11,13 @@
11
11
  */
12
12
 
13
13
  import { createHash } from 'node:crypto'
14
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
15
15
  import { homedir } from 'node:os'
16
16
  import { basename, dirname, join } from 'node:path'
17
17
  import { runCraft } from './exec.js'
18
+ import { CraftMcpClient } from './mcp.js'
19
+ import { discoverCraft } from './discover.js'
20
+ import { PROFILES_BY_ID } from './profiles.mjs'
18
21
 
19
22
 
20
23
  /** Where the previews live when no configuration overrides it. */
@@ -58,9 +61,66 @@ const RECIPES = {
58
61
  lightcraft: { argv: (file, out) => ['render', file, '-o', out] },
59
62
  }
60
63
 
64
+ /**
65
+ * Crafts whose page only exists through their MCP server.
66
+ *
67
+ * PdfCraft has no CLI render verb and FilmCraft renders a sequence frame, not a
68
+ * file; both answer over MCP, so the preview borrows their own server.
69
+ */
70
+ const MCP_RENDER = { pdfcraft: 'pdfcraft', filmcraft: 'filmcraft' }
71
+
72
+ /**
73
+ * Crafts that can export a PDF but cannot rasterise one. Their preview exports
74
+ * a temporary PDF with the craft's own CLI and renders it through PdfCraft —
75
+ * the alternative is no preview at all for the three most document-shaped apps.
76
+ */
77
+ const PDF_BRIDGE = {
78
+ gridcraft: (file, pdf) => ['convert', file, pdf],
79
+ designcraft: (file, pdf) => ['run', '--in', file, '--export', pdf],
80
+ }
81
+
82
+ /** Open one craft's MCP server, run a callback, always close it. */
83
+ async function withMcp (craftId , run ) {
84
+ // `PROFILES_BY_ID` is a Map, not a record.
85
+ const profile = (PROFILES_BY_ID ).get(craftId)
86
+ if (profile === undefined) return undefined
87
+ const binary = discoverCraft(profile)
88
+ if (binary === undefined) return undefined
89
+ const client = new CraftMcpClient(profile, binary)
90
+ try {
91
+ await client.start(60_000)
92
+ return await run(client)
93
+ } catch {
94
+ return undefined
95
+ } finally {
96
+ client.close()
97
+ }
98
+ }
99
+
100
+ /** Call an MCP tool that answers with an image, and copy it to the step file. */
101
+ async function imageFromCall(
102
+ client ,
103
+ tool ,
104
+ args ,
105
+ out ,
106
+ ) {
107
+ const text = await client.callTool(tool, args, join(dirname(out), '.mcp-images'))
108
+ const match = /\[image\]\s+(.+)/.exec(text)
109
+ if (match === null) return undefined
110
+ try {
111
+ copyFileSync(match[1].trim(), out)
112
+ } catch {
113
+ return undefined
114
+ }
115
+ const hash = imageHash(out)
116
+ return hash === undefined ? undefined : { path: out, hash }
117
+ }
118
+
61
119
  /** Whether a craft can render a preview at all. */
62
120
  export function canPreview(profile ) {
63
121
  return RECIPES[profile.id] !== undefined
122
+ || MCP_RENDER[profile.id] !== undefined
123
+ || PDF_BRIDGE[profile.id] !== undefined
64
124
  }
65
125
 
66
126
  /** One preview step, as documented in docs/PREVIEW.md. */
@@ -152,11 +212,41 @@ export async function renderPreview(
152
212
  binary ,
153
213
  options ,
154
214
  ) {
215
+ if (!existsSync(options.file)) return undefined
216
+ mkdirSync(options.dir, { recursive: true })
217
+
218
+ // PdfCraft and FilmCraft render through their own MCP server: PdfCraft has no
219
+ // CLI render verb, and a FilmCraft frame is a timeline position, not a file.
220
+ if (MCP_RENDER[profile.id] !== undefined) {
221
+ if (profile.id === 'pdfcraft') return await renderPdf(options.file, options)
222
+ return await withMcp('filmcraft', async (client) => {
223
+ await client.callTool('command_run', { id: 'file.open', params: { path: options.file } })
224
+ return await imageFromCall(client, 'render_frame', { max_side: 1400 }, options.out)
225
+ })
226
+ }
227
+
228
+ // GridCraft and DesignCraft export a PDF but cannot rasterise one; PdfCraft
229
+ // renders the page, so the preview has a picture instead of a step with none.
230
+ const bridge = PDF_BRIDGE[profile.id]
231
+ if (bridge !== undefined) {
232
+ const pdf = join(options.dir, `${profile.id}-preview.pdf`)
233
+ try {
234
+ const exported = await runCraft({
235
+ profile, binary,
236
+ argv: bridge(options.file, pdf),
237
+ cwd: process.cwd(),
238
+ timeoutMs: options.timeoutMs ?? 120_000,
239
+ })
240
+ if (!exported.ok || !existsSync(pdf)) return undefined
241
+ } catch {
242
+ return undefined
243
+ }
244
+ return await renderPdf(pdf, options)
245
+ }
246
+
155
247
  const recipe = RECIPES[profile.id]
156
248
  if (recipe === undefined) return undefined
157
- if (!existsSync(options.file)) return undefined
158
249
  try {
159
- mkdirSync(options.dir, { recursive: true })
160
250
  const argv = recipe.argv(options.file, options.out, options.scale ?? 1.5, options.page ?? 0)
161
251
  const result = await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs: options.timeoutMs ?? 60_000 })
162
252
  if (!result.ok || !existsSync(options.out)) return undefined
@@ -167,6 +257,28 @@ export async function renderPreview(
167
257
  }
168
258
  }
169
259
 
260
+ /** Render one page of a PDF through PdfCraft's MCP server. */
261
+ async function renderPdf(
262
+ file ,
263
+ options ,
264
+ ) {
265
+ return await withMcp('pdfcraft', async (client) => {
266
+ const opened = await client.callTool('doc_open', { path: file })
267
+ // PdfCraft answers `{doc: 1, pages: 2, …}`; `id` is only a fallback for a
268
+ // server that renames the field.
269
+ let docId
270
+ try {
271
+ const parsed = JSON.parse(opened)
272
+ docId = parsed.doc ?? parsed.id
273
+ } catch {
274
+ const match = /"(?:doc|id)"\s*:\s*(\d+)/.exec(opened)
275
+ docId = match === null ? undefined : Number(match[1])
276
+ }
277
+ if (typeof docId !== 'number') return undefined
278
+ return await imageFromCall(client, 'page_render', { doc: docId, page: (options.page ?? 0) + 1, dpi: 110 }, options.out)
279
+ })
280
+ }
281
+
170
282
  /**
171
283
  * The next step number for a craft, across processes.
172
284
  *