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/LICENSE +430 -418
- package/README.md +7 -2
- package/lib/audio-media.js +5 -2
- package/lib/client.js +17 -5
- package/lib/drawing-runner.js +6 -4
- package/lib/ffmpeg-manager.js +1056 -0
- package/lib/index.js +6 -2
- package/lib/motion/apply.js +4 -3
- package/lib/motion/assets.js +4 -3
- package/lib/motion/beats.js +3 -2
- package/lib/motion/collect.js +3 -1
- package/lib/motion/delivery.js +29 -1
- package/lib/motion/ffmpeg.js +5 -4
- package/lib/motion/jobs.js +5 -1
- package/lib/motion/preview.js +5 -3
- package/lib/motion/render.js +3 -1
- package/lib/motion/runtime/package-lock.json +3058 -0
- package/lib/motion/runtime/package.json +16 -0
- package/lib/motion/style-frames.js +12 -9
- package/lib/motion/video-runtime.js +192 -0
- package/lib/motion-capture.js +2 -1
- package/lib/motion-film-review.js +2 -2
- package/lib/motion-project.js +12 -5
- package/lib/motion-render-worker.js +13 -5
- package/lib/motion-vision.js +3 -2
- package/lib/shotcard-reference.js +2 -2
- package/lib/video-export.js +32 -20
- package/package.json +17 -12
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
|
|
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`
|
|
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
|
|
package/lib/audio-media.js
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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 = ['
|
|
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) {
|
package/lib/drawing-runner.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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
|
|
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'],
|