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/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 : '')
|
package/lib/motion/apply.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
package/lib/motion/assets.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) {
|
package/lib/motion/beats.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
package/lib/motion/collect.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
//
|
|
13
13
|
// 抓下来的每一条都带 source URL,交付时能说清"这张图哪来的"。
|
|
14
14
|
|
|
15
|
-
import {
|
|
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
|
package/lib/motion/delivery.js
CHANGED
|
@@ -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
|
-
|
|
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 渲整片)')
|
package/lib/motion/ffmpeg.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
42
|
+
return existsSync(ffmpegPathSync())
|
|
42
43
|
}
|
package/lib/motion/jobs.js
CHANGED
|
@@ -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
|
|
package/lib/motion/preview.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/lib/motion/render.js
CHANGED
|
@@ -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) => {
|