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/index.js CHANGED
@@ -15,8 +15,14 @@
15
15
  */
16
16
 
17
17
  import { defineTool } from '@deepseek-ai/dsh-tools'
18
- import { readdir } from 'node:fs/promises'
19
- import { isAbsolute, resolve as resolvePath, join as joinPath } from 'node:path'
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 { isAbsolute, resolve as resolvePath, join as joinPath, dirname as dirnamePath, basename as basenamePath } from 'node:path'
22
+ import { createVideoTracePool } from './video-trace-pool.js'
23
+ import { videoTraceOptions, exactVideoFrameKey } from './video-trace-reuse.js'
24
+ import { createPerformanceLog } from './performance-log.js'
25
+ import { createVideoExportManager } from './video-export.js'
20
26
  import { applyDocToMxfile, buildMxfile, contentHash, parseMxfile } from './mxfile.js'
21
27
  import { decodeRaster } from './raster.js'
22
28
  import {
@@ -24,8 +30,40 @@ import {
24
30
  TRACE_PRESET_NAMES,
25
31
  resolveTraceOptions,
26
32
  summarizeTraceForAi,
33
+ toLosslessJson,
27
34
  traceRasterToSvg,
35
+ traceRasterForToolbar,
28
36
  } from './trace-image.js'
37
+ import {
38
+ DEFAULT_MEDIA_FPS,
39
+ DEFAULT_MEDIA_H,
40
+ DEFAULT_MEDIA_W,
41
+ MAX_FRAME_SVG_CHARS,
42
+ allocateClipId,
43
+ appendFrameSvgs,
44
+ cleanupUnreferencedClips,
45
+ clipDirFor,
46
+ copyMediaBeside,
47
+ ensureClipPack,
48
+ findSourceFile,
49
+ importSourceFile,
50
+ listFrameEntries,
51
+ listStillIndices,
52
+ mediaRootFor,
53
+ packCanvasBundle,
54
+ packSvgVideoZip,
55
+ readFrameSvg,
56
+ readManifest,
57
+ readStillPng,
58
+ sanitizeClipId,
59
+ unpackCanvasBundle,
60
+ unpackSvgVideoZip,
61
+ videoDataAttrs,
62
+ videoMetaFromData,
63
+ writeFrameSvg,
64
+ writeManifest,
65
+ writeStillPng,
66
+ } from './media.js'
29
67
  import {
30
68
  ARROW_KINDS,
31
69
  DASH_KINDS,
@@ -33,6 +71,7 @@ import {
33
71
  DEFAULT_FONT,
34
72
  DEFAULT_NODE_STYLE,
35
73
  DEFAULT_STROKE,
74
+ DEFAULT_VIDEO_POSTER,
36
75
  NODE_SHAPES,
37
76
  NODE_SHAPE_STYLE,
38
77
  PALETTE,
@@ -49,12 +88,14 @@ import {
49
88
  isRasterDataUri,
50
89
  isSvgDataUri,
51
90
  isSvgMarkup,
91
+ isVideoNodeData,
52
92
  jettyFromStyle,
53
93
  lineKindFromStyle,
54
94
  nodeShapeFromStyle,
55
95
  normalizeDrawioDoc,
56
96
  normalizePoints,
57
97
  parseStyle,
98
+ reportNodeShape,
58
99
  sideFromStyle,
59
100
  styleWithArrow,
60
101
  styleWithAvoid,
@@ -413,6 +454,32 @@ function fontColorFieldOf(style) {
413
454
  */
414
455
  const DEFAULT_SVG_W = 80
415
456
  const DEFAULT_SVG_H = 80
457
+ const DEFAULT_VIDEO_W = DEFAULT_MEDIA_W
458
+ const DEFAULT_VIDEO_H = DEFAULT_MEDIA_H
459
+
460
+ /** 只碰旁路媒体、不改 .drawio 的 ops(单独出现时不写盘、不重排)。 */
461
+ const MEDIA_SIDE_OPS = {
462
+ listFrames: true,
463
+ getFrame: true,
464
+ setFrame: true,
465
+ appendFrames: true,
466
+ extractFrames: true,
467
+ traceFrame: true,
468
+ exportSvgVideo: true,
469
+ exportMediaSource: true,
470
+ }
471
+
472
+ function resolveVideoNode(doc, id, where) {
473
+ for (let i = 0; i < doc.nodes.length; i += 1) {
474
+ if (String(doc.nodes[i].id) !== String(id)) continue
475
+ const meta = videoMetaFromData(doc.nodes[i].data)
476
+ if (meta === null || meta.clip === null) {
477
+ throw new Error(where + ': node "' + id + '" is not a video node (missing drawaiMedia/mediaClip)')
478
+ }
479
+ return { node: doc.nodes[i], meta: meta, index: i }
480
+ }
481
+ throw new Error(where + ': unknown node "' + id + '"')
482
+ }
416
483
 
417
484
  /**
418
485
  * 一段内嵌 SVG 标记的长度上限。超了直接拒:一个单元格几十万字符会把文件和上下文一起撑爆。
@@ -519,6 +586,10 @@ function nodeStyleFromOp(style, op, where) {
519
586
  if (op.shape === 'svg' && nodeShapeFromStyle(out) !== 'svg') {
520
587
  throw new Error(where + ': shape "svg" 还要一起给内容 —— "svg": "<svg …>…</svg>"(形状本身决定不了画什么)')
521
588
  }
589
+ // video:style 层是海报 SVG;没给 svg 时塞缺省播放按钮海报。
590
+ if (op.shape === 'video' && nodeShapeFromStyle(out) !== 'svg') {
591
+ out = styleWithSvgMarkup(out, DEFAULT_VIDEO_POSTER, '0')
592
+ }
522
593
  if (typeof op.style === 'string' && op.style.length > 0) {
523
594
  // 文字元素(独立文字)没有填充与描边:给它调色板名要落到 **fontColor** 上,
524
595
  // 否则 AI 说 style:"red"、屏幕上一个像素都不变(而且文件里还多两个没用的键)。
@@ -831,6 +902,12 @@ async function applyOps(doc, ops, out, opctx) {
831
902
  if (rawImage === undefined) {
832
903
  throw new Error(where + ' needs a non-empty string "image"(工作区里那张位图的路径,png/jpg/webp/gif)')
833
904
  }
905
+ if (has(op, 'removeBg') === false) {
906
+ throw new Error(
907
+ where +
908
+ ' 必须显式传 removeBg:true(抠背景)或 removeBg:false(保留背景);不再支持 auto / 省略',
909
+ )
910
+ }
834
911
  const bytes = await opctx.readBytes(rawImage, where)
835
912
  let raster
836
913
  try {
@@ -869,6 +946,9 @@ async function applyOps(doc, ops, out, opctx) {
869
946
  bgBright: has(op, 'bgBright') ? op.bgBright : undefined,
870
947
  bgTol: has(op, 'bgTol') ? op.bgTol : undefined,
871
948
  bgColor: has(op, 'bgColor') ? op.bgColor : undefined,
949
+ removeBg: has(op, 'removeBg') ? op.removeBg : undefined,
950
+ bgSolidTol: has(op, 'bgSolidTol') ? op.bgSolidTol : undefined,
951
+ bgSolidBorderRatio: has(op, 'bgSolidBorderRatio') ? op.bgSolidBorderRatio : undefined,
872
952
  seeds: has(op, 'seeds') ? op.seeds : undefined,
873
953
  maxBytes: Math.min(rawTraceBudget, SVG_MARKUP_LIMIT - 2000),
874
954
  }
@@ -894,11 +974,10 @@ async function applyOps(doc, ops, out, opctx) {
894
974
  // 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h 显式给了就用给的。
895
975
  //
896
976
  // 裁剪块的 SVG 里路径坐标是**裁剪局部**的(viewBox = 0 0 cropW cropH)—— 别去平移 viewBox:
897
- // 路径不会跟着走,内容会整体跑出可视区(实测渲染出空白)。要让它落在原图对应的位置上,
898
- // 靠的是节点框的**比例**:节点 w/h ≈ 原图尺寸 × (裁剪块尺寸 / 该块缩放),
899
- // 配合 imageAspect=0 拉伸铺满,局部坐标系就正好铺到原图那一块。
900
- const w = numberOr(op.w, raster.width)
901
- const h = numberOr(op.h, raster.height)
977
+ // 路径不会跟着走,内容会整体跑出可视区。默认节点框使用裁剪块原始尺寸,
978
+ // 与 cropX/cropY 配合按原图像素 1:1 放置;显式 w/h 表示裁剪块的目标尺寸。
979
+ const w = numberOr(op.w, tracedFrom.width)
980
+ const h = numberOr(op.h, tracedFrom.height)
902
981
  const synthetic = {
903
982
  op: 'addNode',
904
983
  svg: traced.svg,
@@ -935,8 +1014,11 @@ async function applyOps(doc, ops, out, opctx) {
935
1014
  '(工作图 ' + metrics.work.width + '×' + metrics.work.height +
936
1015
  ', scale ' + metrics.scaleApplied +
937
1016
  (metrics.sizeNotes ? '; ' + metrics.sizeNotes : '') +
938
- ',像素覆盖' +
939
- (metrics.exactPixelCoverage === true ? '精确' : '不精确 ' + metrics.drawnPixels + '/' + metrics.totalPixels) +
1017
+ ',量化图轮廓' +
1018
+ (metrics.exactPixelCoverage === true ? '无损' : '已简化/删减') +
1019
+ ',丢弃 ' + metrics.loopsDropped + ' 个环' +
1020
+ (metrics.attempts > 1 ? ',预算降级 epsilon=' + metrics.appliedEpsilon : '') +
1021
+ (metrics.budgetExceeded ? ',仍超请求预算' : '') +
940
1022
  ')' +
941
1023
  (metrics.tuneHint ? '\n 调参:' + metrics.tuneHint : ''),
942
1024
  )
@@ -961,7 +1043,8 @@ async function applyOps(doc, ops, out, opctx) {
961
1043
  // 别名:给这个名字之后就能在同一个 ops 数组里引用它(见 applyOps 开头的注释)。
962
1044
  registerAlias(op.as, id, 'ops[' + i + '] addNode')
963
1045
  const style = nodeStyleFromOp(DEFAULT_NODE_STYLE, op, 'ops[' + i + '] addNode')
964
- const svgNode = nodeShapeFromStyle(style) === 'svg'
1046
+ const videoNode = op.shape === 'video'
1047
+ const svgNode = videoNode === false && nodeShapeFromStyle(style) === 'svg'
965
1048
  const node = {
966
1049
  id: id,
967
1050
  label: label,
@@ -969,8 +1052,37 @@ async function applyOps(doc, ops, out, opctx) {
969
1052
  style: style,
970
1053
  // svg 节点缺省是**正方形**:按标签文字估宽(estimateWidth)对图没有意义。
971
1054
  // 空 label 的装饰面板也请显式给 w/h,否则会落到最短标签估宽(130)。
972
- w: numberOr(op.w, svgNode ? DEFAULT_SVG_W : estimateWidth(label)),
973
- h: numberOr(op.h, svgNode ? DEFAULT_SVG_H : DEFAULT_H),
1055
+ // video 缺省 320×180(16:9 播放器框)。
1056
+ w: numberOr(op.w, videoNode ? DEFAULT_VIDEO_W : svgNode ? DEFAULT_SVG_W : estimateWidth(label)),
1057
+ h: numberOr(op.h, videoNode ? DEFAULT_VIDEO_H : svgNode ? DEFAULT_SVG_H : DEFAULT_H),
1058
+ }
1059
+ if (videoNode) {
1060
+ const existingClips = []
1061
+ for (let vi = 0; vi < doc.nodes.length; vi += 1) {
1062
+ const vm = videoMetaFromData(doc.nodes[vi].data)
1063
+ if (vm !== null && vm.clip !== null) existingClips.push(vm.clip)
1064
+ }
1065
+ const clip =
1066
+ sanitizeClipId(op.mediaClip) ||
1067
+ allocateClipId(existingClips)
1068
+ node.data = videoDataAttrs({
1069
+ clip: clip,
1070
+ kind: op.mediaKind === 'raster' ? 'raster' : 'svg',
1071
+ fps: numberOr(op.fps, numberOr(op.mediaFps, DEFAULT_MEDIA_FPS)),
1072
+ w: node.w,
1073
+ h: node.h,
1074
+ posterFrame: numberOr(op.posterFrame, 0),
1075
+ })
1076
+ if (opctx !== undefined && opctx !== null) {
1077
+ if (Array.isArray(opctx.ensureClips) === false) opctx.ensureClips = []
1078
+ opctx.ensureClips.push({
1079
+ clip: clip,
1080
+ kind: node.data.mediaKind,
1081
+ fps: Number(node.data.mediaFps),
1082
+ w: node.w,
1083
+ h: node.h,
1084
+ })
1085
+ }
974
1086
  }
975
1087
  if (typeof op.layer === 'string' && op.layer.length > 0) node.layer = resolveLayerRef(op.layer, 'ops[' + i + '] addNode layer')
976
1088
  // 位置:只有显式给了坐标才写入。缺省写 (0,0) 会让 placeMissing 以为"坐标齐全"而不补位,
@@ -981,7 +1093,7 @@ async function applyOps(doc, ops, out, opctx) {
981
1093
  recordCreated(id, 'node', label)
982
1094
  notes.push(
983
1095
  '+ node ' + id +
984
- (svgNode ? ' [svg]' : label.length > 0 ? ' "' + label + '"' : ' (empty label)') +
1096
+ (videoNode ? ' [video clip=' + node.data.mediaClip + ']' : svgNode ? ' [svg]' : label.length > 0 ? ' "' + label + '"' : ' (empty label)') +
985
1097
  (has(op, 'x') || has(op, 'y') ? ' at explicit coords' : ' (position pending)'),
986
1098
  )
987
1099
  continue
@@ -1588,9 +1700,271 @@ async function applyOps(doc, ops, out, opctx) {
1588
1700
  continue
1589
1701
  }
1590
1702
 
1703
+ // ── 视频媒体 ops(旁路 *.media/<clip>/;多数不改 .drawio)────────────────
1704
+ if (
1705
+ kind === 'listFrames' ||
1706
+ kind === 'getFrame' ||
1707
+ kind === 'setFrame' ||
1708
+ kind === 'appendFrames' ||
1709
+ kind === 'setMediaMeta' ||
1710
+ kind === 'importMedia' ||
1711
+ kind === 'extractFrames' ||
1712
+ kind === 'traceFrame' ||
1713
+ kind === 'exportSvgVideo' ||
1714
+ kind === 'exportMediaSource'
1715
+ ) {
1716
+ const drawioPath = opctx !== undefined && opctx !== null ? opctx.drawioPath : null
1717
+ if (typeof drawioPath !== 'string' || drawioPath.length === 0) {
1718
+ throw new Error('ops[' + i + '] ' + kind + ': 需要已绑定的画布路径(旁路媒体包跟 .drawio 走)')
1719
+ }
1720
+ const where = 'ops[' + i + '] ' + kind
1721
+ const id = typeof op.id === 'string' ? op.id : ''
1722
+ if (id.length === 0) throw new Error(where + ': "id" is required')
1723
+ const resolved = resolveVideoNode(doc, id, where)
1724
+ const clipDir = clipDirFor(drawioPath, resolved.meta.clip)
1725
+ ensureClipPack(drawioPath, resolved.meta.clip, {
1726
+ kind: resolved.meta.kind,
1727
+ fps: resolved.meta.fps,
1728
+ w: resolved.meta.w,
1729
+ h: resolved.meta.h,
1730
+ })
1731
+ let manifest = readManifest(clipDir)
1732
+
1733
+ if (kind === 'listFrames') {
1734
+ const listed = listFrameEntries(manifest, op.from, op.to)
1735
+ if (out !== undefined && out !== null) {
1736
+ if (Array.isArray(out.media) === false) out.media = []
1737
+ out.media.push({ op: kind, id: id, clip: resolved.meta.clip, ...listed })
1738
+ }
1739
+ notes.push('listFrames ' + id + ' → ' + listed.total + ' frame(s)')
1740
+ continue
1741
+ }
1742
+
1743
+ if (kind === 'getFrame') {
1744
+ const index = Math.floor(Number(op.index))
1745
+ const svg = readFrameSvg(clipDir, manifest, index)
1746
+ if (out !== undefined && out !== null) {
1747
+ if (Array.isArray(out.media) === false) out.media = []
1748
+ out.media.push({
1749
+ op: kind,
1750
+ id: id,
1751
+ clip: resolved.meta.clip,
1752
+ index: index,
1753
+ svg: svg.length > SVG_REPORT_LIMIT ? svg.slice(0, SVG_REPORT_LIMIT) + '…«truncated ' + svg.length + '»' : svg,
1754
+ svgChars: svg.length,
1755
+ })
1756
+ }
1757
+ notes.push('getFrame ' + id + '[' + index + '] ' + svg.length + ' chars')
1758
+ continue
1759
+ }
1760
+
1761
+ if (kind === 'setFrame') {
1762
+ if (typeof op.svg !== 'string') throw new Error(where + ': "svg" markup required')
1763
+ const index = Math.floor(Number(op.index))
1764
+ manifest = writeFrameSvg(clipDir, manifest, index, op.svg)
1765
+ if (resolved.meta.posterFrame === index) {
1766
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', op.svg, '0')
1767
+ recordChanged(id)
1768
+ }
1769
+ notes.push('setFrame ' + id + '[' + index + '] → ' + manifest.frames.length + ' frame(s)')
1770
+ continue
1771
+ }
1772
+
1773
+ if (kind === 'appendFrames') {
1774
+ if (Array.isArray(op.svgs) === false) throw new Error(where + ': "svgs" array required')
1775
+ const before = manifest.frames.length
1776
+ manifest = appendFrameSvgs(clipDir, manifest, op.svgs)
1777
+ notes.push('appendFrames ' + id + ' +' + (manifest.frames.length - before) + ' → ' + manifest.frames.length)
1778
+ continue
1779
+ }
1780
+
1781
+ if (kind === 'setMediaMeta') {
1782
+ const next = Object.assign({}, resolved.meta)
1783
+ if (has(op, 'fps') || has(op, 'mediaFps')) next.fps = numberOr(op.fps, numberOr(op.mediaFps, next.fps))
1784
+ if (has(op, 'kind') || has(op, 'mediaKind')) {
1785
+ const k = op.kind !== undefined ? op.kind : op.mediaKind
1786
+ next.kind = k === 'raster' ? 'raster' : 'svg'
1787
+ }
1788
+ if (has(op, 'posterFrame') || has(op, 'mediaPosterFrame')) {
1789
+ next.posterFrame = Math.floor(numberOr(op.posterFrame, numberOr(op.mediaPosterFrame, next.posterFrame)))
1790
+ }
1791
+ if (has(op, 'w') || has(op, 'mediaW')) next.w = numberOr(op.w, numberOr(op.mediaW, next.w))
1792
+ if (has(op, 'h') || has(op, 'mediaH')) next.h = numberOr(op.h, numberOr(op.mediaH, next.h))
1793
+ resolved.node.data = Object.assign({}, resolved.node.data, videoDataAttrs(next))
1794
+ manifest.kind = next.kind
1795
+ manifest.fps = next.fps
1796
+ manifest.width = next.w
1797
+ manifest.height = next.h
1798
+ writeManifest(clipDir, manifest)
1799
+ if (next.posterFrame >= 0 && next.posterFrame < manifest.frames.length) {
1800
+ try {
1801
+ const posterSvg = readFrameSvg(clipDir, manifest, next.posterFrame)
1802
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', posterSvg, '0')
1803
+ } catch (error) {
1804
+ // 海报帧暂不可读时保留原海报
1805
+ }
1806
+ }
1807
+ recordChanged(id)
1808
+ notes.push('setMediaMeta ' + id + ' kind=' + next.kind + ' fps=' + next.fps)
1809
+ continue
1810
+ }
1811
+
1812
+ if (kind === 'importMedia') {
1813
+ const file = typeof op.file === 'string' ? op.file : ''
1814
+ if (file.length === 0) throw new Error(where + ': "file" workspace path required')
1815
+ if (typeof opctx.readAbsolutePath !== 'function') {
1816
+ throw new Error(where + ': host cannot resolve workspace files')
1817
+ }
1818
+ const abs = await opctx.readAbsolutePath(file, where)
1819
+ const lower = abs.toLowerCase()
1820
+ if (/\.(webm|mp4|mov|mkv)$/.test(lower)) {
1821
+ const imported = importSourceFile(clipDir, abs)
1822
+ manifest = imported.manifest
1823
+ resolved.node.data = Object.assign({}, resolved.node.data, videoDataAttrs(Object.assign({}, resolved.meta, { kind: 'raster' })))
1824
+ recordChanged(id)
1825
+ notes.push('importMedia ' + id + ' ← ' + imported.rel + ' (raster)')
1826
+ } else if (/\.zip$/i.test(lower)) {
1827
+ const buf = await readFile(abs)
1828
+ manifest = unpackSvgVideoZip(buf, clipDir)
1829
+ resolved.node.data = Object.assign({}, resolved.node.data, videoDataAttrs(Object.assign({}, resolved.meta, { kind: 'svg' })))
1830
+ if (manifest.frames.length > 0) {
1831
+ try {
1832
+ const posterSvg = readFrameSvg(clipDir, manifest, 0)
1833
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', posterSvg, '0')
1834
+ } catch (error) {
1835
+ // ignore
1836
+ }
1837
+ }
1838
+ recordChanged(id)
1839
+ notes.push('importMedia ' + id + ' ← svg-video zip (' + manifest.frames.length + ' frames)')
1840
+ } else {
1841
+ throw new Error(where + ': unsupported media file (use .webm/.mp4/.mov/.mkv or .zip)')
1842
+ }
1843
+ continue
1844
+ }
1845
+
1846
+ if (kind === 'extractFrames') {
1847
+ // 宿主侧解不了视频:真实抽帧由客户端(<video> + canvas)写 stills/。
1848
+ // 两种用法:
1849
+ // 1) 只回报 —— `{op:'extractFrames', id}`:回执照抄 stills 数量(模型拿它看进度)。
1850
+ // 2) 下计划 —— 带上 seconds/fps/width,宿主把计划挂给画布,浏览器按计划抽帧。
1851
+ const every = Math.max(1, Math.floor(numberOr(op.every, 1)))
1852
+ const max = Math.max(1, Math.floor(numberOr(op.max, 24)))
1853
+ const source = findSourceFile(clipDir, manifest)
1854
+ const stills = listStillIndices(clipDir)
1855
+ const wantsPlan = has(op, 'seconds') || has(op, 'fps') || has(op, 'width') || has(op, 'from')
1856
+ const plan =
1857
+ wantsPlan === true
1858
+ ? {
1859
+ from: Math.max(0, numberOr(op.from, 0)),
1860
+ seconds: Math.max(0.1, Math.min(3600, numberOr(op.seconds, 5))),
1861
+ fps: Math.max(0.1, Math.min(60, numberOr(op.fps, manifest.fps))),
1862
+ width: Math.max(16, Math.min(2048, Math.round(numberOr(op.width, manifest.width)))),
1863
+ max: Math.max(1, Math.min(1200, Math.floor(numberOr(op.max, 360)))),
1864
+ }
1865
+ : null
1866
+ if (out !== undefined && out !== null) {
1867
+ if (Array.isArray(out.media) === false) out.media = []
1868
+ out.media.push({
1869
+ op: kind,
1870
+ id: id,
1871
+ clip: resolved.meta.clip,
1872
+ hasSource: source !== null,
1873
+ ...(source !== null ? { source: source.rel } : {}),
1874
+ stills: stills.length,
1875
+ plan:
1876
+ plan !== null
1877
+ ? Object.assign({}, plan, { note: '已把抽帧计划挂给画布:它在浏览器里解码视频写 stills/' })
1878
+ : { every: every, max: max, note: '带上 seconds/fps/width 下发抽帧计划,浏览器会写入 stills/' },
1879
+ })
1880
+ // 计划由 applyOps 收集、由外层挂到画布轮询上(见 diagram_apply 尾部)。
1881
+ if (plan !== null) out.extract = Object.assign({ clip: resolved.meta.clip }, plan)
1882
+ }
1883
+ notes.push(
1884
+ 'extractFrames ' + id + ': source=' + (source !== null ? source.rel : 'none') +
1885
+ ', stills=' + stills.length + (plan !== null ? ', plan=' + plan.seconds + 's@' + plan.fps + 'fps ' + plan.width + 'w' : ' (report only)'),
1886
+ )
1887
+ continue
1888
+ }
1889
+
1890
+ if (kind === 'traceFrame') {
1891
+ const index = Math.floor(Number(op.index))
1892
+ if (has(op, 'removeBg') === false) {
1893
+ throw new Error(
1894
+ where +
1895
+ ' 必须显式传 removeBg:true(抠背景)或 removeBg:false(保留背景);不再支持 auto / 省略',
1896
+ )
1897
+ }
1898
+ if (typeof opctx.readBytes !== 'function' && existsSync(joinPath(clipDir, 'stills', String(index + 1).padStart(4, '0') + '.png')) === false) {
1899
+ // still 在 clip 内,直接读盘即可
1900
+ }
1901
+ let png
1902
+ try {
1903
+ png = readStillPng(clipDir, index)
1904
+ } catch (error) {
1905
+ throw new Error(where + ': ' + messageOf(error) + ' —— 先 extractFrames 下发抽帧计划并等待 stills/ 写出')
1906
+ }
1907
+ const raster = await decodeRaster(png)
1908
+ const traced = traceRasterToSvg(raster, op)
1909
+ if (traced.svg.length > MAX_FRAME_SVG_CHARS) {
1910
+ throw new Error(where + ': traced svg too long (' + traced.svg.length + ' > ' + MAX_FRAME_SVG_CHARS + ')')
1911
+ }
1912
+ manifest = writeFrameSvg(clipDir, manifest, index, traced.svg)
1913
+ if (resolved.meta.posterFrame === index) {
1914
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', traced.svg, '0')
1915
+ recordChanged(id)
1916
+ }
1917
+ if (out !== undefined && out !== null) {
1918
+ if (Array.isArray(out.trace) === false) out.trace = []
1919
+ out.trace.push(Object.assign({ frame: index, id: id }, summarizeTraceForAi(traced.metrics)))
1920
+ }
1921
+ notes.push('traceFrame ' + id + '[' + index + '] → ' + traced.svg.length + ' chars')
1922
+ continue
1923
+ }
1924
+
1925
+ if (kind === 'exportSvgVideo') {
1926
+ const zip = packSvgVideoZip(clipDir)
1927
+ const outName =
1928
+ typeof op.out === 'string' && op.out.length > 0
1929
+ ? op.out
1930
+ : basenamePath(drawioPath).replace(/\.drawio$/i, '') + '-' + resolved.meta.clip + '.svgvideo.zip'
1931
+ if (typeof opctx.writeWorkspaceFile !== 'function') {
1932
+ throw new Error(where + ': cannot write export file')
1933
+ }
1934
+ const written = await opctx.writeWorkspaceFile(outName, zip, where)
1935
+ if (out !== undefined && out !== null) {
1936
+ if (Array.isArray(out.media) === false) out.media = []
1937
+ out.media.push({ op: kind, id: id, path: written, bytes: zip.length })
1938
+ }
1939
+ notes.push('exportSvgVideo ' + id + ' → ' + written)
1940
+ continue
1941
+ }
1942
+
1943
+ if (kind === 'exportMediaSource') {
1944
+ const source = findSourceFile(clipDir, manifest)
1945
+ if (source === null) throw new Error(where + ': no source.* video in clip')
1946
+ const outName =
1947
+ typeof op.out === 'string' && op.out.length > 0
1948
+ ? op.out
1949
+ : basenamePath(drawioPath).replace(/\.drawio$/i, '') + '-' + resolved.meta.clip + '-' + source.rel
1950
+ if (typeof opctx.writeWorkspaceFile !== 'function') {
1951
+ throw new Error(where + ': cannot write export file')
1952
+ }
1953
+ const buf = await readFile(source.abs)
1954
+ const written = await opctx.writeWorkspaceFile(outName, buf, where)
1955
+ if (out !== undefined && out !== null) {
1956
+ if (Array.isArray(out.media) === false) out.media = []
1957
+ out.media.push({ op: kind, id: id, path: written, bytes: buf.length })
1958
+ }
1959
+ notes.push('exportMediaSource ' + id + ' → ' + written)
1960
+ continue
1961
+ }
1962
+ }
1963
+
1591
1964
  throw new Error(
1592
1965
  'ops[' + i + '] unknown op ' + JSON.stringify(kind) +
1593
- '. Supported: addNode, addEdge, setLabel, setLabelPos, setStyle, setEdge, order, duplicate, addLayer, setLayer, setLayerProps, move, remove, highlight',
1966
+ '. Supported: addNode, addEdge, setLabel, setLabelPos, setStyle, setEdge, order, duplicate, addLayer, setLayer, setLayerProps, move, remove, highlight, ' +
1967
+ 'listFrames, getFrame, setFrame, appendFrames, setMediaMeta, importMedia, extractFrames, traceFrame, exportSvgVideo, exportMediaSource',
1594
1968
  )
1595
1969
  }
1596
1970
  // 结构化返回"这次到底动了什么":模型要用新建的 id(或确认改到了谁)时不必解析 summary 文本。
@@ -1874,9 +2248,12 @@ const MAX_SCAN_DIRS = 12
1874
2248
  export const SAVE_HEADER = 'x-drawai-save'
1875
2249
  /** 普通写回(存盘 / list / export-result 等)的请求体上限,防止一次误发把内存打满。 */
1876
2250
  const SAVE_MAX_BYTES = 4 * 1024 * 1024
2251
+ /** 画布包含原图 data URI 与复刻 SVG,使用独立的有界预算。 */
2252
+ const CANVAS_SAVE_MAX_BYTES = 128 * 1024 * 1024
2253
+ const IMAGE_TRACE_MAX_BYTES = 96 * 1024 * 1024
1877
2254
  /**
1878
2255
  * 「看一眼」PNG 回执(action: render-result)单独放开:base64 比原图大约大 1/3,
1879
- * 大图按 2× 渲很容易超过 4MB。其它 action 解析后仍按 SAVE_MAX_BYTES 拒收。
2256
+ * 大图按 2× 渲很容易超过 4MB。控制请求、原图绘制和画布写回分别限制。
1880
2257
  */
1881
2258
  const RENDER_RESULT_MAX_BYTES = 32 * 1024 * 1024
1882
2259
 
@@ -1973,7 +2350,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1973
2350
  {op:"setLayer", id|ids, layer}
1974
2351
  {op:"setLayerProps", layer, name?, visible?, locked?}
1975
2352
  {op:"export", format?:"svg"|"png", name?}
1976
- {op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, cropX?, cropY?, cropW?, cropH?, as?, layer?}
2353
+ {op:"traceImage", image, removeBg, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, bgSolidTol?, bgSolidBorderRatio?, cropX?, cropY?, cropW?, cropH?, as?, layer?}
1977
2354
  {op:"move", id|ids, dx?, dy?, x?, y?}
1978
2355
  {op:"remove", id|ids}
1979
2356
  {op:"highlight", ids:["n1","n2"]}
@@ -1998,7 +2375,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1998
2375
  渲染在画布那一半做,所以这一步是"请求":返回值/下一次 read 里的 export 状态
1999
2376
  pending → done(带文件路径)/ downloaded / failed。看到 pending 说明画布还没刷新 ——
2000
2377
  别反复请求,等下一次 read 再看(或提醒用户打开那张画布)。
2001
- - **把一张位图"描"成矢量图用 traceImage**:{op:"traceImage", image:"ref.png", x:40, y:40}。
2378
+ - **把一张位图"描"成矢量图用 traceImage**:{op:"traceImage", image:"ref.png", removeBg:false, x:40, y:40}。
2002
2379
  它**真的读那张图的像素**,建成一个 svg 内容节点。
2003
2380
  **不要用"手写一段近似的 svg"冒充复刻** —— 形状词汇表表达不了的照片/插画就直接用这个 op。
2004
2381
  · 默认 **w/h = 原图像素尺寸**、viewBox = 原图像素坐标系(1:1 铺满)。
@@ -2007,6 +2384,8 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
2007
2384
  - preset:"photo" 照片/渐变
2008
2385
  - preset:"simple" 图标/简单图(少色+降采样,省字符)
2009
2386
  - 不写 = "default"
2387
+ · **removeBg 必填**(无 auto):true = 抠背景(先识纯色底,否则近白泛洪);false = 整图保留。
2388
+ 白底/纯色底图标、透明 PNG 通常 true;风景/照片/带场景背景通常 false。回执看 bgRemoved / bgDetect。
2010
2389
  · 调参闭环:看返回值里的 **trace[]**(结构化指标 + hint),不满意就 **remove 旧节点再 traceImage**(改 preset/字段)。
2011
2390
  常见:字符紧 → scale:2 / 加大 epsilon / preset:"simple";线糊断 → preset:"lineart" 或提高 protectLuma/colorsDark。
2012
2391
  · 单次上限:SVG 标记 250000 字符(整图的精细度主要由它决定:预算紧时管线会抬 epsilon、丢小环,
@@ -2016,8 +2395,9 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
2016
2395
  工作分辨率上限是 grid(默认 499px 宽),整张立绘塞进去时"脸"只占三四十像素,眼睛只有 3~4px,
2017
2396
  量化后五官必然消失(实测把 colors 拉满 24、protectLuma 提到 235 也救不回来)。
2018
2397
  裁出头部再描,同一块就占满工作图宽度 —— 脸能有一两百像素,五官才留得住。
2019
- 裁剪块的坐标在**裁剪局部系**里,所以要按比例给它 w/h(= 整张图的 w/h × 裁剪块宽高 / 整块原尺寸),
2020
- x/y 不写时落在原图对应位置,可与整图那块叠着放(顺序在后 = 画在上面)。
2398
+ 裁剪块的 SVG 使用**裁剪局部系**,节点框负责缩放和放置。
2399
+ 默认 w/h 为裁剪块原始尺寸,x/y 为裁剪原点;显式 w/h 是裁剪块目标尺寸。
2400
+ 原图若已缩放,x/y 与 w/h 都需按同一比例换算,可与整图那块叠着放(顺序在后 = 画在上面)。
2021
2401
 
2022
2402
  - **改一组用 ids**:{op:"setStyle", ids:["n1","n2","n5"], style:"green"} —— setStyle / setLabel /
2023
2403
  move / remove 都收 ids,不要为了"把这几个改成绿色"发 12 个 op。批量 move 只收 dx/dy
@@ -2060,6 +2440,37 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
2060
2440
  - 糖(shape/style/dash/arrow/color/exit/entry/jettySize/edgeStyle/avoid)由宿主翻译成 drawio 的 style 键,
2061
2441
  **绝不落盘**;keys 用来写任意 drawio 键,值给 null = 删键回默认。
2062
2442
  - shape:rect | rounded | **text**(独立文字:无边框无底色的一段字)| stadium | ellipse | diamond | parallelogram | cylinder | document | hexagon | **svg**
2443
+ **节点标签与无边框节点**:图片、SVG 内容、视频与独立文字不提供无效描边工具,混合选区描边只作用于有边框的图形和连线。
2444
+ 用户可独立拖动节点文字到节点内部或外部,双击文字编辑(视频标签也一样)。独立文字节点仍整体拖动。
2445
+ 节点标签偏移以 labelOffsetX/labelOffsetY(像素)回报,写入原生 mxGeometry 的 mxPoint offset;不要与边标签 labelX/labelY 的相对量混淆。
2446
+ 偏移随保存、复制、撤销保留;移动节点时文字跟随,导出与适应内容包含节点外的文字。
2447
+ **视频节点(旁路媒体包)**:
2448
+ - **用户导入入口**:右侧「视频」先选本地文件,再点击画布放置;也可拖入视频或 Ctrl+V 粘贴剪贴板中的视频文件。
2449
+ 仅支持浏览器可解码的 MP4/WebM/MOV/MKV,单文件最多 48MB。画布须先保存,完整上传后才创建有封面的原视频节点,不创建空占位。
2450
+ - addNode {shape:"video", fps?, w?, h?, mediaKind?, mediaClip?, x, y} → 海报 + \`*.media/<clip>/\`
2451
+ - listFrames / getFrame / setFrame / appendFrames / setMediaMeta / importMedia
2452
+ - extractFrames(看 stills 计划)/ traceFrame(stills → SVG 帧)
2453
+ - exportSvgVideo / exportMediaSource
2454
+ - diagram_read 对视频只回 mediaKind/mediaClip/fps/frameCount/duration/hasSource,帧内容用 getFrame
2455
+ - **视频复刻交互**:用户选中已导入的普通视频,悬停或点击工具条「绘制复刻」展开设置浮窗。图片绘制同样使用悬停设置卡片。
2456
+ 复刻结果保留原视频音频与片段起点,播放可静音,SVG 视频包及 WebM 导出携带对应音频。
2457
+ 开始后视频在后台静默复刻,画布角落显示可收起的进度卡片,不占全屏、不抢焦点,可继续编辑画布、取消任务或失败后按原参数重试;切换画布/页面会取消任务。
2458
+ 视频工具条复用其他节点的图标在上、文字在下的样式及悬停反馈;绘制与导出使用同一个悬停卡片组件。
2459
+ 悬浮条保留绘制复刻、预览、抽帧、导出;SVG 节点隐藏绘制入口,导出菜单只展示当前媒体适用的格式。抽帧把当前画面放到右侧:源视频为 PNG,SVG 视频保留当前帧矢量。
2460
+ 悬停视频节点表面时显示进度条与播放/暂停;停止、逐帧、时间轴、循环和适配等完整控件集中在预览窗口,选区悬浮条不再提供设置封面按钮。
2461
+ 用户决定完整视频或起止时间范围、任意正数帧率(支持 25/29.97/30/60fps 等)、480/887/1440px 长边、原始尺寸或自定义长边。
2462
+ 碎片数量可选低/中/高,内部自动匹配每帧 60000/120000/248000 字符预算;保留画风、颜色数和去背景选项。
2463
+ 不再按 30 秒、360 帧或 640px 截断。面板估算帧数;源视频末尾自动截短,小视频不放大。
2464
+ 单帧保留 1600 万像素及 16384px 长边的处理边界,超过时明确提示用户选择较小尺寸,不静默降采样。
2465
+ 浏览器负责解码和分块传输高清 PNG;抽帧与常驻 Worker 池描边流水线重叠,帧按序落盘,仅计算选中碎片档位。图片原有的 887px 校准上限不应用于视频。
2466
+ 面板显示实际进度和最近完成帧,长 SVG 序列按需预取并最多缓存 48 帧内容。
2467
+ 原视频保留,全部成功后在右侧新建 SVG 视频节点;取消、失败、切换画布会停止任务,取消/失败清理本次临时媒体。
2468
+ 失败可重新复刻(从头开始)。完成后可播放、逐帧查看、撤销新增节点,以及导出 SVG 视频包。
2469
+ SVG 视频包为 .svgvideo.zip(manifest + SVG 帧 + 封面),不是单文件动态 SVG,不含音轨。逐帧绘制暂不做跨帧稳定处理。
2470
+ - **AI 分步处理视频**:先 importMedia,再 extractFrames {from,seconds,fps,width,max} 下发计划;
2471
+ 返回计划不等于完成,须再次查询 extractFrames 确认 stills 数量及回执,再按实际帧数逐个 traceFrame {id,index,removeBg,…}。
2472
+ 全部写出后 setMediaMeta {id,kind:"svg",fps,w,h,posterFrame:0},用 listFrames 核对帧数后 exportSvgVideo。
2473
+
2063
2474
  要"一段不带框的说明/标题文字"就用 addNode {shape:"text", label:"…", x, y} —— 它就是 drawio 的
2064
2475
  text 形状(样式原样写 text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0)。
2065
2476
  文字元素想换颜色要写 **fontColor**(setStyle {id, keys:{fontColor:"#b85450"}}):
@@ -2175,9 +2586,9 @@ HTML 标签按纯文本显示、图层结构原样保留(画布把它们叠在
2175
2586
  const SKILL_SUMMARY = {
2176
2587
  name: 'drawai-canvas',
2177
2588
  description:
2178
- '读写工作区里的 DrawAI 画布(.drawio,就是 drawio 的 mxfile):diagram_read 看现状、diagram_apply 结构化改图(自动布局、无损写回)。',
2589
+ '读写 DrawAI 画布(.drawio):diagram_read 看现状、diagram_apply 结构化改图;支持图像复刻、视频分段复刻及 SVG 帧序列导出。',
2179
2590
  whenToUse:
2180
- '用户让你画流程图/架构图/泳道图,或让你读/改某张 .drawio 画布,或问"这张图里有什么""画布上怎么操作"时。',
2591
+ '用户让你画流程图/架构图/泳道图、复刻图片或视频、将视频转为 SVG 帧动画,或让你读/改某张 .drawio 画布,或问"这张图里有什么""画布上怎么操作"时。',
2181
2592
  }
2182
2593
 
2183
2594
  /**
@@ -2515,6 +2926,8 @@ export function apply(ctx) {
2515
2926
  y: { type: 'number', required: true },
2516
2927
  w: { type: 'number', required: true },
2517
2928
  h: { type: 'number', required: true },
2929
+ labelOffsetX: { type: 'number' },
2930
+ labelOffsetY: { type: 'number' },
2518
2931
  // 这个单元在哪一层(图层 id)。多于一层的文档才带。
2519
2932
  layer: { type: 'string' },
2520
2933
  // 字色(设过且不是缺省黑字才有):命中调色板给名字,否则给十六进制。
@@ -2522,6 +2935,16 @@ export function apply(ctx) {
2522
2935
  // 容器父级(**只读信息**):单元挂在某个容器/分组里时才有。
2523
2936
  // 画布按绝对位置显示,所以"移动容器"不会带走它 —— 要一起动就把它们一起写进 ids。
2524
2937
  parent: { type: 'string' },
2938
+ // 视频节点摘要(shape=video 才有)。帧内容不塞进 read —— 用 getFrame / listFrames。
2939
+ // 漏声明会踩 additionalProperties:false:画布里一旦有视频节点,diagram_read 整次失败。
2940
+ mediaKind: { type: 'string' },
2941
+ mediaClip: { type: 'string' },
2942
+ fps: { type: 'number' },
2943
+ posterFrame: { type: 'number' },
2944
+ frameCount: { type: 'number' },
2945
+ duration: { type: 'number' },
2946
+ hasSource: { type: 'boolean' },
2947
+ mediaMissing: { type: 'boolean' },
2525
2948
  },
2526
2949
  },
2527
2950
  },
@@ -2745,7 +3168,32 @@ export function apply(ctx) {
2745
3168
  if (keepNode(n) === false) continue
2746
3169
  const style = typeof n.style === 'string' ? n.style : ''
2747
3170
  const svgMarkup = svgMarkupForReport(style)
2748
- const shapeName = nodeShapeFromStyle(style)
3171
+ const shapeName = reportNodeShape(n)
3172
+ const videoMeta = videoMetaFromData(n.data)
3173
+ const mediaSummary =
3174
+ videoMeta !== null && videoMeta.clip !== null
3175
+ ? (function () {
3176
+ const summary = {
3177
+ mediaKind: videoMeta.kind,
3178
+ mediaClip: videoMeta.clip,
3179
+ fps: videoMeta.fps,
3180
+ posterFrame: videoMeta.posterFrame,
3181
+ }
3182
+ try {
3183
+ const m = readManifest(clipDirFor(loaded.absolute, videoMeta.clip))
3184
+ summary.frameCount = Array.isArray(m.frames) ? m.frames.length : 0
3185
+ summary.duration = Number(m.duration) || 0
3186
+ summary.hasSource = findSourceFile(clipDirFor(loaded.absolute, videoMeta.clip), m) !== null
3187
+ summary.mediaMissing = false
3188
+ } catch (error) {
3189
+ summary.frameCount = 0
3190
+ summary.duration = 0
3191
+ summary.hasSource = false
3192
+ summary.mediaMissing = true
3193
+ }
3194
+ return summary
3195
+ })()
3196
+ : null
2749
3197
  nodes.push({
2750
3198
  id: String(n.id),
2751
3199
  label: typeof n.label === 'string' ? n.label : String(n.id),
@@ -2759,9 +3207,13 @@ export function apply(ctx) {
2759
3207
  y: numberOr(n.y, 0),
2760
3208
  w: numberOr(n.w, DEFAULT_W),
2761
3209
  h: numberOr(n.h, DEFAULT_H),
2762
- ...(typeof svgMarkup === 'string' ? { svg: svgMarkup } : {}),
3210
+ ...(Number.isFinite(n.labelOffsetX) ? { labelOffsetX: n.labelOffsetX } : {}),
3211
+ ...(Number.isFinite(n.labelOffsetY) ? { labelOffsetY: n.labelOffsetY } : {}),
3212
+ // 视频节点不回整段海报 svg(用 media 摘要);普通 svg 节点照旧回 svg 字段。
3213
+ ...(shapeName !== 'video' && typeof svgMarkup === 'string' ? { svg: svgMarkup } : {}),
2763
3214
  // 位图节点:只报有图,不把 base64 塞进上下文(用户侧粘贴/拖入)。
2764
3215
  ...(shapeName === 'image' ? { image: true } : {}),
3216
+ ...(mediaSummary !== null ? mediaSummary : {}),
2765
3217
  ...(typeof n.layer === 'string' && n.layer.length > 0 ? { layer: n.layer } : {}),
2766
3218
  // 容器父级:AI 得知道"这东西在哪个分组里",否则把容器挪走/删掉时它不知道会连带什么。
2767
3219
  ...(typeof n.parent === 'string' && n.parent.length > 0 ? { parent: n.parent } : {}),
@@ -2903,7 +3355,7 @@ export function apply(ctx) {
2903
3355
  '{op:"setEdge", id, from?, to?, fromPoint?, toPoint?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
2904
3356
  '{op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?} / {op:"addLayer", name?, as?} / {op:"setLayer", id|ids, layer} / {op:"setLayerProps", layer, name?, visible?, locked?} / ' +
2905
3357
  '{op:"export", format?:"svg"|"png", name?} / ' +
2906
- '{op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, as?, layer?} / ' +
3358
+ '{op:"traceImage", image, removeBg, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, as?, layer?} / ' +
2907
3359
  '{op:"move", id|ids, dx?, dy?, x?, y?} / {op:"remove", id|ids} / {op:"highlight", ids:[...]};节点 id 省略时自动分配。' +
2908
3360
  '**改接一条边**用 setEdge(换 from/to 或某一端改成自由点)—— id、标签、标签位置、折点、端点约束全都留着;' +
2909
3361
  '不要 remove + addEdge 重画(那会把这些丢掉)。' +
@@ -2914,12 +3366,14 @@ export function apply(ctx) {
2914
3366
  '**图层**用 addLayer/setLayer/setLayerProps(加层、把单元移到别的层、改名/显示/隐藏/锁定;隐藏≠删除)。' +
2915
3367
  '**导出**用 export:format:"svg" 会让画布渲染一张 SVG 落在 .drawio 旁边、format:"png" 是浏览器下载;' +
2916
3368
  '这一步由画布执行,返回值里的 export.status 是 pending/done/downloaded/failed(pending = 画布还没刷新)。' +
2917
- '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y} 会真的读像素建成 svg 节点,' +
3369
+ '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", removeBg:false, x, y} 会真的读像素建成 svg 节点,' +
2918
3370
  '**不是凭印象重画**。默认 w/h = 原图像素尺寸(1:1)。' +
3371
+ '**removeBg 必填**(无 auto):true=抠背景(先识纯色底,否则近白泛洪);false=整图保留。' +
3372
+ '白底/纯色底图标通常 true;风景/照片/场景背景通常 false。' +
2919
3373
  '**调参**:先选 preset(' + TRACE_PRESET_NAMES.join('|') + ';lineart=插画线稿、photo=照片、simple=省字符、default=默认),' +
2920
3374
  '再按需覆盖 colors/colorsDark/scale/epsilon/grid/gridH/minArea/minRegionArea/protectLuma/majorityContrast/bgColor 等。' +
2921
3375
  '合并顺序:默认 ← preset ← 显式字段。' +
2922
- '返回值里的 **trace[]** 是结构化指标(svgChars/protectPixels/options/hint 等);' +
3376
+ '返回值里的 **trace[]** 是结构化指标(svgChars/bgRemoved/bgDetect/options/hint 等);' +
2923
3377
  '质量不够就根据 hint remove 旧节点再 trace(改 preset 或字段)。' +
2924
3378
  '字符紧→scale:2/加大epsilon/preset:simple;线糊断→preset:lineart 或提高 protectLuma/colorsDark。' +
2925
3379
  '**一次改一组**就写 ids:[...](setStyle/setLabel/move/remove 都收,move 的批量只收 dx/dy 相对位移)—— 用户说"把这几个换成绿色"时不要逐个发 op。' +
@@ -3013,7 +3467,23 @@ export function apply(ctx) {
3013
3467
  error: { type: 'string' },
3014
3468
  },
3015
3469
  },
3016
- // 本次调用里每次 traceImage 一条:给模型读指标再调参(additionalProperties:false 必须声明)
3470
+ // 抽帧状态(只有下过抽帧计划才有):pending = 计划挂在画布上、还没抽;done = stills 已写出
3471
+ // (written 是张数);failed = 抽帧时报错(error 是原因)。报告一次即清(取走即清)。
3472
+ extract: {
3473
+ type: 'object',
3474
+ additionalProperties: false,
3475
+ properties: {
3476
+ status: { type: 'string', required: true },
3477
+ clip: { type: 'string' },
3478
+ seconds: { type: 'number' },
3479
+ fps: { type: 'number' },
3480
+ width: { type: 'number' },
3481
+ max: { type: 'number' },
3482
+ written: { type: 'number' },
3483
+ error: { type: 'string' },
3484
+ },
3485
+ },
3486
+ // 本次调用里每次 traceImage / traceFrame 一条:给模型读指标再调参(additionalProperties:false 必须声明)
3017
3487
  trace: {
3018
3488
  type: 'array',
3019
3489
  items: {
@@ -3023,6 +3493,9 @@ export function apply(ctx) {
3023
3493
  preset: { type: 'string' },
3024
3494
  image: { type: 'string' },
3025
3495
  nodeId: { type: 'string' },
3496
+ // traceFrame 才有:视频节点 id 与帧下标
3497
+ id: { type: 'string' },
3498
+ frame: { type: 'number' },
3026
3499
  // 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)
3027
3500
  crop: {
3028
3501
  type: 'object',
@@ -3066,11 +3539,23 @@ export function apply(ctx) {
3066
3539
  budget: { type: 'number' },
3067
3540
  attempts: { type: 'number' },
3068
3541
  exactPixelCoverage: { type: 'boolean' },
3542
+ labelPartitionComplete: { type: 'boolean' },
3543
+ coverageBasis: { type: 'string' },
3544
+ loopsDropped: { type: 'number' },
3545
+ budgetExceeded: { type: 'boolean' },
3546
+ appliedEpsilon: { type: 'number' },
3547
+ bgRemoved: { type: 'boolean' },
3548
+ bgDetect: { type: 'json' },
3069
3549
  options: { type: 'json' },
3070
3550
  hint: { type: 'string' },
3071
3551
  },
3072
3552
  },
3073
3553
  },
3554
+ // listFrames / getFrame / extractFrames / export* 的结构化回执(heterogeneous —— 用 json)
3555
+ media: {
3556
+ type: 'array',
3557
+ items: { type: 'json' },
3558
+ },
3074
3559
  },
3075
3560
  },
3076
3561
  render: function (args, value) {
@@ -3087,7 +3572,7 @@ export function apply(ctx) {
3087
3572
  value.trace
3088
3573
  .map(function (t) {
3089
3574
  return (
3090
- (t.image || '?') +
3575
+ (t.image || (typeof t.frame === 'number' ? 'frame[' + t.frame + ']' : '?')) +
3091
3576
  ' preset=' +
3092
3577
  (t.preset || 'default') +
3093
3578
  ' ' +
@@ -3152,7 +3637,14 @@ export function apply(ctx) {
3152
3637
  // `highlight` 只是"让客户端选中这几个"、`export` 只是"让画布渲染一张图"—— 都不改文档:
3153
3638
  // · 一次全是这类 op 的调用**不写盘**(也不会顺手重排,那条特别危险);
3154
3639
  // · 混着编辑时,它们跟着一起生效。
3155
- const edits = ops.filter((op) => op === null || typeof op !== 'object' || (op.op !== 'highlight' && op.op !== 'export'))
3640
+ const edits = ops.filter((op) => {
3641
+ if (op === null || typeof op !== 'object') return true
3642
+ if (op.op === 'highlight' || op.op === 'export') return false
3643
+ // 旁路媒体 ops 默认不改 .drawio;setMediaMeta / importMedia / setFrame(海报) 可能改,
3644
+ // 下面用 out.changed 再判定是否真要写盘。
3645
+ if (MEDIA_SIDE_OPS[op.op] === true) return false
3646
+ return true
3647
+ })
3156
3648
  const noDocEdit = edits.length === 0
3157
3649
  // 默认布局:pinned、纯 highlight、以及**ops 里自带几何**(addNode 给了 x/y、有 move、
3158
3650
  // 或有 duplicate —— 复制出来的位置是相对原件的)时都不重排 —— 否则"把 n1 往右挪 40"
@@ -3170,7 +3662,9 @@ export function apply(ctx) {
3170
3662
  if (LAYOUTS.indexOf(mode) < 0) throw new Error('unknown layout "' + mode + '"; use one of ' + LAYOUTS.join(', '))
3171
3663
 
3172
3664
  const out = {}
3173
- const notes = await applyOps(doc, ops, out, {
3665
+ const opctx = {
3666
+ drawioPath: loaded.absolute,
3667
+ ensureClips: [],
3174
3668
  /**
3175
3669
  * traceImage 的图片读取:走**同一个工作区解析器**(相对路径按会话根解析),
3176
3670
  * 于是"AI 能描哪张图"和"画布能打开哪些文件"是同一套围栏。
@@ -3194,7 +3688,41 @@ export function apply(ctx) {
3194
3688
  throw new Error(where + ': 读 "' + imagePath + '" 失败(' + messageOf(error) + ')')
3195
3689
  }
3196
3690
  },
3197
- })
3691
+ readAbsolutePath: async function (filePath, where) {
3692
+ const target = await resolveTarget(filePath, sessionId)
3693
+ const abs = absoluteHint(target, filePath)
3694
+ const info = await ctx.fs.stat(target)
3695
+ if (info === undefined) throw new Error(where + ': 工作区里没有 "' + filePath + '"')
3696
+ return abs
3697
+ },
3698
+ writeWorkspaceFile: async function (relPath, buffer, where) {
3699
+ const root = workspaceRootOf(sessionId)
3700
+ if (root === undefined) throw new Error(where + ': no workspace root')
3701
+ const safe = String(relPath).replace(/^[\\/]+/, '')
3702
+ if (safe.indexOf('..') >= 0) throw new Error(where + ': path must stay in workspace')
3703
+ const abs = joinPath(root, safe)
3704
+ await mkdir(dirnamePath(abs), { recursive: true })
3705
+ await writeFile(abs, buffer)
3706
+ return safe.replace(/\\/g, '/')
3707
+ },
3708
+ }
3709
+ const notes = await applyOps(doc, ops, out, opctx)
3710
+ // 新建 / 已有视频节点:确保旁路 `*.media/<clip>/` 存在。
3711
+ {
3712
+ const seen = new Set()
3713
+ const queue = Array.isArray(opctx.ensureClips) ? opctx.ensureClips.slice() : []
3714
+ for (let ni = 0; ni < doc.nodes.length; ni += 1) {
3715
+ const meta = videoMetaFromData(doc.nodes[ni].data)
3716
+ if (meta === null || meta.clip === null) continue
3717
+ queue.push({ clip: meta.clip, kind: meta.kind, fps: meta.fps, w: meta.w, h: meta.h })
3718
+ }
3719
+ for (let qi = 0; qi < queue.length; qi += 1) {
3720
+ const item = queue[qi]
3721
+ if (seen.has(item.clip)) continue
3722
+ seen.add(item.clip)
3723
+ ensureClipPack(loaded.absolute, item.clip, item)
3724
+ }
3725
+ }
3198
3726
  if (Array.isArray(out.highlight)) highlightFor.set(sessionId, out.highlight)
3199
3727
  // 删掉的东西不该还挂在"用户选中"里(id 会被复用,见 forgetSelection)。
3200
3728
  if (Array.isArray(out.removed) && out.removed.length > 0) forgetSelection(sessionId, loaded.absolute, out.removed)
@@ -3204,8 +3732,32 @@ export function apply(ctx) {
3204
3732
  exportRequests.set(sessionId, { requestId: requestId, absolute: loaded.absolute, format: out.export.format, name: out.export.name })
3205
3733
  exportResults.delete(sessionId)
3206
3734
  }
3207
- if (noDocEdit) {
3208
- return {
3735
+ // 抽帧计划:同样挂成请求 —— 真正解码视频的是画布那一半(浏览器)。
3736
+ if (out.extract !== undefined && out.extract !== null) {
3737
+ const requestId = 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
3738
+ extractRequests.set(sessionId, {
3739
+ requestId: requestId,
3740
+ absolute: loaded.absolute,
3741
+ clip: out.extract.clip,
3742
+ from: out.extract.from,
3743
+ seconds: out.extract.seconds,
3744
+ fps: out.extract.fps,
3745
+ width: out.extract.width,
3746
+ max: out.extract.max,
3747
+ })
3748
+ extractResults.delete(sessionId)
3749
+ }
3750
+ // 抽帧状态:pending 得留着(模型据此看进度);done / failed **只报一次就清掉** ——
3751
+ // 否则这条回执会永远粘在之后每一次 diagram_apply 上(与 highlight「取走即清」同一套)。
3752
+ const extractState = extractStateFor(sessionId, loaded.absolute)
3753
+ if (extractState !== undefined && extractState.status !== 'pending') extractResults.delete(sessionId)
3754
+ const mediaChangedDoc =
3755
+ Array.isArray(out.changed) && out.changed.length > 0 &&
3756
+ ops.some((op) => op !== null && typeof op === 'object' && (op.op === 'setMediaMeta' || op.op === 'importMedia' || op.op === 'setFrame' || op.op === 'traceFrame'))
3757
+ if (noDocEdit && mediaChangedDoc === false) {
3758
+ // toLosslessJson:宿主对工具输出做 isJsonValue,自有属性值为 undefined / -0 会整次打回
3759
+ // (表现为 `returned invalid output: value is not lossless JSON`)。
3760
+ return toLosslessJson({
3209
3761
  path: loaded.absolute,
3210
3762
  revision: doc.revision,
3211
3763
  nodeCount: doc.nodes.length,
@@ -3215,9 +3767,11 @@ export function apply(ctx) {
3215
3767
  changed: [],
3216
3768
  removed: [],
3217
3769
  ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3770
+ ...(Array.isArray(out.media) && out.media.length > 0 ? { media: out.media } : {}),
3218
3771
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3772
+ ...(extractState !== undefined ? { extract: extractState } : {}),
3219
3773
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3220
- }
3774
+ })
3221
3775
  }
3222
3776
  if (mode === 'none') placeMissing(doc)
3223
3777
  else {
@@ -3255,7 +3809,7 @@ export function apply(ctx) {
3255
3809
  if (revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0) {
3256
3810
  notes.push('↩ 这次改动可以退回(还能退 ' + revertDepth(snapshotKey(sessionId, loaded.absolute)) + ' 步):界面上「编辑 → 撤销 AI 改动」')
3257
3811
  }
3258
- return {
3812
+ return toLosslessJson({
3259
3813
  path: loaded.absolute,
3260
3814
  revision: doc.revision,
3261
3815
  nodeCount: doc.nodes.length,
@@ -3267,9 +3821,11 @@ export function apply(ctx) {
3267
3821
  changed: Array.isArray(out.changed) ? out.changed : [],
3268
3822
  removed: Array.isArray(out.removed) ? out.removed : [],
3269
3823
  ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3824
+ ...(Array.isArray(out.media) && out.media.length > 0 ? { media: out.media } : {}),
3270
3825
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3826
+ ...(extractState !== undefined ? { extract: extractState } : {}),
3271
3827
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3272
- }
3828
+ })
3273
3829
  },
3274
3830
  })
3275
3831
 
@@ -3453,6 +4009,18 @@ function sanitizeNewName(raw) {
3453
4009
  /** 最近一次导出的结果(`diagram_read` 把它带回给模型)。 */
3454
4010
  const exportResults = new Map()
3455
4011
 
4012
+ /**
4013
+ * AI 请求的抽帧计划(按会话):`{ requestId, absolute, clip, from, seconds, fps, width, max }`。
4014
+ *
4015
+ * 视频解码在浏览器里(宿主没有解码器),所以真实抽帧也是一条**请求/回执**通道:
4016
+ * AI 用 `extractFrames {seconds,fps,width}` 写计划 → 客户端轮询的 `read` 里取走 →
4017
+ * 浏览器解码视频写 `stills/NNNN.png` → POST 回 `media-extract-result` → 宿主清掉请求。
4018
+ * 模型侧想看进度就再调一次 `extractFrames`(回执里带 stills 数量)。
4019
+ */
4020
+ const extractRequests = new Map()
4021
+ /** 最近一次抽帧回执(`extractFrames` 把它带回给模型)。 */
4022
+ const extractResults = new Map()
4023
+
3456
4024
  /** 路径小工具:导出文件落在 `.drawio` 旁边,所以只需要在同级拼名字。 */
3457
4025
  function dirOf(absolute) {
3458
4026
  const text = String(absolute)
@@ -3483,6 +4051,29 @@ function sanitizeNewName(raw) {
3483
4051
  return { status: 'failed', format: done.format, error: String(done.error) }
3484
4052
  }
3485
4053
 
4054
+ /**
4055
+ * 这张画布上的抽帧状态:挂着计划 = pending;画布回过执 = done / failed;什么都没发生过 = undefined。
4056
+ * 与导出同一条护栏:路径不对就是别人的事,不报。
4057
+ */
4058
+ function extractStateFor(sessionId, absolute) {
4059
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
4060
+ const pending = extractRequests.get(sessionId)
4061
+ if (pending !== undefined && pending.absolute === absolute) {
4062
+ return {
4063
+ status: 'pending',
4064
+ clip: pending.clip,
4065
+ seconds: pending.seconds,
4066
+ fps: pending.fps,
4067
+ width: pending.width,
4068
+ max: pending.max,
4069
+ }
4070
+ }
4071
+ const done = extractResults.get(sessionId)
4072
+ if (done === undefined || done.absolute !== absolute) return undefined
4073
+ if (done.error.length > 0) return { status: 'failed', clip: done.clip, written: done.written, error: done.error }
4074
+ return { status: 'done', clip: done.clip, written: done.written }
4075
+ }
4076
+
3486
4077
  /**
3487
4078
  * 把"这次被删掉的单元"从选区记录里划掉。
3488
4079
  *
@@ -3587,6 +4178,47 @@ function sanitizeNewName(raw) {
3587
4178
  * 顺带把 meta.pinned 打成 true —— 从此这个文档被人手工摆过,
3588
4179
  * diagram_apply 不该再默认自动重排把它冲掉。
3589
4180
  */
4181
+ // 临时复刻包只由持有本次令牌的会话管理,取消不触及已有媒体。
4182
+ const videoTraceJobs = new Map()
4183
+ const videoUploadJobs = new Map()
4184
+ const videoExports = createVideoExportManager()
4185
+ const videoTracePool = createVideoTracePool()
4186
+ const wakeVideoTrace = (job) => {
4187
+ const waiters = job.waiters || []
4188
+ job.waiters = []
4189
+ for (const wake of waiters) wake()
4190
+ }
4191
+ const waitVideoTraceTurn = (job, index) => new Promise((resolve, reject) => {
4192
+ const check = () => {
4193
+ if (job.cancelled || job.failure || videoTraceJobs.get(job.token) !== job) {
4194
+ reject(new Error(job.failure || '复刻任务已取消'))
4195
+ return true
4196
+ }
4197
+ try {
4198
+ if (readManifest(job.dir).frames.length === index) {
4199
+ resolve()
4200
+ return true
4201
+ }
4202
+ } catch (error) {
4203
+ reject(error)
4204
+ return true
4205
+ }
4206
+ return false
4207
+ }
4208
+ if (check()) return
4209
+ const wake = () => { if (check()) return; job.waiters.push(wake) }
4210
+ job.waiters.push(wake)
4211
+ })
4212
+ ctx.effect(() => () => {
4213
+ for (const job of videoTraceJobs.values()) {
4214
+ job.perf.finish('interrupted')
4215
+ job.cancelled = true
4216
+ wakeVideoTrace(job)
4217
+ }
4218
+ videoTracePool.close()
4219
+ videoTraceJobs.clear(); videoUploadJobs.clear()
4220
+ return videoExports.dispose()
4221
+ })
3590
4222
  async function handleSave(req, res) {
3591
4223
  if (req.method !== 'POST') {
3592
4224
  sendJson(res, 405, { ok: false, error: 'POST only' })
@@ -3612,8 +4244,8 @@ function sanitizeNewName(raw) {
3612
4244
 
3613
4245
  let text
3614
4246
  try {
3615
- // 先按「看一眼」上限读:解析前不知道 action;非 render-result 下面再夹回 SAVE_MAX_BYTES。
3616
- text = await readBody(req, RENDER_RESULT_MAX_BYTES)
4247
+ // 解析前不知道 action,先受最大画布预算约束,再按 action 收紧。
4248
+ text = await readBody(req, CANVAS_SAVE_MAX_BYTES)
3617
4249
  } catch (error) {
3618
4250
  sendJson(res, 413, { ok: false, error: messageOf(error) })
3619
4251
  return
@@ -3629,14 +4261,64 @@ function sanitizeNewName(raw) {
3629
4261
  sendJson(res, 400, { ok: false, error: 'request body must be a JSON object' })
3630
4262
  return
3631
4263
  }
3632
- if (body.action !== 'render-result' && Buffer.byteLength(text, 'utf8') > SAVE_MAX_BYTES) {
3633
- sendJson(res, 413, { ok: false, error: 'request body exceeds ' + SAVE_MAX_BYTES + ' bytes' })
4264
+ const bodyLimit = body.action === 'render-result' ? RENDER_RESULT_MAX_BYTES
4265
+ : body.action === 'trace-node-image' ? IMAGE_TRACE_MAX_BYTES
4266
+ : (body.action === undefined || body.action === 'create') ? CANVAS_SAVE_MAX_BYTES : SAVE_MAX_BYTES
4267
+ if (Buffer.byteLength(text, 'utf8') > bodyLimit) {
4268
+ sendJson(res, 413, { ok: false, error: 'request body exceeds ' + bodyLimit + ' bytes' })
3634
4269
  return
3635
4270
  }
3636
4271
 
3637
4272
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
3638
4273
  const rawPath = typeof body.path === 'string' ? body.path : undefined
3639
4274
 
4275
+ // 仅计算图像节点的 SVG;由客户端通过同一历史/保存通道应用结果。
4276
+ if (body.action === 'trace-node-image') {
4277
+ if (sessionId === undefined || sessionOf(sessionId) === undefined || workspaceRootOf(sessionId) === undefined) {
4278
+ sendJson(res, 403, { ok: false, error: '无法确定当前会话工作区' })
4279
+ return
4280
+ }
4281
+ if (typeof body.image !== 'string' || !isRasterDataUri(body.image)) {
4282
+ sendJson(res, 400, { ok: false, error: '请选择已导入的 PNG/JPEG/GIF/WebP 图片节点' })
4283
+ return
4284
+ }
4285
+ const imagePerf = createPerformanceLog(workspaceRootOf(sessionId), 'image-trace', { level: body.level, preset: body.preset, colors: body.colors, removeBg: body.removeBg })
4286
+ try {
4287
+ const decodeDone = imagePerf.start('sourceReadAndDecode')
4288
+ const bytes = Buffer.from(body.image.slice(body.image.indexOf(',') + 1), 'base64')
4289
+ if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
4290
+ if (bytes.readUInt32BE(16) * bytes.readUInt32BE(20) > 16000000) throw new Error('图片超过 1600 万像素,请先缩小图片')
4291
+ }
4292
+ decodeDone()
4293
+ const traceDone = imagePerf.start('workerRequest')
4294
+ const traced = await videoTracePool.run(bytes, {
4295
+ mode: 'image', profile: imagePerf.enabled,
4296
+ level: body.level, preset: body.preset, colors: body.colors, removeBg: body.removeBg,
4297
+ includeDrawing: true,
4298
+ })
4299
+ traceDone()
4300
+ imagePerf.add('workerQueueWait', traced.queueWaitMs)
4301
+ imagePerf.merge('worker.', traced.performance)
4302
+ imagePerf.count('sourceBytes', bytes.length); imagePerf.count('svgBytes', Buffer.byteLength(traced.svg))
4303
+ imagePerf.finish('done', traced.rasterSize)
4304
+ sendJson(res, 200, { ok: true, svg: traced.svg, drawing: traced.drawing, metrics: {
4305
+ fragments: traced.metrics.fragments, colors: traced.metrics.colors,
4306
+ svgChars: traced.metrics.svgChars, budget: traced.metrics.budget,
4307
+ level: traced.metrics.fragmentLevel, attempts: traced.metrics.attempts,
4308
+ detailReduced: traced.metrics.detailReduced,
4309
+ targetFragments: traced.metrics.targetFragments,
4310
+ fragmentThresholds: traced.metrics.fragmentThresholds,
4311
+ targetReached: traced.metrics.targetReached,
4312
+ imageAnalysis: traced.metrics.imageAnalysis,
4313
+ resolutionRetries: traced.metrics.resolutionRetries, work: traced.metrics.work,
4314
+ } })
4315
+ } catch (error) {
4316
+ imagePerf.finish('failed')
4317
+ sendJson(res, 400, { ok: false, error: messageOf(error) })
4318
+ }
4319
+ return
4320
+ }
4321
+
3640
4322
  // action: 'list' —— 列出工作区里可打开的 .drawio,供「打开」菜单用。
3641
4323
  // 复用同一条路由而不是另开一个:信任围栏(POST + 自定义头 + 同源)只写一次,
3642
4324
  // 少一个口子就少一处可能忘记校验的地方。
@@ -3764,6 +4446,20 @@ function sanitizeNewName(raw) {
3764
4446
  const pendingRender = renderRequests.get(sessionId)
3765
4447
  const renderForThis =
3766
4448
  pendingRender !== undefined && pendingRender.absolute === loaded.absolute ? { requestId: pendingRender.requestId } : null
4449
+ // AI 下发的抽帧计划同理:画布取走后用浏览器解码视频、写 stills/,完成后 POST 回 media-extract-result。
4450
+ const pendingExtract = extractRequests.get(sessionId)
4451
+ const extractForThis =
4452
+ pendingExtract !== undefined && pendingExtract.absolute === loaded.absolute
4453
+ ? {
4454
+ requestId: pendingExtract.requestId,
4455
+ clip: pendingExtract.clip,
4456
+ from: pendingExtract.from,
4457
+ seconds: pendingExtract.seconds,
4458
+ fps: pendingExtract.fps,
4459
+ width: pendingExtract.width,
4460
+ max: pendingExtract.max,
4461
+ }
4462
+ : null
3767
4463
  sendJson(res, 200, {
3768
4464
  ok: true,
3769
4465
  exists: true,
@@ -3781,6 +4477,7 @@ function sanitizeNewName(raw) {
3781
4477
  selection: selectionForCanvas(sessionId, loaded.absolute, loaded.doc),
3782
4478
  export: exportForThis,
3783
4479
  render: renderForThis,
4480
+ media: extractForThis,
3784
4481
  })
3785
4482
  } catch (error) {
3786
4483
  sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
@@ -3997,6 +4694,34 @@ function sanitizeNewName(raw) {
3997
4694
  return
3998
4695
  }
3999
4696
 
4697
+ // action: 'media-extract-result' —— 画布抽完帧的回执(AI 的 `extractFrames {seconds,fps,width}` 走这条)。
4698
+ // 与 export-result 同一条护栏:只有"这个会话此刻真的挂着一份抽帧计划"才收。
4699
+ if (body.action === 'media-extract-result') {
4700
+ if (sessionId === undefined) {
4701
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
4702
+ return
4703
+ }
4704
+ const pending = extractRequests.get(sessionId)
4705
+ if (pending === undefined) {
4706
+ sendJson(res, 409, { ok: false, error: 'no pending extract request' })
4707
+ return
4708
+ }
4709
+ if (typeof body.requestId === 'string' && body.requestId !== pending.requestId) {
4710
+ sendJson(res, 409, { ok: false, error: 'stale extract request' })
4711
+ return
4712
+ }
4713
+ const written = Math.max(0, Math.floor(Number(body.written) || 0))
4714
+ extractRequests.delete(sessionId)
4715
+ extractResults.set(sessionId, {
4716
+ absolute: pending.absolute,
4717
+ clip: pending.clip,
4718
+ written: written,
4719
+ error: typeof body.error === 'string' ? body.error : '',
4720
+ })
4721
+ sendJson(res, 200, { ok: true, written: written })
4722
+ return
4723
+ }
4724
+
4000
4725
  // action: 'focus' —— 画布告诉宿主"用户现在打开的是哪一张"。
4001
4726
  // 之后 diagram_apply 不传 path 时就改它(见下面的 execute)。
4002
4727
  // 校验后**只存相对工作区的路径**:绝对路径能存但会让日志/提示变长,
@@ -4142,6 +4867,514 @@ function sanitizeNewName(raw) {
4142
4867
  return
4143
4868
  }
4144
4869
 
4870
+ // action: 'media-*' —— 旁路媒体包(*.media/<clip>/)的读写。不改 mxfile 时也不碰 revision。
4871
+ if (typeof body.action === 'string' && body.action.indexOf('media-') === 0) {
4872
+ if (sessionId === undefined || rawPath === undefined) {
4873
+ sendJson(res, 400, { ok: false, error: 'sessionId and path are required' })
4874
+ return
4875
+ }
4876
+ if (!String(rawPath).toLowerCase().endsWith('.drawio')) {
4877
+ sendJson(res, 403, { ok: false, error: 'media actions require a .drawio path' })
4878
+ return
4879
+ }
4880
+ const mediaRoot = workspaceRootOf(sessionId)
4881
+ if (mediaRoot === undefined) {
4882
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
4883
+ return
4884
+ }
4885
+ let mediaTarget
4886
+ try {
4887
+ mediaTarget = await ctx.fs.resolve(rawPath, { cwd: mediaRoot })
4888
+ } catch (error) {
4889
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
4890
+ return
4891
+ }
4892
+ try {
4893
+ const rootTarget = await ctx.fs.resolve(mediaRoot)
4894
+ if (!ctx.fs.contains(rootTarget, mediaTarget)) {
4895
+ sendJson(res, 403, { ok: false, error: 'path escapes the workspace root' })
4896
+ return
4897
+ }
4898
+ } catch (error) {
4899
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
4900
+ return
4901
+ }
4902
+ const drawioAbs = absoluteHint(mediaTarget, rawPath)
4903
+ const clip = sanitizeClipId(body.clip)
4904
+ try {
4905
+ if (body.action.startsWith('media-video-export-')) {
4906
+ const scope = { sessionId, drawioAbs, clip }
4907
+ let result
4908
+ switch (body.action) {
4909
+ case 'media-video-export-start': result = await videoExports.start(scope, body.format); break
4910
+ case 'media-video-export-chunk': result = videoExports.chunk(scope, body); break
4911
+ case 'media-video-export-frame': result = videoExports.frame(scope, body); break
4912
+ case 'media-video-export-finish': result = videoExports.finish(scope, body.token); break
4913
+ case 'media-video-export-status': result = videoExports.status(scope, body.token); break
4914
+ case 'media-video-export-read': result = videoExports.read(scope, body.token, body.offset); break
4915
+ case 'media-video-export-discard': result = await videoExports.discard(scope, body.token, body.performance); break
4916
+ default: throw new Error('未知视频导出操作')
4917
+ }
4918
+ sendJson(res, 200, { ...result, ok: true }); return
4919
+ }
4920
+ if (body.action === 'media-upload-start') {
4921
+ const size = Number(body.size), w = Number(body.w), h = Number(body.h)
4922
+ if (!['mp4', 'webm', 'mov', 'mkv'].includes(body.extension) || !Number.isInteger(size) || size <= 0 || size > 48 * 1024 * 1024 ||
4923
+ !Number.isInteger(w) || !Number.isInteger(h) || Math.min(w, h) <= 0 || Math.max(w, h) > 16384) throw new Error('无效视频文件:须为 48MB 以内的 MP4/WebM/MOV/MKV')
4924
+ for (const [key, job] of videoUploadJobs) if (Date.now() - job.time > 3600000) videoUploadJobs.delete(key)
4925
+ if (videoUploadJobs.size >= 16) throw new Error('导入任务过多,请稍后重试')
4926
+ const token = randomUUID(), newClip = 'video-' + randomUUID()
4927
+ ensureClipPack(drawioAbs, newClip, { kind: 'raster', w, h })
4928
+ const dir = clipDirFor(drawioAbs, newClip), part = joinPath(dir, 'source.upload')
4929
+ writeFileSync(part, Buffer.alloc(0))
4930
+ videoUploadJobs.set(token, { sessionId, drawioAbs, clip: newClip, dir, part, source: 'source.' + body.extension, size, received: 0, time: Date.now(), finished: false })
4931
+ sendJson(res, 200, { ok: true, clip: newClip, token })
4932
+ return
4933
+ }
4934
+ if (body.action.indexOf('media-upload-') === 0) {
4935
+ const job = videoUploadJobs.get(body.token)
4936
+ if (!job || job.sessionId !== sessionId || job.drawioAbs !== drawioAbs || job.clip !== clip) throw new Error('视频导入任务已失效')
4937
+ job.time = Date.now()
4938
+ if (body.action === 'media-upload-discard') {
4939
+ rmSync(job.dir, { recursive: true, force: true }); videoUploadJobs.delete(body.token)
4940
+ sendJson(res, 200, { ok: true }); return
4941
+ }
4942
+ if (body.action === 'media-upload-chunk') {
4943
+ const bytes = Buffer.from(typeof body.base64 === 'string' ? body.base64 : '', 'base64')
4944
+ if (job.finished || body.offset !== job.received || bytes.length < 1 || bytes.length > 1024 * 1024 || job.received + bytes.length > job.size) throw new Error('视频分块位置或大小无效')
4945
+ appendFileSync(job.part, bytes); job.received += bytes.length
4946
+ sendJson(res, 200, { ok: true, received: job.received }); return
4947
+ }
4948
+ if (body.action === 'media-upload-finish') {
4949
+ if (job.received !== job.size) throw new Error('视频尚未上传完整')
4950
+ if (!job.finished) {
4951
+ renameSync(job.part, joinPath(job.dir, job.source))
4952
+ const manifest = readManifest(job.dir); manifest.source = job.source
4953
+ writeManifest(job.dir, manifest); job.finished = true
4954
+ }
4955
+ sendJson(res, 200, { ok: true, manifest: readManifest(job.dir) }); return
4956
+ }
4957
+ if (body.action === 'media-upload-release' && job.finished) {
4958
+ videoUploadJobs.delete(body.token); sendJson(res, 200, { ok: true }); return
4959
+ }
4960
+ throw new Error('未知视频导入操作')
4961
+ }
4962
+ if (body.action === 'media-trace-start') {
4963
+ const fps = Number(body.fps), w = Number(body.w), h = Number(body.h), count = Number(body.count)
4964
+ const maxBytes = body.maxBytes === undefined ? 60000 : Number(body.maxBytes)
4965
+ if (!Number.isSafeInteger(count) || count < 1 || !Number.isFinite(fps) || fps <= 0) throw new Error('无效帧数或帧率')
4966
+ if (!Number.isInteger(w) || !Number.isInteger(h) || Math.min(w, h) < 1 || Math.max(w, h) > 16384 || w * h > 16000000) throw new Error('单帧处理上限为 1600 万像素、长边 16384px')
4967
+ if (!Number.isInteger(maxBytes) || maxBytes < 2000 || maxBytes > 248000) throw new Error('无效细节预算')
4968
+ for (const [key, job] of videoTraceJobs) {
4969
+ // 过期仅释放令牌;网络中断时结果可能已被画布引用,不能擅自删文件。
4970
+ if (!job.inFlight && Date.now() - job.time > 3600000) { job.perf.finish('expired'); videoTraceJobs.delete(key) }
4971
+ }
4972
+ if (videoTraceJobs.size >= 16) throw new Error('复刻任务过多,请稍后重试')
4973
+ const token = randomUUID(), newClip = 'trace-' + randomUUID()
4974
+ let originalSource = null
4975
+ if (body.sourceClip !== undefined) {
4976
+ const sourceDir = clipDirFor(drawioAbs, body.sourceClip)
4977
+ originalSource = findSourceFile(sourceDir, readManifest(sourceDir))
4978
+ if (!originalSource) throw new Error('原视频文件缺失,无法保留音频')
4979
+ if (!Number.isFinite(Number(body.sourceOffset)) || Number(body.sourceOffset) < 0) throw new Error('无效音频起始时间')
4980
+ }
4981
+ const pack = ensureClipPack(drawioAbs, newClip, { kind: 'svg', fps, w, h })
4982
+ const tracePerf = createPerformanceLog(dirnamePath(drawioAbs), 'video-trace', { fps, width: w, height: h, count, maxBytes, poolSize: videoTracePool.size })
4983
+ const sourceDone = tracePerf.start('sourceSnapshot')
4984
+ if (originalSource) {
4985
+ try {
4986
+ importSourceFile(pack.dir, originalSource.abs)
4987
+ const manifest = readManifest(pack.dir)
4988
+ manifest.kind = 'svg'; manifest.sourceOffset = Number(body.sourceOffset)
4989
+ writeManifest(pack.dir, manifest)
4990
+ pack.manifest = readManifest(pack.dir)
4991
+ } catch (error) { tracePerf.finish('failed'); rmSync(pack.dir, { recursive: true, force: true }); throw error }
4992
+ }
4993
+ sourceDone()
4994
+ videoTraceJobs.set(token, {
4995
+ token, sessionId, drawioAbs, clip: newClip, dir: clipDirFor(drawioAbs, newClip),
4996
+ count, maxBytes, time: Date.now(), nextSubmit: 0, inFlight: 0, uploads: new Map(), waiters: [], cancelled: false,
4997
+ lastFrame: null, palette: null, paletteKey: null, paletteIndex: -1,
4998
+ perf: tracePerf,
4999
+ })
5000
+ sendJson(res, 200, { ok: true, token, clip: newClip, manifest: pack.manifest, poolSize: videoTracePool.size,
5001
+ performanceEnabled: videoTraceJobs.get(token).perf.enabled, performanceId: videoTraceJobs.get(token).perf.id })
5002
+ return
5003
+ }
5004
+ if (body.action.indexOf('media-trace-') === 0) {
5005
+ const job = videoTraceJobs.get(body.token)
5006
+ if (!job || job.sessionId !== sessionId || job.drawioAbs !== drawioAbs || job.clip !== clip) throw new Error('复刻任务已失效')
5007
+ job.time = Date.now()
5008
+ if (body.action === 'media-trace-heartbeat') {
5009
+ sendJson(res, 200, { ok: true }); return
5010
+ }
5011
+ if (body.action === 'media-trace-discard') {
5012
+ job.perf.merge('browser.', body.performance); job.perf.finish('cancelled')
5013
+ job.cancelled = true
5014
+ wakeVideoTrace(job)
5015
+ videoTracePool.cancelToken(body.token)
5016
+ rmSync(job.dir, { recursive: true, force: true })
5017
+ videoTraceJobs.delete(body.token)
5018
+ sendJson(res, 200, { ok: true })
5019
+ return
5020
+ }
5021
+ let manifest = readManifest(job.dir)
5022
+ if (body.action === 'media-trace-frame-chunk') {
5023
+ const uploadDone = job.perf.start('frameChunkDecodeAndWrite')
5024
+ const index = Number(body.index)
5025
+ if (!Number.isInteger(index) || index !== job.nextSubmit || index >= job.count) throw new Error('帧分块位置无效')
5026
+ const upload = job.uploads.get(index) || { received: 0 }
5027
+ if (body.offset !== upload.received) throw new Error('帧分块位置无效')
5028
+ const bytes = Buffer.from(typeof body.base64 === 'string' ? body.base64 : '', 'base64')
5029
+ if (!bytes.length || bytes.length > 1024 * 1024 || upload.received + bytes.length > 68 * 1024 * 1024) throw new Error('帧分块大小无效')
5030
+ const part = joinPath(job.dir, 'frame.' + index + '.upload')
5031
+ if (body.offset === 0) writeFileSync(part, bytes)
5032
+ else appendFileSync(part, bytes)
5033
+ job.uploads.set(index, { received: upload.received + bytes.length })
5034
+ uploadDone(); job.perf.peak('frameUploadBytes', upload.received + bytes.length)
5035
+ sendJson(res, 200, { ok: true, received: upload.received + bytes.length })
5036
+ return
5037
+ }
5038
+ if (body.action === 'media-trace-frame') {
5039
+ const index = Number(body.index)
5040
+ if (job.cancelled || job.failure || !Number.isInteger(index) || index !== job.nextSubmit || index >= job.count) throw new Error(job.failure || '帧序号不连续或任务已取消')
5041
+ job.nextSubmit += 1
5042
+ job.inFlight += 1
5043
+ const part = joinPath(job.dir, 'frame.' + index + '.upload')
5044
+ let resolveFrame, rejectFrame, resolveKey, rejectKey
5045
+ const previous = job.lastFrame
5046
+ const promise = new Promise((resolve, reject) => { resolveFrame = resolve; rejectFrame = reject })
5047
+ const keyReady = new Promise((resolve, reject) => { resolveKey = resolve; rejectKey = reject })
5048
+ // Reserve in submission order, before asynchronous upload-file reads.
5049
+ // Metadata can finish out of order without making frame 0 await frame 1.
5050
+ promise.catch(() => {}); keyReady.catch(() => {})
5051
+ job.lastFrame = { keyReady, promise }
5052
+ try {
5053
+ const readDone = job.perf.start('frameReadAndBase64Decode')
5054
+ const png = typeof body.pngBase64 === 'string' ? Buffer.from(body.pngBase64, 'base64') : await readFile(part)
5055
+ readDone(); job.perf.count('uploadedPngBytes', png.length)
5056
+ if (job.cancelled || videoTraceJobs.get(body.token) !== job) throw new Error('复刻任务已取消')
5057
+ if (png.length < 24 || png.toString('hex', 0, 8) !== '89504e470d0a1a0a' ||
5058
+ png.readUInt32BE(16) !== manifest.width || png.readUInt32BE(20) !== manifest.height) throw new Error('PNG 帧尺寸与任务不一致')
5059
+ const traceOptions = videoTraceOptions(body, manifest, job.maxBytes)
5060
+ const optionsKey = JSON.stringify(traceOptions)
5061
+ const keyDone = job.perf.start('exactFrameHash')
5062
+ const key = exactVideoFrameKey(png, optionsKey)
5063
+ keyDone()
5064
+ resolveKey(key)
5065
+ const reuse = previous && await previous.keyReady === key
5066
+ let result
5067
+ if (reuse) {
5068
+ const reuseDone = job.perf.start('reuseWait')
5069
+ const cached = await previous.promise
5070
+ reuseDone()
5071
+ result = { svg: cached.svg, detailReduced: cached.detailReduced, work: cached.work, reused: true, paletteShared: false }
5072
+ } else {
5073
+ traceOptions.profile = job.perf.enabled
5074
+ // Age is measured from actual palette generation, never the previous
5075
+ // frame. Shared palettes cannot reset the clock or roll back a new one.
5076
+ const maxPaletteAge = Math.max(1, Math.min(120, Math.round(manifest.fps * 2)))
5077
+ if (job.palette && job.paletteKey === optionsKey && index - job.paletteIndex < maxPaletteAge) traceOptions.paletteOverride = job.palette
5078
+ result = await videoTracePool.run(png, traceOptions, { token: body.token })
5079
+ job.perf.add('workerQueueWait', result.queueWaitMs)
5080
+ job.perf.add('workerRoundTrip', result.workerRoundTripMs)
5081
+ job.perf.merge('worker.', result.performance)
5082
+ }
5083
+ const turnDone = job.perf.start('orderedCommitWait')
5084
+ await waitVideoTraceTurn(job, index)
5085
+ turnDone()
5086
+ if (job.cancelled || job.failure || videoTraceJobs.get(body.token) !== job) throw new Error(job.failure || '复刻任务已取消')
5087
+ const writeDone = job.perf.start('svgWrite')
5088
+ manifest = writeFrameSvg(job.dir, readManifest(job.dir), index, result.svg)
5089
+ writeDone()
5090
+ job.perf.count('frames'); job.perf.count('reusedFrames', result.reused ? 1 : 0)
5091
+ job.perf.count('paletteSharedFrames', result.paletteShared === true ? 1 : 0)
5092
+ job.perf.count('paletteRefreshFrames', !result.reused && !result.paletteShared ? 1 : 0)
5093
+ job.perf.count('paletteRejectedFrames', result.paletteReuseReason?.endsWith('-mismatch') ? 1 : 0)
5094
+ job.perf.count('svgBytes', Buffer.byteLength(result.svg)); job.perf.peak('inFlightFrames', job.inFlight)
5095
+ job.profileOptions = { level: traceOptions.level, preset: traceOptions.preset, colors: traceOptions.colors, removeBg: traceOptions.removeBg }
5096
+ if (!result.reused && !result.paletteShared) {
5097
+ job.palette = Array.isArray(result.palette) && result.palette.length >= 2 ? result.palette : null
5098
+ job.paletteKey = optionsKey; job.paletteIndex = index
5099
+ }
5100
+ job.uploads.delete(index)
5101
+ rmSync(part, { force: true })
5102
+ resolveFrame(result)
5103
+ wakeVideoTrace(job)
5104
+ sendJson(res, 200, {
5105
+ ok: true, svg: result.svg, count: manifest.frames.length, detailReduced: result.detailReduced, work: result.work,
5106
+ reused: result.reused === true, paletteShared: result.paletteShared === true,
5107
+ paletteReuseReason: result.reused ? 'exact-frame' : result.paletteReuseReason,
5108
+ })
5109
+ } catch (error) {
5110
+ job.failure = job.failure || String(error.message || error)
5111
+ rejectKey(error)
5112
+ rejectFrame?.(error)
5113
+ videoTracePool.cancelToken(body.token)
5114
+ wakeVideoTrace(job)
5115
+ job.perf.finish('failed', job.profileOptions); throw error
5116
+ } finally {
5117
+ job.inFlight = Math.max(0, job.inFlight - 1)
5118
+ }
5119
+ return
5120
+ }
5121
+ if (body.action === 'media-trace-finish' || body.action === 'media-trace-release') {
5122
+ if (manifest.frames.length !== job.count) throw new Error('复刻帧尚未全部完成')
5123
+ if (body.action === 'media-trace-finish') {
5124
+ job.perf.merge('browser.', body.performance); job.perf.finish('done', job.profileOptions)
5125
+ }
5126
+ if (body.action === 'media-trace-release') { job.perf.finish('done', job.profileOptions); videoTraceJobs.delete(body.token) }
5127
+ sendJson(res, 200, { ok: true, manifest })
5128
+ return
5129
+ }
5130
+ throw new Error('未知复刻操作')
5131
+ }
5132
+ if (body.action === 'media-ensure') {
5133
+ if (clip === null) {
5134
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5135
+ return
5136
+ }
5137
+ const pack = ensureClipPack(drawioAbs, clip, {
5138
+ kind: body.kind === 'raster' ? 'raster' : 'svg',
5139
+ fps: Number(body.fps),
5140
+ w: Number(body.w),
5141
+ h: Number(body.h),
5142
+ })
5143
+ sendJson(res, 200, { ok: true, clip: pack.clip, manifest: pack.manifest, mediaRoot: mediaRootFor(drawioAbs) })
5144
+ return
5145
+ }
5146
+ if (body.action === 'media-manifest') {
5147
+ if (clip === null) {
5148
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5149
+ return
5150
+ }
5151
+ const dir = clipDirFor(drawioAbs, clip)
5152
+ if (existsSync(dir) === false) {
5153
+ sendJson(res, 200, { ok: true, missing: true, manifest: null })
5154
+ return
5155
+ }
5156
+ const manifest = readManifest(dir)
5157
+ const source = findSourceFile(dir, manifest)
5158
+ sendJson(res, 200, {
5159
+ ok: true,
5160
+ missing: false,
5161
+ manifest: manifest,
5162
+ hasSource: source !== null,
5163
+ source: source !== null ? source.rel : null,
5164
+ })
5165
+ return
5166
+ }
5167
+ if (body.action === 'media-frame') {
5168
+ if (clip === null) {
5169
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5170
+ return
5171
+ }
5172
+ const dir = clipDirFor(drawioAbs, clip)
5173
+ const manifest = readManifest(dir)
5174
+ const index = Math.floor(Number(body.index))
5175
+ const svg = readFrameSvg(dir, manifest, index)
5176
+ sendJson(res, 200, { ok: true, index: index, svg: svg })
5177
+ return
5178
+ }
5179
+ if (body.action === 'media-write-frame') {
5180
+ if (clip === null) {
5181
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5182
+ return
5183
+ }
5184
+ const dir = clipDirFor(drawioAbs, clip)
5185
+ ensureClipPack(drawioAbs, clip, {})
5186
+ let manifest = readManifest(dir)
5187
+ if (Array.isArray(body.svgs)) {
5188
+ manifest = appendFrameSvgs(dir, manifest, body.svgs)
5189
+ } else if (typeof body.svg === 'string') {
5190
+ manifest = writeFrameSvg(dir, manifest, Number(body.index), body.svg)
5191
+ } else {
5192
+ sendJson(res, 400, { ok: false, error: 'svg or svgs required' })
5193
+ return
5194
+ }
5195
+ sendJson(res, 200, { ok: true, manifest: manifest })
5196
+ return
5197
+ }
5198
+ if (body.action === 'media-write-still') {
5199
+ if (clip === null) {
5200
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5201
+ return
5202
+ }
5203
+ const dir = clipDirFor(drawioAbs, clip)
5204
+ ensureClipPack(drawioAbs, clip, {})
5205
+ const index = Math.floor(Number(body.index))
5206
+ const b64 = typeof body.pngBase64 === 'string' ? body.pngBase64 : ''
5207
+ if (b64.length === 0) {
5208
+ sendJson(res, 400, { ok: false, error: 'pngBase64 required' })
5209
+ return
5210
+ }
5211
+ const file = writeStillPng(dir, index, Buffer.from(b64, 'base64'))
5212
+ sendJson(res, 200, { ok: true, file: file, index: index })
5213
+ return
5214
+ }
5215
+ if (body.action === 'media-import-source') {
5216
+ if (clip === null) {
5217
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5218
+ return
5219
+ }
5220
+ const file = typeof body.file === 'string' ? body.file : ''
5221
+ if (file.length === 0) {
5222
+ sendJson(res, 400, { ok: false, error: 'file required' })
5223
+ return
5224
+ }
5225
+ const srcTarget = await resolveTarget(file, sessionId)
5226
+ const srcAbs = absoluteHint(srcTarget, file)
5227
+ const dir = clipDirFor(drawioAbs, clip)
5228
+ ensureClipPack(drawioAbs, clip, { kind: 'raster' })
5229
+ const imported = importSourceFile(dir, srcAbs)
5230
+ sendJson(res, 200, { ok: true, source: imported.rel, manifest: imported.manifest })
5231
+ return
5232
+ }
5233
+ if (body.action === 'media-source-bytes') {
5234
+ if (clip === null) {
5235
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5236
+ return
5237
+ }
5238
+ const dir = clipDirFor(drawioAbs, clip)
5239
+ const manifest = readManifest(dir)
5240
+ const source = findSourceFile(dir, manifest)
5241
+ if (source === null) {
5242
+ sendJson(res, 404, { ok: false, error: 'no source video in clip' })
5243
+ return
5244
+ }
5245
+ const buf = await readFile(source.abs)
5246
+ // 上限约 48MB:更大的片请用外部播放器;JSON base64 约膨胀 33%。
5247
+ if (buf.length > 48 * 1024 * 1024) {
5248
+ sendJson(res, 413, { ok: false, error: 'source too large for inline transfer (>48MB)' })
5249
+ return
5250
+ }
5251
+ const ext = source.rel.toLowerCase().split('.').pop()
5252
+ const mime =
5253
+ ext === 'mp4' ? 'video/mp4' : ext === 'webm' ? 'video/webm' : ext === 'mov' ? 'video/quicktime' : 'application/octet-stream'
5254
+ sendJson(res, 200, { ok: true, rel: source.rel, mime: mime, base64: buf.toString('base64') })
5255
+ return
5256
+ }
5257
+ if (body.action === 'media-pack-bundle') {
5258
+ const zip = packCanvasBundle(drawioAbs)
5259
+ const outName =
5260
+ typeof body.out === 'string' && body.out.length > 0
5261
+ ? body.out
5262
+ : basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '.drawai.zip'
5263
+ const safe = String(outName).replace(/^[\\/]+/, '')
5264
+ if (safe.indexOf('..') >= 0) {
5265
+ sendJson(res, 400, { ok: false, error: 'invalid out path' })
5266
+ return
5267
+ }
5268
+ const abs = joinPath(mediaRoot, safe)
5269
+ await mkdir(dirnamePath(abs), { recursive: true })
5270
+ await writeFile(abs, zip)
5271
+ sendJson(res, 200, { ok: true, path: safe.replace(/\\/g, '/'), bytes: zip.length })
5272
+ return
5273
+ }
5274
+ if (body.action === 'media-unpack-bundle') {
5275
+ const file = typeof body.file === 'string' ? body.file : ''
5276
+ if (file.length === 0) {
5277
+ sendJson(res, 400, { ok: false, error: 'file required' })
5278
+ return
5279
+ }
5280
+ const zipTarget = await resolveTarget(file, sessionId)
5281
+ const zipAbs = absoluteHint(zipTarget, file)
5282
+ const buf = await readFile(zipAbs)
5283
+ const stem = typeof body.stem === 'string' && body.stem.length > 0 ? body.stem : undefined
5284
+ const destDir = dirnamePath(drawioAbs)
5285
+ const result = unpackCanvasBundle(buf, destDir, stem)
5286
+ sendJson(res, 200, { ok: true, path: result.path })
5287
+ return
5288
+ }
5289
+ if (body.action === 'media-pack-svgvideo') {
5290
+ if (clip === null) {
5291
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5292
+ return
5293
+ }
5294
+ const zip = packSvgVideoZip(clipDirFor(drawioAbs, clip))
5295
+ const outName =
5296
+ typeof body.out === 'string' && body.out.length > 0
5297
+ ? body.out
5298
+ : basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '-' + clip + '.svgvideo.zip'
5299
+ const safe = String(outName).replace(/^[\\/]+/, '')
5300
+ if (safe.indexOf('..') >= 0) {
5301
+ sendJson(res, 400, { ok: false, error: 'invalid out path' })
5302
+ return
5303
+ }
5304
+ const abs = joinPath(mediaRoot, safe)
5305
+ await mkdir(dirnamePath(abs), { recursive: true })
5306
+ await writeFile(abs, zip)
5307
+ sendJson(res, 200, { ok: true, path: safe.replace(/\\/g, '/'), bytes: zip.length })
5308
+ return
5309
+ }
5310
+ if (body.action === 'media-unpack-svgvideo') {
5311
+ if (clip === null) {
5312
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5313
+ return
5314
+ }
5315
+ const file = typeof body.file === 'string' ? body.file : ''
5316
+ if (file.length === 0) {
5317
+ sendJson(res, 400, { ok: false, error: 'file required' })
5318
+ return
5319
+ }
5320
+ const zipTarget = await resolveTarget(file, sessionId)
5321
+ const buf = await readFile(absoluteHint(zipTarget, file))
5322
+ ensureClipPack(drawioAbs, clip, { kind: 'svg' })
5323
+ const manifest = unpackSvgVideoZip(buf, clipDirFor(drawioAbs, clip))
5324
+ sendJson(res, 200, { ok: true, manifest: manifest })
5325
+ return
5326
+ }
5327
+ if (body.action === 'media-copy-beside') {
5328
+ const from = typeof body.fromPath === 'string' ? body.fromPath : ''
5329
+ if (from.length === 0) {
5330
+ sendJson(res, 400, { ok: false, error: 'fromPath required' })
5331
+ return
5332
+ }
5333
+ const fromTarget = await resolveTarget(from, sessionId)
5334
+ const result = copyMediaBeside(absoluteHint(fromTarget, from), drawioAbs)
5335
+ sendJson(res, 200, { ok: true, copied: result.copied, from: result.from, to: result.to })
5336
+ return
5337
+ }
5338
+ if (body.action === 'media-cleanup') {
5339
+ const doc = body.doc
5340
+ const removed = cleanupUnreferencedClips(drawioAbs, doc)
5341
+ sendJson(res, 200, { ok: true, removed: removed })
5342
+ return
5343
+ }
5344
+ if (body.action === 'media-export-source') {
5345
+ if (clip === null) {
5346
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5347
+ return
5348
+ }
5349
+ const dir = clipDirFor(drawioAbs, clip)
5350
+ const manifest = readManifest(dir)
5351
+ const source = findSourceFile(dir, manifest)
5352
+ if (source === null) {
5353
+ sendJson(res, 404, { ok: false, error: 'no source video' })
5354
+ return
5355
+ }
5356
+ const outName =
5357
+ typeof body.out === 'string' && body.out.length > 0
5358
+ ? body.out
5359
+ : basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '-' + clip + '-' + source.rel
5360
+ const safe = String(outName).replace(/^[\\/]+/, '')
5361
+ if (safe.indexOf('..') >= 0) {
5362
+ sendJson(res, 400, { ok: false, error: 'invalid out path' })
5363
+ return
5364
+ }
5365
+ const abs = joinPath(mediaRoot, safe)
5366
+ await mkdir(dirnamePath(abs), { recursive: true })
5367
+ await writeFile(abs, await readFile(source.abs))
5368
+ sendJson(res, 200, { ok: true, path: safe.replace(/\\/g, '/') })
5369
+ return
5370
+ }
5371
+ sendJson(res, 400, { ok: false, error: 'unknown media action: ' + body.action })
5372
+ } catch (error) {
5373
+ sendJson(res, 500, { ok: false, error: messageOf(error) })
5374
+ }
5375
+ return
5376
+ }
5377
+
4145
5378
  // action: 'create' —— 新建一张**真实存在**的空画布并绑定到它。
4146
5379
  //
4147
5380
  // 为什么不"留着以后再说":画布一旦没有文件,AI 对话就够不到它
@@ -4302,6 +5535,15 @@ function sanitizeNewName(raw) {
4302
5535
  sendJson(res, 500, { ok: false, error: 'write failed: ' + messageOf(error) + ' [sandbox: ' + scope + ']' })
4303
5536
  return
4304
5537
  }
5538
+ // 另存为:把源画布旁的 *.media 成对复制到新主名(节点只存 mediaClip,不必改 XML)。
5539
+ if (body.createOnly === true && typeof body.fromPath === 'string' && body.fromPath.length > 0) {
5540
+ try {
5541
+ const fromTarget = await resolveTarget(body.fromPath, sessionId)
5542
+ copyMediaBeside(absoluteHint(fromTarget, body.fromPath), absolute)
5543
+ } catch (error) {
5544
+ // 媒体复制失败不回滚画布写入:海报仍在;播放时会报媒体包缺失。
5545
+ }
5546
+ }
4305
5547
  sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped })
4306
5548
  }
4307
5549