dsh-drawai 0.3.0 → 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,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
+ }