dsh-drawai 0.2.7 → 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/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