pellicule 0.0.2 → 0.0.3
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 +42 -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 +15 -10
- 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,37 @@ ${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)')}
|
|
58
59
|
${c.info('--help')} Show this help message
|
|
59
60
|
${c.info('--version')} Show version number
|
|
60
61
|
|
|
62
|
+
${c.bold('COMPONENT CONFIG')}
|
|
63
|
+
Use ${c.highlight('defineVideoConfig')} in your component to set defaults:
|
|
64
|
+
|
|
65
|
+
${c.dim('defineVideoConfig({ durationInSeconds: 5 })')}
|
|
66
|
+
|
|
67
|
+
No import needed - it's a compiler macro like Vue's defineProps.
|
|
68
|
+
Then just run: ${c.highlight('pellicule')} ${c.dim('(no flags needed!)')}
|
|
69
|
+
|
|
61
70
|
${c.bold('EXAMPLES')}
|
|
62
|
-
${c.dim('# Zero-config (
|
|
71
|
+
${c.dim('# Zero-config (uses defineVideoConfig from component)')}
|
|
63
72
|
${c.highlight('pellicule')}
|
|
64
73
|
|
|
65
74
|
${c.dim('# Specify input file (.vue extension is optional)')}
|
|
66
75
|
${c.highlight('pellicule')} MyVideo
|
|
67
76
|
|
|
68
|
-
${c.dim('#
|
|
69
|
-
${c.highlight('pellicule')} Video.vue -
|
|
77
|
+
${c.dim('# Override component config with CLI flags')}
|
|
78
|
+
${c.highlight('pellicule')} Video.vue -d 150
|
|
70
79
|
|
|
71
80
|
${c.dim('# 4K video at 60fps')}
|
|
72
81
|
${c.highlight('pellicule')} Video.vue -w 3840 -h 2160 -f 60
|
|
73
82
|
|
|
74
|
-
${c.dim('# 10 second video')}
|
|
75
|
-
${c.highlight('pellicule')} Video.vue -d 300 -f 30
|
|
76
|
-
|
|
77
83
|
${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
|
|
84
|
+
${c.highlight('pellicule')} Video.vue -r 100:200
|
|
82
85
|
|
|
83
86
|
${c.bold('DURATION HELPER')}
|
|
84
87
|
frames = seconds * fps
|
|
@@ -152,11 +155,23 @@ async function main() {
|
|
|
152
155
|
if (!existsSync(inputPath)) fail(`File not found: ${input}`)
|
|
153
156
|
if (extname(inputPath) !== '.vue') fail(`Input must be a .vue file, got: ${extname(inputPath) || '(no extension)'}`)
|
|
154
157
|
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
158
|
+
// Extract config from component (if defineVideoConfig is used)
|
|
159
|
+
const componentConfig = extractVideoConfig(inputPath)
|
|
160
|
+
|
|
161
|
+
// Build CLI flags object (only include explicitly provided values)
|
|
162
|
+
const cliFlags = {}
|
|
163
|
+
if (values.duration !== undefined) cliFlags.duration = parseInt(values.duration, 10)
|
|
164
|
+
if (values.fps !== undefined) cliFlags.fps = parseInt(values.fps, 10)
|
|
165
|
+
if (values.width !== undefined) cliFlags.width = parseInt(values.width, 10)
|
|
166
|
+
if (values.height !== undefined) cliFlags.height = parseInt(values.height, 10)
|
|
167
|
+
|
|
168
|
+
// Resolve final config: defaults < componentConfig < cliFlags
|
|
169
|
+
const resolvedConfig = resolveVideoConfig({ componentConfig, cliFlags })
|
|
170
|
+
|
|
171
|
+
const fps = resolvedConfig.fps
|
|
172
|
+
const durationInFrames = resolvedConfig.durationInFrames
|
|
173
|
+
const width = resolvedConfig.width
|
|
174
|
+
const height = resolvedConfig.height
|
|
160
175
|
const output = values.output || './output.mp4'
|
|
161
176
|
const outputPath = resolve(output)
|
|
162
177
|
|
|
@@ -174,11 +189,11 @@ async function main() {
|
|
|
174
189
|
if (isNaN(endFrame) || endFrame <= 0) fail(`Invalid end frame in range: ${endStr}`)
|
|
175
190
|
}
|
|
176
191
|
|
|
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: ${
|
|
192
|
+
// Validate resolved config
|
|
193
|
+
if (isNaN(fps) || fps <= 0) fail(`Invalid fps value: ${fps}`)
|
|
194
|
+
if (isNaN(durationInFrames) || durationInFrames <= 0) fail(`Invalid duration value: ${durationInFrames}`)
|
|
195
|
+
if (isNaN(width) || width <= 0) fail(`Invalid width value: ${width}`)
|
|
196
|
+
if (isNaN(height) || height <= 0) fail(`Invalid height value: ${height}`)
|
|
182
197
|
if (startFrame >= endFrame) fail(`Start frame (${startFrame}) must be less than end frame (${endFrame})`)
|
|
183
198
|
if (endFrame > durationInFrames) fail(`End frame (${endFrame}) exceeds duration (${durationInFrames})`)
|
|
184
199
|
|
|
@@ -191,6 +206,9 @@ async function main() {
|
|
|
191
206
|
const partialSeconds = (framesToRender / fps).toFixed(1)
|
|
192
207
|
|
|
193
208
|
console.log(` ${c.bold('Input')} ${c.info(basename(inputPath))}`)
|
|
209
|
+
if (componentConfig) {
|
|
210
|
+
console.log(` ${c.bold('Config')} ${c.highlight('defineVideoConfig')} ${c.dim('detected ✓')}`)
|
|
211
|
+
}
|
|
194
212
|
console.log(` ${c.bold('Output')} ${c.info(basename(outputPath))}`)
|
|
195
213
|
console.log(` ${c.bold('Resolution')} ${width}x${height}`)
|
|
196
214
|
console.log(` ${c.bold('Duration')} ${durationInFrames} frames @ ${fps}fps ${c.dim(`(${durationSeconds}s)`)}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pellicule",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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
|
@@ -80,16 +80,21 @@ export async function renderToMp4(options) {
|
|
|
80
80
|
|
|
81
81
|
const { output = './output.mp4', silent = false, ...renderOptions } = options
|
|
82
82
|
|
|
83
|
-
// Step 1: Render frames
|
|
84
|
-
const { framesDir } = await renderVideo({ ...renderOptions, silent })
|
|
83
|
+
// Step 1: Render frames (stored in .pellicule/frames)
|
|
84
|
+
const { framesDir, cleanup } = await renderVideo({ ...renderOptions, silent })
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
try {
|
|
87
|
+
// Step 2: Encode to MP4
|
|
88
|
+
const videoPath = await encodeVideo({
|
|
89
|
+
framesDir,
|
|
90
|
+
output,
|
|
91
|
+
fps: renderOptions.fps || 30,
|
|
92
|
+
silent
|
|
93
|
+
})
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
return videoPath
|
|
96
|
+
} finally {
|
|
97
|
+
// Step 3: Cleanup .pellicule folder (removes frames automatically)
|
|
98
|
+
await cleanup()
|
|
99
|
+
}
|
|
95
100
|
}
|
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
|
}
|