dsh-drawai 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -15,8 +15,18 @@
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, realpathSync } from 'node:fs'
20
+ import { randomUUID, createHash } from 'node:crypto'
21
+ import { createDocumentCoordinator, createMediaWorkSlots } from './task-coordinator.js'
22
+ import { createDrawingTaskManager } from './drawing-tasks.js'
23
+ import { applyDrawingResult } from './drawing-results.js'
24
+ import { createTaskPatchJournal, applyTaskPatches } from './task-patches.js'
25
+ import { isAbsolute, resolve as resolvePath, join as joinPath, dirname as dirnamePath, basename as basenamePath, relative as relativePath } from 'node:path'
26
+ import { createVideoTracePool } from './video-trace-pool.js'
27
+ import { videoTraceOptions, exactVideoFrameKey } from './video-trace-reuse.js'
28
+ import { createPerformanceLog } from './performance-log.js'
29
+ import { createVideoExportManager } from './video-export.js'
20
30
  import { applyDocToMxfile, buildMxfile, contentHash, parseMxfile } from './mxfile.js'
21
31
  import { decodeRaster } from './raster.js'
22
32
  import {
@@ -24,8 +34,39 @@ import {
24
34
  TRACE_PRESET_NAMES,
25
35
  resolveTraceOptions,
26
36
  summarizeTraceForAi,
27
- traceRasterToSvg,
37
+ toLosslessJson,
38
+ traceRasterForToolbar,
28
39
  } from './trace-image.js'
40
+ import {
41
+ DEFAULT_MEDIA_FPS,
42
+ DEFAULT_MEDIA_H,
43
+ DEFAULT_MEDIA_W,
44
+ MAX_FRAME_SVG_CHARS,
45
+ allocateClipId,
46
+ appendFrameSvgs,
47
+ cleanupUnreferencedClips,
48
+ clipDirFor,
49
+ copyMediaBeside,
50
+ ensureClipPack,
51
+ findSourceFile,
52
+ importSourceFile,
53
+ listFrameEntries,
54
+ listStillIndices,
55
+ mediaRootFor,
56
+ packCanvasBundle,
57
+ packSvgVideoZip,
58
+ readFrameSvg,
59
+ readManifest,
60
+ readStillPng,
61
+ sanitizeClipId,
62
+ unpackCanvasBundle,
63
+ unpackSvgVideoZip,
64
+ videoDataAttrs,
65
+ videoMetaFromData,
66
+ writeFrameSvg,
67
+ writeManifest,
68
+ writeStillPng,
69
+ } from './media.js'
29
70
  import {
30
71
  ARROW_KINDS,
31
72
  DASH_KINDS,
@@ -33,6 +74,7 @@ import {
33
74
  DEFAULT_FONT,
34
75
  DEFAULT_NODE_STYLE,
35
76
  DEFAULT_STROKE,
77
+ DEFAULT_VIDEO_POSTER,
36
78
  NODE_SHAPES,
37
79
  NODE_SHAPE_STYLE,
38
80
  PALETTE,
@@ -49,12 +91,14 @@ import {
49
91
  isRasterDataUri,
50
92
  isSvgDataUri,
51
93
  isSvgMarkup,
94
+ isVideoNodeData,
52
95
  jettyFromStyle,
53
96
  lineKindFromStyle,
54
97
  nodeShapeFromStyle,
55
98
  normalizeDrawioDoc,
56
99
  normalizePoints,
57
100
  parseStyle,
101
+ reportNodeShape,
58
102
  sideFromStyle,
59
103
  styleWithArrow,
60
104
  styleWithAvoid,
@@ -413,6 +457,32 @@ function fontColorFieldOf(style) {
413
457
  */
414
458
  const DEFAULT_SVG_W = 80
415
459
  const DEFAULT_SVG_H = 80
460
+ const DEFAULT_VIDEO_W = DEFAULT_MEDIA_W
461
+ const DEFAULT_VIDEO_H = DEFAULT_MEDIA_H
462
+
463
+ /** 只碰旁路媒体、不改 .drawio 的 ops(单独出现时不写盘、不重排)。 */
464
+ const MEDIA_SIDE_OPS = {
465
+ listFrames: true,
466
+ getFrame: true,
467
+ setFrame: true,
468
+ appendFrames: true,
469
+ extractFrames: true,
470
+ traceFrame: true,
471
+ exportSvgVideo: true,
472
+ exportMediaSource: true,
473
+ }
474
+
475
+ function resolveVideoNode(doc, id, where) {
476
+ for (let i = 0; i < doc.nodes.length; i += 1) {
477
+ if (String(doc.nodes[i].id) !== String(id)) continue
478
+ const meta = videoMetaFromData(doc.nodes[i].data)
479
+ if (meta === null || meta.clip === null) {
480
+ throw new Error(where + ': node "' + id + '" is not a video node (missing drawaiMedia/mediaClip)')
481
+ }
482
+ return { node: doc.nodes[i], meta: meta, index: i }
483
+ }
484
+ throw new Error(where + ': unknown node "' + id + '"')
485
+ }
416
486
 
417
487
  /**
418
488
  * 一段内嵌 SVG 标记的长度上限。超了直接拒:一个单元格几十万字符会把文件和上下文一起撑爆。
@@ -519,6 +589,10 @@ function nodeStyleFromOp(style, op, where) {
519
589
  if (op.shape === 'svg' && nodeShapeFromStyle(out) !== 'svg') {
520
590
  throw new Error(where + ': shape "svg" 还要一起给内容 —— "svg": "<svg …>…</svg>"(形状本身决定不了画什么)')
521
591
  }
592
+ // video:style 层是海报 SVG;没给 svg 时塞缺省播放按钮海报。
593
+ if (op.shape === 'video' && nodeShapeFromStyle(out) !== 'svg') {
594
+ out = styleWithSvgMarkup(out, DEFAULT_VIDEO_POSTER, '0')
595
+ }
522
596
  if (typeof op.style === 'string' && op.style.length > 0) {
523
597
  // 文字元素(独立文字)没有填充与描边:给它调色板名要落到 **fontColor** 上,
524
598
  // 否则 AI 说 style:"red"、屏幕上一个像素都不变(而且文件里还多两个没用的键)。
@@ -831,6 +905,12 @@ async function applyOps(doc, ops, out, opctx) {
831
905
  if (rawImage === undefined) {
832
906
  throw new Error(where + ' needs a non-empty string "image"(工作区里那张位图的路径,png/jpg/webp/gif)')
833
907
  }
908
+ if (has(op, 'removeBg') === false) {
909
+ throw new Error(
910
+ where +
911
+ ' 必须显式传 removeBg:true(抠背景)或 removeBg:false(保留背景);不再支持 auto / 省略',
912
+ )
913
+ }
834
914
  const bytes = await opctx.readBytes(rawImage, where)
835
915
  let raster
836
916
  try {
@@ -869,6 +949,9 @@ async function applyOps(doc, ops, out, opctx) {
869
949
  bgBright: has(op, 'bgBright') ? op.bgBright : undefined,
870
950
  bgTol: has(op, 'bgTol') ? op.bgTol : undefined,
871
951
  bgColor: has(op, 'bgColor') ? op.bgColor : undefined,
952
+ removeBg: has(op, 'removeBg') ? op.removeBg : undefined,
953
+ bgSolidTol: has(op, 'bgSolidTol') ? op.bgSolidTol : undefined,
954
+ bgSolidBorderRatio: has(op, 'bgSolidBorderRatio') ? op.bgSolidBorderRatio : undefined,
872
955
  seeds: has(op, 'seeds') ? op.seeds : undefined,
873
956
  maxBytes: Math.min(rawTraceBudget, SVG_MARKUP_LIMIT - 2000),
874
957
  }
@@ -882,7 +965,7 @@ async function applyOps(doc, ops, out, opctx) {
882
965
  } catch (error) {
883
966
  throw new Error(where + ': ' + messageOf(error))
884
967
  }
885
- const traced = traceRasterToSvg(tracedFrom, resolved.options)
968
+ const traced = await opctx.traceRaster(tracedFrom, resolved.options)
886
969
  const metrics = traced.metrics
887
970
  if (traced.svg.length > SVG_MARKUP_LIMIT) {
888
971
  throw new Error(
@@ -894,11 +977,10 @@ async function applyOps(doc, ops, out, opctx) {
894
977
  // 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h 显式给了就用给的。
895
978
  //
896
979
  // 裁剪块的 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)
980
+ // 路径不会跟着走,内容会整体跑出可视区。默认节点框使用裁剪块原始尺寸,
981
+ // 与 cropX/cropY 配合按原图像素 1:1 放置;显式 w/h 表示裁剪块的目标尺寸。
982
+ const w = numberOr(op.w, tracedFrom.width)
983
+ const h = numberOr(op.h, tracedFrom.height)
902
984
  const synthetic = {
903
985
  op: 'addNode',
904
986
  svg: traced.svg,
@@ -935,8 +1017,11 @@ async function applyOps(doc, ops, out, opctx) {
935
1017
  '(工作图 ' + metrics.work.width + '×' + metrics.work.height +
936
1018
  ', scale ' + metrics.scaleApplied +
937
1019
  (metrics.sizeNotes ? '; ' + metrics.sizeNotes : '') +
938
- ',像素覆盖' +
939
- (metrics.exactPixelCoverage === true ? '精确' : '不精确 ' + metrics.drawnPixels + '/' + metrics.totalPixels) +
1020
+ ',量化图轮廓' +
1021
+ (metrics.exactPixelCoverage === true ? '无损' : '已简化/删减') +
1022
+ ',丢弃 ' + metrics.loopsDropped + ' 个环' +
1023
+ (metrics.attempts > 1 ? ',预算降级 epsilon=' + metrics.appliedEpsilon : '') +
1024
+ (metrics.budgetExceeded ? ',仍超请求预算' : '') +
940
1025
  ')' +
941
1026
  (metrics.tuneHint ? '\n 调参:' + metrics.tuneHint : ''),
942
1027
  )
@@ -961,7 +1046,8 @@ async function applyOps(doc, ops, out, opctx) {
961
1046
  // 别名:给这个名字之后就能在同一个 ops 数组里引用它(见 applyOps 开头的注释)。
962
1047
  registerAlias(op.as, id, 'ops[' + i + '] addNode')
963
1048
  const style = nodeStyleFromOp(DEFAULT_NODE_STYLE, op, 'ops[' + i + '] addNode')
964
- const svgNode = nodeShapeFromStyle(style) === 'svg'
1049
+ const videoNode = op.shape === 'video'
1050
+ const svgNode = videoNode === false && nodeShapeFromStyle(style) === 'svg'
965
1051
  const node = {
966
1052
  id: id,
967
1053
  label: label,
@@ -969,8 +1055,37 @@ async function applyOps(doc, ops, out, opctx) {
969
1055
  style: style,
970
1056
  // svg 节点缺省是**正方形**:按标签文字估宽(estimateWidth)对图没有意义。
971
1057
  // 空 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),
1058
+ // video 缺省 320×180(16:9 播放器框)。
1059
+ w: numberOr(op.w, videoNode ? DEFAULT_VIDEO_W : svgNode ? DEFAULT_SVG_W : estimateWidth(label)),
1060
+ h: numberOr(op.h, videoNode ? DEFAULT_VIDEO_H : svgNode ? DEFAULT_SVG_H : DEFAULT_H),
1061
+ }
1062
+ if (videoNode) {
1063
+ const existingClips = []
1064
+ for (let vi = 0; vi < doc.nodes.length; vi += 1) {
1065
+ const vm = videoMetaFromData(doc.nodes[vi].data)
1066
+ if (vm !== null && vm.clip !== null) existingClips.push(vm.clip)
1067
+ }
1068
+ const clip =
1069
+ sanitizeClipId(op.mediaClip) ||
1070
+ allocateClipId(existingClips)
1071
+ node.data = videoDataAttrs({
1072
+ clip: clip,
1073
+ kind: op.mediaKind === 'raster' ? 'raster' : 'svg',
1074
+ fps: numberOr(op.fps, numberOr(op.mediaFps, DEFAULT_MEDIA_FPS)),
1075
+ w: node.w,
1076
+ h: node.h,
1077
+ posterFrame: numberOr(op.posterFrame, 0),
1078
+ })
1079
+ if (opctx !== undefined && opctx !== null) {
1080
+ if (Array.isArray(opctx.ensureClips) === false) opctx.ensureClips = []
1081
+ opctx.ensureClips.push({
1082
+ clip: clip,
1083
+ kind: node.data.mediaKind,
1084
+ fps: Number(node.data.mediaFps),
1085
+ w: node.w,
1086
+ h: node.h,
1087
+ })
1088
+ }
974
1089
  }
975
1090
  if (typeof op.layer === 'string' && op.layer.length > 0) node.layer = resolveLayerRef(op.layer, 'ops[' + i + '] addNode layer')
976
1091
  // 位置:只有显式给了坐标才写入。缺省写 (0,0) 会让 placeMissing 以为"坐标齐全"而不补位,
@@ -981,7 +1096,7 @@ async function applyOps(doc, ops, out, opctx) {
981
1096
  recordCreated(id, 'node', label)
982
1097
  notes.push(
983
1098
  '+ node ' + id +
984
- (svgNode ? ' [svg]' : label.length > 0 ? ' "' + label + '"' : ' (empty label)') +
1099
+ (videoNode ? ' [video clip=' + node.data.mediaClip + ']' : svgNode ? ' [svg]' : label.length > 0 ? ' "' + label + '"' : ' (empty label)') +
985
1100
  (has(op, 'x') || has(op, 'y') ? ' at explicit coords' : ' (position pending)'),
986
1101
  )
987
1102
  continue
@@ -1588,9 +1703,271 @@ async function applyOps(doc, ops, out, opctx) {
1588
1703
  continue
1589
1704
  }
1590
1705
 
1706
+ // ── 视频媒体 ops(旁路 *.media/<clip>/;多数不改 .drawio)────────────────
1707
+ if (
1708
+ kind === 'listFrames' ||
1709
+ kind === 'getFrame' ||
1710
+ kind === 'setFrame' ||
1711
+ kind === 'appendFrames' ||
1712
+ kind === 'setMediaMeta' ||
1713
+ kind === 'importMedia' ||
1714
+ kind === 'extractFrames' ||
1715
+ kind === 'traceFrame' ||
1716
+ kind === 'exportSvgVideo' ||
1717
+ kind === 'exportMediaSource'
1718
+ ) {
1719
+ const drawioPath = opctx !== undefined && opctx !== null ? opctx.drawioPath : null
1720
+ if (typeof drawioPath !== 'string' || drawioPath.length === 0) {
1721
+ throw new Error('ops[' + i + '] ' + kind + ': 需要已绑定的画布路径(旁路媒体包跟 .drawio 走)')
1722
+ }
1723
+ const where = 'ops[' + i + '] ' + kind
1724
+ const id = typeof op.id === 'string' ? op.id : ''
1725
+ if (id.length === 0) throw new Error(where + ': "id" is required')
1726
+ const resolved = resolveVideoNode(doc, id, where)
1727
+ const clipDir = clipDirFor(drawioPath, resolved.meta.clip)
1728
+ ensureClipPack(drawioPath, resolved.meta.clip, {
1729
+ kind: resolved.meta.kind,
1730
+ fps: resolved.meta.fps,
1731
+ w: resolved.meta.w,
1732
+ h: resolved.meta.h,
1733
+ })
1734
+ let manifest = readManifest(clipDir)
1735
+
1736
+ if (kind === 'listFrames') {
1737
+ const listed = listFrameEntries(manifest, op.from, op.to)
1738
+ if (out !== undefined && out !== null) {
1739
+ if (Array.isArray(out.media) === false) out.media = []
1740
+ out.media.push({ op: kind, id: id, clip: resolved.meta.clip, ...listed })
1741
+ }
1742
+ notes.push('listFrames ' + id + ' → ' + listed.total + ' frame(s)')
1743
+ continue
1744
+ }
1745
+
1746
+ if (kind === 'getFrame') {
1747
+ const index = Math.floor(Number(op.index))
1748
+ const svg = readFrameSvg(clipDir, manifest, index)
1749
+ if (out !== undefined && out !== null) {
1750
+ if (Array.isArray(out.media) === false) out.media = []
1751
+ out.media.push({
1752
+ op: kind,
1753
+ id: id,
1754
+ clip: resolved.meta.clip,
1755
+ index: index,
1756
+ svg: svg.length > SVG_REPORT_LIMIT ? svg.slice(0, SVG_REPORT_LIMIT) + '…«truncated ' + svg.length + '»' : svg,
1757
+ svgChars: svg.length,
1758
+ })
1759
+ }
1760
+ notes.push('getFrame ' + id + '[' + index + '] ' + svg.length + ' chars')
1761
+ continue
1762
+ }
1763
+
1764
+ if (kind === 'setFrame') {
1765
+ if (typeof op.svg !== 'string') throw new Error(where + ': "svg" markup required')
1766
+ const index = Math.floor(Number(op.index))
1767
+ manifest = writeFrameSvg(clipDir, manifest, index, op.svg)
1768
+ if (resolved.meta.posterFrame === index) {
1769
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', op.svg, '0')
1770
+ recordChanged(id)
1771
+ }
1772
+ notes.push('setFrame ' + id + '[' + index + '] → ' + manifest.frames.length + ' frame(s)')
1773
+ continue
1774
+ }
1775
+
1776
+ if (kind === 'appendFrames') {
1777
+ if (Array.isArray(op.svgs) === false) throw new Error(where + ': "svgs" array required')
1778
+ const before = manifest.frames.length
1779
+ manifest = appendFrameSvgs(clipDir, manifest, op.svgs)
1780
+ notes.push('appendFrames ' + id + ' +' + (manifest.frames.length - before) + ' → ' + manifest.frames.length)
1781
+ continue
1782
+ }
1783
+
1784
+ if (kind === 'setMediaMeta') {
1785
+ const next = Object.assign({}, resolved.meta)
1786
+ if (has(op, 'fps') || has(op, 'mediaFps')) next.fps = numberOr(op.fps, numberOr(op.mediaFps, next.fps))
1787
+ if (has(op, 'kind') || has(op, 'mediaKind')) {
1788
+ const k = op.kind !== undefined ? op.kind : op.mediaKind
1789
+ next.kind = k === 'raster' ? 'raster' : 'svg'
1790
+ }
1791
+ if (has(op, 'posterFrame') || has(op, 'mediaPosterFrame')) {
1792
+ next.posterFrame = Math.floor(numberOr(op.posterFrame, numberOr(op.mediaPosterFrame, next.posterFrame)))
1793
+ }
1794
+ if (has(op, 'w') || has(op, 'mediaW')) next.w = numberOr(op.w, numberOr(op.mediaW, next.w))
1795
+ if (has(op, 'h') || has(op, 'mediaH')) next.h = numberOr(op.h, numberOr(op.mediaH, next.h))
1796
+ resolved.node.data = Object.assign({}, resolved.node.data, videoDataAttrs(next))
1797
+ manifest.kind = next.kind
1798
+ manifest.fps = next.fps
1799
+ manifest.width = next.w
1800
+ manifest.height = next.h
1801
+ writeManifest(clipDir, manifest)
1802
+ if (next.posterFrame >= 0 && next.posterFrame < manifest.frames.length) {
1803
+ try {
1804
+ const posterSvg = readFrameSvg(clipDir, manifest, next.posterFrame)
1805
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', posterSvg, '0')
1806
+ } catch (error) {
1807
+ // 海报帧暂不可读时保留原海报
1808
+ }
1809
+ }
1810
+ recordChanged(id)
1811
+ notes.push('setMediaMeta ' + id + ' kind=' + next.kind + ' fps=' + next.fps)
1812
+ continue
1813
+ }
1814
+
1815
+ if (kind === 'importMedia') {
1816
+ const file = typeof op.file === 'string' ? op.file : ''
1817
+ if (file.length === 0) throw new Error(where + ': "file" workspace path required')
1818
+ if (typeof opctx.readAbsolutePath !== 'function') {
1819
+ throw new Error(where + ': host cannot resolve workspace files')
1820
+ }
1821
+ const abs = await opctx.readAbsolutePath(file, where)
1822
+ const lower = abs.toLowerCase()
1823
+ if (/\.(webm|mp4|mov|mkv)$/.test(lower)) {
1824
+ const imported = importSourceFile(clipDir, abs)
1825
+ manifest = imported.manifest
1826
+ resolved.node.data = Object.assign({}, resolved.node.data, videoDataAttrs(Object.assign({}, resolved.meta, { kind: 'raster' })))
1827
+ recordChanged(id)
1828
+ notes.push('importMedia ' + id + ' ← ' + imported.rel + ' (raster)')
1829
+ } else if (/\.zip$/i.test(lower)) {
1830
+ const buf = await readFile(abs)
1831
+ manifest = unpackSvgVideoZip(buf, clipDir)
1832
+ resolved.node.data = Object.assign({}, resolved.node.data, videoDataAttrs(Object.assign({}, resolved.meta, { kind: 'svg' })))
1833
+ if (manifest.frames.length > 0) {
1834
+ try {
1835
+ const posterSvg = readFrameSvg(clipDir, manifest, 0)
1836
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', posterSvg, '0')
1837
+ } catch (error) {
1838
+ // ignore
1839
+ }
1840
+ }
1841
+ recordChanged(id)
1842
+ notes.push('importMedia ' + id + ' ← svg-video zip (' + manifest.frames.length + ' frames)')
1843
+ } else {
1844
+ throw new Error(where + ': unsupported media file (use .webm/.mp4/.mov/.mkv or .zip)')
1845
+ }
1846
+ continue
1847
+ }
1848
+
1849
+ if (kind === 'extractFrames') {
1850
+ // 宿主侧解不了视频:真实抽帧由客户端(<video> + canvas)写 stills/。
1851
+ // 两种用法:
1852
+ // 1) 只回报 —— `{op:'extractFrames', id}`:回执照抄 stills 数量(模型拿它看进度)。
1853
+ // 2) 下计划 —— 带上 seconds/fps/width,宿主把计划挂给画布,浏览器按计划抽帧。
1854
+ const every = Math.max(1, Math.floor(numberOr(op.every, 1)))
1855
+ const max = Math.max(1, Math.floor(numberOr(op.max, 24)))
1856
+ const source = findSourceFile(clipDir, manifest)
1857
+ const stills = listStillIndices(clipDir)
1858
+ const wantsPlan = has(op, 'seconds') || has(op, 'fps') || has(op, 'width') || has(op, 'from')
1859
+ const plan =
1860
+ wantsPlan === true
1861
+ ? {
1862
+ from: Math.max(0, numberOr(op.from, 0)),
1863
+ seconds: Math.max(0.1, Math.min(3600, numberOr(op.seconds, 5))),
1864
+ fps: Math.max(0.1, Math.min(60, numberOr(op.fps, manifest.fps))),
1865
+ width: Math.max(16, Math.min(2048, Math.round(numberOr(op.width, manifest.width)))),
1866
+ max: Math.max(1, Math.min(1200, Math.floor(numberOr(op.max, 360)))),
1867
+ }
1868
+ : null
1869
+ if (out !== undefined && out !== null) {
1870
+ if (Array.isArray(out.media) === false) out.media = []
1871
+ out.media.push({
1872
+ op: kind,
1873
+ id: id,
1874
+ clip: resolved.meta.clip,
1875
+ hasSource: source !== null,
1876
+ ...(source !== null ? { source: source.rel } : {}),
1877
+ stills: stills.length,
1878
+ plan:
1879
+ plan !== null
1880
+ ? Object.assign({}, plan, { note: '已交给宿主后台抽帧,完成后写入 stills/' })
1881
+ : { every: every, max: max, note: '带上 seconds/fps/width 提交宿主抽帧任务' },
1882
+ })
1883
+ // 计划由 applyOps 收集、由外层挂到画布轮询上(见 diagram_apply 尾部)。
1884
+ if (plan !== null) out.extract = Object.assign({ clip: resolved.meta.clip }, plan)
1885
+ }
1886
+ notes.push(
1887
+ 'extractFrames ' + id + ': source=' + (source !== null ? source.rel : 'none') +
1888
+ ', stills=' + stills.length + (plan !== null ? ', plan=' + plan.seconds + 's@' + plan.fps + 'fps ' + plan.width + 'w' : ' (report only)'),
1889
+ )
1890
+ continue
1891
+ }
1892
+
1893
+ if (kind === 'traceFrame') {
1894
+ const index = Math.floor(Number(op.index))
1895
+ if (has(op, 'removeBg') === false) {
1896
+ throw new Error(
1897
+ where +
1898
+ ' 必须显式传 removeBg:true(抠背景)或 removeBg:false(保留背景);不再支持 auto / 省略',
1899
+ )
1900
+ }
1901
+ if (typeof opctx.readBytes !== 'function' && existsSync(joinPath(clipDir, 'stills', String(index + 1).padStart(4, '0') + '.png')) === false) {
1902
+ // still 在 clip 内,直接读盘即可
1903
+ }
1904
+ let png
1905
+ try {
1906
+ png = readStillPng(clipDir, index)
1907
+ } catch (error) {
1908
+ throw new Error(where + ': ' + messageOf(error) + ' —— 先 extractFrames 下发抽帧计划并等待 stills/ 写出')
1909
+ }
1910
+ const raster = await decodeRaster(png)
1911
+ const traced = await opctx.traceRaster(raster, op)
1912
+ if (traced.svg.length > MAX_FRAME_SVG_CHARS) {
1913
+ throw new Error(where + ': traced svg too long (' + traced.svg.length + ' > ' + MAX_FRAME_SVG_CHARS + ')')
1914
+ }
1915
+ manifest = writeFrameSvg(clipDir, manifest, index, traced.svg)
1916
+ if (resolved.meta.posterFrame === index) {
1917
+ resolved.node.style = styleWithSvgMarkup(resolved.node.style || '', traced.svg, '0')
1918
+ recordChanged(id)
1919
+ }
1920
+ if (out !== undefined && out !== null) {
1921
+ if (Array.isArray(out.trace) === false) out.trace = []
1922
+ out.trace.push(Object.assign({ frame: index, id: id }, summarizeTraceForAi(traced.metrics)))
1923
+ }
1924
+ notes.push('traceFrame ' + id + '[' + index + '] → ' + traced.svg.length + ' chars')
1925
+ continue
1926
+ }
1927
+
1928
+ if (kind === 'exportSvgVideo') {
1929
+ const zip = packSvgVideoZip(clipDir)
1930
+ const outName =
1931
+ typeof op.out === 'string' && op.out.length > 0
1932
+ ? op.out
1933
+ : basenamePath(drawioPath).replace(/\.drawio$/i, '') + '-' + resolved.meta.clip + '.svgvideo.zip'
1934
+ if (typeof opctx.writeWorkspaceFile !== 'function') {
1935
+ throw new Error(where + ': cannot write export file')
1936
+ }
1937
+ const written = await opctx.writeWorkspaceFile(outName, zip, where)
1938
+ if (out !== undefined && out !== null) {
1939
+ if (Array.isArray(out.media) === false) out.media = []
1940
+ out.media.push({ op: kind, id: id, path: written, bytes: zip.length })
1941
+ }
1942
+ notes.push('exportSvgVideo ' + id + ' → ' + written)
1943
+ continue
1944
+ }
1945
+
1946
+ if (kind === 'exportMediaSource') {
1947
+ const source = findSourceFile(clipDir, manifest)
1948
+ if (source === null) throw new Error(where + ': no source.* video in clip')
1949
+ const outName =
1950
+ typeof op.out === 'string' && op.out.length > 0
1951
+ ? op.out
1952
+ : basenamePath(drawioPath).replace(/\.drawio$/i, '') + '-' + resolved.meta.clip + '-' + source.rel
1953
+ if (typeof opctx.writeWorkspaceFile !== 'function') {
1954
+ throw new Error(where + ': cannot write export file')
1955
+ }
1956
+ const buf = await readFile(source.abs)
1957
+ const written = await opctx.writeWorkspaceFile(outName, buf, where)
1958
+ if (out !== undefined && out !== null) {
1959
+ if (Array.isArray(out.media) === false) out.media = []
1960
+ out.media.push({ op: kind, id: id, path: written, bytes: buf.length })
1961
+ }
1962
+ notes.push('exportMediaSource ' + id + ' → ' + written)
1963
+ continue
1964
+ }
1965
+ }
1966
+
1591
1967
  throw new Error(
1592
1968
  'ops[' + i + '] unknown op ' + JSON.stringify(kind) +
1593
- '. Supported: addNode, addEdge, setLabel, setLabelPos, setStyle, setEdge, order, duplicate, addLayer, setLayer, setLayerProps, move, remove, highlight',
1969
+ '. Supported: addNode, addEdge, setLabel, setLabelPos, setStyle, setEdge, order, duplicate, addLayer, setLayer, setLayerProps, move, remove, highlight, ' +
1970
+ 'listFrames, getFrame, setFrame, appendFrames, setMediaMeta, importMedia, extractFrames, traceFrame, exportSvgVideo, exportMediaSource',
1594
1971
  )
1595
1972
  }
1596
1973
  // 结构化返回"这次到底动了什么":模型要用新建的 id(或确认改到了谁)时不必解析 summary 文本。
@@ -1874,9 +2251,12 @@ const MAX_SCAN_DIRS = 12
1874
2251
  export const SAVE_HEADER = 'x-drawai-save'
1875
2252
  /** 普通写回(存盘 / list / export-result 等)的请求体上限,防止一次误发把内存打满。 */
1876
2253
  const SAVE_MAX_BYTES = 4 * 1024 * 1024
2254
+ /** 画布包含原图 data URI 与复刻 SVG,使用独立的有界预算。 */
2255
+ const CANVAS_SAVE_MAX_BYTES = 128 * 1024 * 1024
2256
+ const IMAGE_TRACE_MAX_BYTES = 96 * 1024 * 1024
1877
2257
  /**
1878
2258
  * 「看一眼」PNG 回执(action: render-result)单独放开:base64 比原图大约大 1/3,
1879
- * 大图按 2× 渲很容易超过 4MB。其它 action 解析后仍按 SAVE_MAX_BYTES 拒收。
2259
+ * 大图按 2× 渲很容易超过 4MB。控制请求、原图绘制和画布写回分别限制。
1880
2260
  */
1881
2261
  const RENDER_RESULT_MAX_BYTES = 32 * 1024 * 1024
1882
2262
 
@@ -1973,7 +2353,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1973
2353
  {op:"setLayer", id|ids, layer}
1974
2354
  {op:"setLayerProps", layer, name?, visible?, locked?}
1975
2355
  {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?}
2356
+ {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
2357
  {op:"move", id|ids, dx?, dy?, x?, y?}
1978
2358
  {op:"remove", id|ids}
1979
2359
  {op:"highlight", ids:["n1","n2"]}
@@ -1998,7 +2378,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1998
2378
  渲染在画布那一半做,所以这一步是"请求":返回值/下一次 read 里的 export 状态
1999
2379
  pending → done(带文件路径)/ downloaded / failed。看到 pending 说明画布还没刷新 ——
2000
2380
  别反复请求,等下一次 read 再看(或提醒用户打开那张画布)。
2001
- - **把一张位图"描"成矢量图用 traceImage**:{op:"traceImage", image:"ref.png", x:40, y:40}。
2381
+ - **把一张位图"描"成矢量图用 traceImage**:{op:"traceImage", image:"ref.png", removeBg:false, x:40, y:40}。
2002
2382
  它**真的读那张图的像素**,建成一个 svg 内容节点。
2003
2383
  **不要用"手写一段近似的 svg"冒充复刻** —— 形状词汇表表达不了的照片/插画就直接用这个 op。
2004
2384
  · 默认 **w/h = 原图像素尺寸**、viewBox = 原图像素坐标系(1:1 铺满)。
@@ -2007,6 +2387,8 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
2007
2387
  - preset:"photo" 照片/渐变
2008
2388
  - preset:"simple" 图标/简单图(少色+降采样,省字符)
2009
2389
  - 不写 = "default"
2390
+ · **removeBg 必填**(无 auto):true = 抠背景(先识纯色底,否则近白泛洪);false = 整图保留。
2391
+ 白底/纯色底图标、透明 PNG 通常 true;风景/照片/带场景背景通常 false。回执看 bgRemoved / bgDetect。
2010
2392
  · 调参闭环:看返回值里的 **trace[]**(结构化指标 + hint),不满意就 **remove 旧节点再 traceImage**(改 preset/字段)。
2011
2393
  常见:字符紧 → scale:2 / 加大 epsilon / preset:"simple";线糊断 → preset:"lineart" 或提高 protectLuma/colorsDark。
2012
2394
  · 单次上限:SVG 标记 250000 字符(整图的精细度主要由它决定:预算紧时管线会抬 epsilon、丢小环,
@@ -2016,8 +2398,9 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
2016
2398
  工作分辨率上限是 grid(默认 499px 宽),整张立绘塞进去时"脸"只占三四十像素,眼睛只有 3~4px,
2017
2399
  量化后五官必然消失(实测把 colors 拉满 24、protectLuma 提到 235 也救不回来)。
2018
2400
  裁出头部再描,同一块就占满工作图宽度 —— 脸能有一两百像素,五官才留得住。
2019
- 裁剪块的坐标在**裁剪局部系**里,所以要按比例给它 w/h(= 整张图的 w/h × 裁剪块宽高 / 整块原尺寸),
2020
- x/y 不写时落在原图对应位置,可与整图那块叠着放(顺序在后 = 画在上面)。
2401
+ 裁剪块的 SVG 使用**裁剪局部系**,节点框负责缩放和放置。
2402
+ 默认 w/h 为裁剪块原始尺寸,x/y 为裁剪原点;显式 w/h 是裁剪块目标尺寸。
2403
+ 原图若已缩放,x/y 与 w/h 都需按同一比例换算,可与整图那块叠着放(顺序在后 = 画在上面)。
2021
2404
 
2022
2405
  - **改一组用 ids**:{op:"setStyle", ids:["n1","n2","n5"], style:"green"} —— setStyle / setLabel /
2023
2406
  move / remove 都收 ids,不要为了"把这几个改成绿色"发 12 个 op。批量 move 只收 dx/dy
@@ -2060,6 +2443,37 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
2060
2443
  - 糖(shape/style/dash/arrow/color/exit/entry/jettySize/edgeStyle/avoid)由宿主翻译成 drawio 的 style 键,
2061
2444
  **绝不落盘**;keys 用来写任意 drawio 键,值给 null = 删键回默认。
2062
2445
  - shape:rect | rounded | **text**(独立文字:无边框无底色的一段字)| stadium | ellipse | diamond | parallelogram | cylinder | document | hexagon | **svg**
2446
+ **节点标签与无边框节点**:图片、SVG 内容、视频与独立文字不提供无效描边工具,混合选区描边只作用于有边框的图形和连线。
2447
+ 用户可独立拖动节点文字到节点内部或外部,双击文字编辑(视频标签也一样)。独立文字节点仍整体拖动。
2448
+ 节点标签偏移以 labelOffsetX/labelOffsetY(像素)回报,写入原生 mxGeometry 的 mxPoint offset;不要与边标签 labelX/labelY 的相对量混淆。
2449
+ 偏移随保存、复制、撤销保留;移动节点时文字跟随,导出与适应内容包含节点外的文字。
2450
+ **视频节点(旁路媒体包)**:
2451
+ - **用户导入入口**:右侧「视频」先选本地文件,再点击画布放置;也可拖入视频或 Ctrl+V 粘贴剪贴板中的视频文件。
2452
+ 仅支持浏览器可解码的 MP4/WebM/MOV/MKV,单文件最多 48MB。画布须先保存,完整上传后才创建有封面的原视频节点,不创建空占位。
2453
+ - addNode {shape:"video", fps?, w?, h?, mediaKind?, mediaClip?, x, y} → 海报 + \`*.media/<clip>/\`
2454
+ - listFrames / getFrame / setFrame / appendFrames / setMediaMeta / importMedia
2455
+ - extractFrames(看 stills 计划)/ traceFrame(stills → SVG 帧)
2456
+ - exportSvgVideo / exportMediaSource
2457
+ - diagram_read 对视频只回 mediaKind/mediaClip/fps/frameCount/duration/hasSource,帧内容用 getFrame
2458
+ - **视频复刻交互**:用户选中已导入的普通视频,悬停或点击工具条「绘制复刻」展开设置浮窗。图片绘制同样使用悬停设置卡片。
2459
+ 复刻结果保留原视频音频与片段起点,播放可静音,SVG 视频包及 WebM 导出携带对应音频。
2460
+ 开始后视频在后台静默复刻,画布角落显示可收起的进度卡片,不占全屏、不抢焦点,可继续编辑画布、取消任务或失败后按原参数重试;切换画布/页面会取消任务。
2461
+ 视频工具条复用其他节点的图标在上、文字在下的样式及悬停反馈;绘制与导出使用同一个悬停卡片组件。
2462
+ 悬浮条保留绘制复刻、预览、抽帧、导出;SVG 节点隐藏绘制入口,导出菜单只展示当前媒体适用的格式。抽帧把当前画面放到右侧:源视频为 PNG,SVG 视频保留当前帧矢量。
2463
+ 悬停视频节点表面时显示进度条与播放/暂停;停止、逐帧、时间轴、循环和适配等完整控件集中在预览窗口,选区悬浮条不再提供设置封面按钮。
2464
+ 用户决定完整视频或起止时间范围、任意正数帧率(支持 25/29.97/30/60fps 等)、480/887/1440px 长边、原始尺寸或自定义长边。
2465
+ 碎片数量可选低/中/高,内部自动匹配每帧 60000/120000/248000 字符预算;保留画风、颜色数和去背景选项。
2466
+ 不再按 30 秒、360 帧或 640px 截断。面板估算帧数;源视频末尾自动截短,小视频不放大。
2467
+ 单帧保留 1600 万像素及 16384px 长边的处理边界,超过时明确提示用户选择较小尺寸,不静默降采样。
2468
+ 浏览器负责解码和分块传输高清 PNG;抽帧与常驻 Worker 池描边流水线重叠,帧按序落盘,仅计算选中碎片档位。图片原有的 887px 校准上限不应用于视频。
2469
+ 面板显示实际进度和最近完成帧,长 SVG 序列按需预取并最多缓存 48 帧内容。
2470
+ 原视频保留,全部成功后在右侧新建 SVG 视频节点;取消、失败、切换画布会停止任务,取消/失败清理本次临时媒体。
2471
+ 失败可重新复刻(从头开始)。完成后可播放、逐帧查看、撤销新增节点,以及导出 SVG 视频包。
2472
+ SVG 视频包为 .svgvideo.zip(manifest + SVG 帧 + 封面),不是单文件动态 SVG,不含音轨。逐帧绘制暂不做跨帧稳定处理。
2473
+ - **AI 分步处理视频**:先 importMedia,再 extractFrames {from,seconds,fps,width,max} 下发计划;
2474
+ 返回计划不等于完成,须再次查询 extractFrames 确认 stills 数量及回执,再按实际帧数逐个 traceFrame {id,index,removeBg,…}。
2475
+ 全部写出后 setMediaMeta {id,kind:"svg",fps,w,h,posterFrame:0},用 listFrames 核对帧数后 exportSvgVideo。
2476
+
2063
2477
  要"一段不带框的说明/标题文字"就用 addNode {shape:"text", label:"…", x, y} —— 它就是 drawio 的
2064
2478
  text 形状(样式原样写 text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0)。
2065
2479
  文字元素想换颜色要写 **fontColor**(setStyle {id, keys:{fontColor:"#b85450"}}):
@@ -2175,9 +2589,9 @@ HTML 标签按纯文本显示、图层结构原样保留(画布把它们叠在
2175
2589
  const SKILL_SUMMARY = {
2176
2590
  name: 'drawai-canvas',
2177
2591
  description:
2178
- '读写工作区里的 DrawAI 画布(.drawio,就是 drawio 的 mxfile):diagram_read 看现状、diagram_apply 结构化改图(自动布局、无损写回)。',
2592
+ '读写 DrawAI 画布(.drawio):diagram_read 看现状、diagram_apply 结构化改图;支持图像复刻、视频分段复刻及 SVG 帧序列导出。',
2179
2593
  whenToUse:
2180
- '用户让你画流程图/架构图/泳道图,或让你读/改某张 .drawio 画布,或问"这张图里有什么""画布上怎么操作"时。',
2594
+ '用户让你画流程图/架构图/泳道图、复刻图片或视频、将视频转为 SVG 帧动画,或让你读/改某张 .drawio 画布,或问"这张图里有什么""画布上怎么操作"时。',
2181
2595
  }
2182
2596
 
2183
2597
  /**
@@ -2254,6 +2668,7 @@ async function listCanvases(root, cwd, dir) {
2254
2668
 
2255
2669
 
2256
2670
  export function apply(ctx) {
2671
+ const documents = createDocumentCoordinator(), mediaSlots = createMediaWorkSlots(2), taskPatches = createTaskPatchJournal()
2257
2672
  function sessionOf(sessionId) {
2258
2673
  try {
2259
2674
  if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
@@ -2344,6 +2759,8 @@ export function apply(ctx) {
2344
2759
  pageCount: parsed.pageCount,
2345
2760
  pageIndex: parsed.pageIndex,
2346
2761
  pageNames: parsed.pageNames,
2762
+ pageId: parsed.pageId,
2763
+ pageIds: parsed.pageIds,
2347
2764
  }
2348
2765
  }
2349
2766
 
@@ -2515,6 +2932,8 @@ export function apply(ctx) {
2515
2932
  y: { type: 'number', required: true },
2516
2933
  w: { type: 'number', required: true },
2517
2934
  h: { type: 'number', required: true },
2935
+ labelOffsetX: { type: 'number' },
2936
+ labelOffsetY: { type: 'number' },
2518
2937
  // 这个单元在哪一层(图层 id)。多于一层的文档才带。
2519
2938
  layer: { type: 'string' },
2520
2939
  // 字色(设过且不是缺省黑字才有):命中调色板给名字,否则给十六进制。
@@ -2522,6 +2941,16 @@ export function apply(ctx) {
2522
2941
  // 容器父级(**只读信息**):单元挂在某个容器/分组里时才有。
2523
2942
  // 画布按绝对位置显示,所以"移动容器"不会带走它 —— 要一起动就把它们一起写进 ids。
2524
2943
  parent: { type: 'string' },
2944
+ // 视频节点摘要(shape=video 才有)。帧内容不塞进 read —— 用 getFrame / listFrames。
2945
+ // 漏声明会踩 additionalProperties:false:画布里一旦有视频节点,diagram_read 整次失败。
2946
+ mediaKind: { type: 'string' },
2947
+ mediaClip: { type: 'string' },
2948
+ fps: { type: 'number' },
2949
+ posterFrame: { type: 'number' },
2950
+ frameCount: { type: 'number' },
2951
+ duration: { type: 'number' },
2952
+ hasSource: { type: 'boolean' },
2953
+ mediaMissing: { type: 'boolean' },
2525
2954
  },
2526
2955
  },
2527
2956
  },
@@ -2745,7 +3174,32 @@ export function apply(ctx) {
2745
3174
  if (keepNode(n) === false) continue
2746
3175
  const style = typeof n.style === 'string' ? n.style : ''
2747
3176
  const svgMarkup = svgMarkupForReport(style)
2748
- const shapeName = nodeShapeFromStyle(style)
3177
+ const shapeName = reportNodeShape(n)
3178
+ const videoMeta = videoMetaFromData(n.data)
3179
+ const mediaSummary =
3180
+ videoMeta !== null && videoMeta.clip !== null
3181
+ ? (function () {
3182
+ const summary = {
3183
+ mediaKind: videoMeta.kind,
3184
+ mediaClip: videoMeta.clip,
3185
+ fps: videoMeta.fps,
3186
+ posterFrame: videoMeta.posterFrame,
3187
+ }
3188
+ try {
3189
+ const m = readManifest(clipDirFor(loaded.absolute, videoMeta.clip))
3190
+ summary.frameCount = Array.isArray(m.frames) ? m.frames.length : 0
3191
+ summary.duration = Number(m.duration) || 0
3192
+ summary.hasSource = findSourceFile(clipDirFor(loaded.absolute, videoMeta.clip), m) !== null
3193
+ summary.mediaMissing = false
3194
+ } catch (error) {
3195
+ summary.frameCount = 0
3196
+ summary.duration = 0
3197
+ summary.hasSource = false
3198
+ summary.mediaMissing = true
3199
+ }
3200
+ return summary
3201
+ })()
3202
+ : null
2749
3203
  nodes.push({
2750
3204
  id: String(n.id),
2751
3205
  label: typeof n.label === 'string' ? n.label : String(n.id),
@@ -2759,9 +3213,13 @@ export function apply(ctx) {
2759
3213
  y: numberOr(n.y, 0),
2760
3214
  w: numberOr(n.w, DEFAULT_W),
2761
3215
  h: numberOr(n.h, DEFAULT_H),
2762
- ...(typeof svgMarkup === 'string' ? { svg: svgMarkup } : {}),
3216
+ ...(Number.isFinite(n.labelOffsetX) ? { labelOffsetX: n.labelOffsetX } : {}),
3217
+ ...(Number.isFinite(n.labelOffsetY) ? { labelOffsetY: n.labelOffsetY } : {}),
3218
+ // 视频节点不回整段海报 svg(用 media 摘要);普通 svg 节点照旧回 svg 字段。
3219
+ ...(shapeName !== 'video' && typeof svgMarkup === 'string' ? { svg: svgMarkup } : {}),
2763
3220
  // 位图节点:只报有图,不把 base64 塞进上下文(用户侧粘贴/拖入)。
2764
3221
  ...(shapeName === 'image' ? { image: true } : {}),
3222
+ ...(mediaSummary !== null ? mediaSummary : {}),
2765
3223
  ...(typeof n.layer === 'string' && n.layer.length > 0 ? { layer: n.layer } : {}),
2766
3224
  // 容器父级:AI 得知道"这东西在哪个分组里",否则把容器挪走/删掉时它不知道会连带什么。
2767
3225
  ...(typeof n.parent === 'string' && n.parent.length > 0 ? { parent: n.parent } : {}),
@@ -2903,7 +3361,7 @@ export function apply(ctx) {
2903
3361
  '{op:"setEdge", id, from?, to?, fromPoint?, toPoint?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
2904
3362
  '{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
3363
  '{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?} / ' +
3364
+ '{op:"traceImage", image, removeBg, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, as?, layer?} / ' +
2907
3365
  '{op:"move", id|ids, dx?, dy?, x?, y?} / {op:"remove", id|ids} / {op:"highlight", ids:[...]};节点 id 省略时自动分配。' +
2908
3366
  '**改接一条边**用 setEdge(换 from/to 或某一端改成自由点)—— id、标签、标签位置、折点、端点约束全都留着;' +
2909
3367
  '不要 remove + addEdge 重画(那会把这些丢掉)。' +
@@ -2914,12 +3372,14 @@ export function apply(ctx) {
2914
3372
  '**图层**用 addLayer/setLayer/setLayerProps(加层、把单元移到别的层、改名/显示/隐藏/锁定;隐藏≠删除)。' +
2915
3373
  '**导出**用 export:format:"svg" 会让画布渲染一张 SVG 落在 .drawio 旁边、format:"png" 是浏览器下载;' +
2916
3374
  '这一步由画布执行,返回值里的 export.status 是 pending/done/downloaded/failed(pending = 画布还没刷新)。' +
2917
- '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y} 会真的读像素建成 svg 节点,' +
3375
+ '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", removeBg:false, x, y} 会真的读像素建成 svg 节点,' +
2918
3376
  '**不是凭印象重画**。默认 w/h = 原图像素尺寸(1:1)。' +
3377
+ '**removeBg 必填**(无 auto):true=抠背景(先识纯色底,否则近白泛洪);false=整图保留。' +
3378
+ '白底/纯色底图标通常 true;风景/照片/场景背景通常 false。' +
2919
3379
  '**调参**:先选 preset(' + TRACE_PRESET_NAMES.join('|') + ';lineart=插画线稿、photo=照片、simple=省字符、default=默认),' +
2920
3380
  '再按需覆盖 colors/colorsDark/scale/epsilon/grid/gridH/minArea/minRegionArea/protectLuma/majorityContrast/bgColor 等。' +
2921
3381
  '合并顺序:默认 ← preset ← 显式字段。' +
2922
- '返回值里的 **trace[]** 是结构化指标(svgChars/protectPixels/options/hint 等);' +
3382
+ '返回值里的 **trace[]** 是结构化指标(svgChars/bgRemoved/bgDetect/options/hint 等);' +
2923
3383
  '质量不够就根据 hint remove 旧节点再 trace(改 preset 或字段)。' +
2924
3384
  '字符紧→scale:2/加大epsilon/preset:simple;线糊断→preset:lineart 或提高 protectLuma/colorsDark。' +
2925
3385
  '**一次改一组**就写 ids:[...](setStyle/setLabel/move/remove 都收,move 的批量只收 dx/dy 相对位移)—— 用户说"把这几个换成绿色"时不要逐个发 op。' +
@@ -3013,7 +3473,23 @@ export function apply(ctx) {
3013
3473
  error: { type: 'string' },
3014
3474
  },
3015
3475
  },
3016
- // 本次调用里每次 traceImage 一条:给模型读指标再调参(additionalProperties:false 必须声明)
3476
+ // 抽帧状态(只有下过抽帧计划才有):pending = 计划挂在画布上、还没抽;done = stills 已写出
3477
+ // (written 是张数);failed = 抽帧时报错(error 是原因)。报告一次即清(取走即清)。
3478
+ extract: {
3479
+ type: 'object',
3480
+ additionalProperties: false,
3481
+ properties: {
3482
+ status: { type: 'string', required: true },
3483
+ clip: { type: 'string' },
3484
+ seconds: { type: 'number' },
3485
+ fps: { type: 'number' },
3486
+ width: { type: 'number' },
3487
+ max: { type: 'number' },
3488
+ written: { type: 'number' },
3489
+ error: { type: 'string' },
3490
+ },
3491
+ },
3492
+ // 本次调用里每次 traceImage / traceFrame 一条:给模型读指标再调参(additionalProperties:false 必须声明)
3017
3493
  trace: {
3018
3494
  type: 'array',
3019
3495
  items: {
@@ -3023,6 +3499,9 @@ export function apply(ctx) {
3023
3499
  preset: { type: 'string' },
3024
3500
  image: { type: 'string' },
3025
3501
  nodeId: { type: 'string' },
3502
+ // traceFrame 才有:视频节点 id 与帧下标
3503
+ id: { type: 'string' },
3504
+ frame: { type: 'number' },
3026
3505
  // 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)
3027
3506
  crop: {
3028
3507
  type: 'object',
@@ -3066,11 +3545,23 @@ export function apply(ctx) {
3066
3545
  budget: { type: 'number' },
3067
3546
  attempts: { type: 'number' },
3068
3547
  exactPixelCoverage: { type: 'boolean' },
3548
+ labelPartitionComplete: { type: 'boolean' },
3549
+ coverageBasis: { type: 'string' },
3550
+ loopsDropped: { type: 'number' },
3551
+ budgetExceeded: { type: 'boolean' },
3552
+ appliedEpsilon: { type: 'number' },
3553
+ bgRemoved: { type: 'boolean' },
3554
+ bgDetect: { type: 'json' },
3069
3555
  options: { type: 'json' },
3070
3556
  hint: { type: 'string' },
3071
3557
  },
3072
3558
  },
3073
3559
  },
3560
+ // listFrames / getFrame / extractFrames / export* 的结构化回执(heterogeneous —— 用 json)
3561
+ media: {
3562
+ type: 'array',
3563
+ items: { type: 'json' },
3564
+ },
3074
3565
  },
3075
3566
  },
3076
3567
  render: function (args, value) {
@@ -3087,7 +3578,7 @@ export function apply(ctx) {
3087
3578
  value.trace
3088
3579
  .map(function (t) {
3089
3580
  return (
3090
- (t.image || '?') +
3581
+ (t.image || (typeof t.frame === 'number' ? 'frame[' + t.frame + ']' : '?')) +
3091
3582
  ' preset=' +
3092
3583
  (t.preset || 'default') +
3093
3584
  ' ' +
@@ -3152,7 +3643,14 @@ export function apply(ctx) {
3152
3643
  // `highlight` 只是"让客户端选中这几个"、`export` 只是"让画布渲染一张图"—— 都不改文档:
3153
3644
  // · 一次全是这类 op 的调用**不写盘**(也不会顺手重排,那条特别危险);
3154
3645
  // · 混着编辑时,它们跟着一起生效。
3155
- const edits = ops.filter((op) => op === null || typeof op !== 'object' || (op.op !== 'highlight' && op.op !== 'export'))
3646
+ const edits = ops.filter((op) => {
3647
+ if (op === null || typeof op !== 'object') return true
3648
+ if (op.op === 'highlight' || op.op === 'export') return false
3649
+ // 旁路媒体 ops 默认不改 .drawio;setMediaMeta / importMedia / setFrame(海报) 可能改,
3650
+ // 下面用 out.changed 再判定是否真要写盘。
3651
+ if (MEDIA_SIDE_OPS[op.op] === true) return false
3652
+ return true
3653
+ })
3156
3654
  const noDocEdit = edits.length === 0
3157
3655
  // 默认布局:pinned、纯 highlight、以及**ops 里自带几何**(addNode 给了 x/y、有 move、
3158
3656
  // 或有 duplicate —— 复制出来的位置是相对原件的)时都不重排 —— 否则"把 n1 往右挪 40"
@@ -3170,7 +3668,10 @@ export function apply(ctx) {
3170
3668
  if (LAYOUTS.indexOf(mode) < 0) throw new Error('unknown layout "' + mode + '"; use one of ' + LAYOUTS.join(', '))
3171
3669
 
3172
3670
  const out = {}
3173
- const notes = await applyOps(doc, ops, out, {
3671
+ const opctx = {
3672
+ drawioPath: loaded.absolute,
3673
+ traceRaster: (raster, options) => videoTracePool.runRaster(raster, { ...options, mode: 'raw-image' }),
3674
+ ensureClips: [],
3174
3675
  /**
3175
3676
  * traceImage 的图片读取:走**同一个工作区解析器**(相对路径按会话根解析),
3176
3677
  * 于是"AI 能描哪张图"和"画布能打开哪些文件"是同一套围栏。
@@ -3194,7 +3695,41 @@ export function apply(ctx) {
3194
3695
  throw new Error(where + ': 读 "' + imagePath + '" 失败(' + messageOf(error) + ')')
3195
3696
  }
3196
3697
  },
3197
- })
3698
+ readAbsolutePath: async function (filePath, where) {
3699
+ const target = await resolveTarget(filePath, sessionId)
3700
+ const abs = absoluteHint(target, filePath)
3701
+ const info = await ctx.fs.stat(target)
3702
+ if (info === undefined) throw new Error(where + ': 工作区里没有 "' + filePath + '"')
3703
+ return abs
3704
+ },
3705
+ writeWorkspaceFile: async function (relPath, buffer, where) {
3706
+ const root = workspaceRootOf(sessionId)
3707
+ if (root === undefined) throw new Error(where + ': no workspace root')
3708
+ const safe = String(relPath).replace(/^[\\/]+/, '')
3709
+ if (safe.indexOf('..') >= 0) throw new Error(where + ': path must stay in workspace')
3710
+ const abs = joinPath(root, safe)
3711
+ await mkdir(dirnamePath(abs), { recursive: true })
3712
+ await writeFile(abs, buffer)
3713
+ return safe.replace(/\\/g, '/')
3714
+ },
3715
+ }
3716
+ const notes = await applyOps(doc, ops, out, opctx)
3717
+ // 新建 / 已有视频节点:确保旁路 `*.media/<clip>/` 存在。
3718
+ {
3719
+ const seen = new Set()
3720
+ const queue = Array.isArray(opctx.ensureClips) ? opctx.ensureClips.slice() : []
3721
+ for (let ni = 0; ni < doc.nodes.length; ni += 1) {
3722
+ const meta = videoMetaFromData(doc.nodes[ni].data)
3723
+ if (meta === null || meta.clip === null) continue
3724
+ queue.push({ clip: meta.clip, kind: meta.kind, fps: meta.fps, w: meta.w, h: meta.h })
3725
+ }
3726
+ for (let qi = 0; qi < queue.length; qi += 1) {
3727
+ const item = queue[qi]
3728
+ if (seen.has(item.clip)) continue
3729
+ seen.add(item.clip)
3730
+ ensureClipPack(loaded.absolute, item.clip, item)
3731
+ }
3732
+ }
3198
3733
  if (Array.isArray(out.highlight)) highlightFor.set(sessionId, out.highlight)
3199
3734
  // 删掉的东西不该还挂在"用户选中"里(id 会被复用,见 forgetSelection)。
3200
3735
  if (Array.isArray(out.removed) && out.removed.length > 0) forgetSelection(sessionId, loaded.absolute, out.removed)
@@ -3204,8 +3739,34 @@ export function apply(ctx) {
3204
3739
  exportRequests.set(sessionId, { requestId: requestId, absolute: loaded.absolute, format: out.export.format, name: out.export.name })
3205
3740
  exportResults.delete(sessionId)
3206
3741
  }
3207
- if (noDocEdit) {
3208
- return {
3742
+ // The host owns extraction; the browser receives status only.
3743
+ if (out.extract !== undefined && out.extract !== null) {
3744
+ const requestId = 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
3745
+ extractRequests.set(sessionId, {
3746
+ requestId: requestId,
3747
+ absolute: loaded.absolute,
3748
+ clip: out.extract.clip,
3749
+ from: out.extract.from,
3750
+ seconds: out.extract.seconds,
3751
+ fps: out.extract.fps,
3752
+ width: out.extract.width,
3753
+ max: out.extract.max,
3754
+ host: true,
3755
+ })
3756
+ extractResults.delete(sessionId)
3757
+ }
3758
+ // 抽帧状态:pending 得留着(模型据此看进度);done / failed **只报一次就清掉** ——
3759
+ // 否则这条回执会永远粘在之后每一次 diagram_apply 上(与 highlight「取走即清」同一套)。
3760
+ const extractState = extractStateFor(sessionId, loaded.absolute)
3761
+ if (extractState !== undefined && extractState.status !== 'pending') extractResults.delete(sessionId)
3762
+ const mediaChangedDoc =
3763
+ Array.isArray(out.changed) && out.changed.length > 0 &&
3764
+ ops.some((op) => op !== null && typeof op === 'object' && (op.op === 'setMediaMeta' || op.op === 'importMedia' || op.op === 'setFrame' || op.op === 'traceFrame'))
3765
+ if (noDocEdit && mediaChangedDoc === false) {
3766
+ if (out.extract) await startHostExtraction(sessionId, loaded, out.extract)
3767
+ // toLosslessJson:宿主对工具输出做 isJsonValue,自有属性值为 undefined / -0 会整次打回
3768
+ // (表现为 `returned invalid output: value is not lossless JSON`)。
3769
+ return toLosslessJson({
3209
3770
  path: loaded.absolute,
3210
3771
  revision: doc.revision,
3211
3772
  nodeCount: doc.nodes.length,
@@ -3215,9 +3776,11 @@ export function apply(ctx) {
3215
3776
  changed: [],
3216
3777
  removed: [],
3217
3778
  ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3779
+ ...(Array.isArray(out.media) && out.media.length > 0 ? { media: out.media } : {}),
3218
3780
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3781
+ ...(extractState !== undefined ? { extract: extractState } : {}),
3219
3782
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3220
- }
3783
+ })
3221
3784
  }
3222
3785
  if (mode === 'none') placeMissing(doc)
3223
3786
  else {
@@ -3255,7 +3818,8 @@ export function apply(ctx) {
3255
3818
  if (revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0) {
3256
3819
  notes.push('↩ 这次改动可以退回(还能退 ' + revertDepth(snapshotKey(sessionId, loaded.absolute)) + ' 步):界面上「编辑 → 撤销 AI 改动」')
3257
3820
  }
3258
- return {
3821
+ if (out.extract) await startHostExtraction(sessionId, loaded, out.extract)
3822
+ return toLosslessJson({
3259
3823
  path: loaded.absolute,
3260
3824
  revision: doc.revision,
3261
3825
  nodeCount: doc.nodes.length,
@@ -3267,9 +3831,11 @@ export function apply(ctx) {
3267
3831
  changed: Array.isArray(out.changed) ? out.changed : [],
3268
3832
  removed: Array.isArray(out.removed) ? out.removed : [],
3269
3833
  ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3834
+ ...(Array.isArray(out.media) && out.media.length > 0 ? { media: out.media } : {}),
3270
3835
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3836
+ ...(extractState !== undefined ? { extract: extractState } : {}),
3271
3837
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3272
- }
3838
+ })
3273
3839
  },
3274
3840
  })
3275
3841
 
@@ -3453,6 +4019,18 @@ function sanitizeNewName(raw) {
3453
4019
  /** 最近一次导出的结果(`diagram_read` 把它带回给模型)。 */
3454
4020
  const exportResults = new Map()
3455
4021
 
4022
+ /**
4023
+ * AI 请求的抽帧计划(按会话):`{ requestId, absolute, clip, from, seconds, fps, width, max }`。
4024
+ *
4025
+ * 视频解码在浏览器里(宿主没有解码器),所以真实抽帧也是一条**请求/回执**通道:
4026
+ * AI 用 `extractFrames {seconds,fps,width}` 写计划 → 客户端轮询的 `read` 里取走 →
4027
+ * 浏览器解码视频写 `stills/NNNN.png` → POST 回 `media-extract-result` → 宿主清掉请求。
4028
+ * 模型侧想看进度就再调一次 `extractFrames`(回执里带 stills 数量)。
4029
+ */
4030
+ const extractRequests = new Map()
4031
+ /** 最近一次抽帧回执(`extractFrames` 把它带回给模型)。 */
4032
+ const extractResults = new Map()
4033
+
3456
4034
  /** 路径小工具:导出文件落在 `.drawio` 旁边,所以只需要在同级拼名字。 */
3457
4035
  function dirOf(absolute) {
3458
4036
  const text = String(absolute)
@@ -3483,6 +4061,29 @@ function sanitizeNewName(raw) {
3483
4061
  return { status: 'failed', format: done.format, error: String(done.error) }
3484
4062
  }
3485
4063
 
4064
+ /**
4065
+ * 这张画布上的抽帧状态:挂着计划 = pending;画布回过执 = done / failed;什么都没发生过 = undefined。
4066
+ * 与导出同一条护栏:路径不对就是别人的事,不报。
4067
+ */
4068
+ function extractStateFor(sessionId, absolute) {
4069
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
4070
+ const pending = extractRequests.get(sessionId)
4071
+ if (pending !== undefined && pending.absolute === absolute) {
4072
+ return {
4073
+ status: 'pending',
4074
+ clip: pending.clip,
4075
+ seconds: pending.seconds,
4076
+ fps: pending.fps,
4077
+ width: pending.width,
4078
+ max: pending.max,
4079
+ }
4080
+ }
4081
+ const done = extractResults.get(sessionId)
4082
+ if (done === undefined || done.absolute !== absolute) return undefined
4083
+ if (done.error?.length > 0) return { status: 'failed', clip: done.clip, written: done.written, error: done.error }
4084
+ return { status: 'done', clip: done.clip, written: done.written }
4085
+ }
4086
+
3486
4087
  /**
3487
4088
  * 把"这次被删掉的单元"从选区记录里划掉。
3488
4089
  *
@@ -3587,6 +4188,174 @@ function sanitizeNewName(raw) {
3587
4188
  * 顺带把 meta.pinned 打成 true —— 从此这个文档被人手工摆过,
3588
4189
  * diagram_apply 不该再默认自动重排把它冲掉。
3589
4190
  */
4191
+ // 临时复刻包只由持有本次令牌的会话管理,取消不触及已有媒体。
4192
+ const videoTraceJobs = new Map()
4193
+ const videoUploadJobs = new Map()
4194
+ const videoExports = createVideoExportManager({ slots: mediaSlots })
4195
+ const videoTracePool = createVideoTracePool()
4196
+ const drawingTasks = createDrawingTaskManager({ pool: videoTracePool, slots: mediaSlots,
4197
+ commit: commitDrawingTask, undo: undoDrawingTask,
4198
+ publishExtraction: (job, action) => documents.run(job.scope.drawioAbs, async () => {
4199
+ const loaded = await loadTaskDocument(job)
4200
+ const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
4201
+ if (videoMetaFromData(source?.data)?.clip !== job.source.clip) throw new Error('抽帧来源已更改,结果已保留在任务目录')
4202
+ await checkVideoInput(job)
4203
+ return action()
4204
+ }),
4205
+ extracted: (job, output) => {
4206
+ extractRequests.delete(job.scope.sessionId)
4207
+ extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: output.written, taskId: job.id })
4208
+ },
4209
+ failed: (job) => {
4210
+ if (job.kind === 'extract') {
4211
+ extractRequests.delete(job.scope.sessionId)
4212
+ extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: job.done, error: job.error || '抽帧已取消' })
4213
+ }
4214
+ },
4215
+ })
4216
+ const signature = (value) => createHash('sha256').update(value).digest('hex')
4217
+ const stableValue = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
4218
+ ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item)
4219
+ async function loadTaskDocument(job) {
4220
+ let loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, job.scope.page)
4221
+ if (!loaded.exists) throw new Error('原画布已删除,结果已保留')
4222
+ if (job.scope.pageId) {
4223
+ const page = loaded.pageIds.indexOf(job.scope.pageId)
4224
+ if (page < 0 || loaded.pageIds.filter((id) => id === job.scope.pageId).length !== 1) throw new Error('原页面已删除或身份冲突,结果已保留')
4225
+ if (page !== loaded.pageIndex) loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, page)
4226
+ } else if (JSON.stringify(loaded.pageNames) !== JSON.stringify(job.scope.pageNames)) throw new Error('页面身份已改变,结果已保留')
4227
+ return loaded
4228
+ }
4229
+ async function writeTaskDocument(job, loaded, node, removed = []) {
4230
+ const policy = policyFor(job.scope.sessionId)
4231
+ if (policy?.mode === 'read-only') throw new Error('当前会话为只读,绘制结果已保留')
4232
+ const rendered = renderDocText(loaded.text, loaded.doc, { page: loaded.pageIndex })
4233
+ // The coordinator serializes plugin writers; a fresh check also detects
4234
+ // external edits which happened while an asynchronous source check ran.
4235
+ if (contentHash(await ctx.fs.readText(loaded.target)) !== contentHash(loaded.text)) throw new Error('文件已被其他编辑器修改,请重试加入结果')
4236
+ const savedNode = node ? normalizeDoc(parseMxfile(rendered.text, { page: loaded.pageIndex }).doc).nodes.find((item) => item.id === node.id) : null
4237
+ if (savedNode) drawingTasks.recordCommit(job, savedNode)
4238
+ await ctx.fs.writeText(loaded.target, rendered.text, undefined, undefined, policy)
4239
+ taskPatches.record(loaded.absolute, contentHash(loaded.text), rendered.revision, loaded.pageIndex, savedNode ? [savedNode] : [], removed, removed.length && job.createdNode ? [job.createdNode] : [])
4240
+ return { nodeId: savedNode?.id, node: savedNode, page: loaded.pageIndex }
4241
+ }
4242
+ async function commitDrawingTask(job, output) {
4243
+ return documents.run(job.scope.drawioAbs, async () => {
4244
+ const loaded = await loadTaskDocument(job)
4245
+ const existing = loaded.doc.nodes.find((node) => node.data?.drawaiTaskId === job.id)
4246
+ if (existing) return { nodeId: existing.id, node: job.createdNode, page: loaded.pageIndex }
4247
+ const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
4248
+ if (!source) throw new Error('来源节点已删除,结果已保留')
4249
+ const image = job.kind === 'image' ? imageValueFromStyle(source.style || '') : ''
4250
+ if (job.kind === 'image' && signature(image) !== job.source.signature) throw new Error('原图已更改,结果已保留')
4251
+ if (job.kind === 'video') {
4252
+ await checkVideoInput(job)
4253
+ }
4254
+ const node = applyDrawingResult(loaded.doc, { ...job, source: { ...job.source, image } }, output)
4255
+ loaded.doc.meta = { ...loaded.doc.meta, pinned: true }
4256
+ return writeTaskDocument(job, loaded, node)
4257
+ })
4258
+ }
4259
+ async function checkVideoInput(job) {
4260
+ const dir = clipDirFor(job.scope.drawioAbs, job.source.clip), original = findSourceFile(dir, readManifest(dir))
4261
+ if (!original) throw new Error('原视频已删除,结果已保留')
4262
+ const rel = relativePath(realpathSync(dir), realpathSync(original.abs))
4263
+ if (isAbsolute(rel) || rel.startsWith('..') || signature(await readFile(original.abs)) !== job.inputHash) throw new Error('原视频内容或路径已更改,结果已保留')
4264
+ }
4265
+ async function undoDrawingTask(job) {
4266
+ return documents.run(job.scope.drawioAbs, async () => {
4267
+ const loaded = await loadTaskDocument(job)
4268
+ const node = loaded.doc.nodes.find((item) => item.data?.drawaiTaskId === job.id)
4269
+ if (!node) return
4270
+ if (stableValue(node) !== stableValue(job.createdNode)) throw new Error('生成节点已被修改,请在画布中撤销或删除,避免丢失后续编辑')
4271
+ if (loaded.doc.edges.some((edge) => edge.from === node.id || edge.to === node.id)) throw new Error('生成节点已连接新连线,请在画布中处理')
4272
+ loaded.doc.nodes = loaded.doc.nodes.filter((item) => item.id !== node.id)
4273
+ loaded.doc.drawOrder = (loaded.doc.drawOrder || []).filter((id) => id !== node.id)
4274
+ await writeTaskDocument(job, loaded, null, [node.id])
4275
+ })
4276
+ }
4277
+ async function enqueueDrawing(scope, body) {
4278
+ return documents.run(scope.drawioAbs, async () => {
4279
+ if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读,无法创建绘制任务')
4280
+ if (!Number.isInteger(body.page ?? 0) || (body.page ?? 0) < 0) throw new Error('页面位置无效')
4281
+ const loaded = await loadDoc(scope.drawioAbs, scope.sessionId, body.page || 0)
4282
+ if (loaded.pageIndex !== (body.page || 0)) throw new Error('页面已不存在')
4283
+ if (!loaded.exists) throw new Error('请先创建画布')
4284
+ if (body.revision && body.revision !== loaded.doc.revision) throw new Error('画布版本已更新,请等待同步后重试')
4285
+ const kind = body.kind
4286
+ if (!['image', 'video', 'extract'].includes(kind)) throw new Error('未知绘制类型')
4287
+ const source = loaded.doc.nodes.find((node) => node.id === body.sourceId)
4288
+ if (!source) throw new Error('来源节点不存在')
4289
+ const options = { level: 'medium', preset: 'default', colors: 12, removeBg: false, ...(body.options || {}) }
4290
+ if (!['low', 'medium', 'high'].includes(options.level) || !TRACE_PRESET_NAMES.includes(options.preset) ||
4291
+ !Number.isInteger(Number(options.colors)) || options.colors < 2 || options.colors > 24 || typeof options.removeBg !== 'boolean') throw new Error('绘制参数无效')
4292
+ const taskScope = { ...scope, page: loaded.pageIndex, pageId: loaded.pageId, pageNames: loaded.pageNames,
4293
+ pageName: loaded.pageNames[loaded.pageIndex] }
4294
+ let input, taskSource
4295
+ if (kind === 'image') {
4296
+ const image = imageValueFromStyle(source.style || '')
4297
+ if (!isRasterDataUri(image)) throw new Error('请选择位图来源')
4298
+ input = Buffer.from(image.slice(image.indexOf(',') + 1), 'base64')
4299
+ taskSource = { id: source.id, signature: signature(image) }
4300
+ } else {
4301
+ const meta = videoMetaFromData(source.data)
4302
+ if (meta?.kind !== 'raster') throw new Error('请选择原视频来源')
4303
+ const dir = clipDirFor(scope.drawioAbs, meta.clip), original = findSourceFile(dir, readManifest(dir))
4304
+ if (!original) throw new Error('原视频文件缺失')
4305
+ const mediaRel = relativePath(realpathSync(scope.workspaceRoot), realpathSync(dir))
4306
+ if (isAbsolute(mediaRel) || mediaRel.startsWith('..')) throw new Error('视频媒体目录超出工作区')
4307
+ const file = realpathSync(original.abs), rel = relativePath(realpathSync(dir), file)
4308
+ if (isAbsolute(rel) || rel.startsWith('..')) throw new Error('原视频路径超出媒体包')
4309
+ input = file
4310
+ taskSource = { id: source.id, clip: meta.clip, extension: /\.[a-z0-9]+$/i.exec(file)?.[0] || '.mp4', signature: meta.clip + ':' + signature(await readFile(file)) }
4311
+ }
4312
+ return drawingTasks.enqueue(taskScope, { kind, options, source: taskSource, label: source.label || (kind === 'image' ? '图片' : '视频') + ' · ' + source.id }, input)
4313
+ })
4314
+ }
4315
+ async function startHostExtraction(sessionId, loaded, plan) {
4316
+ const current = await loadDoc(loaded.absolute, sessionId, loaded.pageIndex)
4317
+ const source = current.doc.nodes.find((node) => videoMetaFromData(node.data)?.clip === plan.clip)
4318
+ if (!source) throw new Error('抽帧来源节点已不存在')
4319
+ const job = await enqueueDrawing({ sessionId, path: loaded.absolute, drawioAbs: loaded.absolute, workspaceRoot: workspaceRootOf(sessionId) },
4320
+ { kind: 'extract', sourceId: source.id, page: current.pageIndex, options: { ...plan, range: 'segment' } })
4321
+ const pending = extractRequests.get(sessionId)
4322
+ if (pending) pending.taskId = job.id
4323
+ }
4324
+ const wakeVideoTrace = (job) => {
4325
+ const waiters = job.waiters || []
4326
+ job.waiters = []
4327
+ for (const wake of waiters) wake()
4328
+ }
4329
+ const waitVideoTraceTurn = (job, index) => new Promise((resolve, reject) => {
4330
+ const check = () => {
4331
+ if (job.cancelled || job.failure || videoTraceJobs.get(job.token) !== job) {
4332
+ reject(new Error(job.failure || '复刻任务已取消'))
4333
+ return true
4334
+ }
4335
+ try {
4336
+ if (readManifest(job.dir).frames.length === index) {
4337
+ resolve()
4338
+ return true
4339
+ }
4340
+ } catch (error) {
4341
+ reject(error)
4342
+ return true
4343
+ }
4344
+ return false
4345
+ }
4346
+ if (check()) return
4347
+ const wake = () => { if (check()) return; job.waiters.push(wake) }
4348
+ job.waiters.push(wake)
4349
+ })
4350
+ ctx.effect(() => () => {
4351
+ for (const job of videoTraceJobs.values()) {
4352
+ job.perf.finish('interrupted')
4353
+ job.cancelled = true
4354
+ wakeVideoTrace(job)
4355
+ }
4356
+ videoTraceJobs.clear(); videoUploadJobs.clear()
4357
+ return Promise.all([drawingTasks.close(), videoExports.dispose()]).finally(() => videoTracePool.close())
4358
+ })
3590
4359
  async function handleSave(req, res) {
3591
4360
  if (req.method !== 'POST') {
3592
4361
  sendJson(res, 405, { ok: false, error: 'POST only' })
@@ -3612,8 +4381,8 @@ function sanitizeNewName(raw) {
3612
4381
 
3613
4382
  let text
3614
4383
  try {
3615
- // 先按「看一眼」上限读:解析前不知道 action;非 render-result 下面再夹回 SAVE_MAX_BYTES。
3616
- text = await readBody(req, RENDER_RESULT_MAX_BYTES)
4384
+ // 解析前不知道 action,先受最大画布预算约束,再按 action 收紧。
4385
+ text = await readBody(req, CANVAS_SAVE_MAX_BYTES)
3617
4386
  } catch (error) {
3618
4387
  sendJson(res, 413, { ok: false, error: messageOf(error) })
3619
4388
  return
@@ -3629,14 +4398,90 @@ function sanitizeNewName(raw) {
3629
4398
  sendJson(res, 400, { ok: false, error: 'request body must be a JSON object' })
3630
4399
  return
3631
4400
  }
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' })
4401
+ const bodyLimit = body.action === 'render-result' ? RENDER_RESULT_MAX_BYTES
4402
+ : body.action === 'trace-node-image' ? IMAGE_TRACE_MAX_BYTES
4403
+ : (body.action === undefined || body.action === 'create') ? CANVAS_SAVE_MAX_BYTES : SAVE_MAX_BYTES
4404
+ if (Buffer.byteLength(text, 'utf8') > bodyLimit) {
4405
+ sendJson(res, 413, { ok: false, error: 'request body exceeds ' + bodyLimit + ' bytes' })
3634
4406
  return
3635
4407
  }
3636
4408
 
4409
+ try {
4410
+ const lockAction = body.action === undefined || ['create', 'revert', 'media-cleanup', 'media-video-export-start'].includes(body.action)
4411
+ if (lockAction && typeof (body.path || body.name) === 'string' && workspaceRootOf(body.sessionId)) {
4412
+ const target = await resolveTarget(body.path || body.name, body.sessionId)
4413
+ return await documents.run(absoluteHint(target, body.path || body.name), () => dispatchSave(body, res))
4414
+ }
4415
+ return await dispatchSave(body, res)
4416
+ } catch (error) { sendJson(res, 400, { ok: false, error: messageOf(error) }) }
4417
+ }
4418
+
4419
+ async function dispatchSave(body, res) {
4420
+
3637
4421
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
3638
4422
  const rawPath = typeof body.path === 'string' ? body.path : undefined
3639
4423
 
4424
+ if (typeof body.action === 'string' && body.action.startsWith('drawing-task-')) {
4425
+ if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !rawPath?.toLowerCase().endsWith('.drawio')) throw new Error('绘制任务需要当前工作区画布')
4426
+ const target = await resolveTarget(rawPath, sessionId), root = workspaceRootOf(sessionId)
4427
+ if (!ctx.fs.contains(await ctx.fs.resolve(root), target)) throw new Error('画布路径超出工作区')
4428
+ const scope = { sessionId, path: rawPath, drawioAbs: absoluteHint(target, rawPath), workspaceRoot: root }
4429
+ let result
4430
+ if (body.action === 'drawing-task-start') result = { job: await enqueueDrawing(scope, body) }
4431
+ else if (body.action === 'drawing-task-list') result = { jobs: drawingTasks.list(scope) }
4432
+ else if (body.action === 'drawing-task-result') result = drawingTasks.result(scope, body.taskId)
4433
+ else if (body.action === 'drawing-task-command') result = { job: await drawingTasks.command(scope, body.taskId, body.command, body.value) }
4434
+ else throw new Error('未知绘制任务操作')
4435
+ sendJson(res, 200, { ok: true, ...result }); return
4436
+ }
4437
+
4438
+ // 仅计算图像节点的 SVG;由客户端通过同一历史/保存通道应用结果。
4439
+ if (body.action === 'trace-node-image') {
4440
+ if (sessionId === undefined || sessionOf(sessionId) === undefined || workspaceRootOf(sessionId) === undefined) {
4441
+ sendJson(res, 403, { ok: false, error: '无法确定当前会话工作区' })
4442
+ return
4443
+ }
4444
+ if (typeof body.image !== 'string' || !isRasterDataUri(body.image)) {
4445
+ sendJson(res, 400, { ok: false, error: '请选择已导入的 PNG/JPEG/GIF/WebP 图片节点' })
4446
+ return
4447
+ }
4448
+ const imagePerf = createPerformanceLog(workspaceRootOf(sessionId), 'image-trace', { level: body.level, preset: body.preset, colors: body.colors, removeBg: body.removeBg })
4449
+ try {
4450
+ const decodeDone = imagePerf.start('sourceReadAndDecode')
4451
+ const bytes = Buffer.from(body.image.slice(body.image.indexOf(',') + 1), 'base64')
4452
+ if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
4453
+ if (bytes.readUInt32BE(16) * bytes.readUInt32BE(20) > 16000000) throw new Error('图片超过 1600 万像素,请先缩小图片')
4454
+ }
4455
+ decodeDone()
4456
+ const traceDone = imagePerf.start('workerRequest')
4457
+ const traced = await videoTracePool.run(bytes, {
4458
+ mode: 'image', profile: imagePerf.enabled,
4459
+ level: body.level, preset: body.preset, colors: body.colors, removeBg: body.removeBg,
4460
+ includeDrawing: true,
4461
+ })
4462
+ traceDone()
4463
+ imagePerf.add('workerQueueWait', traced.queueWaitMs)
4464
+ imagePerf.merge('worker.', traced.performance)
4465
+ imagePerf.count('sourceBytes', bytes.length); imagePerf.count('svgBytes', Buffer.byteLength(traced.svg))
4466
+ imagePerf.finish('done', traced.rasterSize)
4467
+ sendJson(res, 200, { ok: true, svg: traced.svg, drawing: traced.drawing, metrics: {
4468
+ fragments: traced.metrics.fragments, colors: traced.metrics.colors,
4469
+ svgChars: traced.metrics.svgChars, budget: traced.metrics.budget,
4470
+ level: traced.metrics.fragmentLevel, attempts: traced.metrics.attempts,
4471
+ detailReduced: traced.metrics.detailReduced,
4472
+ targetFragments: traced.metrics.targetFragments,
4473
+ fragmentThresholds: traced.metrics.fragmentThresholds,
4474
+ targetReached: traced.metrics.targetReached,
4475
+ imageAnalysis: traced.metrics.imageAnalysis,
4476
+ resolutionRetries: traced.metrics.resolutionRetries, work: traced.metrics.work,
4477
+ } })
4478
+ } catch (error) {
4479
+ imagePerf.finish('failed')
4480
+ sendJson(res, 400, { ok: false, error: messageOf(error) })
4481
+ }
4482
+ return
4483
+ }
4484
+
3640
4485
  // action: 'list' —— 列出工作区里可打开的 .drawio,供「打开」菜单用。
3641
4486
  // 复用同一条路由而不是另开一个:信任围栏(POST + 自定义头 + 同源)只写一次,
3642
4487
  // 少一个口子就少一处可能忘记校验的地方。
@@ -3764,6 +4609,20 @@ function sanitizeNewName(raw) {
3764
4609
  const pendingRender = renderRequests.get(sessionId)
3765
4610
  const renderForThis =
3766
4611
  pendingRender !== undefined && pendingRender.absolute === loaded.absolute ? { requestId: pendingRender.requestId } : null
4612
+ // AI 下发的抽帧计划同理:画布取走后用浏览器解码视频、写 stills/,完成后 POST 回 media-extract-result。
4613
+ const pendingExtract = extractRequests.get(sessionId)
4614
+ const extractForThis =
4615
+ pendingExtract !== undefined && !pendingExtract.host && pendingExtract.absolute === loaded.absolute
4616
+ ? {
4617
+ requestId: pendingExtract.requestId,
4618
+ clip: pendingExtract.clip,
4619
+ from: pendingExtract.from,
4620
+ seconds: pendingExtract.seconds,
4621
+ fps: pendingExtract.fps,
4622
+ width: pendingExtract.width,
4623
+ max: pendingExtract.max,
4624
+ }
4625
+ : null
3767
4626
  sendJson(res, 200, {
3768
4627
  ok: true,
3769
4628
  exists: true,
@@ -3775,12 +4634,15 @@ function sanitizeNewName(raw) {
3775
4634
  pageCount: loaded.pageCount,
3776
4635
  pageIndex: loaded.pageIndex,
3777
4636
  pageNames: loaded.pageNames,
4637
+ backgroundPatch: typeof body.revision === 'string' && body.revision !== loaded.doc.revision
4638
+ ? taskPatches.between(loaded.absolute, body.revision, loaded.doc.revision, loaded.pageIndex) : null,
3778
4639
  highlight: Array.isArray(pendingHighlight) ? pendingHighlight : [],
3779
4640
  canRevert: revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0,
3780
4641
  // 用户当前的选区(客户端可选地用来做提示;AI 侧由工具读回)。
3781
4642
  selection: selectionForCanvas(sessionId, loaded.absolute, loaded.doc),
3782
4643
  export: exportForThis,
3783
4644
  render: renderForThis,
4645
+ media: extractForThis,
3784
4646
  })
3785
4647
  } catch (error) {
3786
4648
  sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
@@ -3892,10 +4754,12 @@ function sanitizeNewName(raw) {
3892
4754
  }
3893
4755
  let revertTarget
3894
4756
  let revertAbsolute
4757
+ let revertCurrent
3895
4758
  try {
3896
4759
  const loaded = await loadDoc(rawPath, sessionId)
3897
4760
  revertTarget = loaded.target
3898
4761
  revertAbsolute = loaded.absolute
4762
+ revertCurrent = loaded.text
3899
4763
  } catch (error) {
3900
4764
  sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
3901
4765
  return
@@ -3907,6 +4771,16 @@ function sanitizeNewName(raw) {
3907
4771
  sendJson(res, 200, { ok: false, error: '这张画布没有可退回的 AI 改动了' })
3908
4772
  return
3909
4773
  }
4774
+ if (contentHash(revertCurrent) !== snapshot.revision) {
4775
+ const taskNodes = (text) => {
4776
+ const first = parseMxfile(text), nodes = []
4777
+ for (let page = 0; page < first.pageCount; page++) nodes.push(...parseMxfile(text, { page }).doc.nodes.filter((node) => node.data?.drawaiTaskId))
4778
+ return nodes
4779
+ }
4780
+ if (stableValue(taskNodes(revertCurrent)) !== stableValue(taskNodes(snapshot.text))) {
4781
+ sendJson(res, 409, { ok: false, error: 'AI 改动后已有后台生成或编辑的结果,请先在任务队列中撤销生成,避免整页回退丢失结果' }); return
4782
+ }
4783
+ }
3910
4784
  const policy = policyFor(sessionId)
3911
4785
  try {
3912
4786
  if (policy === undefined) await ctx.fs.writeText(revertTarget, snapshot.text)
@@ -3997,6 +4871,34 @@ function sanitizeNewName(raw) {
3997
4871
  return
3998
4872
  }
3999
4873
 
4874
+ // action: 'media-extract-result' —— 画布抽完帧的回执(AI 的 `extractFrames {seconds,fps,width}` 走这条)。
4875
+ // 与 export-result 同一条护栏:只有"这个会话此刻真的挂着一份抽帧计划"才收。
4876
+ if (body.action === 'media-extract-result') {
4877
+ if (sessionId === undefined) {
4878
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
4879
+ return
4880
+ }
4881
+ const pending = extractRequests.get(sessionId)
4882
+ if (pending === undefined) {
4883
+ sendJson(res, 409, { ok: false, error: 'no pending extract request' })
4884
+ return
4885
+ }
4886
+ if (typeof body.requestId === 'string' && body.requestId !== pending.requestId) {
4887
+ sendJson(res, 409, { ok: false, error: 'stale extract request' })
4888
+ return
4889
+ }
4890
+ const written = Math.max(0, Math.floor(Number(body.written) || 0))
4891
+ extractRequests.delete(sessionId)
4892
+ extractResults.set(sessionId, {
4893
+ absolute: pending.absolute,
4894
+ clip: pending.clip,
4895
+ written: written,
4896
+ error: typeof body.error === 'string' ? body.error : '',
4897
+ })
4898
+ sendJson(res, 200, { ok: true, written: written })
4899
+ return
4900
+ }
4901
+
4000
4902
  // action: 'focus' —— 画布告诉宿主"用户现在打开的是哪一张"。
4001
4903
  // 之后 diagram_apply 不传 path 时就改它(见下面的 execute)。
4002
4904
  // 校验后**只存相对工作区的路径**:绝对路径能存但会让日志/提示变长,
@@ -4142,6 +5044,520 @@ function sanitizeNewName(raw) {
4142
5044
  return
4143
5045
  }
4144
5046
 
5047
+ // action: 'media-*' —— 旁路媒体包(*.media/<clip>/)的读写。不改 mxfile 时也不碰 revision。
5048
+ if (typeof body.action === 'string' && body.action.indexOf('media-') === 0) {
5049
+ if (sessionId === undefined || rawPath === undefined) {
5050
+ sendJson(res, 400, { ok: false, error: 'sessionId and path are required' })
5051
+ return
5052
+ }
5053
+ if (!String(rawPath).toLowerCase().endsWith('.drawio')) {
5054
+ sendJson(res, 403, { ok: false, error: 'media actions require a .drawio path' })
5055
+ return
5056
+ }
5057
+ const mediaRoot = workspaceRootOf(sessionId)
5058
+ if (mediaRoot === undefined) {
5059
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
5060
+ return
5061
+ }
5062
+ let mediaTarget
5063
+ try {
5064
+ mediaTarget = await ctx.fs.resolve(rawPath, { cwd: mediaRoot })
5065
+ } catch (error) {
5066
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
5067
+ return
5068
+ }
5069
+ try {
5070
+ const rootTarget = await ctx.fs.resolve(mediaRoot)
5071
+ if (!ctx.fs.contains(rootTarget, mediaTarget)) {
5072
+ sendJson(res, 403, { ok: false, error: 'path escapes the workspace root' })
5073
+ return
5074
+ }
5075
+ } catch (error) {
5076
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
5077
+ return
5078
+ }
5079
+ const drawioAbs = absoluteHint(mediaTarget, rawPath)
5080
+ const clip = sanitizeClipId(body.clip)
5081
+ try {
5082
+ if (body.action.startsWith('media-video-export-')) {
5083
+ const scope = { sessionId, drawioAbs, clip }
5084
+ let result
5085
+ switch (body.action) {
5086
+ case 'media-video-export-start': result = await videoExports.start(scope, body.format); break
5087
+ case 'media-video-export-list': result = { jobs: videoExports.list(scope) }; break
5088
+ case 'media-video-export-finish': result = videoExports.finish(scope, body.token); break
5089
+ case 'media-video-export-status': result = videoExports.status(scope, body.token); break
5090
+ case 'media-video-export-read': result = videoExports.read(scope, body.token, body.offset); break
5091
+ case 'media-video-export-discard': result = await videoExports.discard(scope, body.token, body.performance); break
5092
+ default: throw new Error('未知视频导出操作')
5093
+ }
5094
+ sendJson(res, 200, { ...result, ok: true }); return
5095
+ }
5096
+ if (body.action === 'media-upload-start') {
5097
+ const size = Number(body.size), w = Number(body.w), h = Number(body.h)
5098
+ if (!['mp4', 'webm', 'mov', 'mkv'].includes(body.extension) || !Number.isInteger(size) || size <= 0 || size > 48 * 1024 * 1024 ||
5099
+ !Number.isInteger(w) || !Number.isInteger(h) || Math.min(w, h) <= 0 || Math.max(w, h) > 16384) throw new Error('无效视频文件:须为 48MB 以内的 MP4/WebM/MOV/MKV')
5100
+ for (const [key, job] of videoUploadJobs) if (Date.now() - job.time > 3600000) videoUploadJobs.delete(key)
5101
+ if (videoUploadJobs.size >= 16) throw new Error('导入任务过多,请稍后重试')
5102
+ const token = randomUUID(), newClip = 'video-' + randomUUID()
5103
+ ensureClipPack(drawioAbs, newClip, { kind: 'raster', w, h })
5104
+ const dir = clipDirFor(drawioAbs, newClip), part = joinPath(dir, 'source.upload')
5105
+ writeFileSync(part, Buffer.alloc(0))
5106
+ videoUploadJobs.set(token, { sessionId, drawioAbs, clip: newClip, dir, part, source: 'source.' + body.extension, size, received: 0, time: Date.now(), finished: false })
5107
+ sendJson(res, 200, { ok: true, clip: newClip, token })
5108
+ return
5109
+ }
5110
+ if (body.action.indexOf('media-upload-') === 0) {
5111
+ const job = videoUploadJobs.get(body.token)
5112
+ if (!job || job.sessionId !== sessionId || job.drawioAbs !== drawioAbs || job.clip !== clip) throw new Error('视频导入任务已失效')
5113
+ job.time = Date.now()
5114
+ if (body.action === 'media-upload-discard') {
5115
+ rmSync(job.dir, { recursive: true, force: true }); videoUploadJobs.delete(body.token)
5116
+ sendJson(res, 200, { ok: true }); return
5117
+ }
5118
+ if (body.action === 'media-upload-chunk') {
5119
+ const bytes = Buffer.from(typeof body.base64 === 'string' ? body.base64 : '', 'base64')
5120
+ if (job.finished || body.offset !== job.received || bytes.length < 1 || bytes.length > 1024 * 1024 || job.received + bytes.length > job.size) throw new Error('视频分块位置或大小无效')
5121
+ appendFileSync(job.part, bytes); job.received += bytes.length
5122
+ sendJson(res, 200, { ok: true, received: job.received }); return
5123
+ }
5124
+ if (body.action === 'media-upload-finish') {
5125
+ if (job.received !== job.size) throw new Error('视频尚未上传完整')
5126
+ if (!job.finished) {
5127
+ renameSync(job.part, joinPath(job.dir, job.source))
5128
+ const manifest = readManifest(job.dir); manifest.source = job.source
5129
+ writeManifest(job.dir, manifest); job.finished = true
5130
+ }
5131
+ sendJson(res, 200, { ok: true, manifest: readManifest(job.dir) }); return
5132
+ }
5133
+ if (body.action === 'media-upload-release' && job.finished) {
5134
+ videoUploadJobs.delete(body.token); sendJson(res, 200, { ok: true }); return
5135
+ }
5136
+ throw new Error('未知视频导入操作')
5137
+ }
5138
+ if (body.action === 'media-trace-start') {
5139
+ const fps = Number(body.fps), w = Number(body.w), h = Number(body.h), count = Number(body.count)
5140
+ const maxBytes = body.maxBytes === undefined ? 60000 : Number(body.maxBytes)
5141
+ if (!Number.isSafeInteger(count) || count < 1 || !Number.isFinite(fps) || fps <= 0) throw new Error('无效帧数或帧率')
5142
+ 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')
5143
+ if (!Number.isInteger(maxBytes) || maxBytes < 2000 || maxBytes > 248000) throw new Error('无效细节预算')
5144
+ for (const [key, job] of videoTraceJobs) {
5145
+ // 过期仅释放令牌;网络中断时结果可能已被画布引用,不能擅自删文件。
5146
+ if (!job.inFlight && Date.now() - job.time > 3600000) { job.perf.finish('expired'); videoTraceJobs.delete(key) }
5147
+ }
5148
+ if (videoTraceJobs.size >= 16) throw new Error('复刻任务过多,请稍后重试')
5149
+ const token = randomUUID(), newClip = 'trace-' + randomUUID()
5150
+ let originalSource = null
5151
+ if (body.sourceClip !== undefined) {
5152
+ const sourceDir = clipDirFor(drawioAbs, body.sourceClip)
5153
+ originalSource = findSourceFile(sourceDir, readManifest(sourceDir))
5154
+ if (!originalSource) throw new Error('原视频文件缺失,无法保留音频')
5155
+ if (!Number.isFinite(Number(body.sourceOffset)) || Number(body.sourceOffset) < 0) throw new Error('无效音频起始时间')
5156
+ }
5157
+ const pack = ensureClipPack(drawioAbs, newClip, { kind: 'svg', fps, w, h })
5158
+ const tracePerf = createPerformanceLog(dirnamePath(drawioAbs), 'video-trace', { fps, width: w, height: h, count, maxBytes, poolSize: videoTracePool.size })
5159
+ const sourceDone = tracePerf.start('sourceSnapshot')
5160
+ if (originalSource) {
5161
+ try {
5162
+ importSourceFile(pack.dir, originalSource.abs)
5163
+ const manifest = readManifest(pack.dir)
5164
+ manifest.kind = 'svg'; manifest.sourceOffset = Number(body.sourceOffset)
5165
+ writeManifest(pack.dir, manifest)
5166
+ pack.manifest = readManifest(pack.dir)
5167
+ } catch (error) { tracePerf.finish('failed'); rmSync(pack.dir, { recursive: true, force: true }); throw error }
5168
+ }
5169
+ sourceDone()
5170
+ videoTraceJobs.set(token, {
5171
+ token, sessionId, drawioAbs, clip: newClip, dir: clipDirFor(drawioAbs, newClip),
5172
+ count, maxBytes, time: Date.now(), nextSubmit: 0, inFlight: 0, uploads: new Map(), waiters: [], cancelled: false,
5173
+ lastFrame: null, palette: null, paletteKey: null, paletteIndex: -1,
5174
+ perf: tracePerf,
5175
+ })
5176
+ sendJson(res, 200, { ok: true, token, clip: newClip, manifest: pack.manifest, poolSize: videoTracePool.size,
5177
+ performanceEnabled: videoTraceJobs.get(token).perf.enabled, performanceId: videoTraceJobs.get(token).perf.id })
5178
+ return
5179
+ }
5180
+ if (body.action.indexOf('media-trace-') === 0) {
5181
+ const job = videoTraceJobs.get(body.token)
5182
+ if (!job || job.sessionId !== sessionId || job.drawioAbs !== drawioAbs || job.clip !== clip) throw new Error('复刻任务已失效')
5183
+ job.time = Date.now()
5184
+ if (body.action === 'media-trace-heartbeat') {
5185
+ sendJson(res, 200, { ok: true }); return
5186
+ }
5187
+ if (body.action === 'media-trace-discard') {
5188
+ job.perf.merge('browser.', body.performance); job.perf.finish('cancelled')
5189
+ job.cancelled = true
5190
+ wakeVideoTrace(job)
5191
+ videoTracePool.cancelToken(body.token)
5192
+ rmSync(job.dir, { recursive: true, force: true })
5193
+ videoTraceJobs.delete(body.token)
5194
+ sendJson(res, 200, { ok: true })
5195
+ return
5196
+ }
5197
+ let manifest = readManifest(job.dir)
5198
+ if (body.action === 'media-trace-frame-chunk') {
5199
+ const uploadDone = job.perf.start('frameChunkDecodeAndWrite')
5200
+ const index = Number(body.index)
5201
+ if (!Number.isInteger(index) || index !== job.nextSubmit || index >= job.count) throw new Error('帧分块位置无效')
5202
+ const upload = job.uploads.get(index) || { received: 0 }
5203
+ if (body.offset !== upload.received) throw new Error('帧分块位置无效')
5204
+ const bytes = Buffer.from(typeof body.base64 === 'string' ? body.base64 : '', 'base64')
5205
+ if (!bytes.length || bytes.length > 1024 * 1024 || upload.received + bytes.length > 68 * 1024 * 1024) throw new Error('帧分块大小无效')
5206
+ const part = joinPath(job.dir, 'frame.' + index + '.upload')
5207
+ if (body.offset === 0) writeFileSync(part, bytes)
5208
+ else appendFileSync(part, bytes)
5209
+ job.uploads.set(index, { received: upload.received + bytes.length })
5210
+ uploadDone(); job.perf.peak('frameUploadBytes', upload.received + bytes.length)
5211
+ sendJson(res, 200, { ok: true, received: upload.received + bytes.length })
5212
+ return
5213
+ }
5214
+ if (body.action === 'media-trace-frame') {
5215
+ const index = Number(body.index)
5216
+ if (job.cancelled || job.failure || !Number.isInteger(index) || index !== job.nextSubmit || index >= job.count) throw new Error(job.failure || '帧序号不连续或任务已取消')
5217
+ job.nextSubmit += 1
5218
+ job.inFlight += 1
5219
+ const part = joinPath(job.dir, 'frame.' + index + '.upload')
5220
+ let resolveFrame, rejectFrame, resolveKey, rejectKey
5221
+ const previous = job.lastFrame
5222
+ const promise = new Promise((resolve, reject) => { resolveFrame = resolve; rejectFrame = reject })
5223
+ const keyReady = new Promise((resolve, reject) => { resolveKey = resolve; rejectKey = reject })
5224
+ // Reserve in submission order, before asynchronous upload-file reads.
5225
+ // Metadata can finish out of order without making frame 0 await frame 1.
5226
+ promise.catch(() => {}); keyReady.catch(() => {})
5227
+ job.lastFrame = { keyReady, promise }
5228
+ try {
5229
+ const readDone = job.perf.start('frameReadAndBase64Decode')
5230
+ const png = typeof body.pngBase64 === 'string' ? Buffer.from(body.pngBase64, 'base64') : await readFile(part)
5231
+ readDone(); job.perf.count('uploadedPngBytes', png.length)
5232
+ if (job.cancelled || videoTraceJobs.get(body.token) !== job) throw new Error('复刻任务已取消')
5233
+ if (png.length < 24 || png.toString('hex', 0, 8) !== '89504e470d0a1a0a' ||
5234
+ png.readUInt32BE(16) !== manifest.width || png.readUInt32BE(20) !== manifest.height) throw new Error('PNG 帧尺寸与任务不一致')
5235
+ const traceOptions = videoTraceOptions(body, manifest, job.maxBytes)
5236
+ const optionsKey = JSON.stringify(traceOptions)
5237
+ const keyDone = job.perf.start('exactFrameHash')
5238
+ const key = exactVideoFrameKey(png, optionsKey)
5239
+ keyDone()
5240
+ resolveKey(key)
5241
+ const reuse = previous && await previous.keyReady === key
5242
+ let result
5243
+ if (reuse) {
5244
+ const reuseDone = job.perf.start('reuseWait')
5245
+ const cached = await previous.promise
5246
+ reuseDone()
5247
+ result = { svg: cached.svg, detailReduced: cached.detailReduced, work: cached.work, reused: true, paletteShared: false }
5248
+ } else {
5249
+ traceOptions.profile = job.perf.enabled
5250
+ // Age is measured from actual palette generation, never the previous
5251
+ // frame. Shared palettes cannot reset the clock or roll back a new one.
5252
+ const maxPaletteAge = Math.max(1, Math.min(120, Math.round(manifest.fps * 2)))
5253
+ if (job.palette && job.paletteKey === optionsKey && index - job.paletteIndex < maxPaletteAge) traceOptions.paletteOverride = job.palette
5254
+ result = await videoTracePool.run(png, traceOptions, { token: body.token })
5255
+ job.perf.add('workerQueueWait', result.queueWaitMs)
5256
+ job.perf.add('workerRoundTrip', result.workerRoundTripMs)
5257
+ job.perf.merge('worker.', result.performance)
5258
+ }
5259
+ const turnDone = job.perf.start('orderedCommitWait')
5260
+ await waitVideoTraceTurn(job, index)
5261
+ turnDone()
5262
+ if (job.cancelled || job.failure || videoTraceJobs.get(body.token) !== job) throw new Error(job.failure || '复刻任务已取消')
5263
+ const writeDone = job.perf.start('svgWrite')
5264
+ manifest = writeFrameSvg(job.dir, readManifest(job.dir), index, result.svg)
5265
+ writeDone()
5266
+ job.perf.count('frames'); job.perf.count('reusedFrames', result.reused ? 1 : 0)
5267
+ job.perf.count('paletteSharedFrames', result.paletteShared === true ? 1 : 0)
5268
+ job.perf.count('paletteRefreshFrames', !result.reused && !result.paletteShared ? 1 : 0)
5269
+ job.perf.count('paletteRejectedFrames', result.paletteReuseReason?.endsWith('-mismatch') ? 1 : 0)
5270
+ job.perf.count('svgBytes', Buffer.byteLength(result.svg)); job.perf.peak('inFlightFrames', job.inFlight)
5271
+ job.profileOptions = { level: traceOptions.level, preset: traceOptions.preset, colors: traceOptions.colors, removeBg: traceOptions.removeBg }
5272
+ if (!result.reused && !result.paletteShared) {
5273
+ job.palette = Array.isArray(result.palette) && result.palette.length >= 2 ? result.palette : null
5274
+ job.paletteKey = optionsKey; job.paletteIndex = index
5275
+ }
5276
+ job.uploads.delete(index)
5277
+ rmSync(part, { force: true })
5278
+ resolveFrame(result)
5279
+ wakeVideoTrace(job)
5280
+ sendJson(res, 200, {
5281
+ ok: true, svg: result.svg, count: manifest.frames.length, detailReduced: result.detailReduced, work: result.work,
5282
+ reused: result.reused === true, paletteShared: result.paletteShared === true,
5283
+ paletteReuseReason: result.reused ? 'exact-frame' : result.paletteReuseReason,
5284
+ })
5285
+ } catch (error) {
5286
+ job.failure = job.failure || String(error.message || error)
5287
+ rejectKey(error)
5288
+ rejectFrame?.(error)
5289
+ videoTracePool.cancelToken(body.token)
5290
+ wakeVideoTrace(job)
5291
+ job.perf.finish('failed', job.profileOptions); throw error
5292
+ } finally {
5293
+ job.inFlight = Math.max(0, job.inFlight - 1)
5294
+ }
5295
+ return
5296
+ }
5297
+ if (body.action === 'media-trace-finish' || body.action === 'media-trace-release') {
5298
+ if (manifest.frames.length !== job.count) throw new Error('复刻帧尚未全部完成')
5299
+ if (body.action === 'media-trace-finish') {
5300
+ job.perf.merge('browser.', body.performance); job.perf.finish('done', job.profileOptions)
5301
+ }
5302
+ if (body.action === 'media-trace-release') { job.perf.finish('done', job.profileOptions); videoTraceJobs.delete(body.token) }
5303
+ sendJson(res, 200, { ok: true, manifest })
5304
+ return
5305
+ }
5306
+ throw new Error('未知复刻操作')
5307
+ }
5308
+ if (body.action === 'media-ensure') {
5309
+ if (clip === null) {
5310
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5311
+ return
5312
+ }
5313
+ const pack = ensureClipPack(drawioAbs, clip, {
5314
+ kind: body.kind === 'raster' ? 'raster' : 'svg',
5315
+ fps: Number(body.fps),
5316
+ w: Number(body.w),
5317
+ h: Number(body.h),
5318
+ })
5319
+ sendJson(res, 200, { ok: true, clip: pack.clip, manifest: pack.manifest, mediaRoot: mediaRootFor(drawioAbs) })
5320
+ return
5321
+ }
5322
+ if (body.action === 'media-manifest') {
5323
+ if (clip === null) {
5324
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5325
+ return
5326
+ }
5327
+ const dir = clipDirFor(drawioAbs, clip)
5328
+ if (existsSync(dir) === false) {
5329
+ sendJson(res, 200, { ok: true, missing: true, manifest: null })
5330
+ return
5331
+ }
5332
+ const manifest = readManifest(dir)
5333
+ const source = findSourceFile(dir, manifest)
5334
+ sendJson(res, 200, {
5335
+ ok: true,
5336
+ missing: false,
5337
+ manifest: manifest,
5338
+ hasSource: source !== null,
5339
+ source: source !== null ? source.rel : null,
5340
+ })
5341
+ return
5342
+ }
5343
+ if (body.action === 'media-frame') {
5344
+ if (clip === null) {
5345
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5346
+ return
5347
+ }
5348
+ const dir = clipDirFor(drawioAbs, clip)
5349
+ const manifest = readManifest(dir)
5350
+ const index = Math.floor(Number(body.index))
5351
+ const svg = readFrameSvg(dir, manifest, index)
5352
+ sendJson(res, 200, { ok: true, index: index, svg: svg })
5353
+ return
5354
+ }
5355
+ if (body.action === 'media-write-frame') {
5356
+ if (clip === null) {
5357
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5358
+ return
5359
+ }
5360
+ const dir = clipDirFor(drawioAbs, clip)
5361
+ ensureClipPack(drawioAbs, clip, {})
5362
+ let manifest = readManifest(dir)
5363
+ if (Array.isArray(body.svgs)) {
5364
+ manifest = appendFrameSvgs(dir, manifest, body.svgs)
5365
+ } else if (typeof body.svg === 'string') {
5366
+ manifest = writeFrameSvg(dir, manifest, Number(body.index), body.svg)
5367
+ } else {
5368
+ sendJson(res, 400, { ok: false, error: 'svg or svgs required' })
5369
+ return
5370
+ }
5371
+ sendJson(res, 200, { ok: true, manifest: manifest })
5372
+ return
5373
+ }
5374
+ if (body.action === 'media-write-still') {
5375
+ if (clip === null) {
5376
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5377
+ return
5378
+ }
5379
+ const dir = clipDirFor(drawioAbs, clip)
5380
+ ensureClipPack(drawioAbs, clip, {})
5381
+ const index = Math.floor(Number(body.index))
5382
+ const b64 = typeof body.pngBase64 === 'string' ? body.pngBase64 : ''
5383
+ if (b64.length === 0) {
5384
+ sendJson(res, 400, { ok: false, error: 'pngBase64 required' })
5385
+ return
5386
+ }
5387
+ const file = writeStillPng(dir, index, Buffer.from(b64, 'base64'))
5388
+ sendJson(res, 200, { ok: true, file: file, index: index })
5389
+ return
5390
+ }
5391
+ if (body.action === 'media-import-source') {
5392
+ if (clip === null) {
5393
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5394
+ return
5395
+ }
5396
+ const file = typeof body.file === 'string' ? body.file : ''
5397
+ if (file.length === 0) {
5398
+ sendJson(res, 400, { ok: false, error: 'file required' })
5399
+ return
5400
+ }
5401
+ const srcTarget = await resolveTarget(file, sessionId)
5402
+ const srcAbs = absoluteHint(srcTarget, file)
5403
+ const dir = clipDirFor(drawioAbs, clip)
5404
+ ensureClipPack(drawioAbs, clip, { kind: 'raster' })
5405
+ const imported = importSourceFile(dir, srcAbs)
5406
+ sendJson(res, 200, { ok: true, source: imported.rel, manifest: imported.manifest })
5407
+ return
5408
+ }
5409
+ if (body.action === 'media-source-bytes') {
5410
+ if (clip === null) {
5411
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5412
+ return
5413
+ }
5414
+ const dir = clipDirFor(drawioAbs, clip)
5415
+ const manifest = readManifest(dir)
5416
+ const source = findSourceFile(dir, manifest)
5417
+ if (source === null) {
5418
+ sendJson(res, 404, { ok: false, error: 'no source video in clip' })
5419
+ return
5420
+ }
5421
+ const buf = await readFile(source.abs)
5422
+ // 上限约 48MB:更大的片请用外部播放器;JSON base64 约膨胀 33%。
5423
+ if (buf.length > 48 * 1024 * 1024) {
5424
+ sendJson(res, 413, { ok: false, error: 'source too large for inline transfer (>48MB)' })
5425
+ return
5426
+ }
5427
+ const ext = source.rel.toLowerCase().split('.').pop()
5428
+ const mime =
5429
+ ext === 'mp4' ? 'video/mp4' : ext === 'webm' ? 'video/webm' : ext === 'mov' ? 'video/quicktime' : 'application/octet-stream'
5430
+ sendJson(res, 200, { ok: true, rel: source.rel, mime: mime, base64: buf.toString('base64') })
5431
+ return
5432
+ }
5433
+ if (body.action === 'media-pack-bundle') {
5434
+ const zip = packCanvasBundle(drawioAbs)
5435
+ const outName =
5436
+ typeof body.out === 'string' && body.out.length > 0
5437
+ ? body.out
5438
+ : basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '.drawai.zip'
5439
+ const safe = String(outName).replace(/^[\\/]+/, '')
5440
+ if (safe.indexOf('..') >= 0) {
5441
+ sendJson(res, 400, { ok: false, error: 'invalid out path' })
5442
+ return
5443
+ }
5444
+ const abs = joinPath(mediaRoot, safe)
5445
+ await mkdir(dirnamePath(abs), { recursive: true })
5446
+ await writeFile(abs, zip)
5447
+ sendJson(res, 200, { ok: true, path: safe.replace(/\\/g, '/'), bytes: zip.length })
5448
+ return
5449
+ }
5450
+ if (body.action === 'media-unpack-bundle') {
5451
+ const file = typeof body.file === 'string' ? body.file : ''
5452
+ if (file.length === 0) {
5453
+ sendJson(res, 400, { ok: false, error: 'file required' })
5454
+ return
5455
+ }
5456
+ const zipTarget = await resolveTarget(file, sessionId)
5457
+ const zipAbs = absoluteHint(zipTarget, file)
5458
+ const buf = await readFile(zipAbs)
5459
+ const stem = typeof body.stem === 'string' && body.stem.length > 0 ? body.stem : undefined
5460
+ const destDir = dirnamePath(drawioAbs)
5461
+ const result = unpackCanvasBundle(buf, destDir, stem)
5462
+ sendJson(res, 200, { ok: true, path: result.path })
5463
+ return
5464
+ }
5465
+ if (body.action === 'media-pack-svgvideo') {
5466
+ if (clip === null) {
5467
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5468
+ return
5469
+ }
5470
+ const zip = packSvgVideoZip(clipDirFor(drawioAbs, clip))
5471
+ const outName =
5472
+ typeof body.out === 'string' && body.out.length > 0
5473
+ ? body.out
5474
+ : basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '-' + clip + '.svgvideo.zip'
5475
+ const safe = String(outName).replace(/^[\\/]+/, '')
5476
+ if (safe.indexOf('..') >= 0) {
5477
+ sendJson(res, 400, { ok: false, error: 'invalid out path' })
5478
+ return
5479
+ }
5480
+ const abs = joinPath(mediaRoot, safe)
5481
+ await mkdir(dirnamePath(abs), { recursive: true })
5482
+ await writeFile(abs, zip)
5483
+ sendJson(res, 200, { ok: true, path: safe.replace(/\\/g, '/'), bytes: zip.length })
5484
+ return
5485
+ }
5486
+ if (body.action === 'media-unpack-svgvideo') {
5487
+ if (clip === null) {
5488
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5489
+ return
5490
+ }
5491
+ const file = typeof body.file === 'string' ? body.file : ''
5492
+ if (file.length === 0) {
5493
+ sendJson(res, 400, { ok: false, error: 'file required' })
5494
+ return
5495
+ }
5496
+ const zipTarget = await resolveTarget(file, sessionId)
5497
+ const buf = await readFile(absoluteHint(zipTarget, file))
5498
+ ensureClipPack(drawioAbs, clip, { kind: 'svg' })
5499
+ const manifest = unpackSvgVideoZip(buf, clipDirFor(drawioAbs, clip))
5500
+ sendJson(res, 200, { ok: true, manifest: manifest })
5501
+ return
5502
+ }
5503
+ if (body.action === 'media-copy-beside') {
5504
+ const from = typeof body.fromPath === 'string' ? body.fromPath : ''
5505
+ if (from.length === 0) {
5506
+ sendJson(res, 400, { ok: false, error: 'fromPath required' })
5507
+ return
5508
+ }
5509
+ const fromTarget = await resolveTarget(from, sessionId)
5510
+ const result = copyMediaBeside(absoluteHint(fromTarget, from), drawioAbs)
5511
+ sendJson(res, 200, { ok: true, copied: result.copied, from: result.from, to: result.to })
5512
+ return
5513
+ }
5514
+ if (body.action === 'media-cleanup') {
5515
+ const current = await loadDoc(drawioAbs, sessionId, 0)
5516
+ const nodes = [...(body.doc?.nodes || [])]
5517
+ for (let page = 0; page < current.pageNames.length; page++) {
5518
+ const parsed = page === 0 ? current : await loadDoc(drawioAbs, sessionId, page)
5519
+ nodes.push(...parsed.doc.nodes)
5520
+ }
5521
+ const leases = new Set([...drawingTasks.leases(drawioAbs, { workspaceRoot: mediaRoot }), ...videoExports.leases(drawioAbs)])
5522
+ for (const job of videoTraceJobs.values()) if (job.drawioAbs === drawioAbs) leases.add(job.clip)
5523
+ const removed = cleanupUnreferencedClips(drawioAbs, { nodes }, leases)
5524
+ sendJson(res, 200, { ok: true, removed: removed })
5525
+ return
5526
+ }
5527
+ if (body.action === 'media-export-source') {
5528
+ if (clip === null) {
5529
+ sendJson(res, 400, { ok: false, error: 'clip is required' })
5530
+ return
5531
+ }
5532
+ const dir = clipDirFor(drawioAbs, clip)
5533
+ const manifest = readManifest(dir)
5534
+ const source = findSourceFile(dir, manifest)
5535
+ if (source === null) {
5536
+ sendJson(res, 404, { ok: false, error: 'no source video' })
5537
+ return
5538
+ }
5539
+ const outName =
5540
+ typeof body.out === 'string' && body.out.length > 0
5541
+ ? body.out
5542
+ : basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '-' + clip + '-' + source.rel
5543
+ const safe = String(outName).replace(/^[\\/]+/, '')
5544
+ if (safe.indexOf('..') >= 0) {
5545
+ sendJson(res, 400, { ok: false, error: 'invalid out path' })
5546
+ return
5547
+ }
5548
+ const abs = joinPath(mediaRoot, safe)
5549
+ await mkdir(dirnamePath(abs), { recursive: true })
5550
+ await writeFile(abs, await readFile(source.abs))
5551
+ sendJson(res, 200, { ok: true, path: safe.replace(/\\/g, '/') })
5552
+ return
5553
+ }
5554
+ sendJson(res, 400, { ok: false, error: 'unknown media action: ' + body.action })
5555
+ } catch (error) {
5556
+ sendJson(res, 500, { ok: false, error: messageOf(error) })
5557
+ }
5558
+ return
5559
+ }
5560
+
4145
5561
  // action: 'create' —— 新建一张**真实存在**的空画布并绑定到它。
4146
5562
  //
4147
5563
  // 为什么不"留着以后再说":画布一旦没有文件,AI 对话就够不到它
@@ -4275,14 +5691,18 @@ function sanitizeNewName(raw) {
4275
5691
  sendJson(res, 409, { ok: false, error: 'already exists', path: absolute })
4276
5692
  return
4277
5693
  }
5694
+ let backgroundPatch = null
4278
5695
  if (baseRevision !== undefined && exists && contentHash(originalText) !== baseRevision) {
4279
- sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) })
4280
- return
5696
+ backgroundPatch = taskPatches.between(absolute, baseRevision, contentHash(originalText), Number(body.page) || 0)
5697
+ if (!backgroundPatch) {
5698
+ sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) }); return
5699
+ }
4281
5700
  }
4282
5701
 
4283
5702
  // 人工编辑一律打 pinned:从此这张画布是人摆过版面的,AI 的自动布局不该再冲掉它。
4284
5703
  // (空文件 → 从零生成;已有文件 → 无损写回,见 renderDocText。)
4285
5704
  const next = normalizeDoc(doc)
5705
+ if (backgroundPatch) applyTaskPatches(next, backgroundPatch, true)
4286
5706
  next.meta = Object.assign({}, next.meta, { pinned: true })
4287
5707
  let rendered
4288
5708
  try {
@@ -4302,13 +5722,27 @@ function sanitizeNewName(raw) {
4302
5722
  sendJson(res, 500, { ok: false, error: 'write failed: ' + messageOf(error) + ' [sandbox: ' + scope + ']' })
4303
5723
  return
4304
5724
  }
4305
- sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped })
5725
+ // 另存为:把源画布旁的 *.media 成对复制到新主名(节点只存 mediaClip,不必改 XML)。
5726
+ if (body.createOnly === true && typeof body.fromPath === 'string' && body.fromPath.length > 0) {
5727
+ try {
5728
+ const fromTarget = await resolveTarget(body.fromPath, sessionId)
5729
+ copyMediaBeside(absoluteHint(fromTarget, body.fromPath), absolute)
5730
+ } catch (error) {
5731
+ // 媒体复制失败不回滚画布写入:海报仍在;播放时会报媒体包缺失。
5732
+ }
5733
+ }
5734
+ sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped, ...(backgroundPatch ? { backgroundPatch } : {}) })
4306
5735
  }
4307
5736
 
4308
5737
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: handleSave }), 'drawai: ' + SAVE_PATH)
4309
5738
 
4310
5739
  ctx.effect(() => ctx.tools.register(readTool))
4311
- ctx.effect(() => ctx.tools.register(applyTool))
5740
+ ctx.effect(() => ctx.tools.register({ ...applyTool, execute: async (args, exec) => {
5741
+ const sessionId = sessionIdOf(exec)
5742
+ const path = typeof args.path === 'string' && args.path ? args.path : focusedPathFor(sessionId) || DEFAULT_PATH
5743
+ const target = await resolveTarget(path, sessionId)
5744
+ return documents.run(absoluteHint(target, path), () => applyTool.execute(args, exec))
5745
+ } }))
4312
5746
 
4313
5747
  // 把"怎么用这张画布"注册成**内嵌技能**(见 SKILL_BODY 的注释):
4314
5748
  // 换了电脑、只装了插件(没有源码/README/tools)时,模型依然知道该怎么操作。