rollberry 0.1.8 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +189 -7
  3. package/dist/capture/actions.d.ts +11 -0
  4. package/dist/capture/actions.js +81 -0
  5. package/dist/capture/browser-install.d.ts +3 -0
  6. package/dist/capture/browser-install.js +1 -1
  7. package/dist/capture/browser.d.ts +13 -0
  8. package/dist/capture/browser.js +16 -10
  9. package/dist/capture/capture.d.ts +8 -0
  10. package/dist/capture/capture.js +566 -55
  11. package/dist/capture/constants.d.ts +15 -0
  12. package/dist/capture/constants.js +7 -0
  13. package/dist/capture/ffmpeg.d.ts +41 -0
  14. package/dist/capture/ffmpeg.js +457 -36
  15. package/dist/capture/logger.d.ts +15 -0
  16. package/dist/capture/preflight.d.ts +4 -0
  17. package/dist/capture/preflight.js +8 -2
  18. package/dist/capture/progress.d.ts +7 -0
  19. package/dist/capture/progress.js +50 -0
  20. package/dist/capture/scroll-plan.d.ts +9 -0
  21. package/dist/capture/scroll-plan.js +7 -1
  22. package/dist/capture/stabilize.d.ts +7 -0
  23. package/dist/capture/stabilize.js +11 -5
  24. package/dist/capture/types.d.ts +216 -0
  25. package/dist/capture/utils.d.ts +14 -0
  26. package/dist/capture/utils.js +30 -3
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +86 -5
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +6 -0
  31. package/dist/options.d.ts +42 -0
  32. package/dist/options.js +229 -115
  33. package/dist/project.d.ts +27 -0
  34. package/dist/project.js +722 -0
  35. package/dist/render-plan.d.ts +103 -0
  36. package/dist/render-plan.js +144 -0
  37. package/dist/run-capture.d.ts +3 -0
  38. package/dist/run-capture.js +20 -10
  39. package/dist/run-render.d.ts +17 -0
  40. package/dist/run-render.js +434 -0
  41. package/dist/version.d.ts +1 -0
  42. package/dist/version.js +1 -0
  43. package/package.json +10 -3
  44. package/rollberry.project.sample.json +92 -0
  45. package/rollberry.project.schema.json +474 -0
@@ -0,0 +1,434 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { AbortError, captureSceneVideo } from './capture/capture.js';
5
+ import { composeVideoClips, probeVideoFile, } from './capture/ffmpeg.js';
6
+ import { createCaptureLogger } from './capture/logger.js';
7
+ import { ensureParentDirectory, sanitizeUrl } from './capture/utils.js';
8
+ import { loadRenderProject } from './project.js';
9
+ import { buildArtifactMetrics, buildCaptureMetrics, buildComposedCaptureResult, buildProbeWarningEvent, buildSceneCaptureArtifact, buildSceneClipProbeFailureMessage, collectProbeWarnings, createRenderPlan, shouldFailOnSceneClipProbe, } from './render-plan.js';
10
+ export async function runRenderCommand(options, progress, signal) {
11
+ const project = await loadRenderProject(options);
12
+ await ensureParentDirectory(project.summaryManifestPath);
13
+ const outputs = [];
14
+ const summaryOutputs = [];
15
+ const startedAt = new Date();
16
+ let overallStatus = 'succeeded';
17
+ for (const output of project.outputs) {
18
+ await Promise.all([
19
+ ensureParentDirectory(output.outPath),
20
+ ensureParentDirectory(output.manifestPath),
21
+ ensureParentDirectory(output.logFilePath),
22
+ ]);
23
+ const logger = createCaptureLogger(output.logFilePath);
24
+ const tempDir = await mkdtemp(join(tmpdir(), 'rollberry-render-'));
25
+ const renderPlan = createRenderPlan({
26
+ output,
27
+ scenes: project.scenes,
28
+ timeoutMs: project.timeoutMs,
29
+ tempDir,
30
+ });
31
+ const startedAt = new Date();
32
+ let capture;
33
+ let captureMetrics;
34
+ let artifactMetrics;
35
+ let finalProbe;
36
+ const warningCodes = new Set();
37
+ await logger.info('render.start', 'Project render started', {
38
+ projectPath: project.projectPath,
39
+ projectName: project.projectName,
40
+ outputName: output.name,
41
+ outPath: output.outPath,
42
+ });
43
+ try {
44
+ const sceneClips = [];
45
+ const sceneArtifacts = [];
46
+ for (const scenePlan of renderPlan.sceneCaptures) {
47
+ await logger.info('scene.capture.start', `Capturing scene clip ${scenePlan.sceneIndex + 1}`, {
48
+ outputName: output.name,
49
+ sceneIndex: scenePlan.sceneIndex,
50
+ sceneName: scenePlan.scene.name,
51
+ sceneUrl: sanitizeUrl(scenePlan.scene.url),
52
+ clipPath: scenePlan.clipPath,
53
+ });
54
+ const rawSceneCapture = await captureSceneVideo(scenePlan.job, logger, progress, signal);
55
+ const sceneProbe = await probeVideoFile(scenePlan.clipPath);
56
+ const sceneProbeWarning = buildProbeWarningEvent({
57
+ target: 'scene-clip',
58
+ index: scenePlan.sceneIndex,
59
+ probe: sceneProbe,
60
+ });
61
+ if (sceneProbeWarning) {
62
+ warningCodes.add(`scene_clip_probe_${sceneProbe.status}`);
63
+ await logger.warn(sceneProbeWarning.event, sceneProbeWarning.message, sceneProbeWarning.metadata);
64
+ }
65
+ if (shouldFailOnSceneClipProbe({
66
+ probe: sceneProbe,
67
+ capabilities: renderPlan.capabilities,
68
+ })) {
69
+ throw new Error(buildSceneClipProbeFailureMessage({
70
+ sceneIndex: scenePlan.sceneIndex,
71
+ probe: sceneProbe,
72
+ capabilities: renderPlan.capabilities,
73
+ }));
74
+ }
75
+ const sceneArtifact = buildSceneCaptureArtifact({
76
+ rawCapture: rawSceneCapture,
77
+ clipPath: scenePlan.clipPath,
78
+ probe: sceneProbe,
79
+ });
80
+ sceneArtifacts.push(sceneArtifact);
81
+ sceneClips.push({
82
+ path: sceneArtifact.clip.path,
83
+ durationSeconds: sceneArtifact.clip.durationSeconds,
84
+ });
85
+ await logger.info('scene.capture.complete', 'Scene clip capture finished', {
86
+ outputName: output.name,
87
+ sceneIndex: scenePlan.sceneIndex,
88
+ clipPath: scenePlan.clipPath,
89
+ frameCount: sceneArtifact.captureMetrics.frameCount,
90
+ durationSeconds: sceneArtifact.captureMetrics.durationSeconds,
91
+ });
92
+ }
93
+ await logger.info('compose.start', 'Composing final output', {
94
+ outputName: output.name,
95
+ outPath: output.outPath,
96
+ sceneCount: sceneClips.length,
97
+ transition: output.transition,
98
+ subtitles: output.subtitles,
99
+ });
100
+ await composeVideoClips({
101
+ clips: sceneClips,
102
+ ...renderPlan.composition,
103
+ });
104
+ finalProbe = await probeVideoFile(renderPlan.composition.outPath);
105
+ const outputProbeWarning = buildProbeWarningEvent({
106
+ target: 'output',
107
+ probe: finalProbe,
108
+ });
109
+ if (outputProbeWarning) {
110
+ warningCodes.add(`output_probe_${finalProbe.status}`);
111
+ await logger.warn(outputProbeWarning.event, outputProbeWarning.message, outputProbeWarning.metadata);
112
+ }
113
+ capture = buildComposedCaptureResult({
114
+ outPath: renderPlan.composition.outPath,
115
+ fps: renderPlan.composition.fps,
116
+ sceneArtifacts,
117
+ transition: renderPlan.composition.transition,
118
+ probe: finalProbe,
119
+ });
120
+ captureMetrics = buildCaptureMetrics(sceneArtifacts);
121
+ artifactMetrics = buildArtifactMetrics({
122
+ captureResult: capture,
123
+ probe: finalProbe,
124
+ });
125
+ for (const warning of collectProbeWarnings(sceneArtifacts, finalProbe)) {
126
+ warningCodes.add(warning);
127
+ }
128
+ await logger.info('compose.complete', 'Final output composed', {
129
+ outputName: output.name,
130
+ outPath: output.outPath,
131
+ frameCount: capture.frameCount,
132
+ durationSeconds: capture.durationSeconds,
133
+ });
134
+ const finishedAt = new Date();
135
+ const warnings = finalizeWarnings(warningCodes, capture, captureMetrics);
136
+ const manifest = buildRenderManifest({
137
+ status: 'succeeded',
138
+ projectPath: project.projectPath,
139
+ projectName: project.projectName,
140
+ timeoutMs: project.timeoutMs,
141
+ scenes: project.scenes,
142
+ output,
143
+ startedAt,
144
+ finishedAt,
145
+ warnings,
146
+ videoCreated: true,
147
+ captureMetrics,
148
+ artifactMetrics,
149
+ });
150
+ await writeManifest(output.manifestPath, manifest);
151
+ await logger.info('render.complete', 'Project render finished', {
152
+ outputName: output.name,
153
+ outPath: output.outPath,
154
+ frameCount: capture.frameCount,
155
+ durationSeconds: capture.durationSeconds,
156
+ });
157
+ outputs.push({
158
+ name: output.name,
159
+ fps: output.fps,
160
+ format: output.format,
161
+ capture,
162
+ manifestPath: output.manifestPath,
163
+ logFilePath: output.logFilePath,
164
+ });
165
+ summaryOutputs.push({
166
+ name: output.name,
167
+ format: output.format,
168
+ status: 'succeeded',
169
+ videoPath: output.outPath,
170
+ manifestPath: output.manifestPath,
171
+ logFilePath: output.logFilePath,
172
+ frameCount: capture.frameCount,
173
+ durationSeconds: capture.durationSeconds,
174
+ warnings,
175
+ });
176
+ }
177
+ catch (error) {
178
+ const finishedAt = new Date();
179
+ const isCancelled = error instanceof AbortError;
180
+ const warnings = finalizeWarnings(warningCodes, capture, captureMetrics);
181
+ const manifest = buildRenderManifest({
182
+ status: isCancelled ? 'cancelled' : 'failed',
183
+ projectPath: project.projectPath,
184
+ projectName: project.projectName,
185
+ timeoutMs: project.timeoutMs,
186
+ scenes: project.scenes,
187
+ output,
188
+ startedAt,
189
+ finishedAt,
190
+ warnings,
191
+ videoCreated: capture !== undefined,
192
+ captureMetrics,
193
+ artifactMetrics: artifactMetrics ??
194
+ (capture && finalProbe
195
+ ? buildArtifactMetrics({
196
+ captureResult: capture,
197
+ probe: finalProbe,
198
+ })
199
+ : undefined),
200
+ error: isCancelled ? undefined : error,
201
+ });
202
+ await logger.error(isCancelled ? 'render.cancelled' : 'render.failed', isCancelled ? 'Project render cancelled' : 'Project render failed', {
203
+ outputName: output.name,
204
+ manifestPath: output.manifestPath,
205
+ name: manifest.error?.name,
206
+ message: manifest.error?.message,
207
+ });
208
+ await writeManifest(output.manifestPath, manifest);
209
+ if (isCancelled) {
210
+ summaryOutputs.push({
211
+ name: output.name,
212
+ format: output.format,
213
+ status: 'cancelled',
214
+ videoPath: output.outPath,
215
+ manifestPath: output.manifestPath,
216
+ logFilePath: output.logFilePath,
217
+ frameCount: capture?.frameCount,
218
+ durationSeconds: capture?.durationSeconds,
219
+ warnings,
220
+ });
221
+ overallStatus = 'cancelled';
222
+ await writeSummaryManifest(project.summaryManifestPath, buildRenderSummaryManifest({
223
+ status: overallStatus,
224
+ projectPath: project.projectPath,
225
+ projectName: project.projectName,
226
+ summaryManifestPath: project.summaryManifestPath,
227
+ startedAt,
228
+ finishedAt,
229
+ outputs: summaryOutputs,
230
+ }));
231
+ await logger.close();
232
+ throw error;
233
+ }
234
+ overallStatus = 'failed';
235
+ summaryOutputs.push({
236
+ name: output.name,
237
+ format: output.format,
238
+ status: 'failed',
239
+ videoPath: output.outPath,
240
+ manifestPath: output.manifestPath,
241
+ logFilePath: output.logFilePath,
242
+ frameCount: capture?.frameCount,
243
+ durationSeconds: capture?.durationSeconds,
244
+ warnings,
245
+ error: manifest.error
246
+ ? {
247
+ name: manifest.error.name,
248
+ message: manifest.error.message,
249
+ }
250
+ : undefined,
251
+ });
252
+ }
253
+ finally {
254
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
255
+ await logger.close();
256
+ }
257
+ }
258
+ const finishedAt = new Date();
259
+ await writeSummaryManifest(project.summaryManifestPath, buildRenderSummaryManifest({
260
+ status: overallStatus,
261
+ projectPath: project.projectPath,
262
+ projectName: project.projectName,
263
+ summaryManifestPath: project.summaryManifestPath,
264
+ startedAt,
265
+ finishedAt,
266
+ outputs: summaryOutputs,
267
+ }));
268
+ if (overallStatus === 'failed') {
269
+ throw new Error(`Render failed for outputs: ${summaryOutputs
270
+ .filter((output) => output.status === 'failed')
271
+ .map((output) => output.name)
272
+ .join(', ')}`);
273
+ }
274
+ return {
275
+ projectPath: project.projectPath,
276
+ summaryManifestPath: project.summaryManifestPath,
277
+ outputs,
278
+ };
279
+ }
280
+ async function writeManifest(manifestPath, manifest) {
281
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
282
+ }
283
+ async function writeSummaryManifest(manifestPath, manifest) {
284
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
285
+ }
286
+ function buildRenderManifest(input) {
287
+ return {
288
+ schemaVersion: 2,
289
+ kind: 'render',
290
+ status: input.status,
291
+ startedAt: input.startedAt.toISOString(),
292
+ finishedAt: input.finishedAt.toISOString(),
293
+ durationMs: input.finishedAt.getTime() - input.startedAt.getTime(),
294
+ environment: {
295
+ nodeVersion: process.version,
296
+ platform: process.platform,
297
+ arch: process.arch,
298
+ },
299
+ project: {
300
+ path: input.projectPath,
301
+ name: input.projectName,
302
+ timeoutMs: input.timeoutMs,
303
+ },
304
+ output: {
305
+ name: input.output.name,
306
+ format: input.output.format,
307
+ viewport: input.output.viewport,
308
+ fps: input.output.fps,
309
+ videoPath: input.output.outPath,
310
+ manifestPath: input.output.manifestPath,
311
+ logFilePath: input.output.logFilePath,
312
+ debugFramesDir: input.output.debugFramesDir,
313
+ audio: input.output.audio,
314
+ subtitles: input.output.subtitles,
315
+ transition: input.output.transition,
316
+ intermediateArtifact: input.output.intermediateArtifact,
317
+ finalVideo: input.output.finalVideo,
318
+ videoCreated: input.videoCreated,
319
+ },
320
+ scenes: input.scenes.map((scene) => ({
321
+ name: scene.name,
322
+ url: sanitizeUrl(scene.url),
323
+ duration: scene.duration,
324
+ motion: scene.motion,
325
+ waitFor: scene.waitFor,
326
+ hideSelectors: scene.hideSelectors,
327
+ holdAfterSeconds: scene.holdAfterSeconds,
328
+ actions: scene.actions.map(serializeAction),
329
+ timeline: scene.timeline.map(serializeTimelineSegment),
330
+ })),
331
+ captureMetrics: input.captureMetrics,
332
+ artifactMetrics: input.artifactMetrics,
333
+ warnings: input.warnings,
334
+ error: input.error ? serializeError(input.error) : undefined,
335
+ };
336
+ }
337
+ function finalizeWarnings(warningCodes, capture, captureMetrics) {
338
+ const warnings = new Set(warningCodes);
339
+ if (capture?.truncated || captureMetrics?.truncated) {
340
+ warnings.add('scroll_height_truncated');
341
+ }
342
+ return [...warnings];
343
+ }
344
+ function buildRenderSummaryManifest(input) {
345
+ return {
346
+ schemaVersion: 1,
347
+ kind: 'render-summary',
348
+ status: input.status,
349
+ startedAt: input.startedAt.toISOString(),
350
+ finishedAt: input.finishedAt.toISOString(),
351
+ durationMs: input.finishedAt.getTime() - input.startedAt.getTime(),
352
+ project: {
353
+ path: input.projectPath,
354
+ name: input.projectName,
355
+ summaryManifestPath: input.summaryManifestPath,
356
+ },
357
+ outputs: input.outputs,
358
+ };
359
+ }
360
+ function serializeAction(action) {
361
+ switch (action.kind) {
362
+ case 'wait':
363
+ return { kind: action.kind, ms: action.ms };
364
+ case 'click':
365
+ case 'hover':
366
+ return { kind: action.kind, selector: action.selector };
367
+ case 'press':
368
+ return { kind: action.kind, key: action.key };
369
+ case 'type':
370
+ return {
371
+ kind: action.kind,
372
+ selector: action.selector,
373
+ clear: action.clear,
374
+ textLength: action.text.length,
375
+ };
376
+ case 'scroll-to':
377
+ return {
378
+ kind: action.kind,
379
+ selector: action.selector,
380
+ block: action.block,
381
+ };
382
+ }
383
+ }
384
+ function serializeTimelineSegment(segment) {
385
+ switch (segment.kind) {
386
+ case 'pause':
387
+ return {
388
+ kind: segment.kind,
389
+ durationSeconds: segment.durationSeconds,
390
+ };
391
+ case 'scroll':
392
+ return {
393
+ kind: segment.kind,
394
+ duration: segment.duration,
395
+ motion: segment.motion,
396
+ target: serializeTimelineTarget(segment.target),
397
+ };
398
+ case 'action':
399
+ return {
400
+ kind: segment.kind,
401
+ holdAfterSeconds: segment.holdAfterSeconds,
402
+ action: serializeAction(segment.action),
403
+ };
404
+ }
405
+ }
406
+ function serializeTimelineTarget(target) {
407
+ switch (target.kind) {
408
+ case 'bottom':
409
+ return { kind: target.kind };
410
+ case 'absolute':
411
+ return { kind: target.kind, top: target.top };
412
+ case 'relative':
413
+ return { kind: target.kind, delta: target.delta };
414
+ case 'selector':
415
+ return {
416
+ kind: target.kind,
417
+ selector: target.selector,
418
+ block: target.block,
419
+ };
420
+ }
421
+ }
422
+ function serializeError(error) {
423
+ if (error instanceof Error) {
424
+ return {
425
+ name: error.name,
426
+ message: error.message,
427
+ stack: error.stack,
428
+ };
429
+ }
430
+ return {
431
+ name: 'Error',
432
+ message: typeof error === 'string' ? error : 'Unknown error',
433
+ };
434
+ }
@@ -0,0 +1 @@
1
+ export declare const VERSION = "0.1.9";
@@ -0,0 +1 @@
1
+ export const VERSION = '0.1.9';
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "rollberry",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "CLI to capture smooth top-to-bottom scroll videos from web pages, including localhost URLs.",
5
5
  "type": "module",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js",
9
+ "./package.json": "./package.json"
10
+ },
6
11
  "packageManager": "pnpm@10.15.0",
7
12
  "author": "CORe Inc.",
8
13
  "license": "MIT",
@@ -29,7 +34,9 @@
29
34
  "LICENSE",
30
35
  "README.md",
31
36
  "SECURITY.md",
32
- "regression.sample.json"
37
+ "regression.sample.json",
38
+ "rollberry.project.schema.json",
39
+ "rollberry.project.sample.json"
33
40
  ],
34
41
  "bin": {
35
42
  "rollberry": "dist/cli.js"
@@ -50,7 +57,7 @@
50
57
  "playwright": "1.58.2"
51
58
  },
52
59
  "devDependencies": {
53
- "@biomejs/biome": "2.4.7",
60
+ "@biomejs/biome": "2.4.8",
54
61
  "@types/node": "25.5.0",
55
62
  "selfsigned": "5.5.0",
56
63
  "tsx": "4.21.0",
@@ -0,0 +1,92 @@
1
+ {
2
+ "$schema": "./rollberry.project.schema.json",
3
+ "schemaVersion": 1,
4
+ "name": "Rollberry Demo Project",
5
+ "summaryManifest": "./artifacts/demo.render-summary.json",
6
+ "defaults": {
7
+ "fps": 60,
8
+ "viewport": "1440x900",
9
+ "duration": "auto",
10
+ "waitFor": "selector:body",
11
+ "hideSelectors": ["#cookie-banner", ".intercom-lightweight-app"]
12
+ },
13
+ "scenes": [
14
+ {
15
+ "name": "homepage",
16
+ "url": "http://localhost:3000",
17
+ "actions": [{ "type": "wait", "ms": 300 }],
18
+ "timeline": [
19
+ { "type": "pause", "duration": 0.4 },
20
+ {
21
+ "type": "click",
22
+ "selector": "[data-open-menu]",
23
+ "holdAfterSeconds": 0.4
24
+ },
25
+ { "type": "scroll", "toSelector": "#pricing", "duration": 1.4 },
26
+ { "type": "scroll", "to": "bottom", "duration": "auto" }
27
+ ],
28
+ "holdAfterSeconds": 0.75
29
+ },
30
+ {
31
+ "name": "pricing",
32
+ "url": "http://localhost:3000/pricing"
33
+ }
34
+ ],
35
+ "outputs": [
36
+ {
37
+ "name": "desktop",
38
+ "viewport": "1440x900",
39
+ "out": "./artifacts/demo-desktop.mp4",
40
+ "audio": {
41
+ "path": "./assets/demo-narration.wav",
42
+ "volume": 0.8,
43
+ "loop": true
44
+ },
45
+ "subtitles": {
46
+ "path": "./assets/demo-captions.vtt",
47
+ "mode": "burn-in"
48
+ },
49
+ "transition": {
50
+ "type": "crossfade",
51
+ "duration": 0.25
52
+ },
53
+ "intermediateArtifact": {
54
+ "format": "mp4",
55
+ "preset": "veryfast",
56
+ "crf": 20
57
+ },
58
+ "finalVideo": {
59
+ "preset": "medium",
60
+ "crf": 19
61
+ }
62
+ },
63
+ {
64
+ "name": "mobile",
65
+ "viewport": "430x932",
66
+ "out": "./artifacts/demo-mobile.webm",
67
+ "format": "webm",
68
+ "audio": {
69
+ "path": "./assets/demo-narration.wav",
70
+ "volume": 0.7,
71
+ "loop": true
72
+ },
73
+ "subtitles": {
74
+ "path": "./assets/demo-captions.vtt",
75
+ "mode": "soft"
76
+ },
77
+ "transition": {
78
+ "type": "crossfade",
79
+ "duration": 0.25
80
+ },
81
+ "intermediateArtifact": {
82
+ "format": "mp4",
83
+ "preset": "fast",
84
+ "crf": 22
85
+ },
86
+ "finalVideo": {
87
+ "deadline": "best",
88
+ "crf": 30
89
+ }
90
+ }
91
+ ]
92
+ }