pellicule 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +255 -0
- package/package.json +4 -1
- package/src/components/Sequence.vue +34 -0
- package/src/composables/frame.js +24 -0
- package/src/composables/keys.js +7 -0
- package/src/composables/sequence.js +82 -0
- package/src/composables/video-config.js +23 -0
- package/src/index.js +8 -2
- package/src/renderer/render.js +29 -15
- package/src/composables.js +0 -51
- /package/src/{math.js → utils/math.js} +0 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { resolve, extname, basename, dirname } from 'node:path'
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { renderToMp4 } from '../src/render.js'
|
|
8
|
+
|
|
9
|
+
// Read version from package.json
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'))
|
|
12
|
+
const VERSION = pkg.version
|
|
13
|
+
|
|
14
|
+
// ANSI color codes (no dependencies needed)
|
|
15
|
+
const colors = {
|
|
16
|
+
reset: '\x1b[0m',
|
|
17
|
+
bold: '\x1b[1m',
|
|
18
|
+
dim: '\x1b[2m',
|
|
19
|
+
red: '\x1b[31m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
cyan: '\x1b[36m',
|
|
22
|
+
white: '\x1b[37m',
|
|
23
|
+
pellicule: '\x1b[38;2;66;184;131m', // #42b883 - Vue green
|
|
24
|
+
bgPellicule: '\x1b[48;2;66;184;131m'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const c = {
|
|
28
|
+
error: (s) => `${colors.red}${s}${colors.reset}`,
|
|
29
|
+
warn: (s) => `${colors.yellow}${s}${colors.reset}`,
|
|
30
|
+
info: (s) => `${colors.cyan}${s}${colors.reset}`,
|
|
31
|
+
dim: (s) => `${colors.dim}${s}${colors.reset}`,
|
|
32
|
+
bold: (s) => `${colors.bold}${s}${colors.reset}`,
|
|
33
|
+
highlight: (s) => `${colors.pellicule}${s}${colors.reset}`,
|
|
34
|
+
brand: (s) => `${colors.bgPellicule}${colors.white}${colors.bold}${s}${colors.reset}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fail(msg, hint) {
|
|
38
|
+
console.error(c.error(`\nError: ${msg}\n`))
|
|
39
|
+
if (hint) console.error(c.dim(` ${hint}\n`))
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const HELP = `
|
|
44
|
+
${c.bold('pellicule')} ${c.dim(`v${VERSION}`)} - Render Vue components to video
|
|
45
|
+
|
|
46
|
+
${c.bold('USAGE')}
|
|
47
|
+
${c.highlight('pellicule')} ${c.dim('→ renders Video.vue to output.mp4')}
|
|
48
|
+
${c.highlight('pellicule')} <input.vue> ${c.dim('→ custom input file')}
|
|
49
|
+
${c.highlight('pellicule')} <input.vue> -o <file> ${c.dim('→ custom output path')}
|
|
50
|
+
|
|
51
|
+
${c.bold('OPTIONS')}
|
|
52
|
+
${c.info('-o, --output')} <file> Output file path ${c.dim('(default: ./output.mp4)')}
|
|
53
|
+
${c.info('-d, --duration')} <frames> Duration in frames ${c.dim('(default: 90)')}
|
|
54
|
+
${c.info('-f, --fps')} <number> Frames per second ${c.dim('(default: 30)')}
|
|
55
|
+
${c.info('-w, --width')} <pixels> Video width ${c.dim('(default: 1920)')}
|
|
56
|
+
${c.info('-h, --height')} <pixels> Video height ${c.dim('(default: 1080)')}
|
|
57
|
+
${c.info('-r, --range')} <start:end> Frame range for partial render ${c.dim('(e.g., 100:200)')}
|
|
58
|
+
${c.info('--help')} Show this help message
|
|
59
|
+
${c.info('--version')} Show version number
|
|
60
|
+
|
|
61
|
+
${c.bold('EXAMPLES')}
|
|
62
|
+
${c.dim('# Zero-config (renders Video.vue → output.mp4)')}
|
|
63
|
+
${c.highlight('pellicule')}
|
|
64
|
+
|
|
65
|
+
${c.dim('# Specify input file (.vue extension is optional)')}
|
|
66
|
+
${c.highlight('pellicule')} MyVideo
|
|
67
|
+
|
|
68
|
+
${c.dim('# Custom output and duration')}
|
|
69
|
+
${c.highlight('pellicule')} Video.vue -o intro.mp4 -d 150
|
|
70
|
+
|
|
71
|
+
${c.dim('# 4K video at 60fps')}
|
|
72
|
+
${c.highlight('pellicule')} Video.vue -w 3840 -h 2160 -f 60
|
|
73
|
+
|
|
74
|
+
${c.dim('# 10 second video')}
|
|
75
|
+
${c.highlight('pellicule')} Video.vue -d 300 -f 30
|
|
76
|
+
|
|
77
|
+
${c.dim('# Render only frames 100-200 (for faster iteration)')}
|
|
78
|
+
${c.highlight('pellicule')} Video.vue -d 300 -r 100:200
|
|
79
|
+
|
|
80
|
+
${c.dim('# Render from frame 150 to 250')}
|
|
81
|
+
${c.highlight('pellicule')} Video.vue -d 300 --range 150:250
|
|
82
|
+
|
|
83
|
+
${c.bold('DURATION HELPER')}
|
|
84
|
+
frames = seconds * fps
|
|
85
|
+
${c.dim('3 seconds at 30fps = 90 frames')}
|
|
86
|
+
${c.dim('5 seconds at 30fps = 150 frames')}
|
|
87
|
+
${c.dim('10 seconds at 60fps = 600 frames')}
|
|
88
|
+
|
|
89
|
+
${c.dim('Documentation: https://docs.sailscasts.com/pellicule')}
|
|
90
|
+
`
|
|
91
|
+
|
|
92
|
+
function printBanner() {
|
|
93
|
+
console.log()
|
|
94
|
+
console.log(` ${c.brand(' PELLICULE ')} ${c.dim(`v${VERSION}`)}`)
|
|
95
|
+
console.log()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatTime(ms) {
|
|
99
|
+
if (ms < 1000) return `${ms}ms`
|
|
100
|
+
const seconds = (ms / 1000).toFixed(1)
|
|
101
|
+
return `${seconds}s`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatProgress(frame, total, fps) {
|
|
105
|
+
const percent = Math.round(((frame + 1) / total) * 100)
|
|
106
|
+
const barWidth = 30
|
|
107
|
+
const filled = Math.round((percent / 100) * barWidth)
|
|
108
|
+
const empty = barWidth - filled
|
|
109
|
+
const bar = c.highlight('█'.repeat(filled)) + c.dim('░'.repeat(empty))
|
|
110
|
+
return ` ${bar} ${c.bold(percent + '%')} ${c.dim(`(${frame + 1}/${total} @ ${fps.toFixed(1)} fps)`)}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function main() {
|
|
114
|
+
const { values, positionals } = parseArgs({
|
|
115
|
+
allowPositionals: true,
|
|
116
|
+
options: {
|
|
117
|
+
output: { type: 'string', short: 'o' },
|
|
118
|
+
duration: { type: 'string', short: 'd' },
|
|
119
|
+
fps: { type: 'string', short: 'f' },
|
|
120
|
+
width: { type: 'string', short: 'w' },
|
|
121
|
+
height: { type: 'string', short: 'h' },
|
|
122
|
+
range: { type: 'string', short: 'r' },
|
|
123
|
+
help: { type: 'boolean' },
|
|
124
|
+
version: { type: 'boolean' }
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Handle help and version
|
|
129
|
+
if (values.help) {
|
|
130
|
+
console.log(HELP)
|
|
131
|
+
process.exit(0)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (values.version) {
|
|
135
|
+
console.log(VERSION)
|
|
136
|
+
process.exit(0)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Default to Video.vue if no input provided
|
|
140
|
+
const input = positionals[0] || 'Video.vue'
|
|
141
|
+
|
|
142
|
+
// Try to resolve the input file, auto-appending .vue if needed
|
|
143
|
+
let inputPath = resolve(input)
|
|
144
|
+
|
|
145
|
+
if (!existsSync(inputPath) && !input.endsWith('.vue')) {
|
|
146
|
+
const withVue = resolve(input + '.vue')
|
|
147
|
+
if (existsSync(withVue)) {
|
|
148
|
+
inputPath = withVue
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!existsSync(inputPath)) fail(`File not found: ${input}`)
|
|
153
|
+
if (extname(inputPath) !== '.vue') fail(`Input must be a .vue file, got: ${extname(inputPath) || '(no extension)'}`)
|
|
154
|
+
|
|
155
|
+
// Parse options with defaults
|
|
156
|
+
const fps = parseInt(values.fps || '30', 10)
|
|
157
|
+
const durationInFrames = parseInt(values.duration || '90', 10)
|
|
158
|
+
const width = parseInt(values.width || '1920', 10)
|
|
159
|
+
const height = parseInt(values.height || '1080', 10)
|
|
160
|
+
const output = values.output || './output.mp4'
|
|
161
|
+
const outputPath = resolve(output)
|
|
162
|
+
|
|
163
|
+
// Parse optional range (start:end format)
|
|
164
|
+
let startFrame = 0
|
|
165
|
+
let endFrame = durationInFrames
|
|
166
|
+
|
|
167
|
+
if (values.range) {
|
|
168
|
+
const rangeParts = values.range.split(':')
|
|
169
|
+
if (rangeParts.length !== 2) fail(`Invalid range format: ${values.range}`, 'Expected format: start:end (e.g., 100:200)')
|
|
170
|
+
const [startStr, endStr] = rangeParts
|
|
171
|
+
startFrame = parseInt(startStr, 10)
|
|
172
|
+
endFrame = parseInt(endStr, 10)
|
|
173
|
+
if (isNaN(startFrame) || startFrame < 0) fail(`Invalid start frame in range: ${startStr}`)
|
|
174
|
+
if (isNaN(endFrame) || endFrame <= 0) fail(`Invalid end frame in range: ${endStr}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Validate options
|
|
178
|
+
if (isNaN(fps) || fps <= 0) fail(`Invalid fps value: ${values.fps}`)
|
|
179
|
+
if (isNaN(durationInFrames) || durationInFrames <= 0) fail(`Invalid duration value: ${values.duration}`)
|
|
180
|
+
if (isNaN(width) || width <= 0) fail(`Invalid width value: ${values.width}`)
|
|
181
|
+
if (isNaN(height) || height <= 0) fail(`Invalid height value: ${values.height}`)
|
|
182
|
+
if (startFrame >= endFrame) fail(`Start frame (${startFrame}) must be less than end frame (${endFrame})`)
|
|
183
|
+
if (endFrame > durationInFrames) fail(`End frame (${endFrame}) exceeds duration (${durationInFrames})`)
|
|
184
|
+
|
|
185
|
+
// Print banner and config
|
|
186
|
+
printBanner()
|
|
187
|
+
|
|
188
|
+
const isPartialRender = startFrame > 0 || endFrame < durationInFrames
|
|
189
|
+
const framesToRender = endFrame - startFrame
|
|
190
|
+
const durationSeconds = (durationInFrames / fps).toFixed(1)
|
|
191
|
+
const partialSeconds = (framesToRender / fps).toFixed(1)
|
|
192
|
+
|
|
193
|
+
console.log(` ${c.bold('Input')} ${c.info(basename(inputPath))}`)
|
|
194
|
+
console.log(` ${c.bold('Output')} ${c.info(basename(outputPath))}`)
|
|
195
|
+
console.log(` ${c.bold('Resolution')} ${width}x${height}`)
|
|
196
|
+
console.log(` ${c.bold('Duration')} ${durationInFrames} frames @ ${fps}fps ${c.dim(`(${durationSeconds}s)`)}`)
|
|
197
|
+
if (isPartialRender) {
|
|
198
|
+
console.log(` ${c.bold('Range')} ${c.highlight(`frames ${startFrame}-${endFrame - 1}`)} ${c.dim(`(${framesToRender} frames, ${partialSeconds}s)`)}`)
|
|
199
|
+
}
|
|
200
|
+
console.log()
|
|
201
|
+
|
|
202
|
+
const startTime = Date.now()
|
|
203
|
+
|
|
204
|
+
// ANSI escape codes for line control
|
|
205
|
+
const clearLine = '\x1b[2K' // Clear entire line
|
|
206
|
+
const cursorToStart = '\r' // Move cursor to start of line
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
// Render with progress callback
|
|
210
|
+
await renderToMp4({
|
|
211
|
+
input: inputPath,
|
|
212
|
+
fps,
|
|
213
|
+
durationInFrames,
|
|
214
|
+
startFrame,
|
|
215
|
+
endFrame,
|
|
216
|
+
width,
|
|
217
|
+
height,
|
|
218
|
+
output: outputPath,
|
|
219
|
+
silent: true,
|
|
220
|
+
onProgress: ({ frame, total, fps: currentFps }) => {
|
|
221
|
+
// Clear line and print progress (stays on same line)
|
|
222
|
+
process.stdout.write(clearLine + cursorToStart + formatProgress(frame, total, currentFps || fps))
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// Clear progress line when done
|
|
227
|
+
process.stdout.write(clearLine + cursorToStart)
|
|
228
|
+
|
|
229
|
+
const totalTime = Date.now() - startTime
|
|
230
|
+
console.log()
|
|
231
|
+
console.log(` ${c.highlight('Done!')} Rendered ${framesToRender} frames in ${formatTime(totalTime)}`)
|
|
232
|
+
console.log(` ${c.dim('Output:')} ${outputPath}`)
|
|
233
|
+
console.log()
|
|
234
|
+
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// Clear progress line on error
|
|
237
|
+
process.stdout.write(clearLine + cursorToStart)
|
|
238
|
+
console.error()
|
|
239
|
+
console.error(c.error(` Error: ${error.message}`))
|
|
240
|
+
console.error()
|
|
241
|
+
|
|
242
|
+
if (error.message.includes('ffmpeg')) {
|
|
243
|
+
console.error(c.warn(' Hint: Make sure FFmpeg is installed and available in your PATH'))
|
|
244
|
+
console.error(c.dim(' Install: https://ffmpeg.org/download.html'))
|
|
245
|
+
console.error()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
process.exit(1)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
main().catch((error) => {
|
|
253
|
+
console.error(c.error(`\nUnexpected error: ${error.message}\n`))
|
|
254
|
+
process.exit(1)
|
|
255
|
+
})
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pellicule",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Deterministic video rendering with Vue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pellicule": "./bin/cli.js"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./src/index.js",
|
|
9
12
|
"./render": "./src/render.js"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useFrame } from '../composables/frame.js'
|
|
4
|
+
import { provideSequence } from '../composables/sequence.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
/**
|
|
8
|
+
* The frame number where this sequence starts
|
|
9
|
+
*/
|
|
10
|
+
from: {
|
|
11
|
+
type: Number,
|
|
12
|
+
required: true
|
|
13
|
+
},
|
|
14
|
+
/**
|
|
15
|
+
* Duration of this sequence in frames
|
|
16
|
+
*/
|
|
17
|
+
durationInFrames: {
|
|
18
|
+
type: Number,
|
|
19
|
+
required: true
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const globalFrame = useFrame()
|
|
24
|
+
|
|
25
|
+
// Provide sequence context to children
|
|
26
|
+
const { isActive } = provideSequence(props.from, props.durationInFrames)
|
|
27
|
+
|
|
28
|
+
// Compute whether to show content
|
|
29
|
+
const shouldRender = computed(() => isActive.value)
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<slot v-if="shouldRender" />
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { inject, computed } from 'vue'
|
|
2
|
+
import { FRAME_KEY } from './keys.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get the current frame number.
|
|
6
|
+
* Must be used within a Pellicule render context.
|
|
7
|
+
*
|
|
8
|
+
* @returns {import('vue').ComputedRef<number>} Current frame number
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const frame = useFrame()
|
|
12
|
+
* const opacity = computed(() => frame.value / 30) // fade in over 1 second at 30fps
|
|
13
|
+
*/
|
|
14
|
+
export function useFrame() {
|
|
15
|
+
const frame = inject(FRAME_KEY)
|
|
16
|
+
|
|
17
|
+
if (frame === undefined) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'useFrame() must be used within a Pellicule render context'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return computed(() => frame.value)
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection keys for Pellicule context
|
|
3
|
+
* Using Symbol.for() so keys are shared across module instances
|
|
4
|
+
*/
|
|
5
|
+
export const FRAME_KEY = Symbol.for('pellicule-frame')
|
|
6
|
+
export const CONFIG_KEY = Symbol.for('pellicule-config')
|
|
7
|
+
export const SEQUENCE_KEY = Symbol.for('pellicule-sequence')
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { inject, computed, provide } from 'vue'
|
|
2
|
+
import { SEQUENCE_KEY } from './keys.js'
|
|
3
|
+
import { useFrame } from './frame.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get sequence timing information.
|
|
7
|
+
* Can be used in two ways:
|
|
8
|
+
*
|
|
9
|
+
* 1. Inside a <Sequence> component - returns timing for that sequence
|
|
10
|
+
* 2. With explicit timing - useSequence(from, durationInFrames)
|
|
11
|
+
*
|
|
12
|
+
* @param {number} [from] - Start frame (optional if inside <Sequence>)
|
|
13
|
+
* @param {number} [durationInFrames] - Duration in frames (optional if inside <Sequence>)
|
|
14
|
+
* @returns {{ localFrame: ComputedRef<number>, progress: ComputedRef<number>, isActive: ComputedRef<boolean> }}
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // Inside a <Sequence> component
|
|
18
|
+
* const { localFrame, progress, isActive } = useSequence()
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // With explicit timing
|
|
22
|
+
* const verse1 = useSequence(0, 90)
|
|
23
|
+
* const verse2 = useSequence(90, 90)
|
|
24
|
+
*/
|
|
25
|
+
export function useSequence(from, durationInFrames) {
|
|
26
|
+
const globalFrame = useFrame()
|
|
27
|
+
|
|
28
|
+
// If arguments provided, use them directly
|
|
29
|
+
if (from !== undefined && durationInFrames !== undefined) {
|
|
30
|
+
const end = from + durationInFrames
|
|
31
|
+
|
|
32
|
+
const localFrame = computed(() => Math.max(0, globalFrame.value - from))
|
|
33
|
+
const progress = computed(() => {
|
|
34
|
+
if (globalFrame.value < from) return 0
|
|
35
|
+
if (globalFrame.value >= end) return 1
|
|
36
|
+
return (globalFrame.value - from) / durationInFrames
|
|
37
|
+
})
|
|
38
|
+
const isActive = computed(() =>
|
|
39
|
+
globalFrame.value >= from && globalFrame.value < end
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return { localFrame, progress, isActive }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Otherwise, try to get from parent <Sequence>
|
|
46
|
+
const sequenceContext = inject(SEQUENCE_KEY, null)
|
|
47
|
+
|
|
48
|
+
if (!sequenceContext) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'useSequence() must be used within a <Sequence> component or called with (from, durationInFrames) arguments'
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return sequenceContext
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Provide sequence context to children.
|
|
59
|
+
* Used internally by the <Sequence> component.
|
|
60
|
+
*
|
|
61
|
+
* @param {number} from - Start frame
|
|
62
|
+
* @param {number} durationInFrames - Duration in frames
|
|
63
|
+
*/
|
|
64
|
+
export function provideSequence(from, durationInFrames) {
|
|
65
|
+
const globalFrame = useFrame()
|
|
66
|
+
const end = from + durationInFrames
|
|
67
|
+
|
|
68
|
+
const localFrame = computed(() => Math.max(0, globalFrame.value - from))
|
|
69
|
+
const progress = computed(() => {
|
|
70
|
+
if (globalFrame.value < from) return 0
|
|
71
|
+
if (globalFrame.value >= end) return 1
|
|
72
|
+
return (globalFrame.value - from) / durationInFrames
|
|
73
|
+
})
|
|
74
|
+
const isActive = computed(() =>
|
|
75
|
+
globalFrame.value >= from && globalFrame.value < end
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const context = { localFrame, progress, isActive, from, durationInFrames }
|
|
79
|
+
provide(SEQUENCE_KEY, context)
|
|
80
|
+
|
|
81
|
+
return context
|
|
82
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { inject } from 'vue'
|
|
2
|
+
import { CONFIG_KEY } from './keys.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get the video configuration (fps, duration, dimensions).
|
|
6
|
+
* Must be used within a Pellicule render context.
|
|
7
|
+
*
|
|
8
|
+
* @returns {{ fps: number, durationInFrames: number, width: number, height: number }}
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const { fps, durationInFrames, width, height } = useVideoConfig()
|
|
12
|
+
*/
|
|
13
|
+
export function useVideoConfig() {
|
|
14
|
+
const config = inject(CONFIG_KEY)
|
|
15
|
+
|
|
16
|
+
if (!config) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'useVideoConfig() must be used within a Pellicule render context'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return config
|
|
23
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// Composables
|
|
9
|
-
export { useFrame
|
|
9
|
+
export { useFrame } from './composables/frame.js'
|
|
10
|
+
export { useVideoConfig } from './composables/video-config.js'
|
|
11
|
+
export { useSequence } from './composables/sequence.js'
|
|
12
|
+
export { FRAME_KEY, CONFIG_KEY, SEQUENCE_KEY } from './composables/keys.js'
|
|
13
|
+
|
|
14
|
+
// Components
|
|
15
|
+
export { default as Sequence } from './components/Sequence.vue'
|
|
10
16
|
|
|
11
17
|
// Animation utilities
|
|
12
|
-
export { interpolate, sequence, Easing } from './math.js'
|
|
18
|
+
export { interpolate, sequence, Easing } from './utils/math.js'
|
package/src/renderer/render.js
CHANGED
|
@@ -9,7 +9,9 @@ import { join } from 'path'
|
|
|
9
9
|
* @param {object} options
|
|
10
10
|
* @param {string} options.input - Path to the .vue file
|
|
11
11
|
* @param {number} options.fps - Frames per second (default: 30)
|
|
12
|
-
* @param {number} options.durationInFrames - Total frames
|
|
12
|
+
* @param {number} options.durationInFrames - Total frames in the video (for animation calculations)
|
|
13
|
+
* @param {number} options.startFrame - First frame to render (default: 0)
|
|
14
|
+
* @param {number} options.endFrame - Last frame to render, exclusive (default: durationInFrames)
|
|
13
15
|
* @param {number} options.width - Video width in pixels (default: 1920)
|
|
14
16
|
* @param {number} options.height - Video height in pixels (default: 1080)
|
|
15
17
|
* @param {string} options.outputDir - Directory for frame images (default: './frames')
|
|
@@ -21,6 +23,8 @@ export async function renderVideo(options) {
|
|
|
21
23
|
input,
|
|
22
24
|
fps = 30,
|
|
23
25
|
durationInFrames,
|
|
26
|
+
startFrame = 0,
|
|
27
|
+
endFrame,
|
|
24
28
|
width = 1920,
|
|
25
29
|
height = 1080,
|
|
26
30
|
outputDir = './frames',
|
|
@@ -28,6 +32,10 @@ export async function renderVideo(options) {
|
|
|
28
32
|
silent = false
|
|
29
33
|
} = options
|
|
30
34
|
|
|
35
|
+
// Calculate actual frame range to render
|
|
36
|
+
const actualEndFrame = endFrame !== undefined ? endFrame : durationInFrames
|
|
37
|
+
const framesToRender = actualEndFrame - startFrame
|
|
38
|
+
|
|
31
39
|
const log = silent ? () => {} : console.log.bind(console)
|
|
32
40
|
|
|
33
41
|
const startTime = Date.now()
|
|
@@ -62,10 +70,13 @@ export async function renderVideo(options) {
|
|
|
62
70
|
}
|
|
63
71
|
})
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
const rangeInfo = startFrame > 0 || actualEndFrame < durationInFrames
|
|
74
|
+
? ` (frames ${startFrame}-${actualEndFrame - 1})`
|
|
75
|
+
: ''
|
|
76
|
+
log(`Rendering ${framesToRender} frames at ${fps}fps (${width}x${height})${rangeInfo}`)
|
|
66
77
|
|
|
67
78
|
try {
|
|
68
|
-
// Load page ONCE with config
|
|
79
|
+
// Load page ONCE with config (durationInFrames stays full for correct animation calculations)
|
|
69
80
|
const pageUrl = `${url}?fps=${fps}&duration=${durationInFrames}&width=${width}&height=${height}`
|
|
70
81
|
await page.goto(pageUrl, { waitUntil: 'networkidle' })
|
|
71
82
|
|
|
@@ -80,33 +91,36 @@ export async function renderVideo(options) {
|
|
|
80
91
|
|
|
81
92
|
const renderStart = Date.now()
|
|
82
93
|
|
|
83
|
-
// Render each frame
|
|
84
|
-
for (let frame =
|
|
94
|
+
// Render each frame in the specified range
|
|
95
|
+
for (let frame = startFrame; frame < actualEndFrame; frame++) {
|
|
85
96
|
// Update frame number - Vue reactivity handles re-render
|
|
86
97
|
await page.evaluate((f) => window.__PELLICULE_SET_FRAME__(f), frame)
|
|
87
98
|
|
|
88
|
-
// Screenshot
|
|
89
|
-
const
|
|
99
|
+
// Screenshot - output frames are numbered from 0
|
|
100
|
+
const outputFrameNum = frame - startFrame
|
|
101
|
+
const framePath = join(outputDir, `frame-${String(outputFrameNum).padStart(5, '0')}.png`)
|
|
90
102
|
await page.screenshot({ path: framePath })
|
|
91
103
|
|
|
92
104
|
// Progress callback
|
|
93
105
|
if (onProgress) {
|
|
94
106
|
const elapsed = Date.now() - renderStart
|
|
95
|
-
const
|
|
96
|
-
|
|
107
|
+
const framesRendered = outputFrameNum + 1
|
|
108
|
+
const currentFps = framesRendered / (elapsed / 1000)
|
|
109
|
+
onProgress({ frame: outputFrameNum, total: framesToRender, fps: currentFps })
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
// Log progress every 10 frames
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
const outputFrameIndex = frame - startFrame
|
|
114
|
+
if (outputFrameIndex % 10 === 0 || frame === actualEndFrame - 1) {
|
|
115
|
+
const percent = Math.round(((outputFrameIndex + 1) / framesToRender) * 100)
|
|
102
116
|
const elapsed = Date.now() - renderStart
|
|
103
|
-
const framesPerSec = ((
|
|
104
|
-
log(`Frame ${
|
|
117
|
+
const framesPerSec = ((outputFrameIndex + 1) / (elapsed / 1000)).toFixed(1)
|
|
118
|
+
log(`Frame ${outputFrameIndex + 1}/${framesToRender} (${percent}%) - ${framesPerSec} fps`)
|
|
105
119
|
}
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
const renderTime = Date.now() - renderStart
|
|
109
|
-
log(`Rendered ${
|
|
123
|
+
log(`Rendered ${framesToRender} frames in ${renderTime}ms (${(framesToRender / (renderTime / 1000)).toFixed(1)} fps)`)
|
|
110
124
|
|
|
111
125
|
} finally {
|
|
112
126
|
await browser.close()
|
|
@@ -116,5 +130,5 @@ export async function renderVideo(options) {
|
|
|
116
130
|
log(`Total time: ${Date.now() - startTime}ms`)
|
|
117
131
|
log(`Frames saved to ${outputDir}`)
|
|
118
132
|
|
|
119
|
-
return { framesDir: outputDir, totalFrames:
|
|
133
|
+
return { framesDir: outputDir, totalFrames: framesToRender }
|
|
120
134
|
}
|
package/src/composables.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
}
|
|
File without changes
|