pellicule 0.0.2 → 0.0.4
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/bin/cli.js +59 -24
- package/package.json +4 -3
- package/src/bundler/vite.js +2 -1
- package/src/macros/define-video-config.js +146 -0
- package/src/renderer/encode.js +22 -11
- package/src/renderer/render.js +9 -10
package/bin/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { resolve, extname, basename, dirname } from 'node:path'
|
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { renderToMp4 } from '../src/render.js'
|
|
8
|
+
import { extractVideoConfig, resolveVideoConfig } from '../src/macros/define-video-config.js'
|
|
8
9
|
|
|
9
10
|
// Read version from package.json
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -50,35 +51,38 @@ ${c.bold('USAGE')}
|
|
|
50
51
|
|
|
51
52
|
${c.bold('OPTIONS')}
|
|
52
53
|
${c.info('-o, --output')} <file> Output file path ${c.dim('(default: ./output.mp4)')}
|
|
53
|
-
${c.info('-d, --duration')} <frames> Duration in frames ${c.dim('(default: 90)')}
|
|
54
|
-
${c.info('-f, --fps')} <number> Frames per second ${c.dim('(default: 30)')}
|
|
55
|
-
${c.info('-w, --width')} <pixels> Video width ${c.dim('(default: 1920)')}
|
|
56
|
-
${c.info('-h, --height')} <pixels> Video height ${c.dim('(default: 1080)')}
|
|
54
|
+
${c.info('-d, --duration')} <frames> Duration in frames ${c.dim('(default: from component or 90)')}
|
|
55
|
+
${c.info('-f, --fps')} <number> Frames per second ${c.dim('(default: from component or 30)')}
|
|
56
|
+
${c.info('-w, --width')} <pixels> Video width ${c.dim('(default: from component or 1920)')}
|
|
57
|
+
${c.info('-h, --height')} <pixels> Video height ${c.dim('(default: from component or 1080)')}
|
|
57
58
|
${c.info('-r, --range')} <start:end> Frame range for partial render ${c.dim('(e.g., 100:200)')}
|
|
59
|
+
${c.info('-a, --audio')} <file> Audio file to include ${c.dim('(mp3, wav, aac, etc.)')}
|
|
58
60
|
${c.info('--help')} Show this help message
|
|
59
61
|
${c.info('--version')} Show version number
|
|
60
62
|
|
|
63
|
+
${c.bold('COMPONENT CONFIG')}
|
|
64
|
+
Use ${c.highlight('defineVideoConfig')} in your component to set defaults:
|
|
65
|
+
|
|
66
|
+
${c.dim('defineVideoConfig({ durationInSeconds: 5 })')}
|
|
67
|
+
|
|
68
|
+
No import needed - it's a compiler macro like Vue's defineProps.
|
|
69
|
+
Then just run: ${c.highlight('pellicule')} ${c.dim('(no flags needed!)')}
|
|
70
|
+
|
|
61
71
|
${c.bold('EXAMPLES')}
|
|
62
|
-
${c.dim('# Zero-config (
|
|
72
|
+
${c.dim('# Zero-config (uses defineVideoConfig from component)')}
|
|
63
73
|
${c.highlight('pellicule')}
|
|
64
74
|
|
|
65
75
|
${c.dim('# Specify input file (.vue extension is optional)')}
|
|
66
76
|
${c.highlight('pellicule')} MyVideo
|
|
67
77
|
|
|
68
|
-
${c.dim('#
|
|
69
|
-
${c.highlight('pellicule')} Video.vue -
|
|
78
|
+
${c.dim('# Override component config with CLI flags')}
|
|
79
|
+
${c.highlight('pellicule')} Video.vue -d 150
|
|
70
80
|
|
|
71
81
|
${c.dim('# 4K video at 60fps')}
|
|
72
82
|
${c.highlight('pellicule')} Video.vue -w 3840 -h 2160 -f 60
|
|
73
83
|
|
|
74
|
-
${c.dim('# 10 second video')}
|
|
75
|
-
${c.highlight('pellicule')} Video.vue -d 300 -f 30
|
|
76
|
-
|
|
77
84
|
${c.dim('# Render only frames 100-200 (for faster iteration)')}
|
|
78
|
-
${c.highlight('pellicule')} Video.vue -
|
|
79
|
-
|
|
80
|
-
${c.dim('# Render from frame 150 to 250')}
|
|
81
|
-
${c.highlight('pellicule')} Video.vue -d 300 --range 150:250
|
|
85
|
+
${c.highlight('pellicule')} Video.vue -r 100:200
|
|
82
86
|
|
|
83
87
|
${c.bold('DURATION HELPER')}
|
|
84
88
|
frames = seconds * fps
|
|
@@ -120,6 +124,7 @@ async function main() {
|
|
|
120
124
|
width: { type: 'string', short: 'w' },
|
|
121
125
|
height: { type: 'string', short: 'h' },
|
|
122
126
|
range: { type: 'string', short: 'r' },
|
|
127
|
+
audio: { type: 'string', short: 'a' },
|
|
123
128
|
help: { type: 'boolean' },
|
|
124
129
|
version: { type: 'boolean' }
|
|
125
130
|
}
|
|
@@ -152,11 +157,34 @@ async function main() {
|
|
|
152
157
|
if (!existsSync(inputPath)) fail(`File not found: ${input}`)
|
|
153
158
|
if (extname(inputPath) !== '.vue') fail(`Input must be a .vue file, got: ${extname(inputPath) || '(no extension)'}`)
|
|
154
159
|
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
// Extract config from component (if defineVideoConfig is used)
|
|
161
|
+
const componentConfig = extractVideoConfig(inputPath)
|
|
162
|
+
|
|
163
|
+
// Resolve audio file path (CLI flag takes precedence over component config)
|
|
164
|
+
let audioPath = null
|
|
165
|
+
if (values.audio) {
|
|
166
|
+
audioPath = resolve(values.audio)
|
|
167
|
+
if (!existsSync(audioPath)) fail(`Audio file not found: ${values.audio}`)
|
|
168
|
+
} else if (componentConfig?.audio) {
|
|
169
|
+
// Resolve component audio path relative to the component file
|
|
170
|
+
audioPath = resolve(dirname(inputPath), componentConfig.audio)
|
|
171
|
+
if (!existsSync(audioPath)) fail(`Audio file not found: ${componentConfig.audio}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Build CLI flags object (only include explicitly provided values)
|
|
175
|
+
const cliFlags = {}
|
|
176
|
+
if (values.duration !== undefined) cliFlags.duration = parseInt(values.duration, 10)
|
|
177
|
+
if (values.fps !== undefined) cliFlags.fps = parseInt(values.fps, 10)
|
|
178
|
+
if (values.width !== undefined) cliFlags.width = parseInt(values.width, 10)
|
|
179
|
+
if (values.height !== undefined) cliFlags.height = parseInt(values.height, 10)
|
|
180
|
+
|
|
181
|
+
// Resolve final config: defaults < componentConfig < cliFlags
|
|
182
|
+
const resolvedConfig = resolveVideoConfig({ componentConfig, cliFlags })
|
|
183
|
+
|
|
184
|
+
const fps = resolvedConfig.fps
|
|
185
|
+
const durationInFrames = resolvedConfig.durationInFrames
|
|
186
|
+
const width = resolvedConfig.width
|
|
187
|
+
const height = resolvedConfig.height
|
|
160
188
|
const output = values.output || './output.mp4'
|
|
161
189
|
const outputPath = resolve(output)
|
|
162
190
|
|
|
@@ -174,11 +202,11 @@ async function main() {
|
|
|
174
202
|
if (isNaN(endFrame) || endFrame <= 0) fail(`Invalid end frame in range: ${endStr}`)
|
|
175
203
|
}
|
|
176
204
|
|
|
177
|
-
// Validate
|
|
178
|
-
if (isNaN(fps) || fps <= 0) fail(`Invalid fps value: ${
|
|
179
|
-
if (isNaN(durationInFrames) || durationInFrames <= 0) fail(`Invalid duration value: ${
|
|
180
|
-
if (isNaN(width) || width <= 0) fail(`Invalid width value: ${
|
|
181
|
-
if (isNaN(height) || height <= 0) fail(`Invalid height value: ${
|
|
205
|
+
// Validate resolved config
|
|
206
|
+
if (isNaN(fps) || fps <= 0) fail(`Invalid fps value: ${fps}`)
|
|
207
|
+
if (isNaN(durationInFrames) || durationInFrames <= 0) fail(`Invalid duration value: ${durationInFrames}`)
|
|
208
|
+
if (isNaN(width) || width <= 0) fail(`Invalid width value: ${width}`)
|
|
209
|
+
if (isNaN(height) || height <= 0) fail(`Invalid height value: ${height}`)
|
|
182
210
|
if (startFrame >= endFrame) fail(`Start frame (${startFrame}) must be less than end frame (${endFrame})`)
|
|
183
211
|
if (endFrame > durationInFrames) fail(`End frame (${endFrame}) exceeds duration (${durationInFrames})`)
|
|
184
212
|
|
|
@@ -191,12 +219,18 @@ async function main() {
|
|
|
191
219
|
const partialSeconds = (framesToRender / fps).toFixed(1)
|
|
192
220
|
|
|
193
221
|
console.log(` ${c.bold('Input')} ${c.info(basename(inputPath))}`)
|
|
222
|
+
if (componentConfig) {
|
|
223
|
+
console.log(` ${c.bold('Config')} ${c.highlight('defineVideoConfig')} ${c.dim('detected ✓')}`)
|
|
224
|
+
}
|
|
194
225
|
console.log(` ${c.bold('Output')} ${c.info(basename(outputPath))}`)
|
|
195
226
|
console.log(` ${c.bold('Resolution')} ${width}x${height}`)
|
|
196
227
|
console.log(` ${c.bold('Duration')} ${durationInFrames} frames @ ${fps}fps ${c.dim(`(${durationSeconds}s)`)}`)
|
|
197
228
|
if (isPartialRender) {
|
|
198
229
|
console.log(` ${c.bold('Range')} ${c.highlight(`frames ${startFrame}-${endFrame - 1}`)} ${c.dim(`(${framesToRender} frames, ${partialSeconds}s)`)}`)
|
|
199
230
|
}
|
|
231
|
+
if (audioPath) {
|
|
232
|
+
console.log(` ${c.bold('Audio')} ${c.info(basename(audioPath))}`)
|
|
233
|
+
}
|
|
200
234
|
console.log()
|
|
201
235
|
|
|
202
236
|
const startTime = Date.now()
|
|
@@ -216,6 +250,7 @@ async function main() {
|
|
|
216
250
|
width,
|
|
217
251
|
height,
|
|
218
252
|
output: outputPath,
|
|
253
|
+
audio: audioPath,
|
|
219
254
|
silent: true,
|
|
220
255
|
onProgress: ({ frame, total, fps: currentFps }) => {
|
|
221
256
|
// Clear line and print progress (stays on same line)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pellicule",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Deterministic video rendering with Vue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
"url": "https://github.com/sailscastshq/pellicule"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"
|
|
30
|
+
"@vue/compiler-sfc": "^3.0.0",
|
|
31
31
|
"@vitejs/plugin-vue": "^5.0.0",
|
|
32
|
-
"playwright": "^1.40.0"
|
|
32
|
+
"playwright": "^1.40.0",
|
|
33
|
+
"vite": "^5.0.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"vue": "^3.0.0"
|
package/src/bundler/vite.js
CHANGED
|
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
|
|
3
3
|
import { writeFile, mkdir, rm } from 'fs/promises'
|
|
4
4
|
import { join, resolve, dirname, basename } from 'path'
|
|
5
5
|
import { fileURLToPath } from 'url'
|
|
6
|
+
import { pelliculeMacroPlugin } from '../macros/define-video-config.js'
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
8
9
|
const pelliculeSrc = resolve(__dirname, '..')
|
|
@@ -97,7 +98,7 @@ try {
|
|
|
97
98
|
// Create Vite server rooted in the user's project directory
|
|
98
99
|
const server = await createServer({
|
|
99
100
|
root: tempDir,
|
|
100
|
-
plugins: [vue()],
|
|
101
|
+
plugins: [pelliculeMacroPlugin(), vue()],
|
|
101
102
|
server: {
|
|
102
103
|
port: 0,
|
|
103
104
|
strictPort: false
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* defineVideoConfig - Compile-time macro for video configuration
|
|
3
|
+
*
|
|
4
|
+
* This module provides:
|
|
5
|
+
* 1. extractVideoConfig() - CLI uses this to read config from .vue files
|
|
6
|
+
* 2. pelliculeMacroPlugin() - Vite plugin that strips the macro from output
|
|
7
|
+
*
|
|
8
|
+
* Usage in components (no import needed):
|
|
9
|
+
*
|
|
10
|
+
* defineVideoConfig({
|
|
11
|
+
* durationInSeconds: 5
|
|
12
|
+
* })
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { parse } from '@vue/compiler-sfc'
|
|
16
|
+
import { readFileSync } from 'fs'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Config Extraction (for CLI)
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract video config from a .vue file.
|
|
24
|
+
*/
|
|
25
|
+
export function extractVideoConfig(filePath) {
|
|
26
|
+
const source = readFileSync(filePath, 'utf-8')
|
|
27
|
+
return extractVideoConfigFromSource(source)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract video config from Vue SFC source code.
|
|
32
|
+
*/
|
|
33
|
+
export function extractVideoConfigFromSource(source) {
|
|
34
|
+
const { descriptor } = parse(source)
|
|
35
|
+
const scriptSetup = descriptor.scriptSetup
|
|
36
|
+
|
|
37
|
+
if (!scriptSetup) return null
|
|
38
|
+
|
|
39
|
+
const match = scriptSetup.content.match(/defineVideoConfig\s*\(\s*(\{[\s\S]*?\})\s*\)/)
|
|
40
|
+
if (!match) return null
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return parseObjectLiteral(match[1])
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn(`Failed to parse defineVideoConfig: ${error.message}`)
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a static object literal string.
|
|
52
|
+
*/
|
|
53
|
+
function parseObjectLiteral(str) {
|
|
54
|
+
const trimmed = str.trim()
|
|
55
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
|
56
|
+
throw new Error('Not an object literal')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const config = {}
|
|
60
|
+
const regex = /(\w+)\s*:\s*(-?\d+(?:\.\d+)?|true|false|'[^']*'|"[^"]*")/g
|
|
61
|
+
let match
|
|
62
|
+
|
|
63
|
+
while ((match = regex.exec(trimmed)) !== null) {
|
|
64
|
+
const key = match[1]
|
|
65
|
+
let value = match[2]
|
|
66
|
+
|
|
67
|
+
if (value === 'true') value = true
|
|
68
|
+
else if (value === 'false') value = false
|
|
69
|
+
else if (value.startsWith("'") || value.startsWith('"')) value = value.slice(1, -1)
|
|
70
|
+
else value = parseFloat(value)
|
|
71
|
+
|
|
72
|
+
config[key] = value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return config
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve final config: defaults < defineVideoConfig < CLI flags
|
|
80
|
+
*/
|
|
81
|
+
export function resolveVideoConfig({ componentConfig, cliFlags }) {
|
|
82
|
+
const defaults = { fps: 30, width: 1920, height: 1080, durationInFrames: 90 }
|
|
83
|
+
const config = { ...defaults }
|
|
84
|
+
|
|
85
|
+
if (componentConfig) {
|
|
86
|
+
if (componentConfig.durationInSeconds !== undefined) {
|
|
87
|
+
const fps = componentConfig.fps || config.fps
|
|
88
|
+
config.durationInFrames = Math.round(componentConfig.durationInSeconds * fps)
|
|
89
|
+
}
|
|
90
|
+
if (componentConfig.durationInFrames !== undefined) config.durationInFrames = componentConfig.durationInFrames
|
|
91
|
+
if (componentConfig.fps !== undefined) config.fps = componentConfig.fps
|
|
92
|
+
if (componentConfig.width !== undefined) config.width = componentConfig.width
|
|
93
|
+
if (componentConfig.height !== undefined) config.height = componentConfig.height
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (cliFlags.duration !== undefined) config.durationInFrames = cliFlags.duration
|
|
97
|
+
if (cliFlags.fps !== undefined) config.fps = cliFlags.fps
|
|
98
|
+
if (cliFlags.width !== undefined) config.width = cliFlags.width
|
|
99
|
+
if (cliFlags.height !== undefined) config.height = cliFlags.height
|
|
100
|
+
|
|
101
|
+
return config
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Vite Plugin (strips macro from compiled output)
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Vite plugin that strips defineVideoConfig() calls.
|
|
110
|
+
* Runs before Vue's compiler (enforce: 'pre').
|
|
111
|
+
*/
|
|
112
|
+
export function pelliculeMacroPlugin() {
|
|
113
|
+
return {
|
|
114
|
+
name: 'pellicule:define-video-config',
|
|
115
|
+
enforce: 'pre',
|
|
116
|
+
|
|
117
|
+
transform(code, id) {
|
|
118
|
+
if (!id.endsWith('.vue') || !code.includes('defineVideoConfig')) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let transformed = code
|
|
123
|
+
|
|
124
|
+
// Remove import of defineVideoConfig (in case user mistakenly imports it)
|
|
125
|
+
transformed = transformed.replace(
|
|
126
|
+
/import\s*\{[^}]*defineVideoConfig[^}]*\}\s*from\s*['"]pellicule['"]\s*;?\n?/g,
|
|
127
|
+
(match) => {
|
|
128
|
+
const other = match
|
|
129
|
+
.replace(/defineVideoConfig\s*,?\s*/g, '')
|
|
130
|
+
.replace(/,\s*\}/g, '}')
|
|
131
|
+
.replace(/\{\s*,/g, '{')
|
|
132
|
+
.replace(/\{\s*\}/g, '')
|
|
133
|
+
return other.includes('{') && !other.match(/\{\s*\}/) ? other : ''
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Remove the defineVideoConfig() call
|
|
138
|
+
transformed = transformed.replace(
|
|
139
|
+
/defineVideoConfig\s*\(\s*\{[\s\S]*?\}\s*\)\s*;?\n?/g,
|
|
140
|
+
''
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return transformed !== code ? { code: transformed, map: null } : null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/renderer/encode.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'path'
|
|
|
8
8
|
* @param {string} options.framesDir - Directory containing frame-XXXXX.png files
|
|
9
9
|
* @param {string} options.output - Output video path (default: './output.mp4')
|
|
10
10
|
* @param {number} options.fps - Frames per second (default: 30)
|
|
11
|
+
* @param {string|null} options.audio - Audio file path to include (default: null)
|
|
11
12
|
* @param {boolean} options.silent - Suppress console output (default: false)
|
|
12
13
|
* @returns {Promise<string>} Path to the output video
|
|
13
14
|
*/
|
|
@@ -16,6 +17,7 @@ export function encodeVideo(options) {
|
|
|
16
17
|
framesDir,
|
|
17
18
|
output = './output.mp4',
|
|
18
19
|
fps = 30,
|
|
20
|
+
audio = null,
|
|
19
21
|
silent = false
|
|
20
22
|
} = options
|
|
21
23
|
|
|
@@ -26,9 +28,11 @@ export function encodeVideo(options) {
|
|
|
26
28
|
'-y', // Overwrite output
|
|
27
29
|
'-framerate', String(fps),
|
|
28
30
|
'-i', framePattern,
|
|
31
|
+
...(audio ? ['-i', audio] : []),
|
|
29
32
|
'-c:v', 'libx264',
|
|
30
33
|
'-pix_fmt', 'yuv420p', // Compatibility
|
|
31
34
|
'-preset', 'fast',
|
|
35
|
+
...(audio ? ['-c:a', 'aac'] : []),
|
|
32
36
|
output
|
|
33
37
|
]
|
|
34
38
|
|
|
@@ -72,24 +76,31 @@ export function encodeVideo(options) {
|
|
|
72
76
|
* Full render pipeline: Vue component → frames → MP4
|
|
73
77
|
*
|
|
74
78
|
* @param {object} options - Same as renderVideo, plus output path
|
|
79
|
+
* @param {string|null} options.audio - Audio file path to include (default: null)
|
|
75
80
|
* @param {boolean} options.silent - Suppress console output (default: false)
|
|
76
81
|
* @returns {Promise<string>} Path to the output video
|
|
77
82
|
*/
|
|
78
83
|
export async function renderToMp4(options) {
|
|
79
84
|
const { renderVideo } = await import('./render.js')
|
|
80
85
|
|
|
81
|
-
const { output = './output.mp4', silent = false, ...renderOptions } = options
|
|
86
|
+
const { output = './output.mp4', audio = null, silent = false, ...renderOptions } = options
|
|
82
87
|
|
|
83
|
-
// Step 1: Render frames
|
|
84
|
-
const { framesDir } = await renderVideo({ ...renderOptions, silent })
|
|
88
|
+
// Step 1: Render frames (stored in .pellicule/frames)
|
|
89
|
+
const { framesDir, cleanup } = await renderVideo({ ...renderOptions, silent })
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
try {
|
|
92
|
+
// Step 2: Encode to MP4
|
|
93
|
+
const videoPath = await encodeVideo({
|
|
94
|
+
framesDir,
|
|
95
|
+
output,
|
|
96
|
+
fps: renderOptions.fps || 30,
|
|
97
|
+
audio,
|
|
98
|
+
silent
|
|
99
|
+
})
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
return videoPath
|
|
102
|
+
} finally {
|
|
103
|
+
// Step 3: Cleanup .pellicule folder (removes frames automatically)
|
|
104
|
+
await cleanup()
|
|
105
|
+
}
|
|
95
106
|
}
|
package/src/renderer/render.js
CHANGED
|
@@ -14,9 +14,8 @@ import { join } from 'path'
|
|
|
14
14
|
* @param {number} options.endFrame - Last frame to render, exclusive (default: durationInFrames)
|
|
15
15
|
* @param {number} options.width - Video width in pixels (default: 1920)
|
|
16
16
|
* @param {number} options.height - Video height in pixels (default: 1080)
|
|
17
|
-
* @param {string} options.outputDir - Directory for frame images (default: './frames')
|
|
18
17
|
* @param {function} options.onProgress - Progress callback
|
|
19
|
-
* @returns {Promise<{ framesDir: string, totalFrames: number }>}
|
|
18
|
+
* @returns {Promise<{ framesDir: string, totalFrames: number, cleanup: function }>}
|
|
20
19
|
*/
|
|
21
20
|
export async function renderVideo(options) {
|
|
22
21
|
const {
|
|
@@ -27,7 +26,6 @@ export async function renderVideo(options) {
|
|
|
27
26
|
endFrame,
|
|
28
27
|
width = 1920,
|
|
29
28
|
height = 1080,
|
|
30
|
-
outputDir = './frames',
|
|
31
29
|
onProgress,
|
|
32
30
|
silent = false
|
|
33
31
|
} = options
|
|
@@ -43,11 +41,12 @@ export async function renderVideo(options) {
|
|
|
43
41
|
// Start Vite server with the video component
|
|
44
42
|
log(`Starting Vite server for ${input}...`)
|
|
45
43
|
const viteStart = Date.now()
|
|
46
|
-
const { url, cleanup } = await createVideoServer({ input, width, height })
|
|
44
|
+
const { url, cleanup, tempDir } = await createVideoServer({ input, width, height })
|
|
47
45
|
log(`Server ready in ${Date.now() - viteStart}ms`)
|
|
48
46
|
|
|
49
|
-
//
|
|
50
|
-
|
|
47
|
+
// Store frames inside .pellicule (cleaned up automatically after encoding)
|
|
48
|
+
const framesDir = join(tempDir, 'frames')
|
|
49
|
+
await mkdir(framesDir, { recursive: true })
|
|
51
50
|
|
|
52
51
|
// Launch browser
|
|
53
52
|
const browser = await chromium.launch()
|
|
@@ -98,7 +97,7 @@ export async function renderVideo(options) {
|
|
|
98
97
|
|
|
99
98
|
// Screenshot - output frames are numbered from 0
|
|
100
99
|
const outputFrameNum = frame - startFrame
|
|
101
|
-
const framePath = join(
|
|
100
|
+
const framePath = join(framesDir, `frame-${String(outputFrameNum).padStart(5, '0')}.png`)
|
|
102
101
|
await page.screenshot({ path: framePath })
|
|
103
102
|
|
|
104
103
|
// Progress callback
|
|
@@ -124,11 +123,11 @@ export async function renderVideo(options) {
|
|
|
124
123
|
|
|
125
124
|
} finally {
|
|
126
125
|
await browser.close()
|
|
127
|
-
|
|
126
|
+
// Don't cleanup here - frames are needed for encoding
|
|
127
|
+
// Cleanup will be called by renderToMp4 after encoding
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
log(`Total time: ${Date.now() - startTime}ms`)
|
|
131
|
-
log(`Frames saved to ${outputDir}`)
|
|
132
131
|
|
|
133
|
-
return { framesDir
|
|
132
|
+
return { framesDir, totalFrames: framesToRender, cleanup }
|
|
134
133
|
}
|