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.
- package/README.md +2 -2
- package/lib/client.js +311 -94
- package/lib/drawing-results.js +36 -0
- package/lib/drawing-runner.js +213 -0
- package/lib/drawing-tasks.js +230 -0
- package/lib/index.js +216 -23
- package/lib/media.js +5 -2
- package/lib/mxfile.js +3 -1
- package/lib/performance-log.js +1 -1
- package/lib/task-coordinator.js +54 -0
- package/lib/task-patches.js +59 -0
- package/lib/video-export-worker.js +58 -0
- package/lib/video-export.js +165 -54
- package/lib/video-trace-pool.js +12 -2
- package/lib/video-trace-worker.js +6 -4
- package/package.json +5 -2
package/lib/video-export.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/video-trace-pool.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
},
|