dsh-drawai 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/client.js +311 -94
- package/lib/drawing-results.js +36 -0
- package/lib/drawing-runner.js +213 -0
- package/lib/drawing-tasks.js +230 -0
- package/lib/index.js +216 -23
- package/lib/media.js +5 -2
- package/lib/mxfile.js +3 -1
- package/lib/performance-log.js +1 -1
- package/lib/task-coordinator.js +54 -0
- package/lib/task-patches.js +59 -0
- package/lib/video-export-worker.js +58 -0
- package/lib/video-export.js +165 -54
- package/lib/video-trace-pool.js +12 -2
- package/lib/video-trace-worker.js +6 -4
- package/package.json +5 -2
package/lib/index.js
CHANGED
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
|
|
17
17
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
18
18
|
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
19
|
-
import { existsSync, rmSync, writeFileSync, appendFileSync, renameSync } from 'node:fs'
|
|
20
|
-
import { randomUUID } from 'node:crypto'
|
|
21
|
-
import {
|
|
19
|
+
import { existsSync, rmSync, writeFileSync, appendFileSync, renameSync, realpathSync } from 'node:fs'
|
|
20
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
21
|
+
import { createDocumentCoordinator, createMediaWorkSlots } from './task-coordinator.js'
|
|
22
|
+
import { createDrawingTaskManager } from './drawing-tasks.js'
|
|
23
|
+
import { applyDrawingResult } from './drawing-results.js'
|
|
24
|
+
import { createTaskPatchJournal, applyTaskPatches } from './task-patches.js'
|
|
25
|
+
import { isAbsolute, resolve as resolvePath, join as joinPath, dirname as dirnamePath, basename as basenamePath, relative as relativePath } from 'node:path'
|
|
22
26
|
import { createVideoTracePool } from './video-trace-pool.js'
|
|
23
27
|
import { videoTraceOptions, exactVideoFrameKey } from './video-trace-reuse.js'
|
|
24
28
|
import { createPerformanceLog } from './performance-log.js'
|
|
@@ -31,7 +35,6 @@ import {
|
|
|
31
35
|
resolveTraceOptions,
|
|
32
36
|
summarizeTraceForAi,
|
|
33
37
|
toLosslessJson,
|
|
34
|
-
traceRasterToSvg,
|
|
35
38
|
traceRasterForToolbar,
|
|
36
39
|
} from './trace-image.js'
|
|
37
40
|
import {
|
|
@@ -962,7 +965,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
962
965
|
} catch (error) {
|
|
963
966
|
throw new Error(where + ': ' + messageOf(error))
|
|
964
967
|
}
|
|
965
|
-
const traced =
|
|
968
|
+
const traced = await opctx.traceRaster(tracedFrom, resolved.options)
|
|
966
969
|
const metrics = traced.metrics
|
|
967
970
|
if (traced.svg.length > SVG_MARKUP_LIMIT) {
|
|
968
971
|
throw new Error(
|
|
@@ -1874,8 +1877,8 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1874
1877
|
stills: stills.length,
|
|
1875
1878
|
plan:
|
|
1876
1879
|
plan !== null
|
|
1877
|
-
? Object.assign({}, plan, { note: '
|
|
1878
|
-
: { every: every, max: max, note: '带上 seconds/fps/width
|
|
1880
|
+
? Object.assign({}, plan, { note: '已交给宿主后台抽帧,完成后写入 stills/' })
|
|
1881
|
+
: { every: every, max: max, note: '带上 seconds/fps/width 提交宿主抽帧任务' },
|
|
1879
1882
|
})
|
|
1880
1883
|
// 计划由 applyOps 收集、由外层挂到画布轮询上(见 diagram_apply 尾部)。
|
|
1881
1884
|
if (plan !== null) out.extract = Object.assign({ clip: resolved.meta.clip }, plan)
|
|
@@ -1905,7 +1908,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1905
1908
|
throw new Error(where + ': ' + messageOf(error) + ' —— 先 extractFrames 下发抽帧计划并等待 stills/ 写出')
|
|
1906
1909
|
}
|
|
1907
1910
|
const raster = await decodeRaster(png)
|
|
1908
|
-
const traced =
|
|
1911
|
+
const traced = await opctx.traceRaster(raster, op)
|
|
1909
1912
|
if (traced.svg.length > MAX_FRAME_SVG_CHARS) {
|
|
1910
1913
|
throw new Error(where + ': traced svg too long (' + traced.svg.length + ' > ' + MAX_FRAME_SVG_CHARS + ')')
|
|
1911
1914
|
}
|
|
@@ -2665,6 +2668,7 @@ async function listCanvases(root, cwd, dir) {
|
|
|
2665
2668
|
|
|
2666
2669
|
|
|
2667
2670
|
export function apply(ctx) {
|
|
2671
|
+
const documents = createDocumentCoordinator(), mediaSlots = createMediaWorkSlots(2), taskPatches = createTaskPatchJournal()
|
|
2668
2672
|
function sessionOf(sessionId) {
|
|
2669
2673
|
try {
|
|
2670
2674
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
|
|
@@ -2755,6 +2759,8 @@ export function apply(ctx) {
|
|
|
2755
2759
|
pageCount: parsed.pageCount,
|
|
2756
2760
|
pageIndex: parsed.pageIndex,
|
|
2757
2761
|
pageNames: parsed.pageNames,
|
|
2762
|
+
pageId: parsed.pageId,
|
|
2763
|
+
pageIds: parsed.pageIds,
|
|
2758
2764
|
}
|
|
2759
2765
|
}
|
|
2760
2766
|
|
|
@@ -3664,6 +3670,7 @@ export function apply(ctx) {
|
|
|
3664
3670
|
const out = {}
|
|
3665
3671
|
const opctx = {
|
|
3666
3672
|
drawioPath: loaded.absolute,
|
|
3673
|
+
traceRaster: (raster, options) => videoTracePool.runRaster(raster, { ...options, mode: 'raw-image' }),
|
|
3667
3674
|
ensureClips: [],
|
|
3668
3675
|
/**
|
|
3669
3676
|
* traceImage 的图片读取:走**同一个工作区解析器**(相对路径按会话根解析),
|
|
@@ -3732,7 +3739,7 @@ export function apply(ctx) {
|
|
|
3732
3739
|
exportRequests.set(sessionId, { requestId: requestId, absolute: loaded.absolute, format: out.export.format, name: out.export.name })
|
|
3733
3740
|
exportResults.delete(sessionId)
|
|
3734
3741
|
}
|
|
3735
|
-
//
|
|
3742
|
+
// The host owns extraction; the browser receives status only.
|
|
3736
3743
|
if (out.extract !== undefined && out.extract !== null) {
|
|
3737
3744
|
const requestId = 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
3738
3745
|
extractRequests.set(sessionId, {
|
|
@@ -3744,6 +3751,7 @@ export function apply(ctx) {
|
|
|
3744
3751
|
fps: out.extract.fps,
|
|
3745
3752
|
width: out.extract.width,
|
|
3746
3753
|
max: out.extract.max,
|
|
3754
|
+
host: true,
|
|
3747
3755
|
})
|
|
3748
3756
|
extractResults.delete(sessionId)
|
|
3749
3757
|
}
|
|
@@ -3755,6 +3763,7 @@ export function apply(ctx) {
|
|
|
3755
3763
|
Array.isArray(out.changed) && out.changed.length > 0 &&
|
|
3756
3764
|
ops.some((op) => op !== null && typeof op === 'object' && (op.op === 'setMediaMeta' || op.op === 'importMedia' || op.op === 'setFrame' || op.op === 'traceFrame'))
|
|
3757
3765
|
if (noDocEdit && mediaChangedDoc === false) {
|
|
3766
|
+
if (out.extract) await startHostExtraction(sessionId, loaded, out.extract)
|
|
3758
3767
|
// toLosslessJson:宿主对工具输出做 isJsonValue,自有属性值为 undefined / -0 会整次打回
|
|
3759
3768
|
// (表现为 `returned invalid output: value is not lossless JSON`)。
|
|
3760
3769
|
return toLosslessJson({
|
|
@@ -3809,6 +3818,7 @@ export function apply(ctx) {
|
|
|
3809
3818
|
if (revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0) {
|
|
3810
3819
|
notes.push('↩ 这次改动可以退回(还能退 ' + revertDepth(snapshotKey(sessionId, loaded.absolute)) + ' 步):界面上「编辑 → 撤销 AI 改动」')
|
|
3811
3820
|
}
|
|
3821
|
+
if (out.extract) await startHostExtraction(sessionId, loaded, out.extract)
|
|
3812
3822
|
return toLosslessJson({
|
|
3813
3823
|
path: loaded.absolute,
|
|
3814
3824
|
revision: doc.revision,
|
|
@@ -4070,7 +4080,7 @@ function sanitizeNewName(raw) {
|
|
|
4070
4080
|
}
|
|
4071
4081
|
const done = extractResults.get(sessionId)
|
|
4072
4082
|
if (done === undefined || done.absolute !== absolute) return undefined
|
|
4073
|
-
if (done.error
|
|
4083
|
+
if (done.error?.length > 0) return { status: 'failed', clip: done.clip, written: done.written, error: done.error }
|
|
4074
4084
|
return { status: 'done', clip: done.clip, written: done.written }
|
|
4075
4085
|
}
|
|
4076
4086
|
|
|
@@ -4155,7 +4165,8 @@ function sanitizeNewName(raw) {
|
|
|
4155
4165
|
// (format v4 message requires a producer-owned source kind)。
|
|
4156
4166
|
// 目标形是 `{ kind: 'plugin:<名>' }`(V3→V4 迁移对旧 wrapper 也写成这样),
|
|
4157
4167
|
// 对 user/message 注入在 V3/V4 都可过;不带 form —— 它只是"环境事实"。
|
|
4158
|
-
|
|
4168
|
+
// 消息 id 会落到 user/message 的 data.id;缺失会导致持久化后的会话读取校验失败。
|
|
4169
|
+
agent.inject({ id: randomUUID(), role: 'user', content: [{ type: 'text', text: text }], source: { kind: 'plugin:' + name } })
|
|
4159
4170
|
} catch (error) {
|
|
4160
4171
|
// 注入失败不该影响画布本身:工具那条回退链还在。
|
|
4161
4172
|
}
|
|
@@ -4181,8 +4192,136 @@ function sanitizeNewName(raw) {
|
|
|
4181
4192
|
// 临时复刻包只由持有本次令牌的会话管理,取消不触及已有媒体。
|
|
4182
4193
|
const videoTraceJobs = new Map()
|
|
4183
4194
|
const videoUploadJobs = new Map()
|
|
4184
|
-
const videoExports = createVideoExportManager()
|
|
4195
|
+
const videoExports = createVideoExportManager({ slots: mediaSlots })
|
|
4185
4196
|
const videoTracePool = createVideoTracePool()
|
|
4197
|
+
const drawingTasks = createDrawingTaskManager({ pool: videoTracePool, slots: mediaSlots,
|
|
4198
|
+
commit: commitDrawingTask, undo: undoDrawingTask,
|
|
4199
|
+
publishExtraction: (job, action) => documents.run(job.scope.drawioAbs, async () => {
|
|
4200
|
+
const loaded = await loadTaskDocument(job)
|
|
4201
|
+
const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
|
|
4202
|
+
if (videoMetaFromData(source?.data)?.clip !== job.source.clip) throw new Error('抽帧来源已更改,结果已保留在任务目录')
|
|
4203
|
+
await checkVideoInput(job)
|
|
4204
|
+
return action()
|
|
4205
|
+
}),
|
|
4206
|
+
extracted: (job, output) => {
|
|
4207
|
+
extractRequests.delete(job.scope.sessionId)
|
|
4208
|
+
extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: output.written, taskId: job.id })
|
|
4209
|
+
},
|
|
4210
|
+
failed: (job) => {
|
|
4211
|
+
if (job.kind === 'extract') {
|
|
4212
|
+
extractRequests.delete(job.scope.sessionId)
|
|
4213
|
+
extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: job.done, error: job.error || '抽帧已取消' })
|
|
4214
|
+
}
|
|
4215
|
+
},
|
|
4216
|
+
})
|
|
4217
|
+
const signature = (value) => createHash('sha256').update(value).digest('hex')
|
|
4218
|
+
const stableValue = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
4219
|
+
? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item)
|
|
4220
|
+
async function loadTaskDocument(job) {
|
|
4221
|
+
let loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, job.scope.page)
|
|
4222
|
+
if (!loaded.exists) throw new Error('原画布已删除,结果已保留')
|
|
4223
|
+
if (job.scope.pageId) {
|
|
4224
|
+
const page = loaded.pageIds.indexOf(job.scope.pageId)
|
|
4225
|
+
if (page < 0 || loaded.pageIds.filter((id) => id === job.scope.pageId).length !== 1) throw new Error('原页面已删除或身份冲突,结果已保留')
|
|
4226
|
+
if (page !== loaded.pageIndex) loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, page)
|
|
4227
|
+
} else if (JSON.stringify(loaded.pageNames) !== JSON.stringify(job.scope.pageNames)) throw new Error('页面身份已改变,结果已保留')
|
|
4228
|
+
return loaded
|
|
4229
|
+
}
|
|
4230
|
+
async function writeTaskDocument(job, loaded, node, removed = []) {
|
|
4231
|
+
const policy = policyFor(job.scope.sessionId)
|
|
4232
|
+
if (policy?.mode === 'read-only') throw new Error('当前会话为只读,绘制结果已保留')
|
|
4233
|
+
const rendered = renderDocText(loaded.text, loaded.doc, { page: loaded.pageIndex })
|
|
4234
|
+
// The coordinator serializes plugin writers; a fresh check also detects
|
|
4235
|
+
// external edits which happened while an asynchronous source check ran.
|
|
4236
|
+
if (contentHash(await ctx.fs.readText(loaded.target)) !== contentHash(loaded.text)) throw new Error('文件已被其他编辑器修改,请重试加入结果')
|
|
4237
|
+
const savedNode = node ? normalizeDoc(parseMxfile(rendered.text, { page: loaded.pageIndex }).doc).nodes.find((item) => item.id === node.id) : null
|
|
4238
|
+
if (savedNode) drawingTasks.recordCommit(job, savedNode)
|
|
4239
|
+
await ctx.fs.writeText(loaded.target, rendered.text, undefined, undefined, policy)
|
|
4240
|
+
taskPatches.record(loaded.absolute, contentHash(loaded.text), rendered.revision, loaded.pageIndex, savedNode ? [savedNode] : [], removed, removed.length && job.createdNode ? [job.createdNode] : [])
|
|
4241
|
+
return { nodeId: savedNode?.id, node: savedNode, page: loaded.pageIndex }
|
|
4242
|
+
}
|
|
4243
|
+
async function commitDrawingTask(job, output) {
|
|
4244
|
+
return documents.run(job.scope.drawioAbs, async () => {
|
|
4245
|
+
const loaded = await loadTaskDocument(job)
|
|
4246
|
+
const existing = loaded.doc.nodes.find((node) => node.data?.drawaiTaskId === job.id)
|
|
4247
|
+
if (existing) return { nodeId: existing.id, node: job.createdNode, page: loaded.pageIndex }
|
|
4248
|
+
const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
|
|
4249
|
+
if (!source) throw new Error('来源节点已删除,结果已保留')
|
|
4250
|
+
const image = job.kind === 'image' ? imageValueFromStyle(source.style || '') : ''
|
|
4251
|
+
if (job.kind === 'image' && signature(image) !== job.source.signature) throw new Error('原图已更改,结果已保留')
|
|
4252
|
+
if (job.kind === 'video') {
|
|
4253
|
+
await checkVideoInput(job)
|
|
4254
|
+
}
|
|
4255
|
+
const node = applyDrawingResult(loaded.doc, { ...job, source: { ...job.source, image } }, output)
|
|
4256
|
+
loaded.doc.meta = { ...loaded.doc.meta, pinned: true }
|
|
4257
|
+
return writeTaskDocument(job, loaded, node)
|
|
4258
|
+
})
|
|
4259
|
+
}
|
|
4260
|
+
async function checkVideoInput(job) {
|
|
4261
|
+
const dir = clipDirFor(job.scope.drawioAbs, job.source.clip), original = findSourceFile(dir, readManifest(dir))
|
|
4262
|
+
if (!original) throw new Error('原视频已删除,结果已保留')
|
|
4263
|
+
const rel = relativePath(realpathSync(dir), realpathSync(original.abs))
|
|
4264
|
+
if (isAbsolute(rel) || rel.startsWith('..') || signature(await readFile(original.abs)) !== job.inputHash) throw new Error('原视频内容或路径已更改,结果已保留')
|
|
4265
|
+
}
|
|
4266
|
+
async function undoDrawingTask(job) {
|
|
4267
|
+
return documents.run(job.scope.drawioAbs, async () => {
|
|
4268
|
+
const loaded = await loadTaskDocument(job)
|
|
4269
|
+
const node = loaded.doc.nodes.find((item) => item.data?.drawaiTaskId === job.id)
|
|
4270
|
+
if (!node) return
|
|
4271
|
+
if (stableValue(node) !== stableValue(job.createdNode)) throw new Error('生成节点已被修改,请在画布中撤销或删除,避免丢失后续编辑')
|
|
4272
|
+
if (loaded.doc.edges.some((edge) => edge.from === node.id || edge.to === node.id)) throw new Error('生成节点已连接新连线,请在画布中处理')
|
|
4273
|
+
loaded.doc.nodes = loaded.doc.nodes.filter((item) => item.id !== node.id)
|
|
4274
|
+
loaded.doc.drawOrder = (loaded.doc.drawOrder || []).filter((id) => id !== node.id)
|
|
4275
|
+
await writeTaskDocument(job, loaded, null, [node.id])
|
|
4276
|
+
})
|
|
4277
|
+
}
|
|
4278
|
+
async function enqueueDrawing(scope, body) {
|
|
4279
|
+
return documents.run(scope.drawioAbs, async () => {
|
|
4280
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读,无法创建绘制任务')
|
|
4281
|
+
if (!Number.isInteger(body.page ?? 0) || (body.page ?? 0) < 0) throw new Error('页面位置无效')
|
|
4282
|
+
const loaded = await loadDoc(scope.drawioAbs, scope.sessionId, body.page || 0)
|
|
4283
|
+
if (loaded.pageIndex !== (body.page || 0)) throw new Error('页面已不存在')
|
|
4284
|
+
if (!loaded.exists) throw new Error('请先创建画布')
|
|
4285
|
+
if (body.revision && body.revision !== loaded.doc.revision) throw new Error('画布版本已更新,请等待同步后重试')
|
|
4286
|
+
const kind = body.kind
|
|
4287
|
+
if (!['image', 'video', 'extract'].includes(kind)) throw new Error('未知绘制类型')
|
|
4288
|
+
const source = loaded.doc.nodes.find((node) => node.id === body.sourceId)
|
|
4289
|
+
if (!source) throw new Error('来源节点不存在')
|
|
4290
|
+
const options = { level: 'medium', preset: 'default', colors: 12, removeBg: false, ...(body.options || {}) }
|
|
4291
|
+
if (!['low', 'medium', 'high'].includes(options.level) || !TRACE_PRESET_NAMES.includes(options.preset) ||
|
|
4292
|
+
!Number.isInteger(Number(options.colors)) || options.colors < 2 || options.colors > 24 || typeof options.removeBg !== 'boolean') throw new Error('绘制参数无效')
|
|
4293
|
+
const taskScope = { ...scope, page: loaded.pageIndex, pageId: loaded.pageId, pageNames: loaded.pageNames,
|
|
4294
|
+
pageName: loaded.pageNames[loaded.pageIndex] }
|
|
4295
|
+
let input, taskSource
|
|
4296
|
+
if (kind === 'image') {
|
|
4297
|
+
const image = imageValueFromStyle(source.style || '')
|
|
4298
|
+
if (!isRasterDataUri(image)) throw new Error('请选择位图来源')
|
|
4299
|
+
input = Buffer.from(image.slice(image.indexOf(',') + 1), 'base64')
|
|
4300
|
+
taskSource = { id: source.id, signature: signature(image) }
|
|
4301
|
+
} else {
|
|
4302
|
+
const meta = videoMetaFromData(source.data)
|
|
4303
|
+
if (meta?.kind !== 'raster') throw new Error('请选择原视频来源')
|
|
4304
|
+
const dir = clipDirFor(scope.drawioAbs, meta.clip), original = findSourceFile(dir, readManifest(dir))
|
|
4305
|
+
if (!original) throw new Error('原视频文件缺失')
|
|
4306
|
+
const mediaRel = relativePath(realpathSync(scope.workspaceRoot), realpathSync(dir))
|
|
4307
|
+
if (isAbsolute(mediaRel) || mediaRel.startsWith('..')) throw new Error('视频媒体目录超出工作区')
|
|
4308
|
+
const file = realpathSync(original.abs), rel = relativePath(realpathSync(dir), file)
|
|
4309
|
+
if (isAbsolute(rel) || rel.startsWith('..')) throw new Error('原视频路径超出媒体包')
|
|
4310
|
+
input = file
|
|
4311
|
+
taskSource = { id: source.id, clip: meta.clip, extension: /\.[a-z0-9]+$/i.exec(file)?.[0] || '.mp4', signature: meta.clip + ':' + signature(await readFile(file)) }
|
|
4312
|
+
}
|
|
4313
|
+
return drawingTasks.enqueue(taskScope, { kind, options, source: taskSource, label: source.label || (kind === 'image' ? '图片' : '视频') + ' · ' + source.id }, input)
|
|
4314
|
+
})
|
|
4315
|
+
}
|
|
4316
|
+
async function startHostExtraction(sessionId, loaded, plan) {
|
|
4317
|
+
const current = await loadDoc(loaded.absolute, sessionId, loaded.pageIndex)
|
|
4318
|
+
const source = current.doc.nodes.find((node) => videoMetaFromData(node.data)?.clip === plan.clip)
|
|
4319
|
+
if (!source) throw new Error('抽帧来源节点已不存在')
|
|
4320
|
+
const job = await enqueueDrawing({ sessionId, path: loaded.absolute, drawioAbs: loaded.absolute, workspaceRoot: workspaceRootOf(sessionId) },
|
|
4321
|
+
{ kind: 'extract', sourceId: source.id, page: current.pageIndex, options: { ...plan, range: 'segment' } })
|
|
4322
|
+
const pending = extractRequests.get(sessionId)
|
|
4323
|
+
if (pending) pending.taskId = job.id
|
|
4324
|
+
}
|
|
4186
4325
|
const wakeVideoTrace = (job) => {
|
|
4187
4326
|
const waiters = job.waiters || []
|
|
4188
4327
|
job.waiters = []
|
|
@@ -4215,9 +4354,8 @@ function sanitizeNewName(raw) {
|
|
|
4215
4354
|
job.cancelled = true
|
|
4216
4355
|
wakeVideoTrace(job)
|
|
4217
4356
|
}
|
|
4218
|
-
videoTracePool.close()
|
|
4219
4357
|
videoTraceJobs.clear(); videoUploadJobs.clear()
|
|
4220
|
-
return videoExports.dispose()
|
|
4358
|
+
return Promise.all([drawingTasks.close(), videoExports.dispose()]).finally(() => videoTracePool.close())
|
|
4221
4359
|
})
|
|
4222
4360
|
async function handleSave(req, res) {
|
|
4223
4361
|
if (req.method !== 'POST') {
|
|
@@ -4269,9 +4407,35 @@ function sanitizeNewName(raw) {
|
|
|
4269
4407
|
return
|
|
4270
4408
|
}
|
|
4271
4409
|
|
|
4410
|
+
try {
|
|
4411
|
+
const lockAction = body.action === undefined || ['create', 'revert', 'media-cleanup', 'media-video-export-start'].includes(body.action)
|
|
4412
|
+
if (lockAction && typeof (body.path || body.name) === 'string' && workspaceRootOf(body.sessionId)) {
|
|
4413
|
+
const target = await resolveTarget(body.path || body.name, body.sessionId)
|
|
4414
|
+
return await documents.run(absoluteHint(target, body.path || body.name), () => dispatchSave(body, res))
|
|
4415
|
+
}
|
|
4416
|
+
return await dispatchSave(body, res)
|
|
4417
|
+
} catch (error) { sendJson(res, 400, { ok: false, error: messageOf(error) }) }
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
async function dispatchSave(body, res) {
|
|
4421
|
+
|
|
4272
4422
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
|
|
4273
4423
|
const rawPath = typeof body.path === 'string' ? body.path : undefined
|
|
4274
4424
|
|
|
4425
|
+
if (typeof body.action === 'string' && body.action.startsWith('drawing-task-')) {
|
|
4426
|
+
if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !rawPath?.toLowerCase().endsWith('.drawio')) throw new Error('绘制任务需要当前工作区画布')
|
|
4427
|
+
const target = await resolveTarget(rawPath, sessionId), root = workspaceRootOf(sessionId)
|
|
4428
|
+
if (!ctx.fs.contains(await ctx.fs.resolve(root), target)) throw new Error('画布路径超出工作区')
|
|
4429
|
+
const scope = { sessionId, path: rawPath, drawioAbs: absoluteHint(target, rawPath), workspaceRoot: root }
|
|
4430
|
+
let result
|
|
4431
|
+
if (body.action === 'drawing-task-start') result = { job: await enqueueDrawing(scope, body) }
|
|
4432
|
+
else if (body.action === 'drawing-task-list') result = { jobs: drawingTasks.list(scope) }
|
|
4433
|
+
else if (body.action === 'drawing-task-result') result = drawingTasks.result(scope, body.taskId)
|
|
4434
|
+
else if (body.action === 'drawing-task-command') result = { job: await drawingTasks.command(scope, body.taskId, body.command, body.value) }
|
|
4435
|
+
else throw new Error('未知绘制任务操作')
|
|
4436
|
+
sendJson(res, 200, { ok: true, ...result }); return
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4275
4439
|
// 仅计算图像节点的 SVG;由客户端通过同一历史/保存通道应用结果。
|
|
4276
4440
|
if (body.action === 'trace-node-image') {
|
|
4277
4441
|
if (sessionId === undefined || sessionOf(sessionId) === undefined || workspaceRootOf(sessionId) === undefined) {
|
|
@@ -4449,7 +4613,7 @@ function sanitizeNewName(raw) {
|
|
|
4449
4613
|
// AI 下发的抽帧计划同理:画布取走后用浏览器解码视频、写 stills/,完成后 POST 回 media-extract-result。
|
|
4450
4614
|
const pendingExtract = extractRequests.get(sessionId)
|
|
4451
4615
|
const extractForThis =
|
|
4452
|
-
pendingExtract !== undefined && pendingExtract.absolute === loaded.absolute
|
|
4616
|
+
pendingExtract !== undefined && !pendingExtract.host && pendingExtract.absolute === loaded.absolute
|
|
4453
4617
|
? {
|
|
4454
4618
|
requestId: pendingExtract.requestId,
|
|
4455
4619
|
clip: pendingExtract.clip,
|
|
@@ -4471,6 +4635,8 @@ function sanitizeNewName(raw) {
|
|
|
4471
4635
|
pageCount: loaded.pageCount,
|
|
4472
4636
|
pageIndex: loaded.pageIndex,
|
|
4473
4637
|
pageNames: loaded.pageNames,
|
|
4638
|
+
backgroundPatch: typeof body.revision === 'string' && body.revision !== loaded.doc.revision
|
|
4639
|
+
? taskPatches.between(loaded.absolute, body.revision, loaded.doc.revision, loaded.pageIndex) : null,
|
|
4474
4640
|
highlight: Array.isArray(pendingHighlight) ? pendingHighlight : [],
|
|
4475
4641
|
canRevert: revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0,
|
|
4476
4642
|
// 用户当前的选区(客户端可选地用来做提示;AI 侧由工具读回)。
|
|
@@ -4589,10 +4755,12 @@ function sanitizeNewName(raw) {
|
|
|
4589
4755
|
}
|
|
4590
4756
|
let revertTarget
|
|
4591
4757
|
let revertAbsolute
|
|
4758
|
+
let revertCurrent
|
|
4592
4759
|
try {
|
|
4593
4760
|
const loaded = await loadDoc(rawPath, sessionId)
|
|
4594
4761
|
revertTarget = loaded.target
|
|
4595
4762
|
revertAbsolute = loaded.absolute
|
|
4763
|
+
revertCurrent = loaded.text
|
|
4596
4764
|
} catch (error) {
|
|
4597
4765
|
sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
|
|
4598
4766
|
return
|
|
@@ -4604,6 +4772,16 @@ function sanitizeNewName(raw) {
|
|
|
4604
4772
|
sendJson(res, 200, { ok: false, error: '这张画布没有可退回的 AI 改动了' })
|
|
4605
4773
|
return
|
|
4606
4774
|
}
|
|
4775
|
+
if (contentHash(revertCurrent) !== snapshot.revision) {
|
|
4776
|
+
const taskNodes = (text) => {
|
|
4777
|
+
const first = parseMxfile(text), nodes = []
|
|
4778
|
+
for (let page = 0; page < first.pageCount; page++) nodes.push(...parseMxfile(text, { page }).doc.nodes.filter((node) => node.data?.drawaiTaskId))
|
|
4779
|
+
return nodes
|
|
4780
|
+
}
|
|
4781
|
+
if (stableValue(taskNodes(revertCurrent)) !== stableValue(taskNodes(snapshot.text))) {
|
|
4782
|
+
sendJson(res, 409, { ok: false, error: 'AI 改动后已有后台生成或编辑的结果,请先在任务队列中撤销生成,避免整页回退丢失结果' }); return
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4607
4785
|
const policy = policyFor(sessionId)
|
|
4608
4786
|
try {
|
|
4609
4787
|
if (policy === undefined) await ctx.fs.writeText(revertTarget, snapshot.text)
|
|
@@ -4907,8 +5085,7 @@ function sanitizeNewName(raw) {
|
|
|
4907
5085
|
let result
|
|
4908
5086
|
switch (body.action) {
|
|
4909
5087
|
case 'media-video-export-start': result = await videoExports.start(scope, body.format); break
|
|
4910
|
-
case 'media-video-export-
|
|
4911
|
-
case 'media-video-export-frame': result = videoExports.frame(scope, body); break
|
|
5088
|
+
case 'media-video-export-list': result = { jobs: videoExports.list(scope) }; break
|
|
4912
5089
|
case 'media-video-export-finish': result = videoExports.finish(scope, body.token); break
|
|
4913
5090
|
case 'media-video-export-status': result = videoExports.status(scope, body.token); break
|
|
4914
5091
|
case 'media-video-export-read': result = videoExports.read(scope, body.token, body.offset); break
|
|
@@ -5336,8 +5513,15 @@ function sanitizeNewName(raw) {
|
|
|
5336
5513
|
return
|
|
5337
5514
|
}
|
|
5338
5515
|
if (body.action === 'media-cleanup') {
|
|
5339
|
-
const
|
|
5340
|
-
const
|
|
5516
|
+
const current = await loadDoc(drawioAbs, sessionId, 0)
|
|
5517
|
+
const nodes = [...(body.doc?.nodes || [])]
|
|
5518
|
+
for (let page = 0; page < current.pageNames.length; page++) {
|
|
5519
|
+
const parsed = page === 0 ? current : await loadDoc(drawioAbs, sessionId, page)
|
|
5520
|
+
nodes.push(...parsed.doc.nodes)
|
|
5521
|
+
}
|
|
5522
|
+
const leases = new Set([...drawingTasks.leases(drawioAbs, { workspaceRoot: mediaRoot }), ...videoExports.leases(drawioAbs)])
|
|
5523
|
+
for (const job of videoTraceJobs.values()) if (job.drawioAbs === drawioAbs) leases.add(job.clip)
|
|
5524
|
+
const removed = cleanupUnreferencedClips(drawioAbs, { nodes }, leases)
|
|
5341
5525
|
sendJson(res, 200, { ok: true, removed: removed })
|
|
5342
5526
|
return
|
|
5343
5527
|
}
|
|
@@ -5508,14 +5692,18 @@ function sanitizeNewName(raw) {
|
|
|
5508
5692
|
sendJson(res, 409, { ok: false, error: 'already exists', path: absolute })
|
|
5509
5693
|
return
|
|
5510
5694
|
}
|
|
5695
|
+
let backgroundPatch = null
|
|
5511
5696
|
if (baseRevision !== undefined && exists && contentHash(originalText) !== baseRevision) {
|
|
5512
|
-
|
|
5513
|
-
|
|
5697
|
+
backgroundPatch = taskPatches.between(absolute, baseRevision, contentHash(originalText), Number(body.page) || 0)
|
|
5698
|
+
if (!backgroundPatch) {
|
|
5699
|
+
sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) }); return
|
|
5700
|
+
}
|
|
5514
5701
|
}
|
|
5515
5702
|
|
|
5516
5703
|
// 人工编辑一律打 pinned:从此这张画布是人摆过版面的,AI 的自动布局不该再冲掉它。
|
|
5517
5704
|
// (空文件 → 从零生成;已有文件 → 无损写回,见 renderDocText。)
|
|
5518
5705
|
const next = normalizeDoc(doc)
|
|
5706
|
+
if (backgroundPatch) applyTaskPatches(next, backgroundPatch, true)
|
|
5519
5707
|
next.meta = Object.assign({}, next.meta, { pinned: true })
|
|
5520
5708
|
let rendered
|
|
5521
5709
|
try {
|
|
@@ -5544,13 +5732,18 @@ function sanitizeNewName(raw) {
|
|
|
5544
5732
|
// 媒体复制失败不回滚画布写入:海报仍在;播放时会报媒体包缺失。
|
|
5545
5733
|
}
|
|
5546
5734
|
}
|
|
5547
|
-
sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped })
|
|
5735
|
+
sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped, ...(backgroundPatch ? { backgroundPatch } : {}) })
|
|
5548
5736
|
}
|
|
5549
5737
|
|
|
5550
5738
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: handleSave }), 'drawai: ' + SAVE_PATH)
|
|
5551
5739
|
|
|
5552
5740
|
ctx.effect(() => ctx.tools.register(readTool))
|
|
5553
|
-
ctx.effect(() => ctx.tools.register(applyTool)
|
|
5741
|
+
ctx.effect(() => ctx.tools.register({ ...applyTool, execute: async (args, exec) => {
|
|
5742
|
+
const sessionId = sessionIdOf(exec)
|
|
5743
|
+
const path = typeof args.path === 'string' && args.path ? args.path : focusedPathFor(sessionId) || DEFAULT_PATH
|
|
5744
|
+
const target = await resolveTarget(path, sessionId)
|
|
5745
|
+
return documents.run(absoluteHint(target, path), () => applyTool.execute(args, exec))
|
|
5746
|
+
} }))
|
|
5554
5747
|
|
|
5555
5748
|
// 把"怎么用这张画布"注册成**内嵌技能**(见 SKILL_BODY 的注释):
|
|
5556
5749
|
// 换了电脑、只装了插件(没有源码/README/tools)时,模型依然知道该怎么操作。
|
package/lib/media.js
CHANGED
|
@@ -209,7 +209,9 @@ export function readManifest(clipDir) {
|
|
|
209
209
|
export function writeManifest(clipDir, manifest) {
|
|
210
210
|
mkdirSync(clipDir, { recursive: true })
|
|
211
211
|
const next = syncDuration(normalizeManifest(manifest))
|
|
212
|
-
|
|
212
|
+
const path = join(clipDir, MANIFEST_NAME)
|
|
213
|
+
writeFileSync(path + '.tmp', JSON.stringify(next, null, 2) + '\n', 'utf8')
|
|
214
|
+
renameSync(path + '.tmp', path)
|
|
213
215
|
return next
|
|
214
216
|
}
|
|
215
217
|
|
|
@@ -399,8 +401,9 @@ export function collectReferencedClips(doc) {
|
|
|
399
401
|
return used
|
|
400
402
|
}
|
|
401
403
|
|
|
402
|
-
export function cleanupUnreferencedClips(drawioPath, doc) {
|
|
404
|
+
export function cleanupUnreferencedClips(drawioPath, doc, protectedClips = []) {
|
|
403
405
|
const used = collectReferencedClips(doc)
|
|
406
|
+
for (const clip of protectedClips) used.add(clip)
|
|
404
407
|
const all = listClipIds(drawioPath)
|
|
405
408
|
const removed = []
|
|
406
409
|
for (let i = 0; i < all.length; i += 1) {
|
package/lib/mxfile.js
CHANGED
|
@@ -532,6 +532,7 @@ function analyzeMxfile(source, pageIndex) {
|
|
|
532
532
|
pageCount: diagrams.length,
|
|
533
533
|
pageIndex: idx,
|
|
534
534
|
pageNames: pageNames,
|
|
535
|
+
pageIds: diagrams.map((item) => attr(item[1], 'id') || null),
|
|
535
536
|
// 纸张尺寸(drawio 挂在 `mxGraphModel` 上的属性):AI 做布局/导出建议时要看它 ——
|
|
536
537
|
// 850×1100 是 drawio 的 A4 竖版缺省,但不代表用户这份文件就是那个尺寸。
|
|
537
538
|
pageSize: readPageSize(modelXml),
|
|
@@ -975,7 +976,8 @@ export function parseMxfile(text, options) {
|
|
|
975
976
|
if (existing !== null && existing.meta !== undefined && existing.meta !== null && existing.meta.pinned === true) {
|
|
976
977
|
doc.meta.pinned = true
|
|
977
978
|
}
|
|
978
|
-
return { doc: doc, notes: notes, pageCount: pageCount, pageIndex: info.pageIndex, pageNames: info.pageNames
|
|
979
|
+
return { doc: doc, notes: notes, pageCount: pageCount, pageIndex: info.pageIndex, pageNames: info.pageNames,
|
|
980
|
+
pageId: (info.pageIds || [])[info.pageIndex] || null, pageIds: info.pageIds || [null] }
|
|
979
981
|
}
|
|
980
982
|
|
|
981
983
|
// ---- 单元的 XML 生成(新单元、以及从零生成一份文件时共用)--------------------
|
package/lib/performance-log.js
CHANGED
|
@@ -11,7 +11,7 @@ function environmentInfo() {
|
|
|
11
11
|
if (!environment) {
|
|
12
12
|
const cpu = cpus()
|
|
13
13
|
let sourceSha256 = null
|
|
14
|
-
try { sourceSha256 = createHash('sha256').update(['trace-image.js', 'raster.js', 'video-trace-worker.js', 'video-trace-pool.js', 'video-trace-reuse.js', 'video-export.js']
|
|
14
|
+
try { sourceSha256 = createHash('sha256').update(['trace-image.js', 'raster.js', 'video-trace-worker.js', 'video-trace-pool.js', 'video-trace-reuse.js', 'video-export.js', 'video-export-worker.js']
|
|
15
15
|
.map((file) => readFileSync(new URL(file, import.meta.url))).join('')).digest('hex') } catch {}
|
|
16
16
|
environment = { node: process.version, platform: process.platform, arch: process.arch, cpu: cpu[0]?.model,
|
|
17
17
|
logicalCpus: cpu.length, totalMemoryBytes: totalmem(), sourceSha256 }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { realpathSync } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
export function createDocumentCoordinator() {
|
|
7
|
+
const tails = new Map(), local = new AsyncLocalStorage()
|
|
8
|
+
const keyOf = (path) => {
|
|
9
|
+
let key = resolve(path)
|
|
10
|
+
try { key = realpathSync(key) } catch {}
|
|
11
|
+
return process.platform === 'win32' ? key.toLowerCase() : key
|
|
12
|
+
}
|
|
13
|
+
return { keyOf, async run(path, action) {
|
|
14
|
+
const key = keyOf(path)
|
|
15
|
+
if (local.getStore() === key) return action()
|
|
16
|
+
const previous = tails.get(key) || Promise.resolve()
|
|
17
|
+
let release
|
|
18
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
19
|
+
const tail = previous.catch(() => {}).then(() => gate)
|
|
20
|
+
tails.set(key, tail)
|
|
21
|
+
await previous.catch(() => {})
|
|
22
|
+
try { return await local.run(key, action) }
|
|
23
|
+
finally { release(); if (tails.get(key) === tail) tails.delete(key) }
|
|
24
|
+
} }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A shared, abortable budget for user-level drawing and export jobs. Individual
|
|
28
|
+
// drawing tasks reuse the existing bounded frame Worker pool.
|
|
29
|
+
export function createMediaWorkSlots(limit = 2) {
|
|
30
|
+
let active = 0
|
|
31
|
+
const queue = []
|
|
32
|
+
function pump() {
|
|
33
|
+
while (active < limit && queue.length) {
|
|
34
|
+
const item = queue.shift()
|
|
35
|
+
item.signal?.removeEventListener('abort', item.abort)
|
|
36
|
+
if (item.signal?.aborted) { item.reject(new Error('已取消任务')); continue }
|
|
37
|
+
active++
|
|
38
|
+
let released = false
|
|
39
|
+
item.resolve(() => { if (!released) { released = true; active--; pump() } })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { acquire(signal) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const item = { signal, resolve, reject, abort: () => {
|
|
45
|
+
const index = queue.indexOf(item)
|
|
46
|
+
if (index >= 0) queue.splice(index, 1)
|
|
47
|
+
reject(new Error('已取消任务'))
|
|
48
|
+
} }
|
|
49
|
+
if (signal?.aborted) { reject(new Error('已取消任务')); return }
|
|
50
|
+
signal?.addEventListener('abort', item.abort, { once: true })
|
|
51
|
+
queue.push(item); pump()
|
|
52
|
+
})
|
|
53
|
+
} }
|
|
54
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
// Rebase only changes known to consist of generated-node patches. Unknown
|
|
7
|
+
// edits remain revision conflicts, never a license to overwrite a snapshot.
|
|
8
|
+
export function createTaskPatchJournal() {
|
|
9
|
+
const cache = new Map()
|
|
10
|
+
const fileOf = (path) => join(dirname(path), '.drawai-cache', 'task-patches', createHash('sha256').update(path.toLowerCase()).digest('hex') + '.json')
|
|
11
|
+
function entries(path) {
|
|
12
|
+
if (!cache.has(path)) {
|
|
13
|
+
let values = []
|
|
14
|
+
try { values = JSON.parse(readFileSync(fileOf(path), 'utf8')) } catch {}
|
|
15
|
+
cache.set(path, Array.isArray(values) ? values.slice(-32) : [])
|
|
16
|
+
}
|
|
17
|
+
return cache.get(path)
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
record(path, from, to, page, added = [], removed = [], removedNodes = []) {
|
|
21
|
+
const list = entries(path); list.push({ from, to, page, added, removed, removedNodes }); if (list.length > 32) list.shift()
|
|
22
|
+
const file = fileOf(path); mkdirSync(dirname(file), { recursive: true })
|
|
23
|
+
writeFileSync(file + '.tmp', JSON.stringify(list)); renameSync(file + '.tmp', file)
|
|
24
|
+
},
|
|
25
|
+
between(path, from, to, page) {
|
|
26
|
+
const list = entries(path), changes = []; let revision = from
|
|
27
|
+
for (let attempt = 0; revision !== to && attempt < 32; attempt++) {
|
|
28
|
+
const entry = list.find((item) => item.from === revision)
|
|
29
|
+
if (!entry) return null
|
|
30
|
+
if (entry.page === page) changes.push(entry)
|
|
31
|
+
revision = entry.to
|
|
32
|
+
}
|
|
33
|
+
if (revision !== to) return null
|
|
34
|
+
return { added: changes.flatMap((item) => item.added), removed: changes.flatMap((item) => item.removed), changes }
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function applyTaskPatches(doc, patch, strict = false) {
|
|
40
|
+
const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
41
|
+
? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item)
|
|
42
|
+
for (const change of patch.changes || [patch]) {
|
|
43
|
+
const removed = new Set(change.removed || [])
|
|
44
|
+
if (strict) {
|
|
45
|
+
for (const before of change.removedNodes || []) {
|
|
46
|
+
const current = doc.nodes.find((node) => node.id === before.id)
|
|
47
|
+
if (current && stable(current) !== stable(before)) throw new Error('后台撤销与本地节点编辑冲突,本地编辑已保留')
|
|
48
|
+
}
|
|
49
|
+
if ((doc.edges || []).some((edge) => removed.has(edge.from) || removed.has(edge.to))) throw new Error('后台撤销与本地连线冲突,本地编辑已保留')
|
|
50
|
+
}
|
|
51
|
+
doc.nodes = doc.nodes.filter((node) => !removed.has(node.id))
|
|
52
|
+
if (Array.isArray(doc.drawOrder)) doc.drawOrder = doc.drawOrder.filter((id) => !removed.has(id))
|
|
53
|
+
for (const node of change.added || []) if (!doc.nodes.some((item) => item.id === node.id)) {
|
|
54
|
+
doc.nodes.push(JSON.parse(JSON.stringify(node)))
|
|
55
|
+
if (Array.isArray(doc.drawOrder) && !doc.drawOrder.includes(node.id)) doc.drawOrder.push(node.id)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return doc
|
|
59
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
3
|
+
import { readFileSync, writeFileSync, realpathSync, statSync } from 'node:fs'
|
|
4
|
+
import { resolve, relative, isAbsolute, join } from 'node:path'
|
|
5
|
+
import { Resvg } from '@resvg/resvg-js'
|
|
6
|
+
import { MAX_FRAME_SVG_CHARS } from './media.js'
|
|
7
|
+
|
|
8
|
+
// Rendering must not acquire filesystem/network resources referenced by SVGs.
|
|
9
|
+
export function validateExportSvg(svg) {
|
|
10
|
+
if (svg.length > MAX_FRAME_SVG_CHARS || !/<svg\b/i.test(svg)) throw new Error('导出帧不是有效尺寸的 SVG')
|
|
11
|
+
if (/<!DOCTYPE|<!ENTITY|<\?xml-stylesheet|@import|<foreignObject\b/i.test(svg)) throw new Error('导出帧包含不支持的外部资源或 foreignObject')
|
|
12
|
+
for (const match of svg.matchAll(/(?:\b(?:xlink:)?href)\s*=\s*(["'])(.*?)\1/gis)) {
|
|
13
|
+
if (!/^(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(match[2].trim())) throw new Error('导出帧不能引用外部图片或文件')
|
|
14
|
+
}
|
|
15
|
+
for (const match of svg.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gis)) {
|
|
16
|
+
if (!match[2].trim().startsWith('#')) throw new Error('导出帧不能引用外部 CSS 资源')
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderExportFrame(svg, width, height) {
|
|
21
|
+
validateExportSvg(svg)
|
|
22
|
+
// Parse the actual document first: an invalid nested SVG image would
|
|
23
|
+
// otherwise be silently omitted by the outer viewport wrapper.
|
|
24
|
+
const normalized = new Resvg(svg, { font: { loadSystemFonts: /<text\b/i.test(svg) } }).toString()
|
|
25
|
+
// Match drawImage(..., w, h): stretch the intrinsic SVG viewport to the
|
|
26
|
+
// manifest dimensions, retaining its own viewBox/clip behavior inside it.
|
|
27
|
+
const wrapper = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">' +
|
|
28
|
+
'<image width="100%" height="100%" preserveAspectRatio="none" href="data:image/svg+xml;base64,' +
|
|
29
|
+
Buffer.from(normalized).toString('base64') + '"/></svg>'
|
|
30
|
+
const image = new Resvg(wrapper, { background: 'white', font: { loadSystemFonts: false } }).render()
|
|
31
|
+
if (image.width !== width || image.height !== height) throw new Error('宿主渲染尺寸与视频不一致')
|
|
32
|
+
return image.asPng()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (parentPort) {
|
|
36
|
+
try {
|
|
37
|
+
const root = realpathSync(workerData.dir)
|
|
38
|
+
let bytes = 0, last = 0
|
|
39
|
+
for (let index = 0; index < workerData.frames.length; index++) {
|
|
40
|
+
const file = workerData.frames[index].file
|
|
41
|
+
if (typeof file !== 'string' || isAbsolute(file)) throw new Error('导出帧路径无效')
|
|
42
|
+
const absolute = realpathSync(resolve(root, file))
|
|
43
|
+
const rel = relative(root, absolute)
|
|
44
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) throw new Error('导出帧路径超出媒体包')
|
|
45
|
+
if (statSync(absolute).size > MAX_FRAME_SVG_CHARS * 4) throw new Error('导出帧 SVG 过大')
|
|
46
|
+
const begin = performance.now()
|
|
47
|
+
const png = renderExportFrame(readFileSync(absolute, 'utf8'), workerData.w, workerData.h)
|
|
48
|
+
writeFileSync(join(workerData.temp, 'frame-' + String(index).padStart(8, '0') + '.png'), png)
|
|
49
|
+
bytes += png.length
|
|
50
|
+
parentPort.postMessage({ type: 'timing', ms: performance.now() - begin })
|
|
51
|
+
if (Date.now() - last > 100 || index + 1 === workerData.frames.length) {
|
|
52
|
+
parentPort.postMessage({ type: 'progress', rendered: index + 1, bytes }); last = Date.now()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
parentPort.postMessage({ type: 'done', rendered: workerData.frames.length, bytes })
|
|
56
|
+
} catch (error) { parentPort.postMessage({ type: 'error', error: String(error.message || error) }) }
|
|
57
|
+
parentPort.close()
|
|
58
|
+
}
|