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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pellicule",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic video rendering with Vue",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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,
@@ -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)
@@ -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
+ &nbsp;·&nbsp;
170
+ <span id="po-time">0.00s</span> / ${(durationInFrames / fps).toFixed(2)}s
171
+ &nbsp;·&nbsp;
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
+ }
@@ -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') {