dsh-drawai 0.4.0 → 0.4.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/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  4. **SVG 图形节点** —— 图标 / logo 可直接当节点内容,拼流程图与示意图。
19
19
  5. **位图矢量化(`traceImage`)** —— 照着工作区图片逐像素描成 SVG 节点,不是凭印象手写近似图。
20
20
  6. **原生文档与 drawio 兼容** —— `.drawai` 保存多页、节点扩展和叠放顺序;旧画布迁移保留完整 XML 与原媒体,支持另存为 `.drawio`。
21
- 7. **视频复刻** —— 选中已导入视频,点击悬浮工具条「绘制复刻」自主设置画质、帧率、完整视频或起止范围,自动抽帧并绘制成 SVG 视频;显示预计帧数、实际进度与帧预览,支持取消,原视频保留。支持 SVG 帧序列包,以及保留原帧率、白底和片段音频的 MP4 / WebM 离线导出;也可导出白底、循环播放的无声 GIF。渲染、编码和保存由宿主后台执行,切换标签或刷新网页后仍继续,完成文件自动保存到画布旁(编码需 FFmpeg)。详见[视频节点说明](docs/video-nodes.md)。
21
+ 7. **视频复刻** —— 选中已导入视频,点击悬浮工具条「绘制复刻」自主设置画质、帧率、完整视频或起止范围,自动抽帧并绘制成 SVG 视频;显示预计帧数、实际进度与帧预览,支持取消,原视频保留。支持 SVG 帧序列包,以及保留原帧率、白底和片段音频的 MP4 / WebM 离线导出;也可导出白底、循环播放的无声 GIF。渲染、编码和保存由宿主后台执行,切换标签或刷新网页后仍继续,完成文件自动保存到画布旁(编码需要 FFmpeg,首次使用会自动准备)。详见[视频节点说明](docs/video-nodes.md)。
22
22
  8. **文生图** —— 0.4.0 暂未开放,已有图片节点仍可显示和编辑。
23
23
 
24
24
  9. **节点绘制视频** —— 选中视频节点,先用 `shotcraft_search` 挑几张镜头卡连到它(连线顺序=镜头顺序,卡片时长=段长;**卡必须连**,断连的卡会被 `motion_project create` 直接拒绝),再说一句要做什么:`motion_project` 按卡片排好时间轴,并把**分镜表**(每镜做什么/屏幕上出现什么/配什么字)写进工程;先只写 `scene-0`、`motion_preview` 看一张**定调帧**把风格定住,再铺开写其余场景。AI 在对话里直接读写 `scene-*.tsx`,`motion_render` 在后台渲染,完成后 `motion_render apply` 把成片落成画布上能播的 clip pack。**只有对话里这一个 agent**:没有第二套后台 agent、不需要轮询、不用等任务面板。详见[动效视频说明](docs/motion-video.md)。
@@ -47,6 +47,10 @@ dsh plugin --profile web add github:fourzkw/dsh-drawai
47
47
 
48
48
  然后**重启 `dsh web`** —— 宿主侧只在启动时加载,这一步不能省。
49
49
 
50
+ 从 **0.4.1** 起,视频依赖使用独立运行环境:安装插件不会安装 Remotion / esbuild,也不需要授权 esbuild 的构建脚本。首次渲染、网页素材采集或风格参考帧截取时自动初始化,按锁文件下载依赖,禁用安装脚本并验证二进制可用后启用;后续复用本地缓存。第一次可能需要数分钟,下载失败后重试原操作即可。
51
+
52
+ 运行环境默认位于 `~/.dsh/drawai-video-runtime/`,按依赖版本、系统、架构与 Node 主版本隔离。可用 `DRAWAI_VIDEO_RUNTIME_DIR` 指定独立缓存目录,`DRAWAI_RUNTIME_REGISTRY` 指定 npm 镜像。它与字体和素材包缓存分开管理。初始化进度会出现在渲染任务状态中,网页采集等操作的初始化信息记录在宿主日志中。
53
+
50
54
  > **更新到最新版**:`dsh plugin --profile web update dsh-drawai@latest`
51
55
 
52
56
  重启之后,你会看到:
@@ -274,7 +278,8 @@ id / layer / parent、选区能上报给模型、写回是逐单元的、画布
274
278
  - **界面语言**:目前只有简体中文,还没有 i18n。
275
279
  - **大图性能**:几百个单元时的重渲染与路由开销还没测过。
276
280
  - **数据与网络**:画布文件和视频工程保存在工作区;音效搜索会访问 Freesound,网页素材收集会访问指定网站及品牌素材服务,Edge TTS 旁白和首次获取 Chromium 也可能联网。API key 存在浏览器 localStorage,宿主进程不保存;没有安装期脚本。
277
- - **视频素材**:动效用的 46 个字体与 102 个乐器采样 / 9 张纹理**不在 npm 包里**(约 200MB),第一次用到动效功能时会从 GitHub Release 下载一次(约 145MB),逐文件校验后缓存在 `~/.dsh/drawai-assets/v1/`,之后离线可用。下载失败或素材缺失时工具会返回可照做的中文指引;画布与图片功能不受影响。缓存位置与镜像地址可用 `DRAWAI_ASSETS_DIR` / `DRAWAI_ASSETS_URL` 覆盖;素材来源与许可集中记录在 [统一说明](LICENSE)。通过 `motion_assets library` 检索,再用 `motion_assets use` 导入当前工程,不依赖其他工程或固定磁盘路径;文件与上游哈希不一致时会提示并使用本地文件。导入图片和音频时需要可用的 FFmpeg。
281
+ - **视频素材**:动效用的 46 个字体与 102 个乐器采样 / 9 张纹理**不在 npm 包里**(约 200MB),第一次用到动效功能时会从 GitHub Release 下载一次(约 145MB),逐文件校验后缓存在 `~/.dsh/drawai-assets/v1/`,之后离线可用。下载失败或素材缺失时工具会返回可照做的中文指引;画布与图片功能不受影响。缓存位置与镜像地址可用 `DRAWAI_ASSETS_DIR` / `DRAWAI_ASSETS_URL` 覆盖;素材来源与许可集中记录在 [统一说明](LICENSE)。通过 `motion_assets library` 检索,再用 `motion_assets use` 导入当前工程,不依赖其他工程或固定磁盘路径;文件与上游哈希不一致时会提示并使用本地文件。
282
+ - **FFmpeg**:音频导入、素材探测、视频导出与混音需要 FFmpeg,第一次真正用到时才准备。查找顺序是 `DRAWAI_FFMPEG_PATH`(显式指定;路径无效或缺少所需能力会直接说明并列出缺失项,不会静默改用别的)→ 系统 `PATH`(逐个候选检查全部能力)→ 开发目录 `_local/bin` → 已验证缓存 → 自动下载。下载会校验大小与 SHA-256,解压后试运行 `-version`、实际跑一次短编码(用项目真实用到的 `-fps_mode` 等选项),并检查项目所需的编码器、解码器、滤镜和封装器,通过后才放进缓存 `~/.dsh/drawai-ffmpeg/<平台>-<架构>/`,之后离线可用;复用缓存时也会核对版本、二进制哈希和功能。要求 FFmpeg 5.1 或更新版本(更老的构建缺少 `-fps_mode`,会在真实任务里失败)。失败、取消或全部调用者取消都会清掉临时文件,下次可重试;下载与取消按调用者独立,不会因为一个任务取消而中断另一个。缓存目录可用 `DRAWAI_FFMPEG_DIR` 覆盖,清理只删本平台的子目录;下载可用 `DRAWAI_FFMPEG_MIRROR` 指向镜像,镜像只改下载地址,大小与哈希仍按注册表校验。没有自动构建的平台会提示把 FFmpeg 加入 PATH 或设置 `DRAWAI_FFMPEG_PATH`。来源为 gyan.dev(Windows x64)、BtbN FFmpeg-Builds(Windows ARM64、Linux x64/ARM64)和 osxexperts.net(macOS Intel/Apple Silicon),均为 GPL 构建,来源与许可见[统一说明](LICENSE)。
278
283
 
279
284
  ## 已知限制 / Limitations
280
285
 
@@ -5,13 +5,16 @@ import { randomUUID, createHash } from 'node:crypto'
5
5
  import { mkdirSync, readFileSync, writeFileSync, copyFileSync, statSync, realpathSync, existsSync, renameSync, unlinkSync } from 'node:fs'
6
6
  import { join, dirname, basename, relative, isAbsolute } from 'node:path'
7
7
  import { searchSfx, getSfx } from './motion/sfx.js'
8
- import { ffmpegBinary } from './video-export.js'
8
+ import { ensureFfmpeg } from './ffmpeg-manager.js'
9
9
  import { clipDirFor, ensureClipPack, mediaRootFor } from './media.js'
10
10
  import { audioNodeData, audioNodeSettings, validateAudioTimeline } from './motion-core.js'
11
11
  const exec = promisify(execFile)
12
12
  const MAX_BYTES = 32 * 1024 * 1024
13
13
  const sha = bytes => createHash('sha256').update(bytes).digest('hex')
14
- const run = (args,signal,timeout=120000) => exec(ffmpegBinary(),args,{windowsHide:true,timeout,maxBuffer:2*1024*1024,signal})
14
+ const run = async (args,signal,timeout=120000) => {
15
+ const binary = await ensureFfmpeg({ signal })
16
+ return exec(binary,args,{windowsHide:true,timeout,maxBuffer:2*1024*1024,signal})
17
+ }
15
18
 
16
19
  export function audioScopeDirectory(drawioAbs,directory,create=false) {
17
20
  const root=realpathSync(dirname(drawioAbs))
package/lib/client.js CHANGED
@@ -4770,7 +4770,7 @@ window.__ModuleLoader__.load({
4770
4770
  const ended = (job) => ['succeeded', 'done', 'cancelled', 'failed', 'error', 'retained', ...(job.motion ? ['interrupted'] : [])].includes(job.status)
4771
4771
  const live = jobs.filter((job) => !ended(job)).length, needs = jobs.filter(attention).length
4772
4772
  const visible = jobs.filter((job) => filter === 'all' || (filter === 'active' ? !ended(job) || attention(job) : filter === 'attention' ? attention(job) : ended(job) && !attention(job)))
4773
- const labels = { queued: '等待中', running: '绘制中', paused: '已暂停', cancelling: '正在取消', interrupted: '等待恢复', failed: '绘制失败', cancelled: '已取消', succeeded: '已完成', rendering: '渲染中', encoding: '编码中', done: '已保存文件', error: '导出失败' }
4773
+ const labels = { preparing: '准备 FFmpeg', queued: '等待中', running: '绘制中', paused: '已暂停', cancelling: '正在取消', interrupted: '等待恢复', failed: '绘制失败', cancelled: '已取消', succeeded: '已完成', rendering: '渲染中', encoding: '编码中', done: '已保存文件', error: '导出失败' }
4774
4774
  const phases = { preparing: '检查素材', probing: '探测视频', decoding: '解码抽帧', tracing: '绘制色块' }
4775
4775
  const invoke = async (job, action, value) => {
4776
4776
  setBusy(job.id || 'queue'); setError('')
@@ -4816,7 +4816,7 @@ window.__ModuleLoader__.load({
4816
4816
  ended(job) ? button('移除记录', () => invoke(job, 'remove')) : null,
4817
4817
  job.error ? button('复制错误', () => writeClipboardText(job.error).catch(() => {})) : null))
4818
4818
  }
4819
- const running = ['running', 'rendering', 'encoding'].includes(job.status)
4819
+ const running = job.export ? ['preparing', 'queued', 'rendering', 'encoding'].includes(job.status) : job.status === 'running'
4820
4820
  const done = job.export ? job.status === 'rendering' ? job.received : job.encoded : job.done
4821
4821
  const total = job.export ? job.count : job.total
4822
4822
  const percent = total ? Math.min(100, Math.round((done || 0) / total * 100)) : 0
@@ -4826,6 +4826,7 @@ window.__ModuleLoader__.load({
4826
4826
  return React.createElement('article', { key: job.id, className: 'drawai-video-task-detail', style: { borderTop: '1px solid #8883', paddingTop: 10, marginTop: 8, userSelect: 'text' } },
4827
4827
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
4828
4828
  React.createElement('strong', { style: { overflowWrap: 'anywhere' } }, job.label), React.createElement('small', { role: 'status' }, state)),
4829
+ job.message ? React.createElement('small', { role: 'status', style: { display: 'block', overflowWrap: 'anywhere' } }, job.message) : null,
4829
4830
  !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,
4830
4831
  running ? React.createElement('div', null,
4831
4832
  React.createElement('progress', { value: percent, max: 100, style: { width: '100%' }, 'aria-label': job.label + '进度' }),
@@ -10309,7 +10310,8 @@ window.__ModuleLoader__.load({
10309
10310
  button(collapsed ? '展开' : '收起', () => setCollapsed(!collapsed))),
10310
10311
  collapsed ? null : props.jobs.map((job) => React.createElement('div', { key: job.token, className: 'drawai-video-task-detail' },
10311
10312
  React.createElement('strong', null, job.clip + ' · ' + job.format.toUpperCase() + ' · ' +
10312
- ({ rendering: '正在渲染', encoding: '正在编码', done: '已保存', error: '导出失败' }[job.status] || job.status)),
10313
+ ({ preparing: '正在准备 FFmpeg', queued: '等待开始', rendering: '正在渲染', encoding: '正在编码', done: '已保存', error: '导出失败' }[job.status] || job.status)),
10314
+ job.message ? React.createElement('small', { role: 'status' }, job.message) : null,
10313
10315
  ['rendering', 'encoding'].includes(job.status) ? React.createElement('small', null,
10314
10316
  (job.status === 'rendering' ? job.received : job.encoded) + (job.count ? '/' + job.count : '') + ' 帧;切换页面后仍会继续') : null,
10315
10317
  job.path ? React.createElement('small', { style: { overflowWrap: 'anywhere', userSelect: 'text' } }, '已保存:' + job.path) : null,
@@ -16882,8 +16884,16 @@ window.__ModuleLoader__.load({
16882
16884
  async function uploadAudioFile(file,id,x,y) {
16883
16885
  if(!hasPath||!docRef.current){setSaveNote('请先保存画布');return}
16884
16886
  const control=new AbortController();if(!videoImportsRef.current)videoImportsRef.current=new Set();videoImportsRef.current.add(control)
16885
- let token
16887
+ let token, poll=null
16886
16888
  const api=(action,args)=>mediaAction(action,args,{throwOnError:true,signal:control.signal})
16889
+ const noteFfmpeg=(state)=>{
16890
+ const p=state&&state.progress, mb=(n)=>n>=1048576?(n/1048576).toFixed(n>=10485760?0:1)+' MB':Math.max(1,Math.round((Number(n)||0)/1024))+' KB'
16891
+ if(state&&state.status==='ready')return
16892
+ if(!p){setSaveNote('正在准备 FFmpeg…');return}
16893
+ if(p.phase==='download'){setSaveNote(p.total?'正在下载 FFmpeg '+Math.round((p.received||0)/p.total*100)+'%('+mb(p.received)+' / '+mb(p.total)+')':'正在下载 FFmpeg('+mb(p.received)+')');return}
16894
+ if(p.phase==='waiting'){setSaveNote('另一个任务正在准备 FFmpeg,等待中…');return}
16895
+ setSaveNote(({resolve:'正在查找 FFmpeg…',verify:'正在校验下载的 FFmpeg…',extract:'正在解压 FFmpeg…',check:'正在检查 FFmpeg 功能…',ready:'FFmpeg 已就绪'}[p.phase])||p.message||'正在准备 FFmpeg…')
16896
+ }
16887
16897
  try {
16888
16898
  if(id){motionCore.assertMotionEditable(docRef.current,id);if(!motionCore.isAudioNode(nodeById(id)))throw new Error('请选择音频节点')}
16889
16899
  else if(activeLayerLocked())throw new Error('当前图层已锁定')
@@ -16893,11 +16903,13 @@ window.__ModuleLoader__.load({
16893
16903
  const base64=await readVideoChunk(file.slice(offset,offset+1024*1024),control.signal)
16894
16904
  await api('audio-upload-chunk',{token,offset,base64});setSaveNote('音频上传 '+Math.round(Math.min(offset+1024*1024,file.size)/file.size*100)+'%')
16895
16905
  }
16906
+ setSaveNote('正在准备 FFmpeg(首次使用会自动下载)…')
16907
+ poll=setInterval(()=>{api('media-ffmpeg-status',{}).then(noteFfmpeg).catch(()=>{})},600)
16896
16908
  const result=await api('audio-upload-finish',{token})
16897
16909
  if(control.signal.aborted)throw new Error('音频导入已取消')
16898
16910
  applyAudioResult(id,result,x,y)
16899
16911
  }catch(e){setSaveNote(e.message||String(e))}
16900
- finally{if(token)mediaAction('audio-upload-release',{token}).catch(()=>{});videoImportsRef.current.delete(control)}
16912
+ finally{clearInterval(poll);if(token)mediaAction('audio-upload-release',{token}).catch(()=>{});videoImportsRef.current.delete(control)}
16901
16913
  }
16902
16914
 
16903
16915
  function insertMediaFiles(files, userX, userY) {
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process'
3
3
  import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, renameSync, mkdtempSync, rmSync, realpathSync } from 'node:fs'
4
4
  import { join, relative, isAbsolute } from 'node:path'
5
5
  import { createHash } from 'node:crypto'
6
- import { ffmpegBinary } from './video-export.js'
6
+ import { ensureFfmpeg } from './ffmpeg-manager.js'
7
7
  import { ensureClipPack, writeManifest, clipDirFor, mediaRootFor, frameFileName, STILLS_DIR, stillFileName } from './media.js'
8
8
 
9
9
  function checkMediaDirectory(job, path) {
@@ -11,9 +11,10 @@ function checkMediaDirectory(job, path) {
11
11
  if (isAbsolute(rel) || rel.startsWith('..')) throw new Error('媒体目录超出工作区')
12
12
  }
13
13
 
14
- export async function runMediaCommand(args, signal, onProgress, binary = ffmpegBinary()) {
14
+ export async function runMediaCommand(args, signal, onProgress, binary) {
15
+ const executable = binary || await ensureFfmpeg({ signal })
15
16
  return new Promise((resolve, reject) => {
16
- const child = spawn(binary, ['-hide_banner', '-nostdin', '-nostats', ...args], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
17
+ const child = spawn(executable, ['-hide_banner', '-nostdin', '-nostats', ...args], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
17
18
  let stdout = '', stderr = ''
18
19
  const abort = () => child.kill()
19
20
  signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort()
@@ -74,7 +75,8 @@ export function planDrawingVideo(options, info, extract = false) {
74
75
  async function* rawVideoFrames(job, signal, binary, perf) {
75
76
  const decodeDone = perf?.start('decodePipelineWall')
76
77
  const { plan } = job, offset = job.done || 0
77
- const child = spawn(binary || ffmpegBinary(), ['-hide_banner', '-nostdin', '-loglevel', 'error',
78
+ const executable = binary || await ensureFfmpeg({ signal })
79
+ const child = spawn(executable, ['-hide_banner', '-nostdin', '-loglevel', 'error',
78
80
  '-ss', String(plan.from), ...inputArgs(job.input), '-map', '0:v:0', '-an', '-sn',
79
81
  '-filter_threads', '1', '-vf', 'tpad=stop_mode=clone:stop_duration=' + (1 / plan.fps) + ',fps=fps=' + plan.fps + ':start_time=0:round=near,scale=' + plan.w + ':' + plan.h + ':flags=bicubic,setsar=1,format=rgba,trim=start_frame=' + offset + ',setpts=PTS-STARTPTS',
80
82
  '-frames:v', String(plan.count - offset), '-fps_mode', 'passthrough', '-threads', '1', '-pix_fmt', 'rgba', '-f', 'rawvideo', 'pipe:1'],