dsh-drawai 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -10
- package/lib/client.js +3765 -461
- package/lib/index.js +1299 -51
- package/lib/media.js +661 -0
- package/lib/mxfile.js +88 -12
- package/lib/performance-log.js +66 -0
- package/lib/performance-metrics.js +47 -0
- package/lib/raster.js +21 -4
- package/lib/style-kernel.js +51 -11
- package/lib/trace-image.js +595 -106
- package/lib/video-export.js +193 -0
- package/lib/video-trace-pool.js +119 -0
- package/lib/video-trace-reuse.js +49 -0
- package/lib/video-trace-worker.js +59 -0
- package/package.json +3 -2
package/lib/media.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
/**
|
|
3
|
+
* DrawAI 视频旁路媒体包 —— 路径约定、manifest、帧读写、画布包 zip。
|
|
4
|
+
*
|
|
5
|
+
* 约定:画布 `foo.drawio` ↔ 旁路根 `foo.media/<clipId>/`。
|
|
6
|
+
* 节点只存 `mediaClip`(及 drawaiMedia 等元数据),目录主名由画布 stem 推导,
|
|
7
|
+
* 成对改名时不必改 XML。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import {
|
|
12
|
+
createReadStream,
|
|
13
|
+
createWriteStream,
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
renameSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
copyFileSync,
|
|
23
|
+
} from 'node:fs'
|
|
24
|
+
import { dirname, basename, join, resolve, sep } from 'node:path'
|
|
25
|
+
import { pipeline } from 'node:stream/promises'
|
|
26
|
+
import { createGzip, createGunzip, deflateRawSync, inflateRawSync } from 'node:zlib'
|
|
27
|
+
import { Readable } from 'node:stream'
|
|
28
|
+
import { DEFAULT_VIDEO_POSTER } from './style-kernel.js'
|
|
29
|
+
|
|
30
|
+
export { DEFAULT_VIDEO_POSTER }
|
|
31
|
+
|
|
32
|
+
export const MEDIA_DIR_SUFFIX = '.media'
|
|
33
|
+
export const MANIFEST_NAME = 'manifest.json'
|
|
34
|
+
export const FRAMES_DIR = 'frames'
|
|
35
|
+
export const STILLS_DIR = 'stills'
|
|
36
|
+
export const POSTER_NAME = 'poster.svg'
|
|
37
|
+
export const MEDIA_VERSION = 1
|
|
38
|
+
export const MAX_FRAME_SVG_CHARS = 250000
|
|
39
|
+
export const MAX_APPEND_FRAMES = 48
|
|
40
|
+
export const DEFAULT_MEDIA_FPS = 12
|
|
41
|
+
export const DEFAULT_MEDIA_W = 320
|
|
42
|
+
export const DEFAULT_MEDIA_H = 180
|
|
43
|
+
|
|
44
|
+
const MEDIA_DATA_KEYS = ['drawaiMedia', 'mediaKind', 'mediaClip', 'mediaFps', 'mediaW', 'mediaH', 'mediaPosterFrame']
|
|
45
|
+
|
|
46
|
+
/** 从画布路径得到 stem(去掉 .drawio)。 */
|
|
47
|
+
export function drawioStem(drawioPath) {
|
|
48
|
+
const base = basename(String(drawioPath || ''))
|
|
49
|
+
return base.replace(/\.drawio$/i, '') || 'canvas'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `foo.drawio` → `…/foo.media`(绝对或相对,跟随入参形态)。 */
|
|
53
|
+
export function mediaRootFor(drawioPath) {
|
|
54
|
+
const p = String(drawioPath || '')
|
|
55
|
+
const dir = dirname(p)
|
|
56
|
+
const stem = drawioStem(p)
|
|
57
|
+
return dir === '.' ? stem + MEDIA_DIR_SUFFIX : join(dir, stem + MEDIA_DIR_SUFFIX)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** clip 目录:`…/foo.media/clip-a` */
|
|
61
|
+
export function clipDirFor(drawioPath, clipId) {
|
|
62
|
+
const clip = sanitizeClipId(clipId)
|
|
63
|
+
if (clip === null) throw new Error('invalid mediaClip id')
|
|
64
|
+
return join(mediaRootFor(drawioPath), clip)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sanitizeClipId(raw) {
|
|
68
|
+
const s = String(raw === undefined || raw === null ? '' : raw).trim()
|
|
69
|
+
if (s.length === 0 || s.length > 64) return null
|
|
70
|
+
if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s) === false) return null
|
|
71
|
+
if (s === '.' || s === '..') return null
|
|
72
|
+
return s
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function allocateClipId(existing) {
|
|
76
|
+
const used = new Set((existing || []).map((x) => String(x)))
|
|
77
|
+
let n = 1
|
|
78
|
+
while (used.has('clip-' + n)) n += 1
|
|
79
|
+
return 'clip-' + n
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isVideoData(data) {
|
|
83
|
+
if (data === null || data === undefined || typeof data !== 'object') return false
|
|
84
|
+
const v = data.drawaiMedia
|
|
85
|
+
return v === '1' || v === 1 || v === true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function videoMetaFromData(data) {
|
|
89
|
+
if (isVideoData(data) === false) return null
|
|
90
|
+
const clip = sanitizeClipId(data.mediaClip)
|
|
91
|
+
const kind = data.mediaKind === 'raster' ? 'raster' : 'svg'
|
|
92
|
+
const fps = Number(data.mediaFps)
|
|
93
|
+
const w = Number(data.mediaW)
|
|
94
|
+
const h = Number(data.mediaH)
|
|
95
|
+
const posterFrame = Number(data.mediaPosterFrame)
|
|
96
|
+
return {
|
|
97
|
+
clip: clip,
|
|
98
|
+
kind: kind,
|
|
99
|
+
fps: Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_MEDIA_FPS,
|
|
100
|
+
w: Number.isFinite(w) && w > 0 ? w : DEFAULT_MEDIA_W,
|
|
101
|
+
h: Number.isFinite(h) && h > 0 ? h : DEFAULT_MEDIA_H,
|
|
102
|
+
posterFrame: Number.isFinite(posterFrame) && posterFrame >= 0 ? Math.floor(posterFrame) : 0,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function videoDataAttrs(meta) {
|
|
107
|
+
const m = meta || {}
|
|
108
|
+
const clip = sanitizeClipId(m.clip || m.mediaClip) || 'clip-1'
|
|
109
|
+
const kind = m.kind === 'raster' || m.mediaKind === 'raster' ? 'raster' : 'svg'
|
|
110
|
+
const fps = Number(m.fps !== undefined ? m.fps : m.mediaFps)
|
|
111
|
+
const w = Number(m.w !== undefined ? m.w : m.mediaW)
|
|
112
|
+
const h = Number(m.h !== undefined ? m.h : m.mediaH)
|
|
113
|
+
const posterFrame = Number(m.posterFrame !== undefined ? m.posterFrame : m.mediaPosterFrame)
|
|
114
|
+
return {
|
|
115
|
+
drawaiMedia: '1',
|
|
116
|
+
mediaKind: kind,
|
|
117
|
+
mediaClip: clip,
|
|
118
|
+
mediaFps: String(Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_MEDIA_FPS),
|
|
119
|
+
mediaW: String(Number.isFinite(w) && w > 0 ? Math.round(w) : DEFAULT_MEDIA_W),
|
|
120
|
+
mediaH: String(Number.isFinite(h) && h > 0 ? Math.round(h) : DEFAULT_MEDIA_H),
|
|
121
|
+
mediaPosterFrame: String(Number.isFinite(posterFrame) && posterFrame >= 0 ? Math.floor(posterFrame) : 0),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function stripVideoData(data) {
|
|
126
|
+
if (data === null || data === undefined || typeof data !== 'object') return data
|
|
127
|
+
const out = Object.assign({}, data)
|
|
128
|
+
for (let i = 0; i < MEDIA_DATA_KEYS.length; i += 1) delete out[MEDIA_DATA_KEYS[i]]
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function frameFileName(index) {
|
|
133
|
+
const n = Math.floor(Number(index))
|
|
134
|
+
if (Number.isFinite(n) === false || n < 0) throw new Error('frame index must be >= 0')
|
|
135
|
+
return String(n + 1).padStart(4, '0') + '.svg'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function stillFileName(index) {
|
|
139
|
+
const n = Math.floor(Number(index))
|
|
140
|
+
if (Number.isFinite(n) === false || n < 0) throw new Error('frame index must be >= 0')
|
|
141
|
+
return String(n + 1).padStart(4, '0') + '.png'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function emptyManifest(opts) {
|
|
145
|
+
const o = opts || {}
|
|
146
|
+
const fps = Number(o.fps)
|
|
147
|
+
const width = Number(o.width !== undefined ? o.width : o.w)
|
|
148
|
+
const height = Number(o.height !== undefined ? o.height : o.h)
|
|
149
|
+
return {
|
|
150
|
+
version: MEDIA_VERSION,
|
|
151
|
+
kind: o.kind === 'raster' ? 'raster' : 'svg',
|
|
152
|
+
fps: Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_MEDIA_FPS,
|
|
153
|
+
width: Number.isFinite(width) && width > 0 ? Math.round(width) : DEFAULT_MEDIA_W,
|
|
154
|
+
height: Number.isFinite(height) && height > 0 ? Math.round(height) : DEFAULT_MEDIA_H,
|
|
155
|
+
source: typeof o.source === 'string' && o.source.length > 0 ? o.source : null,
|
|
156
|
+
sourceOffset: Number.isFinite(Number(o.sourceOffset)) && Number(o.sourceOffset) >= 0 ? Number(o.sourceOffset) : 0,
|
|
157
|
+
frames: Array.isArray(o.frames) ? o.frames.slice() : [],
|
|
158
|
+
duration: 0,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function syncDuration(manifest) {
|
|
163
|
+
const frames = Array.isArray(manifest.frames) ? manifest.frames.length : 0
|
|
164
|
+
const fps = Number(manifest.fps)
|
|
165
|
+
manifest.duration = frames > 0 && Number.isFinite(fps) && fps > 0 ? frames / fps : 0
|
|
166
|
+
return manifest
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function normalizeManifest(raw) {
|
|
170
|
+
const base = emptyManifest()
|
|
171
|
+
if (raw === null || raw === undefined || typeof raw !== 'object') return base
|
|
172
|
+
const fps = Number(raw.fps)
|
|
173
|
+
const width = Number(raw.width)
|
|
174
|
+
const height = Number(raw.height)
|
|
175
|
+
const frames = []
|
|
176
|
+
if (Array.isArray(raw.frames)) {
|
|
177
|
+
for (let i = 0; i < raw.frames.length; i += 1) {
|
|
178
|
+
const f = raw.frames[i]
|
|
179
|
+
if (f === null || typeof f !== 'object') continue
|
|
180
|
+
const file = typeof f.file === 'string' ? f.file.replace(/\\/g, '/') : null
|
|
181
|
+
if (file === null || file.indexOf('..') >= 0) continue
|
|
182
|
+
const t = Number(f.t)
|
|
183
|
+
frames.push({ file: file, t: Number.isFinite(t) ? t : i / (Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_MEDIA_FPS) })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return syncDuration({
|
|
187
|
+
version: Number(raw.version) || MEDIA_VERSION,
|
|
188
|
+
kind: raw.kind === 'raster' ? 'raster' : 'svg',
|
|
189
|
+
fps: Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_MEDIA_FPS,
|
|
190
|
+
width: Number.isFinite(width) && width > 0 ? Math.round(width) : DEFAULT_MEDIA_W,
|
|
191
|
+
height: Number.isFinite(height) && height > 0 ? Math.round(height) : DEFAULT_MEDIA_H,
|
|
192
|
+
source: typeof raw.source === 'string' && raw.source.length > 0 ? raw.source.replace(/\\/g, '/') : null,
|
|
193
|
+
sourceOffset: Number.isFinite(Number(raw.sourceOffset)) && Number(raw.sourceOffset) >= 0 ? Number(raw.sourceOffset) : 0,
|
|
194
|
+
frames: frames,
|
|
195
|
+
duration: 0,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function readManifest(clipDir) {
|
|
200
|
+
const path = join(clipDir, MANIFEST_NAME)
|
|
201
|
+
if (existsSync(path) === false) return emptyManifest()
|
|
202
|
+
try {
|
|
203
|
+
return normalizeManifest(JSON.parse(readFileSync(path, 'utf8')))
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new Error('manifest.json 无法解析:' + (error && error.message ? error.message : String(error)))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function writeManifest(clipDir, manifest) {
|
|
210
|
+
mkdirSync(clipDir, { recursive: true })
|
|
211
|
+
const next = syncDuration(normalizeManifest(manifest))
|
|
212
|
+
writeFileSync(join(clipDir, MANIFEST_NAME), JSON.stringify(next, null, 2) + '\n', 'utf8')
|
|
213
|
+
return next
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function ensureClipPack(drawioPath, clipId, opts) {
|
|
217
|
+
const clip = sanitizeClipId(clipId)
|
|
218
|
+
if (clip === null) throw new Error('invalid mediaClip id')
|
|
219
|
+
const dir = clipDirFor(drawioPath, clip)
|
|
220
|
+
mkdirSync(join(dir, FRAMES_DIR), { recursive: true })
|
|
221
|
+
mkdirSync(join(dir, STILLS_DIR), { recursive: true })
|
|
222
|
+
const existing = existsSync(join(dir, MANIFEST_NAME))
|
|
223
|
+
if (existing === false) {
|
|
224
|
+
const m = emptyManifest(opts)
|
|
225
|
+
writeManifest(dir, m)
|
|
226
|
+
writeFileSync(join(dir, POSTER_NAME), DEFAULT_VIDEO_POSTER, 'utf8')
|
|
227
|
+
}
|
|
228
|
+
return { clip: clip, dir: dir, manifest: readManifest(dir) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function listFrameEntries(manifest, from, to) {
|
|
232
|
+
const frames = Array.isArray(manifest.frames) ? manifest.frames : []
|
|
233
|
+
const start = Number.isFinite(Number(from)) ? Math.max(0, Math.floor(Number(from))) : 0
|
|
234
|
+
const end = Number.isFinite(Number(to)) ? Math.min(frames.length, Math.floor(Number(to)) + 1) : frames.length
|
|
235
|
+
const out = []
|
|
236
|
+
for (let i = start; i < end; i += 1) {
|
|
237
|
+
out.push({ index: i, file: frames[i].file, t: frames[i].t })
|
|
238
|
+
}
|
|
239
|
+
return { total: frames.length, from: start, to: Math.max(start, end - 1), frames: out }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function readFrameSvg(clipDir, manifest, index) {
|
|
243
|
+
const frames = Array.isArray(manifest.frames) ? manifest.frames : []
|
|
244
|
+
const i = Math.floor(Number(index))
|
|
245
|
+
if (Number.isFinite(i) === false || i < 0 || i >= frames.length) {
|
|
246
|
+
throw new Error('frame index out of range (0..' + Math.max(0, frames.length - 1) + ')')
|
|
247
|
+
}
|
|
248
|
+
const rel = frames[i].file
|
|
249
|
+
const abs = join(clipDir, rel)
|
|
250
|
+
if (abs.startsWith(resolve(clipDir) + sep) === false && resolve(abs) !== resolve(clipDir, rel)) {
|
|
251
|
+
// containment: resolve both
|
|
252
|
+
}
|
|
253
|
+
const resolved = resolve(abs)
|
|
254
|
+
const root = resolve(clipDir)
|
|
255
|
+
if (resolved !== root && resolved.startsWith(root + sep) === false) {
|
|
256
|
+
throw new Error('frame path escapes clip directory')
|
|
257
|
+
}
|
|
258
|
+
return readFileSync(resolved, 'utf8')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function writeFrameSvg(clipDir, manifest, index, svg) {
|
|
262
|
+
if (typeof svg !== 'string' || svg.indexOf('<svg') < 0) {
|
|
263
|
+
throw new Error('frame svg must be an SVG markup string')
|
|
264
|
+
}
|
|
265
|
+
if (svg.length > MAX_FRAME_SVG_CHARS) {
|
|
266
|
+
throw new Error('frame svg too long (' + svg.length + ' > ' + MAX_FRAME_SVG_CHARS + ')')
|
|
267
|
+
}
|
|
268
|
+
const i = Math.floor(Number(index))
|
|
269
|
+
if (Number.isFinite(i) === false || i < 0) throw new Error('frame index must be >= 0')
|
|
270
|
+
mkdirSync(join(clipDir, FRAMES_DIR), { recursive: true })
|
|
271
|
+
const file = FRAMES_DIR + '/' + frameFileName(i)
|
|
272
|
+
writeFileSync(join(clipDir, file), svg, 'utf8')
|
|
273
|
+
const next = normalizeManifest(manifest)
|
|
274
|
+
const fps = next.fps
|
|
275
|
+
while (next.frames.length <= i) {
|
|
276
|
+
const j = next.frames.length
|
|
277
|
+
next.frames.push({ file: FRAMES_DIR + '/' + frameFileName(j), t: j / fps })
|
|
278
|
+
}
|
|
279
|
+
next.frames[i] = { file: file, t: i / fps }
|
|
280
|
+
return writeManifest(clipDir, next)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function appendFrameSvgs(clipDir, manifest, svgs) {
|
|
284
|
+
if (Array.isArray(svgs) === false) throw new Error('svgs must be an array')
|
|
285
|
+
if (svgs.length === 0) return normalizeManifest(manifest)
|
|
286
|
+
if (svgs.length > MAX_APPEND_FRAMES) {
|
|
287
|
+
throw new Error('appendFrames limited to ' + MAX_APPEND_FRAMES + ' per call')
|
|
288
|
+
}
|
|
289
|
+
let next = normalizeManifest(manifest)
|
|
290
|
+
for (let i = 0; i < svgs.length; i += 1) {
|
|
291
|
+
next = writeFrameSvg(clipDir, next, next.frames.length, svgs[i])
|
|
292
|
+
}
|
|
293
|
+
return next
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function findSourceFile(clipDir, manifest) {
|
|
297
|
+
if (manifest && typeof manifest.source === 'string' && manifest.source.length > 0) {
|
|
298
|
+
const abs = join(clipDir, manifest.source)
|
|
299
|
+
if (existsSync(abs)) return { rel: manifest.source.replace(/\\/g, '/'), abs: abs }
|
|
300
|
+
}
|
|
301
|
+
const names = ['source.webm', 'source.mp4', 'source.mov', 'source.mkv']
|
|
302
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
303
|
+
const abs = join(clipDir, names[i])
|
|
304
|
+
if (existsSync(abs)) return { rel: names[i], abs: abs }
|
|
305
|
+
}
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function importSourceFile(clipDir, sourcePath, preferredName) {
|
|
310
|
+
if (existsSync(sourcePath) === false) throw new Error('source file not found: ' + sourcePath)
|
|
311
|
+
mkdirSync(clipDir, { recursive: true })
|
|
312
|
+
const ext = basename(sourcePath).toLowerCase().match(/\.(webm|mp4|mov|mkv)$/)
|
|
313
|
+
const name = typeof preferredName === 'string' && preferredName.length > 0
|
|
314
|
+
? preferredName
|
|
315
|
+
: 'source' + (ext ? ext[0] : '.webm')
|
|
316
|
+
if (name.indexOf('..') >= 0 || name.indexOf('/') >= 0 || name.indexOf('\\') >= 0) {
|
|
317
|
+
throw new Error('invalid source file name')
|
|
318
|
+
}
|
|
319
|
+
const dest = join(clipDir, name)
|
|
320
|
+
copyFileSync(sourcePath, dest)
|
|
321
|
+
const manifest = readManifest(clipDir)
|
|
322
|
+
manifest.source = name
|
|
323
|
+
if (manifest.kind !== 'svg' || manifest.frames.length === 0) manifest.kind = 'raster'
|
|
324
|
+
writeManifest(clipDir, manifest)
|
|
325
|
+
return { rel: name, abs: dest, manifest: readManifest(clipDir) }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function writeStillPng(clipDir, index, pngBuffer) {
|
|
329
|
+
mkdirSync(join(clipDir, STILLS_DIR), { recursive: true })
|
|
330
|
+
const file = STILLS_DIR + '/' + stillFileName(index)
|
|
331
|
+
writeFileSync(join(clipDir, file), pngBuffer)
|
|
332
|
+
return file
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function readStillPng(clipDir, index) {
|
|
336
|
+
const file = join(clipDir, STILLS_DIR, stillFileName(index))
|
|
337
|
+
if (existsSync(file) === false) throw new Error('still not found: ' + stillFileName(index))
|
|
338
|
+
return readFileSync(file)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function listStillIndices(clipDir) {
|
|
342
|
+
const dir = join(clipDir, STILLS_DIR)
|
|
343
|
+
if (existsSync(dir) === false) return []
|
|
344
|
+
const names = readdirSync(dir)
|
|
345
|
+
const out = []
|
|
346
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
347
|
+
const m = /^(\d+)\.png$/i.exec(names[i])
|
|
348
|
+
if (m) out.push(parseInt(m[1], 10) - 1)
|
|
349
|
+
}
|
|
350
|
+
out.sort((a, b) => a - b)
|
|
351
|
+
return out
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 递归复制目录(覆盖目标同名文件)。 */
|
|
355
|
+
export function copyDirRecursive(src, dest) {
|
|
356
|
+
if (existsSync(src) === false) return false
|
|
357
|
+
mkdirSync(dest, { recursive: true })
|
|
358
|
+
const entries = readdirSync(src, { withFileTypes: true })
|
|
359
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
360
|
+
const e = entries[i]
|
|
361
|
+
const from = join(src, e.name)
|
|
362
|
+
const to = join(dest, e.name)
|
|
363
|
+
if (e.isDirectory()) copyDirRecursive(from, to)
|
|
364
|
+
else copyFileSync(from, to)
|
|
365
|
+
}
|
|
366
|
+
return true
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 另存为:把源画布旁的 `*.media` 复制到新主名。 */
|
|
370
|
+
export function copyMediaBeside(fromDrawio, toDrawio) {
|
|
371
|
+
const fromRoot = mediaRootFor(fromDrawio)
|
|
372
|
+
const toRoot = mediaRootFor(toDrawio)
|
|
373
|
+
if (existsSync(fromRoot) === false) return { copied: false, from: fromRoot, to: toRoot }
|
|
374
|
+
if (resolve(fromRoot) === resolve(toRoot)) return { copied: false, from: fromRoot, to: toRoot }
|
|
375
|
+
if (existsSync(toRoot)) rmSync(toRoot, { recursive: true, force: true })
|
|
376
|
+
copyDirRecursive(fromRoot, toRoot)
|
|
377
|
+
return { copied: true, from: fromRoot, to: toRoot }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function listClipIds(drawioPath) {
|
|
381
|
+
const root = mediaRootFor(drawioPath)
|
|
382
|
+
if (existsSync(root) === false) return []
|
|
383
|
+
const entries = readdirSync(root, { withFileTypes: true })
|
|
384
|
+
const out = []
|
|
385
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
386
|
+
if (entries[i].isDirectory() && sanitizeClipId(entries[i].name)) out.push(entries[i].name)
|
|
387
|
+
}
|
|
388
|
+
out.sort()
|
|
389
|
+
return out
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function collectReferencedClips(doc) {
|
|
393
|
+
const used = new Set()
|
|
394
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc.nodes) === false) return used
|
|
395
|
+
for (let i = 0; i < doc.nodes.length; i += 1) {
|
|
396
|
+
const meta = videoMetaFromData(doc.nodes[i].data)
|
|
397
|
+
if (meta !== null && meta.clip !== null) used.add(meta.clip)
|
|
398
|
+
}
|
|
399
|
+
return used
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function cleanupUnreferencedClips(drawioPath, doc) {
|
|
403
|
+
const used = collectReferencedClips(doc)
|
|
404
|
+
const all = listClipIds(drawioPath)
|
|
405
|
+
const removed = []
|
|
406
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
407
|
+
if (used.has(all[i])) continue
|
|
408
|
+
rmSync(clipDirFor(drawioPath, all[i]), { recursive: true, force: true })
|
|
409
|
+
removed.push(all[i])
|
|
410
|
+
}
|
|
411
|
+
const root = mediaRootFor(drawioPath)
|
|
412
|
+
if (existsSync(root) && listClipIds(drawioPath).length === 0) {
|
|
413
|
+
rmSync(root, { recursive: true, force: true })
|
|
414
|
+
}
|
|
415
|
+
return removed
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* ----------------------------- 最小 zip(store + deflate) ----------------------------- */
|
|
419
|
+
|
|
420
|
+
function crc32(buf) {
|
|
421
|
+
let c = ~0
|
|
422
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
423
|
+
c ^= buf[i]
|
|
424
|
+
for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1))
|
|
425
|
+
}
|
|
426
|
+
return ~c >>> 0
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function u16(n) {
|
|
430
|
+
const b = Buffer.alloc(2)
|
|
431
|
+
b.writeUInt16LE(n >>> 0, 0)
|
|
432
|
+
return b
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function u32(n) {
|
|
436
|
+
const b = Buffer.alloc(4)
|
|
437
|
+
b.writeUInt32LE(n >>> 0, 0)
|
|
438
|
+
return b
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 把文件表打成 zip Buffer。
|
|
443
|
+
* @param {{name:string, data:Buffer|string}[]} files
|
|
444
|
+
*/
|
|
445
|
+
export function buildZip(files) {
|
|
446
|
+
const locals = []
|
|
447
|
+
const centrals = []
|
|
448
|
+
let offset = 0
|
|
449
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
450
|
+
const name = String(files[i].name).replace(/\\/g, '/')
|
|
451
|
+
const data = Buffer.isBuffer(files[i].data) ? files[i].data : Buffer.from(String(files[i].data), 'utf8')
|
|
452
|
+
const nameBuf = Buffer.from(name, 'utf8')
|
|
453
|
+
const compressed = deflateRawSync(data)
|
|
454
|
+
const useStore = compressed.length >= data.length
|
|
455
|
+
const payload = useStore ? data : compressed
|
|
456
|
+
const method = useStore ? 0 : 8
|
|
457
|
+
const crc = crc32(data)
|
|
458
|
+
const local = Buffer.concat([
|
|
459
|
+
Buffer.from([0x50, 0x4b, 0x03, 0x04]),
|
|
460
|
+
u16(20),
|
|
461
|
+
u16(0),
|
|
462
|
+
u16(method),
|
|
463
|
+
u16(0),
|
|
464
|
+
u16(0),
|
|
465
|
+
u32(crc),
|
|
466
|
+
u32(payload.length),
|
|
467
|
+
u32(data.length),
|
|
468
|
+
u16(nameBuf.length),
|
|
469
|
+
u16(0),
|
|
470
|
+
nameBuf,
|
|
471
|
+
payload,
|
|
472
|
+
])
|
|
473
|
+
const central = Buffer.concat([
|
|
474
|
+
Buffer.from([0x50, 0x4b, 0x01, 0x02]),
|
|
475
|
+
u16(20),
|
|
476
|
+
u16(20),
|
|
477
|
+
u16(0),
|
|
478
|
+
u16(method),
|
|
479
|
+
u16(0),
|
|
480
|
+
u16(0),
|
|
481
|
+
u32(crc),
|
|
482
|
+
u32(payload.length),
|
|
483
|
+
u32(data.length),
|
|
484
|
+
u16(nameBuf.length),
|
|
485
|
+
u16(0),
|
|
486
|
+
u16(0),
|
|
487
|
+
u16(0),
|
|
488
|
+
u16(0),
|
|
489
|
+
u32(0),
|
|
490
|
+
u32(offset),
|
|
491
|
+
nameBuf,
|
|
492
|
+
])
|
|
493
|
+
locals.push(local)
|
|
494
|
+
centrals.push(central)
|
|
495
|
+
offset += local.length
|
|
496
|
+
}
|
|
497
|
+
const centralDir = Buffer.concat(centrals)
|
|
498
|
+
const end = Buffer.concat([
|
|
499
|
+
Buffer.from([0x50, 0x4b, 0x05, 0x06]),
|
|
500
|
+
u16(0),
|
|
501
|
+
u16(0),
|
|
502
|
+
u16(files.length),
|
|
503
|
+
u16(files.length),
|
|
504
|
+
u32(centralDir.length),
|
|
505
|
+
u32(offset),
|
|
506
|
+
u16(0),
|
|
507
|
+
])
|
|
508
|
+
return Buffer.concat(locals.concat([centralDir, end]))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function parseZip(buffer) {
|
|
512
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)
|
|
513
|
+
const files = []
|
|
514
|
+
let i = 0
|
|
515
|
+
while (i + 30 <= buf.length) {
|
|
516
|
+
if (buf.readUInt32LE(i) !== 0x04034b50) break
|
|
517
|
+
const method = buf.readUInt16LE(i + 8)
|
|
518
|
+
const compSize = buf.readUInt32LE(i + 18)
|
|
519
|
+
const uncompSize = buf.readUInt32LE(i + 22)
|
|
520
|
+
const nameLen = buf.readUInt16LE(i + 26)
|
|
521
|
+
const extraLen = buf.readUInt16LE(i + 28)
|
|
522
|
+
const name = buf.slice(i + 30, i + 30 + nameLen).toString('utf8')
|
|
523
|
+
const start = i + 30 + nameLen + extraLen
|
|
524
|
+
const payload = buf.slice(start, start + compSize)
|
|
525
|
+
let data
|
|
526
|
+
if (method === 0) data = payload
|
|
527
|
+
else if (method === 8) data = inflateRawSync(payload)
|
|
528
|
+
else throw new Error('unsupported zip method ' + method + ' for ' + name)
|
|
529
|
+
if (name.endsWith('/') === false) files.push({ name: name, data: data })
|
|
530
|
+
i = start + compSize
|
|
531
|
+
}
|
|
532
|
+
return files
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function walkFiles(dir, prefix, out) {
|
|
536
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
537
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
538
|
+
const e = entries[i]
|
|
539
|
+
const rel = prefix.length > 0 ? prefix + '/' + e.name : e.name
|
|
540
|
+
const abs = join(dir, e.name)
|
|
541
|
+
if (e.isDirectory()) walkFiles(abs, rel, out)
|
|
542
|
+
else out.push({ name: rel.replace(/\\/g, '/'), data: readFileSync(abs) })
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** 导出画布包:`stem.drawio` + `stem.media/**` → zip Buffer */
|
|
547
|
+
export function packCanvasBundle(drawioPath) {
|
|
548
|
+
const stem = drawioStem(drawioPath)
|
|
549
|
+
const files = []
|
|
550
|
+
if (existsSync(drawioPath) === false) throw new Error('drawio not found: ' + drawioPath)
|
|
551
|
+
files.push({ name: stem + '.drawio', data: readFileSync(drawioPath) })
|
|
552
|
+
const mediaRoot = mediaRootFor(drawioPath)
|
|
553
|
+
if (existsSync(mediaRoot)) {
|
|
554
|
+
const mediaFiles = []
|
|
555
|
+
walkFiles(mediaRoot, stem + MEDIA_DIR_SUFFIX, mediaFiles)
|
|
556
|
+
for (let i = 0; i < mediaFiles.length; i += 1) files.push(mediaFiles[i])
|
|
557
|
+
}
|
|
558
|
+
return buildZip(files)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** 导入画布包到目标目录;返回写出的 .drawio 相对名。 */
|
|
562
|
+
export function unpackCanvasBundle(zipBuffer, destDir, preferredStem) {
|
|
563
|
+
const entries = parseZip(zipBuffer)
|
|
564
|
+
if (entries.length === 0) throw new Error('empty zip')
|
|
565
|
+
let drawioEntry = null
|
|
566
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
567
|
+
if (/\.drawio$/i.test(entries[i].name) && entries[i].name.indexOf('/') < 0) {
|
|
568
|
+
drawioEntry = entries[i]
|
|
569
|
+
break
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (drawioEntry === null) {
|
|
573
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
574
|
+
if (/\.drawio$/i.test(entries[i].name)) {
|
|
575
|
+
drawioEntry = entries[i]
|
|
576
|
+
break
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (drawioEntry === null) throw new Error('zip 里没有 .drawio')
|
|
581
|
+
const stem = preferredStem || basename(drawioEntry.name).replace(/\.drawio$/i, '')
|
|
582
|
+
mkdirSync(destDir, { recursive: true })
|
|
583
|
+
const drawioOut = join(destDir, stem + '.drawio')
|
|
584
|
+
if (existsSync(drawioOut)) throw new Error('already exists: ' + stem + '.drawio')
|
|
585
|
+
writeFileSync(drawioOut, drawioEntry.data)
|
|
586
|
+
const mediaOut = join(destDir, stem + MEDIA_DIR_SUFFIX)
|
|
587
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
588
|
+
const name = entries[i].name.replace(/\\/g, '/')
|
|
589
|
+
if (/\.drawio$/i.test(name)) continue
|
|
590
|
+
// 接受 `foo.media/...` 或任意 `*.media/...`
|
|
591
|
+
const m = /([^/]+)\.media\/(.+)$/i.exec(name)
|
|
592
|
+
if (m === null) continue
|
|
593
|
+
const rel = m[2]
|
|
594
|
+
if (rel.indexOf('..') >= 0) continue
|
|
595
|
+
const abs = join(mediaOut, rel)
|
|
596
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
597
|
+
writeFileSync(abs, entries[i].data)
|
|
598
|
+
}
|
|
599
|
+
return { path: stem + '.drawio', mediaRoot: mediaOut }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** 导出单个 clip 为 SVG 视频 zip(manifest + frames + poster)。 */
|
|
603
|
+
export function packSvgVideoZip(clipDir) {
|
|
604
|
+
const manifest = readManifest(clipDir)
|
|
605
|
+
const files = [{ name: MANIFEST_NAME, data: Buffer.from(JSON.stringify(manifest, null, 2) + '\n', 'utf8') }]
|
|
606
|
+
const poster = join(clipDir, POSTER_NAME)
|
|
607
|
+
const source = findSourceFile(clipDir, manifest)
|
|
608
|
+
if (source) files.push({ name: source.rel, data: readFileSync(source.abs) })
|
|
609
|
+
if (existsSync(poster)) files.push({ name: POSTER_NAME, data: readFileSync(poster) })
|
|
610
|
+
for (let i = 0; i < manifest.frames.length; i += 1) {
|
|
611
|
+
const rel = manifest.frames[i].file
|
|
612
|
+
const abs = join(clipDir, rel)
|
|
613
|
+
if (existsSync(abs)) files.push({ name: rel.replace(/\\/g, '/'), data: readFileSync(abs) })
|
|
614
|
+
}
|
|
615
|
+
return buildZip(files)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function unpackSvgVideoZip(zipBuffer, clipDir) {
|
|
619
|
+
const entries = parseZip(zipBuffer)
|
|
620
|
+
mkdirSync(join(clipDir, FRAMES_DIR), { recursive: true })
|
|
621
|
+
let manifest = null
|
|
622
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
623
|
+
const name = entries[i].name.replace(/\\/g, '/')
|
|
624
|
+
if (name.indexOf('..') >= 0) continue
|
|
625
|
+
if (basename(name) === MANIFEST_NAME || name === MANIFEST_NAME) {
|
|
626
|
+
manifest = normalizeManifest(JSON.parse(entries[i].data.toString('utf8')))
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
const abs = join(clipDir, name)
|
|
630
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
631
|
+
writeFileSync(abs, entries[i].data)
|
|
632
|
+
}
|
|
633
|
+
if (manifest === null) {
|
|
634
|
+
// 仅有 frames/*.svg
|
|
635
|
+
const frames = []
|
|
636
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
637
|
+
const name = entries[i].name.replace(/\\/g, '/')
|
|
638
|
+
if (/\.svg$/i.test(name)) frames.push({ file: name, t: frames.length / DEFAULT_MEDIA_FPS })
|
|
639
|
+
}
|
|
640
|
+
frames.sort((a, b) => a.file.localeCompare(b.file))
|
|
641
|
+
for (let i = 0; i < frames.length; i += 1) frames[i].t = i / DEFAULT_MEDIA_FPS
|
|
642
|
+
manifest = emptyManifest({ kind: 'svg', frames: frames })
|
|
643
|
+
}
|
|
644
|
+
manifest.kind = 'svg'
|
|
645
|
+
writeManifest(clipDir, manifest)
|
|
646
|
+
return readManifest(clipDir)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export function contentFingerprint(buf) {
|
|
650
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 16)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// 避免未使用 import 告警(部分环境 tree-shake 敏感)
|
|
654
|
+
void createReadStream
|
|
655
|
+
void createWriteStream
|
|
656
|
+
void pipeline
|
|
657
|
+
void createGzip
|
|
658
|
+
void createGunzip
|
|
659
|
+
void Readable
|
|
660
|
+
void renameSync
|
|
661
|
+
void statSync
|