vueseq 0.1.0

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/README.md ADDED
@@ -0,0 +1,245 @@
1
+ <p align="center">
2
+ <img src="./vueseq.svg" alt="VueSeq Logo" width="200">
3
+ </p>
4
+
5
+ # VueSeq
6
+
7
+ > **Vue Sequencer**: A minimal, deterministic video renderer for Vue 3 + GSAP, inspired by Pellicule and Remotion.
8
+
9
+ Render Vue components with GSAP animations to video. Write standard Vue + GSAP code—no special APIs to learn.
10
+
11
+ ## Features
12
+
13
+ - ✅ **Standard GSAP** - Use your existing GSAP knowledge
14
+ - ✅ **Deterministic** - Same input = identical output, every time
15
+ - ✅ **Simple CLI** - One command to render your video
16
+ - ✅ **Programmatic API** - Integrate into your build pipeline
17
+ - ✅ **Full GSAP Power** - All easing, timelines, and plugins work
18
+
19
+ ## Demo
20
+
21
+
22
+
23
+ https://github.com/user-attachments/assets/84d01c02-4b4f-4d86-879e-720a7e367967
24
+
25
+
26
+
27
+ *This video was rendered with VueSeq from [examples/HelloWorld.vue](./examples/HelloWorld.vue)*
28
+
29
+ ## Philosophy
30
+
31
+ VueSeq is intentionally minimal. We bundle only the essentials: **Vue**, **GSAP**, **Playwright**, and **Vite**.
32
+
33
+ We don't include CSS frameworks (Tailwind, UnoCSS, etc.) because:
34
+ - Every developer has their preferred styling approach
35
+ - Your project likely already has styling configured
36
+ - Video components are self-contained—vanilla CSS works great
37
+ - Less dependencies = fewer conflicts and smaller installs
38
+
39
+ **Bring your own styles.** If your project uses Tailwind, SCSS, or any other solution, it will work seamlessly with VueSeq.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ npm install vueseq
45
+ ```
46
+
47
+ ### Requirements
48
+
49
+ - Node.js 18+
50
+ - FFmpeg (for video encoding)
51
+ - macOS: `brew install ffmpeg`
52
+ - Ubuntu/Debian: `sudo apt install ffmpeg`
53
+ - Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
54
+
55
+ ## Quick Start
56
+
57
+ ### 1. Create a Video Component
58
+
59
+ Create a Vue component with GSAP animations:
60
+
61
+ ```vue
62
+ <!-- MyVideo.vue -->
63
+ <script setup>
64
+ import { onMounted, ref } from 'vue'
65
+ import gsap from 'gsap'
66
+
67
+ const boxRef = ref(null)
68
+ const textRef = ref(null)
69
+
70
+ onMounted(() => {
71
+ const tl = gsap.timeline()
72
+
73
+ tl.from(boxRef.value, {
74
+ x: -200,
75
+ opacity: 0,
76
+ duration: 1,
77
+ ease: 'power2.out'
78
+ })
79
+
80
+ tl.from(textRef.value, {
81
+ y: 50,
82
+ opacity: 0,
83
+ duration: 0.8,
84
+ ease: 'back.out'
85
+ }, '-=0.3')
86
+
87
+ tl.to(boxRef.value, {
88
+ rotation: 360,
89
+ duration: 2,
90
+ ease: 'elastic.out(1, 0.3)'
91
+ })
92
+ })
93
+ </script>
94
+
95
+ <template>
96
+ <div class="scene">
97
+ <div ref="boxRef" class="box">
98
+ <span ref="textRef">Hello GSAP!</span>
99
+ </div>
100
+ </div>
101
+ </template>
102
+
103
+ <style scoped>
104
+ .scene {
105
+ width: 100%;
106
+ height: 100%;
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
111
+ }
112
+
113
+ .box {
114
+ width: 300px;
115
+ height: 300px;
116
+ background: linear-gradient(45deg, #e94560, #0f3460);
117
+ border-radius: 20px;
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ box-shadow: 0 20px 60px rgba(233, 69, 96, 0.3);
122
+ }
123
+
124
+ span {
125
+ color: white;
126
+ font-size: 32px;
127
+ font-weight: 600;
128
+ font-family: system-ui;
129
+ }
130
+ </style>
131
+ ```
132
+
133
+ ### 2. Render to Video
134
+
135
+ ```bash
136
+ npx vueseq MyVideo.vue -d 4 -o hello.mp4
137
+ ```
138
+
139
+ That's it! Your video will be rendered at 1920x1080, 30fps.
140
+
141
+ ## CLI Options
142
+
143
+ ```
144
+ vueseq <Video.vue> [options]
145
+
146
+ Options:
147
+ -o, --output Output file (default: ./output.mp4)
148
+ -d, --duration Duration in seconds (required)
149
+ -f, --fps Frames per second (default: 30)
150
+ -w, --width Video width in pixels (default: 1920)
151
+ -H, --height Video height in pixels (default: 1080)
152
+ --help Show help message
153
+ ```
154
+
155
+ ### Examples
156
+
157
+ ```bash
158
+ # Basic render
159
+ vueseq Intro.vue -d 5 -o intro.mp4
160
+
161
+ # 4K at 60fps
162
+ vueseq Intro.vue -d 10 -f 60 -w 3840 -H 2160 -o intro-4k.mp4
163
+
164
+ # Square for social media
165
+ vueseq Story.vue -d 15 -w 1080 -H 1080 -o story.mp4
166
+ ```
167
+
168
+ ## Programmatic API
169
+
170
+ ```javascript
171
+ import { renderToMp4, renderFrames, encodeVideo } from 'vueseq'
172
+
173
+ // Render directly to MP4
174
+ await renderToMp4({
175
+ input: '/path/to/MyVideo.vue',
176
+ duration: 5,
177
+ fps: 30,
178
+ width: 1920,
179
+ height: 1080,
180
+ output: './output.mp4',
181
+ onProgress: ({ frame, total, percent }) => {
182
+ console.log(`Rendering: ${percent}%`)
183
+ }
184
+ })
185
+
186
+ // Or render frames separately for custom processing
187
+ const { framesDir, totalFrames, cleanup } = await renderFrames({
188
+ input: '/path/to/MyVideo.vue',
189
+ duration: 5,
190
+ fps: 30,
191
+ width: 1920,
192
+ height: 1080
193
+ })
194
+
195
+ // Process frames here...
196
+
197
+ await encodeVideo({ framesDir, output: './output.mp4', fps: 30 })
198
+ await cleanup()
199
+ ```
200
+
201
+ ## How It Works
202
+
203
+ VueSeq uses GSAP's deterministic timeline control:
204
+
205
+ 1. **Vite** bundles your Vue component
206
+ 2. **Playwright** opens it in headless Chrome
207
+ 3. For each frame, GSAP's `globalTimeline.seek(time)` jumps to the exact moment
208
+ 4. **Screenshot** captures the frame
209
+ 5. **FFmpeg** encodes all frames to video
210
+
211
+ This is deterministic because `seek()` applies all GSAP values synchronously—given the same time, you get the exact same DOM state every time.
212
+
213
+ ## Multi-Scene Videos
214
+
215
+ For longer videos with multiple scenes, use nested GSAP timelines:
216
+
217
+ ```javascript
218
+ onMounted(() => {
219
+ const master = gsap.timeline()
220
+
221
+ master.add(createIntro())
222
+ master.add(createMainContent(), '-=0.5') // Overlap for smooth transition
223
+ master.add(createOutro())
224
+ })
225
+
226
+ function createIntro() {
227
+ const tl = gsap.timeline()
228
+ tl.from('.title', { opacity: 0, duration: 1 })
229
+ tl.to({}, { duration: 2 }) // Hold
230
+ tl.to('.scene-intro', { opacity: 0, duration: 0.5 })
231
+ return tl
232
+ }
233
+ ```
234
+
235
+ Stack scenes with `position: absolute` and control visibility via GSAP opacity animations. See `examples/HelloWorld.vue` for a complete 4-scene example, and `AGENTS.md` for detailed patterns.
236
+
237
+ ## Tips
238
+
239
+ - **Keep animations on the `globalTimeline`** - Nested timelines work fine, they're all part of the global timeline by default
240
+ - **Avoid random values** - For deterministic renders, don't use `random()` or `Math.random()` without seeding
241
+ - **Handle callbacks carefully** - `onComplete` and similar callbacks may not fire as expected during seek
242
+
243
+ ## License
244
+
245
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * VueSeq CLI
5
+ *
6
+ * Render Vue + GSAP components to video.
7
+ *
8
+ * Usage:
9
+ * vueseq <Video.vue> [options]
10
+ *
11
+ * Example:
12
+ * vueseq MyAnimation.vue -d 5 -o my-video.mp4
13
+ */
14
+
15
+ import { parseArgs } from 'node:util'
16
+ import { resolve, extname } from 'node:path'
17
+ import { existsSync } from 'node:fs'
18
+
19
+ // Show help text
20
+ function showHelp() {
21
+ console.log(`
22
+ VueSeq - Render Vue + GSAP components to video
23
+
24
+ USAGE:
25
+ vueseq <Video.vue> [options]
26
+
27
+ OPTIONS:
28
+ -o, --output Output file (default: ./output.mp4)
29
+ -d, --duration Duration in seconds (required)
30
+ -f, --fps Frames per second (default: 30)
31
+ -w, --width Video width in pixels (default: 1920)
32
+ -H, --height Video height in pixels (default: 1080)
33
+ --help Show this help message
34
+
35
+ EXAMPLE:
36
+ vueseq MyAnimation.vue -d 5 -o my-video.mp4
37
+ vueseq Intro.vue -d 10 -f 60 -w 3840 -H 2160 -o intro-4k.mp4
38
+ `)
39
+ }
40
+
41
+ // Parse command line arguments
42
+ const { values, positionals } = parseArgs({
43
+ allowPositionals: true,
44
+ options: {
45
+ output: { type: 'string', short: 'o', default: './output.mp4' },
46
+ duration: { type: 'string', short: 'd' },
47
+ fps: { type: 'string', short: 'f', default: '30' },
48
+ width: { type: 'string', short: 'w', default: '1920' },
49
+ height: { type: 'string', short: 'H', default: '1080' },
50
+ help: { type: 'boolean' }
51
+ }
52
+ })
53
+
54
+ // Show help if requested
55
+ if (values.help) {
56
+ showHelp()
57
+ process.exit(0)
58
+ }
59
+
60
+ // Validate input file
61
+ const input = positionals[0]
62
+ if (!input) {
63
+ console.error('Error: Please specify a .vue file\n')
64
+ showHelp()
65
+ process.exit(1)
66
+ }
67
+
68
+ const inputPath = resolve(input)
69
+ if (!existsSync(inputPath)) {
70
+ console.error(`Error: File not found: ${inputPath}`)
71
+ process.exit(1)
72
+ }
73
+
74
+ if (extname(inputPath) !== '.vue') {
75
+ console.error('Error: Input must be a .vue file')
76
+ process.exit(1)
77
+ }
78
+
79
+ // Validate duration
80
+ if (!values.duration) {
81
+ console.error('Error: Duration is required. Use -d or --duration to specify duration in seconds.')
82
+ process.exit(1)
83
+ }
84
+
85
+ const duration = parseFloat(values.duration)
86
+ if (isNaN(duration) || duration <= 0) {
87
+ console.error('Error: Duration must be a positive number')
88
+ process.exit(1)
89
+ }
90
+
91
+ // Parse numeric options
92
+ const fps = parseInt(values.fps)
93
+ const width = parseInt(values.width)
94
+ const height = parseInt(values.height)
95
+
96
+ if (isNaN(fps) || fps <= 0) {
97
+ console.error('Error: FPS must be a positive number')
98
+ process.exit(1)
99
+ }
100
+
101
+ if (isNaN(width) || width <= 0 || isNaN(height) || height <= 0) {
102
+ console.error('Error: Width and height must be positive numbers')
103
+ process.exit(1)
104
+ }
105
+
106
+ // Import renderer and start rendering
107
+ console.log(`\nVueSeq - Rendering ${input}`)
108
+ console.log(` Duration: ${duration}s at ${fps}fps (${Math.ceil(duration * fps)} frames)`)
109
+ console.log(` Resolution: ${width}x${height}`)
110
+ console.log(` Output: ${values.output}\n`)
111
+
112
+ try {
113
+ const { renderToMp4 } = await import('../src/renderer/encode.js')
114
+
115
+ const startTime = Date.now()
116
+ let lastLoggedPercent = -1
117
+
118
+ await renderToMp4({
119
+ input: inputPath,
120
+ duration,
121
+ fps,
122
+ width,
123
+ height,
124
+ output: values.output,
125
+ onProgress: ({ frame, total, percent }) => {
126
+ // Only log every 5% to reduce noise
127
+ if (percent % 5 === 0 && percent !== lastLoggedPercent) {
128
+ lastLoggedPercent = percent
129
+ process.stdout.write(`\rRendering: ${percent}% (${frame + 1}/${total} frames)`)
130
+ }
131
+ }
132
+ })
133
+
134
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
135
+ console.log(`\n\n✓ Video saved to ${values.output} (${elapsed}s)`)
136
+
137
+ } catch (error) {
138
+ console.error(`\nError: ${error.message}`)
139
+ process.exit(1)
140
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "vueseq",
3
+ "version": "0.1.0",
4
+ "description": "A minimal, deterministic video renderer for Vue 3 + GSAP",
5
+ "type": "module",
6
+ "bin": {
7
+ "vueseq": "./bin/cli.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "keywords": [
21
+ "vue",
22
+ "gsap",
23
+ "video",
24
+ "animation",
25
+ "render",
26
+ "remotion",
27
+ "pellicule"
28
+ ],
29
+ "author": "bennyzen",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/bennyzen/vueseq.git"
34
+ },
35
+ "homepage": "https://github.com/bennyzen/vueseq#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/bennyzen/vueseq/issues"
38
+ },
39
+ "dependencies": {
40
+ "gsap": "^3.12.0",
41
+ "playwright": "^1.40.0"
42
+ },
43
+ "devDependencies": {
44
+ "@vitejs/plugin-vue": "^5.0.0",
45
+ "vite": "^5.0.0",
46
+ "vue": "^3.4.0"
47
+ },
48
+ "peerDependencies": {
49
+ "vue": "^3.0.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=18.0.0"
53
+ }
54
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Vite Bundler
3
+ *
4
+ * Creates a temporary Vite dev server that:
5
+ * 1. Serves the user's Video.vue component
6
+ * 2. Injects the GSAP bridge runtime
7
+ * 3. Provides an entry HTML file
8
+ */
9
+
10
+ import { createServer } from 'vite'
11
+ import vue from '@vitejs/plugin-vue'
12
+ import { resolve, dirname } from 'path'
13
+ import { fileURLToPath } from 'url'
14
+ import { mkdtemp, writeFile, rm } from 'fs/promises'
15
+ import { tmpdir } from 'os'
16
+ import { createRequire } from 'module'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const require = createRequire(import.meta.url)
20
+
21
+ /**
22
+ * Create a Vite dev server for rendering a Vue component
23
+ * @param {Object} options
24
+ * @param {string} options.input - Absolute path to the Video.vue component
25
+ * @param {number} options.width - Video width in pixels
26
+ * @param {number} options.height - Video height in pixels
27
+ * @returns {Promise<{url: string, tempDir: string, cleanup: () => Promise<void>}>}
28
+ */
29
+ export async function createVideoServer({ input, width, height }) {
30
+ // Create temp directory for build artifacts
31
+ const tempDir = await mkdtemp(resolve(tmpdir(), 'vueseq-'))
32
+
33
+ // Path to the gsap-bridge runtime
34
+ const gsapBridgePath = resolve(__dirname, '../runtime/gsap-bridge.js')
35
+
36
+ // User's project directory (where the video component lives)
37
+ const userProjectDir = dirname(input)
38
+
39
+ // VueSeq package root (for resolving our dependencies)
40
+ const vueseqRoot = resolve(__dirname, '../..')
41
+
42
+ // Resolve package paths from vueseq's node_modules
43
+ const vuePath = dirname(require.resolve('vue/package.json'))
44
+ const gsapPath = dirname(require.resolve('gsap/package.json'))
45
+
46
+ // Generate entry HTML with proper viewport sizing
47
+ const entryHtml = `<!DOCTYPE html>
48
+ <html>
49
+ <head>
50
+ <meta charset="UTF-8">
51
+ <meta name="viewport" content="width=${width}, height=${height}">
52
+ <style>
53
+ * { margin: 0; padding: 0; box-sizing: border-box; }
54
+ html, body {
55
+ width: ${width}px;
56
+ height: ${height}px;
57
+ overflow: hidden;
58
+ background: #000;
59
+ }
60
+ #app {
61
+ width: ${width}px;
62
+ height: ${height}px;
63
+ overflow: hidden;
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <div id="app"></div>
69
+ <script type="module" src="/@vueseq/entry.js"></script>
70
+ </body>
71
+ </html>`
72
+
73
+ await writeFile(resolve(tempDir, 'index.html'), entryHtml)
74
+
75
+ // Virtual module plugin for the entry point
76
+ const virtualEntryPlugin = {
77
+ name: 'vueseq-entry',
78
+ resolveId(id) {
79
+ if (id === '/@vueseq/entry.js') return id
80
+ if (id === '/@vueseq/gsap-bridge.js') return gsapBridgePath
81
+ },
82
+ load(id) {
83
+ if (id === '/@vueseq/entry.js') {
84
+ // Generate the entry module that imports the user's component
85
+ return `
86
+ import { createApp } from 'vue'
87
+ import '/@vueseq/gsap-bridge.js'
88
+ import Video from '${input}'
89
+
90
+ const app = createApp(Video)
91
+ app.mount('#app')
92
+
93
+ // Signal ready after Vue has mounted
94
+ window.__VUESEQ_READY__ = true
95
+ `
96
+ }
97
+ }
98
+ }
99
+
100
+ const server = await createServer({
101
+ root: tempDir,
102
+ plugins: [vue(), virtualEntryPlugin],
103
+ server: {
104
+ port: 0, // Auto-assign available port
105
+ strictPort: false
106
+ },
107
+ resolve: {
108
+ alias: {
109
+ // Resolve vue and gsap from vueseq's node_modules
110
+ 'vue': vuePath,
111
+ 'gsap': gsapPath
112
+ }
113
+ },
114
+ optimizeDeps: {
115
+ // Let Vite know where to find these
116
+ include: ['vue', 'gsap'],
117
+ // Force re-optimization in temp directory
118
+ force: true,
119
+ // Include paths for the optimizer to search
120
+ entries: [input]
121
+ },
122
+ // Allow serving files from:
123
+ // 1. temp directory (index.html)
124
+ // 2. user's project (Video.vue and its imports)
125
+ // 3. vueseq package (gsap-bridge.js and dependencies)
126
+ fs: {
127
+ allow: [
128
+ tempDir,
129
+ userProjectDir,
130
+ vueseqRoot,
131
+ vuePath,
132
+ gsapPath
133
+ ]
134
+ },
135
+ logLevel: 'warn' // Reduce noise during rendering
136
+ })
137
+
138
+ await server.listen()
139
+ const address = server.httpServer.address()
140
+ const url = `http://localhost:${address.port}`
141
+
142
+ return {
143
+ url,
144
+ tempDir,
145
+ cleanup: async () => {
146
+ await server.close()
147
+ await rm(tempDir, { recursive: true, force: true })
148
+ }
149
+ }
150
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * VueSeq - Public API
3
+ *
4
+ * A minimal, deterministic video renderer for Vue 3 + GSAP.
5
+ */
6
+
7
+ export { renderFrames } from './renderer/render.js'
8
+ export { encodeVideo, renderToMp4 } from './renderer/encode.js'
9
+ export { createVideoServer } from './bundler/vite.js'
@@ -0,0 +1,92 @@
1
+ /**
2
+ * FFmpeg Encoder
3
+ *
4
+ * Encodes PNG frames to MP4 video using FFmpeg.
5
+ * Requires FFmpeg to be installed on the system.
6
+ */
7
+
8
+ import { spawn } from 'child_process'
9
+ import { join } from 'path'
10
+
11
+ /**
12
+ * Encode frames to video using FFmpeg
13
+ * @param {Object} options
14
+ * @param {string} options.framesDir - Directory containing frame-XXXXX.png files
15
+ * @param {string} options.output - Output video file path
16
+ * @param {number} [options.fps=30] - Frames per second
17
+ * @returns {Promise<string>} - Path to the output video
18
+ */
19
+ export function encodeVideo({ framesDir, output, fps = 30 }) {
20
+ return new Promise((resolve, reject) => {
21
+ const args = [
22
+ '-y', // Overwrite output file without asking
23
+ '-framerate', String(fps),
24
+ '-i', join(framesDir, 'frame-%05d.png'),
25
+ '-c:v', 'libx264',
26
+ '-pix_fmt', 'yuv420p', // Compatibility with most players
27
+ '-preset', 'fast',
28
+ '-crf', '18', // High quality (lower = better, 18-23 is good range)
29
+ output
30
+ ]
31
+
32
+ const ffmpeg = spawn('ffmpeg', args, {
33
+ stdio: ['ignore', 'pipe', 'pipe']
34
+ })
35
+
36
+ let stderr = ''
37
+ ffmpeg.stderr.on('data', (data) => {
38
+ stderr += data.toString()
39
+ })
40
+
41
+ ffmpeg.on('close', (code) => {
42
+ if (code === 0) {
43
+ resolve(output)
44
+ } else {
45
+ reject(new Error(`FFmpeg exited with code ${code}\n${stderr}`))
46
+ }
47
+ })
48
+
49
+ ffmpeg.on('error', (err) => {
50
+ if (err.code === 'ENOENT') {
51
+ reject(new Error(
52
+ 'FFmpeg not found. Please install FFmpeg:\n' +
53
+ ' - macOS: brew install ffmpeg\n' +
54
+ ' - Ubuntu/Debian: sudo apt install ffmpeg\n' +
55
+ ' - Windows: Download from https://ffmpeg.org/download.html'
56
+ ))
57
+ } else {
58
+ reject(new Error(`FFmpeg error: ${err.message}`))
59
+ }
60
+ })
61
+ })
62
+ }
63
+
64
+ /**
65
+ * Render a Vue component to MP4 video
66
+ * @param {Object} options
67
+ * @param {string} options.input - Absolute path to the Video.vue component
68
+ * @param {string} [options.output='./output.mp4'] - Output video file path
69
+ * @param {number} [options.fps=30] - Frames per second
70
+ * @param {number} options.duration - Duration in seconds
71
+ * @param {number} [options.width=1920] - Video width in pixels
72
+ * @param {number} [options.height=1080] - Video height in pixels
73
+ * @param {function} [options.onProgress] - Progress callback
74
+ * @returns {Promise<string>} - Path to the output video
75
+ */
76
+ export async function renderToMp4(options) {
77
+ const { renderFrames } = await import('./render.js')
78
+ const { output = './output.mp4', ...renderOptions } = options
79
+
80
+ const { framesDir, cleanup } = await renderFrames(renderOptions)
81
+
82
+ try {
83
+ await encodeVideo({
84
+ framesDir,
85
+ output,
86
+ fps: renderOptions.fps || 30
87
+ })
88
+ return output
89
+ } finally {
90
+ await cleanup()
91
+ }
92
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Frame Renderer
3
+ *
4
+ * The core rendering loop that captures each frame using Playwright.
5
+ * For each frame:
6
+ * 1. Seek GSAP globalTimeline to the exact time
7
+ * 2. Wait for requestAnimationFrame to ensure DOM is painted
8
+ * 3. Take a screenshot
9
+ */
10
+
11
+ import { chromium } from 'playwright'
12
+ import { createVideoServer } from '../bundler/vite.js'
13
+ import { mkdir } from 'fs/promises'
14
+ import { join } from 'path'
15
+
16
+ /**
17
+ * Render frames from a Vue component
18
+ * @param {Object} options
19
+ * @param {string} options.input - Absolute path to the Video.vue component
20
+ * @param {number} [options.fps=30] - Frames per second
21
+ * @param {number} options.duration - Duration in seconds
22
+ * @param {number} [options.width=1920] - Video width in pixels
23
+ * @param {number} [options.height=1080] - Video height in pixels
24
+ * @param {function} [options.onProgress] - Progress callback
25
+ * @returns {Promise<{framesDir: string, totalFrames: number, cleanup: () => Promise<void>}>}
26
+ */
27
+ export async function renderFrames(options) {
28
+ const {
29
+ input,
30
+ fps = 30,
31
+ duration,
32
+ width = 1920,
33
+ height = 1080,
34
+ onProgress
35
+ } = options
36
+
37
+ if (!duration || duration <= 0) {
38
+ throw new Error('Duration must be a positive number (in seconds)')
39
+ }
40
+
41
+ const totalFrames = Math.ceil(duration * fps)
42
+
43
+ // Start Vite server
44
+ const { url, tempDir, cleanup } = await createVideoServer({ input, width, height })
45
+
46
+ const framesDir = join(tempDir, 'frames')
47
+ await mkdir(framesDir, { recursive: true })
48
+
49
+ // Launch headless browser
50
+ const browser = await chromium.launch({
51
+ headless: true
52
+ })
53
+
54
+ const context = await browser.newContext({
55
+ viewport: { width, height },
56
+ deviceScaleFactor: 1
57
+ })
58
+
59
+ const page = await context.newPage()
60
+
61
+ try {
62
+ // Load the page
63
+ await page.goto(url, { waitUntil: 'networkidle' })
64
+
65
+ // Wait for VueSeq bridge to be ready
66
+ await page.waitForFunction(
67
+ () => window.__VUESEQ_READY__ === true,
68
+ { timeout: 30000 }
69
+ )
70
+
71
+ // Give Vue a moment to mount and GSAP to set up timelines
72
+ await page.waitForTimeout(100)
73
+
74
+ // Render each frame
75
+ for (let frame = 0; frame < totalFrames; frame++) {
76
+ const timeInSeconds = frame / fps
77
+
78
+ // Seek GSAP to exact time and wait for paint
79
+ await page.evaluate(async (t) => {
80
+ window.__VUESEQ_SEEK__(t)
81
+ // Wait for next animation frame to ensure DOM is painted
82
+ await new Promise(resolve => requestAnimationFrame(resolve))
83
+ }, timeInSeconds)
84
+
85
+ // Take screenshot
86
+ const framePath = join(framesDir, `frame-${String(frame).padStart(5, '0')}.png`)
87
+ await page.screenshot({
88
+ path: framePath,
89
+ type: 'png'
90
+ })
91
+
92
+ // Progress callback
93
+ if (onProgress) {
94
+ onProgress({
95
+ frame,
96
+ total: totalFrames,
97
+ timeInSeconds,
98
+ percent: Math.round((frame + 1) / totalFrames * 100)
99
+ })
100
+ }
101
+ }
102
+ } finally {
103
+ await browser.close()
104
+ }
105
+
106
+ return { framesDir, totalFrames, cleanup }
107
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * GSAP Bridge - Browser Runtime
3
+ *
4
+ * This script runs inside the browser (injected by Vite) and provides
5
+ * the deterministic time-control interface for frame-by-frame rendering.
6
+ *
7
+ * Key design decisions:
8
+ * - We pause globalTimeline to freeze ALL animations
9
+ * - seek() with suppressEvents=true prevents callbacks from firing
10
+ * - The user writes standard GSAP code; no special composables needed
11
+ */
12
+
13
+ import gsap from 'gsap'
14
+
15
+ // 1. Pause all animations immediately
16
+ gsap.globalTimeline.pause()
17
+
18
+ // 2. Disable lag smoothing (ensures consistent timing)
19
+ gsap.ticker.lagSmoothing(0)
20
+
21
+ // 3. Expose seek function to Playwright
22
+ // This is the core function that enables deterministic rendering
23
+ window.__VUESEQ_SEEK__ = (timeInSeconds) => {
24
+ // suppressEvents = true prevents onComplete/onUpdate callbacks from firing
25
+ gsap.globalTimeline.seek(timeInSeconds, true)
26
+ }
27
+
28
+ // 4. Signal ready state
29
+ window.__VUESEQ_READY__ = false
30
+
31
+ // 5. Store video config for external access
32
+ window.__VUESEQ_CONFIG__ = null
33
+
34
+ window.__VUESEQ_SET_CONFIG__ = (config) => {
35
+ window.__VUESEQ_CONFIG__ = config
36
+ }
37
+
38
+ // 6. Mark as ready after a microtask to ensure Vue is mounted
39
+ queueMicrotask(() => {
40
+ window.__VUESEQ_READY__ = true
41
+ })
42
+
43
+ export { gsap }