dsh-drawai 0.4.1 → 0.4.2
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 +95 -253
- package/lib/client.js +28 -7
- package/lib/index.js +7719 -7400
- package/lib/motion/asset-pack.js +394 -335
- package/lib/motion/demo-assets-lock.js +10 -0
- package/lib/motion/mgstyles/vendor/README.md +28 -0
- package/lib/motion/mgstyles/vendor/modules/d3/LICENSE +13 -0
- package/lib/motion/mgstyles/vendor/modules/d3/dist/d3.min.js +2 -0
- package/lib/motion/mgstyles/vendor/modules/opentype.js/LICENSE +20 -0
- package/lib/motion/mgstyles/vendor/modules/opentype.js/dist/opentype.mjs +14460 -0
- package/lib/motion/mgstyles/vendor/modules/simplex-noise/LICENSE +21 -0
- package/lib/motion/mgstyles/vendor/modules/simplex-noise/dist/esm/simplex-noise.js +466 -0
- package/lib/motion/mgstyles/vendor/modules/topojson-client/LICENSE +13 -0
- package/lib/motion/mgstyles/vendor/modules/topojson-client/dist/topojson-client.min.js +2 -0
- package/lib/motion/preview.js +10 -3
- package/lib/motion/render.js +179 -161
- package/lib/motion/style-frames.js +306 -271
- package/lib/motion/sync.js +214 -214
- package/lib/motion-project.js +105 -86
- package/lib/motion-render-worker.js +174 -152
- package/lib/routing/router.js +222 -211
- package/lib/routing/vendor/README.md +96 -96
- package/lib/routing/vendor/libavoid-routing.js +753 -753
- package/lib/routing/vendor/libavoid.min.js +59 -59
- package/lib/style-kernel.js +16 -6
- package/package.json +1 -1
package/lib/motion-project.js
CHANGED
|
@@ -1,87 +1,106 @@
|
|
|
1
1
|
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
|
-
import { join, dirname, resolve, relative, isAbsolute } from 'node:path'
|
|
4
|
-
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import { videoRuntimeRequire } from './motion/video-runtime.js'
|
|
6
|
-
import { validateMotionSources, sceneComponentPath } from './motion-source.js'
|
|
7
|
-
import { shotcraftModuleMap } from './motion-library.js'
|
|
8
|
-
import { enforceMotionCards } from './motion-library.js'
|
|
9
|
-
import { enforceMgStyle } from './motion-library.js'
|
|
10
|
-
import { validateMotionPlan } from './motion-core.js'
|
|
11
|
-
import { parseProjectJson } from './motion/json.js'
|
|
12
|
-
|
|
13
|
-
// Agent projects are edited on disk. Read their sources before validating or
|
|
14
|
-
// building; never restore a stale inline source over an agent's changes.
|
|
15
|
-
export function readMotionProject(directory, input) {
|
|
16
|
-
const root=realpathSync(directory)
|
|
17
|
-
const read=(name)=>{
|
|
18
|
-
const file=realpathSync(join(root,name)),rel=relative(root,file)
|
|
19
|
-
if(isAbsolute(rel)||rel==='..'||/^\.\.[\\/]/.test(rel))throw new Error('工程文件超出任务目录')
|
|
20
|
-
return readFileSync(file,'utf8')
|
|
21
|
-
}
|
|
22
|
-
const raw=parseProjectJson(read('composition.json'))
|
|
23
|
-
if(!Array.isArray(raw.shots))throw new Error('工程缺少 shots 分镜')
|
|
24
|
-
raw.shots=raw.shots.map((shot,index)=>({...shot,sceneSource:read('scene-'+index+'.tsx'),
|
|
25
|
-
...(shot.sceneComponents?{sceneComponents:shot.sceneComponents.map(c=>({...c,source:read(sceneComponentPath('shot-'+(index+1),c.id))}))}:{})}))
|
|
26
|
-
return enforceMgStyle(enforceMotionCards(validateMotionSources(validateMotionPlan(raw,input)),input),input)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
|
+
import { join, dirname, resolve, relative, isAbsolute } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { videoRuntimeRequire } from './motion/video-runtime.js'
|
|
6
|
+
import { validateMotionSources, sceneComponentPath } from './motion-source.js'
|
|
7
|
+
import { shotcraftModuleMap } from './motion-library.js'
|
|
8
|
+
import { enforceMotionCards } from './motion-library.js'
|
|
9
|
+
import { enforceMgStyle } from './motion-library.js'
|
|
10
|
+
import { validateMotionPlan } from './motion-core.js'
|
|
11
|
+
import { parseProjectJson } from './motion/json.js'
|
|
12
|
+
|
|
13
|
+
// Agent projects are edited on disk. Read their sources before validating or
|
|
14
|
+
// building; never restore a stale inline source over an agent's changes.
|
|
15
|
+
export function readMotionProject(directory, input) {
|
|
16
|
+
const root=realpathSync(directory)
|
|
17
|
+
const read=(name)=>{
|
|
18
|
+
const file=realpathSync(join(root,name)),rel=relative(root,file)
|
|
19
|
+
if(isAbsolute(rel)||rel==='..'||/^\.\.[\\/]/.test(rel))throw new Error('工程文件超出任务目录')
|
|
20
|
+
return readFileSync(file,'utf8')
|
|
21
|
+
}
|
|
22
|
+
const raw=parseProjectJson(read('composition.json'))
|
|
23
|
+
if(!Array.isArray(raw.shots))throw new Error('工程缺少 shots 分镜')
|
|
24
|
+
raw.shots=raw.shots.map((shot,index)=>({...shot,sceneSource:read('scene-'+index+'.tsx'),
|
|
25
|
+
...(shot.sceneComponents?{sceneComponents:shot.sceneComponents.map(c=>({...c,source:read(sceneComponentPath('shot-'+(index+1),c.id))}))}:{})}))
|
|
26
|
+
return enforceMgStyle(enforceMotionCards(validateMotionSources(validateMotionPlan(raw,input)),input),input)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 入口与运行时 JSX 的源文件在插件里,但它们**必须被复制到工程目录**再引用:
|
|
30
|
+
// Remotion 的 webpack 只给 .tsx?/.ts 挂 esbuild-loader,配 .jsx? 的那条规则显式
|
|
31
|
+
// exclude /node_modules/ —— 插件装在 profile 的 node_modules 下时,入口 JSX 会被当成
|
|
32
|
+
// 普通文本解析,报 "Unexpected token (<AbsoluteFill>)"(issue #21、#22)。
|
|
33
|
+
const RUNTIME_ENTRY = fileURLToPath(new URL('./motion/entry.jsx', import.meta.url))
|
|
34
|
+
const RUNTIME_REFERENCE = fileURLToPath(new URL('./motion/reference-media.jsx', import.meta.url))
|
|
35
|
+
const GENERATED_HEADER = '// 由 dsh-drawai 生成,请勿手改;插件更新后会按源文件重新生成。\n'
|
|
36
|
+
|
|
37
|
+
// 只在内容变化时写盘:保持 mtime 稳定,bundle 缓存才不会每次重建。
|
|
38
|
+
function writeGenerated(file, source) {
|
|
39
|
+
const desired = GENERATED_HEADER + source
|
|
40
|
+
if (existsSync(file) && readFileSync(file, 'utf8') === desired) return
|
|
41
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
42
|
+
writeFileSync(file, desired)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function prepareMotionProject(composition, directory, options = {}) {
|
|
46
|
+
validateMotionSources(composition)
|
|
47
|
+
mkdirSync(directory, { recursive: true })
|
|
48
|
+
const imports = [], entries = []
|
|
49
|
+
composition.shots.forEach((shot, index) => {
|
|
50
|
+
if (shot.recipeId !== 'scene') return
|
|
51
|
+
const name = 'scene-' + index + '.tsx'
|
|
52
|
+
if(!options.preserveSources)writeFileSync(join(directory, name), shot.sceneSource)
|
|
53
|
+
for (const component of shot.sceneComponents || []) {
|
|
54
|
+
const file = join(directory, sceneComponentPath(shot.id, component.id))
|
|
55
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
56
|
+
if(!options.preserveSources)writeFileSync(file, component.source)
|
|
57
|
+
}
|
|
58
|
+
imports.push(`import Scene${index} from './${name}';`)
|
|
59
|
+
entries.push(`${JSON.stringify(shot.id)}:Scene${index}`)
|
|
60
|
+
})
|
|
61
|
+
writeGenerated(join(directory, 'entry.gen.jsx'), readFileSync(RUNTIME_ENTRY, 'utf8'))
|
|
62
|
+
writeGenerated(join(directory, 'reference-media.gen.jsx'), readFileSync(RUNTIME_REFERENCE, 'utf8'))
|
|
63
|
+
const entryPoint = join(directory, 'index.jsx')
|
|
64
|
+
const entrySource = `import {registerMotionRoot} from './entry.gen.jsx';\n${imports.join('\n')}\nregisterMotionRoot({${entries.join(',')}});\n`
|
|
65
|
+
// 入口本身就是生成的:内容变了(换插件路径、增删镜头)就重写,不看 preserveSources。
|
|
66
|
+
if(!existsSync(entryPoint) || readFileSync(entryPoint, 'utf8') !== entrySource)writeFileSync(entryPoint, entrySource)
|
|
67
|
+
if(!options.preserveSources)writeFileSync(join(directory, 'composition.json'), JSON.stringify(composition, null, 2))
|
|
68
|
+
if (composition.production && !options.preserveSources) {
|
|
69
|
+
writeFileSync(join(directory, 'design.json'), JSON.stringify(composition.production, null, 2))
|
|
70
|
+
writeFileSync(join(directory, 'storyboard.json'), JSON.stringify(composition.shots.map(({ sceneSource: _source, sceneComponents, ...shot }) => ({ ...shot,
|
|
71
|
+
...(sceneComponents ? { components: sceneComponents.map(({ source: _code, ...component }) => component) } : {}) })), null, 2))
|
|
72
|
+
}
|
|
73
|
+
const alias = Object.fromEntries(Object.entries(shotcraftModuleMap()).map(([name, url]) => [name + '$', fileURLToPath(url)]))
|
|
74
|
+
// Demos use paths relative to the upstream repository. Generated components
|
|
75
|
+
// live elsewhere: resolve known library paths without rewriting their source.
|
|
76
|
+
const library = shotcraftModuleMap()
|
|
77
|
+
const libraryImports = { apply(compiler) {
|
|
78
|
+
compiler.hooks.normalModuleFactory.tap('DrawAiShotcraftImports', (factory) => {
|
|
79
|
+
factory.hooks.beforeResolve.tap('DrawAiShotcraftImports', (request) => {
|
|
80
|
+
if (!request?.request?.startsWith('.')) return
|
|
81
|
+
const local = relative(resolve(directory), resolve(request.context))
|
|
82
|
+
if (isAbsolute(local) || local === '..' || /^\.\.[\\/]/.test(local)) return
|
|
83
|
+
const stem = resolve(request.context, request.request)
|
|
84
|
+
if (['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.js'].some((ext) => existsSync(stem + ext))) return
|
|
85
|
+
const path = request.request.replaceAll('\\', '/').replace(/^(?:\.\.?\/)+/, '').replace(/\.(tsx?|jsx?)$/, '')
|
|
86
|
+
const name = path.startsWith('demos/') ? '@shotcraft/' + path
|
|
87
|
+
: path.startsWith('_fixtures/') ? '@shotcraft/demos/' + path
|
|
88
|
+
: path.startsWith('assets/lib/') ? '@shotcraft/lib/' + path.slice('assets/lib/'.length)
|
|
89
|
+
: path.startsWith('lib/') ? '@shotcraft/' + path : ''
|
|
90
|
+
if (Object.hasOwn(library, name)) request.request = fileURLToPath(library[name])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
} }
|
|
94
|
+
alias['@drawai/motion$'] = join(directory, 'reference-media.gen.jsx')
|
|
95
|
+
// Resolve the runtime lazily: creating a project only writes its files, and
|
|
96
|
+
// the video runtime may not be initialized yet (it is installed on first use).
|
|
97
|
+
const runtimeModules = () => {
|
|
98
|
+
const require = videoRuntimeRequire(options.runtimeDirectory)
|
|
99
|
+
return [...new Set(['react', 'remotion', '@remotion/motion-blur'].map((name) => dirname(require.resolve(name + '/package.json')).replace(/[\\/]@remotion[\\/][^\\/]+$/, '').replace(/[\\/](?:react|remotion)$/, '')))]
|
|
100
|
+
}
|
|
101
|
+
return { entryPoint, webpackOverride: (config) => {
|
|
102
|
+
const modules = runtimeModules()
|
|
103
|
+
return { ...config, plugins: [...(config.plugins || []), libraryImports], resolve: { ...config.resolve,
|
|
104
|
+
alias: { ...config.resolve?.alias, ...alias }, modules: [...modules, ...(config.resolve?.modules || ['node_modules'])] } }
|
|
105
|
+
} }
|
|
106
|
+
}
|
|
@@ -1,153 +1,175 @@
|
|
|
1
1
|
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
-
import { ensureVideoRuntime, videoRuntimeRequire } from './motion/video-runtime.js'
|
|
3
|
-
import { existsSync, rmSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
-
import { dirname, join, relative, isAbsolute, resolve } from 'node:path'
|
|
5
|
-
import { createHash } from 'node:crypto'
|
|
6
|
-
import { prepareMotionProject } from './motion-project.js'
|
|
7
|
-
import { runFfmpeg } from './motion/ffmpeg.js'
|
|
8
|
-
|
|
9
|
-
let cancelled = false, running = false
|
|
10
|
-
const runtimeAbort = new AbortController()
|
|
11
|
-
let cancel = () => {}
|
|
12
|
-
const send = (message) => { if (process.connected) process.send(message) }
|
|
13
|
-
process.on('message', async (request) => {
|
|
14
|
-
if (request.type === 'cancel') { cancelled = true; runtimeAbort.abort(new Error('已取消动效生成')); cancel(); return }
|
|
15
|
-
if (running) return
|
|
16
|
-
running = true
|
|
17
|
-
let browser
|
|
18
|
-
const outDir = request.bundleDir || join(dirname(request.output), 'bundle')
|
|
19
|
-
const cacheFile = join(outDir, '.drawai-bundle-key')
|
|
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
|
|
29
|
-
// 同一个工程连续预览/渲染时复用上次的 bundle:预览从 30 秒降到 3 秒,
|
|
30
|
-
// 改一个场景文件只会让 key 变化一次。request.bundleKey 由调用方按源码计算。
|
|
31
|
-
const reuse = request.reuseBundle === true && request.bundleKey && existsSync(join(outDir, 'index.html'))
|
|
32
|
-
&& existsSync(cacheFile) && readFileSync(cacheFile, 'utf8').trim() === request.bundleKey
|
|
33
|
-
let serveUrl
|
|
34
|
-
if (reuse) {
|
|
35
|
-
serveUrl = outDir
|
|
36
|
-
send({ type: 'progress', phase: 'bundling', progress: .1 })
|
|
37
|
-
} else {
|
|
38
|
-
const project = prepareMotionProject(request.composition, request.projectDirectory || join(dirname(request.output), 'project'), {preserveSources:request.preserveSources===true, runtimeDirectory})
|
|
39
|
-
serveUrl = await bundle({ ...project, outDir, ignoreRegisterRootWarning: true,
|
|
40
|
-
publicDir: request.publicDir, onProgress: (progress) => send({ type: 'progress', phase: 'bundling', progress: progress / 100 * .1 }) })
|
|
41
|
-
const index = join(outDir, 'index.html')
|
|
42
|
-
writeFileSync(index, readFileSync(index, 'utf8').replace('<head>', '<head><meta http-equiv="Content-Security-Policy" content="default-src \'self\' blob: data:; script-src \'self\' \'unsafe-inline\' \'unsafe-eval\'; style-src \'self\' \'unsafe-inline\'; connect-src \'self\'; img-src \'self\' blob: data:; media-src \'self\' blob:; frame-src \'none\'; object-src \'none\'">'))
|
|
43
|
-
if (request.bundleKey) writeFileSync(cacheFile, request.bundleKey)
|
|
44
|
-
}
|
|
45
|
-
if (cancelled) throw new Error('已取消动效生成')
|
|
46
|
-
// Use a configured browser, or a local
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
})
|
|
2
|
+
import { ensureVideoRuntime, videoRuntimeRequire } from './motion/video-runtime.js'
|
|
3
|
+
import { existsSync, rmSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join, relative, isAbsolute, resolve } from 'node:path'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { prepareMotionProject } from './motion-project.js'
|
|
7
|
+
import { runFfmpeg } from './motion/ffmpeg.js'
|
|
8
|
+
|
|
9
|
+
let cancelled = false, running = false
|
|
10
|
+
const runtimeAbort = new AbortController()
|
|
11
|
+
let cancel = () => {}
|
|
12
|
+
const send = (message) => { if (process.connected) process.send(message) }
|
|
13
|
+
process.on('message', async (request) => {
|
|
14
|
+
if (request.type === 'cancel') { cancelled = true; runtimeAbort.abort(new Error('已取消动效生成')); cancel(); return }
|
|
15
|
+
if (running) return
|
|
16
|
+
running = true
|
|
17
|
+
let browser
|
|
18
|
+
const outDir = request.bundleDir || join(dirname(request.output), 'bundle')
|
|
19
|
+
const cacheFile = join(outDir, '.drawai-bundle-key')
|
|
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
|
|
29
|
+
// 同一个工程连续预览/渲染时复用上次的 bundle:预览从 30 秒降到 3 秒,
|
|
30
|
+
// 改一个场景文件只会让 key 变化一次。request.bundleKey 由调用方按源码计算。
|
|
31
|
+
const reuse = request.reuseBundle === true && request.bundleKey && existsSync(join(outDir, 'index.html'))
|
|
32
|
+
&& existsSync(cacheFile) && readFileSync(cacheFile, 'utf8').trim() === request.bundleKey
|
|
33
|
+
let serveUrl
|
|
34
|
+
if (reuse) {
|
|
35
|
+
serveUrl = outDir
|
|
36
|
+
send({ type: 'progress', phase: 'bundling', progress: .1 })
|
|
37
|
+
} else {
|
|
38
|
+
const project = prepareMotionProject(request.composition, request.projectDirectory || join(dirname(request.output), 'project'), {preserveSources:request.preserveSources===true, runtimeDirectory})
|
|
39
|
+
serveUrl = await bundle({ ...project, outDir, ignoreRegisterRootWarning: true,
|
|
40
|
+
publicDir: request.publicDir, onProgress: (progress) => send({ type: 'progress', phase: 'bundling', progress: progress / 100 * .1 }) })
|
|
41
|
+
const index = join(outDir, 'index.html')
|
|
42
|
+
writeFileSync(index, readFileSync(index, 'utf8').replace('<head>', '<head><meta http-equiv="Content-Security-Policy" content="default-src \'self\' blob: data:; script-src \'self\' \'unsafe-inline\' \'unsafe-eval\'; style-src \'self\' \'unsafe-inline\'; connect-src \'self\'; img-src \'self\' blob: data:; media-src \'self\' blob:; frame-src \'none\'; object-src \'none\'">'))
|
|
43
|
+
if (request.bundleKey) writeFileSync(cacheFile, request.bundleKey)
|
|
44
|
+
}
|
|
45
|
+
if (cancelled) throw new Error('已取消动效生成')
|
|
46
|
+
// Use a configured browser, or a local browser before downloading Chromium.
|
|
47
|
+
// 参数名必须是 puppeteerInstance:@remotion/renderer 4.x 只认这个名字,
|
|
48
|
+
// 写成 browserInstance 会被静默忽略,然后它自己去下载 chrome-headless-shell(内网/无外网必炸)。
|
|
49
|
+
// 同时把 browserExecutable 一并传下去:万一实例没用上,也还有本机浏览器可退回,不去联网。
|
|
50
|
+
const candidates = [
|
|
51
|
+
request.browserExecutable, process.env.DRAWAI_CHROME_PATH,
|
|
52
|
+
// Windows
|
|
53
|
+
'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
|
54
|
+
// macOS(插件装在 Mac 上时,浏览器不在 PATH 里,也不在 Windows 的默认位置)
|
|
55
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
56
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
57
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
58
|
+
// Linux
|
|
59
|
+
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge',
|
|
60
|
+
]
|
|
61
|
+
const browserExecutable = candidates.find((path) => path && existsSync(path))
|
|
62
|
+
if (!browserExecutable) {
|
|
63
|
+
// 没有本机浏览器时 Remotion 会去下载 chrome-headless-shell:内网/无外网会卡很久。
|
|
64
|
+
// 先把这件事说出来,别让它看起来像"卡死"(issue #22)。
|
|
65
|
+
send({ type: 'progress', phase: 'browser', progress: 0, message: '没找到本机 Chrome / Edge,正在下载内置浏览器;首次会慢一些' })
|
|
66
|
+
}
|
|
67
|
+
browser = await openBrowser('chrome', { browserExecutable, logLevel: 'error' }).catch((error) => {
|
|
68
|
+
const detail = error?.message || String(error)
|
|
69
|
+
throw new Error(browserExecutable
|
|
70
|
+
? `打开本机浏览器失败(${browserExecutable}):${detail}`
|
|
71
|
+
: `未找到本机 Chrome / Edge,下载 chrome-headless-shell 也失败:${detail}。请安装 Chrome,或用 DRAWAI_CHROME_PATH 指定浏览器路径。`)
|
|
72
|
+
})
|
|
73
|
+
if (cancelled) throw new Error('已取消动效生成')
|
|
74
|
+
const inputProps = { plan: request.composition, assets: request.assets }
|
|
75
|
+
const composition = await selectComposition({ serveUrl, id: 'DrawAiMotion', inputProps, puppeteerInstance: browser, browserExecutable })
|
|
76
|
+
if(request.operation==='compile'){send({type:'done',compiled:true});return}
|
|
77
|
+
send({ type: 'progress', phase: 'reviewing', progress: .1 })
|
|
78
|
+
const qaDirectory = request.qaDirectory || join(dirname(request.output), 'qa'); mkdirSync(qaDirectory, { recursive: true })
|
|
79
|
+
const audioCues = Array.isArray(request.composition?.audio) ? request.composition.audio : []
|
|
80
|
+
const qa = { status: 'render-checks-passed', visualReview: 'manual-required', audio: audioCues.length ? 'mixed:' + audioCues.length : 'silent', frames: [], warnings: [] }
|
|
81
|
+
if(request.operation==='still'){
|
|
82
|
+
const frames=[]
|
|
83
|
+
for(const frame of request.frames){
|
|
84
|
+
if(!Number.isInteger(frame)||frame<0||frame>=composition.durationInFrames)throw new Error('截图帧超出成片')
|
|
85
|
+
const filename='agent-frame-'+frame+'.png'
|
|
86
|
+
await renderStill({serveUrl,composition,inputProps,output:join(qaDirectory,filename),frame,puppeteerInstance:browser,browserExecutable,cancelSignal,logLevel:'error'})
|
|
87
|
+
frames.push({frame,filename,sha256:createHash('sha256').update(readFileSync(join(qaDirectory,filename))).digest('hex')})
|
|
88
|
+
}
|
|
89
|
+
send({type:'done',frames});return
|
|
90
|
+
}
|
|
91
|
+
for (const shot of request.composition.shots) {
|
|
92
|
+
const hashes = []
|
|
93
|
+
for (const fraction of (shot.motionCards?.length ? [0, .25, .5, .75, .98] : [.25, .7])) {
|
|
94
|
+
const frame = shot.startFrame + Math.min(shot.durationFrames - 1, Math.floor(shot.durationFrames * fraction))
|
|
95
|
+
const filename = shot.id + '-' + frame + '.png'
|
|
96
|
+
await renderStill({ serveUrl, composition, inputProps, output: join(qaDirectory, filename), frame, puppeteerInstance: browser, browserExecutable, cancelSignal, logLevel: 'error', timeoutInMilliseconds: 30000 })
|
|
97
|
+
const bytes = readFileSync(join(qaDirectory, filename))
|
|
98
|
+
const hash = createHash('sha256').update(bytes).digest('hex'); hashes.push(hash)
|
|
99
|
+
qa.frames.push({ shotId: shot.id, frame, filename, fraction, sha256: hash })
|
|
100
|
+
}
|
|
101
|
+
if (hashes[0] === hashes[1]) qa.warnings.push(shot.id + ': 两张采样帧相同,请检查主运动或停留段')
|
|
102
|
+
}
|
|
103
|
+
// 渲染规格随请求进来(由 render.js 的 resolveRenderSettings 解析过):
|
|
104
|
+
// 编码参数、色彩标签、超采样倍数、并发都在这里唯一地落到 renderMedia 上。
|
|
105
|
+
const settings = request.render || {}
|
|
106
|
+
qa.render = settings
|
|
107
|
+
writeFileSync(join(qaDirectory, 'report.json'), JSON.stringify(qa, null, 2))
|
|
108
|
+
const scale = Number(settings.scale) > 1 ? Math.round(Number(settings.scale)) : 1
|
|
109
|
+
const finalOutput = request.output
|
|
110
|
+
// 超采样:Remotion 的 scale 是「渲成更大」,不会替你缩回来。
|
|
111
|
+
// 所以大图先落到临时文件,再用一次 lanczos + bt709 的母版编码缩回目标尺寸。
|
|
112
|
+
const rawOutput = scale > 1 ? join(dirname(finalOutput), '.supersample.mp4') : finalOutput
|
|
113
|
+
const startedAt = Date.now()
|
|
114
|
+
let seenFrames = 0
|
|
115
|
+
const totalFrames = request.frameRange
|
|
116
|
+
? request.frameRange[1] - request.frameRange[0] + 1
|
|
117
|
+
: composition.durationInFrames
|
|
118
|
+
await renderMedia({ serveUrl, composition, inputProps, outputLocation: rawOutput,
|
|
119
|
+
codec: settings.codec || 'h264',
|
|
120
|
+
...(settings.crf ? { crf: settings.crf } : {}),
|
|
121
|
+
...(settings.preset ? { x264Preset: settings.preset } : {}),
|
|
122
|
+
...(settings.pixelFormat ? { pixelFormat: settings.pixelFormat } : {}),
|
|
123
|
+
...(settings.colorSpace ? { colorSpace: settings.colorSpace } : {}),
|
|
124
|
+
...(settings.audioBitrate ? { audioBitrate: settings.audioBitrate } : {}),
|
|
125
|
+
scale,
|
|
126
|
+
concurrency: settings.concurrency || 2,
|
|
127
|
+
...(request.frameRange?{frameRange:request.frameRange}:{}),
|
|
128
|
+
puppeteerInstance: browser, browserExecutable, cancelSignal, logLevel: 'error',
|
|
129
|
+
onProgress: ({ progress, renderedFrames }) => {
|
|
130
|
+
const done = Number(renderedFrames) || 0
|
|
131
|
+
if (done > seenFrames) seenFrames = done
|
|
132
|
+
const elapsed = (Date.now() - startedAt) / 1000
|
|
133
|
+
const perFrame = seenFrames > 0 ? elapsed / seenFrames : null
|
|
134
|
+
// 单帧耗时是「样本数该开多大」的唯一依据:没有它,超采样只能靠猜。
|
|
135
|
+
send({ type: 'progress', phase: 'rendering', progress: .1 + progress * .85,
|
|
136
|
+
frames: { done: seenFrames, total: totalFrames,
|
|
137
|
+
perFrameSeconds: perFrame ? Math.round(perFrame * 1000) / 1000 : null,
|
|
138
|
+
etaSeconds: perFrame ? Math.round(perFrame * Math.max(totalFrames - seenFrames, 0)) : null } })
|
|
139
|
+
} })
|
|
140
|
+
if (scale > 1) {
|
|
141
|
+
send({ type: 'progress', phase: 'encoding', progress: .96 })
|
|
142
|
+
// 这一趟本来就是重编码,顺手把 bt709 三标签补全。
|
|
143
|
+
// 注意:这个 ffmpeg 构建里 -color_primaries / -color_trc 当**输出选项**不生效
|
|
144
|
+
// (连续实测:只剩 colorspace 标签,primaries/trc 仍是 unknown),必须走 setparams 滤镜改帧元数据。
|
|
145
|
+
const colorFilter = settings.colorSpace === 'bt709'
|
|
146
|
+
? ',setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709' : ''
|
|
147
|
+
await runFfmpeg(['-i', rawOutput,
|
|
148
|
+
'-vf', 'scale=' + composition.width + ':' + composition.height + ':flags=lanczos+accurate_rnd+full_chroma_int' + colorFilter,
|
|
149
|
+
'-c:v', 'libx264', '-crf', String(settings.crf || 14), '-preset', String(settings.preset || 'slow'),
|
|
150
|
+
'-profile:v', 'high', '-pix_fmt', 'yuv420p',
|
|
151
|
+
'-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
152
|
+
'-c:a', 'copy', '-movflags', '+faststart', finalOutput],
|
|
153
|
+
{ cwd: dirname(finalOutput), log: join(dirname(finalOutput), 'encode.log'), timeoutMs: 900000 })
|
|
154
|
+
rmSync(rawOutput, { force: true })
|
|
155
|
+
qa.supersample = { from: composition.width * scale + 'x' + composition.height * scale, to: composition.width + 'x' + composition.height }
|
|
156
|
+
qa.colorTags = settings.colorSpace === 'bt709' ? 'bt709(三标签齐全)' : '未标注'
|
|
157
|
+
} else if (settings.colorSpace === 'bt709') {
|
|
158
|
+
// Remotion 只写 colorspace,primaries/trc 留 unknown;要齐全只能再重编一次,
|
|
159
|
+
// 而重编会让画质掉一代——所以 1× 下宁可把这件事说清楚,不偷偷多编一遍。
|
|
160
|
+
qa.colorTags = '仅 colorspace=bt709(primaries/trc 为 unknown;母版级标签请设 render.scale=2)'
|
|
161
|
+
qa.warnings.push('色彩标签只写了 colorspace:要 primaries/trc 齐全(严格 QC)请用 render.scale=2,那一趟重编码会一并补齐')
|
|
162
|
+
}
|
|
163
|
+
const posterShot = request.composition.shots.find((shot) => shot.referenceIds.length) || request.composition.shots[0]
|
|
164
|
+
await renderStill({ serveUrl, composition, inputProps, output: request.poster,
|
|
165
|
+
frame: posterShot.startFrame + Math.floor(posterShot.durationFrames / 2), puppeteerInstance: browser, browserExecutable, cancelSignal, logLevel: 'error' })
|
|
166
|
+
writeFileSync(join(qaDirectory, 'report.json'), JSON.stringify(qa, null, 2))
|
|
167
|
+
send({ type: 'done', qa })
|
|
168
|
+
} catch (error) { send({ type: 'error', error: error.message || String(error) }); process.exitCode = 1 }
|
|
169
|
+
finally {
|
|
170
|
+
await browser?.close({ silent: true }).catch(() => {})
|
|
171
|
+
const cleanup = relative(resolve(dirname(request.output)), resolve(outDir))
|
|
172
|
+
if (request.keepBundle !== true && cleanup === 'bundle' && !isAbsolute(cleanup)) rmSync(outDir, { recursive: true, force: true })
|
|
173
|
+
process.disconnect()
|
|
174
|
+
}
|
|
175
|
+
})
|