pellicule 0.1.1 → 0.2.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/bin/cli.js +80 -12
- package/package.json +1 -1
- package/src/bundler/entry.js +68 -3
- package/src/bundler/rsbuild.js +14 -2
- package/src/bundler/vite.js +10 -2
- package/src/dev/overlay.js +286 -0
- package/src/dev/server.js +100 -0
- package/src/renderer/render.js +5 -1
package/bin/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { renderToMp4 } from '../src/render.js'
|
|
8
8
|
import { extractVideoConfig, resolveVideoConfig } from '../src/macros/define-video-config.js'
|
|
9
9
|
import { detectProject, readPelliculeConfig, resolveInputFile } from '../src/config/detect.js'
|
|
10
|
+
import { startDevServer } from '../src/dev/server.js'
|
|
10
11
|
|
|
11
12
|
// Read version from package.json
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -49,6 +50,8 @@ ${c.bold('USAGE')}
|
|
|
49
50
|
${c.highlight('pellicule')} ${c.dim('→ renders Video.vue to output.mp4')}
|
|
50
51
|
${c.highlight('pellicule')} <input.vue> ${c.dim('→ custom input file')}
|
|
51
52
|
${c.highlight('pellicule')} <input.vue> -o <file> ${c.dim('→ custom output path')}
|
|
53
|
+
${c.highlight('pellicule dev')} ${c.dim('→ live preview in browser')}
|
|
54
|
+
${c.highlight('pellicule dev')} <input.vue> ${c.dim('→ preview a specific component')}
|
|
52
55
|
|
|
53
56
|
${c.bold('OPTIONS')}
|
|
54
57
|
${c.info('-o, --output')} <file> Output file path ${c.dim('(default: ./output.mp4)')}
|
|
@@ -114,6 +117,12 @@ ${c.bold('EXAMPLES')}
|
|
|
114
117
|
${c.dim('# Force Rsbuild bundler')}
|
|
115
118
|
${c.highlight('pellicule')} Video.vue --bundler rsbuild
|
|
116
119
|
|
|
120
|
+
${c.dim('# Live preview with hot-reload (Space to play, arrows to step)')}
|
|
121
|
+
${c.highlight('pellicule dev')}
|
|
122
|
+
|
|
123
|
+
${c.dim('# Preview a specific component at 720p')}
|
|
124
|
+
${c.highlight('pellicule dev')} MyVideo -w 1280 -h 720
|
|
125
|
+
|
|
117
126
|
${c.bold('DURATION HELPER')}
|
|
118
127
|
frames = seconds * fps
|
|
119
128
|
${c.dim('3 seconds at 30fps = 90 frames')}
|
|
@@ -176,6 +185,10 @@ async function main() {
|
|
|
176
185
|
process.exit(0)
|
|
177
186
|
}
|
|
178
187
|
|
|
188
|
+
// ── Subcommand detection ─────────────────────────────────────────
|
|
189
|
+
const isDevMode = positionals[0] === 'dev'
|
|
190
|
+
if (isDevMode) positionals.shift()
|
|
191
|
+
|
|
179
192
|
// ── Auto-detection ────────────────────────────────────────────────
|
|
180
193
|
const detected = detectProject()
|
|
181
194
|
const pkgConfig = readPelliculeConfig()
|
|
@@ -188,6 +201,15 @@ async function main() {
|
|
|
188
201
|
const serverUrl = values['server-url'] || pkgConfig.serverUrl || detected.defaultServerUrl || null
|
|
189
202
|
const projectType = detected.projectType
|
|
190
203
|
|
|
204
|
+
const projectLabels = {
|
|
205
|
+
laravel: 'Laravel',
|
|
206
|
+
vite: 'Vite',
|
|
207
|
+
rsbuild: 'Rsbuild',
|
|
208
|
+
shipwright: 'Boring Stack (Shipwright)',
|
|
209
|
+
nuxt: 'Nuxt',
|
|
210
|
+
quasar: 'Quasar'
|
|
211
|
+
}
|
|
212
|
+
|
|
191
213
|
// Validate bundler flag
|
|
192
214
|
if (values.bundler && !['vite', 'rsbuild'].includes(values.bundler)) {
|
|
193
215
|
fail(`Unknown bundler: ${values.bundler}`, 'Supported bundlers: vite, rsbuild')
|
|
@@ -267,15 +289,70 @@ async function main() {
|
|
|
267
289
|
if (isNaN(durationInFrames) || durationInFrames <= 0) fail(`Invalid duration value: ${durationInFrames}`)
|
|
268
290
|
if (isNaN(width) || width <= 0) fail(`Invalid width value: ${width}`)
|
|
269
291
|
if (isNaN(height) || height <= 0) fail(`Invalid height value: ${height}`)
|
|
270
|
-
if (startFrame >= endFrame) fail(`Start frame (${startFrame}) must be less than end frame (${endFrame})`)
|
|
271
|
-
if (endFrame > durationInFrames) fail(`End frame (${endFrame}) exceeds duration (${durationInFrames})`)
|
|
292
|
+
if (!isDevMode && startFrame >= endFrame) fail(`Start frame (${startFrame}) must be less than end frame (${endFrame})`)
|
|
293
|
+
if (!isDevMode && endFrame > durationInFrames) fail(`End frame (${endFrame}) exceeds duration (${durationInFrames})`)
|
|
294
|
+
|
|
295
|
+
const durationSeconds = (durationInFrames / fps).toFixed(1)
|
|
296
|
+
|
|
297
|
+
// ── Dev mode ─────────────────────────────────────────────────────
|
|
298
|
+
if (isDevMode) {
|
|
299
|
+
printBanner()
|
|
300
|
+
|
|
301
|
+
console.log(` ${c.bold('Mode')} ${c.highlight('dev')} ${c.dim('(live preview)')}`)
|
|
302
|
+
console.log(` ${c.bold('Input')} ${c.info(basename(inputPath))}`)
|
|
303
|
+
if (componentConfig) {
|
|
304
|
+
console.log(` ${c.bold('Config')} ${c.highlight('defineVideoConfig')} ${c.dim('detected ✓')}`)
|
|
305
|
+
}
|
|
306
|
+
if (projectType !== 'standalone') {
|
|
307
|
+
console.log(` ${c.bold('Project')} ${c.highlight(projectLabels[projectType] || projectType)} ${c.dim('detected ✓')}`)
|
|
308
|
+
}
|
|
309
|
+
if (serverUrl) {
|
|
310
|
+
console.log(` ${c.bold('Server')} ${c.info(serverUrl)} ${c.dim('(BYOS)')}`)
|
|
311
|
+
}
|
|
312
|
+
console.log(` ${c.bold('Resolution')} ${width}x${height}`)
|
|
313
|
+
console.log(` ${c.bold('Duration')} ${durationInFrames} frames @ ${fps}fps ${c.dim(`(${durationSeconds}s)`)}`)
|
|
314
|
+
console.log()
|
|
315
|
+
|
|
316
|
+
// For Nuxt/Quasar, construct /pellicule render page URL
|
|
317
|
+
let devServerUrl = serverUrl
|
|
318
|
+
if ((projectType === 'nuxt' || projectType === 'quasar') && serverUrl) {
|
|
319
|
+
const componentName = basename(inputPath, '.vue')
|
|
320
|
+
devServerUrl = `${serverUrl.replace(/\/$/, '')}/pellicule?component=${encodeURIComponent(componentName)}`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const { url } = await startDevServer({
|
|
325
|
+
input: inputPath,
|
|
326
|
+
fps,
|
|
327
|
+
durationInFrames,
|
|
328
|
+
width,
|
|
329
|
+
height,
|
|
330
|
+
serverUrl: devServerUrl,
|
|
331
|
+
bundler,
|
|
332
|
+
configFile,
|
|
333
|
+
projectType,
|
|
334
|
+
version: VERSION
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
console.log(` ${c.highlight('Preview ready!')} ${c.info(url)}`)
|
|
338
|
+
console.log()
|
|
339
|
+
console.log(` ${c.dim('Controls:')} ${c.bold('Space')} play/pause ${c.bold('←→')} step frame ${c.bold('Home/End')} first/last`)
|
|
340
|
+
console.log(` ${c.dim('Press')} ${c.bold('Ctrl+C')} ${c.dim('to stop')}`)
|
|
341
|
+
console.log()
|
|
342
|
+
|
|
343
|
+
// Keep process alive
|
|
344
|
+
await new Promise(() => {})
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error(c.error(` Error: ${error.message}`))
|
|
347
|
+
process.exit(1)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
272
350
|
|
|
273
351
|
// Print banner and config
|
|
274
352
|
printBanner()
|
|
275
353
|
|
|
276
354
|
const isPartialRender = startFrame > 0 || endFrame < durationInFrames
|
|
277
355
|
const framesToRender = endFrame - startFrame
|
|
278
|
-
const durationSeconds = (durationInFrames / fps).toFixed(1)
|
|
279
356
|
const partialSeconds = (framesToRender / fps).toFixed(1)
|
|
280
357
|
|
|
281
358
|
console.log(` ${c.bold('Input')} ${c.info(basename(inputPath))}`)
|
|
@@ -283,16 +360,7 @@ async function main() {
|
|
|
283
360
|
console.log(` ${c.bold('Config')} ${c.highlight('defineVideoConfig')} ${c.dim('detected ✓')}`)
|
|
284
361
|
}
|
|
285
362
|
|
|
286
|
-
// Show detected project info
|
|
287
363
|
if (projectType !== 'standalone') {
|
|
288
|
-
const projectLabels = {
|
|
289
|
-
laravel: 'Laravel',
|
|
290
|
-
vite: 'Vite',
|
|
291
|
-
rsbuild: 'Rsbuild',
|
|
292
|
-
shipwright: 'Boring Stack (Shipwright)',
|
|
293
|
-
nuxt: 'Nuxt',
|
|
294
|
-
quasar: 'Quasar'
|
|
295
|
-
}
|
|
296
364
|
console.log(` ${c.bold('Project')} ${c.highlight(projectLabels[projectType] || projectType)} ${c.dim('detected ✓')}`)
|
|
297
365
|
}
|
|
298
366
|
if (serverUrl) {
|
package/package.json
CHANGED
package/src/bundler/entry.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { writeFile, mkdir, rm } from 'fs/promises'
|
|
10
10
|
import { join, basename } from 'path'
|
|
11
|
+
import { generateOverlayScript } from '../dev/overlay.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Generate the entry HTML that wraps the video at the given dimensions.
|
|
@@ -15,9 +16,69 @@ import { join, basename } from 'path'
|
|
|
15
16
|
* @param {object} options
|
|
16
17
|
* @param {number} options.width
|
|
17
18
|
* @param {number} options.height
|
|
19
|
+
* @param {boolean} [options.preview] - Whether to inject the dev overlay
|
|
20
|
+
* @param {number} [options.fps] - FPS (used for overlay display)
|
|
21
|
+
* @param {number} [options.durationInFrames] - Total frames (used for overlay)
|
|
22
|
+
* @param {string} [options.version] - Package version (shown in overlay)
|
|
18
23
|
* @returns {string}
|
|
19
24
|
*/
|
|
20
|
-
export function generateHtml({ width = 1920, height = 1080 }) {
|
|
25
|
+
export function generateHtml({ width = 1920, height = 1080, preview = false, fps = 30, durationInFrames = 90, version = '' }) {
|
|
26
|
+
const overlayHtml = preview ? generateOverlayScript({ fps, durationInFrames, version }) : ''
|
|
27
|
+
|
|
28
|
+
if (preview) {
|
|
29
|
+
// Preview mode: scale the video canvas to fit the browser window
|
|
30
|
+
// while maintaining aspect ratio, with the overlay bar below
|
|
31
|
+
return `<!DOCTYPE html>
|
|
32
|
+
<html>
|
|
33
|
+
<head>
|
|
34
|
+
<meta charset="UTF-8">
|
|
35
|
+
<style>
|
|
36
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
37
|
+
html, body { width: 100%; height: 100%; overflow: hidden; background: #111; }
|
|
38
|
+
#pellicule-canvas {
|
|
39
|
+
width: ${width}px;
|
|
40
|
+
height: ${height}px;
|
|
41
|
+
transform-origin: top left;
|
|
42
|
+
position: absolute;
|
|
43
|
+
top: 0;
|
|
44
|
+
left: 0;
|
|
45
|
+
}
|
|
46
|
+
#app { width: 100%; height: 100%; }
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<div id="pellicule-canvas">
|
|
51
|
+
<div id="app"></div>
|
|
52
|
+
</div>
|
|
53
|
+
<script type="module" src="./entry.js"></script>
|
|
54
|
+
<script>
|
|
55
|
+
(function() {
|
|
56
|
+
const VIDEO_W = ${width};
|
|
57
|
+
const VIDEO_H = ${height};
|
|
58
|
+
const OVERLAY_H = 64;
|
|
59
|
+
const canvas = document.getElementById('pellicule-canvas');
|
|
60
|
+
|
|
61
|
+
function fitToWindow() {
|
|
62
|
+
const winW = window.innerWidth;
|
|
63
|
+
const winH = window.innerHeight - OVERLAY_H;
|
|
64
|
+
const scale = Math.min(winW / VIDEO_W, winH / VIDEO_H);
|
|
65
|
+
const offsetX = (winW - VIDEO_W * scale) / 2;
|
|
66
|
+
const offsetY = (winH - VIDEO_H * scale) / 2;
|
|
67
|
+
canvas.style.transform = 'scale(' + scale + ')';
|
|
68
|
+
canvas.style.left = offsetX + 'px';
|
|
69
|
+
canvas.style.top = offsetY + 'px';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fitToWindow();
|
|
73
|
+
window.addEventListener('resize', fitToWindow);
|
|
74
|
+
})();
|
|
75
|
+
</script>
|
|
76
|
+
${overlayHtml}
|
|
77
|
+
</body>
|
|
78
|
+
</html>`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Render mode: fixed pixel dimensions matching Playwright viewport
|
|
21
82
|
return `<!DOCTYPE html>
|
|
22
83
|
<html>
|
|
23
84
|
<head>
|
|
@@ -99,16 +160,20 @@ try {
|
|
|
99
160
|
* @param {string} options.inputPath - Absolute path to the .vue file
|
|
100
161
|
* @param {number} options.width
|
|
101
162
|
* @param {number} options.height
|
|
163
|
+
* @param {boolean} [options.preview] - Whether to inject the dev overlay
|
|
164
|
+
* @param {number} [options.fps] - FPS (used for overlay)
|
|
165
|
+
* @param {number} [options.durationInFrames] - Total frames (used for overlay)
|
|
166
|
+
* @param {string} [options.version] - Package version (shown in overlay)
|
|
102
167
|
* @returns {Promise<{ tempDir: string, cleanup: () => Promise<void> }>}
|
|
103
168
|
*/
|
|
104
|
-
export async function writeTempEntry({ inputPath, width = 1920, height = 1080 }) {
|
|
169
|
+
export async function writeTempEntry({ inputPath, width = 1920, height = 1080, preview = false, fps = 30, durationInFrames = 90, version = '' }) {
|
|
105
170
|
const inputDir = join(inputPath, '..')
|
|
106
171
|
const inputFile = basename(inputPath)
|
|
107
172
|
const tempDir = join(inputDir, '.pellicule')
|
|
108
173
|
|
|
109
174
|
await mkdir(tempDir, { recursive: true })
|
|
110
175
|
|
|
111
|
-
const html = generateHtml({ width, height })
|
|
176
|
+
const html = generateHtml({ width, height, preview, fps, durationInFrames, version })
|
|
112
177
|
const js = generateEntryJs({
|
|
113
178
|
componentPath: `../${inputFile}`,
|
|
114
179
|
width,
|
package/src/bundler/rsbuild.js
CHANGED
|
@@ -50,6 +50,10 @@ async function loadUserConfig(configFile, projectType) {
|
|
|
50
50
|
* @param {number} options.height - Video height
|
|
51
51
|
* @param {string|null} [options.configFile] - Path to the user's config file
|
|
52
52
|
* @param {'rsbuild'|'shipwright'} [options.projectType] - Which config format to read
|
|
53
|
+
* @param {boolean} [options.preview] - Whether to inject the dev preview overlay
|
|
54
|
+
* @param {number} [options.fps] - FPS (passed to overlay when preview=true)
|
|
55
|
+
* @param {number} [options.durationInFrames] - Total frames (passed to overlay when preview=true)
|
|
56
|
+
* @param {string} [options.version] - Package version (shown in overlay)
|
|
53
57
|
* @returns {Promise<{ server: object, url: string, cleanup: function, tempDir: string }>}
|
|
54
58
|
*/
|
|
55
59
|
export async function createVideoServer(options) {
|
|
@@ -58,7 +62,11 @@ export async function createVideoServer(options) {
|
|
|
58
62
|
width = 1920,
|
|
59
63
|
height = 1080,
|
|
60
64
|
configFile = null,
|
|
61
|
-
projectType = 'rsbuild'
|
|
65
|
+
projectType = 'rsbuild',
|
|
66
|
+
preview = false,
|
|
67
|
+
fps = 30,
|
|
68
|
+
durationInFrames = 90,
|
|
69
|
+
version = ''
|
|
62
70
|
} = options
|
|
63
71
|
|
|
64
72
|
// Resolve Rsbuild from the user's project (not from pellicule's location).
|
|
@@ -86,7 +94,11 @@ export async function createVideoServer(options) {
|
|
|
86
94
|
const { tempDir, cleanup: cleanupTemp } = await writeTempEntry({
|
|
87
95
|
inputPath,
|
|
88
96
|
width,
|
|
89
|
-
height
|
|
97
|
+
height,
|
|
98
|
+
preview,
|
|
99
|
+
fps,
|
|
100
|
+
durationInFrames,
|
|
101
|
+
version
|
|
90
102
|
})
|
|
91
103
|
|
|
92
104
|
// Load @rsbuild/plugin-vue (also resolved from user's project)
|
package/src/bundler/vite.js
CHANGED
|
@@ -22,10 +22,14 @@ const pelliculeSrc = resolve(__dirname, '..')
|
|
|
22
22
|
* @param {number} options.width - Video width
|
|
23
23
|
* @param {number} options.height - Video height
|
|
24
24
|
* @param {string|null} [options.configFile] - Path to the user's vite.config.js (auto-detected or explicit)
|
|
25
|
+
* @param {boolean} [options.preview] - Whether to inject the dev preview overlay
|
|
26
|
+
* @param {number} [options.fps] - FPS (passed to overlay when preview=true)
|
|
27
|
+
* @param {number} [options.durationInFrames] - Total frames (passed to overlay when preview=true)
|
|
28
|
+
* @param {string} [options.version] - Package version (shown in overlay)
|
|
25
29
|
* @returns {Promise<{ server: object, url: string, cleanup: function, tempDir: string }>}
|
|
26
30
|
*/
|
|
27
31
|
export async function createVideoServer(options) {
|
|
28
|
-
const { input, width = 1920, height = 1080, configFile = null } = options
|
|
32
|
+
const { input, width = 1920, height = 1080, configFile = null, preview = false, fps = 30, durationInFrames = 90, version = '' } = options
|
|
29
33
|
|
|
30
34
|
const inputPath = resolve(input)
|
|
31
35
|
|
|
@@ -33,7 +37,11 @@ export async function createVideoServer(options) {
|
|
|
33
37
|
const { tempDir, cleanup: cleanupTemp } = await writeTempEntry({
|
|
34
38
|
inputPath,
|
|
35
39
|
width,
|
|
36
|
-
height
|
|
40
|
+
height,
|
|
41
|
+
preview,
|
|
42
|
+
fps,
|
|
43
|
+
durationInFrames,
|
|
44
|
+
version
|
|
37
45
|
})
|
|
38
46
|
|
|
39
47
|
// Resolve Vue from the user's project to avoid duplicate Vue runtimes.
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the preview overlay HTML/CSS/JS that gets injected into the entry page.
|
|
3
|
+
*
|
|
4
|
+
* This is a self-contained vanilla JS overlay — no Vue dependency.
|
|
5
|
+
* It controls frames via the same `window.__PELLICULE_SET_FRAME__()` interface
|
|
6
|
+
* that Playwright uses during rendering, ensuring WYSIWYG fidelity.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @param {number} options.fps
|
|
10
|
+
* @param {number} options.durationInFrames
|
|
11
|
+
* @param {string} options.version
|
|
12
|
+
* @returns {string} Script tag contents to inject
|
|
13
|
+
*/
|
|
14
|
+
export function generateOverlayScript({ fps = 30, durationInFrames = 90, version = '' }) {
|
|
15
|
+
// Inline SVG icons — consistent sizing across all platforms.
|
|
16
|
+
// These are static, trusted strings generated at build time (not user input).
|
|
17
|
+
const playIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"/></svg>'
|
|
18
|
+
const pauseIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/></svg>'
|
|
19
|
+
const prevIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="19,3 7,12 19,21"/><rect x="5" y="3" width="3" height="18"/></svg>'
|
|
20
|
+
const nextIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 17,12 5,21"/><rect x="16" y="3" width="3" height="18"/></svg>'
|
|
21
|
+
|
|
22
|
+
return `
|
|
23
|
+
<style>
|
|
24
|
+
#pellicule-overlay {
|
|
25
|
+
position: fixed;
|
|
26
|
+
bottom: 0;
|
|
27
|
+
left: 0;
|
|
28
|
+
right: 0;
|
|
29
|
+
z-index: 99999;
|
|
30
|
+
background: rgba(15, 15, 15, 0.92);
|
|
31
|
+
backdrop-filter: blur(8px);
|
|
32
|
+
border-top: 1px solid rgba(66, 184, 131, 0.3);
|
|
33
|
+
padding: 8px 12px;
|
|
34
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
35
|
+
font-size: 12px;
|
|
36
|
+
color: #e0e0e0;
|
|
37
|
+
user-select: none;
|
|
38
|
+
}
|
|
39
|
+
#pellicule-overlay .po-top {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 8px;
|
|
43
|
+
}
|
|
44
|
+
#pellicule-overlay .po-bottom {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 8px;
|
|
48
|
+
margin-top: 6px;
|
|
49
|
+
}
|
|
50
|
+
#pellicule-overlay .po-brand-group {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: baseline;
|
|
53
|
+
gap: 5px;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
flex-shrink: 0;
|
|
56
|
+
}
|
|
57
|
+
#pellicule-overlay .po-brand {
|
|
58
|
+
color: #42b883;
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
font-size: 11px;
|
|
61
|
+
letter-spacing: 0.5px;
|
|
62
|
+
text-transform: uppercase;
|
|
63
|
+
}
|
|
64
|
+
#pellicule-overlay .po-version {
|
|
65
|
+
color: rgba(66, 184, 131, 0.5);
|
|
66
|
+
font-size: 9px;
|
|
67
|
+
font-weight: 500;
|
|
68
|
+
}
|
|
69
|
+
#pellicule-overlay .po-controls {
|
|
70
|
+
display: flex;
|
|
71
|
+
gap: 4px;
|
|
72
|
+
flex-shrink: 0;
|
|
73
|
+
}
|
|
74
|
+
#pellicule-overlay .po-btn {
|
|
75
|
+
background: rgba(66, 184, 131, 0.15);
|
|
76
|
+
border: 1px solid rgba(66, 184, 131, 0.3);
|
|
77
|
+
color: #42b883;
|
|
78
|
+
border-radius: 4px;
|
|
79
|
+
width: 28px;
|
|
80
|
+
height: 28px;
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: center;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
transition: background 0.15s;
|
|
86
|
+
flex-shrink: 0;
|
|
87
|
+
padding: 0;
|
|
88
|
+
line-height: 0;
|
|
89
|
+
}
|
|
90
|
+
#pellicule-overlay .po-btn:hover {
|
|
91
|
+
background: rgba(66, 184, 131, 0.25);
|
|
92
|
+
}
|
|
93
|
+
#pellicule-overlay .po-btn svg {
|
|
94
|
+
display: block;
|
|
95
|
+
}
|
|
96
|
+
#pellicule-overlay .po-scrubber {
|
|
97
|
+
flex: 1;
|
|
98
|
+
min-width: 60px;
|
|
99
|
+
-webkit-appearance: none;
|
|
100
|
+
appearance: none;
|
|
101
|
+
height: 4px;
|
|
102
|
+
background: rgba(255, 255, 255, 0.1);
|
|
103
|
+
border-radius: 2px;
|
|
104
|
+
outline: none;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
}
|
|
107
|
+
#pellicule-overlay .po-scrubber::-webkit-slider-thumb {
|
|
108
|
+
-webkit-appearance: none;
|
|
109
|
+
appearance: none;
|
|
110
|
+
width: 14px;
|
|
111
|
+
height: 14px;
|
|
112
|
+
border-radius: 50%;
|
|
113
|
+
background: #42b883;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
border: 2px solid #0f0f0f;
|
|
116
|
+
}
|
|
117
|
+
#pellicule-overlay .po-scrubber::-moz-range-thumb {
|
|
118
|
+
width: 14px;
|
|
119
|
+
height: 14px;
|
|
120
|
+
border-radius: 50%;
|
|
121
|
+
background: #42b883;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
border: 2px solid #0f0f0f;
|
|
124
|
+
}
|
|
125
|
+
#pellicule-overlay .po-info {
|
|
126
|
+
font-variant-numeric: tabular-nums;
|
|
127
|
+
color: #999;
|
|
128
|
+
white-space: nowrap;
|
|
129
|
+
font-size: 11px;
|
|
130
|
+
flex-shrink: 0;
|
|
131
|
+
}
|
|
132
|
+
#pellicule-overlay .po-info span {
|
|
133
|
+
color: #e0e0e0;
|
|
134
|
+
}
|
|
135
|
+
#pellicule-overlay .po-kbd {
|
|
136
|
+
font-size: 10px;
|
|
137
|
+
color: #666;
|
|
138
|
+
white-space: nowrap;
|
|
139
|
+
margin-left: auto;
|
|
140
|
+
flex-shrink: 0;
|
|
141
|
+
}
|
|
142
|
+
#pellicule-overlay .po-kbd kbd {
|
|
143
|
+
background: rgba(255, 255, 255, 0.08);
|
|
144
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
145
|
+
border-radius: 3px;
|
|
146
|
+
padding: 1px 4px;
|
|
147
|
+
font-family: inherit;
|
|
148
|
+
font-size: 10px;
|
|
149
|
+
color: #888;
|
|
150
|
+
}
|
|
151
|
+
/* Responsive: hide keyboard hints on narrow screens */
|
|
152
|
+
@media (max-width: 640px) {
|
|
153
|
+
#pellicule-overlay .po-kbd { display: none; }
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
156
|
+
<div id="pellicule-overlay">
|
|
157
|
+
<div class="po-top">
|
|
158
|
+
<span class="po-brand-group">
|
|
159
|
+
<span class="po-brand">Pellicule</span>
|
|
160
|
+
<span class="po-version">v${version}</span>
|
|
161
|
+
</span>
|
|
162
|
+
<span class="po-controls">
|
|
163
|
+
<button class="po-btn" id="po-play" title="Play / Pause (Space)">${playIcon}</button>
|
|
164
|
+
<button class="po-btn" id="po-prev" title="Previous frame (←)">${prevIcon}</button>
|
|
165
|
+
<button class="po-btn" id="po-next" title="Next frame (→)">${nextIcon}</button>
|
|
166
|
+
</span>
|
|
167
|
+
<span class="po-info">
|
|
168
|
+
<span id="po-frame">0</span> / ${durationInFrames - 1}
|
|
169
|
+
·
|
|
170
|
+
<span id="po-time">0.00s</span> / ${(durationInFrames / fps).toFixed(2)}s
|
|
171
|
+
·
|
|
172
|
+
${fps}fps
|
|
173
|
+
</span>
|
|
174
|
+
<span class="po-kbd">
|
|
175
|
+
<kbd>Space</kbd> play
|
|
176
|
+
<kbd>←</kbd><kbd>→</kbd> step
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="po-bottom">
|
|
180
|
+
<input type="range" class="po-scrubber" id="po-scrubber" min="0" max="${durationInFrames - 1}" value="0">
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<script>
|
|
184
|
+
(function() {
|
|
185
|
+
var FPS = ${fps};
|
|
186
|
+
var TOTAL = ${durationInFrames};
|
|
187
|
+
var FRAME_MS = 1000 / FPS;
|
|
188
|
+
|
|
189
|
+
var currentFrame = 0;
|
|
190
|
+
var playing = false;
|
|
191
|
+
var lastTime = 0;
|
|
192
|
+
|
|
193
|
+
var scrubber = document.getElementById('po-scrubber');
|
|
194
|
+
var frameDisplay = document.getElementById('po-frame');
|
|
195
|
+
var timeDisplay = document.getElementById('po-time');
|
|
196
|
+
var playBtn = document.getElementById('po-play');
|
|
197
|
+
var prevBtn = document.getElementById('po-prev');
|
|
198
|
+
var nextBtn = document.getElementById('po-next');
|
|
199
|
+
|
|
200
|
+
// Pre-create the SVG DOM nodes for play/pause toggle (avoids innerHTML)
|
|
201
|
+
var playTemplate = document.createElement('template');
|
|
202
|
+
playTemplate.innerHTML = '${playIcon}';
|
|
203
|
+
var pauseTemplate = document.createElement('template');
|
|
204
|
+
pauseTemplate.innerHTML = '${pauseIcon}';
|
|
205
|
+
|
|
206
|
+
function setFrame(f) {
|
|
207
|
+
f = Math.max(0, Math.min(TOTAL - 1, f));
|
|
208
|
+
if (f === currentFrame) return;
|
|
209
|
+
currentFrame = f;
|
|
210
|
+
scrubber.value = f;
|
|
211
|
+
frameDisplay.textContent = f;
|
|
212
|
+
timeDisplay.textContent = (f / FPS).toFixed(2) + 's';
|
|
213
|
+
if (window.__PELLICULE_SET_FRAME__) {
|
|
214
|
+
window.__PELLICULE_SET_FRAME__(f);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function togglePlay() {
|
|
219
|
+
playing = !playing;
|
|
220
|
+
// Swap the SVG icon by cloning from the template
|
|
221
|
+
playBtn.replaceChildren(
|
|
222
|
+
(playing ? pauseTemplate : playTemplate).content.cloneNode(true)
|
|
223
|
+
);
|
|
224
|
+
if (playing) {
|
|
225
|
+
lastTime = performance.now();
|
|
226
|
+
requestAnimationFrame(tick);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function tick(now) {
|
|
231
|
+
if (!playing) return;
|
|
232
|
+
var delta = now - lastTime;
|
|
233
|
+
if (delta >= FRAME_MS) {
|
|
234
|
+
var steps = Math.floor(delta / FRAME_MS);
|
|
235
|
+
var nextFrame = currentFrame + steps;
|
|
236
|
+
if (nextFrame >= TOTAL) {
|
|
237
|
+
setFrame(0);
|
|
238
|
+
} else {
|
|
239
|
+
setFrame(nextFrame);
|
|
240
|
+
}
|
|
241
|
+
lastTime = now - (delta % FRAME_MS);
|
|
242
|
+
}
|
|
243
|
+
requestAnimationFrame(tick);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Scrubber interaction
|
|
247
|
+
scrubber.addEventListener('input', function() {
|
|
248
|
+
setFrame(parseInt(scrubber.value, 10));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
playBtn.addEventListener('click', togglePlay);
|
|
252
|
+
prevBtn.addEventListener('click', function() { setFrame(currentFrame - 1); });
|
|
253
|
+
nextBtn.addEventListener('click', function() { setFrame(currentFrame + 1); });
|
|
254
|
+
|
|
255
|
+
// Keyboard shortcuts
|
|
256
|
+
document.addEventListener('keydown', function(e) {
|
|
257
|
+
if (e.target.tagName === 'INPUT' && e.target.type !== 'range') return;
|
|
258
|
+
|
|
259
|
+
switch (e.code) {
|
|
260
|
+
case 'Space':
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
togglePlay();
|
|
263
|
+
break;
|
|
264
|
+
case 'ArrowLeft':
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
if (playing) togglePlay();
|
|
267
|
+
setFrame(currentFrame - 1);
|
|
268
|
+
break;
|
|
269
|
+
case 'ArrowRight':
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
if (playing) togglePlay();
|
|
272
|
+
setFrame(currentFrame + 1);
|
|
273
|
+
break;
|
|
274
|
+
case 'Home':
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
setFrame(0);
|
|
277
|
+
break;
|
|
278
|
+
case 'End':
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
setFrame(TOTAL - 1);
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
})();
|
|
285
|
+
</script>`
|
|
286
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev preview server.
|
|
3
|
+
*
|
|
4
|
+
* Starts the same bundler dev server used for rendering, but instead of
|
|
5
|
+
* launching Playwright to screenshot frames, opens the user's browser
|
|
6
|
+
* with an interactive preview overlay.
|
|
7
|
+
*
|
|
8
|
+
* The overlay uses the same `window.__PELLICULE_SET_FRAME__()` mechanism
|
|
9
|
+
* as the renderer, so what you see in preview is what you get in the
|
|
10
|
+
* final render.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFile } from 'node:child_process'
|
|
14
|
+
import { startBundlerServer } from '../renderer/render.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Open a URL in the user's default browser using platform-native commands.
|
|
18
|
+
* Uses execFile (not exec) to avoid shell injection.
|
|
19
|
+
* @param {string} url
|
|
20
|
+
*/
|
|
21
|
+
function openBrowser(url) {
|
|
22
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
23
|
+
: process.platform === 'win32' ? 'start'
|
|
24
|
+
: 'xdg-open'
|
|
25
|
+
execFile(cmd, [url], () => {})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Start the dev preview server.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {string} options.input - Absolute path to the .vue file
|
|
33
|
+
* @param {number} options.fps
|
|
34
|
+
* @param {number} options.durationInFrames
|
|
35
|
+
* @param {number} options.width
|
|
36
|
+
* @param {number} options.height
|
|
37
|
+
* @param {string|null} [options.serverUrl] - BYOS server URL (Nuxt/Quasar)
|
|
38
|
+
* @param {'vite'|'rsbuild'} [options.bundler]
|
|
39
|
+
* @param {string|null} [options.configFile]
|
|
40
|
+
* @param {string} [options.projectType]
|
|
41
|
+
* @param {string} [options.version] - Package version (shown in overlay)
|
|
42
|
+
* @returns {Promise<void>}
|
|
43
|
+
*/
|
|
44
|
+
export async function startDevServer(options) {
|
|
45
|
+
const {
|
|
46
|
+
input,
|
|
47
|
+
fps = 30,
|
|
48
|
+
durationInFrames = 90,
|
|
49
|
+
width = 1920,
|
|
50
|
+
height = 1080,
|
|
51
|
+
serverUrl = null,
|
|
52
|
+
bundler = 'vite',
|
|
53
|
+
configFile = null,
|
|
54
|
+
projectType = 'standalone',
|
|
55
|
+
version = ''
|
|
56
|
+
} = options
|
|
57
|
+
|
|
58
|
+
let url
|
|
59
|
+
let cleanup
|
|
60
|
+
|
|
61
|
+
if (serverUrl) {
|
|
62
|
+
// BYOS mode (Nuxt/Quasar) — just use the existing server
|
|
63
|
+
url = serverUrl
|
|
64
|
+
cleanup = async () => {}
|
|
65
|
+
} else {
|
|
66
|
+
// Start a bundler dev server with preview overlay enabled
|
|
67
|
+
const server = await startBundlerServer({
|
|
68
|
+
input,
|
|
69
|
+
width,
|
|
70
|
+
height,
|
|
71
|
+
bundler,
|
|
72
|
+
configFile,
|
|
73
|
+
projectType,
|
|
74
|
+
preview: true,
|
|
75
|
+
fps,
|
|
76
|
+
durationInFrames,
|
|
77
|
+
version
|
|
78
|
+
})
|
|
79
|
+
url = server.url
|
|
80
|
+
cleanup = server.cleanup
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build the full URL with config params
|
|
84
|
+
const separator = url.includes('?') ? '&' : '?'
|
|
85
|
+
const fullUrl = `${url}${separator}fps=${fps}&duration=${durationInFrames}&width=${width}&height=${height}`
|
|
86
|
+
|
|
87
|
+
// Open in the user's default browser
|
|
88
|
+
openBrowser(fullUrl)
|
|
89
|
+
|
|
90
|
+
// Keep process alive and handle graceful shutdown
|
|
91
|
+
const shutdown = async () => {
|
|
92
|
+
await cleanup()
|
|
93
|
+
process.exit(0)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
process.on('SIGINT', shutdown)
|
|
97
|
+
process.on('SIGTERM', shutdown)
|
|
98
|
+
|
|
99
|
+
return { url: fullUrl, cleanup }
|
|
100
|
+
}
|
package/src/renderer/render.js
CHANGED
|
@@ -12,9 +12,13 @@ import { join, dirname } from 'path'
|
|
|
12
12
|
* @param {'vite'|'rsbuild'} options.bundler - Which bundler adapter to use
|
|
13
13
|
* @param {string|null} options.configFile - Path to the user's config file
|
|
14
14
|
* @param {string} options.projectType - Detected project type (for Shipwright config reading)
|
|
15
|
+
* @param {boolean} [options.preview] - Whether to inject the dev preview overlay
|
|
16
|
+
* @param {number} [options.fps] - FPS (passed to overlay when preview=true)
|
|
17
|
+
* @param {number} [options.durationInFrames] - Total frames (passed to overlay when preview=true)
|
|
18
|
+
* @param {string} [options.version] - Package version (shown in overlay)
|
|
15
19
|
* @returns {Promise<{ url: string, cleanup: function, tempDir: string }>}
|
|
16
20
|
*/
|
|
17
|
-
async function startBundlerServer(options) {
|
|
21
|
+
export async function startBundlerServer(options) {
|
|
18
22
|
const { bundler = 'vite', ...serverOptions } = options
|
|
19
23
|
|
|
20
24
|
if (bundler === 'rsbuild') {
|