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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
import { imageValueFromStyle, styleWithSvgMarkup, styleGet } from './style-kernel.js'
|
|
3
|
+
import { videoMetaFromData } from './media.js'
|
|
4
|
+
|
|
5
|
+
export function applyDrawingResult(doc, job, output) {
|
|
6
|
+
const existing = doc.nodes.find((node) => node.data?.drawaiTaskId === job.id)
|
|
7
|
+
if (existing) return existing
|
|
8
|
+
const source = doc.nodes.find((node) => node.id === job.source.id)
|
|
9
|
+
if (!source) throw new Error('来源节点已删除,结果已保留')
|
|
10
|
+
const layer = (doc.layers || []).find((item) => item.id === source.layer)
|
|
11
|
+
if (layer?.locked || source.locked || styleGet(source.style || '', 'locked', '0') === '1') throw new Error('来源图层或节点已锁定,结果已保留')
|
|
12
|
+
const seen = new Set([source.id])
|
|
13
|
+
for (let parent = source.parent; parent;) {
|
|
14
|
+
if (seen.has(parent)) throw new Error('来源分组结构无效')
|
|
15
|
+
seen.add(parent)
|
|
16
|
+
const ancestor = doc.nodes.find((node) => node.id === parent)
|
|
17
|
+
if (!ancestor) break
|
|
18
|
+
if (ancestor.locked || styleGet(ancestor.style || '', 'locked', '0') === '1') throw new Error('来源分组已锁定,结果已保留')
|
|
19
|
+
parent = ancestor.parent
|
|
20
|
+
}
|
|
21
|
+
if (job.kind === 'image' && imageValueFromStyle(source.style || '') !== job.source.image) throw new Error('原图已更改,结果已保留')
|
|
22
|
+
if (job.kind === 'video' && videoMetaFromData(source.data)?.clip !== job.source.clip) throw new Error('原视频已更改,结果已保留')
|
|
23
|
+
const id = 'draw-' + job.id
|
|
24
|
+
if (doc.nodes.concat(doc.edges || [], doc.layers || [], doc.labels || []).some((cell) => cell.id === id)) throw new Error('绘制结果 ID 冲突')
|
|
25
|
+
const node = { ...source, id, x: source.x + source.w + 24, data: { ...source.data, drawaiTaskId: job.id } }
|
|
26
|
+
if (job.kind === 'image') node.style = styleWithSvgMarkup(source.style || '', output.svg, styleGet(source.style || '', 'imageAspect', '1'))
|
|
27
|
+
else {
|
|
28
|
+
node.label = (source.label || '视频') + ' · SVG 复刻'
|
|
29
|
+
node.style = styleWithSvgMarkup(source.style || '', output.poster, '1')
|
|
30
|
+
node.data = { ...node.data, drawaiMedia: '1', mediaKind: 'svg', mediaClip: output.clip,
|
|
31
|
+
mediaFps: String(output.fps), mediaW: String(output.w), mediaH: String(output.h), mediaPosterFrame: '0' }
|
|
32
|
+
}
|
|
33
|
+
const order = Array.isArray(doc.drawOrder) ? doc.drawOrder.slice() : (doc.edges || []).concat(doc.nodes).map((item) => item.id)
|
|
34
|
+
doc.nodes.push(node); doc.drawOrder = [...order.filter((value) => value !== id), id]
|
|
35
|
+
return node
|
|
36
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, renameSync, mkdtempSync, rmSync, realpathSync } from 'node:fs'
|
|
4
|
+
import { join, relative, isAbsolute } from 'node:path'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { ffmpegBinary } from './video-export.js'
|
|
7
|
+
import { ensureClipPack, writeManifest, clipDirFor, mediaRootFor, frameFileName, STILLS_DIR, stillFileName } from './media.js'
|
|
8
|
+
|
|
9
|
+
function checkMediaDirectory(job, path) {
|
|
10
|
+
const rel = relative(realpathSync(job.scope.workspaceRoot), realpathSync(path))
|
|
11
|
+
if (isAbsolute(rel) || rel.startsWith('..')) throw new Error('媒体目录超出工作区')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function runMediaCommand(args, signal, onProgress, binary = ffmpegBinary()) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(binary, ['-hide_banner', '-nostdin', '-nostats', ...args], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
17
|
+
let stdout = '', stderr = ''
|
|
18
|
+
const abort = () => child.kill()
|
|
19
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort()
|
|
20
|
+
child.stdout.on('data', (bytes) => {
|
|
21
|
+
stdout = (stdout + bytes.toString()).slice(-65536)
|
|
22
|
+
const matches = [...stdout.matchAll(/frame=(\d+)/g)]; if (matches.length) onProgress?.(Number(matches.at(-1)[1]))
|
|
23
|
+
})
|
|
24
|
+
child.stderr.on('data', (bytes) => { stderr = (stderr + bytes.toString()).slice(-32768) })
|
|
25
|
+
child.on('error', (error) => { signal?.removeEventListener('abort', abort); reject(new Error(error.code === 'ENOENT' ? '未找到 FFmpeg,请安装或设置 DRAWAI_FFMPEG_PATH' : error.message)) })
|
|
26
|
+
child.on('close', (code) => {
|
|
27
|
+
signal?.removeEventListener('abort', abort)
|
|
28
|
+
if (signal?.aborted) reject(new Error('已取消任务'))
|
|
29
|
+
else if (code !== 0) reject(new Error('FFmpeg 处理失败:' + stderr.trim()))
|
|
30
|
+
else resolve({ stdout, stderr })
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const inputArgs = (file) => ['-protocol_whitelist', 'file,pipe', '-format_whitelist', 'mov,matroska,webm', '-i', file]
|
|
36
|
+
|
|
37
|
+
export async function probeDrawingVideo(file, signal, binary) {
|
|
38
|
+
// Only FFmpeg is required. The null pass reports the actual decoded endpoint
|
|
39
|
+
// (including fractional duration), unlike the rounded "Duration:" header.
|
|
40
|
+
const result = await runMediaCommand([...inputArgs(file), '-map', '0:v:0', '-an', '-sn', '-fps_mode', 'passthrough',
|
|
41
|
+
'-progress', 'pipe:1', '-f', 'null', '-'], signal, null, binary)
|
|
42
|
+
const times = [...result.stdout.matchAll(/out_time_us=(\d+)/g)]
|
|
43
|
+
const videos = [...result.stderr.matchAll(/Video:[^\r\n]*?\b(\d{1,5})x(\d{1,5})\b([^\r\n]*)/g)]
|
|
44
|
+
const video = videos.at(-1)
|
|
45
|
+
if (!video || !times.length) throw new Error('无法探测视频尺寸或时长')
|
|
46
|
+
const sar = /SAR (\d+):(\d+)/.exec(video[3])
|
|
47
|
+
const w = Math.round(Number(video[1]) * (sar ? Number(sar[1]) / Number(sar[2]) : 1))
|
|
48
|
+
const h = Number(video[2]), duration = Number(times.at(-1)[1]) / 1000000
|
|
49
|
+
if (!(w > 0 && h > 0 && duration > 0)) throw new Error('视频尺寸或时长无效')
|
|
50
|
+
return { w, h, duration }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function planDrawingVideo(options, info, extract = false) {
|
|
54
|
+
const from = options.range === 'all' ? 0 : Number(options.from || 0)
|
|
55
|
+
const end = options.range === 'all' || options.end === '' ? info.duration : options.end !== undefined ? Number(options.end) : from + Number(options.seconds)
|
|
56
|
+
const fps = Number(options.fps)
|
|
57
|
+
const width = options.resolution === undefined || options.resolution === 'custom' ? Number(options.width) : Number(options.resolution)
|
|
58
|
+
if (!Number.isFinite(from) || from < 0 || from >= info.duration || !Number.isFinite(end) || end <= from) throw new Error('绘制时间范围无效')
|
|
59
|
+
if (!Number.isFinite(fps) || fps <= 0 || !Number.isInteger(width) || width < 0 || width > 16384 || (options.resolution === 'custom' && !width)) throw new Error('帧率或采样尺寸无效')
|
|
60
|
+
const raw = (Math.min(end, info.duration) - from) * fps
|
|
61
|
+
let count = Math.max(1, Math.ceil(raw - Math.min(1e-7, raw * Number.EPSILON * 4)))
|
|
62
|
+
if (extract) {
|
|
63
|
+
const max = options.max ?? 1200
|
|
64
|
+
if (!Number.isInteger(max) || max < 1 || max > 1200) throw new Error('抽帧数量上限必须为 1 至 1200')
|
|
65
|
+
count = Math.min(count, max)
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isSafeInteger(count) || count > 1000000) throw new Error('预计帧数超出 100 万帧上限')
|
|
68
|
+
const scale = width === 0 ? 1 : extract ? width / info.w : Math.min(1, width / Math.max(info.w, info.h))
|
|
69
|
+
const w = Math.max(1, Math.round(info.w * scale)), h = Math.max(1, Math.round(info.h * scale))
|
|
70
|
+
if (w * h > 16000000 || Math.max(w, h) > 16384) throw new Error('单帧处理上限为 1600 万像素、长边 16384px')
|
|
71
|
+
return { from, fps, count, w, h, maxBytes: options.level === 'low' ? 60000 : options.level === 'high' ? 248000 : 120000 }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function* rawVideoFrames(job, signal, binary, perf) {
|
|
75
|
+
const decodeDone = perf?.start('decodePipelineWall')
|
|
76
|
+
const { plan } = job, offset = job.done || 0
|
|
77
|
+
const child = spawn(binary || ffmpegBinary(), ['-hide_banner', '-nostdin', '-loglevel', 'error',
|
|
78
|
+
'-ss', String(plan.from), ...inputArgs(job.input), '-map', '0:v:0', '-an', '-sn',
|
|
79
|
+
'-filter_threads', '1', '-vf', 'tpad=stop_mode=clone:stop_duration=' + (1 / plan.fps) + ',fps=fps=' + plan.fps + ':start_time=0:round=near,scale=' + plan.w + ':' + plan.h + ':flags=bicubic,setsar=1,format=rgba,trim=start_frame=' + offset + ',setpts=PTS-STARTPTS',
|
|
80
|
+
'-frames:v', String(plan.count - offset), '-fps_mode', 'passthrough', '-threads', '1', '-pix_fmt', 'rgba', '-f', 'rawvideo', 'pipe:1'],
|
|
81
|
+
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
82
|
+
let stderr = '', error
|
|
83
|
+
const abort = () => child.kill()
|
|
84
|
+
signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) abort()
|
|
85
|
+
child.stderr.on('data', (bytes) => { stderr = (stderr + bytes.toString()).slice(-8000) })
|
|
86
|
+
const completed = new Promise((resolve) => {
|
|
87
|
+
child.on('error', (value) => { error = value; resolve(-1) }); child.on('close', resolve)
|
|
88
|
+
})
|
|
89
|
+
const size = plan.w * plan.h * 4
|
|
90
|
+
let frame = Buffer.alloc(size), filled = 0
|
|
91
|
+
try {
|
|
92
|
+
for await (const chunk of child.stdout) {
|
|
93
|
+
for (let offset = 0; offset < chunk.length;) {
|
|
94
|
+
const take = Math.min(size - filled, chunk.length - offset)
|
|
95
|
+
chunk.copy(frame, filled, offset, offset + take); offset += take; filled += take
|
|
96
|
+
if (filled === size) { yield frame; frame = Buffer.alloc(size); filled = 0 }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const code = await completed
|
|
100
|
+
if (signal.aborted) throw new Error('已取消任务')
|
|
101
|
+
if (code !== 0 || filled) throw new Error(error?.message || '视频解码失败或像素帧不完整:' + stderr)
|
|
102
|
+
} finally { signal.removeEventListener('abort', abort); child.kill(); await completed; decodeDone?.() }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function executeDrawing(job, context) {
|
|
106
|
+
const { pool, signal, update, checkpoint, perf } = context
|
|
107
|
+
if (job.kind === 'image') {
|
|
108
|
+
update({ phase: 'tracing', total: 1, message: '正在分析图片与生成色块' })
|
|
109
|
+
const output = await pool.run(readFileSync(job.input), { ...job.options, mode: 'image', profile: perf?.enabled }, { token: job.id })
|
|
110
|
+
perf?.merge('worker.', output.performance); perf?.add('workerQueueWait', output.queueWaitMs)
|
|
111
|
+
job.done = 1; checkpoint(); return output
|
|
112
|
+
}
|
|
113
|
+
if (!job.info) {
|
|
114
|
+
update({ phase: 'probing', message: '正在探测视频与采样范围' })
|
|
115
|
+
const probeDone = perf?.start('videoProbe')
|
|
116
|
+
job.info = await probeDrawingVideo(job.input, signal, context.ffmpegPath)
|
|
117
|
+
probeDone?.()
|
|
118
|
+
job.plan = planDrawingVideo(job.options, job.info, job.kind === 'extract'); checkpoint()
|
|
119
|
+
}
|
|
120
|
+
const plan = job.plan
|
|
121
|
+
update({ phase: 'decoding', total: plan.count, message: '正在抽帧并绘制' })
|
|
122
|
+
if (job.kind === 'extract') {
|
|
123
|
+
const out = join(job.directory, 'stills'); mkdirSync(out, { recursive: true })
|
|
124
|
+
await runMediaCommand(['-y', '-ss', String(plan.from), ...inputArgs(job.input), '-map', '0:v:0', '-an', '-filter_threads', '1',
|
|
125
|
+
'-vf', 'tpad=stop_mode=clone:stop_duration=' + (1 / plan.fps) + ',fps=fps=' + plan.fps + ':start_time=0,scale=' + plan.w + ':' + plan.h + ',setsar=1',
|
|
126
|
+
'-frames:v', String(plan.count), '-threads', '1', '-start_number', '1', '-progress', 'pipe:1', join(out, '%04d.png')],
|
|
127
|
+
signal, (done) => update({ done, total: plan.count }), context.ffmpegPath)
|
|
128
|
+
for (let i = 0; i < plan.count; i++) if (!existsSync(join(out, stillFileName(i)))) throw new Error('抽帧结果不完整')
|
|
129
|
+
await (context.publishExtraction || (async (action) => action()))(() => {
|
|
130
|
+
const clipDir = clipDirFor(job.scope.drawioAbs, job.source.clip)
|
|
131
|
+
checkMediaDirectory(job, clipDir)
|
|
132
|
+
const target = join(clipDir, STILLS_DIR), stage = mkdtempSync(join(clipDir, '.stills-')), backup = stage + '.previous'
|
|
133
|
+
try {
|
|
134
|
+
for (let i = 0; i < plan.count; i++) {
|
|
135
|
+
if (signal.aborted) throw new Error('已取消抽帧')
|
|
136
|
+
copyFileSync(join(out, stillFileName(i)), join(stage, stillFileName(i)))
|
|
137
|
+
}
|
|
138
|
+
if (existsSync(target)) renameSync(target, backup)
|
|
139
|
+
try { renameSync(stage, target) }
|
|
140
|
+
catch (error) { if (existsSync(backup) && !existsSync(target)) renameSync(backup, target); throw error }
|
|
141
|
+
rmSync(backup, { recursive: true, force: true })
|
|
142
|
+
} finally { rmSync(stage, { recursive: true, force: true }) }
|
|
143
|
+
})
|
|
144
|
+
job.done = plan.count; checkpoint(); return { written: plan.count, clip: job.source.clip }
|
|
145
|
+
}
|
|
146
|
+
const clip = job.outputClip || ('trace-' + job.id)
|
|
147
|
+
const mediaRoot = mediaRootFor(job.scope.drawioAbs)
|
|
148
|
+
if (existsSync(mediaRoot)) checkMediaDirectory(job, mediaRoot)
|
|
149
|
+
const pack = ensureClipPack(job.scope.drawioAbs, clip, { kind: 'svg', w: plan.w, h: plan.h, fps: plan.fps })
|
|
150
|
+
checkMediaDirectory(job, pack.dir)
|
|
151
|
+
if (!job.outputClip) { job.outputClip = clip; checkpoint() }
|
|
152
|
+
// Audio is immutable input owned by this task, not a live reference to the browser.
|
|
153
|
+
const sourceName = 'source' + (job.source.extension || '.mp4')
|
|
154
|
+
if (!existsSync(join(pack.dir, sourceName))) copyFileSync(job.input, join(pack.dir, sourceName))
|
|
155
|
+
let manifest = { ...pack.manifest, source: sourceName, sourceOffset: plan.from, frames: pack.manifest.frames.slice(0, job.done || 0) }
|
|
156
|
+
if (manifest.frames.length !== (job.done || 0)) throw new Error('绘制检查点缺少连续帧,请重新开始')
|
|
157
|
+
let chain = ''
|
|
158
|
+
for (let i = 0; i < manifest.frames.length; i++) {
|
|
159
|
+
if (manifest.frames[i].file !== 'frames/' + frameFileName(i)) throw new Error('检查点帧顺序无效,请重新开始')
|
|
160
|
+
const svg = readFileSync(join(pack.dir, manifest.frames[i].file), 'utf8')
|
|
161
|
+
chain = createHash('sha256').update(chain).update(svg).digest('hex')
|
|
162
|
+
}
|
|
163
|
+
if (job.done && chain !== job.frameChain) throw new Error('检查点帧已变化,请重新开始')
|
|
164
|
+
let previous = job.lastFrame ? Promise.resolve({ svg: readFileSync(join(pack.dir, 'frames', frameFileName(job.done - 1)), 'utf8'), paletteShared: false }) : null
|
|
165
|
+
let previousHash = job.lastFrame || '', index = job.done || 0
|
|
166
|
+
const pending = []
|
|
167
|
+
const inFlight = Math.max(1, Math.min(pool.size, Math.floor(64 * 1024 * 1024 / (plan.w * plan.h * 8))))
|
|
168
|
+
let checkpointAt = Date.now(), checkpointFrame = job.done || 0
|
|
169
|
+
const flush = () => {
|
|
170
|
+
writeManifest(pack.dir, manifest); checkpoint()
|
|
171
|
+
checkpointAt = Date.now(); checkpointFrame = job.done
|
|
172
|
+
}
|
|
173
|
+
const commit = async () => {
|
|
174
|
+
const item = pending.shift(), result = await item.promise
|
|
175
|
+
if (signal.aborted) throw new Error('已取消任务')
|
|
176
|
+
if (!item.reused) { perf?.merge('worker.', result.performance); perf?.add('workerQueueWait', result.queueWaitMs) }
|
|
177
|
+
const commitDone = perf?.start('frameCommit')
|
|
178
|
+
const file = join(pack.dir, 'frames', frameFileName(item.index))
|
|
179
|
+
writeFileSync(file + '.tmp', result.svg); renameSync(file + '.tmp', file)
|
|
180
|
+
manifest.frames.push({ file: 'frames/' + frameFileName(item.index), t: item.index / plan.fps })
|
|
181
|
+
job.frameChain = createHash('sha256').update(job.frameChain || '').update(result.svg).digest('hex')
|
|
182
|
+
if (!result.paletteShared && result.palette && !item.reused) { job.palette = result.palette; job.paletteIndex = item.index }
|
|
183
|
+
job.done = item.index + 1; job.lastFrame = item.hash
|
|
184
|
+
if (result.detailReduced) job.reducedFrames = (job.reducedFrames || 0) + 1
|
|
185
|
+
if (!item.index) { job.poster = result.svg; writeFileSync(join(pack.dir, 'poster.svg'), result.svg) }
|
|
186
|
+
update({ phase: 'tracing', done: job.done, total: plan.count, message: '已绘制 ' + job.done + '/' + plan.count + ' 帧' })
|
|
187
|
+
if (!item.index || job.done === plan.count || job.done - checkpointFrame >= 30 || Date.now() - checkpointAt >= 500) flush()
|
|
188
|
+
commitDone?.()
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
if (index < plan.count) for await (const rgba of rawVideoFrames(job, signal, context.ffmpegPath, perf)) {
|
|
192
|
+
if (signal.aborted) throw new Error('已取消任务')
|
|
193
|
+
if (context.pauseRequested()) break
|
|
194
|
+
const hash = createHash('sha256').update(rgba).digest('hex')
|
|
195
|
+
const shared = previous && hash === previousHash
|
|
196
|
+
const options = { ...job.options, width: plan.w, height: plan.h, maxBytes: plan.maxBytes, profile: perf?.enabled }
|
|
197
|
+
if (job.palette && index - job.paletteIndex < Math.max(1, Math.min(120, Math.round(plan.fps * 2)))) options.paletteOverride = job.palette
|
|
198
|
+
const promise = shared ? previous : pool.runRaster({ width: plan.w, height: plan.h, data: rgba }, options, { token: job.id })
|
|
199
|
+
// Attach immediately: a later frame can fail before an earlier one is awaited.
|
|
200
|
+
promise.catch(() => {})
|
|
201
|
+
pending.push({ index, promise, hash, reused: !!shared }); previous = promise; previousHash = hash; index++
|
|
202
|
+
if (pending.length >= inFlight) await commit()
|
|
203
|
+
if (index >= plan.count) break
|
|
204
|
+
}
|
|
205
|
+
while (pending.length) await commit()
|
|
206
|
+
if (context.pauseRequested()) return null
|
|
207
|
+
if (job.done !== plan.count) throw new Error('输出帧数不完整:预期 ' + plan.count + ',实际 ' + job.done)
|
|
208
|
+
return { clip, ...plan, reducedFrames: job.reducedFrames || 0, poster: job.poster || readFileSync(join(pack.dir, 'poster.svg'), 'utf8') }
|
|
209
|
+
} finally {
|
|
210
|
+
await Promise.allSettled(pending.map((item) => item.promise))
|
|
211
|
+
if (job.done !== checkpointFrame) flush()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, rmSync, statSync, realpathSync } from 'node:fs'
|
|
4
|
+
import { copyFile, readFile } from 'node:fs/promises'
|
|
5
|
+
import { join, resolve, relative, isAbsolute } from 'node:path'
|
|
6
|
+
import { executeDrawing } from './drawing-runner.js'
|
|
7
|
+
import { createPerformanceLog } from './performance-log.js'
|
|
8
|
+
|
|
9
|
+
const algorithm = createHash('sha256').update(Buffer.concat(['trace-image.js', 'video-trace-reuse.js', 'drawing-runner.js', 'video-trace-worker.js', 'style-kernel.js', 'raster.js']
|
|
10
|
+
.map((name) => readFileSync(new URL(name, import.meta.url))))).digest('hex')
|
|
11
|
+
const terminal = (job) => ['succeeded', 'failed', 'cancelled', 'interrupted', 'paused'].includes(job.status)
|
|
12
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex')
|
|
13
|
+
const fileIdentity = (path) => {
|
|
14
|
+
let value = resolve(path)
|
|
15
|
+
try { value = realpathSync(value) } catch {}
|
|
16
|
+
return process.platform === 'win32' ? value.toLowerCase() : value
|
|
17
|
+
}
|
|
18
|
+
function atomic(path, value) { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) }
|
|
19
|
+
|
|
20
|
+
export function createDrawingTaskManager(options) {
|
|
21
|
+
const jobs = new Map(), loaded = new Set(), paused = new Map()
|
|
22
|
+
let active, closed = false, pumping = false, sequence = Date.now()
|
|
23
|
+
const nextOrder = () => (sequence = Math.max(Date.now(), sequence + 1))
|
|
24
|
+
const contained = (root, path) => { const rel = relative(realpathSync(root), realpathSync(path)); return !isAbsolute(rel) && rel !== '..' && !rel.startsWith('..\\') && !rel.startsWith('../') }
|
|
25
|
+
const rootOf = (scope) => {
|
|
26
|
+
const path = join(scope.workspaceRoot, '.drawai-cache', 'drawing-tasks')
|
|
27
|
+
mkdirSync(path, { recursive: true })
|
|
28
|
+
if (!contained(scope.workspaceRoot, path)) throw new Error('任务目录超出工作区')
|
|
29
|
+
return path
|
|
30
|
+
}
|
|
31
|
+
const ownerKey = (scope) => fileIdentity(scope.workspaceRoot) + '\0' + scope.sessionId
|
|
32
|
+
const policyPath = (scope) => join(rootOf(scope), 'queue-' + digest(scope.sessionId).slice(0, 24) + '.json')
|
|
33
|
+
function persist(job) {
|
|
34
|
+
const { control, promise, deliveryPromise, poster, ...data } = job
|
|
35
|
+
atomic(join(job.directory, 'job.json'), data)
|
|
36
|
+
}
|
|
37
|
+
function view(job) {
|
|
38
|
+
return { id: job.id, kind: job.kind, label: job.label, scope: { path: job.scope.path, page: job.scope.page,
|
|
39
|
+
pageId: job.scope.pageId, pageName: job.scope.pageName }, options: job.options, status: job.status, delivery: job.delivery,
|
|
40
|
+
phase: job.phase, done: job.done || 0, total: job.total || job.plan?.count || 1, reducedFrames: job.reducedFrames || 0, createdAt: job.createdAt,
|
|
41
|
+
startedAt: job.startedAt, finishedAt: job.finishedAt, error: job.error || '', message: job.message || '',
|
|
42
|
+
pauseRequested: !!job.pauseRequested, outputClip: job.outputClip, nodeId: job.nodeId, resultPath: job.resultPath,
|
|
43
|
+
queuePaused: !!paused.get(ownerKey(job.scope)), sourceId: job.source.id }
|
|
44
|
+
}
|
|
45
|
+
function load(scope) {
|
|
46
|
+
const key = ownerKey(scope)
|
|
47
|
+
if (loaded.has(key)) return
|
|
48
|
+
const root = rootOf(scope); mkdirSync(root, { recursive: true })
|
|
49
|
+
const names = readdirSync(root)
|
|
50
|
+
loaded.add(key)
|
|
51
|
+
try { paused.set(key, JSON.parse(readFileSync(policyPath(scope))).paused === true) } catch {}
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
if (!/^[a-f0-9-]{36}$/.test(name) || jobs.has(name)) continue
|
|
54
|
+
try {
|
|
55
|
+
const directory = join(root, name), data = JSON.parse(readFileSync(join(directory, 'job.json'), 'utf8'))
|
|
56
|
+
if (data.id !== name || !contained(root, directory) || !contained(directory, join(directory, 'job.json'))) continue
|
|
57
|
+
const rel = relative(resolve(scope.workspaceRoot), resolve(data.scope.drawioAbs))
|
|
58
|
+
if (data.scope.sessionId !== scope.sessionId || fileIdentity(data.scope.workspaceRoot) !== fileIdentity(scope.workspaceRoot) ||
|
|
59
|
+
isAbsolute(rel) || rel === '..' || rel.startsWith('..\\') || rel.startsWith('../')) continue
|
|
60
|
+
const job = { ...data, directory, input: join(directory, 'input.bin'), control: new AbortController() }
|
|
61
|
+
if (job.status !== 'succeeded' && !contained(directory, job.input)) continue
|
|
62
|
+
if (job.status === 'succeeded' && job.delivery === 'pending') {
|
|
63
|
+
job.delivery = 'conflict'; job.error = '绘制产物已保存,宿主曾停止,请重试加入原画布'
|
|
64
|
+
}
|
|
65
|
+
if (job.resultPath) job.resultPath = join(directory, 'result.json')
|
|
66
|
+
if (['running', 'queued', 'cancelling'].includes(job.status)) {
|
|
67
|
+
job.status = 'interrupted'; job.message = '宿主曾停止,可继续检查点或重新开始'
|
|
68
|
+
}
|
|
69
|
+
sequence = Math.max(sequence, Number(job.order) || 0)
|
|
70
|
+
jobs.set(job.id, job); persist(job)
|
|
71
|
+
} catch { /* A damaged record must not prevent other tasks from loading. */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function owned(scope, id) {
|
|
75
|
+
load(scope)
|
|
76
|
+
const job = jobs.get(id)
|
|
77
|
+
if (!job || ownerKey(job.scope) !== ownerKey(scope)) throw new Error('任务不存在或不属于当前会话')
|
|
78
|
+
return job
|
|
79
|
+
}
|
|
80
|
+
async function deliver(job) {
|
|
81
|
+
if (job.status !== 'succeeded' || job.kind === 'extract') return
|
|
82
|
+
try {
|
|
83
|
+
const output = JSON.parse(readFileSync(job.resultPath, 'utf8'))
|
|
84
|
+
const result = await options.commit(job, output)
|
|
85
|
+
job.delivery = 'applied'; job.nodeId = result.nodeId; job.createdNode = result.node; job.error = ''
|
|
86
|
+
if (result.page !== undefined) job.scope.page = result.page
|
|
87
|
+
} catch (error) { job.delivery = 'conflict'; job.error = String(error.message || error) }
|
|
88
|
+
persist(job)
|
|
89
|
+
}
|
|
90
|
+
async function run(job) {
|
|
91
|
+
let release
|
|
92
|
+
const perf = createPerformanceLog(job.scope.workspaceRoot, job.kind + '-background', { ...job.options, workerCount: options.pool.size, algorithm })
|
|
93
|
+
perf.add('queueWait', Date.now() - job.createdAt)
|
|
94
|
+
job.control = new AbortController(); job.pauseRequested = false
|
|
95
|
+
job.status = 'running'; job.startedAt ||= Date.now(); job.phase = 'preparing'; job.error = ''; persist(job)
|
|
96
|
+
const checkpoint = () => { job.updatedAt = Date.now(); persist(job) }
|
|
97
|
+
try {
|
|
98
|
+
release = await options.slots?.acquire(job.control.signal)
|
|
99
|
+
const verifyDone = perf.start('inputVerification')
|
|
100
|
+
if (digest(await readFile(job.input)) !== job.inputHash) throw new Error('任务素材摘要已变化,请重新提交')
|
|
101
|
+
verifyDone()
|
|
102
|
+
job.control.signal.throwIfAborted()
|
|
103
|
+
const output = await executeDrawing(job, { pool: options.pool, signal: job.control.signal, ffmpegPath: options.ffmpegPath, perf,
|
|
104
|
+
publishExtraction: (action) => options.publishExtraction ? options.publishExtraction(job, action) : action(),
|
|
105
|
+
pauseRequested: () => job.pauseRequested, checkpoint,
|
|
106
|
+
update: (patch) => { Object.assign(job, patch); job.updatedAt = Date.now() },
|
|
107
|
+
})
|
|
108
|
+
if (job.control.signal.aborted) throw new Error('已取消任务')
|
|
109
|
+
if (!output) { job.status = 'paused'; job.message = '已在完整帧边界暂停'; checkpoint(); return }
|
|
110
|
+
job.resultPath = join(job.directory, 'result.json'); atomic(job.resultPath, output)
|
|
111
|
+
if (output.svg) writeFileSync(join(job.directory, 'result.svg'), output.svg)
|
|
112
|
+
job.status = 'succeeded'; job.delivery = job.kind === 'extract' ? 'applied' : 'pending'; job.finishedAt = Date.now()
|
|
113
|
+
job.phase = 'finished'; job.message = '绘制产物已保存'; checkpoint()
|
|
114
|
+
rmSync(job.input, { force: true })
|
|
115
|
+
release?.(); release = null
|
|
116
|
+
if (job.kind === 'extract') await options.extracted?.(job, output)
|
|
117
|
+
else job.deliveryPromise = deliver(job).catch((error) => { job.delivery = 'conflict'; job.error = String(error.message || error) })
|
|
118
|
+
} catch (error) {
|
|
119
|
+
job.status = closed ? 'interrupted' : job.control.signal.aborted ? 'cancelled' : 'failed'
|
|
120
|
+
job.error = job.status === 'cancelled' ? '' : String(error.message || error)
|
|
121
|
+
job.finishedAt = Date.now(); checkpoint()
|
|
122
|
+
await options.failed?.(job)
|
|
123
|
+
} finally { release?.(); perf.count('committedFrames', job.done || 0); perf.finish(job.status, { count: job.total, width: job.plan?.w, height: job.plan?.h }) }
|
|
124
|
+
}
|
|
125
|
+
function pump() {
|
|
126
|
+
if (closed || active || pumping) return
|
|
127
|
+
pumping = true
|
|
128
|
+
queueMicrotask(() => {
|
|
129
|
+
pumping = false
|
|
130
|
+
if (closed || active) return
|
|
131
|
+
const job = [...jobs.values()].filter((item) => item.status === 'queued' && !paused.get(ownerKey(item.scope)))
|
|
132
|
+
.sort((a, b) => a.order - b.order)[0]
|
|
133
|
+
if (!job) return
|
|
134
|
+
active = job; job.promise = run(job).finally(() => { active = null; pump() })
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
async function enqueue(scope, data, input) {
|
|
138
|
+
if (closed) throw new Error('绘制服务已关闭')
|
|
139
|
+
load(scope)
|
|
140
|
+
const history = [...jobs.values()].filter((job) => ownerKey(job.scope) === ownerKey(scope) &&
|
|
141
|
+
['succeeded', 'failed', 'cancelled'].includes(job.status) && job.delivery !== 'conflict' && !(job.status === 'succeeded' && job.delivery === 'pending')).sort((a, b) => a.order - b.order)
|
|
142
|
+
for (const job of history.slice(0, Math.max(0, history.length - 63))) {
|
|
143
|
+
jobs.delete(job.id); rmSync(job.directory, { recursive: true, force: true })
|
|
144
|
+
}
|
|
145
|
+
const optionsKey = JSON.stringify(data.options)
|
|
146
|
+
const same = [...jobs.values()].find((job) => ownerKey(job.scope) === ownerKey(scope) &&
|
|
147
|
+
fileIdentity(job.scope.drawioAbs) === fileIdentity(scope.drawioAbs) && (scope.pageId ? job.scope.pageId === scope.pageId : job.scope.page === scope.page) &&
|
|
148
|
+
job.source.signature === data.source.signature && job.source.id === data.source.id && job.kind === data.kind &&
|
|
149
|
+
JSON.stringify(job.options) === optionsKey && ['queued', 'running', 'paused'].includes(job.status))
|
|
150
|
+
if (same) return view(same)
|
|
151
|
+
const pending = [...jobs.values()].filter((job) => !['succeeded', 'failed', 'cancelled'].includes(job.status))
|
|
152
|
+
if (pending.length >= 16) throw new Error('队列已满(最多 16 个未完成任务)')
|
|
153
|
+
const bytes = Buffer.isBuffer(input) ? input.length : statSync(input).size
|
|
154
|
+
const retainedBytes = [...jobs.values()].filter((job) => job.status !== 'succeeded' && existsSync(job.input)).reduce((sum, job) => sum + job.inputBytes, 0)
|
|
155
|
+
if (bytes > 64 * 1024 * 1024) throw new Error('单个绘制素材不能超过 64MiB')
|
|
156
|
+
if (retainedBytes + bytes > 256 * 1024 * 1024) throw new Error('任务素材缓存超出 256MiB 预算,请移除已结束记录或完成部分任务')
|
|
157
|
+
const id = randomUUID(), directory = join(rootOf(scope), id); mkdirSync(directory)
|
|
158
|
+
const job = { id, scope: { ...scope }, ...data, algorithm, directory, input: join(directory, 'input.bin'), inputBytes: bytes,
|
|
159
|
+
createdAt: Date.now(), order: nextOrder(), status: 'queued', delivery: 'pending', phase: 'queued', done: 0, total: 1, control: new AbortController() }
|
|
160
|
+
try {
|
|
161
|
+
if (Buffer.isBuffer(input)) writeFileSync(job.input, input)
|
|
162
|
+
else await copyFile(input, job.input)
|
|
163
|
+
job.inputHash = digest(await readFile(job.input)); persist(job); jobs.set(id, job); pump(); return view(job)
|
|
164
|
+
} catch (error) { rmSync(directory, { recursive: true, force: true }); throw error }
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
enqueue,
|
|
168
|
+
recordCommit(job, node) { job.createdNode = node; persist(job) },
|
|
169
|
+
list(scope) { load(scope); return [...jobs.values()].filter((job) => ownerKey(job.scope) === ownerKey(scope)).sort((a, b) => a.order - b.order).map(view) },
|
|
170
|
+
result(scope, id) { const job = owned(scope, id); if (!job.resultPath || !existsSync(job.resultPath)) throw new Error('结果尚未生成'); return { job: view(job), output: JSON.parse(readFileSync(job.resultPath, 'utf8')) } },
|
|
171
|
+
async command(scope, id, action, value) {
|
|
172
|
+
load(scope)
|
|
173
|
+
if (action === 'pause-queue') {
|
|
174
|
+
paused.set(ownerKey(scope), value === true); atomic(policyPath(scope), { paused: value === true }); pump(); return { queuePaused: value === true }
|
|
175
|
+
}
|
|
176
|
+
const job = owned(scope, id)
|
|
177
|
+
if (action === 'pause') {
|
|
178
|
+
if (job.status === 'queued') job.status = 'paused'
|
|
179
|
+
else if (job.status === 'running') { job.pauseRequested = true; job.message = '等待在途帧完成后暂停' }
|
|
180
|
+
} else if (action === 'resume' || action === 'retry') {
|
|
181
|
+
if (!['paused', 'failed', 'cancelled', 'interrupted'].includes(job.status)) throw new Error('当前任务不可继续')
|
|
182
|
+
if (job.algorithm !== algorithm) throw new Error('算法已更新,请选择重新开始')
|
|
183
|
+
job.status = 'queued'; job.error = ''; job.pauseRequested = false; job.order = nextOrder()
|
|
184
|
+
} else if (action === 'restart') {
|
|
185
|
+
if (!terminal(job) || job.status === 'succeeded') throw new Error('请先停止当前任务')
|
|
186
|
+
job.status = 'cancelled'; persist(job)
|
|
187
|
+
return enqueue(job.scope, { kind: job.kind, label: job.label, source: job.source, options: job.options }, job.input)
|
|
188
|
+
} else if (action === 'cancel') {
|
|
189
|
+
if (job.status === 'running') { job.status = 'cancelling'; job.control.abort(); options.pool.cancelToken(job.id) }
|
|
190
|
+
else if (!['succeeded', 'failed'].includes(job.status)) job.status = 'cancelled'
|
|
191
|
+
} else if (action === 'apply') { await job.deliveryPromise; await deliver(job) }
|
|
192
|
+
else if (action === 'undo') {
|
|
193
|
+
await job.deliveryPromise
|
|
194
|
+
if (job.delivery !== 'applied' || job.kind === 'extract') throw new Error('此任务没有可撤销的生成节点')
|
|
195
|
+
await options.undo(job); job.delivery = 'undone'; job.error = ''
|
|
196
|
+
} else if (action === 'remove') {
|
|
197
|
+
if (!terminal(job)) throw new Error('请先取消运行中的任务')
|
|
198
|
+
await job.deliveryPromise
|
|
199
|
+
jobs.delete(job.id); rmSync(job.directory, { recursive: true, force: true }); return { removed: true }
|
|
200
|
+
} else if (action === 'move') {
|
|
201
|
+
if (job.status !== 'queued') throw new Error('只有等待中的任务可以排序')
|
|
202
|
+
const waiting = [...jobs.values()].filter((item) => ownerKey(item.scope) === ownerKey(scope) && item.status === 'queued').sort((a, b) => a.order - b.order)
|
|
203
|
+
const next = waiting[waiting.indexOf(job) + (value < 0 ? -1 : 1)]
|
|
204
|
+
if (next) { const order = job.order; job.order = next.order; next.order = order; persist(next) }
|
|
205
|
+
} else throw new Error('未知队列操作')
|
|
206
|
+
persist(job); pump(); return view(job)
|
|
207
|
+
},
|
|
208
|
+
leases(drawioAbs, scope) {
|
|
209
|
+
const clips = [...jobs.values()].filter((job) => fileIdentity(job.scope.drawioAbs) === fileIdentity(drawioAbs)).flatMap((job) => [job.source.clip, job.outputClip])
|
|
210
|
+
if (scope) {
|
|
211
|
+
const root = rootOf(scope)
|
|
212
|
+
for (const name of readdirSync(root)) if (/^[a-f0-9-]{36}$/.test(name)) {
|
|
213
|
+
try {
|
|
214
|
+
const directory = join(root, name)
|
|
215
|
+
if (!contained(root, directory)) continue
|
|
216
|
+
const job = JSON.parse(readFileSync(join(directory, 'job.json'), 'utf8'))
|
|
217
|
+
if (fileIdentity(job.scope.drawioAbs) === fileIdentity(drawioAbs)) clips.push(job.source.clip, job.outputClip)
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return new Set(clips.filter(Boolean))
|
|
222
|
+
},
|
|
223
|
+
async close() {
|
|
224
|
+
closed = true
|
|
225
|
+
if (active) { active.control.abort(); options.pool.cancelToken(active.id); await active.promise }
|
|
226
|
+
await Promise.allSettled([...jobs.values()].map((job) => job.deliveryPromise))
|
|
227
|
+
for (const job of jobs.values()) if (job.status === 'queued') { job.status = 'interrupted'; persist(job) }
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|