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 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 (renders Video.vue output.mp4)')}
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('# Custom output and duration')}
69
- ${c.highlight('pellicule')} Video.vue -o intro.mp4 -d 150
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 -d 300 -r 100:200
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
- // Parse options with defaults
156
- const fps = parseInt(values.fps || '30', 10)
157
- const durationInFrames = parseInt(values.duration || '90', 10)
158
- const width = parseInt(values.width || '1920', 10)
159
- const height = parseInt(values.height || '1080', 10)
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 options
178
- if (isNaN(fps) || fps <= 0) fail(`Invalid fps value: ${values.fps}`)
179
- if (isNaN(durationInFrames) || durationInFrames <= 0) fail(`Invalid duration value: ${values.duration}`)
180
- if (isNaN(width) || width <= 0) fail(`Invalid width value: ${values.width}`)
181
- if (isNaN(height) || height <= 0) fail(`Invalid height value: ${values.height}`)
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.2",
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
- "vite": "^5.0.0",
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"
@@ -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
+ }
@@ -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
- // Step 2: Encode to MP4
87
- const videoPath = await encodeVideo({
88
- framesDir,
89
- output,
90
- fps: renderOptions.fps || 30,
91
- silent
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
- return videoPath
101
+ return videoPath
102
+ } finally {
103
+ // Step 3: Cleanup .pellicule folder (removes frames automatically)
104
+ await cleanup()
105
+ }
95
106
  }
@@ -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
- // Ensure output directory exists
50
- await mkdir(outputDir, { recursive: true })
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(outputDir, `frame-${String(outputFrameNum).padStart(5, '0')}.png`)
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
- await cleanup()
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: outputDir, totalFrames: framesToRender }
132
+ return { framesDir, totalFrames: framesToRender, cleanup }
134
133
  }