dsh-drawai 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/client.js +311 -94
- package/lib/drawing-results.js +36 -0
- package/lib/drawing-runner.js +213 -0
- package/lib/drawing-tasks.js +230 -0
- package/lib/index.js +216 -23
- package/lib/media.js +5 -2
- package/lib/mxfile.js +3 -1
- package/lib/performance-log.js +1 -1
- package/lib/task-coordinator.js +54 -0
- package/lib/task-patches.js +59 -0
- package/lib/video-export-worker.js +58 -0
- package/lib/video-export.js +165 -54
- package/lib/video-trace-pool.js +12 -2
- package/lib/video-trace-worker.js +6 -4
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -1058,6 +1058,29 @@ window.__ModuleLoader__.load({
|
|
|
1058
1058
|
return { STYLE_KEY_ORDER, PALETTE, NODE_SHAPES, NODE_SHAPE_STYLE, DASH_KINDS, ARROW_KINDS, SIDES, DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_FONT, DEFAULT_NODE_STYLE, DEFAULT_EDGE_STYLE, DEFAULT_VIDEO_POSTER, parseStyle, formatStyle, styleGet, styleSet, stylePatch, styleIsEmpty, styleNumber, nodeShapeFromStyle, styleFromNodeShape, styleWithNodeShape, isVideoNodeData, reportNodeShape, isSvgMarkup, normalizeSvgMarkup, isSvgDataUri, isRasterDataUri, svgMarkupFromImageValue, imageValueFromSvgMarkup, imageValueFromStyle, svgMarkupFromStyle, styleWithSvgMarkup, styleWithoutSvgMarkup, styleWithImageDataUri, styleWithoutImageDataUri, SVG_IMAGE_VALUE_PREFIX, SVG_IMAGE_VALUE_PREFIX_B64, colorsFromStyle, colorNameFromStyle, styleWithColorName, styleWithColors, textColorNameFromStyle, styleWithTextColorName, dashFromStyle, styleWithDash, dashPatternFromStyle, arrowFromStyle, styleWithArrow, sideFromStyle, fractionFromStyle, styleWithSide, sideFromFraction, edgeStyleValueFromStyle, isOrthogonalEdgeStyle, jettyFromStyle, styleWithJetty, lineKindFromStyle, styleWithLineKind, curvedFromStyle, avoidFromStyle, styleWithAvoid, normalizePoint, normalizePoints, pointsEqual, edgeTerminalId, edgeFreePoint, copyData, normalizeDrawioDoc, snapDocGeometry }
|
|
1059
1059
|
})()
|
|
1060
1060
|
|
|
1061
|
+
function applyTaskPatches(doc, patch, strict = false) {
|
|
1062
|
+
const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
1063
|
+
? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item)
|
|
1064
|
+
for (const change of patch.changes || [patch]) {
|
|
1065
|
+
const removed = new Set(change.removed || [])
|
|
1066
|
+
if (strict) {
|
|
1067
|
+
for (const before of change.removedNodes || []) {
|
|
1068
|
+
const current = doc.nodes.find((node) => node.id === before.id)
|
|
1069
|
+
if (current && stable(current) !== stable(before)) throw new Error('后台撤销与本地节点编辑冲突,本地编辑已保留')
|
|
1070
|
+
}
|
|
1071
|
+
if ((doc.edges || []).some((edge) => removed.has(edge.from) || removed.has(edge.to))) throw new Error('后台撤销与本地连线冲突,本地编辑已保留')
|
|
1072
|
+
}
|
|
1073
|
+
doc.nodes = doc.nodes.filter((node) => !removed.has(node.id))
|
|
1074
|
+
if (Array.isArray(doc.drawOrder)) doc.drawOrder = doc.drawOrder.filter((id) => !removed.has(id))
|
|
1075
|
+
for (const node of change.added || []) if (!doc.nodes.some((item) => item.id === node.id)) {
|
|
1076
|
+
doc.nodes.push(JSON.parse(JSON.stringify(node)))
|
|
1077
|
+
if (Array.isArray(doc.drawOrder) && !doc.drawOrder.includes(node.id)) doc.drawOrder.push(node.id)
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return doc
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
|
|
1061
1084
|
// Bounded aggregates shared by browser, host and Workers. Durations may overlap.
|
|
1062
1085
|
function createPerformanceMetrics(clock = () => performance.now()) {
|
|
1063
1086
|
const started = clock(), stages = Object.create(null), counters = Object.create(null), peaks = Object.create(null)
|
|
@@ -1872,6 +1895,84 @@ window.__ModuleLoader__.load({
|
|
|
1872
1895
|
return { createVideoController, VideoSurface, VideoControls, VideoPreview }
|
|
1873
1896
|
})()
|
|
1874
1897
|
|
|
1898
|
+
// Browser-only task presentation. Execution and persistence belong to the host.
|
|
1899
|
+
function BackgroundTaskPanel(props) {
|
|
1900
|
+
const [collapsed, setCollapsed] = React.useState(false)
|
|
1901
|
+
const [filter, setFilter] = React.useState('active')
|
|
1902
|
+
const [busy, setBusy] = React.useState('')
|
|
1903
|
+
const [error, setError] = React.useState('')
|
|
1904
|
+
const jobs = props.jobs.concat(props.exports.map((job) => ({ ...job, id: job.token, export: true, label: job.clip + ' · ' + job.format.toUpperCase() })))
|
|
1905
|
+
const attention = (job) => ['failed', 'error', 'interrupted'].includes(job.status) || job.delivery === 'conflict'
|
|
1906
|
+
const ended = (job) => ['succeeded', 'done', 'cancelled', 'failed', 'error'].includes(job.status)
|
|
1907
|
+
const live = jobs.filter((job) => !ended(job)).length, needs = jobs.filter(attention).length
|
|
1908
|
+
const visible = jobs.filter((job) => filter === 'all' || (filter === 'active' ? !ended(job) || attention(job) : filter === 'attention' ? attention(job) : ended(job) && !attention(job)))
|
|
1909
|
+
const labels = { queued: '等待中', running: '绘制中', paused: '已暂停', cancelling: '正在取消', interrupted: '等待恢复', failed: '绘制失败', cancelled: '已取消', succeeded: '已完成', rendering: '渲染中', encoding: '编码中', done: '已保存文件', error: '导出失败' }
|
|
1910
|
+
const phases = { preparing: '检查素材', probing: '探测视频', decoding: '解码抽帧', tracing: '绘制色块' }
|
|
1911
|
+
const invoke = async (job, action, value) => {
|
|
1912
|
+
setBusy(job.id || 'queue'); setError('')
|
|
1913
|
+
try { await props.onCommand(job, action, value) }
|
|
1914
|
+
catch (err) { setError(String(err.message || err)) }
|
|
1915
|
+
finally { setBusy('') }
|
|
1916
|
+
}
|
|
1917
|
+
const button = (text, action, disabled = false) => React.createElement('button', { key: text, type: 'button', className: 'drawai-trace-pin', disabled: !!busy || disabled, onClick: action }, text)
|
|
1918
|
+
if (props.hidden) return null
|
|
1919
|
+
return React.createElement('section', { className: 'drawai-video-task-card drawai-background-tasks', role: 'region', 'aria-label': '后台任务队列',
|
|
1920
|
+
style: { width: 'min(380px, calc(100% - 24px))', maxHeight: '60%', overflow: 'auto', left: 12, right: 'auto' },
|
|
1921
|
+
onPointerDown: (event) => event.stopPropagation(), onDoubleClick: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(),
|
|
1922
|
+
}, React.createElement('div', { className: 'drawai-trace-card-heading' },
|
|
1923
|
+
React.createElement('strong', null, '后台任务 · ' + live + ' 项进行中' + (needs ? ' · ' + needs + ' 项需处理' : '')),
|
|
1924
|
+
React.createElement('div', { className: 'drawai-trace-card-actions' },
|
|
1925
|
+
button(collapsed ? '展开' : '收起', () => setCollapsed(!collapsed)),
|
|
1926
|
+
React.createElement('button', { type: 'button', className: 'drawai-trace-pin', 'aria-label': '隐藏任务列表', onClick: props.onHide }, '隐藏'))),
|
|
1927
|
+
collapsed ? null : React.createElement('div', null,
|
|
1928
|
+
React.createElement('div', { className: 'drawai-trace-card-actions', style: { flexWrap: 'wrap' }, role: 'group', 'aria-label': '筛选任务' },
|
|
1929
|
+
[['active', '进行中'], ['attention', '需处理'], ['complete', '已结束'], ['all', '全部']].map(([key, label]) =>
|
|
1930
|
+
React.createElement('button', { key, type: 'button', className: 'drawai-trace-pin', 'aria-pressed': filter === key, style: { fontWeight: filter === key ? 700 : 400 }, onClick: () => setFilter(key) }, label)),
|
|
1931
|
+
props.jobs.length ? button(props.jobs.some((job) => job.queuePaused) ? '继续绘制队列' : '暂停后续绘制', () => invoke({}, 'pause-queue', !props.jobs.some((job) => job.queuePaused))) : null),
|
|
1932
|
+
React.createElement('small', null, '切换页面或关闭网页后继续;宿主重启后可恢复绘制。'),
|
|
1933
|
+
error || props.error ? React.createElement('div', { role: 'alert', className: 'drawai-err', style: { userSelect: 'text' } }, error || props.error) : null,
|
|
1934
|
+
!visible.length ? React.createElement('p', { className: 'drawai-note' }, jobs.length ? '此分类暂无任务,可切换到“全部”查看记录。' : '暂无后台任务') : null,
|
|
1935
|
+
visible.map((job) => {
|
|
1936
|
+
const running = ['running', 'rendering', 'encoding'].includes(job.status)
|
|
1937
|
+
const done = job.export ? job.status === 'rendering' ? job.received : job.encoded : job.done
|
|
1938
|
+
const total = job.export ? job.count : job.total
|
|
1939
|
+
const percent = total ? Math.min(100, Math.round((done || 0) / total * 100)) : 0
|
|
1940
|
+
const state = job.delivery === 'conflict' ? '产物已保存 · 等待加入画布' : job.delivery === 'undone' ? '生成节点已撤销' : job.delivery === 'applied' && job.status === 'succeeded' ? '已写入画布' : labels[job.status] || job.status
|
|
1941
|
+
const here = !job.export && job.scope.path === props.path && job.scope.page === props.page
|
|
1942
|
+
const queued = props.jobs.filter((item) => item.status === 'queued')
|
|
1943
|
+
return React.createElement('article', { key: job.id, className: 'drawai-video-task-detail', style: { borderTop: '1px solid #8883', paddingTop: 10, marginTop: 8, userSelect: 'text' } },
|
|
1944
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
|
|
1945
|
+
React.createElement('strong', { style: { overflowWrap: 'anywhere' } }, job.label), React.createElement('small', { role: 'status' }, state)),
|
|
1946
|
+
!job.export ? React.createElement('small', null, ({ image: '图片绘制', video: '视频绘制', extract: '视频抽帧' }[job.kind]) + ' · ' + String(job.scope.path).split(/[\\/]/).pop() + ' · ' + (job.scope.pageName || '第 ' + (job.scope.page + 1) + ' 页') + (job.kind !== 'extract' ? ' · ' + job.options.colors + ' 色' : '')) : null,
|
|
1947
|
+
running ? React.createElement('div', null,
|
|
1948
|
+
React.createElement('progress', { value: percent, max: 100, style: { width: '100%' }, 'aria-label': job.label + '进度' }),
|
|
1949
|
+
React.createElement('small', null, (phases[job.phase] || labels[job.status]) + (total > 1 ? ' · ' + (done || 0) + '/' + total + ' 帧 · ' + percent + '%' : '') + (job.startedAt ? ' · ' + Math.floor((Date.now() - job.startedAt) / 1000) + ' 秒' : ''))) : null,
|
|
1950
|
+
job.pauseRequested ? React.createElement('small', null, '正在等待完整帧保存后暂停…') : null,
|
|
1951
|
+
job.reducedFrames ? React.createElement('small', null, job.reducedFrames + ' 帧因 SVG 字符预算进行了细节简化') : null,
|
|
1952
|
+
job.path ? React.createElement('small', { style: { overflowWrap: 'anywhere' } }, '文件:' + job.path) : null,
|
|
1953
|
+
job.error ? React.createElement('div', { className: 'drawai-err', style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' } }, job.error) : null,
|
|
1954
|
+
React.createElement('div', { className: 'drawai-trace-card-actions', style: { flexWrap: 'wrap' } },
|
|
1955
|
+
job.export ? [job.status === 'done' ? button('下载文件', () => invoke(job, 'download')) : null,
|
|
1956
|
+
['error', 'interrupted'].includes(job.status) ? button('重新导出', () => invoke(job, 'retry-export')) : null,
|
|
1957
|
+
button(ended(job) || job.status === 'interrupted' ? '移除记录' : '取消导出', () => invoke(job, 'discard'))] : [
|
|
1958
|
+
job.status === 'queued' ? button('上移', () => invoke(job, 'move', -1), queued[0]?.id === job.id) : null,
|
|
1959
|
+
job.status === 'queued' ? button('下移', () => invoke(job, 'move', 1), queued.at(-1)?.id === job.id) : null,
|
|
1960
|
+
job.status === 'queued' || job.status === 'running' && job.kind === 'video' ? button('暂停', () => invoke(job, 'pause'), job.pauseRequested) : null,
|
|
1961
|
+
['paused', 'interrupted'].includes(job.status) ? button('继续', () => invoke(job, 'resume')) : null,
|
|
1962
|
+
['failed', 'cancelled'].includes(job.status) ? button('重试', () => invoke(job, 'retry')) : null,
|
|
1963
|
+
['failed', 'cancelled', 'interrupted', 'paused'].includes(job.status) ? button('重新开始', () => invoke(job, 'restart')) : null,
|
|
1964
|
+
['queued', 'running', 'paused', 'interrupted'].includes(job.status) ? button('取消', () => invoke(job, 'cancel')) : null,
|
|
1965
|
+
job.delivery === 'conflict' || job.delivery === 'undone' ? button('加入原画布', () => invoke(job, 'apply')) : null,
|
|
1966
|
+
job.delivery === 'applied' && job.kind !== 'extract' ? button('撤销生成', () => invoke(job, 'undo')) : null,
|
|
1967
|
+
job.nodeId && here ? button(job.kind === 'image' ? '查看 / 回放绘制' : '定位结果', () => invoke(job, 'locate')) : null,
|
|
1968
|
+
!here ? button('打开原页面', () => props.onOpen(job)) : null,
|
|
1969
|
+
ended(job) ? button('移除记录', () => invoke(job, 'remove')) : null,
|
|
1970
|
+
],
|
|
1971
|
+
job.error ? button('复制错误', () => writeClipboardText(job.error).catch(() => {})) : null))
|
|
1972
|
+
})))
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
|
|
1875
1976
|
const React = require('react')
|
|
1876
1977
|
const { createVideoController, VideoSurface, VideoControls, VideoPreview } = videoPlayer
|
|
1877
1978
|
|
|
@@ -4455,6 +4556,12 @@ window.__ModuleLoader__.load({
|
|
|
4455
4556
|
if (kind === 'text') {
|
|
4456
4557
|
return svg(React.createElement('path', { d: 'M6 6.5h12M12 6.5V18' }))
|
|
4457
4558
|
}
|
|
4559
|
+
if (kind === 'tasks') {
|
|
4560
|
+
return svg([
|
|
4561
|
+
React.createElement('path', { key: 'checks', d: 'M3 6l1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2' }),
|
|
4562
|
+
React.createElement('path', { key: 'rows', d: 'M10 6h11M10 12h11M10 18h11' }),
|
|
4563
|
+
])
|
|
4564
|
+
}
|
|
4458
4565
|
if (kind === 'section') {
|
|
4459
4566
|
// 叠框:分区 / 容器(对应飞书侧栏的重叠矩形)。
|
|
4460
4567
|
return svg([
|
|
@@ -5096,7 +5203,7 @@ window.__ModuleLoader__.load({
|
|
|
5096
5203
|
|
|
5097
5204
|
/** 本机偏好(不进文件):网格、默认形状/配色。 */
|
|
5098
5205
|
const DRAWAI_PREFS_KEY = 'dsh-drawai.prefs'
|
|
5099
|
-
const DEFAULT_DRAWAI_PREFS = { showGrid: true, defaultShape: 'rounded', defaultColor: 'blue' }
|
|
5206
|
+
const DEFAULT_DRAWAI_PREFS = { showGrid: true, showTaskList: false, defaultShape: 'rounded', defaultColor: 'blue' }
|
|
5100
5207
|
|
|
5101
5208
|
const STANDARD_COLORS = [
|
|
5102
5209
|
['#ffffff', '白'], ['#f5f5f5', '浅灰'], ['#e1d5e7', '浅紫'], ['#eff4ff', '冰蓝'],
|
|
@@ -5617,7 +5724,9 @@ window.__ModuleLoader__.load({
|
|
|
5617
5724
|
const [options, setOptions] = React.useState(() => Object.assign({ range: 'clip', from: 0, end: 5, fps: 12, resolution: '480', width: 1024,
|
|
5618
5725
|
level: 'medium', preset: 'default', colors: 12, removeBg: false }, props.initialOptions || props.job && props.job.options || {}))
|
|
5619
5726
|
const job = props.job
|
|
5620
|
-
const
|
|
5727
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
5728
|
+
const [submitError, setSubmitError] = React.useState('')
|
|
5729
|
+
const busy = submitting || job && job.busy
|
|
5621
5730
|
const changeOptions = (patch) => {
|
|
5622
5731
|
const next = Object.assign({}, options, patch)
|
|
5623
5732
|
setOptions(next)
|
|
@@ -5661,12 +5770,18 @@ window.__ModuleLoader__.load({
|
|
|
5661
5770
|
React.createElement('div', { className: 'drawai-export-info', role: 'status' }, invalid || estimate),
|
|
5662
5771
|
React.createElement('div', { className: 'drawai-export-info' }, '画质、帧率和范围由你决定。提高尺寸、帧率或时长会增加处理时间与文件大小;小视频不放大。原视频保留,结果不含音轨。'),
|
|
5663
5772
|
job ? React.createElement('div', { role: job.error ? 'alert' : 'status', 'aria-live': 'polite' }, job.message) : null,
|
|
5773
|
+
submitError ? React.createElement('div', { role: 'alert', className: 'drawai-err' }, submitError) : null,
|
|
5664
5774
|
busy ? React.createElement('progress', { value: job.done || 0, max: job.total || 1, 'aria-label': '视频复刻进度', style: { width: '100%' } }) : null,
|
|
5665
5775
|
job && job.svg ? React.createElement('img', { src: 'data:image/svg+xml,' + encodeURIComponent(job.svg), alt: '最近完成的 SVG 帧', style: { width: '100%', maxHeight: '180px', objectFit: 'contain' } }) : null,
|
|
5666
5776
|
React.createElement('div', { className: 'drawai-layer-footer' },
|
|
5667
5777
|
props.inline ? null : React.createElement('button', { type: 'button', className: 'drawai-btn', onClick: busy ? props.onCancel : props.onClose }, busy ? '取消复刻' : '关闭'),
|
|
5668
5778
|
React.createElement('button', { type: 'button', className: 'drawai-btn drawai-primary-btn', disabled: busy || !!invalid,
|
|
5669
|
-
onClick: () =>
|
|
5779
|
+
onClick: async () => {
|
|
5780
|
+
setSubmitting(true); setSubmitError('')
|
|
5781
|
+
try { await props.onStart(options) }
|
|
5782
|
+
catch (error) { setSubmitError(String(error.message || error)) }
|
|
5783
|
+
finally { setSubmitting(false) }
|
|
5784
|
+
} }, job && job.error ? '重新复刻' : props.queued ? '加入绘制队列' : '让大肥鱼开始画画!'))))
|
|
5670
5785
|
}
|
|
5671
5786
|
|
|
5672
5787
|
// 安装监听后才触发 load/seek;超时、取消、解码错误均释放监听器。
|
|
@@ -5872,7 +5987,7 @@ window.__ModuleLoader__.load({
|
|
|
5872
5987
|
}, props.renderPanel ? props.renderPanel(close) : React.createElement(ImageTracePanel, {
|
|
5873
5988
|
key: props.source && props.source.id, source: props.source,
|
|
5874
5989
|
onGenerate: (options) => props.onGenerate(props.source, options),
|
|
5875
|
-
onEnqueue: props.onEnqueue ? (options) => { const job = props.onEnqueue(props.source, options); close(); return job } : undefined,
|
|
5990
|
+
onEnqueue: props.onEnqueue ? async (options) => { const job = await props.onEnqueue(props.source, options); close(); return job } : undefined,
|
|
5876
5991
|
onApply: (result) => { props.onApply(props.source, result); close() },
|
|
5877
5992
|
})),
|
|
5878
5993
|
)
|
|
@@ -5900,6 +6015,29 @@ window.__ModuleLoader__.load({
|
|
|
5900
6015
|
job.error && job.options ? React.createElement('button', { type: 'button', className: 'drawai-btn', onClick: props.onRetry }, '重试复刻') : null))
|
|
5901
6016
|
}
|
|
5902
6017
|
|
|
6018
|
+
function VideoExportJobs(props) {
|
|
6019
|
+
const [collapsed, setCollapsed] = React.useState(false)
|
|
6020
|
+
if (!props.jobs.length) return null
|
|
6021
|
+
const button = (label, onClick) => React.createElement('button', { type: 'button', className: 'drawai-trace-pin', onClick }, label)
|
|
6022
|
+
return React.createElement('section', { className: 'drawai-video-task-card', role: 'region', 'aria-label': '后台视频导出',
|
|
6023
|
+
style: { top: 68, bottom: 'auto', left: 12, right: 'auto', width: 320, maxHeight: '45%', overflow: 'auto' },
|
|
6024
|
+
onPointerDown: (event) => event.stopPropagation(), onDoubleClick: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(),
|
|
6025
|
+
}, React.createElement('div', { className: 'drawai-trace-card-heading' },
|
|
6026
|
+
React.createElement('strong', null, '后台导出 · ' + props.jobs.length),
|
|
6027
|
+
button(collapsed ? '展开' : '收起', () => setCollapsed(!collapsed))),
|
|
6028
|
+
collapsed ? null : props.jobs.map((job) => React.createElement('div', { key: job.token, className: 'drawai-video-task-detail' },
|
|
6029
|
+
React.createElement('strong', null, job.clip + ' · ' + job.format.toUpperCase() + ' · ' +
|
|
6030
|
+
({ rendering: '正在渲染', encoding: '正在编码', done: '已保存', error: '导出失败' }[job.status] || job.status)),
|
|
6031
|
+
['rendering', 'encoding'].includes(job.status) ? React.createElement('small', null,
|
|
6032
|
+
(job.status === 'rendering' ? job.received : job.encoded) + (job.count ? '/' + job.count : '') + ' 帧;切换页面后仍会继续') : null,
|
|
6033
|
+
job.path ? React.createElement('small', { style: { overflowWrap: 'anywhere', userSelect: 'text' } }, '已保存:' + job.path) : null,
|
|
6034
|
+
job.error ? React.createElement('div', { className: 'drawai-err', role: 'alert' }, job.error) : null,
|
|
6035
|
+
React.createElement('div', { className: 'drawai-trace-card-actions' },
|
|
6036
|
+
job.error ? button('复制错误', () => writeClipboardText(job.error).catch(() => {})) : null,
|
|
6037
|
+
job.status === 'done' ? button('下载文件', () => props.onDownload(job)) : null,
|
|
6038
|
+
button(['done', 'error'].includes(job.status) ? '移除记录' : '取消导出', () => props.onDiscard(job))))))
|
|
6039
|
+
}
|
|
6040
|
+
|
|
5903
6041
|
function DrawingQueuePanel(props) {
|
|
5904
6042
|
const [, refresh] = React.useState(0)
|
|
5905
6043
|
const [collapsed, setCollapsed] = React.useState(false)
|
|
@@ -8593,6 +8731,42 @@ window.__ModuleLoader__.load({
|
|
|
8593
8731
|
mediaRequestRef.current = mediaAction
|
|
8594
8732
|
const [videoController] = React.useState(() => createVideoController((action, extra) => mediaRequestRef.current(action, extra)))
|
|
8595
8733
|
const videoExportRun = React.useRef(false)
|
|
8734
|
+
const [videoExportJobs, setVideoExportJobs] = React.useState([])
|
|
8735
|
+
const [backgroundDrawingJobs, setBackgroundDrawingJobs] = React.useState([])
|
|
8736
|
+
const [backgroundQueueError, setBackgroundQueueError] = React.useState('')
|
|
8737
|
+
React.useEffect(() => {
|
|
8738
|
+
if (!client || !hasPath || !active) return
|
|
8739
|
+
let alive = true, timer, fetching = false
|
|
8740
|
+
const poll = async () => {
|
|
8741
|
+
if (!alive || fetching) return
|
|
8742
|
+
fetching = true
|
|
8743
|
+
try {
|
|
8744
|
+
const result = await mediaAction('drawing-task-list', {}, { throwOnError: true })
|
|
8745
|
+
if (alive) { setBackgroundDrawingJobs(result.jobs || []); setBackgroundQueueError('') }
|
|
8746
|
+
} catch (error) { if (alive) setBackgroundQueueError('任务状态暂时无法更新:' + String(error.message || error)) }
|
|
8747
|
+
finally { fetching = false; if (alive) timer = setTimeout(poll, document.hidden ? 8000 : 1500) }
|
|
8748
|
+
}
|
|
8749
|
+
const resume = () => { if (!document.hidden) { clearTimeout(timer); poll() } }
|
|
8750
|
+
poll(); document.addEventListener('visibilitychange', resume)
|
|
8751
|
+
return () => { alive = false; clearTimeout(timer); document.removeEventListener('visibilitychange', resume) }
|
|
8752
|
+
}, [client, sessionId, target, hasPath, active])
|
|
8753
|
+
React.useEffect(() => {
|
|
8754
|
+
if (!client || !hasPath || !active) return
|
|
8755
|
+
let alive = true, timer, fetching = false
|
|
8756
|
+
const poll = async () => {
|
|
8757
|
+
if (!alive || fetching) return
|
|
8758
|
+
fetching = true
|
|
8759
|
+
try {
|
|
8760
|
+
const result = await mediaAction('media-video-export-list', {}, { throwOnError: true })
|
|
8761
|
+
if (alive && Array.isArray(result?.jobs)) setVideoExportJobs(result.jobs)
|
|
8762
|
+
} catch { /* Keep the last known progress while disconnected. */ }
|
|
8763
|
+
finally { fetching = false; if (alive) timer = setTimeout(poll, 1500) }
|
|
8764
|
+
}
|
|
8765
|
+
const resume = () => { if (!document.hidden) { clearTimeout(timer); poll() } }
|
|
8766
|
+
setVideoExportJobs([]); poll()
|
|
8767
|
+
document.addEventListener('visibilitychange', resume)
|
|
8768
|
+
return () => { alive = false; clearTimeout(timer); document.removeEventListener('visibilitychange', resume) }
|
|
8769
|
+
}, [client, sessionId, target, hasPath, active])
|
|
8596
8770
|
React.useEffect(() => () => videoController.reset(), [videoController, target])
|
|
8597
8771
|
React.useEffect(() => { if (!active) videoController.suspend() }, [videoController, active])
|
|
8598
8772
|
React.useEffect(() => { videoController.prune(doc === null ? [] : doc.nodes) }, [videoController, doc])
|
|
@@ -8642,6 +8816,12 @@ window.__ModuleLoader__.load({
|
|
|
8642
8816
|
const pageIndex = pageIndexState[0]
|
|
8643
8817
|
const setPageIndex = pageIndexState[1]
|
|
8644
8818
|
React.useEffect(() => { canvasSessions.workspace(sessionId).pages[target] = pageIndex }, [sessionId, target, pageIndex])
|
|
8819
|
+
const handledPageRequest = React.useRef(null)
|
|
8820
|
+
React.useEffect(() => {
|
|
8821
|
+
if (props.pageRequest && active && props.pageRequest !== handledPageRequest.current) {
|
|
8822
|
+
handledPageRequest.current = props.pageRequest; switchPage(props.pageRequest.page)
|
|
8823
|
+
}
|
|
8824
|
+
}, [props.pageRequest, active])
|
|
8645
8825
|
React.useEffect(() => () => {
|
|
8646
8826
|
pendingVideoRef.current = null
|
|
8647
8827
|
for (const controller of videoImportsRef.current || []) controller.abort()
|
|
@@ -8655,6 +8835,7 @@ window.__ModuleLoader__.load({
|
|
|
8655
8835
|
const prefs = prefsState[0]
|
|
8656
8836
|
const setPrefs = prefsState[1]
|
|
8657
8837
|
const showGrid = prefs.showGrid !== false
|
|
8838
|
+
const showTaskList = prefs.showTaskList === true
|
|
8658
8839
|
function patchPrefs(patch) {
|
|
8659
8840
|
const next = Object.assign({}, prefs, patch === undefined || patch === null ? {} : patch)
|
|
8660
8841
|
setPrefs(next)
|
|
@@ -8674,6 +8855,18 @@ window.__ModuleLoader__.load({
|
|
|
8674
8855
|
const edgePreview = previewState[0]
|
|
8675
8856
|
const setEdgePreview = previewState[1]
|
|
8676
8857
|
const [traceDrawing, setTraceDrawing] = React.useState(null)
|
|
8858
|
+
const autoReplayTasks = React.useRef(new Set())
|
|
8859
|
+
React.useEffect(() => {
|
|
8860
|
+
if (!active || !doc) return
|
|
8861
|
+
for (const taskId of autoReplayTasks.current) {
|
|
8862
|
+
const node = doc.nodes.find((item) => item.data?.drawaiTaskId === taskId)
|
|
8863
|
+
if (!node) continue
|
|
8864
|
+
autoReplayTasks.current.delete(taskId)
|
|
8865
|
+
mediaAction('drawing-task-result', { taskId }, { throwOnError: true }).then((result) => {
|
|
8866
|
+
if (result.output.drawing?.steps.length) setTraceDrawing({ id: node.id, svg: result.output.svg, drawing: result.output.drawing, metrics: result.output.metrics })
|
|
8867
|
+
}).catch((error) => setSaveNote('绘制结果已保存,回放加载失败:' + String(error.message || error)))
|
|
8868
|
+
}
|
|
8869
|
+
}, [active, doc])
|
|
8677
8870
|
const videoTraceRun = canvasSessions.task(sessionId, target, pageIndex)
|
|
8678
8871
|
const [, refreshVideoTrace] = React.useState(0)
|
|
8679
8872
|
React.useEffect(() => canvasSessions.subscribe(videoTraceRun, () => refreshVideoTrace((value) => value + 1)), [videoTraceRun])
|
|
@@ -8715,23 +8908,50 @@ window.__ModuleLoader__.load({
|
|
|
8715
8908
|
}
|
|
8716
8909
|
}
|
|
8717
8910
|
React.useEffect(applyPendingVideoTrace, [doc, videoTraceRun, videoTraceRun.result, status.kind])
|
|
8718
|
-
function
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8911
|
+
async function submitBackgroundDrawing(kind, source, options) {
|
|
8912
|
+
await saveNow()
|
|
8913
|
+
if (dirtyRef.current || saveInFlightRef.current) throw new Error('画布自动保存未完成,请稍后重试')
|
|
8914
|
+
const result = await mediaAction('drawing-task-start', { kind, sourceId: source.id, options,
|
|
8915
|
+
page: pageIndex, revision: revisionRef.current }, { throwOnError: true })
|
|
8916
|
+
setBackgroundDrawingJobs((jobs) => jobs.filter((job) => job.id !== result.job.id).concat(result.job))
|
|
8917
|
+
if (kind === 'image') autoReplayTasks.current.add(result.job.id)
|
|
8918
|
+
setSaveNote('已加入后台队列,切换或关闭网页后仍会继续')
|
|
8919
|
+
return result.job
|
|
8920
|
+
}
|
|
8921
|
+
async function enqueueVideoTrace(options, source) {
|
|
8922
|
+
return submitBackgroundDrawing('video', source, options)
|
|
8923
|
+
}
|
|
8924
|
+
async function enqueueImageTrace(source, options) {
|
|
8925
|
+
return submitBackgroundDrawing('image', source, options)
|
|
8926
|
+
}
|
|
8927
|
+
async function backgroundTaskCommand(job, command, value) {
|
|
8928
|
+
if (job.export) {
|
|
8929
|
+
if (command === 'download') return downloadVideoExport(job)
|
|
8930
|
+
if (command === 'retry-export') {
|
|
8931
|
+
const result = await mediaAction('media-video-export-start', { clip: job.clip, format: job.format }, { throwOnError: true })
|
|
8932
|
+
setVideoExportJobs((jobs) => jobs.concat(result)); return
|
|
8933
|
+
}
|
|
8934
|
+
await mediaAction('media-video-export-discard', { clip: job.clip, token: job.token }, { throwOnError: true })
|
|
8935
|
+
setVideoExportJobs((jobs) => jobs.filter((item) => item.token !== job.token)); return
|
|
8936
|
+
}
|
|
8937
|
+
if (command === 'locate') {
|
|
8938
|
+
const node = docRef.current?.nodes.find((item) => item.data?.drawaiTaskId === job.id)
|
|
8939
|
+
if (!node) throw new Error('结果尚未同步到当前页,请稍后重试或打开原页面')
|
|
8940
|
+
setSelectedIds([node.id]); setViewOverride(null)
|
|
8941
|
+
if (job.kind === 'image') {
|
|
8942
|
+
const result = await mediaAction('drawing-task-result', { taskId: job.id }, { throwOnError: true })
|
|
8943
|
+
if (result.output.drawing?.steps.length) setTraceDrawing({ id: node.id, svg: result.output.svg, drawing: result.output.drawing, metrics: result.output.metrics })
|
|
8944
|
+
}
|
|
8945
|
+
return
|
|
8946
|
+
}
|
|
8947
|
+
if (['apply', 'undo'].includes(command)) {
|
|
8948
|
+
await saveNow()
|
|
8949
|
+
if (dirtyRef.current) throw new Error('请等待当前编辑自动保存成功后重试')
|
|
8950
|
+
}
|
|
8951
|
+
await mediaAction('drawing-task-command', { taskId: job.id, command, value }, { throwOnError: true })
|
|
8952
|
+
const result = await mediaAction('drawing-task-list', {}, { throwOnError: true })
|
|
8953
|
+
setBackgroundDrawingJobs(result.jobs || [])
|
|
8954
|
+
if (['apply', 'undo'].includes(command)) setNonce((value) => value + 1)
|
|
8735
8955
|
}
|
|
8736
8956
|
async function startVideoTrace(options, sourceOverride, queueContext) {
|
|
8737
8957
|
if (videoTraceRun.current || videoTraceRun.result || videoTraceRun.pendingSave || !(sourceOverride || videoTraceSource)) {
|
|
@@ -8983,6 +9203,15 @@ window.__ModuleLoader__.load({
|
|
|
8983
9203
|
}
|
|
8984
9204
|
|
|
8985
9205
|
/** 服务端推来新版本时清空历史 —— 否则一次撤销会把别人(或 AI)刚写的改动悄悄顶掉。 */
|
|
9206
|
+
function mergeBackgroundHistory(patch) {
|
|
9207
|
+
for (const document of historyRef.current.past.concat(historyRef.current.future)) applyTaskPatches(document, patch)
|
|
9208
|
+
if (docRef.current) {
|
|
9209
|
+
const next = applyTaskPatches(cloneDoc(docRef.current), patch)
|
|
9210
|
+
docRef.current = next; setDoc(next)
|
|
9211
|
+
}
|
|
9212
|
+
lastCoalesceRef.current = null
|
|
9213
|
+
}
|
|
9214
|
+
|
|
8986
9215
|
function resetHistory() {
|
|
8987
9216
|
historyRef.current.past.length = 0
|
|
8988
9217
|
historyRef.current.future.length = 0
|
|
@@ -9586,10 +9815,9 @@ window.__ModuleLoader__.load({
|
|
|
9586
9815
|
if (response === null || response.ok !== true) {
|
|
9587
9816
|
const message = String(response !== null && response.error ? response.error : 'unknown')
|
|
9588
9817
|
if (message === 'revision conflict') {
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
setSaveNote('保存被拒:文件已被他处修改,稍后会重新载入')
|
|
9818
|
+
dirtyRef.current = true
|
|
9819
|
+
reportSaveError('自动保存遇到版本冲突,本地编辑已保留')
|
|
9820
|
+
setSaveNote('保存冲突:本地编辑已保留,请先另存副本或处理外部修改')
|
|
9593
9821
|
return
|
|
9594
9822
|
}
|
|
9595
9823
|
dirtyRef.current = true
|
|
@@ -9598,6 +9826,7 @@ window.__ModuleLoader__.load({
|
|
|
9598
9826
|
return
|
|
9599
9827
|
}
|
|
9600
9828
|
revisionRef.current = response.revision
|
|
9829
|
+
if (response.backgroundPatch) mergeBackgroundHistory(response.backgroundPatch)
|
|
9601
9830
|
canvasSessions.acknowledge(videoTraceRun, snapshot)
|
|
9602
9831
|
// 有边被略过就如实说(两端都没有落点的边写不进文件),别让用户以为"全保存了"。
|
|
9603
9832
|
const dropped = Array.isArray(response.dropped) ? response.dropped : []
|
|
@@ -12182,11 +12411,12 @@ window.__ModuleLoader__.load({
|
|
|
12182
12411
|
*/
|
|
12183
12412
|
async function pull() {
|
|
12184
12413
|
let payload = null
|
|
12414
|
+
const requestedRevision = revisionRef.current
|
|
12185
12415
|
try {
|
|
12186
12416
|
const raw = await fetch(SAVE_ENDPOINT, {
|
|
12187
12417
|
method: 'POST',
|
|
12188
12418
|
headers: { 'content-type': 'application/json', [SAVE_HEADER]: '1' },
|
|
12189
|
-
body: JSON.stringify({ action: 'read', sessionId: sessionId, path: target, page: pageIndex }),
|
|
12419
|
+
body: JSON.stringify({ action: 'read', sessionId: sessionId, path: target, page: pageIndex, revision: requestedRevision }),
|
|
12190
12420
|
signal: signal,
|
|
12191
12421
|
})
|
|
12192
12422
|
payload = await raw.json()
|
|
@@ -12279,6 +12509,7 @@ window.__ModuleLoader__.load({
|
|
|
12279
12509
|
setStatus((prev) => (prev.kind === 'ready' ? prev : { kind: 'ready', path: absolute, error: '', absolute: absolute }))
|
|
12280
12510
|
return
|
|
12281
12511
|
}
|
|
12512
|
+
if (revisionRef.current !== requestedRevision) return
|
|
12282
12513
|
if (loadedDocumentRef.current === videoTraceRun && parsed.doc.revision === revisionRef.current && docRef.current !== null) {
|
|
12283
12514
|
setStatus((prev) => (prev.kind === 'ready' && prev.absolute === absolute ? prev : { kind: 'ready', path: absolute, error: '', absolute: absolute }))
|
|
12284
12515
|
return
|
|
@@ -12287,7 +12518,8 @@ window.__ModuleLoader__.load({
|
|
|
12287
12518
|
loadedDocumentRef.current = videoTraceRun
|
|
12288
12519
|
canvasSessions.restore(videoTraceRun, parsed.doc)
|
|
12289
12520
|
revisionRef.current = parsed.doc.revision
|
|
12290
|
-
|
|
12521
|
+
if (payload.backgroundPatch) mergeBackgroundHistory(payload.backgroundPatch)
|
|
12522
|
+
else resetHistory()
|
|
12291
12523
|
setDoc(parsed.doc)
|
|
12292
12524
|
if (Number.isFinite(Number(payload.pageCount))) setPageCount(Math.max(1, Number(payload.pageCount)))
|
|
12293
12525
|
if (Number.isFinite(Number(payload.pageIndex))) setPageIndex(Number(payload.pageIndex))
|
|
@@ -12730,6 +12962,13 @@ window.__ModuleLoader__.load({
|
|
|
12730
12962
|
* - `extractStillsFromVideo(node, every, max)` —— 工具栏「抽帧」按钮的老路径:t = i/12、步长 every。
|
|
12731
12963
|
*/
|
|
12732
12964
|
async function extractStillsFromVideo(node, planOrEvery, legacyMax) {
|
|
12965
|
+
if (videoMetaOf(node)?.kind === 'raster') {
|
|
12966
|
+
const options = planOrEvery && typeof planOrEvery === 'object'
|
|
12967
|
+
? { ...planOrEvery, range: 'segment' }
|
|
12968
|
+
: { range: 'all', fps: DEFAULT_MEDIA_FPS / Math.max(1, Number(planOrEvery) || 1), width: 480, max: legacyMax || 1200 }
|
|
12969
|
+
try { return await submitBackgroundDrawing('extract', node, options) }
|
|
12970
|
+
catch (error) { setSaveNote('后台抽帧失败:' + String(error.message || error)); return }
|
|
12971
|
+
}
|
|
12733
12972
|
const meta = videoMetaOf(node)
|
|
12734
12973
|
if (meta === null) return
|
|
12735
12974
|
const plan =
|
|
@@ -12847,68 +13086,28 @@ window.__ModuleLoader__.load({
|
|
|
12847
13086
|
setSaveNote('正在准备逐帧导出 ' + format.toUpperCase() + '…')
|
|
12848
13087
|
const pack = await request('media-video-export-start', { format })
|
|
12849
13088
|
perf.enable(pack.performanceEnabled)
|
|
12850
|
-
|
|
12851
|
-
|
|
12852
|
-
|
|
12853
|
-
|
|
12854
|
-
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
|
|
12861
|
-
|
|
12862
|
-
await perf.measure('svgImageDecode', () => new Promise((resolve, reject) => {
|
|
12863
|
-
image.onload = resolve; image.onerror = () => reject(new Error('第 ' + (i + 1) + ' 帧解码失败'))
|
|
12864
|
-
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(frame.svg)
|
|
12865
|
-
}))
|
|
12866
|
-
const drawDone = perf.start('canvasRender')
|
|
12867
|
-
context.fillStyle = '#fff'; context.fillRect(0, 0, pack.w, pack.h)
|
|
12868
|
-
context.drawImage(image, 0, 0, pack.w, pack.h)
|
|
12869
|
-
drawDone()
|
|
12870
|
-
const png = await perf.measure('pngEncode', () => new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('帧图片导出失败')), 'image/png')))
|
|
12871
|
-
perf.count('pngBytes', png.size)
|
|
12872
|
-
for (let offset = 0; offset < png.size; offset += 1024 * 1024) {
|
|
12873
|
-
const base64 = await perf.measure('base64Encode', () => readVideoChunk(png.slice(offset, offset + 1024 * 1024), signal))
|
|
12874
|
-
await request('media-video-export-chunk', Object.assign({}, auth, { index: i, offset, base64 }))
|
|
12875
|
-
}
|
|
12876
|
-
await request('media-video-export-frame', Object.assign({}, auth, { index: i }))
|
|
12877
|
-
setSaveNote('正在准备导出帧:' + (i + 1) + '/' + pack.count + '(' + pack.fps + 'fps)')
|
|
12878
|
-
}
|
|
12879
|
-
}
|
|
12880
|
-
const encodeDone = perf.start('encodingAndPolling')
|
|
12881
|
-
let status = await request('media-video-export-finish', auth)
|
|
12882
|
-
while (status.status === 'encoding') {
|
|
12883
|
-
setSaveNote('正在编码 ' + format.toUpperCase() + ':' + status.encoded + (pack.count ? '/' + pack.count : '') + ' 帧…')
|
|
12884
|
-
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
12885
|
-
status = await request('media-video-export-status', auth)
|
|
12886
|
-
}
|
|
12887
|
-
encodeDone()
|
|
12888
|
-
if (status.status !== 'done') throw new Error(status.error || '视频编码失败')
|
|
12889
|
-
const downloadDone = perf.start('download')
|
|
12890
|
-
const chunks = []
|
|
12891
|
-
for (let offset = 0; offset < status.bytes;) {
|
|
12892
|
-
const part = await request('media-video-export-read', Object.assign({}, auth, { offset }))
|
|
12893
|
-
const bytes = Uint8Array.from(atob(part.base64), (c) => c.charCodeAt(0))
|
|
12894
|
-
if (part.offset !== offset || !bytes.length || bytes.length !== part.bytes || part.total !== status.bytes) throw new Error('导出文件下载不完整')
|
|
12895
|
-
chunks.push(bytes); offset += bytes.length
|
|
12896
|
-
}
|
|
12897
|
-
downloadDone()
|
|
12898
|
-
const blob = new Blob(chunks, { type: 'video/' + format })
|
|
12899
|
-
if (!blob.size) throw new Error('视频编码结果为空')
|
|
12900
|
-
const url = URL.createObjectURL(blob)
|
|
12901
|
-
try {
|
|
12902
|
-
const anchor = document.createElement('a'); anchor.href = url
|
|
12903
|
-
anchor.download = (typeof target === 'string' ? target.split(/[\/]/).pop().replace(/.drawio$/i, '') : 'clip') + '-' + meta.clip + '.' + format
|
|
12904
|
-
anchor.click()
|
|
12905
|
-
} finally { URL.revokeObjectURL(url) }
|
|
12906
|
-
setSaveNote('已导出 ' + format.toUpperCase() + (pack.count ? ':' + pack.count + ' 帧,' + pack.fps + 'fps' : '') + '(白底,保留原片段音频)')
|
|
12907
|
-
} finally {
|
|
12908
|
-
const report = perf.report()
|
|
12909
|
-
// Release encoded files and uploaded PNGs on both success and failure.
|
|
12910
|
-
await mediaAction('media-video-export-discard', Object.assign({ clip: meta.clip, performance: pack.performanceEnabled ? report : undefined }, auth)).catch(() => {})
|
|
13089
|
+
if (!pack.token) throw new Error('后台导出任务未返回令牌')
|
|
13090
|
+
setVideoExportJobs((jobs) => [pack, ...jobs.filter((job) => job.token !== pack.token)])
|
|
13091
|
+
setSaveNote('已在后台开始导出 ' + format.toUpperCase() + ',完成后自动保存到画布文件旁,可切换标签页或对话')
|
|
13092
|
+
}
|
|
13093
|
+
|
|
13094
|
+
async function downloadVideoExport(job) {
|
|
13095
|
+
const chunks = []
|
|
13096
|
+
for (let offset = 0; offset < job.bytes;) {
|
|
13097
|
+
const part = await mediaAction('media-video-export-read', { clip: job.clip, token: job.token, offset }, { throwOnError: true })
|
|
13098
|
+
const bytes = Uint8Array.from(atob(part.base64), (c) => c.charCodeAt(0))
|
|
13099
|
+
if (part.offset !== offset || !bytes.length || bytes.length !== part.bytes || part.total !== job.bytes) throw new Error('导出文件下载不完整')
|
|
13100
|
+
chunks.push(bytes); offset += bytes.length
|
|
12911
13101
|
}
|
|
13102
|
+
const blob = new Blob(chunks, { type: job.format === 'gif' ? 'image/gif' : 'video/' + job.format })
|
|
13103
|
+
if (!blob.size) throw new Error('导出文件为空')
|
|
13104
|
+
const url = URL.createObjectURL(blob)
|
|
13105
|
+
try {
|
|
13106
|
+
const anchor = document.createElement('a'); anchor.href = url
|
|
13107
|
+
anchor.download = job.path ? job.path.split(/[\\/]/).pop() : job.clip + '.' + job.format
|
|
13108
|
+
anchor.click()
|
|
13109
|
+
} finally { URL.revokeObjectURL(url) }
|
|
13110
|
+
setSaveNote('已下载 ' + job.format.toUpperCase() + ';后台保存的文件仍然保留')
|
|
12912
13111
|
}
|
|
12913
13112
|
|
|
12914
13113
|
async function grabVideoStill(node) {
|
|
@@ -12970,6 +13169,7 @@ window.__ModuleLoader__.load({
|
|
|
12970
13169
|
if (result) setSaveNote('已导出 ' + result.path)
|
|
12971
13170
|
} },
|
|
12972
13171
|
{ label: '导出 MP4', export: true, disabled: () => !hasPath || !meta || videoExportRun.current, run: () => exportVideoFile(node, 'mp4') },
|
|
13172
|
+
{ label: '导出 GIF', export: true, disabled: () => !hasPath || !meta || videoExportRun.current, run: () => exportVideoFile(node, 'gif') },
|
|
12973
13173
|
{ label: '导出 WebM', export: true, mediaKind: 'svg', disabled: () => !hasPath || !meta || meta.kind !== 'svg' || videoExportRun.current, run: () => exportVideoFile(node, 'webm') },
|
|
12974
13174
|
{ label: '导出源视频', export: true, mediaKind: 'raster', disabled: () => !hasPath || !meta || meta.kind !== 'raster', run: async () => {
|
|
12975
13175
|
if (!meta) return
|
|
@@ -12986,10 +13186,10 @@ window.__ModuleLoader__.load({
|
|
|
12986
13186
|
renderPanel: (close) => React.createElement(VideoTracePanel, { inline: true, queued: true, source: {
|
|
12987
13187
|
id: node.id, clip: meta.clip, w: Number(node.data.mediaW), h: Number(node.data.mediaH),
|
|
12988
13188
|
duration: Number(node.data.mediaDuration) || videoController.get(node.id).duration,
|
|
12989
|
-
}, onClose: close, onStart: (options) => {
|
|
13189
|
+
}, onClose: close, onStart: async (options) => {
|
|
12990
13190
|
const source = { id: node.id, clip: meta.clip, w: Number(node.data.mediaW), h: Number(node.data.mediaH),
|
|
12991
13191
|
duration: Number(node.data.mediaDuration) || videoController.get(node.id).duration }
|
|
12992
|
-
|
|
13192
|
+
await enqueueVideoTrace(options, source); close()
|
|
12993
13193
|
} }),
|
|
12994
13194
|
}),
|
|
12995
13195
|
renderExport: (items, state, run, icon) => React.createElement(ToolbarPopover, {
|
|
@@ -13217,6 +13417,18 @@ window.__ModuleLoader__.load({
|
|
|
13217
13417
|
)
|
|
13218
13418
|
}
|
|
13219
13419
|
|
|
13420
|
+
const activeTaskCount = backgroundDrawingJobs.concat(videoExportJobs).filter((job) =>
|
|
13421
|
+
!['succeeded', 'done', 'cancelled', 'failed', 'error'].includes(job.status)).length
|
|
13422
|
+
chips.push(React.createElement('div', { key: 'tasks-separator', className: 'drawai-rail-sep', style: { marginTop: 'auto' } }),
|
|
13423
|
+
React.createElement('button', {
|
|
13424
|
+
key: 'rail-tasks', type: 'button', className: 'drawai-rail-chip' + (showTaskList ? ' on' : ''),
|
|
13425
|
+
style: { position: 'relative' }, 'aria-label': showTaskList ? '隐藏任务列表' : '展开任务列表', 'aria-expanded': showTaskList,
|
|
13426
|
+
title: '后台任务' + (activeTaskCount ? ' · ' + activeTaskCount + ' 项进行中' : ''),
|
|
13427
|
+
onClick: () => patchPrefs({ showTaskList: !showTaskList }),
|
|
13428
|
+
}, railIcon('tasks'), activeTaskCount ? React.createElement('span', { 'aria-hidden': true,
|
|
13429
|
+
style: { position: 'absolute', top: 0, right: 0, borderRadius: 8, padding: '1px 4px', fontSize: 10, background: 'var(--dsw-alias-brand-primary,#3370ff)', color: '#fff', lineHeight: '14px' },
|
|
13430
|
+
}, activeTaskCount > 99 ? '99+' : activeTaskCount) : null))
|
|
13431
|
+
|
|
13220
13432
|
const rail = React.createElement(
|
|
13221
13433
|
'div',
|
|
13222
13434
|
{ className: 'drawai-rail', 'aria-label': '元素工具栏' },
|
|
@@ -14720,8 +14932,10 @@ window.__ModuleLoader__.load({
|
|
|
14720
14932
|
// 4) 工作栏的下拉与「打开/新建/另存为」面板:挂在 root 上(不在画布里),
|
|
14721
14933
|
// 否则插进标签条之后它们的参照系会算成画布上方的负坐标,被 overflow:hidden 裁掉。
|
|
14722
14934
|
renderDocMenu(),
|
|
14723
|
-
active ? React.createElement(
|
|
14724
|
-
|
|
14935
|
+
active ? React.createElement(BackgroundTaskPanel, { jobs: backgroundDrawingJobs, exports: videoExportJobs,
|
|
14936
|
+
hidden: !showTaskList, onHide: () => patchPrefs({ showTaskList: false }),
|
|
14937
|
+
path: target, page: pageIndex, error: backgroundQueueError, onCommand: backgroundTaskCommand,
|
|
14938
|
+
onOpen: (job) => job.scope.path === target ? switchPage(job.scope.page) : onOpenTab(job.scope.path, job.scope.page) }) : null,
|
|
14725
14939
|
videoTraceSource && videoTraceJob && !videoTraceRun.queueId ? React.createElement(VideoTraceStatusCard, { job: videoTraceJob,
|
|
14726
14940
|
onRetry: () => videoTraceRun.result ? applyPendingVideoTrace() : startVideoTrace(videoTraceJob.options, videoTraceSource), onCancel: () => {
|
|
14727
14941
|
setVideoTraceJob((job) => Object.assign({}, job, { message: '正在取消并清理临时帧…' }))
|
|
@@ -14853,12 +15067,14 @@ window.__ModuleLoader__.load({
|
|
|
14853
15067
|
}, [tabPath])
|
|
14854
15068
|
|
|
14855
15069
|
/** 打开一个路径:已经在标签里就切过去,否则新开一个。规则在 openTabIn(可自测)。 */
|
|
14856
|
-
function openPath(path) {
|
|
15070
|
+
function openPath(path, page) {
|
|
14857
15071
|
const next = openTabIn(tabs, path)
|
|
14858
15072
|
if (next.active === null) return
|
|
14859
15073
|
bootstrappedRef.current = true
|
|
14860
15074
|
workspace.bootstrapped = true
|
|
14861
|
-
if (
|
|
15075
|
+
if (Number.isInteger(page) && page >= 0) {
|
|
15076
|
+
setTabs(next.tabs.map((tab) => tab.key === next.active ? { ...tab, pageRequest: { page } } : tab))
|
|
15077
|
+
} else if (next.tabs !== tabs) setTabs(next.tabs)
|
|
14862
15078
|
setActiveKey(next.active)
|
|
14863
15079
|
}
|
|
14864
15080
|
|
|
@@ -14936,6 +15152,7 @@ window.__ModuleLoader__.load({
|
|
|
14936
15152
|
React.createElement(CanvasView, {
|
|
14937
15153
|
sessionId: props.sessionId,
|
|
14938
15154
|
path: t.path,
|
|
15155
|
+
pageRequest: t.pageRequest,
|
|
14939
15156
|
active: isActive,
|
|
14940
15157
|
infoError: '',
|
|
14941
15158
|
onOpenTab: openPath,
|