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 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 (renders Video.vue output.mp4)')}
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('# Custom output and duration')}
69
- ${c.highlight('pellicule')} Video.vue -o intro.mp4 -d 150
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 -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
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
- // 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)
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 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}`)
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.2",
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
- "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
+ }
@@ -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
- // Step 2: Encode to MP4
87
- const videoPath = await encodeVideo({
88
- framesDir,
89
- output,
90
- fps: renderOptions.fps || 30,
91
- silent
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
- return videoPath
95
+ return videoPath
96
+ } finally {
97
+ // Step 3: Cleanup .pellicule folder (removes frames automatically)
98
+ await cleanup()
99
+ }
95
100
  }
@@ -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
  }