dsh-drawai 0.3.0 → 0.3.2

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.
@@ -2,8 +2,10 @@
2
2
  /** Offline export: frame index, not wall-clock time, determines each timestamp. */
3
3
  import { spawn } from 'node:child_process'
4
4
  import { randomUUID } from 'node:crypto'
5
- import { existsSync, mkdirSync, mkdtempSync, rmSync, copyFileSync, appendFileSync, writeFileSync, renameSync, statSync, openSync, readSync, closeSync, realpathSync } from 'node:fs'
6
- import { join, relative, isAbsolute, extname, dirname } from 'node:path'
5
+ import { Worker } from 'node:worker_threads'
6
+ import { copyFile } from 'node:fs/promises'
7
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, renameSync, statSync, openSync, readSync, closeSync, realpathSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
8
+ import { join, relative, isAbsolute, extname, dirname, basename } from 'node:path'
7
9
  import { fileURLToPath } from 'node:url'
8
10
  import { clipDirFor, readManifest, findSourceFile, sanitizeClipId } from './media.js'
9
11
  import { createPerformanceLog } from './performance-log.js'
@@ -34,7 +36,7 @@ function runFfmpeg(binary, args, signal, progress) {
34
36
  })
35
37
  }
36
38
 
37
- function ffmpegBinary() {
39
+ export function ffmpegBinary() {
38
40
  if (process.env.DRAWAI_FFMPEG_PATH) return process.env.DRAWAI_FFMPEG_PATH
39
41
  // Development checkout only; published packages use the configured binary or PATH.
40
42
  const local = fileURLToPath(new URL('../_local/bin/' + (process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), import.meta.url))
@@ -45,13 +47,42 @@ export function createVideoExportManager(options = {}) {
45
47
  const jobs = new Map()
46
48
  let closed = false
47
49
  const binary = options.ffmpegPath || ffmpegBinary()
48
- const framePath = (job, index) => join(job.temp, 'frame-' + String(index).padStart(8, '0') + '.png')
50
+ const historyRoot = (path) => join(dirname(path), '.drawai-cache', 'video-export-history')
51
+ const ended = (job) => ['done', 'error', 'interrupted'].includes(job.status)
52
+ function persist(job) {
53
+ const root = historyRoot(job.drawioAbs); mkdirSync(root, { recursive: true })
54
+ const record = Object.fromEntries(['sessionId', 'drawioAbs', 'clip', 'token', 'status', 'time', 'next', 'encoded', 'format', 'kind', 'w', 'h', 'fps', 'count', 'bytes', 'savedPath', 'error'].map((key) => [key, job[key]]))
55
+ const file = join(root, job.token + '.json')
56
+ writeFileSync(file + '.tmp', JSON.stringify(record)); renameSync(file + '.tmp', file)
57
+ }
58
+ function load(scope) {
59
+ const root = historyRoot(scope.drawioAbs)
60
+ if (!existsSync(root)) return
61
+ for (const name of readdirSync(root)) if (/^[a-f0-9-]{36}\.json$/.test(name) && !jobs.has(name.slice(0, -5))) {
62
+ try {
63
+ const data = JSON.parse(readFileSync(join(root, name), 'utf8'))
64
+ if (data.token + '.json' !== name || data.sessionId !== scope.sessionId || data.drawioAbs !== scope.drawioAbs || !sanitizeClipId(data.clip) || !['mp4', 'webm', 'gif'].includes(data.format)) continue
65
+ const expected = join(dirname(scope.drawioAbs), basename(scope.drawioAbs).replace(/\.drawio$/i, '') + '-' + data.clip + '-' + data.token + '.' + data.format)
66
+ if (data.status === 'done' && (data.savedPath !== expected || !existsSync(expected) ||
67
+ isAbsolute(relative(realpathSync(dirname(scope.drawioAbs)), realpathSync(expected))) || relative(realpathSync(dirname(scope.drawioAbs)), realpathSync(expected)).startsWith('..'))) {
68
+ data.status = 'error'; data.error = '已导出的文件已移动或删除'; data.savedPath = null
69
+ }
70
+ if (!ended(data)) { data.status = 'interrupted'; data.error = '宿主曾停止,可选择重新导出;编码进度不能直接续传' }
71
+ const job = { ...data, output: data.savedPath, control: new AbortController(),
72
+ temp: join(dirname(scope.drawioAbs), '.drawai-cache', 'video-exports', 'restored-' + data.token),
73
+ perf: createPerformanceLog(dirname(scope.drawioAbs), 'video-export', {}, { enabled: false }) }
74
+ jobs.set(job.token, job); persist(job)
75
+ } catch { /* Ignore damaged history without hiding healthy tasks. */ }
76
+ }
77
+ }
49
78
  function view(job) {
50
79
  return { token: job.token, status: job.status, count: job.count, received: job.next, encoded: job.encoded,
51
- fps: job.fps, w: job.w, h: job.h, bytes: job.bytes || 0, error: job.error || '', format: job.format,
80
+ clip: job.clip, fps: job.fps, w: job.w, h: job.h, bytes: job.bytes || 0, error: job.error || '', format: job.format,
81
+ background: true, path: job.savedPath || null,
52
82
  performanceEnabled: job.perf.enabled, performanceId: job.perf.id }
53
83
  }
54
84
  function owned(scope, token) {
85
+ load(scope)
55
86
  const job = jobs.get(token)
56
87
  if (!job || job.sessionId !== scope.sessionId || job.drawioAbs !== scope.drawioAbs || job.clip !== scope.clip) throw new Error('视频导出任务已失效或不属于当前画布')
57
88
  job.time = Date.now()
@@ -64,24 +95,104 @@ export function createVideoExportManager(options = {}) {
64
95
  if (job.promise) await job.promise.catch(() => {})
65
96
  job.perf.finish(job.status === 'done' ? 'done' : job.status === 'error' ? 'failed' : 'cancelled')
66
97
  rmSync(job.temp, { recursive: true, force: true })
98
+ rmSync(join(historyRoot(job.drawioAbs), job.token + '.json'), { force: true })
99
+ }
100
+ function renderFrames(job) {
101
+ return new Promise((resolve, reject) => {
102
+ const worker = new Worker(options.workerUrl || new URL('./video-export-worker.js', import.meta.url), {
103
+ workerData: { dir: job.dir, frames: job.frames, temp: job.temp, w: job.w, h: job.h },
104
+ })
105
+ let settled = false
106
+ const abort = () => finish(new Error('已取消视频导出'))
107
+ const finish = (error) => {
108
+ if (settled) return
109
+ settled = true; job.control.signal.removeEventListener('abort', abort)
110
+ // Wait for native rendering to exit before removing its directory.
111
+ worker.terminate().then(() => error ? reject(error) : resolve(), reject)
112
+ }
113
+ worker.on('message', (message) => {
114
+ if (settled) return
115
+ if (message.type === 'timing') job.perf.add('hostSvgRender', message.ms)
116
+ if (message.type === 'progress' || message.type === 'done') {
117
+ job.next = message.rendered; job.tempBytes = (job.source ? statSync(job.source).size : 0) + message.bytes
118
+ job.perf.peak('tempInputBytes', job.tempBytes)
119
+ }
120
+ if (message.type === 'done') finish()
121
+ if (message.type === 'error') finish(new Error(message.error))
122
+ })
123
+ worker.on('error', finish)
124
+ worker.on('exit', (code) => { if (!settled) finish(new Error('导出渲染线程意外退出(' + code + ')')) })
125
+ job.control.signal.addEventListener('abort', abort, { once: true })
126
+ if (job.control.signal.aborted) abort()
127
+ })
128
+ }
129
+ async function runJob(job) {
130
+ let release
131
+ try {
132
+ release = await options.slots?.acquire(job.control.signal)
133
+ job.status = 'rendering'
134
+ persist(job)
135
+ if (job.kind === 'svg') {
136
+ await renderFrames(job)
137
+ job.perf.count('renderedFrames', job.next)
138
+ }
139
+ if (job.control.signal.aborted) throw new Error('已取消视频导出')
140
+ job.status = 'encoding'
141
+ persist(job)
142
+ await encode(job)
143
+ if (job.status === 'error') throw new Error(job.error)
144
+ if (job.control.signal.aborted) throw new Error('已取消视频导出')
145
+ // Persist independently of the browser's progress polling or download.
146
+ const name = basename(job.drawioAbs).replace(/\.drawio$/i, '') + '-' + job.clip + '-' + job.token + '.' + job.format
147
+ const path = join(dirname(job.drawioAbs), name)
148
+ renameSync(job.output, path)
149
+ job.output = path; job.savedPath = path; job.status = 'done'; job.time = Date.now()
150
+ persist(job)
151
+ job.perf.finish('done')
152
+ } catch (error) {
153
+ job.status = closed ? 'interrupted' : 'error'; job.error = String(error.message || error); job.time = Date.now()
154
+ persist(job)
155
+ job.perf.finish(job.control.signal.aborted ? 'cancelled' : 'failed')
156
+ } finally { release?.(); rmSync(job.temp, { recursive: true, force: true }) }
67
157
  }
68
158
  async function encode(job) {
159
+ const gifWhite = '[0:v:0]split[foreground][background];[background]lutrgb=r=255:g=255:b=255,format=rgb24[white];' +
160
+ '[white][foreground]overlay=shortest=1:format=rgb[opaque];'
69
161
  const args = ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y']
70
162
  if (job.kind === 'svg') args.push('-framerate', String(job.fps), '-start_number', '0', '-i', join(job.temp, 'frame-%08d.png'))
71
163
  else args.push('-protocol_whitelist', 'file,pipe', '-i', job.source)
72
164
  if (job.kind === 'svg' && job.source) args.push('-ss', String(job.offset), '-protocol_whitelist', 'file,pipe', '-i', job.source)
73
- args.push('-map', '0:v:0')
74
- if (job.source) args.push('-map', job.kind === 'svg' ? '1:a:0?' : '0:a:0?')
75
- // PNGs are painted over white by the client. Pad odd dimensions for yuv420p.
76
- args.push('-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0:color=white,format=yuv420p', '-fps_mode', 'cfr')
165
+ if (job.format === 'gif') {
166
+ args.push('-i', join(job.temp, 'palette.png'), '-filter_complex',
167
+ gifWhite + '[opaque][1:v:0]paletteuse=dither=sierra2_4a:diff_mode=rectangle[gif]',
168
+ '-map', '[gif]', '-an', '-fps_mode', 'cfr', '-r', String(job.fps))
169
+ } else {
170
+ args.push('-map', '0:v:0')
171
+ if (job.source) args.push('-map', job.kind === 'svg' ? '1:a:0?' : '0:a:0?')
172
+ // PNGs are painted over white by the client. Pad odd dimensions for yuv420p.
173
+ args.push('-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0:color=white,format=yuv420p', '-fps_mode', 'cfr')
174
+ }
77
175
  if (job.kind === 'svg') {
78
176
  args.push('-r', String(job.fps), '-t', String(job.count / job.fps))
79
177
  if (job.source) args.push('-af', 'apad')
80
178
  }
81
179
  if (job.format === 'mp4') args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart')
82
- else args.push('-c:v', 'libvpx-vp9', '-crf', '28', '-b:v', '0', '-row-mt', '1', '-c:a', 'libopus', '-b:a', '128k')
180
+ else if (job.format === 'webm') args.push('-c:v', 'libvpx-vp9', '-crf', '28', '-b:v', '0', '-row-mt', '1', '-c:a', 'libopus', '-b:a', '128k')
181
+ else args.push('-c:v', 'gif', '-loop', '0')
83
182
  args.push('-threads', '4', '-progress', 'pipe:1', job.output)
84
183
  try {
184
+ if (job.format === 'gif') {
185
+ // Two passes keep a single stable palette without buffering the full
186
+ // video in a split/framesync filter graph. Input frames already live on disk.
187
+ const paletteArgs = ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y']
188
+ if (job.kind === 'svg') paletteArgs.push('-framerate', String(job.fps), '-start_number', '0', '-i', join(job.temp, 'frame-%08d.png'))
189
+ else paletteArgs.push('-protocol_whitelist', 'file,pipe', '-i', job.source)
190
+ paletteArgs.push('-filter_complex', gifWhite + '[opaque]palettegen=max_colors=256:reserve_transparent=0:stats_mode=full[palette]', '-map', '[palette]',
191
+ '-frames:v', '1', '-threads', '1', '-update', '1', join(job.temp, 'palette.png'))
192
+ const paletteDone = job.perf.start('gifPaletteGenerate')
193
+ try { await runFfmpeg(binary, paletteArgs, job.control.signal) }
194
+ finally { paletteDone() }
195
+ }
85
196
  const encodeDone = job.perf.start('ffmpegEncode')
86
197
  try { await runFfmpeg(binary, args, job.control.signal, (frame) => { job.encoded = frame }) }
87
198
  finally { encodeDone() }
@@ -96,15 +207,20 @@ export function createVideoExportManager(options = {}) {
96
207
  return {
97
208
  async start(scope, format) {
98
209
  if (closed) throw new Error('视频导出服务已关闭')
99
- if (!sanitizeClipId(scope.clip) || !['mp4', 'webm'].includes(format)) throw new Error('无效视频导出参数')
100
- for (const job of jobs.values()) if (job.status !== 'encoding' && Date.now() - job.time > 30 * 60 * 1000) await remove(job)
101
- if (jobs.size >= 4) throw new Error('视频导出任务过多,请稍后重试')
210
+ load(scope)
211
+ if (!sanitizeClipId(scope.clip) || !['mp4', 'webm', 'gif'].includes(format)) throw new Error('无效视频导出参数')
212
+ for (const job of jobs.values()) if (['done', 'error'].includes(job.status) && Date.now() - job.time > 24 * 60 * 60 * 1000) await remove(job)
213
+ if ([...jobs.values()].filter((job) => !ended(job)).length >= 4) throw new Error('视频导出任务过多,请稍后重试')
214
+ const history = [...jobs.values()].filter(ended)
215
+ while (history.length >= 16) await remove(history.shift())
102
216
  const dir = clipDirFor(scope.drawioAbs, scope.clip), manifest = readManifest(dir)
103
217
  if (manifest.kind !== 'raster' && !manifest.frames.length) throw new Error('没有 SVG 帧可编码')
104
218
  if (manifest.width * manifest.height > 16000000 || Math.max(manifest.width, manifest.height) > 16384 || !(manifest.fps > 0)) throw new Error('视频尺寸或帧率无效')
105
- const original = findSourceFile(dir, manifest)
219
+ // GIF has no audio. SVG exports only need their rendered frames, even
220
+ // when the original audio source has since been removed.
221
+ const original = manifest.kind === 'raster' || format !== 'gif' ? findSourceFile(dir, manifest) : null
106
222
  if (manifest.kind === 'raster' && !original) throw new Error('源视频缺失')
107
- if (manifest.source && !original) throw new Error('原片段音频来源缺失,无法保留音频')
223
+ if (format !== 'gif' && manifest.source && !original) throw new Error('原片段音频来源缺失,无法保留音频')
108
224
  if (original) {
109
225
  const rel = relative(realpathSync(dir), realpathSync(original.abs))
110
226
  if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('音频或视频来源不在媒体包内')
@@ -120,66 +236,52 @@ export function createVideoExportManager(options = {}) {
120
236
  try {
121
237
  const sourceDone = perf.start('sourceSnapshot')
122
238
  const source = original ? join(temp, 'source' + extname(original.abs)) : null
123
- if (source) copyFileSync(original.abs, source)
239
+ if (source) await copyFile(original.abs, source)
240
+ let inputDir = dir, frames = manifest.frames
241
+ if (manifest.kind === 'svg') {
242
+ inputDir = join(temp, 'input'); mkdirSync(inputDir)
243
+ frames = []
244
+ for (let index = 0; index < manifest.frames.length; index++) {
245
+ const frame = manifest.frames[index], file = join(dir, frame.file)
246
+ const rel = relative(realpathSync(dir), realpathSync(file))
247
+ if (isAbsolute(rel) || rel.startsWith('..')) throw new Error('SVG 帧路径超出媒体包')
248
+ const name = index + '.svg'; await copyFile(file, join(inputDir, name))
249
+ frames.push({ ...frame, file: name })
250
+ }
251
+ }
124
252
  sourceDone()
125
- job = { ...scope, perf, token: randomUUID(), temp, source, format, kind: manifest.kind, fps: manifest.fps,
253
+ if (closed) throw new Error('视频导出服务已关闭')
254
+ job = { ...scope, dir: inputDir, frames, perf, token: randomUUID(), temp, source, format, kind: manifest.kind, fps: manifest.fps,
126
255
  w: manifest.width, h: manifest.height, count: manifest.kind === 'raster' ? 0 : manifest.frames.length, offset: manifest.sourceOffset || 0,
127
- next: 0, received: 0, encoded: 0, status: 'rendering', time: Date.now(), control: new AbortController(), output: join(temp, 'output.' + format) }
256
+ next: 0, received: 0, encoded: 0, status: 'queued', time: Date.now(), control: new AbortController(), output: join(temp, 'output.' + format) }
128
257
  jobs.set(job.token, job)
258
+ persist(job)
129
259
  // Probe the actual output path and encoder before uploading any large frames.
130
260
  job.preflight = runFfmpeg(binary, ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'color=white:s=2x2:r=1',
131
- '-frames:v', '1', '-c:v', format === 'mp4' ? 'libx264' : 'libvpx-vp9', '-threads', '1', job.output], job.control.signal)
261
+ '-frames:v', '1', '-c:v', format === 'mp4' ? 'libx264' : format === 'gif' ? 'gif' : 'libvpx-vp9', '-threads', '1', job.output], job.control.signal)
132
262
  const probeDone = perf.start('encoderPreflight')
133
263
  try { await job.preflight }
134
264
  catch (error) { throw new Error('导出预检查失败(目录:' + temp + '):' + error.message) }
135
265
  finally { probeDone() }
136
266
  if (closed || job.control.signal.aborted) throw new Error('视频导出服务已关闭')
267
+ job.promise = runJob(job)
137
268
  return view(job)
138
269
  } catch (error) {
139
270
  perf.finish('failed')
140
271
  if (job) jobs.delete(job.token)
272
+ if (job) rmSync(join(historyRoot(job.drawioAbs), job.token + '.json'), { force: true })
141
273
  rmSync(temp, { recursive: true, force: true })
142
274
  throw error
143
275
  }
144
276
  },
145
- chunk(scope, body) {
146
- const job = owned(scope, body.token)
147
- if (job.kind !== 'svg' || job.status !== 'rendering' || body.index !== job.next || job.next >= job.count || body.offset !== job.received) throw new Error('导出帧分块位置无效')
148
- const chunkDone = job.perf.start('base64DecodeAndDiskWrite')
149
- const bytes = Buffer.from(typeof body.base64 === 'string' ? body.base64 : '', 'base64')
150
- if (!bytes.length || bytes.length > 1024 * 1024 || job.received + bytes.length > 68 * 1024 * 1024) throw new Error('导出帧分块大小无效')
151
- const part = join(job.temp, 'frame.upload')
152
- if (!job.received) writeFileSync(part, bytes)
153
- else appendFileSync(part, bytes)
154
- job.received += bytes.length
155
- chunkDone(); job.perf.count('uploadedPngBytes', bytes.length)
156
- // Monotonic footprint: input PNGs + source snapshot + encoded output.
157
- job.tempBytes = (job.tempBytes || (job.source ? statSync(job.source).size : 0)) + bytes.length
158
- job.perf.peak('tempInputBytes', job.tempBytes)
159
- return { ok: true, received: job.received }
160
- },
161
- frame(scope, body) {
162
- const job = owned(scope, body.token)
163
- if (job.status !== 'rendering' || body.index !== job.next || job.next >= job.count || !job.received) throw new Error('导出帧序号不连续或数据缺失')
164
- const part = join(job.temp, 'frame.upload'), head = Buffer.alloc(24), fd = openSync(part, 'r')
165
- const frameDone = job.perf.start('frameValidateAndCommit')
166
- try { readSync(fd, head, 0, head.length, 0) } finally { closeSync(fd) }
167
- if (head.length !== 24 || head.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || head.toString('ascii', 12, 16) !== 'IHDR' || head.readUInt32BE(16) !== job.w || head.readUInt32BE(20) !== job.h) throw new Error('导出帧不是符合媒体尺寸的 PNG')
168
- renameSync(part, framePath(job, job.next))
169
- job.next++; job.received = 0
170
- frameDone(); job.perf.count('receivedFrames')
171
- return { ok: true, received: job.next }
172
- },
173
- finish(scope, token) {
174
- const job = owned(scope, token)
175
- if (job.kind === 'svg' && (job.next !== job.count || job.received)) throw new Error('导出帧尚未全部提交')
176
- if (!job.promise) { job.status = 'encoding'; job.promise = encode(job) }
177
- return view(job)
178
- },
277
+ // Compatibility endpoint: the task already runs after start, without a client finish call.
278
+ finish(scope, token) { return view(owned(scope, token)) },
279
+ list(scope) { load(scope); return [...jobs.values()].filter((job) => job.sessionId === scope.sessionId && job.drawioAbs === scope.drawioAbs).map(view) },
179
280
  status(scope, token) { return view(owned(scope, token)) },
180
281
  read(scope, token, offset) {
181
282
  const job = owned(scope, token)
182
283
  if (job.status !== 'done' || !Number.isSafeInteger(offset) || offset < 0 || offset >= job.bytes) throw new Error('导出文件未完成或读取位置无效')
284
+ if (statSync(job.output).size !== job.bytes) throw new Error('导出文件已被修改,请重新导出')
183
285
  const bytes = Buffer.alloc(Math.min(1024 * 1024, job.bytes - offset)), fd = openSync(job.output, 'r')
184
286
  const readDone = job.perf.start('outputReadAndBase64Encode')
185
287
  try { readSync(fd, bytes, 0, bytes.length, offset) } finally { closeSync(fd) }
@@ -188,6 +290,15 @@ export function createVideoExportManager(options = {}) {
188
290
  return { ok: true, offset, bytes: bytes.length, base64, total: job.bytes }
189
291
  },
190
292
  async discard(scope, token, performance) { const job = owned(scope, token); job.perf.merge('browser.', performance); await remove(job); return { ok: true } },
191
- async dispose() { closed = true; await Promise.all(Array.from(jobs.values(), remove)) },
293
+ leases(drawioAbs) { return [...jobs.values()].filter((job) => job.drawioAbs === drawioAbs && !['done', 'error'].includes(job.status)).map((job) => job.clip) },
294
+ async dispose() {
295
+ closed = true
296
+ for (const job of jobs.values()) job.control.abort()
297
+ await Promise.allSettled([...jobs.values()].flatMap((job) => [job.preflight, job.promise]))
298
+ for (const job of jobs.values()) {
299
+ if (!ended(job)) { job.status = 'interrupted'; persist(job) }
300
+ rmSync(job.temp, { recursive: true, force: true })
301
+ }
302
+ },
192
303
  }
193
304
  }
@@ -43,7 +43,7 @@ export function createVideoTracePool(options = {}) {
43
43
  if (typeof worker.ref === 'function') worker.ref()
44
44
  busy.set(worker, { id, item, dispatched, queueWaitMs: dispatched - item.queuedAt })
45
45
  try {
46
- worker.postMessage({ id, png: item.png, options: item.options })
46
+ worker.postMessage({ id, png: item.png, raster: item.raster, options: item.options }, item.transfer || [])
47
47
  } catch (error) {
48
48
  busy.delete(worker)
49
49
  if (typeof worker.unref === 'function') worker.unref()
@@ -115,5 +115,15 @@ export function createVideoTracePool(options = {}) {
115
115
  idle.length = 0
116
116
  }
117
117
 
118
- return { run, cancelToken, close, get size() { return size } }
118
+ function runRaster(raster, traceOptions, meta = {}) {
119
+ if (closed) return Promise.reject(new Error('复刻线程池已关闭'))
120
+ // Copy into an owned allocation; never transfer a Node Buffer pool slab.
121
+ const pixels = new Uint8Array(raster.data.length); pixels.set(raster.data)
122
+ return new Promise((resolve, reject) => {
123
+ queue.push({ raster: { width: raster.width, height: raster.height, data: pixels }, transfer: [pixels.buffer], options: traceOptions,
124
+ resolve, reject, token: meta.token, queuedAt: performance.now() })
125
+ pump()
126
+ })
127
+ }
128
+ return { run, runRaster, cancelToken, close, get size() { return size } }
119
129
  }
@@ -2,16 +2,18 @@
2
2
  // 单帧复刻在独立线程计算;支持常驻消息循环(池)与一次性 workerData(兼容)。
3
3
  import { parentPort, workerData } from 'node:worker_threads'
4
4
  import { decodeRaster } from './raster.js'
5
- import { traceRasterForToolbar } from './trace-image.js'
5
+ import { traceRasterForToolbar, traceRasterToSvg } from './trace-image.js'
6
6
  import { createPerformanceMetrics } from './performance-metrics.js'
7
7
  import { checkVideoPalette } from './video-trace-reuse.js'
8
8
 
9
- async function tracePng(png, rawOptions) {
9
+ async function tracePng(png, rawOptions, rawRaster) {
10
10
  const timing = rawOptions.profile === true ? createPerformanceMetrics() : null
11
11
  const decodeDone = timing?.start('pngDecode')
12
- const raster = await decodeRaster(Buffer.from(png))
12
+ const raster = rawRaster || await decodeRaster(Buffer.from(png))
13
13
  decodeDone?.()
14
14
  if (raster.width * raster.height > 16000000) throw new Error('图片超过 1600 万像素,请先缩小图片')
15
+ if (raster.data.length !== raster.width * raster.height * 4) throw new Error('原始像素长度无效')
16
+ if (rawOptions.mode === 'raw-image') return { ok: true, ...traceRasterToSvg(raster, rawOptions) }
15
17
  if (rawOptions.mode === 'image') {
16
18
  const result = traceRasterForToolbar(raster, { ...rawOptions, includeDrawing: true,
17
19
  onTiming: timing ? (name, ms) => timing.add(name, ms) : undefined })
@@ -43,7 +45,7 @@ async function tracePng(png, rawOptions) {
43
45
 
44
46
  async function handle(message) {
45
47
  try {
46
- const result = await tracePng(message.png, message.options)
48
+ const result = await tracePng(message.png, message.options, message.raster)
47
49
  parentPort.postMessage(Object.assign({ id: message.id }, result))
48
50
  } catch (error) {
49
51
  parentPort.postMessage({ id: message.id, ok: false, error: error.message || String(error) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",
@@ -14,7 +14,7 @@
14
14
  "build": "node tools/build.mjs",
15
15
  "watch": "node tools/watch.mjs",
16
16
  "check": "node tools/check-package.mjs",
17
- "test": "node tools/check-adaptive-palette.mjs && node tools/check-auto-save.mjs && node tools/check-drawing-queue.mjs && node tools/check-trace-optimization.mjs && node tools/check-performance.mjs && node tools/check-mxfile.mjs && node tools/check-route-preview.mjs && node tools/check-host.mjs && node tools/check-render.mjs && node tools/check-component.mjs && node tools/check-text-copy.mjs && node tools/check-image-export.mjs && node tools/check-canvas-sessions.mjs && node tools/check-trace.mjs && node tools/check-media.mjs && node tools/check-video-player.mjs && node tools/check-image-trace.mjs && node tools/check-video-trace.mjs && node tools/check-video-reuse.mjs && node tools/check-video-audio.mjs && node tools/check-video-export.mjs && node tools/check-video-import.mjs && node tools/check-node-labels.mjs",
17
+ "test": "node tools/check-adaptive-palette.mjs && node tools/check-auto-save.mjs && node tools/check-drawing-queue.mjs && node tools/check-background-tasks.mjs && node tools/check-background-ui.mjs && node tools/check-trace-optimization.mjs && node tools/check-performance.mjs && node tools/check-mxfile.mjs && node tools/check-route-preview.mjs && node tools/check-host.mjs && node tools/check-render.mjs && node tools/check-component.mjs && node tools/check-text-copy.mjs && node tools/check-image-export.mjs && node tools/check-canvas-sessions.mjs && node tools/check-trace.mjs && node tools/check-media.mjs && node tools/check-video-player.mjs && node tools/check-image-trace.mjs && node tools/check-video-trace.mjs && node tools/check-video-reuse.mjs && node tools/check-video-audio.mjs && node tools/check-video-export.mjs && node tools/check-video-import.mjs && node tools/check-node-labels.mjs",
18
18
  "bench:performance": "node tools/benchmark-performance.mjs"
19
19
  },
20
20
  "exports": {
@@ -39,6 +39,9 @@
39
39
  "engines": {
40
40
  "node": ">=20"
41
41
  },
42
+ "dependencies": {
43
+ "@resvg/resvg-js": "^2.6.2"
44
+ },
42
45
  "peerDependencies": {
43
46
  "@deepseek-ai/dsh-tools": ">=0.1.0-rc.1 <0.1.0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
44
47
  },