@dickpy/dsh-imagegen 1.5.6 → 1.5.7

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.
@@ -1,375 +1,375 @@
1
- /** Host-persisted infinite canvas documents and content-addressed assets. */
2
-
3
- import { promises as fs } from 'node:fs'
4
- import { createHash, randomUUID } from 'node:crypto'
5
- import path from 'node:path'
6
- import type { CanvasAssetRef, CanvasDocument, CanvasNode, CanvasSummary } from './protocol.ts'
7
- import { imageDataRoot } from './image-storage-path.ts'
8
-
9
-
10
-
11
- export interface CanvasImageInput {
12
- data: Uint8Array
13
- mime: string
14
- width: number
15
- height: number
16
- origin: CanvasAssetRef['origin']
17
- originId?: string
18
- entryId?: string
19
- imageIndex?: number
20
- name?: string
21
- }
22
-
23
- export class CanvasConflictError extends Error {
24
- readonly code = 'canvas-conflict'
25
- constructor(message = '画布已在其他窗口更新,请重新加载后再保存。') {
26
- super(message)
27
- this.name = 'CanvasConflictError'
28
- }
29
- }
30
-
31
- interface IndexFile {
32
- projects: CanvasSummary[]
33
- }
34
-
35
- function extensionOf(mime: string): string {
36
- switch (mime.split(';')[0]!.trim().toLowerCase()) {
37
- case 'image/jpeg': return 'jpg'
38
- case 'image/webp': return 'webp'
39
- case 'image/gif': return 'gif'
40
- default: return 'png'
41
- }
42
- }
43
-
44
- function mimeOf(file: string): string {
45
- switch (path.extname(file).toLowerCase()) {
46
- case '.jpg':
47
- case '.jpeg': return 'image/jpeg'
48
- case '.webp': return 'image/webp'
49
- case '.gif': return 'image/gif'
50
- default: return 'image/png'
51
- }
52
- }
53
-
54
- function safeId(value: string): string {
55
- const id = value.replace(/[^a-zA-Z0-9_-]/g, '-')
56
- return id === '' ? randomUUID() : id
57
- }
58
-
59
- function pagePath(id: string): string {
60
- return path.join(path.join(imageDataRoot(), 'canvas', 'pages'), `${safeId(id)}.json`)
61
- }
62
-
63
- function assetFilePath(id: string): string | undefined {
64
- if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
65
- const root = path.join(imageDataRoot(), 'canvas', 'assets')
66
- const file = path.join(root, id)
67
- const relative = path.relative(root, file)
68
- if (relative.startsWith('..') || path.isAbsolute(relative)) return undefined
69
- return file
70
- }
71
-
72
- async function ensureDirs(): Promise<void> {
73
- await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'pages'), { recursive: true })
74
- await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'assets'), { recursive: true })
75
- }
76
-
77
- async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
78
- await fs.mkdir(path.dirname(file), { recursive: true })
79
- const temp = `${file}.tmp-${process.pid}-${randomUUID()}`
80
- await fs.writeFile(temp, `${JSON.stringify(value)}\n`, 'utf8')
81
- await fs.rename(temp, file)
82
- }
83
-
84
- async function readJson(file: string): Promise<unknown | undefined> {
85
- try { return JSON.parse(await fs.readFile(file, 'utf8')) as unknown } catch { return undefined }
86
- }
87
-
88
- function defaultDocument(id: string, title: string): CanvasDocument {
89
- const now = Date.now()
90
- return {
91
- version: 2,
92
- id,
93
- title,
94
- revision: 1,
95
- viewport: { x: 0, y: 0, k: 1 },
96
- background: 'dots',
97
- nodes: [],
98
- connections: [],
99
- createdAt: now,
100
- updatedAt: now,
101
- }
102
- }
103
-
104
- function isAssetRef(value: unknown): value is CanvasAssetRef {
105
- if (value === null || typeof value !== 'object') return false
106
- const asset = value as Record<string, unknown>
107
- return typeof asset.assetId === 'string' && typeof asset.url === 'string' && typeof asset.mime === 'string'
108
- && typeof asset.width === 'number' && typeof asset.height === 'number'
109
- }
110
-
111
- function isNode(value: unknown): value is CanvasNode {
112
- if (value === null || typeof value !== 'object') return false
113
- const node = value as Record<string, unknown>
114
- if (typeof node.id !== 'string' || typeof node.title !== 'string' || typeof node.x !== 'number'
115
- || typeof node.y !== 'number' || typeof node.width !== 'number' || typeof node.height !== 'number') return false
116
- if (node.type !== 'image' && node.type !== 'text' && node.type !== 'config') return false
117
- const metadata = node.metadata
118
- if (metadata !== undefined && (metadata === null || typeof metadata !== 'object')) return false
119
- const state = (metadata ?? {}) as Record<string, unknown>
120
- if (node.type === 'image') {
121
- if (state.asset !== undefined && !isAssetRef(state.asset)) return false
122
- return state.status === undefined || state.status === 'idle' || state.status === 'generating' || state.status === 'success' || state.status === 'error'
123
- }
124
- if (node.type === 'config') {
125
- return state.prompt === undefined || typeof state.prompt === 'string'
126
- }
127
- return state.text === undefined || typeof state.text === 'string'
128
- }
129
-
130
- function isDocument(value: unknown): value is CanvasDocument {
131
- if (value === null || typeof value !== 'object') return false
132
- const document = value as Record<string, unknown>
133
- return document.version === 2 && typeof document.id === 'string' && typeof document.title === 'string'
134
- && typeof document.revision === 'number' && document.viewport !== null && typeof document.viewport === 'object'
135
- && typeof (document.viewport as { x?: unknown }).x === 'number'
136
- && typeof (document.viewport as { y?: unknown }).y === 'number'
137
- && typeof (document.viewport as { k?: unknown }).k === 'number'
138
- && (document.background === 'dots' || document.background === 'lines' || document.background === 'diagonal'
139
- || document.background === 'checker' || document.background === 'blank' || document.background === 'image')
140
- && (document.backgroundImage === undefined || typeof document.backgroundImage === 'string')
141
- && Array.isArray(document.nodes) && document.nodes.every(isNode)
142
- && Array.isArray(document.connections)
143
- }
144
-
145
- /** Upgrade a v1 (image/text/annotation + edges) document to the v2 node-graph model.
146
- * Images and text notes keep their geometry; annotation prompt cards become plain
147
- * text notes carrying the prompt, and old edges survive only between surviving nodes. */
148
- function migrateLegacyDocument(input: Record<string, unknown>): CanvasDocument {
149
- const now = Date.now()
150
- const legacyNodes = Array.isArray(input.nodes) ? input.nodes : []
151
- const nodes: CanvasNode[] = []
152
- const annotationIds = new Set<string>()
153
- for (const raw of legacyNodes) {
154
- if (raw === null || typeof raw !== 'object') continue
155
- const node = raw as Record<string, unknown>
156
- if (typeof node.id !== 'string' || typeof node.x !== 'number' || typeof node.y !== 'number') continue
157
- const base = {
158
- id: node.id,
159
- title: typeof node.title === 'string' ? node.title : '未命名节点',
160
- x: node.x,
161
- y: node.y,
162
- width: typeof node.width === 'number' ? node.width : 300,
163
- height: typeof node.height === 'number' ? node.height : 220,
164
- }
165
- if (node.type === 'image' && isAssetRef(node.asset)) {
166
- const generation = (node.generation ?? {}) as Record<string, unknown>
167
- nodes.push({
168
- ...base,
169
- type: 'image',
170
- metadata: {
171
- asset: node.asset,
172
- status: (typeof node.status === 'string' && ['idle', 'generating', 'success', 'error'].includes(node.status)
173
- ? node.status
174
- : 'success') as 'idle' | 'generating' | 'success' | 'error',
175
- ...(typeof node.error === 'string' ? { error: node.error } : {}),
176
- ...(typeof generation.prompt === 'string' ? { prompt: generation.prompt } : {}),
177
- ...(typeof generation.model === 'string' ? { model: generation.model } : {}),
178
- ...(typeof generation.taskId === 'string' ? { taskId: generation.taskId } : {}),
179
- ...(typeof generation.sourceNodeId === 'string' ? { sourceNodeId: generation.sourceNodeId } : {}),
180
- },
181
- })
182
- } else if (node.type === 'text') {
183
- nodes.push({ ...base, type: 'text', metadata: { text: typeof node.text === 'string' ? node.text : '', ...(typeof node.fontSize === 'number' ? { fontSize: node.fontSize } : {}) } })
184
- } else if (node.type === 'annotation') {
185
- annotationIds.add(node.id)
186
- const prompt = typeof node.prompt === 'string' && node.prompt.trim() !== '' ? node.prompt : '(旧版标注,提示词见此)'
187
- nodes.push({ ...base, type: 'text', title: '旧版标注', metadata: { text: prompt } })
188
- }
189
- }
190
- nodes.sort((a, b) => {
191
- const za = (legacyNodes.find(item => (item as Record<string, unknown>)?.id === a.id) as Record<string, unknown> | undefined)?.zIndex
192
- const zb = (legacyNodes.find(item => (item as Record<string, unknown>)?.id === b.id) as Record<string, unknown> | undefined)?.zIndex
193
- return (typeof za === 'number' ? za : 0) - (typeof zb === 'number' ? zb : 0)
194
- })
195
- const validIds = new Set(nodes.map(node => node.id))
196
- const seen = new Set<string>()
197
- const connections = (Array.isArray(input.edges) ? input.edges : []).flatMap(raw => {
198
- if (raw === null || typeof raw !== 'object') return []
199
- const edge = raw as Record<string, unknown>
200
- if (typeof edge.fromNodeId !== 'string' || typeof edge.toNodeId !== 'string') return []
201
- if (annotationIds.has(edge.fromNodeId) || annotationIds.has(edge.toNodeId)) return []
202
- if (!validIds.has(edge.fromNodeId) || !validIds.has(edge.toNodeId) || edge.fromNodeId === edge.toNodeId) return []
203
- const key = `${edge.fromNodeId}->${edge.toNodeId}`
204
- if (seen.has(key)) return []
205
- seen.add(key)
206
- return [{ id: typeof edge.id === 'string' ? edge.id : `edge-${randomUUID()}`, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId }]
207
- })
208
- const legacyViewport = (input.viewport ?? {}) as Record<string, unknown>
209
- const background = input.background === 'grid' ? 'lines' : input.background === 'blank' ? 'blank' : 'dots'
210
- return {
211
- version: 2,
212
- id: typeof input.id === 'string' ? input.id : randomUUID(),
213
- title: typeof input.title === 'string' ? input.title : '未命名画布',
214
- revision: typeof input.revision === 'number' ? input.revision : 1,
215
- viewport: {
216
- x: typeof legacyViewport.x === 'number' ? legacyViewport.x : 0,
217
- y: typeof legacyViewport.y === 'number' ? legacyViewport.y : 0,
218
- k: typeof legacyViewport.scale === 'number' && legacyViewport.scale > 0 ? legacyViewport.scale : 1,
219
- },
220
- background,
221
- nodes,
222
- connections,
223
- createdAt: typeof input.createdAt === 'number' ? input.createdAt : now,
224
- updatedAt: typeof input.updatedAt === 'number' ? input.updatedAt : now,
225
- }
226
- }
227
-
228
- /** Accept either the v2 document or a legacy v1 payload and return v2. */
229
- function coerceDocument(value: unknown): CanvasDocument | undefined {
230
- if (value === null || typeof value !== 'object') return undefined
231
- const document = value as Record<string, unknown>
232
- if (document.version === 1) {
233
- const migrated = migrateLegacyDocument(document)
234
- return isDocument(migrated) ? migrated : undefined
235
- }
236
- return isDocument(value) ? value : undefined
237
- }
238
-
239
- let mutation: Promise<void> = Promise.resolve()
240
- function serialize<T>(operation: () => Promise<T>): Promise<T> {
241
- const next = mutation.then(operation, operation)
242
- mutation = next.then(() => undefined, () => undefined)
243
- return next
244
- }
245
-
246
- export class CanvasStore {
247
- constructor(private readonly root?: string) {}
248
-
249
- private pagesDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'pages') }
250
- private assetsDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'assets') }
251
- private indexPath(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'index.json') }
252
-
253
- private async ensure(): Promise<void> {
254
- await fs.mkdir(this.pagesDir(), { recursive: true })
255
- await fs.mkdir(this.assetsDir(), { recursive: true })
256
- }
257
-
258
- private pagePath(id: string): string { return path.join(this.pagesDir(), `${safeId(id)}.json`) }
259
- private assetPath(id: string): string | undefined {
260
- if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
261
- const target = path.join(this.assetsDir(), id)
262
- const relative = path.relative(this.assetsDir(), target)
263
- return relative.startsWith('..') || path.isAbsolute(relative) ? undefined : target
264
- }
265
-
266
- private async readIndex(): Promise<CanvasSummary[]> {
267
- const value = await readJson(this.indexPath())
268
- if (value === undefined || typeof value !== 'object' || !Array.isArray((value as { projects?: unknown }).projects)) return []
269
- return (value as { projects: unknown[] }).projects.filter(item => {
270
- if (item === null || typeof item !== 'object') return false
271
- const project = item as Record<string, unknown>
272
- return typeof project.id === 'string' && typeof project.title === 'string' && typeof project.revision === 'number'
273
- && typeof project.nodeCount === 'number' && typeof project.createdAt === 'number' && typeof project.updatedAt === 'number'
274
- }) as CanvasSummary[]
275
- }
276
-
277
- private async writeIndex(projects: CanvasSummary[]): Promise<void> {
278
- await this.ensure()
279
- await writeJsonAtomic(this.indexPath(), { projects })
280
- }
281
-
282
- async list(): Promise<CanvasSummary[]> {
283
- return this.readIndex()
284
- }
285
-
286
- async create(title = '未命名画布'): Promise<CanvasDocument> {
287
- return serialize(async () => {
288
- await this.ensure()
289
- const id = randomUUID()
290
- const document = defaultDocument(id, title.trim() || '未命名画布')
291
- await writeJsonAtomic(this.pagePath(id), document)
292
- const projects = await this.readIndex()
293
- await this.writeIndex([this.summaryOf(document), ...projects])
294
- return document
295
- })
296
- }
297
-
298
- async read(id: string): Promise<CanvasDocument | undefined> {
299
- return coerceDocument(await readJson(this.pagePath(id)))
300
- }
301
-
302
- async save(document: CanvasDocument, expectedRevision?: number): Promise<CanvasDocument> {
303
- return serialize(async () => {
304
- const incoming = coerceDocument(document)
305
- if (incoming === undefined) throw new Error('malformed canvas document')
306
- const current = await this.read(incoming.id)
307
- if (current !== undefined && expectedRevision !== undefined && current.revision !== expectedRevision) {
308
- throw new CanvasConflictError()
309
- }
310
- const next: CanvasDocument = {
311
- ...incoming,
312
- revision: Math.max(current?.revision ?? 0, incoming.revision) + 1,
313
- updatedAt: Date.now(),
314
- }
315
- await this.ensure()
316
- await writeJsonAtomic(this.pagePath(next.id), next)
317
- const projects = (await this.readIndex()).filter(item => item.id !== next.id)
318
- await this.writeIndex([this.summaryOf(next), ...projects])
319
- return next
320
- })
321
- }
322
-
323
- async remove(id: string): Promise<CanvasSummary[]> {
324
- return serialize(async () => {
325
- try { await fs.rm(this.pagePath(id), { force: true }) } catch { /* best effort */ }
326
- const projects = (await this.readIndex()).filter(item => item.id !== id)
327
- await this.writeIndex(projects)
328
- return projects
329
- })
330
- }
331
-
332
- async putImage(input: CanvasImageInput): Promise<CanvasAssetRef> {
333
- if (!input.data.byteLength) throw new Error('image data is empty')
334
- if (!/^image\/(png|jpeg|webp|gif)$/.test(input.mime)) throw new Error('unsupported image type')
335
- if (!Number.isSafeInteger(input.width) || input.width < 1 || !Number.isSafeInteger(input.height) || input.height < 1) {
336
- throw new Error('image dimensions are invalid')
337
- }
338
- await this.ensure()
339
- const hash = createHash('sha256').update(input.data).digest('hex')
340
- const file = `${hash}.${extensionOf(input.mime)}`
341
- const target = path.join(this.assetsDir(), file)
342
- try { await fs.access(target) } catch { await fs.writeFile(target, input.data) }
343
- return {
344
- assetId: file,
345
- url: `/api/dsh-imagegen/canvas/asset/${file}`,
346
- mime: input.mime,
347
- bytes: input.data.byteLength,
348
- width: input.width,
349
- height: input.height,
350
- origin: input.origin,
351
- ...input.originId === undefined ? {} : { originId: input.originId },
352
- ...input.entryId === undefined ? {} : { entryId: input.entryId },
353
- ...input.imageIndex === undefined ? {} : { imageIndex: input.imageIndex },
354
- }
355
- }
356
-
357
- async readAsset(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
358
- const target = this.assetPath(file)
359
- if (target === undefined) return undefined
360
- try { return { data: await fs.readFile(target), mime: mimeOf(file) } } catch { return undefined }
361
- }
362
-
363
- private summaryOf(document: CanvasDocument): CanvasSummary {
364
- return {
365
- id: document.id,
366
- title: document.title,
367
- revision: document.revision,
368
- nodeCount: document.nodes.length,
369
- createdAt: document.createdAt,
370
- updatedAt: document.updatedAt,
371
- }
372
- }
373
- }
374
-
375
- export const canvasStore = new CanvasStore()
1
+ /** Host-persisted infinite canvas documents and content-addressed assets. */
2
+
3
+ import { promises as fs } from 'node:fs'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import path from 'node:path'
6
+ import type { CanvasAssetRef, CanvasDocument, CanvasNode, CanvasSummary } from './protocol.ts'
7
+ import { imageDataRoot } from './image-storage-path.ts'
8
+
9
+
10
+
11
+ export interface CanvasImageInput {
12
+ data: Uint8Array
13
+ mime: string
14
+ width: number
15
+ height: number
16
+ origin: CanvasAssetRef['origin']
17
+ originId?: string
18
+ entryId?: string
19
+ imageIndex?: number
20
+ name?: string
21
+ }
22
+
23
+ export class CanvasConflictError extends Error {
24
+ readonly code = 'canvas-conflict'
25
+ constructor(message = '画布已在其他窗口更新,请重新加载后再保存。') {
26
+ super(message)
27
+ this.name = 'CanvasConflictError'
28
+ }
29
+ }
30
+
31
+ interface IndexFile {
32
+ projects: CanvasSummary[]
33
+ }
34
+
35
+ function extensionOf(mime: string): string {
36
+ switch (mime.split(';')[0]!.trim().toLowerCase()) {
37
+ case 'image/jpeg': return 'jpg'
38
+ case 'image/webp': return 'webp'
39
+ case 'image/gif': return 'gif'
40
+ default: return 'png'
41
+ }
42
+ }
43
+
44
+ function mimeOf(file: string): string {
45
+ switch (path.extname(file).toLowerCase()) {
46
+ case '.jpg':
47
+ case '.jpeg': return 'image/jpeg'
48
+ case '.webp': return 'image/webp'
49
+ case '.gif': return 'image/gif'
50
+ default: return 'image/png'
51
+ }
52
+ }
53
+
54
+ function safeId(value: string): string {
55
+ const id = value.replace(/[^a-zA-Z0-9_-]/g, '-')
56
+ return id === '' ? randomUUID() : id
57
+ }
58
+
59
+ function pagePath(id: string): string {
60
+ return path.join(path.join(imageDataRoot(), 'canvas', 'pages'), `${safeId(id)}.json`)
61
+ }
62
+
63
+ function assetFilePath(id: string): string | undefined {
64
+ if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
65
+ const root = path.join(imageDataRoot(), 'canvas', 'assets')
66
+ const file = path.join(root, id)
67
+ const relative = path.relative(root, file)
68
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return undefined
69
+ return file
70
+ }
71
+
72
+ async function ensureDirs(): Promise<void> {
73
+ await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'pages'), { recursive: true })
74
+ await fs.mkdir(path.join(imageDataRoot(), 'canvas', 'assets'), { recursive: true })
75
+ }
76
+
77
+ async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
78
+ await fs.mkdir(path.dirname(file), { recursive: true })
79
+ const temp = `${file}.tmp-${process.pid}-${randomUUID()}`
80
+ await fs.writeFile(temp, `${JSON.stringify(value)}\n`, 'utf8')
81
+ await fs.rename(temp, file)
82
+ }
83
+
84
+ async function readJson(file: string): Promise<unknown | undefined> {
85
+ try { return JSON.parse(await fs.readFile(file, 'utf8')) as unknown } catch { return undefined }
86
+ }
87
+
88
+ function defaultDocument(id: string, title: string): CanvasDocument {
89
+ const now = Date.now()
90
+ return {
91
+ version: 2,
92
+ id,
93
+ title,
94
+ revision: 1,
95
+ viewport: { x: 0, y: 0, k: 1 },
96
+ background: 'dots',
97
+ nodes: [],
98
+ connections: [],
99
+ createdAt: now,
100
+ updatedAt: now,
101
+ }
102
+ }
103
+
104
+ function isAssetRef(value: unknown): value is CanvasAssetRef {
105
+ if (value === null || typeof value !== 'object') return false
106
+ const asset = value as Record<string, unknown>
107
+ return typeof asset.assetId === 'string' && typeof asset.url === 'string' && typeof asset.mime === 'string'
108
+ && typeof asset.width === 'number' && typeof asset.height === 'number'
109
+ }
110
+
111
+ function isNode(value: unknown): value is CanvasNode {
112
+ if (value === null || typeof value !== 'object') return false
113
+ const node = value as Record<string, unknown>
114
+ if (typeof node.id !== 'string' || typeof node.title !== 'string' || typeof node.x !== 'number'
115
+ || typeof node.y !== 'number' || typeof node.width !== 'number' || typeof node.height !== 'number') return false
116
+ if (node.type !== 'image' && node.type !== 'text' && node.type !== 'config') return false
117
+ const metadata = node.metadata
118
+ if (metadata !== undefined && (metadata === null || typeof metadata !== 'object')) return false
119
+ const state = (metadata ?? {}) as Record<string, unknown>
120
+ if (node.type === 'image') {
121
+ if (state.asset !== undefined && !isAssetRef(state.asset)) return false
122
+ return state.status === undefined || state.status === 'idle' || state.status === 'generating' || state.status === 'success' || state.status === 'error'
123
+ }
124
+ if (node.type === 'config') {
125
+ return state.prompt === undefined || typeof state.prompt === 'string'
126
+ }
127
+ return state.text === undefined || typeof state.text === 'string'
128
+ }
129
+
130
+ function isDocument(value: unknown): value is CanvasDocument {
131
+ if (value === null || typeof value !== 'object') return false
132
+ const document = value as Record<string, unknown>
133
+ return document.version === 2 && typeof document.id === 'string' && typeof document.title === 'string'
134
+ && typeof document.revision === 'number' && document.viewport !== null && typeof document.viewport === 'object'
135
+ && typeof (document.viewport as { x?: unknown }).x === 'number'
136
+ && typeof (document.viewport as { y?: unknown }).y === 'number'
137
+ && typeof (document.viewport as { k?: unknown }).k === 'number'
138
+ && (document.background === 'dots' || document.background === 'lines' || document.background === 'diagonal'
139
+ || document.background === 'checker' || document.background === 'blank' || document.background === 'image')
140
+ && (document.backgroundImage === undefined || typeof document.backgroundImage === 'string')
141
+ && Array.isArray(document.nodes) && document.nodes.every(isNode)
142
+ && Array.isArray(document.connections)
143
+ }
144
+
145
+ /** Upgrade a v1 (image/text/annotation + edges) document to the v2 node-graph model.
146
+ * Images and text notes keep their geometry; annotation prompt cards become plain
147
+ * text notes carrying the prompt, and old edges survive only between surviving nodes. */
148
+ function migrateLegacyDocument(input: Record<string, unknown>): CanvasDocument {
149
+ const now = Date.now()
150
+ const legacyNodes = Array.isArray(input.nodes) ? input.nodes : []
151
+ const nodes: CanvasNode[] = []
152
+ const annotationIds = new Set<string>()
153
+ for (const raw of legacyNodes) {
154
+ if (raw === null || typeof raw !== 'object') continue
155
+ const node = raw as Record<string, unknown>
156
+ if (typeof node.id !== 'string' || typeof node.x !== 'number' || typeof node.y !== 'number') continue
157
+ const base = {
158
+ id: node.id,
159
+ title: typeof node.title === 'string' ? node.title : '未命名节点',
160
+ x: node.x,
161
+ y: node.y,
162
+ width: typeof node.width === 'number' ? node.width : 300,
163
+ height: typeof node.height === 'number' ? node.height : 220,
164
+ }
165
+ if (node.type === 'image' && isAssetRef(node.asset)) {
166
+ const generation = (node.generation ?? {}) as Record<string, unknown>
167
+ nodes.push({
168
+ ...base,
169
+ type: 'image',
170
+ metadata: {
171
+ asset: node.asset,
172
+ status: (typeof node.status === 'string' && ['idle', 'generating', 'success', 'error'].includes(node.status)
173
+ ? node.status
174
+ : 'success') as 'idle' | 'generating' | 'success' | 'error',
175
+ ...(typeof node.error === 'string' ? { error: node.error } : {}),
176
+ ...(typeof generation.prompt === 'string' ? { prompt: generation.prompt } : {}),
177
+ ...(typeof generation.model === 'string' ? { model: generation.model } : {}),
178
+ ...(typeof generation.taskId === 'string' ? { taskId: generation.taskId } : {}),
179
+ ...(typeof generation.sourceNodeId === 'string' ? { sourceNodeId: generation.sourceNodeId } : {}),
180
+ },
181
+ })
182
+ } else if (node.type === 'text') {
183
+ nodes.push({ ...base, type: 'text', metadata: { text: typeof node.text === 'string' ? node.text : '', ...(typeof node.fontSize === 'number' ? { fontSize: node.fontSize } : {}) } })
184
+ } else if (node.type === 'annotation') {
185
+ annotationIds.add(node.id)
186
+ const prompt = typeof node.prompt === 'string' && node.prompt.trim() !== '' ? node.prompt : '(旧版标注,提示词见此)'
187
+ nodes.push({ ...base, type: 'text', title: '旧版标注', metadata: { text: prompt } })
188
+ }
189
+ }
190
+ nodes.sort((a, b) => {
191
+ const za = (legacyNodes.find(item => (item as Record<string, unknown>)?.id === a.id) as Record<string, unknown> | undefined)?.zIndex
192
+ const zb = (legacyNodes.find(item => (item as Record<string, unknown>)?.id === b.id) as Record<string, unknown> | undefined)?.zIndex
193
+ return (typeof za === 'number' ? za : 0) - (typeof zb === 'number' ? zb : 0)
194
+ })
195
+ const validIds = new Set(nodes.map(node => node.id))
196
+ const seen = new Set<string>()
197
+ const connections = (Array.isArray(input.edges) ? input.edges : []).flatMap(raw => {
198
+ if (raw === null || typeof raw !== 'object') return []
199
+ const edge = raw as Record<string, unknown>
200
+ if (typeof edge.fromNodeId !== 'string' || typeof edge.toNodeId !== 'string') return []
201
+ if (annotationIds.has(edge.fromNodeId) || annotationIds.has(edge.toNodeId)) return []
202
+ if (!validIds.has(edge.fromNodeId) || !validIds.has(edge.toNodeId) || edge.fromNodeId === edge.toNodeId) return []
203
+ const key = `${edge.fromNodeId}->${edge.toNodeId}`
204
+ if (seen.has(key)) return []
205
+ seen.add(key)
206
+ return [{ id: typeof edge.id === 'string' ? edge.id : `edge-${randomUUID()}`, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId }]
207
+ })
208
+ const legacyViewport = (input.viewport ?? {}) as Record<string, unknown>
209
+ const background = input.background === 'grid' ? 'lines' : input.background === 'blank' ? 'blank' : 'dots'
210
+ return {
211
+ version: 2,
212
+ id: typeof input.id === 'string' ? input.id : randomUUID(),
213
+ title: typeof input.title === 'string' ? input.title : '未命名画布',
214
+ revision: typeof input.revision === 'number' ? input.revision : 1,
215
+ viewport: {
216
+ x: typeof legacyViewport.x === 'number' ? legacyViewport.x : 0,
217
+ y: typeof legacyViewport.y === 'number' ? legacyViewport.y : 0,
218
+ k: typeof legacyViewport.scale === 'number' && legacyViewport.scale > 0 ? legacyViewport.scale : 1,
219
+ },
220
+ background,
221
+ nodes,
222
+ connections,
223
+ createdAt: typeof input.createdAt === 'number' ? input.createdAt : now,
224
+ updatedAt: typeof input.updatedAt === 'number' ? input.updatedAt : now,
225
+ }
226
+ }
227
+
228
+ /** Accept either the v2 document or a legacy v1 payload and return v2. */
229
+ function coerceDocument(value: unknown): CanvasDocument | undefined {
230
+ if (value === null || typeof value !== 'object') return undefined
231
+ const document = value as Record<string, unknown>
232
+ if (document.version === 1) {
233
+ const migrated = migrateLegacyDocument(document)
234
+ return isDocument(migrated) ? migrated : undefined
235
+ }
236
+ return isDocument(value) ? value : undefined
237
+ }
238
+
239
+ let mutation: Promise<void> = Promise.resolve()
240
+ function serialize<T>(operation: () => Promise<T>): Promise<T> {
241
+ const next = mutation.then(operation, operation)
242
+ mutation = next.then(() => undefined, () => undefined)
243
+ return next
244
+ }
245
+
246
+ export class CanvasStore {
247
+ constructor(private readonly root?: string) {}
248
+
249
+ private pagesDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'pages') }
250
+ private assetsDir(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'assets') }
251
+ private indexPath(): string { return path.join(this.root ?? path.join(imageDataRoot(), 'canvas'), 'index.json') }
252
+
253
+ private async ensure(): Promise<void> {
254
+ await fs.mkdir(this.pagesDir(), { recursive: true })
255
+ await fs.mkdir(this.assetsDir(), { recursive: true })
256
+ }
257
+
258
+ private pagePath(id: string): string { return path.join(this.pagesDir(), `${safeId(id)}.json`) }
259
+ private assetPath(id: string): string | undefined {
260
+ if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
261
+ const target = path.join(this.assetsDir(), id)
262
+ const relative = path.relative(this.assetsDir(), target)
263
+ return relative.startsWith('..') || path.isAbsolute(relative) ? undefined : target
264
+ }
265
+
266
+ private async readIndex(): Promise<CanvasSummary[]> {
267
+ const value = await readJson(this.indexPath())
268
+ if (value === undefined || typeof value !== 'object' || !Array.isArray((value as { projects?: unknown }).projects)) return []
269
+ return (value as { projects: unknown[] }).projects.filter(item => {
270
+ if (item === null || typeof item !== 'object') return false
271
+ const project = item as Record<string, unknown>
272
+ return typeof project.id === 'string' && typeof project.title === 'string' && typeof project.revision === 'number'
273
+ && typeof project.nodeCount === 'number' && typeof project.createdAt === 'number' && typeof project.updatedAt === 'number'
274
+ }) as CanvasSummary[]
275
+ }
276
+
277
+ private async writeIndex(projects: CanvasSummary[]): Promise<void> {
278
+ await this.ensure()
279
+ await writeJsonAtomic(this.indexPath(), { projects })
280
+ }
281
+
282
+ async list(): Promise<CanvasSummary[]> {
283
+ return this.readIndex()
284
+ }
285
+
286
+ async create(title = '未命名画布'): Promise<CanvasDocument> {
287
+ return serialize(async () => {
288
+ await this.ensure()
289
+ const id = randomUUID()
290
+ const document = defaultDocument(id, title.trim() || '未命名画布')
291
+ await writeJsonAtomic(this.pagePath(id), document)
292
+ const projects = await this.readIndex()
293
+ await this.writeIndex([this.summaryOf(document), ...projects])
294
+ return document
295
+ })
296
+ }
297
+
298
+ async read(id: string): Promise<CanvasDocument | undefined> {
299
+ return coerceDocument(await readJson(this.pagePath(id)))
300
+ }
301
+
302
+ async save(document: CanvasDocument, expectedRevision?: number): Promise<CanvasDocument> {
303
+ return serialize(async () => {
304
+ const incoming = coerceDocument(document)
305
+ if (incoming === undefined) throw new Error('malformed canvas document')
306
+ const current = await this.read(incoming.id)
307
+ if (current !== undefined && expectedRevision !== undefined && current.revision !== expectedRevision) {
308
+ throw new CanvasConflictError()
309
+ }
310
+ const next: CanvasDocument = {
311
+ ...incoming,
312
+ revision: Math.max(current?.revision ?? 0, incoming.revision) + 1,
313
+ updatedAt: Date.now(),
314
+ }
315
+ await this.ensure()
316
+ await writeJsonAtomic(this.pagePath(next.id), next)
317
+ const projects = (await this.readIndex()).filter(item => item.id !== next.id)
318
+ await this.writeIndex([this.summaryOf(next), ...projects])
319
+ return next
320
+ })
321
+ }
322
+
323
+ async remove(id: string): Promise<CanvasSummary[]> {
324
+ return serialize(async () => {
325
+ try { await fs.rm(this.pagePath(id), { force: true }) } catch { /* best effort */ }
326
+ const projects = (await this.readIndex()).filter(item => item.id !== id)
327
+ await this.writeIndex(projects)
328
+ return projects
329
+ })
330
+ }
331
+
332
+ async putImage(input: CanvasImageInput): Promise<CanvasAssetRef> {
333
+ if (!input.data.byteLength) throw new Error('image data is empty')
334
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(input.mime)) throw new Error('unsupported image type')
335
+ if (!Number.isSafeInteger(input.width) || input.width < 1 || !Number.isSafeInteger(input.height) || input.height < 1) {
336
+ throw new Error('image dimensions are invalid')
337
+ }
338
+ await this.ensure()
339
+ const hash = createHash('sha256').update(input.data).digest('hex')
340
+ const file = `${hash}.${extensionOf(input.mime)}`
341
+ const target = path.join(this.assetsDir(), file)
342
+ try { await fs.access(target) } catch { await fs.writeFile(target, input.data) }
343
+ return {
344
+ assetId: file,
345
+ url: `/api/dsh-imagegen/canvas/asset/${file}`,
346
+ mime: input.mime,
347
+ bytes: input.data.byteLength,
348
+ width: input.width,
349
+ height: input.height,
350
+ origin: input.origin,
351
+ ...input.originId === undefined ? {} : { originId: input.originId },
352
+ ...input.entryId === undefined ? {} : { entryId: input.entryId },
353
+ ...input.imageIndex === undefined ? {} : { imageIndex: input.imageIndex },
354
+ }
355
+ }
356
+
357
+ async readAsset(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
358
+ const target = this.assetPath(file)
359
+ if (target === undefined) return undefined
360
+ try { return { data: await fs.readFile(target), mime: mimeOf(file) } } catch { return undefined }
361
+ }
362
+
363
+ private summaryOf(document: CanvasDocument): CanvasSummary {
364
+ return {
365
+ id: document.id,
366
+ title: document.title,
367
+ revision: document.revision,
368
+ nodeCount: document.nodes.length,
369
+ createdAt: document.createdAt,
370
+ updatedAt: document.updatedAt,
371
+ }
372
+ }
373
+ }
374
+
375
+ export const canvasStore = new CanvasStore()