dsh-drawai 0.2.7 → 0.3.1

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.
@@ -0,0 +1,58 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ import { parentPort, workerData } from 'node:worker_threads'
3
+ import { readFileSync, writeFileSync, realpathSync, statSync } from 'node:fs'
4
+ import { resolve, relative, isAbsolute, join } from 'node:path'
5
+ import { Resvg } from '@resvg/resvg-js'
6
+ import { MAX_FRAME_SVG_CHARS } from './media.js'
7
+
8
+ // Rendering must not acquire filesystem/network resources referenced by SVGs.
9
+ export function validateExportSvg(svg) {
10
+ if (svg.length > MAX_FRAME_SVG_CHARS || !/<svg\b/i.test(svg)) throw new Error('导出帧不是有效尺寸的 SVG')
11
+ if (/<!DOCTYPE|<!ENTITY|<\?xml-stylesheet|@import|<foreignObject\b/i.test(svg)) throw new Error('导出帧包含不支持的外部资源或 foreignObject')
12
+ for (const match of svg.matchAll(/(?:\b(?:xlink:)?href)\s*=\s*(["'])(.*?)\1/gis)) {
13
+ if (!/^(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(match[2].trim())) throw new Error('导出帧不能引用外部图片或文件')
14
+ }
15
+ for (const match of svg.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gis)) {
16
+ if (!match[2].trim().startsWith('#')) throw new Error('导出帧不能引用外部 CSS 资源')
17
+ }
18
+ }
19
+
20
+ export function renderExportFrame(svg, width, height) {
21
+ validateExportSvg(svg)
22
+ // Parse the actual document first: an invalid nested SVG image would
23
+ // otherwise be silently omitted by the outer viewport wrapper.
24
+ const normalized = new Resvg(svg, { font: { loadSystemFonts: /<text\b/i.test(svg) } }).toString()
25
+ // Match drawImage(..., w, h): stretch the intrinsic SVG viewport to the
26
+ // manifest dimensions, retaining its own viewBox/clip behavior inside it.
27
+ const wrapper = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">' +
28
+ '<image width="100%" height="100%" preserveAspectRatio="none" href="data:image/svg+xml;base64,' +
29
+ Buffer.from(normalized).toString('base64') + '"/></svg>'
30
+ const image = new Resvg(wrapper, { background: 'white', font: { loadSystemFonts: false } }).render()
31
+ if (image.width !== width || image.height !== height) throw new Error('宿主渲染尺寸与视频不一致')
32
+ return image.asPng()
33
+ }
34
+
35
+ if (parentPort) {
36
+ try {
37
+ const root = realpathSync(workerData.dir)
38
+ let bytes = 0, last = 0
39
+ for (let index = 0; index < workerData.frames.length; index++) {
40
+ const file = workerData.frames[index].file
41
+ if (typeof file !== 'string' || isAbsolute(file)) throw new Error('导出帧路径无效')
42
+ const absolute = realpathSync(resolve(root, file))
43
+ const rel = relative(root, absolute)
44
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) throw new Error('导出帧路径超出媒体包')
45
+ if (statSync(absolute).size > MAX_FRAME_SVG_CHARS * 4) throw new Error('导出帧 SVG 过大')
46
+ const begin = performance.now()
47
+ const png = renderExportFrame(readFileSync(absolute, 'utf8'), workerData.w, workerData.h)
48
+ writeFileSync(join(workerData.temp, 'frame-' + String(index).padStart(8, '0') + '.png'), png)
49
+ bytes += png.length
50
+ parentPort.postMessage({ type: 'timing', ms: performance.now() - begin })
51
+ if (Date.now() - last > 100 || index + 1 === workerData.frames.length) {
52
+ parentPort.postMessage({ type: 'progress', rendered: index + 1, bytes }); last = Date.now()
53
+ }
54
+ }
55
+ parentPort.postMessage({ type: 'done', rendered: workerData.frames.length, bytes })
56
+ } catch (error) { parentPort.postMessage({ type: 'error', error: String(error.message || error) }) }
57
+ parentPort.close()
58
+ }
@@ -0,0 +1,304 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ /** Offline export: frame index, not wall-clock time, determines each timestamp. */
3
+ import { spawn } from 'node:child_process'
4
+ import { randomUUID } from 'node:crypto'
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'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { clipDirFor, readManifest, findSourceFile, sanitizeClipId } from './media.js'
11
+ import { createPerformanceLog } from './performance-log.js'
12
+
13
+ function runFfmpeg(binary, args, signal, progress) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(binary, args, { windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
16
+ let errorText = '', line = ''
17
+ const abort = () => child.kill()
18
+ signal.addEventListener('abort', abort, { once: true })
19
+ if (signal.aborted) abort()
20
+ child.stdout.on('data', (bytes) => {
21
+ line += bytes.toString()
22
+ const lines = line.split(/\r?\n/); line = lines.pop()
23
+ for (const entry of lines) if (entry.startsWith('frame=')) progress?.(Number(entry.slice(6)))
24
+ })
25
+ child.stderr.on('data', (bytes) => { errorText = (errorText + bytes.toString()).slice(-8000) })
26
+ child.on('error', (error) => {
27
+ signal.removeEventListener('abort', abort)
28
+ reject(new Error(error.code === 'ENOENT' ? '未找到 FFmpeg,请安装到 PATH,或设置 DRAWAI_FFMPEG_PATH 为可执行文件路径' : error.message))
29
+ })
30
+ child.on('close', (code) => {
31
+ signal.removeEventListener('abort', abort)
32
+ if (signal.aborted) reject(new Error('已取消视频导出'))
33
+ else if (code !== 0) reject(new Error('FFmpeg 编码失败:' + errorText.trim()))
34
+ else resolve()
35
+ })
36
+ })
37
+ }
38
+
39
+ export function ffmpegBinary() {
40
+ if (process.env.DRAWAI_FFMPEG_PATH) return process.env.DRAWAI_FFMPEG_PATH
41
+ // Development checkout only; published packages use the configured binary or PATH.
42
+ const local = fileURLToPath(new URL('../_local/bin/' + (process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), import.meta.url))
43
+ return existsSync(local) ? local : 'ffmpeg'
44
+ }
45
+
46
+ export function createVideoExportManager(options = {}) {
47
+ const jobs = new Map()
48
+ let closed = false
49
+ const binary = options.ffmpegPath || ffmpegBinary()
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
+ }
78
+ function view(job) {
79
+ return { token: job.token, status: job.status, count: job.count, received: job.next, encoded: job.encoded,
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,
82
+ performanceEnabled: job.perf.enabled, performanceId: job.perf.id }
83
+ }
84
+ function owned(scope, token) {
85
+ load(scope)
86
+ const job = jobs.get(token)
87
+ if (!job || job.sessionId !== scope.sessionId || job.drawioAbs !== scope.drawioAbs || job.clip !== scope.clip) throw new Error('视频导出任务已失效或不属于当前画布')
88
+ job.time = Date.now()
89
+ return job
90
+ }
91
+ async function remove(job) {
92
+ jobs.delete(job.token)
93
+ job.control.abort()
94
+ if (job.preflight) await job.preflight.catch(() => {})
95
+ if (job.promise) await job.promise.catch(() => {})
96
+ job.perf.finish(job.status === 'done' ? 'done' : job.status === 'error' ? 'failed' : 'cancelled')
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 }) }
157
+ }
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];'
161
+ const args = ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y']
162
+ if (job.kind === 'svg') args.push('-framerate', String(job.fps), '-start_number', '0', '-i', join(job.temp, 'frame-%08d.png'))
163
+ else args.push('-protocol_whitelist', 'file,pipe', '-i', job.source)
164
+ if (job.kind === 'svg' && job.source) args.push('-ss', String(job.offset), '-protocol_whitelist', 'file,pipe', '-i', job.source)
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
+ }
175
+ if (job.kind === 'svg') {
176
+ args.push('-r', String(job.fps), '-t', String(job.count / job.fps))
177
+ if (job.source) args.push('-af', 'apad')
178
+ }
179
+ if (job.format === 'mp4') args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart')
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')
182
+ args.push('-threads', '4', '-progress', 'pipe:1', job.output)
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
+ }
196
+ const encodeDone = job.perf.start('ffmpegEncode')
197
+ try { await runFfmpeg(binary, args, job.control.signal, (frame) => { job.encoded = frame }) }
198
+ finally { encodeDone() }
199
+ if (job.kind === 'svg' && job.encoded !== job.count) throw new Error('输出帧数不完整:预期 ' + job.count + ',实际 ' + job.encoded)
200
+ job.bytes = statSync(job.output).size
201
+ if (!job.bytes) throw new Error('视频编码结果为空')
202
+ job.status = 'done'
203
+ job.perf.peak('tempTotalBytes', (job.tempBytes || (job.source ? statSync(job.source).size : 0)) + job.bytes);
204
+ job.perf.count('outputBytes', job.bytes); job.perf.count('encodedFrames', job.encoded)
205
+ } catch (error) { job.status = 'error'; job.error = error.message || String(error) }
206
+ }
207
+ return {
208
+ async start(scope, format) {
209
+ if (closed) 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())
216
+ const dir = clipDirFor(scope.drawioAbs, scope.clip), manifest = readManifest(dir)
217
+ if (manifest.kind !== 'raster' && !manifest.frames.length) throw new Error('没有 SVG 帧可编码')
218
+ if (manifest.width * manifest.height > 16000000 || Math.max(manifest.width, manifest.height) > 16384 || !(manifest.fps > 0)) throw new Error('视频尺寸或帧率无效')
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
222
+ if (manifest.kind === 'raster' && !original) throw new Error('源视频缺失')
223
+ if (format !== 'gif' && manifest.source && !original) throw new Error('原片段音频来源缺失,无法保留音频')
224
+ if (original) {
225
+ const rel = relative(realpathSync(dir), realpathSync(original.abs))
226
+ if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('音频或视频来源不在媒体包内')
227
+ }
228
+ // Child processes may lack write access to the OS temp directory on Windows.
229
+ // Keep transient files beside the writable canvas, outside its media package.
230
+ const cache = options.tempRoot || join(dirname(scope.drawioAbs), '.drawai-cache', 'video-exports')
231
+ mkdirSync(cache, { recursive: true })
232
+ const temp = mkdtempSync(join(cache, 'export-'))
233
+ const perf = createPerformanceLog(dirname(scope.drawioAbs), 'video-export', { ...options.metadata, format, kind: manifest.kind, fps: manifest.fps,
234
+ width: manifest.width, height: manifest.height, count: manifest.frames.length, encoderThreads: 4 }, { enabled: options.profile })
235
+ let job
236
+ try {
237
+ const sourceDone = perf.start('sourceSnapshot')
238
+ const source = original ? join(temp, 'source' + extname(original.abs)) : null
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
+ }
252
+ sourceDone()
253
+ if (closed) throw new Error('视频导出服务已关闭')
254
+ job = { ...scope, dir: inputDir, frames, perf, token: randomUUID(), temp, source, format, kind: manifest.kind, fps: manifest.fps,
255
+ w: manifest.width, h: manifest.height, count: manifest.kind === 'raster' ? 0 : manifest.frames.length, offset: manifest.sourceOffset || 0,
256
+ next: 0, received: 0, encoded: 0, status: 'queued', time: Date.now(), control: new AbortController(), output: join(temp, 'output.' + format) }
257
+ jobs.set(job.token, job)
258
+ persist(job)
259
+ // Probe the actual output path and encoder before uploading any large frames.
260
+ job.preflight = runFfmpeg(binary, ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'color=white:s=2x2:r=1',
261
+ '-frames:v', '1', '-c:v', format === 'mp4' ? 'libx264' : format === 'gif' ? 'gif' : 'libvpx-vp9', '-threads', '1', job.output], job.control.signal)
262
+ const probeDone = perf.start('encoderPreflight')
263
+ try { await job.preflight }
264
+ catch (error) { throw new Error('导出预检查失败(目录:' + temp + '):' + error.message) }
265
+ finally { probeDone() }
266
+ if (closed || job.control.signal.aborted) throw new Error('视频导出服务已关闭')
267
+ job.promise = runJob(job)
268
+ return view(job)
269
+ } catch (error) {
270
+ perf.finish('failed')
271
+ if (job) jobs.delete(job.token)
272
+ if (job) rmSync(join(historyRoot(job.drawioAbs), job.token + '.json'), { force: true })
273
+ rmSync(temp, { recursive: true, force: true })
274
+ throw error
275
+ }
276
+ },
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) },
280
+ status(scope, token) { return view(owned(scope, token)) },
281
+ read(scope, token, offset) {
282
+ const job = owned(scope, token)
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('导出文件已被修改,请重新导出')
285
+ const bytes = Buffer.alloc(Math.min(1024 * 1024, job.bytes - offset)), fd = openSync(job.output, 'r')
286
+ const readDone = job.perf.start('outputReadAndBase64Encode')
287
+ try { readSync(fd, bytes, 0, bytes.length, offset) } finally { closeSync(fd) }
288
+ const base64 = bytes.toString('base64')
289
+ readDone(); job.perf.count('downloadedBytes', bytes.length)
290
+ return { ok: true, offset, bytes: bytes.length, base64, total: job.bytes }
291
+ },
292
+ async discard(scope, token, performance) { const job = owned(scope, token); job.perf.merge('browser.', performance); await remove(job); return { ok: true } },
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
+ },
303
+ }
304
+ }
@@ -0,0 +1,129 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ /**
3
+ * 视频复刻常驻 Worker 池:复用线程,按任务令牌取消排队/在算任务。
4
+ */
5
+ import { Worker } from 'node:worker_threads'
6
+ import { cpus } from 'node:os'
7
+
8
+ export function createVideoTracePool(options = {}) {
9
+ const size = options.size ?? Math.max(1, Math.min(4, Math.max(1, (cpus()?.length || 2) - 1)))
10
+ const workerUrl = options.workerUrl ?? new URL('./video-trace-worker.js', import.meta.url)
11
+ let nextId = 1
12
+ let closed = false
13
+ const idle = []
14
+ const queue = []
15
+ const busy = new Map()
16
+
17
+ function spawn() {
18
+ const worker = new Worker(workerUrl)
19
+ worker.on('message', (message) => finish(worker, message))
20
+ worker.on('error', (error) => fail(worker, error))
21
+ worker.on('exit', (code) => {
22
+ if (busy.has(worker)) fail(worker, new Error('复刻线程已停止(' + code + ')'))
23
+ const at = idle.indexOf(worker)
24
+ if (at >= 0) idle.splice(at, 1)
25
+ })
26
+ // 空闲时不拖住进程退出(测试与短命脚本);接任务时再 ref。
27
+ if (typeof worker.unref === 'function') worker.unref()
28
+ return worker
29
+ }
30
+
31
+ function ensure() {
32
+ while (!closed && idle.length + busy.size < size) idle.push(spawn())
33
+ }
34
+
35
+ function pump() {
36
+ if (closed) return
37
+ ensure()
38
+ while (idle.length && queue.length) {
39
+ const worker = idle.pop()
40
+ const item = queue.shift()
41
+ const id = nextId++
42
+ const dispatched = performance.now()
43
+ if (typeof worker.ref === 'function') worker.ref()
44
+ busy.set(worker, { id, item, dispatched, queueWaitMs: dispatched - item.queuedAt })
45
+ try {
46
+ worker.postMessage({ id, png: item.png, raster: item.raster, options: item.options }, item.transfer || [])
47
+ } catch (error) {
48
+ busy.delete(worker)
49
+ if (typeof worker.unref === 'function') worker.unref()
50
+ idle.push(worker)
51
+ item.reject(error)
52
+ }
53
+ }
54
+ }
55
+
56
+ function finish(worker, message) {
57
+ const active = busy.get(worker)
58
+ if (!active) return
59
+ if (message && message.id !== active.id) return
60
+ busy.delete(worker)
61
+ if (!closed) {
62
+ if (typeof worker.unref === 'function') worker.unref()
63
+ idle.push(worker)
64
+ } else worker.terminate().catch(() => {})
65
+ if (!message || message.ok !== true) active.item.reject(new Error((message && message.error) || '复刻失败'))
66
+ else active.item.resolve({ ...message, queueWaitMs: active.queueWaitMs, workerRoundTripMs: performance.now() - active.dispatched })
67
+ pump()
68
+ }
69
+
70
+ function fail(worker, error) {
71
+ const active = busy.get(worker)
72
+ busy.delete(worker)
73
+ if (active) active.item.reject(error instanceof Error ? error : new Error(String(error)))
74
+ worker.terminate().catch(() => {})
75
+ if (!closed) pump()
76
+ }
77
+
78
+ function run(png, traceOptions, meta = {}) {
79
+ if (closed) return Promise.reject(new Error('复刻线程池已关闭'))
80
+ return new Promise((resolve, reject) => {
81
+ queue.push({ png, options: traceOptions, resolve, reject, token: meta.token, queuedAt: performance.now() })
82
+ pump()
83
+ })
84
+ }
85
+
86
+ function cancelToken(token) {
87
+ if (token === undefined || token === null) return
88
+ const kept = []
89
+ for (const item of queue) {
90
+ if (item.token === token) item.reject(new Error('已取消复刻'))
91
+ else kept.push(item)
92
+ }
93
+ queue.length = 0
94
+ for (const item of kept) queue.push(item)
95
+ for (const [worker, active] of Array.from(busy.entries())) {
96
+ if (active.item.token !== token) continue
97
+ busy.delete(worker)
98
+ active.item.reject(new Error('已取消复刻'))
99
+ worker.terminate().catch(() => {})
100
+ }
101
+ if (!closed) pump()
102
+ }
103
+
104
+ function close() {
105
+ if (closed) return
106
+ closed = true
107
+ for (const item of queue) item.reject(new Error('复刻线程池已关闭'))
108
+ queue.length = 0
109
+ for (const [worker, active] of busy) {
110
+ active.item.reject(new Error('复刻线程池已关闭'))
111
+ worker.terminate().catch(() => {})
112
+ }
113
+ busy.clear()
114
+ for (const worker of idle) worker.terminate().catch(() => {})
115
+ idle.length = 0
116
+ }
117
+
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 } }
129
+ }
@@ -0,0 +1,49 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ import { createHash } from 'node:crypto'
3
+ import { TRACE_DEFAULTS, TRACE_FRAGMENT_LEVELS, TRACE_PRESET_NAMES } from './trace-image.js'
4
+
5
+ // Normalize/validate before an exact-cache lookup: a hit must not bypass validation.
6
+ export function videoTraceOptions(raw, manifest, maxBytes) {
7
+ const level = raw.level === undefined ? 'medium' : raw.level
8
+ const preset = raw.preset === undefined ? 'default' : raw.preset
9
+ const colors = raw.colors === undefined ? TRACE_DEFAULTS.colors : Number(raw.colors)
10
+ if (!Object.prototype.hasOwnProperty.call(TRACE_FRAGMENT_LEVELS, level)) throw new Error('未知碎片程度')
11
+ if (!TRACE_PRESET_NAMES.includes(preset)) throw new Error('未知复刻画风')
12
+ if (!Number.isInteger(colors) || colors < 2 || colors > 24) throw new Error('颜色数须在 2–24 之间')
13
+ if (typeof raw.removeBg !== 'boolean') throw new Error('removeBg 必须为 true 或 false')
14
+ return { width: manifest.width, height: manifest.height, level, preset, colors, removeBg: raw.removeBg, maxBytes }
15
+ }
16
+
17
+ export function exactVideoFrameKey(png, optionsKey) {
18
+ // Equal uploaded PNG bytes guarantee equal decoded pixels. Different PNG encodings
19
+ // may miss this conservative cache; they always go through normal decoding.
20
+ return createHash('sha256').update(png).update('\0').update(optionsKey).digest('hex')
21
+ }
22
+
23
+ export function checkVideoPalette(work, foreground, palette) {
24
+ if (!Array.isArray(palette) || palette.length < 2 || palette.length > 24 ||
25
+ !palette.every((rgb) => Array.isArray(rgb) && rgb.length === 3 && rgb.every((c) => Number.isInteger(c) && c >= 0 && c <= 255))) {
26
+ return { accepted: false, reason: 'invalid-palette' }
27
+ }
28
+ // Check actual composited/downsampled foreground, not a global gray average.
29
+ // Any new bright/color detail can reject reuse, regardless of its area.
30
+ const seen = new Set()
31
+ for (const pixel of foreground) {
32
+ const at = pixel * 4, r = work[at], g = work[at + 1], b = work[at + 2]
33
+ const key = (r << 16) | (g << 8) | b
34
+ if (seen.has(key)) continue
35
+ let nearest = null, best = Infinity
36
+ for (const rgb of palette) {
37
+ const dr = r - rgb[0], dg = g - rgb[1], db = b - rgb[2]
38
+ const distance = dr * dr + dg * dg + db * db
39
+ if (distance < best) { best = distance; nearest = rgb }
40
+ }
41
+ const dr = r - nearest[0], dg = g - nearest[1], db = b - nearest[2]
42
+ const lumaError = Math.abs(0.299 * dr + 0.587 * dg + 0.114 * db)
43
+ if (lumaError > 8) return { accepted: false, reason: 'brightness-mismatch' }
44
+ if (Math.max(Math.abs(dr), Math.abs(dg), Math.abs(db)) > 12) return { accepted: false, reason: 'color-mismatch' }
45
+ // Bounded exact RGB memoization speeds up flat animation without sampling away details.
46
+ if (seen.size < 4096) seen.add(key)
47
+ }
48
+ return { accepted: true, reason: 'fits-foreground' }
49
+ }
@@ -0,0 +1,61 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ // 单帧复刻在独立线程计算;支持常驻消息循环(池)与一次性 workerData(兼容)。
3
+ import { parentPort, workerData } from 'node:worker_threads'
4
+ import { decodeRaster } from './raster.js'
5
+ import { traceRasterForToolbar, traceRasterToSvg } from './trace-image.js'
6
+ import { createPerformanceMetrics } from './performance-metrics.js'
7
+ import { checkVideoPalette } from './video-trace-reuse.js'
8
+
9
+ async function tracePng(png, rawOptions, rawRaster) {
10
+ const timing = rawOptions.profile === true ? createPerformanceMetrics() : null
11
+ const decodeDone = timing?.start('pngDecode')
12
+ const raster = rawRaster || await decodeRaster(Buffer.from(png))
13
+ decodeDone?.()
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) }
17
+ if (rawOptions.mode === 'image') {
18
+ const result = traceRasterForToolbar(raster, { ...rawOptions, includeDrawing: true,
19
+ onTiming: timing ? (name, ms) => timing.add(name, ms) : undefined })
20
+ return { ok: true, ...result, rasterSize: { width: raster.width, height: raster.height },
21
+ ...(timing ? { performance: timing.snapshot() } : {}) }
22
+ }
23
+ const { width, height, ...options } = rawOptions
24
+ if (raster.width !== width || raster.height !== height) throw new Error('帧尺寸与任务不一致')
25
+ let paletteCheck = null
26
+ const result = traceRasterForToolbar(raster, { ...options, allowEmpty: true, onlySelectedLevel: true,
27
+ paletteValidator: (work, foreground, palette) => {
28
+ const checked = timing?.start('paletteValidation')
29
+ paletteCheck = checkVideoPalette(work, foreground, palette)
30
+ checked?.()
31
+ return paletteCheck.accepted
32
+ },
33
+ onTiming: timing ? (name, ms) => timing.add(name, ms) : undefined,
34
+ maxLongSide: Math.max(width, height) })
35
+ const hexes = Array.isArray(result.metrics.palette) ? result.metrics.palette : []
36
+ const used = options.removeBg ? hexes.slice(1) : hexes
37
+ const palette = used.map((hex) => {
38
+ const h = String(hex).replace('#', '')
39
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
40
+ }).filter((rgb) => rgb.length === 3 && rgb.every((n) => Number.isInteger(n)))
41
+ return { ok: true, svg: result.svg, detailReduced: result.metrics.detailReduced, work: result.metrics.work, palette,
42
+ paletteShared: paletteCheck?.accepted === true, paletteReuseReason: paletteCheck?.reason || 'fresh-palette',
43
+ ...(timing ? { performance: timing.snapshot() } : {}) }
44
+ }
45
+
46
+ async function handle(message) {
47
+ try {
48
+ const result = await tracePng(message.png, message.options, message.raster)
49
+ parentPort.postMessage(Object.assign({ id: message.id }, result))
50
+ } catch (error) {
51
+ parentPort.postMessage({ id: message.id, ok: false, error: error.message || String(error) })
52
+ }
53
+ }
54
+
55
+ if (workerData && workerData.png) {
56
+ handle({ id: 0, png: workerData.png, options: workerData.options }).catch((error) => {
57
+ parentPort.postMessage({ id: 0, ok: false, error: error.message || String(error) })
58
+ })
59
+ } else {
60
+ parentPort.on('message', (message) => { handle(message) })
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",
@@ -14,7 +14,8 @@
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-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-trace.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
+ "bench:performance": "node tools/benchmark-performance.mjs"
18
19
  },
19
20
  "exports": {
20
21
  ".": "./lib/index.js",
@@ -38,6 +39,9 @@
38
39
  "engines": {
39
40
  "node": ">=20"
40
41
  },
42
+ "dependencies": {
43
+ "@resvg/resvg-js": "^2.6.2"
44
+ },
41
45
  "peerDependencies": {
42
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"
43
47
  },