dsh-drawai 0.2.7 → 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.
package/lib/media.js ADDED
@@ -0,0 +1,664 @@
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
+ const path = join(clipDir, MANIFEST_NAME)
213
+ writeFileSync(path + '.tmp', JSON.stringify(next, null, 2) + '\n', 'utf8')
214
+ renameSync(path + '.tmp', path)
215
+ return next
216
+ }
217
+
218
+ export function ensureClipPack(drawioPath, clipId, opts) {
219
+ const clip = sanitizeClipId(clipId)
220
+ if (clip === null) throw new Error('invalid mediaClip id')
221
+ const dir = clipDirFor(drawioPath, clip)
222
+ mkdirSync(join(dir, FRAMES_DIR), { recursive: true })
223
+ mkdirSync(join(dir, STILLS_DIR), { recursive: true })
224
+ const existing = existsSync(join(dir, MANIFEST_NAME))
225
+ if (existing === false) {
226
+ const m = emptyManifest(opts)
227
+ writeManifest(dir, m)
228
+ writeFileSync(join(dir, POSTER_NAME), DEFAULT_VIDEO_POSTER, 'utf8')
229
+ }
230
+ return { clip: clip, dir: dir, manifest: readManifest(dir) }
231
+ }
232
+
233
+ export function listFrameEntries(manifest, from, to) {
234
+ const frames = Array.isArray(manifest.frames) ? manifest.frames : []
235
+ const start = Number.isFinite(Number(from)) ? Math.max(0, Math.floor(Number(from))) : 0
236
+ const end = Number.isFinite(Number(to)) ? Math.min(frames.length, Math.floor(Number(to)) + 1) : frames.length
237
+ const out = []
238
+ for (let i = start; i < end; i += 1) {
239
+ out.push({ index: i, file: frames[i].file, t: frames[i].t })
240
+ }
241
+ return { total: frames.length, from: start, to: Math.max(start, end - 1), frames: out }
242
+ }
243
+
244
+ export function readFrameSvg(clipDir, manifest, index) {
245
+ const frames = Array.isArray(manifest.frames) ? manifest.frames : []
246
+ const i = Math.floor(Number(index))
247
+ if (Number.isFinite(i) === false || i < 0 || i >= frames.length) {
248
+ throw new Error('frame index out of range (0..' + Math.max(0, frames.length - 1) + ')')
249
+ }
250
+ const rel = frames[i].file
251
+ const abs = join(clipDir, rel)
252
+ if (abs.startsWith(resolve(clipDir) + sep) === false && resolve(abs) !== resolve(clipDir, rel)) {
253
+ // containment: resolve both
254
+ }
255
+ const resolved = resolve(abs)
256
+ const root = resolve(clipDir)
257
+ if (resolved !== root && resolved.startsWith(root + sep) === false) {
258
+ throw new Error('frame path escapes clip directory')
259
+ }
260
+ return readFileSync(resolved, 'utf8')
261
+ }
262
+
263
+ export function writeFrameSvg(clipDir, manifest, index, svg) {
264
+ if (typeof svg !== 'string' || svg.indexOf('<svg') < 0) {
265
+ throw new Error('frame svg must be an SVG markup string')
266
+ }
267
+ if (svg.length > MAX_FRAME_SVG_CHARS) {
268
+ throw new Error('frame svg too long (' + svg.length + ' > ' + MAX_FRAME_SVG_CHARS + ')')
269
+ }
270
+ const i = Math.floor(Number(index))
271
+ if (Number.isFinite(i) === false || i < 0) throw new Error('frame index must be >= 0')
272
+ mkdirSync(join(clipDir, FRAMES_DIR), { recursive: true })
273
+ const file = FRAMES_DIR + '/' + frameFileName(i)
274
+ writeFileSync(join(clipDir, file), svg, 'utf8')
275
+ const next = normalizeManifest(manifest)
276
+ const fps = next.fps
277
+ while (next.frames.length <= i) {
278
+ const j = next.frames.length
279
+ next.frames.push({ file: FRAMES_DIR + '/' + frameFileName(j), t: j / fps })
280
+ }
281
+ next.frames[i] = { file: file, t: i / fps }
282
+ return writeManifest(clipDir, next)
283
+ }
284
+
285
+ export function appendFrameSvgs(clipDir, manifest, svgs) {
286
+ if (Array.isArray(svgs) === false) throw new Error('svgs must be an array')
287
+ if (svgs.length === 0) return normalizeManifest(manifest)
288
+ if (svgs.length > MAX_APPEND_FRAMES) {
289
+ throw new Error('appendFrames limited to ' + MAX_APPEND_FRAMES + ' per call')
290
+ }
291
+ let next = normalizeManifest(manifest)
292
+ for (let i = 0; i < svgs.length; i += 1) {
293
+ next = writeFrameSvg(clipDir, next, next.frames.length, svgs[i])
294
+ }
295
+ return next
296
+ }
297
+
298
+ export function findSourceFile(clipDir, manifest) {
299
+ if (manifest && typeof manifest.source === 'string' && manifest.source.length > 0) {
300
+ const abs = join(clipDir, manifest.source)
301
+ if (existsSync(abs)) return { rel: manifest.source.replace(/\\/g, '/'), abs: abs }
302
+ }
303
+ const names = ['source.webm', 'source.mp4', 'source.mov', 'source.mkv']
304
+ for (let i = 0; i < names.length; i += 1) {
305
+ const abs = join(clipDir, names[i])
306
+ if (existsSync(abs)) return { rel: names[i], abs: abs }
307
+ }
308
+ return null
309
+ }
310
+
311
+ export function importSourceFile(clipDir, sourcePath, preferredName) {
312
+ if (existsSync(sourcePath) === false) throw new Error('source file not found: ' + sourcePath)
313
+ mkdirSync(clipDir, { recursive: true })
314
+ const ext = basename(sourcePath).toLowerCase().match(/\.(webm|mp4|mov|mkv)$/)
315
+ const name = typeof preferredName === 'string' && preferredName.length > 0
316
+ ? preferredName
317
+ : 'source' + (ext ? ext[0] : '.webm')
318
+ if (name.indexOf('..') >= 0 || name.indexOf('/') >= 0 || name.indexOf('\\') >= 0) {
319
+ throw new Error('invalid source file name')
320
+ }
321
+ const dest = join(clipDir, name)
322
+ copyFileSync(sourcePath, dest)
323
+ const manifest = readManifest(clipDir)
324
+ manifest.source = name
325
+ if (manifest.kind !== 'svg' || manifest.frames.length === 0) manifest.kind = 'raster'
326
+ writeManifest(clipDir, manifest)
327
+ return { rel: name, abs: dest, manifest: readManifest(clipDir) }
328
+ }
329
+
330
+ export function writeStillPng(clipDir, index, pngBuffer) {
331
+ mkdirSync(join(clipDir, STILLS_DIR), { recursive: true })
332
+ const file = STILLS_DIR + '/' + stillFileName(index)
333
+ writeFileSync(join(clipDir, file), pngBuffer)
334
+ return file
335
+ }
336
+
337
+ export function readStillPng(clipDir, index) {
338
+ const file = join(clipDir, STILLS_DIR, stillFileName(index))
339
+ if (existsSync(file) === false) throw new Error('still not found: ' + stillFileName(index))
340
+ return readFileSync(file)
341
+ }
342
+
343
+ export function listStillIndices(clipDir) {
344
+ const dir = join(clipDir, STILLS_DIR)
345
+ if (existsSync(dir) === false) return []
346
+ const names = readdirSync(dir)
347
+ const out = []
348
+ for (let i = 0; i < names.length; i += 1) {
349
+ const m = /^(\d+)\.png$/i.exec(names[i])
350
+ if (m) out.push(parseInt(m[1], 10) - 1)
351
+ }
352
+ out.sort((a, b) => a - b)
353
+ return out
354
+ }
355
+
356
+ /** 递归复制目录(覆盖目标同名文件)。 */
357
+ export function copyDirRecursive(src, dest) {
358
+ if (existsSync(src) === false) return false
359
+ mkdirSync(dest, { recursive: true })
360
+ const entries = readdirSync(src, { withFileTypes: true })
361
+ for (let i = 0; i < entries.length; i += 1) {
362
+ const e = entries[i]
363
+ const from = join(src, e.name)
364
+ const to = join(dest, e.name)
365
+ if (e.isDirectory()) copyDirRecursive(from, to)
366
+ else copyFileSync(from, to)
367
+ }
368
+ return true
369
+ }
370
+
371
+ /** 另存为:把源画布旁的 `*.media` 复制到新主名。 */
372
+ export function copyMediaBeside(fromDrawio, toDrawio) {
373
+ const fromRoot = mediaRootFor(fromDrawio)
374
+ const toRoot = mediaRootFor(toDrawio)
375
+ if (existsSync(fromRoot) === false) return { copied: false, from: fromRoot, to: toRoot }
376
+ if (resolve(fromRoot) === resolve(toRoot)) return { copied: false, from: fromRoot, to: toRoot }
377
+ if (existsSync(toRoot)) rmSync(toRoot, { recursive: true, force: true })
378
+ copyDirRecursive(fromRoot, toRoot)
379
+ return { copied: true, from: fromRoot, to: toRoot }
380
+ }
381
+
382
+ export function listClipIds(drawioPath) {
383
+ const root = mediaRootFor(drawioPath)
384
+ if (existsSync(root) === false) return []
385
+ const entries = readdirSync(root, { withFileTypes: true })
386
+ const out = []
387
+ for (let i = 0; i < entries.length; i += 1) {
388
+ if (entries[i].isDirectory() && sanitizeClipId(entries[i].name)) out.push(entries[i].name)
389
+ }
390
+ out.sort()
391
+ return out
392
+ }
393
+
394
+ export function collectReferencedClips(doc) {
395
+ const used = new Set()
396
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc.nodes) === false) return used
397
+ for (let i = 0; i < doc.nodes.length; i += 1) {
398
+ const meta = videoMetaFromData(doc.nodes[i].data)
399
+ if (meta !== null && meta.clip !== null) used.add(meta.clip)
400
+ }
401
+ return used
402
+ }
403
+
404
+ export function cleanupUnreferencedClips(drawioPath, doc, protectedClips = []) {
405
+ const used = collectReferencedClips(doc)
406
+ for (const clip of protectedClips) used.add(clip)
407
+ const all = listClipIds(drawioPath)
408
+ const removed = []
409
+ for (let i = 0; i < all.length; i += 1) {
410
+ if (used.has(all[i])) continue
411
+ rmSync(clipDirFor(drawioPath, all[i]), { recursive: true, force: true })
412
+ removed.push(all[i])
413
+ }
414
+ const root = mediaRootFor(drawioPath)
415
+ if (existsSync(root) && listClipIds(drawioPath).length === 0) {
416
+ rmSync(root, { recursive: true, force: true })
417
+ }
418
+ return removed
419
+ }
420
+
421
+ /* ----------------------------- 最小 zip(store + deflate) ----------------------------- */
422
+
423
+ function crc32(buf) {
424
+ let c = ~0
425
+ for (let i = 0; i < buf.length; i += 1) {
426
+ c ^= buf[i]
427
+ for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1))
428
+ }
429
+ return ~c >>> 0
430
+ }
431
+
432
+ function u16(n) {
433
+ const b = Buffer.alloc(2)
434
+ b.writeUInt16LE(n >>> 0, 0)
435
+ return b
436
+ }
437
+
438
+ function u32(n) {
439
+ const b = Buffer.alloc(4)
440
+ b.writeUInt32LE(n >>> 0, 0)
441
+ return b
442
+ }
443
+
444
+ /**
445
+ * 把文件表打成 zip Buffer。
446
+ * @param {{name:string, data:Buffer|string}[]} files
447
+ */
448
+ export function buildZip(files) {
449
+ const locals = []
450
+ const centrals = []
451
+ let offset = 0
452
+ for (let i = 0; i < files.length; i += 1) {
453
+ const name = String(files[i].name).replace(/\\/g, '/')
454
+ const data = Buffer.isBuffer(files[i].data) ? files[i].data : Buffer.from(String(files[i].data), 'utf8')
455
+ const nameBuf = Buffer.from(name, 'utf8')
456
+ const compressed = deflateRawSync(data)
457
+ const useStore = compressed.length >= data.length
458
+ const payload = useStore ? data : compressed
459
+ const method = useStore ? 0 : 8
460
+ const crc = crc32(data)
461
+ const local = Buffer.concat([
462
+ Buffer.from([0x50, 0x4b, 0x03, 0x04]),
463
+ u16(20),
464
+ u16(0),
465
+ u16(method),
466
+ u16(0),
467
+ u16(0),
468
+ u32(crc),
469
+ u32(payload.length),
470
+ u32(data.length),
471
+ u16(nameBuf.length),
472
+ u16(0),
473
+ nameBuf,
474
+ payload,
475
+ ])
476
+ const central = Buffer.concat([
477
+ Buffer.from([0x50, 0x4b, 0x01, 0x02]),
478
+ u16(20),
479
+ u16(20),
480
+ u16(0),
481
+ u16(method),
482
+ u16(0),
483
+ u16(0),
484
+ u32(crc),
485
+ u32(payload.length),
486
+ u32(data.length),
487
+ u16(nameBuf.length),
488
+ u16(0),
489
+ u16(0),
490
+ u16(0),
491
+ u16(0),
492
+ u32(0),
493
+ u32(offset),
494
+ nameBuf,
495
+ ])
496
+ locals.push(local)
497
+ centrals.push(central)
498
+ offset += local.length
499
+ }
500
+ const centralDir = Buffer.concat(centrals)
501
+ const end = Buffer.concat([
502
+ Buffer.from([0x50, 0x4b, 0x05, 0x06]),
503
+ u16(0),
504
+ u16(0),
505
+ u16(files.length),
506
+ u16(files.length),
507
+ u32(centralDir.length),
508
+ u32(offset),
509
+ u16(0),
510
+ ])
511
+ return Buffer.concat(locals.concat([centralDir, end]))
512
+ }
513
+
514
+ export function parseZip(buffer) {
515
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)
516
+ const files = []
517
+ let i = 0
518
+ while (i + 30 <= buf.length) {
519
+ if (buf.readUInt32LE(i) !== 0x04034b50) break
520
+ const method = buf.readUInt16LE(i + 8)
521
+ const compSize = buf.readUInt32LE(i + 18)
522
+ const uncompSize = buf.readUInt32LE(i + 22)
523
+ const nameLen = buf.readUInt16LE(i + 26)
524
+ const extraLen = buf.readUInt16LE(i + 28)
525
+ const name = buf.slice(i + 30, i + 30 + nameLen).toString('utf8')
526
+ const start = i + 30 + nameLen + extraLen
527
+ const payload = buf.slice(start, start + compSize)
528
+ let data
529
+ if (method === 0) data = payload
530
+ else if (method === 8) data = inflateRawSync(payload)
531
+ else throw new Error('unsupported zip method ' + method + ' for ' + name)
532
+ if (name.endsWith('/') === false) files.push({ name: name, data: data })
533
+ i = start + compSize
534
+ }
535
+ return files
536
+ }
537
+
538
+ function walkFiles(dir, prefix, out) {
539
+ const entries = readdirSync(dir, { withFileTypes: true })
540
+ for (let i = 0; i < entries.length; i += 1) {
541
+ const e = entries[i]
542
+ const rel = prefix.length > 0 ? prefix + '/' + e.name : e.name
543
+ const abs = join(dir, e.name)
544
+ if (e.isDirectory()) walkFiles(abs, rel, out)
545
+ else out.push({ name: rel.replace(/\\/g, '/'), data: readFileSync(abs) })
546
+ }
547
+ }
548
+
549
+ /** 导出画布包:`stem.drawio` + `stem.media/**` → zip Buffer */
550
+ export function packCanvasBundle(drawioPath) {
551
+ const stem = drawioStem(drawioPath)
552
+ const files = []
553
+ if (existsSync(drawioPath) === false) throw new Error('drawio not found: ' + drawioPath)
554
+ files.push({ name: stem + '.drawio', data: readFileSync(drawioPath) })
555
+ const mediaRoot = mediaRootFor(drawioPath)
556
+ if (existsSync(mediaRoot)) {
557
+ const mediaFiles = []
558
+ walkFiles(mediaRoot, stem + MEDIA_DIR_SUFFIX, mediaFiles)
559
+ for (let i = 0; i < mediaFiles.length; i += 1) files.push(mediaFiles[i])
560
+ }
561
+ return buildZip(files)
562
+ }
563
+
564
+ /** 导入画布包到目标目录;返回写出的 .drawio 相对名。 */
565
+ export function unpackCanvasBundle(zipBuffer, destDir, preferredStem) {
566
+ const entries = parseZip(zipBuffer)
567
+ if (entries.length === 0) throw new Error('empty zip')
568
+ let drawioEntry = null
569
+ for (let i = 0; i < entries.length; i += 1) {
570
+ if (/\.drawio$/i.test(entries[i].name) && entries[i].name.indexOf('/') < 0) {
571
+ drawioEntry = entries[i]
572
+ break
573
+ }
574
+ }
575
+ if (drawioEntry === null) {
576
+ for (let i = 0; i < entries.length; i += 1) {
577
+ if (/\.drawio$/i.test(entries[i].name)) {
578
+ drawioEntry = entries[i]
579
+ break
580
+ }
581
+ }
582
+ }
583
+ if (drawioEntry === null) throw new Error('zip 里没有 .drawio')
584
+ const stem = preferredStem || basename(drawioEntry.name).replace(/\.drawio$/i, '')
585
+ mkdirSync(destDir, { recursive: true })
586
+ const drawioOut = join(destDir, stem + '.drawio')
587
+ if (existsSync(drawioOut)) throw new Error('already exists: ' + stem + '.drawio')
588
+ writeFileSync(drawioOut, drawioEntry.data)
589
+ const mediaOut = join(destDir, stem + MEDIA_DIR_SUFFIX)
590
+ for (let i = 0; i < entries.length; i += 1) {
591
+ const name = entries[i].name.replace(/\\/g, '/')
592
+ if (/\.drawio$/i.test(name)) continue
593
+ // 接受 `foo.media/...` 或任意 `*.media/...`
594
+ const m = /([^/]+)\.media\/(.+)$/i.exec(name)
595
+ if (m === null) continue
596
+ const rel = m[2]
597
+ if (rel.indexOf('..') >= 0) continue
598
+ const abs = join(mediaOut, rel)
599
+ mkdirSync(dirname(abs), { recursive: true })
600
+ writeFileSync(abs, entries[i].data)
601
+ }
602
+ return { path: stem + '.drawio', mediaRoot: mediaOut }
603
+ }
604
+
605
+ /** 导出单个 clip 为 SVG 视频 zip(manifest + frames + poster)。 */
606
+ export function packSvgVideoZip(clipDir) {
607
+ const manifest = readManifest(clipDir)
608
+ const files = [{ name: MANIFEST_NAME, data: Buffer.from(JSON.stringify(manifest, null, 2) + '\n', 'utf8') }]
609
+ const poster = join(clipDir, POSTER_NAME)
610
+ const source = findSourceFile(clipDir, manifest)
611
+ if (source) files.push({ name: source.rel, data: readFileSync(source.abs) })
612
+ if (existsSync(poster)) files.push({ name: POSTER_NAME, data: readFileSync(poster) })
613
+ for (let i = 0; i < manifest.frames.length; i += 1) {
614
+ const rel = manifest.frames[i].file
615
+ const abs = join(clipDir, rel)
616
+ if (existsSync(abs)) files.push({ name: rel.replace(/\\/g, '/'), data: readFileSync(abs) })
617
+ }
618
+ return buildZip(files)
619
+ }
620
+
621
+ export function unpackSvgVideoZip(zipBuffer, clipDir) {
622
+ const entries = parseZip(zipBuffer)
623
+ mkdirSync(join(clipDir, FRAMES_DIR), { recursive: true })
624
+ let manifest = null
625
+ for (let i = 0; i < entries.length; i += 1) {
626
+ const name = entries[i].name.replace(/\\/g, '/')
627
+ if (name.indexOf('..') >= 0) continue
628
+ if (basename(name) === MANIFEST_NAME || name === MANIFEST_NAME) {
629
+ manifest = normalizeManifest(JSON.parse(entries[i].data.toString('utf8')))
630
+ continue
631
+ }
632
+ const abs = join(clipDir, name)
633
+ mkdirSync(dirname(abs), { recursive: true })
634
+ writeFileSync(abs, entries[i].data)
635
+ }
636
+ if (manifest === null) {
637
+ // 仅有 frames/*.svg
638
+ const frames = []
639
+ for (let i = 0; i < entries.length; i += 1) {
640
+ const name = entries[i].name.replace(/\\/g, '/')
641
+ if (/\.svg$/i.test(name)) frames.push({ file: name, t: frames.length / DEFAULT_MEDIA_FPS })
642
+ }
643
+ frames.sort((a, b) => a.file.localeCompare(b.file))
644
+ for (let i = 0; i < frames.length; i += 1) frames[i].t = i / DEFAULT_MEDIA_FPS
645
+ manifest = emptyManifest({ kind: 'svg', frames: frames })
646
+ }
647
+ manifest.kind = 'svg'
648
+ writeManifest(clipDir, manifest)
649
+ return readManifest(clipDir)
650
+ }
651
+
652
+ export function contentFingerprint(buf) {
653
+ return createHash('sha256').update(buf).digest('hex').slice(0, 16)
654
+ }
655
+
656
+ // 避免未使用 import 告警(部分环境 tree-shake 敏感)
657
+ void createReadStream
658
+ void createWriteStream
659
+ void pipeline
660
+ void createGzip
661
+ void createGunzip
662
+ void Readable
663
+ void renameSync
664
+ void statSync