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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "drawai-video-runtime",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@react-three/fiber": "9.5.0",
7
+ "@remotion/bundler": "4.0.484",
8
+ "@remotion/motion-blur": "4.0.484",
9
+ "@remotion/renderer": "4.0.484",
10
+ "@remotion/three": "4.0.484",
11
+ "react": "19.2.7",
12
+ "react-dom": "19.2.7",
13
+ "remotion": "4.0.484",
14
+ "three": "0.183.2"
15
+ }
16
+ }
@@ -6,7 +6,7 @@
6
6
  * 打开 demos/<slug>/index.html,按 cues.json 的时间点调 renderAt(t) 截帧,交给模型当
7
7
  * 对照物。缺字体/纸纹时用占位资源兜底,并记在 notes 里,不假装素材齐全。
8
8
  */
9
- import {openBrowser} from '@remotion/renderer'
9
+ import { loadVideoModule, ensureVideoRuntime } from './video-runtime.js'
10
10
  import {existsSync,mkdirSync,readFileSync,statSync,writeFileSync} from 'node:fs'
11
11
  import {createServer} from 'node:http'
12
12
  import {extname,join,normalize,resolve,sep} from 'node:path'
@@ -25,7 +25,7 @@ const DEMO_FONT_ALIASES = {
25
25
  // 上游 demo 的 importmap 指向 /node_modules/three/...、/node_modules/opentype.js/...,
26
26
  // 但这些包只在插件根目录的 node_modules 里。旧版只服务 upstream 目录,于是模块 404、
27
27
  // window.__ready 永不置真,最后报了句"没有在 120 秒内就绪"——错误信息指向了完全错误的地方。
28
- const MODULES = fileURLToPath(new URL('../../node_modules', import.meta.url))
28
+
29
29
 
30
30
  const MIME = {
31
31
  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
@@ -101,16 +101,16 @@ function pickTimes(slug, count, explicit, duration) {
101
101
 
102
102
  // demo 的 importmap 指向 /node_modules/*,只有真装在本机的包才服务得出来。
103
103
  // 缺包时页面会静默卡在模块加载阶段,等到超时也只会说"没就绪"—— 这里提前查,几十毫秒就报清缺什么。
104
- function missingModules(slug) {
104
+ function missingModules(slug, modules) {
105
105
  const html = join(UPSTREAM, 'demos', slug, 'index.html')
106
106
  let text = ''
107
107
  try { text = readFileSync(html, 'utf8') } catch { return [] }
108
108
  const refs = [...text.matchAll(/["'](\/node_modules\/[^"']+)["']/g)].map((match) => match[1])
109
- return [...new Set(refs)].filter((ref) => !existsSync(join(MODULES, ref.slice('/node_modules/'.length))))
109
+ return [...new Set(refs)].filter((ref) => !existsSync(join(modules, ref.slice('/node_modules/'.length))))
110
110
  }
111
111
 
112
- function startStaticServer(misses) {
113
- const roots = {vendor: resolve(MODULES), upstream: resolve(UPSTREAM)}
112
+ function startStaticServer(misses, modules) {
113
+ const roots = {vendor: resolve(modules), upstream: resolve(UPSTREAM)}
114
114
  return new Promise((done) => {
115
115
  const server = createServer((req, res) => {
116
116
  try {
@@ -181,16 +181,19 @@ export async function captureStyleFrames({slug, times: explicitTimes, count = 8,
181
181
  if (typeof slug !== 'string' || !/^[a-z0-9][a-z0-9-]*$/i.test(slug)) throw new Error('slug 无效:' + slug)
182
182
  const demo = join(UPSTREAM, 'demos', slug)
183
183
  if (!existsSync(join(demo, 'index.html'))) throw new Error('这个风格没有可截帧的上游 demo:' + slug + '(只有说明、没有 index.html)')
184
- const missing = missingModules(slug)
184
+ const runtime = await ensureVideoRuntime({signal})
185
+ const modules = join(runtime, 'node_modules')
186
+ const { openBrowser } = await loadVideoModule('@remotion/renderer', {signal})
187
+ const missing = missingModules(slug, modules)
185
188
  if (missing.length) {
186
189
  throw new Error('这个 demo 的依赖没装在本机(' + missing.join(', ') + '),页面加载不到模块,截不了帧。'
187
190
  + '三条路:① 改用 action:"source" 读 demos/' + slug + '/ 下的源码与 cues.json 反推动效(多数情况够用);'
188
- + '② 在插件目录跑 `npm i --no-save opentype.js simplex-noise topojson-client d3` 后重试(这几个包只为截参考帧,不进 package.json);'
191
+ + '② 检查独立视频运行环境中的额外依赖(' + modules + ');'
189
192
  + '③ 换一个不依赖它们的 demo。')
190
193
  }
191
194
 
192
195
  const misses = []
193
- const server = await startStaticServer(misses)
196
+ const server = await startStaticServer(misses, modules)
194
197
  const port = server.address().port
195
198
  const executable = findChrome(browserExecutable)
196
199
  signal?.throwIfAborted()
@@ -0,0 +1,192 @@
1
+ // Isolated video dependencies: no Remotion/esbuild is loaded or installed at plugin startup.
2
+ import { spawn, spawnSync } from 'node:child_process'
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import { createRequire } from 'node:module'
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { dirname, isAbsolute, join, relative, resolve, parse } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const localRequire = createRequire(import.meta.url)
11
+ const MANIFEST = fileURLToPath(new URL('./runtime/package.json', import.meta.url))
12
+ const LOCK = fileURLToPath(new URL('./runtime/package-lock.json', import.meta.url))
13
+ const fingerprint = createHash('sha256').update(readFileSync(LOCK)).digest('hex')
14
+ const spec = JSON.parse(readFileSync(MANIFEST, 'utf8'))
15
+ const key = `v1-${process.platform}-${process.arch}-node${process.versions.node.split('.')[0]}-${fingerprint.slice(0, 12)}`
16
+ const pending = new Map()
17
+
18
+ function waitForInitialization(work, signal) {
19
+ if (!signal) return work
20
+ signal.throwIfAborted()
21
+ return new Promise((done, fail) => {
22
+ const abort = () => fail(signal.reason || new Error('已取消视频环境初始化'))
23
+ signal.addEventListener('abort', abort, { once: true })
24
+ work.then(done, fail).finally(() => signal.removeEventListener('abort', abort))
25
+ })
26
+ }
27
+
28
+ export function videoRuntimeDirectory() {
29
+ const directory = resolve(process.env.DRAWAI_VIDEO_RUNTIME_DIR || join(homedir(), '.dsh', 'drawai-video-runtime', key))
30
+ if ([parse(directory).root, resolve(homedir()), resolve(process.cwd())].includes(directory))
31
+ throw new Error('视频运行环境必须使用独立子目录')
32
+ return directory
33
+ }
34
+
35
+ export function videoRuntimeRequire(directory = videoRuntimeDirectory()) {
36
+ return createRequire(join(directory, 'package.json'))
37
+ }
38
+
39
+ function validate(directory, native = false) {
40
+ const require = videoRuntimeRequire(directory)
41
+ for (const [name, version] of Object.entries(spec.dependencies)) {
42
+ let file
43
+ try { file = require.resolve(name + '/package.json') } catch (error) {
44
+ if (error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error
45
+ let parent = dirname(require.resolve(name))
46
+ while (dirname(parent) !== parent) {
47
+ const candidate = join(parent, 'package.json')
48
+ if (existsSync(candidate) && JSON.parse(readFileSync(candidate, 'utf8')).name === name) { file = candidate; break }
49
+ parent = dirname(parent)
50
+ }
51
+ if (!file) throw new Error('缺少视频依赖清单:' + name)
52
+ }
53
+ const rel = relative(realpathSync(join(directory, 'node_modules')), realpathSync(file))
54
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')))
55
+ throw new Error('视频依赖未安装在独立目录:' + name)
56
+ if (JSON.parse(readFileSync(file, 'utf8')).version !== version) throw new Error('视频依赖版本不匹配:' + name)
57
+ }
58
+ if (!native) return
59
+ // Validate in a child so Windows DLL handles and cached module paths don't
60
+ // pin the staging directory while it is atomically renamed into the cache.
61
+ const checked = spawnSync(process.execPath, ['-e',
62
+ "const r=require('node:module').createRequire(process.argv[1]);const b=r('@remotion/bundler');r('@remotion/renderer');if(!b.BundlerInternals.esbuild.transformSync('const n: number = 1',{loader:'ts'}).code)process.exit(1)",
63
+ join(directory, 'package.json')], { windowsHide: true, encoding: 'utf8', timeout: 90000,
64
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } })
65
+ if (checked.status !== 0) throw new Error('视频二进制验证失败:' + (checked.error?.message || checked.stderr))
66
+ }
67
+
68
+ export function videoRuntimeState() {
69
+ const directory = videoRuntimeDirectory()
70
+ try {
71
+ const ready = JSON.parse(readFileSync(join(directory, '.ready.json'), 'utf8'))
72
+ if (ready.fingerprint !== fingerprint || ready.key !== key) return { ready: false, directory }
73
+ validate(directory)
74
+ return { ready: true, directory }
75
+ } catch { return { ready: false, directory } }
76
+ }
77
+
78
+ function install(directory, { signal, onProgress }) {
79
+ const cli = join(dirname(localRequire.resolve('npm/package.json')), 'bin', 'npm-cli.js')
80
+ const registry = process.env.DRAWAI_RUNTIME_REGISTRY || 'https://registry.npmjs.org'
81
+ const url = new URL(registry)
82
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('视频依赖 registry 地址无效')
83
+ return new Promise((done, fail) => {
84
+ const child = spawn(process.execPath, [cli, 'ci', '--ignore-scripts', '--include=optional', '--no-audit', '--no-fund',
85
+ '--registry=' + url.href, '--fetch-retries=1', '--fetch-timeout=30000', '--loglevel=error'], {
86
+ cwd: directory, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'],
87
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NODE_ENV: 'production', npm_config_ignore_scripts: 'true' },
88
+ })
89
+ let stderr = '', settled = false
90
+ const abort = () => child.kill()
91
+ const timer = setTimeout(() => child.kill(), 10 * 60000)
92
+ const progress = setInterval(() => onProgress?.({ phase: 'runtime-installing', message: '正在下载和校验视频依赖,首次初始化可能需要数分钟' }), 5000)
93
+ const finish = error => {
94
+ if (settled) return
95
+ settled = true; clearTimeout(timer); clearInterval(progress); signal?.removeEventListener('abort', abort)
96
+ if (signal?.aborted) fail(signal.reason || new Error('已取消视频环境初始化'))
97
+ else if (error) fail(error)
98
+ else done()
99
+ }
100
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-3000) })
101
+ child.on('error', finish)
102
+ child.on('close', code => finish(code === 0 ? null : new Error('视频依赖安装失败(' + code + '):' + stderr)))
103
+ signal?.addEventListener('abort', abort, { once: true })
104
+ if (signal?.aborted) abort()
105
+ })
106
+ }
107
+
108
+ async function initialize({ signal, onProgress }) {
109
+ const directory = videoRuntimeDirectory(), lock = directory + '.lock'
110
+ if (existsSync(directory)) {
111
+ const previous = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'))
112
+ if (previous.name !== spec.name) throw new Error('缓存位置包含其他文件,请指定独立目录')
113
+ }
114
+ mkdirSync(dirname(directory), { recursive: true })
115
+ // Coordinate independent render workers. A dead owner's lock is recoverable.
116
+ let ownsLock = false
117
+ const deadline = Date.now() + 11 * 60000
118
+ while (!ownsLock) {
119
+ signal?.throwIfAborted()
120
+ try {
121
+ mkdirSync(lock)
122
+ ownsLock = true
123
+ writeFileSync(join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, at: Date.now() }))
124
+ } catch (error) {
125
+ if (ownsLock || error.code !== 'EEXIST') throw error
126
+ try {
127
+ const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'))
128
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error('invalid lock owner')
129
+ try { process.kill(owner.pid, 0) } catch (e) {
130
+ if (e.code === 'ESRCH') { rmSync(lock, { recursive: true, force: true }); continue }
131
+ }
132
+ } catch {
133
+ // Recover if the installer crashed between mkdir and writing its owner.
134
+ if (existsSync(lock) && Date.now() - statSync(lock).mtimeMs > 30000) {
135
+ rmSync(lock, { recursive: true, force: true }); continue
136
+ }
137
+ }
138
+ if (Date.now() > deadline) throw new Error('等待其他视频环境初始化超时,请重试')
139
+ onProgress?.({ phase: 'runtime-installing', message: '正在等待共享视频运行环境就绪' })
140
+ await new Promise(done => setTimeout(done, 500))
141
+ }
142
+ }
143
+ const staging = directory + '.tmp-' + randomUUID()
144
+ try {
145
+ if (videoRuntimeState().ready) return directory
146
+ onProgress?.({ phase: 'runtime-installing', message: '首次使用:正在初始化独立视频运行环境' })
147
+ signal?.throwIfAborted()
148
+ mkdirSync(staging)
149
+ writeFileSync(join(staging, 'package.json'), readFileSync(MANIFEST))
150
+ writeFileSync(join(staging, 'package-lock.json'), readFileSync(LOCK))
151
+ await install(staging, { signal, onProgress })
152
+ signal?.throwIfAborted()
153
+ onProgress?.({ phase: 'runtime-checking', message: '正在验证视频运行环境' })
154
+ validate(staging, true)
155
+ writeFileSync(join(staging, '.ready.json'), JSON.stringify({ fingerprint, key }))
156
+ // Only replace this cache after installation and native-binary validation succeed.
157
+ if (existsSync(directory)) {
158
+ const previous = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'))
159
+ if (previous.name !== spec.name) throw new Error('缓存位置包含其他文件,请指定独立目录')
160
+ rmSync(directory, { recursive: true, force: true })
161
+ }
162
+ renameSync(staging, directory)
163
+ onProgress?.({ phase: 'runtime-ready', message: '视频运行环境已就绪,后续离线复用' })
164
+ return directory
165
+ } finally {
166
+ rmSync(staging, { recursive: true, force: true })
167
+ rmSync(lock, { recursive: true, force: true })
168
+ }
169
+ }
170
+
171
+ export async function ensureVideoRuntime(options = {}) {
172
+ options.signal?.throwIfAborted()
173
+ const state = videoRuntimeState()
174
+ if (state.ready) return state.directory
175
+ // A caller's cancellation must not cancel another caller's shared initialization.
176
+ if (pending.has(state.directory)) {
177
+ try { await waitForInitialization(pending.get(state.directory), options.signal) } catch { /* Another caller may have cancelled. */ }
178
+ options.signal?.throwIfAborted()
179
+ return ensureVideoRuntime(options)
180
+ }
181
+ const work = initialize({ ...options, onProgress: options.onProgress || (event => console.error('[drawai] ' + event.message)) }).catch(error => {
182
+ throw new Error('视频运行环境未就绪:' + error.message + '\n联网后重试当前操作即可重新初始化。可用 DRAWAI_RUNTIME_REGISTRY 设置镜像,DRAWAI_VIDEO_RUNTIME_DIR 设置缓存目录。', { cause: error })
183
+ }).finally(() => pending.delete(state.directory))
184
+ pending.set(state.directory, work)
185
+ return work
186
+ }
187
+
188
+ export async function loadVideoModule(name, options = {}) {
189
+ if (!Object.hasOwn(spec.dependencies, name)) throw new Error('不支持的视频模块:' + name)
190
+ const directory = await ensureVideoRuntime(options)
191
+ return videoRuntimeRequire(directory)(name)
192
+ }
@@ -1,5 +1,5 @@
1
1
  /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
- import {openBrowser} from '@remotion/renderer'
2
+ import { loadVideoModule } from './motion/video-runtime.js'
3
3
  import {existsSync,mkdirSync,writeFileSync} from 'node:fs'
4
4
  import {join} from 'node:path'
5
5
  import {randomUUID,createHash} from 'node:crypto'
@@ -12,6 +12,7 @@ export async function captureMotionPage(args,{directory,signal,browserExecutable
12
12
  if(!Array.isArray(args.elements??[])||(args.elements||[]).length>20||!Array.isArray(args.steps??[])||(args.steps||[]).length>16)throw new Error('单页最多20个切片、16个操作')
13
13
  const executable=[browserExecutable,process.env.DRAWAI_CHROME_PATH,'C:/Program Files/Google/Chrome/Application/chrome.exe','C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find(p=>p&&existsSync(p))
14
14
  signal?.throwIfAborted()
15
+ const { openBrowser } = await loadVideoModule('@remotion/renderer', {signal})
15
16
  const browser=await openBrowser('chrome',{browserExecutable:executable,logLevel:'error'})
16
17
  const abort=()=>{void browser.close({silent:true}).catch(()=>{})};signal?.addEventListener('abort',abort,{once:true})
17
18
  try{
@@ -3,11 +3,11 @@ import {mkdirSync,readFileSync,readdirSync,writeFileSync} from 'node:fs'
3
3
  import {join} from 'node:path'
4
4
  import {execFile} from 'node:child_process'
5
5
  import {promisify} from 'node:util'
6
- import {ffmpegBinary} from './video-export.js'
6
+ import {ensureFfmpeg} from './ffmpeg-manager.js'
7
7
  import {agentHash,agentAtomicJson} from './motion-agent-files.js'
8
8
  import {streamAgentTurn,agentMessage} from './motion-agent-protocol.js'
9
9
  const exec=promisify(execFile)
10
- const run=(args,signal)=>exec(ffmpegBinary(),['-hide_banner','-nostdin',...args],{windowsHide:true,signal,timeout:180000,maxBuffer:8*1024*1024})
10
+ const run=async(args,signal)=>{const binary=await ensureFfmpeg({signal});return exec(binary,['-hide_banner','-nostdin',...args],{windowsHide:true,signal,timeout:180000,maxBuffer:8*1024*1024})}
11
11
 
12
12
  // Decode the actual delivered file, including its final mixed audio. Visual
13
13
  // semantics use ordered contact sheets; listening is a separate human verdict.
@@ -2,14 +2,13 @@
2
2
  import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from 'node:fs'
3
3
  import { join, dirname, resolve, relative, isAbsolute } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
- import { createRequire } from 'node:module'
5
+ import { videoRuntimeRequire } from './motion/video-runtime.js'
6
6
  import { validateMotionSources, sceneComponentPath } from './motion-source.js'
7
7
  import { shotcraftModuleMap } from './motion-library.js'
8
8
  import { enforceMotionCards } from './motion-library.js'
9
9
  import { enforceMgStyle } from './motion-library.js'
10
10
  import { validateMotionPlan } from './motion-core.js'
11
11
  import { parseProjectJson } from './motion/json.js'
12
- const require = createRequire(import.meta.url)
13
12
 
14
13
  // Agent projects are edited on disk. Read their sources before validating or
15
14
  // building; never restore a stale inline source over an agent's changes.
@@ -74,7 +73,15 @@ export function prepareMotionProject(composition, directory, options = {}) {
74
73
  })
75
74
  } }
76
75
  alias['@drawai/motion$'] = fileURLToPath(new URL('./motion/reference-media.jsx', import.meta.url))
77
- const modules = [...new Set(['react', 'remotion', '@remotion/motion-blur'].map((name) => dirname(require.resolve(name + '/package.json')).replace(/[\\/]@remotion[\\/][^\\/]+$/, '').replace(/[\\/](?:react|remotion)$/, '')))]
78
- return { entryPoint, webpackOverride: (config) => ({ ...config, plugins: [...(config.plugins || []), libraryImports], resolve: { ...config.resolve,
79
- alias: { ...config.resolve?.alias, ...alias }, modules: [...modules, ...(config.resolve?.modules || ['node_modules'])] } }) }
76
+ // Resolve the runtime lazily: creating a project only writes its files, and
77
+ // the video runtime may not be initialized yet (it is installed on first use).
78
+ const runtimeModules = () => {
79
+ const require = videoRuntimeRequire(options.runtimeDirectory)
80
+ return [...new Set(['react', 'remotion', '@remotion/motion-blur'].map((name) => dirname(require.resolve(name + '/package.json')).replace(/[\\/]@remotion[\\/][^\\/]+$/, '').replace(/[\\/](?:react|remotion)$/, '')))]
81
+ }
82
+ return { entryPoint, webpackOverride: (config) => {
83
+ const modules = runtimeModules()
84
+ return { ...config, plugins: [...(config.plugins || []), libraryImports], resolve: { ...config.resolve,
85
+ alias: { ...config.resolve?.alias, ...alias }, modules: [...modules, ...(config.resolve?.modules || ['node_modules'])] } }
86
+ } }
80
87
  }
@@ -1,6 +1,5 @@
1
1
  /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
- import { bundle } from '@remotion/bundler'
3
- import { openBrowser, selectComposition, renderMedia, renderStill, makeCancelSignal } from '@remotion/renderer'
2
+ import { ensureVideoRuntime, videoRuntimeRequire } from './motion/video-runtime.js'
4
3
  import { existsSync, rmSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
4
  import { dirname, join, relative, isAbsolute, resolve } from 'node:path'
6
5
  import { createHash } from 'node:crypto'
@@ -8,16 +7,25 @@ import { prepareMotionProject } from './motion-project.js'
8
7
  import { runFfmpeg } from './motion/ffmpeg.js'
9
8
 
10
9
  let cancelled = false, running = false
11
- const { cancelSignal, cancel } = makeCancelSignal()
10
+ const runtimeAbort = new AbortController()
11
+ let cancel = () => {}
12
12
  const send = (message) => { if (process.connected) process.send(message) }
13
13
  process.on('message', async (request) => {
14
- if (request.type === 'cancel') { cancelled = true; cancel(); return }
14
+ if (request.type === 'cancel') { cancelled = true; runtimeAbort.abort(new Error('已取消动效生成')); cancel(); return }
15
15
  if (running) return
16
16
  running = true
17
17
  let browser
18
18
  const outDir = request.bundleDir || join(dirname(request.output), 'bundle')
19
19
  const cacheFile = join(outDir, '.drawai-bundle-key')
20
20
  try {
21
+ const runtimeDirectory = await ensureVideoRuntime({signal:runtimeAbort.signal, onProgress:message => send({type:'progress', ...message, progress:0})})
22
+ if (cancelled) throw new Error('已取消动效生成')
23
+ const require = videoRuntimeRequire(runtimeDirectory)
24
+ const { bundle } = require('@remotion/bundler')
25
+ const { openBrowser, selectComposition, renderMedia, renderStill, makeCancelSignal } = require('@remotion/renderer')
26
+ const cancellation = makeCancelSignal()
27
+ const cancelSignal = cancellation.cancelSignal
28
+ cancel = cancellation.cancel
21
29
  // 同一个工程连续预览/渲染时复用上次的 bundle:预览从 30 秒降到 3 秒,
22
30
  // 改一个场景文件只会让 key 变化一次。request.bundleKey 由调用方按源码计算。
23
31
  const reuse = request.reuseBundle === true && request.bundleKey && existsSync(join(outDir, 'index.html'))
@@ -27,7 +35,7 @@ process.on('message', async (request) => {
27
35
  serveUrl = outDir
28
36
  send({ type: 'progress', phase: 'bundling', progress: .1 })
29
37
  } else {
30
- const project = prepareMotionProject(request.composition, request.projectDirectory || join(dirname(request.output), 'project'), {preserveSources:request.preserveSources===true})
38
+ const project = prepareMotionProject(request.composition, request.projectDirectory || join(dirname(request.output), 'project'), {preserveSources:request.preserveSources===true, runtimeDirectory})
31
39
  serveUrl = await bundle({ ...project, outDir, ignoreRegisterRootWarning: true,
32
40
  publicDir: request.publicDir, onProgress: (progress) => send({ type: 'progress', phase: 'bundling', progress: progress / 100 * .1 }) })
33
41
  const index = join(outDir, 'index.html')
@@ -4,7 +4,7 @@ import { relative, isAbsolute, join } from 'node:path'
4
4
  import { createHash } from 'node:crypto'
5
5
  import { execFile } from 'node:child_process'
6
6
  import { promisify } from 'node:util'
7
- import { ffmpegBinary } from './video-export.js'
7
+ import { ensureFfmpeg } from './ffmpeg-manager.js'
8
8
  const exec=promisify(execFile)
9
9
 
10
10
  export async function loadFrozenReferenceImages(input,assets,publicDir,options) {
@@ -29,7 +29,8 @@ export async function loadFrozenReferenceImages(input,assets,publicDir,options)
29
29
  if(fraction===null){data=await readFile(path);mediaType=asset.src.endsWith('jpg')?'image/jpeg':asset.src.endsWith('webp')?'image/webp':'image/png'}
30
30
  else{
31
31
  // Video snapshots describe appearance; two stills are not motion analysis.
32
- const {stdout}=await exec(ffmpegBinary(),['-hide_banner','-loglevel','error','-ss',String(fraction),'-i',path,'-frames:v','1','-vf','scale=1280:-2','-f','image2pipe','-vcodec','png','pipe:1'],{encoding:'buffer',windowsHide:true,timeout:30000,maxBuffer:8*1024*1024,signal})
32
+ const binary=await ensureFfmpeg({signal})
33
+ const {stdout}=await exec(binary,['-hide_banner','-loglevel','error','-ss',String(fraction),'-i',path,'-frames:v','1','-vf','scale=1280:-2','-f','image2pipe','-vcodec','png','pipe:1'],{encoding:'buffer',windowsHide:true,timeout:30000,maxBuffer:8*1024*1024,signal})
33
34
  data=stdout;mediaType='image/png';name=asset.src+'-'+fraction+'.png'
34
35
  }
35
36
  const attachment=await attachments.saveImage({data,mediaType,name})
@@ -4,7 +4,7 @@ import { join } from 'node:path'
4
4
  import { createHash } from 'node:crypto'
5
5
  import { execFile } from 'node:child_process'
6
6
  import { promisify } from 'node:util'
7
- import { ffmpegBinary } from './video-export.js'
7
+ import { ensureFfmpeg } from './ffmpeg-manager.js'
8
8
  import { motionCardContract } from './motion-library.js'
9
9
  const exec = promisify(execFile)
10
10
 
@@ -30,7 +30,7 @@ export async function prepareReferenceFrames(composition, directory, options = {
30
30
  }
31
31
  writeFileSync(video + '.tmp', Buffer.concat(chunks)); renameSync(video + '.tmp', video)
32
32
  }
33
- const binary = options.ffmpegPath || ffmpegBinary()
33
+ const binary = options.ffmpegPath || await ensureFfmpeg({ signal: options.signal })
34
34
  let info = ''
35
35
  try { const probe = await exec(binary, ['-hide_banner', '-i', video], { windowsHide: true, timeout: 15000, signal: options.signal }); info = probe.stderr }
36
36
  catch (error) { info = error.stderr || ''; if (!info) throw error }
@@ -6,9 +6,9 @@ import { Worker } from 'node:worker_threads'
6
6
  import { copyFile } from 'node:fs/promises'
7
7
  import { existsSync, mkdirSync, mkdtempSync, rmSync, renameSync, statSync, openSync, readSync, closeSync, realpathSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
8
8
  import { join, relative, isAbsolute, extname, dirname, basename } from 'node:path'
9
- import { fileURLToPath } from 'node:url'
10
9
  import { clipDirFor, readManifest, findSourceFile, sanitizeClipId } from './media.js'
11
10
  import { createPerformanceLog } from './performance-log.js'
11
+ import { ensureFfmpeg, ffmpegPathSync } from './ffmpeg-manager.js'
12
12
 
13
13
  function runFfmpeg(binary, args, signal, progress) {
14
14
  return new Promise((resolve, reject) => {
@@ -36,17 +36,14 @@ function runFfmpeg(binary, args, signal, progress) {
36
36
  })
37
37
  }
38
38
 
39
+ /** 同步近似值,只给「还没异步准备过」的旧路径兜底;真正导出走 ensureFfmpeg。 */
39
40
  export function ffmpegBinary() {
40
- if (process.env.DRAWAI_FFMPEG_PATH) return process.env.DRAWAI_FFMPEG_PATH
41
- // Development checkout only; published packages use the configured binary or PATH.
42
- const local = fileURLToPath(new URL('../_local/bin/' + (process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), import.meta.url))
43
- return existsSync(local) ? local : 'ffmpeg'
41
+ return ffmpegPathSync()
44
42
  }
45
43
 
46
44
  export function createVideoExportManager(options = {}) {
47
45
  const jobs = new Map()
48
46
  let closed = false
49
- const binary = options.ffmpegPath || ffmpegBinary()
50
47
  const historyRoot = (path) => join(dirname(path), '.drawai-cache', 'video-export-history')
51
48
  const ended = (job) => ['done', 'error', 'interrupted'].includes(job.status)
52
49
  function migrationRecords(scope) {
@@ -60,7 +57,7 @@ export function createVideoExportManager(options = {}) {
60
57
  }
61
58
  function persist(job) {
62
59
  const root = historyRoot(job.drawioAbs); mkdirSync(root, { recursive: true })
63
- const record = Object.fromEntries(['sessionId', 'drawioAbs', 'clip', 'token', 'status', 'time', 'next', 'encoded', 'format', 'kind', 'w', 'h', 'fps', 'count', 'bytes', 'savedPath', 'error'].map((key) => [key, job[key]]))
60
+ const record = Object.fromEntries(['sessionId', 'drawioAbs', 'clip', 'token', 'status', 'time', 'next', 'encoded', 'format', 'kind', 'w', 'h', 'fps', 'count', 'bytes', 'savedPath', 'error', 'message'].map((key) => [key, job[key]]))
64
61
  const file = join(root, job.token + '.json')
65
62
  writeFileSync(file + '.tmp', JSON.stringify(record)); renameSync(file + '.tmp', file)
66
63
  }
@@ -87,7 +84,7 @@ export function createVideoExportManager(options = {}) {
87
84
  function view(job) {
88
85
  return { token: job.token, status: job.status, count: job.count, received: job.next, encoded: job.encoded,
89
86
  clip: job.clip, fps: job.fps, w: job.w, h: job.h, bytes: job.bytes || 0, error: job.error || '', format: job.format,
90
- background: true, path: job.savedPath || null,
87
+ message: job.message || '', background: true, path: job.savedPath || null,
91
88
  performanceEnabled: job.perf.enabled, performanceId: job.perf.id }
92
89
  }
93
90
  function owned(scope, token) {
@@ -99,6 +96,7 @@ export function createVideoExportManager(options = {}) {
99
96
  }
100
97
  async function remove(job) {
101
98
  jobs.delete(job.token)
99
+ job.removed = true
102
100
  job.control.abort()
103
101
  if (job.preflight) await job.preflight.catch(() => {})
104
102
  if (job.promise) await job.promise.catch(() => {})
@@ -138,6 +136,26 @@ export function createVideoExportManager(options = {}) {
138
136
  async function runJob(job) {
139
137
  let release
140
138
  try {
139
+ // Resolve FFmpeg inside the task: the first use may download it, and a
140
+ // later retry must not keep using a path cached when the host started.
141
+ job.status = 'preparing'
142
+ job.message = '正在准备 FFmpeg…'
143
+ persist(job)
144
+ job.binary = options.ffmpegPath || await ensureFfmpeg({
145
+ signal: job.control.signal,
146
+ onProgress: (event) => { if (event?.message) job.message = event.message },
147
+ })
148
+ if (job.control.signal.aborted) throw new Error('已取消视频导出')
149
+ job.status = 'queued'
150
+ job.message = ''
151
+ persist(job)
152
+ // Probe the actual output path and encoder before uploading any large frames.
153
+ job.preflight = runFfmpeg(job.binary, ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'color=white:s=2x2:r=1',
154
+ '-frames:v', '1', '-c:v', job.format === 'mp4' ? 'libx264' : job.format === 'gif' ? 'gif' : 'libvpx-vp9', '-threads', '1', job.output], job.control.signal)
155
+ const probeDone = job.perf.start('encoderPreflight')
156
+ try { await job.preflight }
157
+ catch (error) { throw new Error('导出预检查失败(目录:' + job.temp + '):' + error.message) }
158
+ finally { probeDone() }
141
159
  release = await options.slots?.acquire(job.control.signal)
142
160
  job.status = 'rendering'
143
161
  persist(job)
@@ -159,8 +177,10 @@ export function createVideoExportManager(options = {}) {
159
177
  persist(job)
160
178
  job.perf.finish('done')
161
179
  } catch (error) {
162
- job.status = closed ? 'interrupted' : 'error'; job.error = String(error.message || error); job.time = Date.now()
163
- persist(job)
180
+ if (!job.removed) {
181
+ job.status = closed ? 'interrupted' : 'error'; job.error = String(error.message || error); job.message = ''; job.time = Date.now()
182
+ persist(job)
183
+ }
164
184
  job.perf.finish(job.control.signal.aborted ? 'cancelled' : 'failed')
165
185
  } finally { release?.(); rmSync(job.temp, { recursive: true, force: true }) }
166
186
  }
@@ -199,11 +219,11 @@ export function createVideoExportManager(options = {}) {
199
219
  paletteArgs.push('-filter_complex', gifWhite + '[opaque]palettegen=max_colors=256:reserve_transparent=0:stats_mode=full[palette]', '-map', '[palette]',
200
220
  '-frames:v', '1', '-threads', '1', '-update', '1', join(job.temp, 'palette.png'))
201
221
  const paletteDone = job.perf.start('gifPaletteGenerate')
202
- try { await runFfmpeg(binary, paletteArgs, job.control.signal) }
222
+ try { await runFfmpeg(job.binary, paletteArgs, job.control.signal) }
203
223
  finally { paletteDone() }
204
224
  }
205
225
  const encodeDone = job.perf.start('ffmpegEncode')
206
- try { await runFfmpeg(binary, args, job.control.signal, (frame) => { job.encoded = frame }) }
226
+ try { await runFfmpeg(job.binary, args, job.control.signal, (frame) => { job.encoded = frame }) }
207
227
  finally { encodeDone() }
208
228
  if (job.kind === 'svg' && job.encoded !== job.count) throw new Error('输出帧数不完整:预期 ' + job.count + ',实际 ' + job.encoded)
209
229
  job.bytes = statSync(job.output).size
@@ -265,14 +285,6 @@ export function createVideoExportManager(options = {}) {
265
285
  next: 0, received: 0, encoded: 0, status: 'queued', time: Date.now(), control: new AbortController(), output: join(temp, 'output.' + format) }
266
286
  jobs.set(job.token, job)
267
287
  persist(job)
268
- // Probe the actual output path and encoder before uploading any large frames.
269
- job.preflight = runFfmpeg(binary, ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'color=white:s=2x2:r=1',
270
- '-frames:v', '1', '-c:v', format === 'mp4' ? 'libx264' : format === 'gif' ? 'gif' : 'libvpx-vp9', '-threads', '1', job.output], job.control.signal)
271
- const probeDone = perf.start('encoderPreflight')
272
- try { await job.preflight }
273
- catch (error) { throw new Error('导出预检查失败(目录:' + temp + '):' + error.message) }
274
- finally { probeDone() }
275
- if (closed || job.control.signal.aborted) throw new Error('视频导出服务已关闭')
276
288
  job.promise = runJob(job)
277
289
  return view(job)
278
290
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",
@@ -22,10 +22,12 @@
22
22
  "test:audio-ui": "node tools/check-audio-ui.mjs",
23
23
  "watch": "node tools/watch.mjs",
24
24
  "check": "node tools/check-package.mjs",
25
- "test": "node tools/check-media-package.mjs && node tools/check-motion-session.mjs && node tools/check-motion-host.mjs && node tools/check-motion-canvas-spec.mjs && node tools/check-motion-json-bom.mjs && node tools/check-motion-pilot-host.mjs && node tools/check-motion-boundaries.mjs && node tools/check-motion-cache-fetch.mjs && node tools/check-media-library-host.mjs && node tools/check-motion-ui.mjs && node tools/check-native-canvas.mjs && node tools/check-adaptive-palette.mjs && node tools/check-auto-save.mjs && node tools/check-drawing-queue.mjs && node tools/check-background-tasks.mjs && node tools/check-background-ui.mjs && node tools/check-trace-optimization.mjs && node tools/check-performance.mjs && node tools/check-mxfile.mjs && node tools/check-routing.mjs && node tools/check-route-preview.mjs && node tools/check-host.mjs && node tools/check-render.mjs && node tools/check-component.mjs && node tools/check-text-copy.mjs && node tools/check-image-export.mjs && node tools/check-canvas-sessions.mjs && node tools/check-trace.mjs && node tools/check-media.mjs && node tools/check-video-player.mjs && node tools/check-image-trace.mjs && node tools/check-video-trace.mjs && node tools/check-video-reuse.mjs && node tools/check-video-audio.mjs && node tools/check-video-export.mjs && node tools/check-video-import.mjs && node tools/check-node-labels.mjs && node tools/check-imagegen-disabled.mjs && node tools/check-imagegen.mjs",
25
+ "test": "node tools/check-video-runtime.mjs && node tools/check-ffmpeg-manager.mjs && node tools/check-media-package.mjs && node tools/check-motion-session.mjs && node tools/check-motion-host.mjs && node tools/check-motion-canvas-spec.mjs && node tools/check-motion-json-bom.mjs && node tools/check-motion-pilot-host.mjs && node tools/check-motion-boundaries.mjs && node tools/check-motion-cache-fetch.mjs && node tools/check-media-library-host.mjs && node tools/check-motion-ui.mjs && node tools/check-native-canvas.mjs && node tools/check-adaptive-palette.mjs && node tools/check-auto-save.mjs && node tools/check-drawing-queue.mjs && node tools/check-background-tasks.mjs && node tools/check-background-ui.mjs && node tools/check-trace-optimization.mjs && node tools/check-performance.mjs && node tools/check-mxfile.mjs && node tools/check-routing.mjs && node tools/check-route-preview.mjs && node tools/check-host.mjs && node tools/check-render.mjs && node tools/check-component.mjs && node tools/check-text-copy.mjs && node tools/check-image-export.mjs && node tools/check-canvas-sessions.mjs && node tools/check-trace.mjs && node tools/check-media.mjs && node tools/check-video-player.mjs && node tools/check-image-trace.mjs && node tools/check-video-trace.mjs && node tools/check-video-reuse.mjs && node tools/check-video-audio.mjs && node tools/check-video-export.mjs && node tools/check-video-import.mjs && node tools/check-node-labels.mjs && node tools/check-imagegen-disabled.mjs && node tools/check-imagegen.mjs",
26
26
  "bench:performance": "node tools/benchmark-performance.mjs",
27
27
  "test:shotcards-render": "node tools/check-shotcard-render.mjs",
28
- "test:shotcards-ui": "node tools/check-shotcards-ui.mjs"
28
+ "test:shotcards-ui": "node tools/check-shotcards-ui.mjs",
29
+ "test:video-runtime": "node tools/check-video-runtime.mjs",
30
+ "test:ffmpeg": "node tools/check-ffmpeg-manager.mjs"
29
31
  },
30
32
  "exports": {
31
33
  ".": "./lib/index.js",
@@ -54,18 +56,10 @@
54
56
  "dependencies": {
55
57
  "@babel/parser": "7.28.5",
56
58
  "@babel/traverse": "7.28.5",
57
- "@react-three/fiber": "9.5.0",
58
- "@remotion/bundler": "4.0.484",
59
- "@remotion/motion-blur": "4.0.484",
60
- "@remotion/renderer": "4.0.484",
61
- "@remotion/three": "4.0.484",
62
59
  "@resvg/resvg-js": "^2.6.2",
63
60
  "parse5": "7.3.0",
64
61
  "postcss": "8.5.32",
65
- "react": "19.2.7",
66
- "react-dom": "19.2.7",
67
- "remotion": "4.0.484",
68
- "three": "0.183.2"
62
+ "npm": "10.9.4"
69
63
  },
70
64
  "peerDependencies": {
71
65
  "@deepseek-ai/dsh-tools": ">=0.1.0-rc.1 <0.1.0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
@@ -82,5 +76,16 @@
82
76
  "@deepseek-ai/dsh-api-workspace-files"
83
77
  ]
84
78
  }
79
+ },
80
+ "devDependencies": {
81
+ "@react-three/fiber": "9.5.0",
82
+ "@remotion/bundler": "4.0.484",
83
+ "@remotion/motion-blur": "4.0.484",
84
+ "@remotion/renderer": "4.0.484",
85
+ "@remotion/three": "4.0.484",
86
+ "react": "19.2.7",
87
+ "react-dom": "19.2.7",
88
+ "remotion": "4.0.484",
89
+ "three": "0.183.2"
85
90
  }
86
91
  }