dsh-drawai 0.2.7 → 0.3.0
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 +51 -9
- package/lib/client.js +3764 -461
- package/lib/index.js +1282 -40
- package/lib/media.js +661 -0
- package/lib/mxfile.js +88 -12
- package/lib/performance-log.js +66 -0
- package/lib/performance-metrics.js +47 -0
- package/lib/raster.js +21 -4
- package/lib/style-kernel.js +51 -11
- package/lib/trace-image.js +595 -106
- package/lib/video-export.js +193 -0
- package/lib/video-trace-pool.js +119 -0
- package/lib/video-trace-reuse.js +49 -0
- package/lib/video-trace-worker.js +59 -0
- package/package.json +3 -2
|
@@ -0,0 +1,193 @@
|
|
|
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 { 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'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { clipDirFor, readManifest, findSourceFile, sanitizeClipId } from './media.js'
|
|
9
|
+
import { createPerformanceLog } from './performance-log.js'
|
|
10
|
+
|
|
11
|
+
function runFfmpeg(binary, args, signal, progress) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(binary, args, { windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
14
|
+
let errorText = '', line = ''
|
|
15
|
+
const abort = () => child.kill()
|
|
16
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
17
|
+
if (signal.aborted) abort()
|
|
18
|
+
child.stdout.on('data', (bytes) => {
|
|
19
|
+
line += bytes.toString()
|
|
20
|
+
const lines = line.split(/\r?\n/); line = lines.pop()
|
|
21
|
+
for (const entry of lines) if (entry.startsWith('frame=')) progress?.(Number(entry.slice(6)))
|
|
22
|
+
})
|
|
23
|
+
child.stderr.on('data', (bytes) => { errorText = (errorText + bytes.toString()).slice(-8000) })
|
|
24
|
+
child.on('error', (error) => {
|
|
25
|
+
signal.removeEventListener('abort', abort)
|
|
26
|
+
reject(new Error(error.code === 'ENOENT' ? '未找到 FFmpeg,请安装到 PATH,或设置 DRAWAI_FFMPEG_PATH 为可执行文件路径' : error.message))
|
|
27
|
+
})
|
|
28
|
+
child.on('close', (code) => {
|
|
29
|
+
signal.removeEventListener('abort', abort)
|
|
30
|
+
if (signal.aborted) reject(new Error('已取消视频导出'))
|
|
31
|
+
else if (code !== 0) reject(new Error('FFmpeg 编码失败:' + errorText.trim()))
|
|
32
|
+
else resolve()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ffmpegBinary() {
|
|
38
|
+
if (process.env.DRAWAI_FFMPEG_PATH) return process.env.DRAWAI_FFMPEG_PATH
|
|
39
|
+
// Development checkout only; published packages use the configured binary or PATH.
|
|
40
|
+
const local = fileURLToPath(new URL('../_local/bin/' + (process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), import.meta.url))
|
|
41
|
+
return existsSync(local) ? local : 'ffmpeg'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createVideoExportManager(options = {}) {
|
|
45
|
+
const jobs = new Map()
|
|
46
|
+
let closed = false
|
|
47
|
+
const binary = options.ffmpegPath || ffmpegBinary()
|
|
48
|
+
const framePath = (job, index) => join(job.temp, 'frame-' + String(index).padStart(8, '0') + '.png')
|
|
49
|
+
function view(job) {
|
|
50
|
+
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,
|
|
52
|
+
performanceEnabled: job.perf.enabled, performanceId: job.perf.id }
|
|
53
|
+
}
|
|
54
|
+
function owned(scope, token) {
|
|
55
|
+
const job = jobs.get(token)
|
|
56
|
+
if (!job || job.sessionId !== scope.sessionId || job.drawioAbs !== scope.drawioAbs || job.clip !== scope.clip) throw new Error('视频导出任务已失效或不属于当前画布')
|
|
57
|
+
job.time = Date.now()
|
|
58
|
+
return job
|
|
59
|
+
}
|
|
60
|
+
async function remove(job) {
|
|
61
|
+
jobs.delete(job.token)
|
|
62
|
+
job.control.abort()
|
|
63
|
+
if (job.preflight) await job.preflight.catch(() => {})
|
|
64
|
+
if (job.promise) await job.promise.catch(() => {})
|
|
65
|
+
job.perf.finish(job.status === 'done' ? 'done' : job.status === 'error' ? 'failed' : 'cancelled')
|
|
66
|
+
rmSync(job.temp, { recursive: true, force: true })
|
|
67
|
+
}
|
|
68
|
+
async function encode(job) {
|
|
69
|
+
const args = ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y']
|
|
70
|
+
if (job.kind === 'svg') args.push('-framerate', String(job.fps), '-start_number', '0', '-i', join(job.temp, 'frame-%08d.png'))
|
|
71
|
+
else args.push('-protocol_whitelist', 'file,pipe', '-i', job.source)
|
|
72
|
+
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')
|
|
77
|
+
if (job.kind === 'svg') {
|
|
78
|
+
args.push('-r', String(job.fps), '-t', String(job.count / job.fps))
|
|
79
|
+
if (job.source) args.push('-af', 'apad')
|
|
80
|
+
}
|
|
81
|
+
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')
|
|
83
|
+
args.push('-threads', '4', '-progress', 'pipe:1', job.output)
|
|
84
|
+
try {
|
|
85
|
+
const encodeDone = job.perf.start('ffmpegEncode')
|
|
86
|
+
try { await runFfmpeg(binary, args, job.control.signal, (frame) => { job.encoded = frame }) }
|
|
87
|
+
finally { encodeDone() }
|
|
88
|
+
if (job.kind === 'svg' && job.encoded !== job.count) throw new Error('输出帧数不完整:预期 ' + job.count + ',实际 ' + job.encoded)
|
|
89
|
+
job.bytes = statSync(job.output).size
|
|
90
|
+
if (!job.bytes) throw new Error('视频编码结果为空')
|
|
91
|
+
job.status = 'done'
|
|
92
|
+
job.perf.peak('tempTotalBytes', (job.tempBytes || (job.source ? statSync(job.source).size : 0)) + job.bytes);
|
|
93
|
+
job.perf.count('outputBytes', job.bytes); job.perf.count('encodedFrames', job.encoded)
|
|
94
|
+
} catch (error) { job.status = 'error'; job.error = error.message || String(error) }
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
async start(scope, format) {
|
|
98
|
+
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('视频导出任务过多,请稍后重试')
|
|
102
|
+
const dir = clipDirFor(scope.drawioAbs, scope.clip), manifest = readManifest(dir)
|
|
103
|
+
if (manifest.kind !== 'raster' && !manifest.frames.length) throw new Error('没有 SVG 帧可编码')
|
|
104
|
+
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)
|
|
106
|
+
if (manifest.kind === 'raster' && !original) throw new Error('源视频缺失')
|
|
107
|
+
if (manifest.source && !original) throw new Error('原片段音频来源缺失,无法保留音频')
|
|
108
|
+
if (original) {
|
|
109
|
+
const rel = relative(realpathSync(dir), realpathSync(original.abs))
|
|
110
|
+
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('音频或视频来源不在媒体包内')
|
|
111
|
+
}
|
|
112
|
+
// Child processes may lack write access to the OS temp directory on Windows.
|
|
113
|
+
// Keep transient files beside the writable canvas, outside its media package.
|
|
114
|
+
const cache = options.tempRoot || join(dirname(scope.drawioAbs), '.drawai-cache', 'video-exports')
|
|
115
|
+
mkdirSync(cache, { recursive: true })
|
|
116
|
+
const temp = mkdtempSync(join(cache, 'export-'))
|
|
117
|
+
const perf = createPerformanceLog(dirname(scope.drawioAbs), 'video-export', { ...options.metadata, format, kind: manifest.kind, fps: manifest.fps,
|
|
118
|
+
width: manifest.width, height: manifest.height, count: manifest.frames.length, encoderThreads: 4 }, { enabled: options.profile })
|
|
119
|
+
let job
|
|
120
|
+
try {
|
|
121
|
+
const sourceDone = perf.start('sourceSnapshot')
|
|
122
|
+
const source = original ? join(temp, 'source' + extname(original.abs)) : null
|
|
123
|
+
if (source) copyFileSync(original.abs, source)
|
|
124
|
+
sourceDone()
|
|
125
|
+
job = { ...scope, perf, token: randomUUID(), temp, source, format, kind: manifest.kind, fps: manifest.fps,
|
|
126
|
+
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) }
|
|
128
|
+
jobs.set(job.token, job)
|
|
129
|
+
// Probe the actual output path and encoder before uploading any large frames.
|
|
130
|
+
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)
|
|
132
|
+
const probeDone = perf.start('encoderPreflight')
|
|
133
|
+
try { await job.preflight }
|
|
134
|
+
catch (error) { throw new Error('导出预检查失败(目录:' + temp + '):' + error.message) }
|
|
135
|
+
finally { probeDone() }
|
|
136
|
+
if (closed || job.control.signal.aborted) throw new Error('视频导出服务已关闭')
|
|
137
|
+
return view(job)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
perf.finish('failed')
|
|
140
|
+
if (job) jobs.delete(job.token)
|
|
141
|
+
rmSync(temp, { recursive: true, force: true })
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
},
|
|
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
|
+
},
|
|
179
|
+
status(scope, token) { return view(owned(scope, token)) },
|
|
180
|
+
read(scope, token, offset) {
|
|
181
|
+
const job = owned(scope, token)
|
|
182
|
+
if (job.status !== 'done' || !Number.isSafeInteger(offset) || offset < 0 || offset >= job.bytes) throw new Error('导出文件未完成或读取位置无效')
|
|
183
|
+
const bytes = Buffer.alloc(Math.min(1024 * 1024, job.bytes - offset)), fd = openSync(job.output, 'r')
|
|
184
|
+
const readDone = job.perf.start('outputReadAndBase64Encode')
|
|
185
|
+
try { readSync(fd, bytes, 0, bytes.length, offset) } finally { closeSync(fd) }
|
|
186
|
+
const base64 = bytes.toString('base64')
|
|
187
|
+
readDone(); job.perf.count('downloadedBytes', bytes.length)
|
|
188
|
+
return { ok: true, offset, bytes: bytes.length, base64, total: job.bytes }
|
|
189
|
+
},
|
|
190
|
+
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)) },
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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, options: item.options })
|
|
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
|
+
return { run, cancelToken, close, get size() { return size } }
|
|
119
|
+
}
|
|
@@ -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,59 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
// 单帧复刻在独立线程计算;支持常驻消息循环(池)与一次性 workerData(兼容)。
|
|
3
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
4
|
+
import { decodeRaster } from './raster.js'
|
|
5
|
+
import { traceRasterForToolbar } 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) {
|
|
10
|
+
const timing = rawOptions.profile === true ? createPerformanceMetrics() : null
|
|
11
|
+
const decodeDone = timing?.start('pngDecode')
|
|
12
|
+
const raster = await decodeRaster(Buffer.from(png))
|
|
13
|
+
decodeDone?.()
|
|
14
|
+
if (raster.width * raster.height > 16000000) throw new Error('图片超过 1600 万像素,请先缩小图片')
|
|
15
|
+
if (rawOptions.mode === 'image') {
|
|
16
|
+
const result = traceRasterForToolbar(raster, { ...rawOptions, includeDrawing: true,
|
|
17
|
+
onTiming: timing ? (name, ms) => timing.add(name, ms) : undefined })
|
|
18
|
+
return { ok: true, ...result, rasterSize: { width: raster.width, height: raster.height },
|
|
19
|
+
...(timing ? { performance: timing.snapshot() } : {}) }
|
|
20
|
+
}
|
|
21
|
+
const { width, height, ...options } = rawOptions
|
|
22
|
+
if (raster.width !== width || raster.height !== height) throw new Error('帧尺寸与任务不一致')
|
|
23
|
+
let paletteCheck = null
|
|
24
|
+
const result = traceRasterForToolbar(raster, { ...options, allowEmpty: true, onlySelectedLevel: true,
|
|
25
|
+
paletteValidator: (work, foreground, palette) => {
|
|
26
|
+
const checked = timing?.start('paletteValidation')
|
|
27
|
+
paletteCheck = checkVideoPalette(work, foreground, palette)
|
|
28
|
+
checked?.()
|
|
29
|
+
return paletteCheck.accepted
|
|
30
|
+
},
|
|
31
|
+
onTiming: timing ? (name, ms) => timing.add(name, ms) : undefined,
|
|
32
|
+
maxLongSide: Math.max(width, height) })
|
|
33
|
+
const hexes = Array.isArray(result.metrics.palette) ? result.metrics.palette : []
|
|
34
|
+
const used = options.removeBg ? hexes.slice(1) : hexes
|
|
35
|
+
const palette = used.map((hex) => {
|
|
36
|
+
const h = String(hex).replace('#', '')
|
|
37
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
|
38
|
+
}).filter((rgb) => rgb.length === 3 && rgb.every((n) => Number.isInteger(n)))
|
|
39
|
+
return { ok: true, svg: result.svg, detailReduced: result.metrics.detailReduced, work: result.metrics.work, palette,
|
|
40
|
+
paletteShared: paletteCheck?.accepted === true, paletteReuseReason: paletteCheck?.reason || 'fresh-palette',
|
|
41
|
+
...(timing ? { performance: timing.snapshot() } : {}) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function handle(message) {
|
|
45
|
+
try {
|
|
46
|
+
const result = await tracePng(message.png, message.options)
|
|
47
|
+
parentPort.postMessage(Object.assign({ id: message.id }, result))
|
|
48
|
+
} catch (error) {
|
|
49
|
+
parentPort.postMessage({ id: message.id, ok: false, error: error.message || String(error) })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (workerData && workerData.png) {
|
|
54
|
+
handle({ id: 0, png: workerData.png, options: workerData.options }).catch((error) => {
|
|
55
|
+
parentPort.postMessage({ id: 0, ok: false, error: error.message || String(error) })
|
|
56
|
+
})
|
|
57
|
+
} else {
|
|
58
|
+
parentPort.on('message', (message) => { handle(message) })
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-drawai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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-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",
|