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.
@@ -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
- export function prepareMotionProject(composition, directory, options = {}) {
30
- validateMotionSources(composition)
31
- mkdirSync(directory, { recursive: true })
32
- const imports = [], entries = []
33
- composition.shots.forEach((shot, index) => {
34
- if (shot.recipeId !== 'scene') return
35
- const name = 'scene-' + index + '.tsx'
36
- if(!options.preserveSources)writeFileSync(join(directory, name), shot.sceneSource)
37
- for (const component of shot.sceneComponents || []) {
38
- const file = join(directory, sceneComponentPath(shot.id, component.id))
39
- mkdirSync(dirname(file), { recursive: true })
40
- if(!options.preserveSources)writeFileSync(file, component.source)
41
- }
42
- imports.push(`import Scene${index} from './${name}';`)
43
- entries.push(`${JSON.stringify(shot.id)}:Scene${index}`)
44
- })
45
- const runtime = fileURLToPath(new URL('./motion/entry.jsx', import.meta.url)).replaceAll('\\', '/')
46
- const entryPoint = join(directory, 'index.jsx')
47
- if(!options.preserveSources||options.refreshEntry||!existsSync(entryPoint))writeFileSync(entryPoint, `import {registerMotionRoot} from ${JSON.stringify(runtime)};\n${imports.join('\n')}\nregisterMotionRoot({${entries.join(',')}});\n`)
48
- if(!options.preserveSources)writeFileSync(join(directory, 'composition.json'), JSON.stringify(composition, null, 2))
49
- if (composition.production && !options.preserveSources) {
50
- writeFileSync(join(directory, 'design.json'), JSON.stringify(composition.production, null, 2))
51
- writeFileSync(join(directory, 'storyboard.json'), JSON.stringify(composition.shots.map(({ sceneSource: _source, sceneComponents, ...shot }) => ({ ...shot,
52
- ...(sceneComponents ? { components: sceneComponents.map(({ source: _code, ...component }) => component) } : {}) })), null, 2))
53
- }
54
- const alias = Object.fromEntries(Object.entries(shotcraftModuleMap()).map(([name, url]) => [name + '$', fileURLToPath(url)]))
55
- // Demos use paths relative to the upstream repository. Generated components
56
- // live elsewhere: resolve known library paths without rewriting their source.
57
- const library = shotcraftModuleMap()
58
- const libraryImports = { apply(compiler) {
59
- compiler.hooks.normalModuleFactory.tap('DrawAiShotcraftImports', (factory) => {
60
- factory.hooks.beforeResolve.tap('DrawAiShotcraftImports', (request) => {
61
- if (!request?.request?.startsWith('.')) return
62
- const local = relative(resolve(directory), resolve(request.context))
63
- if (isAbsolute(local) || local === '..' || /^\.\.[\\/]/.test(local)) return
64
- const stem = resolve(request.context, request.request)
65
- if (['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.js'].some((ext) => existsSync(stem + ext))) return
66
- const path = request.request.replaceAll('\\', '/').replace(/^(?:\.\.?\/)+/, '').replace(/\.(tsx?|jsx?)$/, '')
67
- const name = path.startsWith('demos/') ? '@shotcraft/' + path
68
- : path.startsWith('_fixtures/') ? '@shotcraft/demos/' + path
69
- : path.startsWith('assets/lib/') ? '@shotcraft/lib/' + path.slice('assets/lib/'.length)
70
- : path.startsWith('lib/') ? '@shotcraft/' + path : ''
71
- if (Object.hasOwn(library, name)) request.request = fileURLToPath(library[name])
72
- })
73
- })
74
- } }
75
- alias['@drawai/motion$'] = fileURLToPath(new URL('./motion/reference-media.jsx', import.meta.url))
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
- } }
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 Windows browser before downloading Chromium.
47
- const candidates = [request.browserExecutable, process.env.DRAWAI_CHROME_PATH,
48
- 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe']
49
- const browserExecutable = candidates.find((path) => path && existsSync(path))
50
- browser = await openBrowser('chrome', { browserExecutable, logLevel: 'error' })
51
- if (cancelled) throw new Error('已取消动效生成')
52
- const inputProps = { plan: request.composition, assets: request.assets }
53
- const composition = await selectComposition({ serveUrl, id: 'DrawAiMotion', inputProps, browserInstance: browser })
54
- if(request.operation==='compile'){send({type:'done',compiled:true});return}
55
- send({ type: 'progress', phase: 'reviewing', progress: .1 })
56
- const qaDirectory = request.qaDirectory || join(dirname(request.output), 'qa'); mkdirSync(qaDirectory, { recursive: true })
57
- const audioCues = Array.isArray(request.composition?.audio) ? request.composition.audio : []
58
- const qa = { status: 'render-checks-passed', visualReview: 'manual-required', audio: audioCues.length ? 'mixed:' + audioCues.length : 'silent', frames: [], warnings: [] }
59
- if(request.operation==='still'){
60
- const frames=[]
61
- for(const frame of request.frames){
62
- if(!Number.isInteger(frame)||frame<0||frame>=composition.durationInFrames)throw new Error('截图帧超出成片')
63
- const filename='agent-frame-'+frame+'.png'
64
- await renderStill({serveUrl,composition,inputProps,output:join(qaDirectory,filename),frame,browserInstance:browser,cancelSignal,logLevel:'error'})
65
- frames.push({frame,filename,sha256:createHash('sha256').update(readFileSync(join(qaDirectory,filename))).digest('hex')})
66
- }
67
- send({type:'done',frames});return
68
- }
69
- for (const shot of request.composition.shots) {
70
- const hashes = []
71
- for (const fraction of (shot.motionCards?.length ? [0, .25, .5, .75, .98] : [.25, .7])) {
72
- const frame = shot.startFrame + Math.min(shot.durationFrames - 1, Math.floor(shot.durationFrames * fraction))
73
- const filename = shot.id + '-' + frame + '.png'
74
- await renderStill({ serveUrl, composition, inputProps, output: join(qaDirectory, filename), frame, browserInstance: browser, cancelSignal, logLevel: 'error', timeoutInMilliseconds: 30000 })
75
- const bytes = readFileSync(join(qaDirectory, filename))
76
- const hash = createHash('sha256').update(bytes).digest('hex'); hashes.push(hash)
77
- qa.frames.push({ shotId: shot.id, frame, filename, fraction, sha256: hash })
78
- }
79
- if (hashes[0] === hashes[1]) qa.warnings.push(shot.id + ': 两张采样帧相同,请检查主运动或停留段')
80
- }
81
- // 渲染规格随请求进来(由 render.js 的 resolveRenderSettings 解析过):
82
- // 编码参数、色彩标签、超采样倍数、并发都在这里唯一地落到 renderMedia 上。
83
- const settings = request.render || {}
84
- qa.render = settings
85
- writeFileSync(join(qaDirectory, 'report.json'), JSON.stringify(qa, null, 2))
86
- const scale = Number(settings.scale) > 1 ? Math.round(Number(settings.scale)) : 1
87
- const finalOutput = request.output
88
- // 超采样:Remotion 的 scale 是「渲成更大」,不会替你缩回来。
89
- // 所以大图先落到临时文件,再用一次 lanczos + bt709 的母版编码缩回目标尺寸。
90
- const rawOutput = scale > 1 ? join(dirname(finalOutput), '.supersample.mp4') : finalOutput
91
- const startedAt = Date.now()
92
- let seenFrames = 0
93
- const totalFrames = request.frameRange
94
- ? request.frameRange[1] - request.frameRange[0] + 1
95
- : composition.durationInFrames
96
- await renderMedia({ serveUrl, composition, inputProps, outputLocation: rawOutput,
97
- codec: settings.codec || 'h264',
98
- ...(settings.crf ? { crf: settings.crf } : {}),
99
- ...(settings.preset ? { x264Preset: settings.preset } : {}),
100
- ...(settings.pixelFormat ? { pixelFormat: settings.pixelFormat } : {}),
101
- ...(settings.colorSpace ? { colorSpace: settings.colorSpace } : {}),
102
- ...(settings.audioBitrate ? { audioBitrate: settings.audioBitrate } : {}),
103
- scale,
104
- concurrency: settings.concurrency || 2,
105
- ...(request.frameRange?{frameRange:request.frameRange}:{}),
106
- browserInstance: browser, cancelSignal, logLevel: 'error',
107
- onProgress: ({ progress, renderedFrames }) => {
108
- const done = Number(renderedFrames) || 0
109
- if (done > seenFrames) seenFrames = done
110
- const elapsed = (Date.now() - startedAt) / 1000
111
- const perFrame = seenFrames > 0 ? elapsed / seenFrames : null
112
- // 单帧耗时是「样本数该开多大」的唯一依据:没有它,超采样只能靠猜。
113
- send({ type: 'progress', phase: 'rendering', progress: .1 + progress * .85,
114
- frames: { done: seenFrames, total: totalFrames,
115
- perFrameSeconds: perFrame ? Math.round(perFrame * 1000) / 1000 : null,
116
- etaSeconds: perFrame ? Math.round(perFrame * Math.max(totalFrames - seenFrames, 0)) : null } })
117
- } })
118
- if (scale > 1) {
119
- send({ type: 'progress', phase: 'encoding', progress: .96 })
120
- // 这一趟本来就是重编码,顺手把 bt709 三标签补全。
121
- // 注意:这个 ffmpeg 构建里 -color_primaries / -color_trc 当**输出选项**不生效
122
- // (连续实测:只剩 colorspace 标签,primaries/trc 仍是 unknown),必须走 setparams 滤镜改帧元数据。
123
- const colorFilter = settings.colorSpace === 'bt709'
124
- ? ',setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709' : ''
125
- await runFfmpeg(['-i', rawOutput,
126
- '-vf', 'scale=' + composition.width + ':' + composition.height + ':flags=lanczos+accurate_rnd+full_chroma_int' + colorFilter,
127
- '-c:v', 'libx264', '-crf', String(settings.crf || 14), '-preset', String(settings.preset || 'slow'),
128
- '-profile:v', 'high', '-pix_fmt', 'yuv420p',
129
- '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
130
- '-c:a', 'copy', '-movflags', '+faststart', finalOutput],
131
- { cwd: dirname(finalOutput), log: join(dirname(finalOutput), 'encode.log'), timeoutMs: 900000 })
132
- rmSync(rawOutput, { force: true })
133
- qa.supersample = { from: composition.width * scale + 'x' + composition.height * scale, to: composition.width + 'x' + composition.height }
134
- qa.colorTags = settings.colorSpace === 'bt709' ? 'bt709(三标签齐全)' : '未标注'
135
- } else if (settings.colorSpace === 'bt709') {
136
- // Remotion 只写 colorspace,primaries/trc 留 unknown;要齐全只能再重编一次,
137
- // 而重编会让画质掉一代——所以 1× 下宁可把这件事说清楚,不偷偷多编一遍。
138
- qa.colorTags = '仅 colorspace=bt709(primaries/trc 为 unknown;母版级标签请设 render.scale=2)'
139
- qa.warnings.push('色彩标签只写了 colorspace:要 primaries/trc 齐全(严格 QC)请用 render.scale=2,那一趟重编码会一并补齐')
140
- }
141
- const posterShot = request.composition.shots.find((shot) => shot.referenceIds.length) || request.composition.shots[0]
142
- await renderStill({ serveUrl, composition, inputProps, output: request.poster,
143
- frame: posterShot.startFrame + Math.floor(posterShot.durationFrames / 2), browserInstance: browser, cancelSignal, logLevel: 'error' })
144
- writeFileSync(join(qaDirectory, 'report.json'), JSON.stringify(qa, null, 2))
145
- send({ type: 'done', qa })
146
- } catch (error) { send({ type: 'error', error: error.message || String(error) }); process.exitCode = 1 }
147
- finally {
148
- await browser?.close({ silent: true }).catch(() => {})
149
- const cleanup = relative(resolve(dirname(request.output)), resolve(outDir))
150
- if (request.keepBundle !== true && cleanup === 'bundle' && !isAbsolute(cleanup)) rmSync(outDir, { recursive: true, force: true })
151
- process.disconnect()
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
+ })