pellicule 0.0.0 → 0.0.1

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/package.json CHANGED
@@ -1,42 +1,34 @@
1
1
  {
2
2
  "name": "pellicule",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Deterministic video rendering with Vue",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./render": "./src/render.js"
10
+ },
5
11
  "keywords": [
6
12
  "vue",
7
13
  "video",
8
14
  "rendering",
9
15
  "animation",
10
16
  "frames",
11
- "remotion",
12
17
  "programmatic-video"
13
18
  ],
14
- "author": "",
19
+ "author": "Kelvin Omereshone <kelvin@sailscasts.com>",
15
20
  "license": "MIT",
21
+ "homepage": "https://docs.sailscasts.com/pellicule",
16
22
  "repository": {
17
23
  "type": "git",
18
- "url": ""
24
+ "url": "https://github.com/sailscastshq/pellicule"
19
25
  },
20
- "homepage": "",
21
- "type": "module",
22
- "main": "./dist/index.cjs",
23
- "module": "./dist/index.js",
24
- "types": "./dist/index.d.ts",
25
- "exports": {
26
- ".": {
27
- "import": "./dist/index.js",
28
- "require": "./dist/index.cjs",
29
- "types": "./dist/index.d.ts"
30
- }
26
+ "dependencies": {
27
+ "vite": "^5.0.0",
28
+ "@vitejs/plugin-vue": "^5.0.0",
29
+ "playwright": "^1.40.0"
31
30
  },
32
- "files": [
33
- "dist"
34
- ],
35
31
  "peerDependencies": {
36
32
  "vue": "^3.0.0"
37
- },
38
- "devDependencies": {
39
- "vue": "^3.4.0",
40
- "typescript": "^5.0.0"
41
33
  }
42
34
  }
@@ -0,0 +1,124 @@
1
+ import { createServer } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+ import { writeFile, mkdir, rm } from 'fs/promises'
4
+ import { join, resolve, dirname, basename } from 'path'
5
+ import { fileURLToPath } from 'url'
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url))
8
+ const pelliculeSrc = resolve(__dirname, '..')
9
+
10
+ /**
11
+ * Creates a Vite dev server in the user's project directory.
12
+ * This way Vite naturally finds their node_modules with Vue installed.
13
+ *
14
+ * @param {object} options
15
+ * @param {string} options.input - Path to the .vue file
16
+ * @param {number} options.width - Video width
17
+ * @param {number} options.height - Video height
18
+ * @returns {Promise<{ server: object, url: string, cleanup: function }>}
19
+ */
20
+ export async function createVideoServer(options) {
21
+ const { input, width = 1920, height = 1080 } = options
22
+
23
+ const inputPath = resolve(input)
24
+ const inputDir = dirname(inputPath)
25
+ const inputFile = basename(inputPath)
26
+
27
+ // Create .pellicule temp folder in user's project
28
+ const tempDir = join(inputDir, '.pellicule')
29
+ await mkdir(tempDir, { recursive: true })
30
+
31
+ // Create entry HTML
32
+ const htmlContent = `<!DOCTYPE html>
33
+ <html>
34
+ <head>
35
+ <meta charset="UTF-8">
36
+ <style>
37
+ * { margin: 0; padding: 0; box-sizing: border-box; }
38
+ html, body { width: ${width}px; height: ${height}px; overflow: hidden; }
39
+ #app { width: 100%; height: 100%; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div id="app"></div>
44
+ <script type="module" src="./entry.js"></script>
45
+ </body>
46
+ </html>`
47
+
48
+ // Create entry JS with setFrame function for fast frame updates
49
+ const entryContent = `
50
+ import { createApp, ref, provide, h, nextTick } from 'vue'
51
+ import VideoComponent from '../${inputFile}'
52
+
53
+ // Pellicule injection keys (must match composables.js)
54
+ const FRAME_KEY = Symbol.for('pellicule-frame')
55
+ const CONFIG_KEY = Symbol.for('pellicule-config')
56
+
57
+ // Get initial config from URL
58
+ const params = new URLSearchParams(window.location.search)
59
+ const fps = parseInt(params.get('fps') || '30', 10)
60
+ const durationInFrames = parseInt(params.get('duration') || '90', 10)
61
+ const width = parseInt(params.get('width') || '${width}', 10)
62
+ const height = parseInt(params.get('height') || '${height}', 10)
63
+
64
+ const config = { fps, durationInFrames, width, height }
65
+
66
+ // Frame ref - reactive, will trigger re-render when changed
67
+ const frameRef = ref(0)
68
+
69
+ // Expose setFrame function for the renderer to call
70
+ window.__PELLICULE_SET_FRAME__ = async (frame) => {
71
+ frameRef.value = frame
72
+ await nextTick() // Wait for Vue to re-render
73
+ }
74
+
75
+ try {
76
+ // Create app with frame context
77
+ const app = createApp({
78
+ setup() {
79
+ provide(FRAME_KEY, frameRef)
80
+ provide(CONFIG_KEY, config)
81
+ return () => h(VideoComponent)
82
+ }
83
+ })
84
+
85
+ app.mount('#app')
86
+ window.__PELLICULE_READY__ = true
87
+ } catch (error) {
88
+ console.error('Pellicule render error:', error)
89
+ window.__PELLICULE_READY__ = true
90
+ window.__PELLICULE_ERROR__ = error.message
91
+ }
92
+ `
93
+
94
+ await writeFile(join(tempDir, 'index.html'), htmlContent)
95
+ await writeFile(join(tempDir, 'entry.js'), entryContent)
96
+
97
+ // Create Vite server rooted in the user's project directory
98
+ const server = await createServer({
99
+ root: tempDir,
100
+ plugins: [vue()],
101
+ server: {
102
+ port: 0,
103
+ strictPort: false
104
+ },
105
+ resolve: {
106
+ alias: {
107
+ 'pellicule': pelliculeSrc
108
+ }
109
+ },
110
+ logLevel: 'warn'
111
+ })
112
+
113
+ await server.listen()
114
+
115
+ const address = server.httpServer.address()
116
+ const url = `http://localhost:${address.port}`
117
+
118
+ const cleanup = async () => {
119
+ await server.close()
120
+ await rm(tempDir, { recursive: true, force: true })
121
+ }
122
+
123
+ return { server, url, cleanup, tempDir }
124
+ }
@@ -0,0 +1,51 @@
1
+ import { inject, computed } from 'vue'
2
+
3
+ /**
4
+ * Injection keys for Pellicule context
5
+ * Using Symbol.for() so keys are shared across module instances
6
+ */
7
+ export const FRAME_KEY = Symbol.for('pellicule-frame')
8
+ export const CONFIG_KEY = Symbol.for('pellicule-config')
9
+
10
+ /**
11
+ * Get the current frame number.
12
+ * Must be used within a Pellicule render context.
13
+ *
14
+ * @returns {import('vue').ComputedRef<number>} Current frame number
15
+ *
16
+ * @example
17
+ * const frame = useFrame()
18
+ * const opacity = computed(() => frame.value / 30) // fade in over 1 second at 30fps
19
+ */
20
+ export function useFrame() {
21
+ const frame = inject(FRAME_KEY)
22
+
23
+ if (frame === undefined) {
24
+ throw new Error(
25
+ 'useFrame() must be used within a Pellicule render context'
26
+ )
27
+ }
28
+
29
+ return computed(() => frame.value)
30
+ }
31
+
32
+ /**
33
+ * Get the video configuration (fps, duration, dimensions).
34
+ * Must be used within a Pellicule render context.
35
+ *
36
+ * @returns {{ fps: number, durationInFrames: number, width: number, height: number }}
37
+ *
38
+ * @example
39
+ * const { fps, durationInFrames, width, height } = useVideoConfig()
40
+ */
41
+ export function useVideoConfig() {
42
+ const config = inject(CONFIG_KEY)
43
+
44
+ if (!config) {
45
+ throw new Error(
46
+ 'useVideoConfig() must be used within a Pellicule render context'
47
+ )
48
+ }
49
+
50
+ return config
51
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pellicule - Deterministic video rendering with Vue
3
+ *
4
+ * This is the browser-safe entry point.
5
+ * For rendering (Node.js), import from 'pellicule/render'
6
+ */
7
+
8
+ // Composables
9
+ export { useFrame, useVideoConfig, FRAME_KEY, CONFIG_KEY } from './composables.js'
10
+
11
+ // Animation utilities
12
+ export { interpolate, sequence, Easing } from './math.js'
package/src/math.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Easing functions for smooth animations
3
+ */
4
+ export const Easing = {
5
+ linear: (t) => t,
6
+ easeIn: (t) => t * t,
7
+ easeOut: (t) => t * (2 - t),
8
+ easeInOut: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)
9
+ }
10
+
11
+ /**
12
+ * Maps a value from one range to another.
13
+ * The core animation primitive in Pellicule.
14
+ *
15
+ * @param {number} frame - Current frame number
16
+ * @param {[number, number]} inputRange - [startFrame, endFrame]
17
+ * @param {[number, number]} outputRange - [startValue, endValue]
18
+ * @param {object} options - Optional settings
19
+ * @param {function} options.easing - Easing function (default: linear)
20
+ * @param {'clamp'|'extend'} options.extrapolate - Behavior outside input range
21
+ * @returns {number} Interpolated value
22
+ *
23
+ * @example
24
+ * // Fade in over first 30 frames
25
+ * const opacity = interpolate(frame, [0, 30], [0, 1])
26
+ *
27
+ * @example
28
+ * // Slide from left with easing
29
+ * const x = interpolate(frame, [0, 60], [-100, 0], { easing: Easing.easeOut })
30
+ */
31
+ export function interpolate(frame, inputRange, outputRange, options = {}) {
32
+ const { easing = Easing.linear, extrapolate = 'clamp' } = options
33
+
34
+ const [inStart, inEnd] = inputRange
35
+ const [outStart, outEnd] = outputRange
36
+
37
+ // Calculate progress (0 to 1)
38
+ let progress = (frame - inStart) / (inEnd - inStart)
39
+
40
+ // Handle extrapolation
41
+ if (extrapolate === 'clamp') {
42
+ progress = Math.max(0, Math.min(1, progress))
43
+ }
44
+
45
+ // Apply easing
46
+ const easedProgress = easing(progress)
47
+
48
+ // Map to output range
49
+ return outStart + (outEnd - outStart) * easedProgress
50
+ }
51
+
52
+ /**
53
+ * Creates a sequence of timed animations.
54
+ * Useful for staggered or chained animations.
55
+ *
56
+ * @param {number} frame - Current frame number
57
+ * @param {Array<{start: number, end: number, from: number, to: number}>} sequence
58
+ * @returns {number} Current value in the sequence
59
+ *
60
+ * @example
61
+ * const scale = sequence(frame, [
62
+ * { start: 0, end: 15, from: 0, to: 1 }, // grow
63
+ * { start: 45, end: 60, from: 1, to: 0 } // shrink
64
+ * ])
65
+ */
66
+ export function sequence(frame, steps) {
67
+ for (const step of steps) {
68
+ if (frame >= step.start && frame <= step.end) {
69
+ return interpolate(frame, [step.start, step.end], [step.from, step.to])
70
+ }
71
+ if (frame < step.start) {
72
+ return step.from
73
+ }
74
+ }
75
+ // Return last value if past all steps
76
+ const lastStep = steps[steps.length - 1]
77
+ return lastStep ? lastStep.to : 0
78
+ }
package/src/render.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Pellicule Renderer - Node.js only
3
+ *
4
+ * This module requires Node.js and cannot run in the browser.
5
+ */
6
+
7
+ export { renderVideo } from './renderer/render.js'
8
+ export { encodeVideo, renderToMp4 } from './renderer/encode.js'
@@ -0,0 +1,95 @@
1
+ import { spawn } from 'child_process'
2
+ import path from 'path'
3
+
4
+ /**
5
+ * Encodes PNG frames into an MP4 video using FFmpeg.
6
+ *
7
+ * @param {object} options
8
+ * @param {string} options.framesDir - Directory containing frame-XXXXX.png files
9
+ * @param {string} options.output - Output video path (default: './output.mp4')
10
+ * @param {number} options.fps - Frames per second (default: 30)
11
+ * @param {boolean} options.silent - Suppress console output (default: false)
12
+ * @returns {Promise<string>} Path to the output video
13
+ */
14
+ export function encodeVideo(options) {
15
+ const {
16
+ framesDir,
17
+ output = './output.mp4',
18
+ fps = 30,
19
+ silent = false
20
+ } = options
21
+
22
+ return new Promise((resolve, reject) => {
23
+ const framePattern = path.join(framesDir, 'frame-%05d.png')
24
+
25
+ const args = [
26
+ '-y', // Overwrite output
27
+ '-framerate', String(fps),
28
+ '-i', framePattern,
29
+ '-c:v', 'libx264',
30
+ '-pix_fmt', 'yuv420p', // Compatibility
31
+ '-preset', 'fast',
32
+ output
33
+ ]
34
+
35
+ if (!silent) {
36
+ console.log(`Encoding video: ffmpeg ${args.join(' ')}`)
37
+ }
38
+
39
+ const ffmpeg = spawn('ffmpeg', args)
40
+
41
+ ffmpeg.stderr.on('data', (data) => {
42
+ // FFmpeg outputs progress to stderr
43
+ if (!silent) {
44
+ const line = data.toString()
45
+ if (line.includes('frame=')) {
46
+ process.stdout.write(`\r${line.trim()}`)
47
+ }
48
+ }
49
+ })
50
+
51
+ ffmpeg.on('close', (code) => {
52
+ if (!silent) {
53
+ console.log('') // New line after progress
54
+ }
55
+ if (code === 0) {
56
+ if (!silent) {
57
+ console.log(`Video saved to ${output}`)
58
+ }
59
+ resolve(output)
60
+ } else {
61
+ reject(new Error(`FFmpeg exited with code ${code}`))
62
+ }
63
+ })
64
+
65
+ ffmpeg.on('error', (err) => {
66
+ reject(new Error(`ffmpeg error: ${err.message}. Is ffmpeg installed?`))
67
+ })
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Full render pipeline: Vue component → frames → MP4
73
+ *
74
+ * @param {object} options - Same as renderVideo, plus output path
75
+ * @param {boolean} options.silent - Suppress console output (default: false)
76
+ * @returns {Promise<string>} Path to the output video
77
+ */
78
+ export async function renderToMp4(options) {
79
+ const { renderVideo } = await import('./render.js')
80
+
81
+ const { output = './output.mp4', silent = false, ...renderOptions } = options
82
+
83
+ // Step 1: Render frames
84
+ const { framesDir } = await renderVideo({ ...renderOptions, silent })
85
+
86
+ // Step 2: Encode to MP4
87
+ const videoPath = await encodeVideo({
88
+ framesDir,
89
+ output,
90
+ fps: renderOptions.fps || 30,
91
+ silent
92
+ })
93
+
94
+ return videoPath
95
+ }
@@ -0,0 +1,120 @@
1
+ import { chromium } from 'playwright'
2
+ import { createVideoServer } from '../bundler/vite.js'
3
+ import { mkdir } from 'fs/promises'
4
+ import { join } from 'path'
5
+
6
+ /**
7
+ * Renders a .vue component to video frames.
8
+ *
9
+ * @param {object} options
10
+ * @param {string} options.input - Path to the .vue file
11
+ * @param {number} options.fps - Frames per second (default: 30)
12
+ * @param {number} options.durationInFrames - Total frames to render
13
+ * @param {number} options.width - Video width in pixels (default: 1920)
14
+ * @param {number} options.height - Video height in pixels (default: 1080)
15
+ * @param {string} options.outputDir - Directory for frame images (default: './frames')
16
+ * @param {function} options.onProgress - Progress callback
17
+ * @returns {Promise<{ framesDir: string, totalFrames: number }>}
18
+ */
19
+ export async function renderVideo(options) {
20
+ const {
21
+ input,
22
+ fps = 30,
23
+ durationInFrames,
24
+ width = 1920,
25
+ height = 1080,
26
+ outputDir = './frames',
27
+ onProgress,
28
+ silent = false
29
+ } = options
30
+
31
+ const log = silent ? () => {} : console.log.bind(console)
32
+
33
+ const startTime = Date.now()
34
+
35
+ // Start Vite server with the video component
36
+ log(`Starting Vite server for ${input}...`)
37
+ const viteStart = Date.now()
38
+ const { url, cleanup } = await createVideoServer({ input, width, height })
39
+ log(`Server ready in ${Date.now() - viteStart}ms`)
40
+
41
+ // Ensure output directory exists
42
+ await mkdir(outputDir, { recursive: true })
43
+
44
+ // Launch browser
45
+ const browser = await chromium.launch()
46
+ const context = await browser.newContext({
47
+ viewport: { width, height },
48
+ deviceScaleFactor: 1
49
+ })
50
+ const page = await context.newPage()
51
+
52
+ // Capture console errors (only log if not silent)
53
+ page.on('console', msg => {
54
+ if (msg.type() === 'error' && !silent) {
55
+ console.error('Browser error:', msg.text())
56
+ }
57
+ })
58
+
59
+ page.on('pageerror', error => {
60
+ if (!silent) {
61
+ console.error('Page error:', error.message)
62
+ }
63
+ })
64
+
65
+ log(`Rendering ${durationInFrames} frames at ${fps}fps (${width}x${height})`)
66
+
67
+ try {
68
+ // Load page ONCE with config
69
+ const pageUrl = `${url}?fps=${fps}&duration=${durationInFrames}&width=${width}&height=${height}`
70
+ await page.goto(pageUrl, { waitUntil: 'networkidle' })
71
+
72
+ // Wait for Vue to mount
73
+ await page.waitForFunction(() => window.__PELLICULE_READY__ === true, { timeout: 10000 })
74
+
75
+ // Check for errors
76
+ const error = await page.evaluate(() => window.__PELLICULE_ERROR__)
77
+ if (error) {
78
+ throw new Error(`Render error: ${error}`)
79
+ }
80
+
81
+ const renderStart = Date.now()
82
+
83
+ // Render each frame by updating the frame ref (no page reload!)
84
+ for (let frame = 0; frame < durationInFrames; frame++) {
85
+ // Update frame number - Vue reactivity handles re-render
86
+ await page.evaluate((f) => window.__PELLICULE_SET_FRAME__(f), frame)
87
+
88
+ // Screenshot
89
+ const framePath = join(outputDir, `frame-${String(frame).padStart(5, '0')}.png`)
90
+ await page.screenshot({ path: framePath })
91
+
92
+ // Progress callback
93
+ if (onProgress) {
94
+ const elapsed = Date.now() - renderStart
95
+ const currentFps = (frame + 1) / (elapsed / 1000)
96
+ onProgress({ frame, total: durationInFrames, fps: currentFps })
97
+ }
98
+
99
+ // Log progress every 10 frames
100
+ if (frame % 10 === 0 || frame === durationInFrames - 1) {
101
+ const percent = Math.round(((frame + 1) / durationInFrames) * 100)
102
+ const elapsed = Date.now() - renderStart
103
+ const framesPerSec = ((frame + 1) / (elapsed / 1000)).toFixed(1)
104
+ log(`Frame ${frame + 1}/${durationInFrames} (${percent}%) - ${framesPerSec} fps`)
105
+ }
106
+ }
107
+
108
+ const renderTime = Date.now() - renderStart
109
+ log(`Rendered ${durationInFrames} frames in ${renderTime}ms (${(durationInFrames / (renderTime / 1000)).toFixed(1)} fps)`)
110
+
111
+ } finally {
112
+ await browser.close()
113
+ await cleanup()
114
+ }
115
+
116
+ log(`Total time: ${Date.now() - startTime}ms`)
117
+ log(`Frames saved to ${outputDir}`)
118
+
119
+ return { framesDir: outputDir, totalFrames: durationInFrames }
120
+ }
package/dist/index.cjs DELETED
@@ -1,6 +0,0 @@
1
- /**
2
- * Pellicule - Deterministic video rendering with Vue
3
- */
4
- "use strict";
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.VERSION = '0.0.0';
package/dist/index.d.ts DELETED
@@ -1,4 +0,0 @@
1
- /**
2
- * Pellicule - Deterministic video rendering with Vue
3
- */
4
- export declare const VERSION = "0.0.0";
package/dist/index.js DELETED
@@ -1,4 +0,0 @@
1
- /**
2
- * Pellicule - Deterministic video rendering with Vue
3
- */
4
- export const VERSION = '0.0.0';