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 +245 -0
- package/bin/cli.js +140 -0
- package/package.json +54 -0
- package/src/bundler/vite.js +150 -0
- package/src/index.js +9 -0
- package/src/renderer/encode.js +92 -0
- package/src/renderer/render.js +107 -0
- package/src/runtime/gsap-bridge.js +43 -0
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 }
|