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 +14 -22
- package/src/bundler/vite.js +124 -0
- package/src/composables.js +51 -0
- package/src/index.js +12 -0
- package/src/math.js +78 -0
- package/src/render.js +8 -0
- package/src/renderer/encode.js +95 -0
- package/src/renderer/render.js +120 -0
- package/dist/index.cjs +0 -6
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -4
package/package.json
CHANGED
|
@@ -1,42 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pellicule",
|
|
3
|
-
"version": "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
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,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
package/dist/index.d.ts
DELETED
package/dist/index.js
DELETED