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/lib/index.js CHANGED
@@ -40,6 +40,7 @@ import { getRoutingRuntime } from './routing/node-runtime.js'
40
40
  import { motionInput, motionInputKey, assertMotionEditable, applyMotionResult, isMotionNode, motionNodeData, motionSettings, mergeMotionSettings, isAudioNode, audioNodeData, audioNodeSettings, motionReferenceEdges, motionReferenceRole, unlinkedShotCards } from './motion-core.js'
41
41
  import { createAudioService } from './audio-service.js'
42
42
  import { frozenAudioFile, importAudioFile } from './audio-media.js'
43
+ import { ffmpegState } from './ffmpeg-manager.js'
43
44
  import { shotCardCatalog, enforceMotionCards, motionCardContract, mgStyleCatalog, mgStyleKit, mgStyleSources, mgStyleFile, mgStyleFileSize } from './motion-library.js'
44
45
  import { searchShotCards, resolveShotStyle, shotCardData, isShotCardNode } from './shotcard-core.js'
45
46
  import { searchMgStyles, resolveMgStyle, mgStyleData, isMgStyleNode } from './mgstyle-core.js'
@@ -5515,6 +5516,9 @@ function sanitizeNewName(raw) {
5515
5516
  const drawioAbs = absoluteHint(mediaTarget, rawPath)
5516
5517
  const clip = sanitizeClipId(body.clip)
5517
5518
  try {
5519
+ if (body.action === 'media-ffmpeg-status') {
5520
+ sendJson(res, 200, { ok: true, ...ffmpegState() }); return
5521
+ }
5518
5522
  if (body.action.startsWith('media-video-export-')) {
5519
5523
  const scope = { sessionId, drawioAbs, clip }
5520
5524
  let result
@@ -6752,7 +6756,7 @@ function sanitizeNewName(raw) {
6752
6756
  const next = job.kind === 'pilot' && job.status === 'done'
6753
6757
  ? '样片文件不会自动显示在画布:在当前画布页创建或复用独立「样片」视频节点,记下其 ID,然后调用 motion_render {action:"apply",nodeId:"样片节点 ID",jobId:"' + job.id + '",source:"pilot"};核对结果并告知用户。'
6754
6758
  : undefined
6755
- return { summary: '渲染任务:' + job.status + (mix ? ' · ' + mix : ''), details: JSON.stringify({ ...job, ...(next ? { next } : {}) }) }
6759
+ return { summary: '渲染任务:' + job.status + (job.message ? ' · ' + job.message : '') + (mix ? ' · ' + mix : ''), details: JSON.stringify({ ...job, ...(next ? { next } : {}) }) }
6756
6760
  }
6757
6761
  if (args.action === 'cancel') {
6758
6762
  if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
@@ -6845,7 +6849,7 @@ function sanitizeNewName(raw) {
6845
6849
  const job = motionJobs.create({ kind, dir, nodeId: node.id })
6846
6850
  const handle = renderProject(dir, { operation, frames: args.frames, frameRange: ranged ? args.range : undefined,
6847
6851
  output: outputPath(dir, outputName),
6848
- onProgress: (message) => motionJobs.progress(job, message.phase, message.progress) })
6852
+ onProgress: (message) => motionJobs.progress(job, message.phase, message.progress, message.message) })
6849
6853
  motionJobs.attach(job, handle)
6850
6854
  const renderLine = handle.render ? describeRenderSettings(handle.render) : ''
6851
6855
  return { summary: '已开始渲染' + (kind === 'pilot' ? '样片' : '') + '(' + job.id + ')' + (renderLine ? ' · ' + renderLine : '')
@@ -11,7 +11,7 @@ import { execFile } from 'node:child_process'
11
11
  import { promisify } from 'node:util'
12
12
  import { chmodSync, existsSync, statSync, writeFileSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
- import { ffmpegBinary } from '../video-export.js'
14
+ import { ensureFfmpeg } from '../ffmpeg-manager.js'
15
15
  import {
16
16
  allocateClipId,
17
17
  clipDirFor,
@@ -27,8 +27,9 @@ import {
27
27
  const exec = promisify(execFile)
28
28
  const MAX_SOURCE_BYTES = 512 * 1024 * 1024
29
29
 
30
- function ffmpeg(args, options = {}) {
31
- return exec(ffmpegBinary(), args, {
30
+ async function ffmpeg(args, options = {}) {
31
+ const binary = await ensureFfmpeg({ signal: options.signal })
32
+ return exec(binary, args, {
32
33
  windowsHide: true,
33
34
  timeout: 120000,
34
35
  maxBuffer: 64 * 1024 * 1024,
@@ -13,7 +13,7 @@ import { promisify } from 'node:util'
13
13
  import { open as openFile } from 'node:fs/promises'
14
14
  import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
15
15
  import { basename, extname, join } from 'node:path'
16
- import { ffmpegBinary } from '../video-export.js'
16
+ import { ensureFfmpeg } from '../ffmpeg-manager.js'
17
17
  import { parseProjectJson } from './json.js'
18
18
 
19
19
  const exec = promisify(execFile)
@@ -27,8 +27,9 @@ const MAX_BYTES = 24 * 1024 * 1024
27
27
  const MAX_VIDEO_BYTES = 256 * 1024 * 1024
28
28
  export const MEDIA_EXT = new Set([...IMAGE_EXT, ...AUDIO_EXT, ...VIDEO_EXT, ...VECTOR_EXT])
29
29
 
30
- function run(args, options = {}) {
31
- return exec(ffmpegBinary(), args, { windowsHide: true, timeout: 60000, maxBuffer: 8 * 1024 * 1024, ...options })
30
+ async function run(args, options = {}) {
31
+ const binary = await ensureFfmpeg({ signal: options.signal })
32
+ return exec(binary, args, { windowsHide: true, timeout: 60000, maxBuffer: 8 * 1024 * 1024, ...options })
32
33
  }
33
34
 
34
35
  export function assetsDirectory(projectDir) {
@@ -11,7 +11,7 @@
11
11
  import { execFile } from 'node:child_process'
12
12
  import { promisify } from 'node:util'
13
13
  import { existsSync } from 'node:fs'
14
- import { ffmpegBinary } from '../video-export.js'
14
+ import { ensureFfmpeg } from '../ffmpeg-manager.js'
15
15
 
16
16
  const exec = promisify(execFile)
17
17
  const SAMPLE_RATE = 22050
@@ -20,7 +20,8 @@ const WINDOW = 1024
20
20
 
21
21
  export async function decodePcm(file) {
22
22
  if (!existsSync(file)) throw new Error('找不到音频文件:' + file)
23
- const { stdout } = await exec(ffmpegBinary(), ['-hide_banner', '-loglevel', 'error', '-i', file, '-vn', '-ac', '1', '-ar', String(SAMPLE_RATE), '-f', 's16le', '-'], { encoding: 'buffer', windowsHide: true, timeout: 120000, maxBuffer: 256 * 1024 * 1024 })
23
+ const binary = await ensureFfmpeg()
24
+ const { stdout } = await exec(binary, ['-hide_banner', '-loglevel', 'error', '-i', file, '-vn', '-ac', '1', '-ar', String(SAMPLE_RATE), '-f', 's16le', '-'], { encoding: 'buffer', windowsHide: true, timeout: 120000, maxBuffer: 256 * 1024 * 1024 })
24
25
  return { pcm: stdout, sampleRate: SAMPLE_RATE, hop: HOP, window: WINDOW }
25
26
  }
26
27
 
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // 抓下来的每一条都带 source URL,交付时能说清"这张图哪来的"。
14
14
 
15
- import { openBrowser } from '@remotion/renderer'
15
+ import { loadVideoModule } from './video-runtime.js'
16
16
  import { createHash } from 'node:crypto'
17
17
  import { mkdirSync, writeFileSync } from 'node:fs'
18
18
  import { join } from 'node:path'
@@ -303,6 +303,7 @@ export async function collectPageAssets(projectDir, rawUrl, options = {}) {
303
303
  const minWidth = clamp(options.minWidth || 240, 40, 2000)
304
304
 
305
305
  mkdirSync(join(projectDir, '.out'), { recursive: true })
306
+ const { openBrowser } = await loadVideoModule('@remotion/renderer', options)
306
307
  const browser = await openBrowser('chrome', { browserExecutable: browserExecutable(), logLevel: 'error' })
307
308
  let scan
308
309
  try {
@@ -367,6 +368,7 @@ export async function gatherAssets(projectDir, options = {}) {
367
368
  mkdirSync(join(projectDir, '.out'), { recursive: true })
368
369
  const scans = []
369
370
  const failedPages = []
371
+ const { openBrowser } = await loadVideoModule('@remotion/renderer', options)
370
372
  const browser = await openBrowser('chrome', { browserExecutable: browserExecutable(), logLevel: 'error' })
371
373
  try {
372
374
  let index = 0
@@ -16,9 +16,13 @@ const POLISH = `#!/usr/bin/env node
16
16
  // node tools/polish.mjs --web 平台版:1080p / 720p,crf 18,faststart
17
17
  // node tools/polish.mjs --vertical 竖版:等比缩放 + 模糊底,抖音/Reels
18
18
  // node tools/polish.mjs --loudness 只量响度,不产出文件
19
+ //
20
+ // FFmpeg 查找顺序:DRAWAI_FFMPEG_PATH → FFMPEG_PATH → PATH → DrawAI 缓存
21
+ // (~/.dsh/drawai-ffmpeg/<平台>-<架构>/,可用 DRAWAI_FFMPEG_DIR 覆盖)。
19
22
 
20
23
  import { spawnSync } from 'node:child_process'
21
24
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
22
26
  import { dirname, join, resolve } from 'node:path'
23
27
  import { fileURLToPath } from 'node:url'
24
28
 
@@ -26,7 +30,31 @@ const here = dirname(fileURLToPath(import.meta.url))
26
30
  const project = resolve(here, '..')
27
31
  const source = join(project, '.out', 'film.mp4')
28
32
  const dist = join(project, 'dist')
29
- const ffmpeg = process.env.FFMPEG_PATH || 'ffmpeg'
33
+
34
+ // 查找顺序与插件一致:显式路径 → 旧环境变量 → PATH → DrawAI 的独立缓存。
35
+ // 显式路径无效时直接报错,不会悄悄换成别的 FFmpeg。
36
+ function usable(candidate) {
37
+ if (!candidate) return false
38
+ const result = spawnSync(candidate, ['-hide_banner', '-version'], { stdio: 'ignore' })
39
+ return !result.error && result.status === 0
40
+ }
41
+ function resolveFfmpeg() {
42
+ const explicit = process.env.DRAWAI_FFMPEG_PATH
43
+ if (explicit) {
44
+ if (!usable(explicit)) {
45
+ console.error('DRAWAI_FFMPEG_PATH 指定的 FFmpeg 不可用:' + explicit + '。请修正这个路径,或删除这个环境变量,让脚本使用 PATH / DrawAI 缓存。')
46
+ process.exit(1)
47
+ }
48
+ return explicit
49
+ }
50
+ for (const candidate of [process.env.FFMPEG_PATH, 'ffmpeg']) if (usable(candidate)) return candidate
51
+ const cacheRoot = process.env.DRAWAI_FFMPEG_DIR || join(homedir(), '.dsh', 'drawai-ffmpeg')
52
+ const cached = join(cacheRoot, process.platform + '-' + process.arch, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')
53
+ if (usable(cached)) return cached
54
+ console.error('找不到 FFmpeg:先在 DrawAI 里导出一次视频(首次使用会自动下载并校验),或把 ffmpeg 加入 PATH,或设置 DRAWAI_FFMPEG_PATH 指向可执行文件。')
55
+ process.exit(1)
56
+ }
57
+ const ffmpeg = resolveFfmpeg()
30
58
 
31
59
  if (!existsSync(source)) {
32
60
  console.error('找不到成片:' + source + '(先 motion_render start 渲整片)')
@@ -6,11 +6,12 @@
6
6
  import { existsSync, openSync, closeSync, readFileSync } from 'node:fs'
7
7
  import { join, isAbsolute } from 'node:path'
8
8
  import { spawn } from 'node:child_process'
9
- import { ffmpegBinary } from '../video-export.js'
9
+ import { ensureFfmpeg, ffmpegPathSync } from '../ffmpeg-manager.js'
10
10
 
11
- export function runFfmpeg(args, options = {}) {
11
+ export async function runFfmpeg(args, options = {}) {
12
12
  const cwd = options.cwd || process.cwd()
13
13
  const logPath = isAbsolute(options.log || '') ? options.log : join(cwd, options.log || 'ff.log')
14
+ const binary = options.binary || await ensureFfmpeg({ signal: options.signal, onProgress: options.onProgress })
14
15
  return new Promise((resolve, reject) => {
15
16
  let fd
16
17
  try {
@@ -19,7 +20,7 @@ export function runFfmpeg(args, options = {}) {
19
20
  reject(error)
20
21
  return
21
22
  }
22
- const child = spawn(ffmpegBinary(), ['-hide_banner', '-nostdin', '-y', ...args], {
23
+ const child = spawn(binary, ['-hide_banner', '-nostdin', '-y', ...args], {
23
24
  cwd, windowsHide: true, stdio: ['ignore', 'ignore', fd],
24
25
  })
25
26
  const timer = setTimeout(() => child.kill(), options.timeoutMs || 300000)
@@ -38,5 +39,5 @@ export function runFfmpeg(args, options = {}) {
38
39
  }
39
40
 
40
41
  export function ffmpegAvailable() {
41
- return existsSync(ffmpegBinary())
42
+ return existsSync(ffmpegPathSync())
42
43
  }
@@ -62,9 +62,13 @@ export class RenderJobs {
62
62
  }
63
63
 
64
64
  // 进度只在内存里刷:每 1% 写一次盘既慢又没意义。
65
- progress(job, phase, value) {
65
+ progress(job, phase, value, message) {
66
+ const changed = phase && phase !== job.phase
66
67
  if (phase) job.phase = phase
68
+ if (message) job.message = message
69
+ else if (changed) job.message = ''
67
70
  if (Number.isFinite(value)) job.progress = value
71
+ if (changed) this.save(job)
68
72
  return job
69
73
  }
70
74
 
@@ -9,7 +9,7 @@ import { join } from 'node:path'
9
9
  import { createHash } from 'node:crypto'
10
10
  import { execFile } from 'node:child_process'
11
11
  import { promisify } from 'node:util'
12
- import { ffmpegBinary } from '../video-export.js'
12
+ import { ensureFfmpeg } from '../ffmpeg-manager.js'
13
13
  import { renderProject, outputPath } from './render.js'
14
14
  import { readTimeline } from './project.js'
15
15
 
@@ -33,7 +33,8 @@ export async function contactSheet(images, options = {}) {
33
33
  const width = options.width === undefined ? 640 : Number(options.width)
34
34
  const filter = (width > 0 ? 'scale=' + width + ':-2:flags=lanczos,' : '')
35
35
  + 'tile=' + cols + 'x' + rows + ':padding=8:margin=8:color=0x101010'
36
- await exec(ffmpegBinary(), ['-hide_banner', '-loglevel', 'error', '-y', '-i', join(work, 'frame-%d.png'),
36
+ const binary = await ensureFfmpeg()
37
+ await exec(binary, ['-hide_banner', '-loglevel', 'error', '-y', '-i', join(work, 'frame-%d.png'),
37
38
  '-frames:v', '1', '-vf', filter, output],
38
39
  { windowsHide: true, timeout: 60000 })
39
40
  if (!existsSync(output)) throw new Error('拼图失败')
@@ -50,7 +51,8 @@ function dirnameOf(file) {
50
51
 
51
52
  async function cropFile(file, crop) {
52
53
  const out = file.replace(/\.png$/i, '-crop.png')
53
- await exec(ffmpegBinary(), ['-hide_banner', '-loglevel', 'error', '-y', '-i', file,
54
+ const binary = await ensureFfmpeg()
55
+ await exec(binary, ['-hide_banner', '-loglevel', 'error', '-y', '-i', file,
54
56
  '-vf', 'crop=' + crop.w + ':' + crop.h + ':' + crop.x + ':' + crop.y, out], { windowsHide: true, timeout: 60000 })
55
57
  return existsSync(out) ? out : file
56
58
  }
@@ -5,6 +5,7 @@
5
5
  // 2. 永远复用 bundle:预览和全片共用 .out/bundle/.drawai-bundle-key;
6
6
  // 3. 永远先出图:预览走 renderStill,几秒钟,不跑全片。
7
7
 
8
+ import { videoRuntimeDirectory } from './video-runtime.js'
8
9
  import { fork } from 'node:child_process'
9
10
  import { fileURLToPath } from 'node:url'
10
11
  import { createHash } from 'node:crypto'
@@ -52,6 +53,7 @@ export function bundleKey(composition, projectDir) {
52
53
  walk()
53
54
  }
54
55
  hash.update(readFileSync(ENTRY))
56
+ hash.update(videoRuntimeDirectory())
55
57
  return hash.digest('hex')
56
58
  }
57
59
 
@@ -107,7 +109,7 @@ export function renderProject(dir, options = {}) {
107
109
  let cancelTimer
108
110
  const rendered = new Promise((resolve, reject) => {
109
111
  const finish = (fn, value) => { if (settled) return; settled = true; clearTimeout(cancelTimer); fn(value) }
110
- const timeout = setTimeout(() => { child.kill(); finish(reject, new Error('渲染超过 ' + Math.round(timeoutFor(render, composition.durationFrames) / 60000) + ' 分钟,请拆小场景或在 timeline.json 的 render.timeoutMs 里放大预算')) }, options.timeoutMs || timeoutFor(render, composition.durationFrames))
112
+ const timeout = setTimeout(() => { child.kill(); finish(reject, new Error('渲染超过 ' + Math.round(timeoutFor(render, composition.durationFrames) / 60000) + ' 分钟,请拆小场景或在 timeline.json 的 render.timeoutMs 里放大预算')) }, options.timeoutMs || (timeoutFor(render, composition.durationFrames) + 11 * 60000))
111
113
  let stderr = ''
112
114
  child.stderr.on('data', (bytes) => { stderr = (stderr + bytes).slice(-4000) })
113
115
  child.on('message', (message) => {