@vorec/cli 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/bin/vorec.mjs +2 -0
- package/dist/api.d.ts +3 -0
- package/dist/api.js +33 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +18 -0
- package/dist/commands/record.d.ts +8 -0
- package/dist/commands/record.js +108 -0
- package/dist/commands/run.d.ts +8 -0
- package/dist/commands/run.js +314 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +17 -0
- package/dist/commands/upload.d.ts +5 -0
- package/dist/commands/upload.js +41 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +47 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +41 -0
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +21 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.js +1 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @vorec/cli
|
|
2
|
+
|
|
3
|
+
Turn any web app into a narrated tutorial video. Record your screen, track user actions, and let AI generate professional voice-over narration automatically.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
- Records your web app in 1080p using Playwright
|
|
8
|
+
- Tracks every click, keystroke, and navigation with precise coordinates
|
|
9
|
+
- Converts to MP4 and uploads to [Vorec](https://vorec.ai)
|
|
10
|
+
- AI analyzes the video and writes natural narration
|
|
11
|
+
- Generates voice-over audio in multiple languages and styles
|
|
12
|
+
- Returns an editor URL to preview, adjust, and export
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @vorec/cli@latest init
|
|
18
|
+
npx @vorec/cli@latest run vorec.json --auto
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- **Node.js** 18+
|
|
24
|
+
- **Playwright** — `npm install playwright && npx playwright install chromium`
|
|
25
|
+
- **FFmpeg** — `brew install ffmpeg` (macOS) or `apt install ffmpeg` (Linux)
|
|
26
|
+
- **Vorec API key** — create one at [vorec.ai](https://vorec.ai) → Settings → API Keys
|
|
27
|
+
|
|
28
|
+
## Manifest Format
|
|
29
|
+
|
|
30
|
+
Create a `vorec.json` file describing the actions to record:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"title": "How to create a project",
|
|
35
|
+
"url": "http://localhost:3000/dashboard",
|
|
36
|
+
"viewport": { "width": 1920, "height": 1080 },
|
|
37
|
+
"language": "en",
|
|
38
|
+
"narrationStyle": "tutorial",
|
|
39
|
+
"actions": [
|
|
40
|
+
{ "type": "click", "selector": "button.new-project", "description": "Open the create dialog" },
|
|
41
|
+
{ "type": "type", "selector": "#name", "text": "My Project", "description": "Enter the project name" },
|
|
42
|
+
{ "type": "click", "selector": "button[type=submit]", "description": "Save the project" }
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
| Command | What it does |
|
|
50
|
+
|---------|-------------|
|
|
51
|
+
| `vorec init` | Save your API key |
|
|
52
|
+
| `vorec run <manifest>` | Record and upload |
|
|
53
|
+
| `vorec run <manifest> --auto` | Record, upload, and generate narration |
|
|
54
|
+
| `vorec run <manifest> --auto --skip-record` | Resume an existing project |
|
|
55
|
+
| `vorec upload <video>` | Upload a video file directly |
|
|
56
|
+
| `vorec status` | Check project status |
|
|
57
|
+
|
|
58
|
+
## Narration Styles
|
|
59
|
+
|
|
60
|
+
| Style | Tone |
|
|
61
|
+
|-------|------|
|
|
62
|
+
| `tutorial` | Step-by-step, clear, helpful |
|
|
63
|
+
| `professional` | Polished, enterprise-ready |
|
|
64
|
+
| `conversational` | Casual, like explaining to a colleague |
|
|
65
|
+
| `storytelling` | Engaging, narrative-driven |
|
|
66
|
+
| `concise` | Minimal, just the essentials |
|
|
67
|
+
| `exact` | Neutral, factual, technical |
|
|
68
|
+
|
|
69
|
+
## Claude Code Plugin
|
|
70
|
+
|
|
71
|
+
For the best experience, install the Vorec plugin for Claude Code. Your AI agent will research the codebase, generate the manifest, and run the CLI automatically.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
1. In Claude Code, run /plugin
|
|
75
|
+
2. Go to Marketplaces → Add Marketplace
|
|
76
|
+
3. Enter: MustaphaSteph/vorec-plugins
|
|
77
|
+
4. Select record-tutorial and install it
|
|
78
|
+
5. Enable auto-update
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then just say: *"record a tutorial showing how to create a project"*
|
|
82
|
+
|
|
83
|
+
## Links
|
|
84
|
+
|
|
85
|
+
- [Vorec](https://vorec.ai) — AI-narrated tutorial videos
|
|
86
|
+
- [Plugin repo](https://github.com/MustaphaSteph/vorec-plugins) — Claude Code plugin
|
package/bin/vorec.mjs
ADDED
package/dist/api.d.ts
ADDED
package/dist/api.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadConfig, requireApiKey } from './config.js';
|
|
2
|
+
export async function apiCall(action, body = {}) {
|
|
3
|
+
const config = loadConfig();
|
|
4
|
+
const apiKey = requireApiKey();
|
|
5
|
+
const res = await fetch(config.apiBase, {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: {
|
|
8
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
9
|
+
'Content-Type': 'application/json',
|
|
10
|
+
},
|
|
11
|
+
body: JSON.stringify({ action, ...body }),
|
|
12
|
+
});
|
|
13
|
+
const data = await res.json();
|
|
14
|
+
if (!res.ok && !data.error) {
|
|
15
|
+
throw new Error(`API error ${res.status}`);
|
|
16
|
+
}
|
|
17
|
+
if (data.error) {
|
|
18
|
+
throw new Error(data.error);
|
|
19
|
+
}
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
export async function uploadVideo(uploadUrl, videoPath, contentType = 'video/webm') {
|
|
23
|
+
const { readFileSync } = await import('node:fs');
|
|
24
|
+
const videoData = readFileSync(videoPath);
|
|
25
|
+
const res = await fetch(uploadUrl, {
|
|
26
|
+
method: 'PUT',
|
|
27
|
+
headers: { 'Content-Type': contentType },
|
|
28
|
+
body: videoData,
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function initCommand(): Promise<void>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { saveConfig, loadConfig } from '../config.js';
|
|
3
|
+
export async function initCommand() {
|
|
4
|
+
const config = loadConfig();
|
|
5
|
+
if (config.apiKey) {
|
|
6
|
+
console.log(`Current key: ${config.apiKey.slice(0, 17)}${'*'.repeat(24)}`);
|
|
7
|
+
}
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
10
|
+
const key = await ask('Enter your Vorec API key (vrc_live_...): ');
|
|
11
|
+
rl.close();
|
|
12
|
+
if (!key.startsWith('vrc_live_')) {
|
|
13
|
+
console.error('Invalid key format. Keys start with vrc_live_');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
saveConfig({ apiKey: key.trim() });
|
|
17
|
+
console.log('API key saved to ~/.vorec/config.json');
|
|
18
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
export async function recordCommand(opts) {
|
|
4
|
+
let pw;
|
|
5
|
+
try {
|
|
6
|
+
// @ts-ignore — playwright is an optional peer dependency
|
|
7
|
+
pw = await import('playwright');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
try {
|
|
11
|
+
const { createRequire } = await import('node:module');
|
|
12
|
+
const req = createRequire(process.cwd() + '/package.json');
|
|
13
|
+
pw = req('playwright');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
console.error('Playwright is required for recording.\n' +
|
|
17
|
+
'Install it with: npm install playwright && npx playwright install chromium');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const { readFileSync, mkdirSync } = await import('node:fs');
|
|
22
|
+
const actions = JSON.parse(readFileSync(opts.actions, 'utf-8'));
|
|
23
|
+
const [vw, vh] = opts.viewport.split('x').map(Number);
|
|
24
|
+
const viewport = { width: vw || 1920, height: vh || 1080 };
|
|
25
|
+
console.log(`Recording ${opts.url} (${viewport.width}x${viewport.height})...`);
|
|
26
|
+
console.log(`Actions: ${actions.length}`);
|
|
27
|
+
mkdirSync('.vorec/recordings', { recursive: true });
|
|
28
|
+
const browser = await pw.chromium.launch({ headless: false });
|
|
29
|
+
const context = await browser.newContext({
|
|
30
|
+
viewport,
|
|
31
|
+
recordVideo: { dir: resolve('.vorec/recordings'), size: viewport },
|
|
32
|
+
});
|
|
33
|
+
const page = await context.newPage();
|
|
34
|
+
const startTime = Date.now();
|
|
35
|
+
const tracked = [];
|
|
36
|
+
await page.goto(opts.url, { waitUntil: 'networkidle' });
|
|
37
|
+
for (const [i, action] of actions.entries()) {
|
|
38
|
+
try {
|
|
39
|
+
await page.waitForSelector(action.selector, { timeout: 10000 });
|
|
40
|
+
const element = await page.$(action.selector);
|
|
41
|
+
if (!element)
|
|
42
|
+
throw new Error(`Element not found: ${action.selector}`);
|
|
43
|
+
const box = await element.boundingBox();
|
|
44
|
+
const timestamp = (Date.now() - startTime) / 1000;
|
|
45
|
+
const coords = box ? {
|
|
46
|
+
x: Math.round((box.x + box.width / 2) / viewport.width * 1000),
|
|
47
|
+
y: Math.round((box.y + box.height / 2) / viewport.height * 1000),
|
|
48
|
+
} : { x: 500, y: 500 };
|
|
49
|
+
switch (action.type) {
|
|
50
|
+
case 'click':
|
|
51
|
+
await element.click();
|
|
52
|
+
break;
|
|
53
|
+
case 'type':
|
|
54
|
+
await element.click();
|
|
55
|
+
if (action.text)
|
|
56
|
+
await page.keyboard.type(action.text, { delay: 50 });
|
|
57
|
+
break;
|
|
58
|
+
case 'select':
|
|
59
|
+
if (action.value)
|
|
60
|
+
await page.selectOption(action.selector, action.value);
|
|
61
|
+
break;
|
|
62
|
+
case 'hover':
|
|
63
|
+
await element.hover();
|
|
64
|
+
break;
|
|
65
|
+
case 'scroll':
|
|
66
|
+
await page.mouse.wheel(0, 300);
|
|
67
|
+
break;
|
|
68
|
+
case 'wait':
|
|
69
|
+
await page.waitForTimeout(action.delay || 1000);
|
|
70
|
+
break;
|
|
71
|
+
case 'navigate':
|
|
72
|
+
if (action.text)
|
|
73
|
+
await page.goto(action.text, { waitUntil: 'networkidle' });
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
tracked.push({
|
|
77
|
+
type: action.type,
|
|
78
|
+
timestamp,
|
|
79
|
+
coordinates: coords,
|
|
80
|
+
target: action.selector || '',
|
|
81
|
+
interaction_type: action.type === 'type' ? 'click' : action.type,
|
|
82
|
+
});
|
|
83
|
+
console.log(` [${i + 1}/${actions.length}] ${action.type} ${action.selector} @ ${timestamp.toFixed(1)}s`);
|
|
84
|
+
if (action.delay)
|
|
85
|
+
await page.waitForTimeout(action.delay);
|
|
86
|
+
else {
|
|
87
|
+
await page.waitForLoadState('networkidle').catch(() => { });
|
|
88
|
+
await page.waitForTimeout(500);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(` [${i + 1}] Failed: ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await page.waitForTimeout(1000);
|
|
96
|
+
const video = page.video();
|
|
97
|
+
await page.close();
|
|
98
|
+
await context.close();
|
|
99
|
+
await browser.close();
|
|
100
|
+
const videoPath = video ? await video.path() : null;
|
|
101
|
+
// Output: video path + tracked actions
|
|
102
|
+
const output = { video: videoPath, actions: tracked, recorded_at: new Date().toISOString() };
|
|
103
|
+
writeFileSync(opts.output, JSON.stringify(output, null, 2));
|
|
104
|
+
console.log(`\nRecording complete!`);
|
|
105
|
+
if (videoPath)
|
|
106
|
+
console.log(`Video: ${videoPath}`);
|
|
107
|
+
console.log(`Actions: ${opts.output}`);
|
|
108
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { loadManifest } from '../manifest.js';
|
|
5
|
+
import { apiCall, uploadVideo } from '../api.js';
|
|
6
|
+
import { saveProjectState, loadProjectState } from '../config.js';
|
|
7
|
+
export async function runCommand(manifestPath, opts) {
|
|
8
|
+
const manifest = loadManifest(manifestPath);
|
|
9
|
+
console.log(chalk.bold(`Vorec: ${manifest.title}`));
|
|
10
|
+
console.log(`Actions: ${manifest.actions.length}, URL: ${manifest.url}\n`);
|
|
11
|
+
// Check if we're resuming an existing project (--skip-record with saved state)
|
|
12
|
+
const savedState = loadProjectState();
|
|
13
|
+
const resuming = opts.skipRecord && savedState?.projectId && savedState?.status === 'analyzing';
|
|
14
|
+
if (resuming) {
|
|
15
|
+
// Resume: skip recording + upload, go straight to analysis
|
|
16
|
+
console.log(`Resuming project: ${savedState.projectId}`);
|
|
17
|
+
if (opts.auto) {
|
|
18
|
+
await runAnalysis(savedState.projectId, manifest);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const status = await apiCall('get-status', { projectId: savedState.projectId });
|
|
22
|
+
console.log(`\n${chalk.bold('Editor:')} ${status.editor_url}`);
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Fresh run: record → upload → (optionally analyze)
|
|
27
|
+
let videoPath = opts.video;
|
|
28
|
+
let trackedActions = [];
|
|
29
|
+
let videoDuration = 0;
|
|
30
|
+
let videoWidth = manifest.viewport?.width || 1920;
|
|
31
|
+
let videoHeight = manifest.viewport?.height || 1080;
|
|
32
|
+
// Step 1: Record with Playwright
|
|
33
|
+
if (!opts.skipRecord && !videoPath) {
|
|
34
|
+
const spinner = ora('Recording...').start();
|
|
35
|
+
try {
|
|
36
|
+
const result = await recordAndTrack(manifest);
|
|
37
|
+
videoPath = result.videoPath;
|
|
38
|
+
trackedActions = result.actions;
|
|
39
|
+
videoDuration = result.duration;
|
|
40
|
+
videoWidth = result.viewport.width;
|
|
41
|
+
videoHeight = result.viewport.height;
|
|
42
|
+
spinner.succeed(`Recorded: ${videoPath} (${trackedActions.length} actions, ${videoDuration.toFixed(1)}s)`);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
spinner.fail(`Recording failed: ${err.message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (opts.trackedActions) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(opts.trackedActions, 'utf-8'));
|
|
52
|
+
trackedActions = Array.isArray(raw) ? raw : (raw.actions || []);
|
|
53
|
+
console.log(`Loaded ${trackedActions.length} tracked actions from ${opts.trackedActions}`);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error(`Failed to load tracked actions: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!videoPath || !existsSync(videoPath)) {
|
|
60
|
+
console.error('No video file. Use --video or enable recording.');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
const fileSize = statSync(videoPath).size;
|
|
64
|
+
const ext = videoPath.split('.').pop()?.toLowerCase() || 'mp4';
|
|
65
|
+
const mimeMap = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska' };
|
|
66
|
+
const contentType = mimeMap[ext] || 'video/mp4';
|
|
67
|
+
// Step 2: Create project + upload
|
|
68
|
+
const spinner = ora('Creating project...').start();
|
|
69
|
+
const createResult = await apiCall('create-project', {
|
|
70
|
+
title: manifest.title,
|
|
71
|
+
contentType,
|
|
72
|
+
fileSize,
|
|
73
|
+
actions: trackedActions.map(a => ({
|
|
74
|
+
type: a.interaction_type || a.type,
|
|
75
|
+
timestamp: a.timestamp,
|
|
76
|
+
coordinates: a.coordinates,
|
|
77
|
+
target: a.target,
|
|
78
|
+
description: a.description,
|
|
79
|
+
context: a.context,
|
|
80
|
+
typed_text: a.typed_text,
|
|
81
|
+
selected_value: a.selected_value,
|
|
82
|
+
})),
|
|
83
|
+
});
|
|
84
|
+
spinner.text = 'Uploading video...';
|
|
85
|
+
await uploadVideo(createResult.upload_url, videoPath, contentType);
|
|
86
|
+
spinner.text = 'Confirming upload...';
|
|
87
|
+
await apiCall('confirm-upload', {
|
|
88
|
+
projectId: createResult.projectId,
|
|
89
|
+
storage_key: createResult.storage_key,
|
|
90
|
+
video_duration: videoDuration || undefined,
|
|
91
|
+
video_width: videoWidth,
|
|
92
|
+
video_height: videoHeight,
|
|
93
|
+
});
|
|
94
|
+
saveProjectState({
|
|
95
|
+
projectId: createResult.projectId,
|
|
96
|
+
storageKey: createResult.storage_key,
|
|
97
|
+
videoPath,
|
|
98
|
+
status: 'analyzing',
|
|
99
|
+
});
|
|
100
|
+
spinner.succeed(`Project created: ${createResult.projectId}`);
|
|
101
|
+
if (!opts.auto) {
|
|
102
|
+
const status = await apiCall('get-status', { projectId: createResult.projectId });
|
|
103
|
+
console.log(`\nVideo uploaded. Run with --auto to continue processing.`);
|
|
104
|
+
console.log(`${chalk.bold('Editor:')} ${status.editor_url}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Auto mode: continue with analysis
|
|
108
|
+
await runAnalysis(createResult.projectId, manifest);
|
|
109
|
+
}
|
|
110
|
+
async function runAnalysis(projectId, manifest) {
|
|
111
|
+
// Analyze video
|
|
112
|
+
const descSpinner = ora('Analyzing video...').start();
|
|
113
|
+
try {
|
|
114
|
+
const descResult = await apiCall('describe-video', {
|
|
115
|
+
projectId,
|
|
116
|
+
videoContext: manifest.videoContext,
|
|
117
|
+
});
|
|
118
|
+
descSpinner.succeed(`Video analyzed (${descResult.description?.length || 0} chars)`);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
descSpinner.fail(`Analysis failed: ${err.message}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
// Generate narration
|
|
125
|
+
const narrSpinner = ora('Writing narration...').start();
|
|
126
|
+
try {
|
|
127
|
+
const narrResult = await apiCall('generate-narration', {
|
|
128
|
+
projectId,
|
|
129
|
+
language: manifest.language,
|
|
130
|
+
narrationStyle: manifest.narrationStyle,
|
|
131
|
+
customPrompt: manifest.customPrompt,
|
|
132
|
+
includeIntro: false,
|
|
133
|
+
});
|
|
134
|
+
narrSpinner.succeed(`${narrResult.segments_created} narration segments`);
|
|
135
|
+
if (narrResult.segments) {
|
|
136
|
+
for (const seg of narrResult.segments) {
|
|
137
|
+
console.log(chalk.dim(` [${seg.sort_order}] ${seg.timestamp_seconds.toFixed(1)}s — ${seg.action_name}`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
narrSpinner.fail(`Narration failed: ${err.message}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
// Done
|
|
146
|
+
const finalStatus = await apiCall('get-status', { projectId });
|
|
147
|
+
console.log(`\n${chalk.bold.green('Done!')} ${finalStatus.segments?.length || 0} segments ready`);
|
|
148
|
+
console.log(`${chalk.bold('Editor:')} ${finalStatus.editor_url}`);
|
|
149
|
+
console.log(chalk.dim('Open the editor to preview narration and generate audio.'));
|
|
150
|
+
// Clear state so next run creates a fresh project
|
|
151
|
+
saveProjectState({ projectId, status: 'complete' });
|
|
152
|
+
}
|
|
153
|
+
async function recordAndTrack(manifest) {
|
|
154
|
+
let pw;
|
|
155
|
+
try {
|
|
156
|
+
// @ts-ignore — playwright is an optional peer dependency
|
|
157
|
+
pw = await import('playwright');
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
try {
|
|
161
|
+
const { createRequire } = await import('node:module');
|
|
162
|
+
const req = createRequire(process.cwd() + '/package.json');
|
|
163
|
+
pw = req('playwright');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new Error('Playwright required. Run: npm install playwright && npx playwright install chromium');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const { execSync } = await import('node:child_process');
|
|
170
|
+
try {
|
|
171
|
+
execSync('ffmpeg -version', { stdio: 'pipe' });
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error('FFmpeg required. Install it: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
|
|
175
|
+
}
|
|
176
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
177
|
+
const { resolve } = await import('node:path');
|
|
178
|
+
const viewport = manifest.viewport || { width: 1920, height: 1080 };
|
|
179
|
+
mkdirSync('.vorec/recordings', { recursive: true });
|
|
180
|
+
const browser = await pw.chromium.launch({ headless: false });
|
|
181
|
+
const contextOpts = {
|
|
182
|
+
viewport,
|
|
183
|
+
recordVideo: { dir: resolve('.vorec/recordings'), size: viewport },
|
|
184
|
+
};
|
|
185
|
+
if (manifest.storageState && existsSync(manifest.storageState)) {
|
|
186
|
+
contextOpts.storageState = manifest.storageState;
|
|
187
|
+
console.log(' Using saved auth session:', manifest.storageState);
|
|
188
|
+
}
|
|
189
|
+
const context = await browser.newContext(contextOpts);
|
|
190
|
+
const page = await context.newPage();
|
|
191
|
+
const startTime = Date.now();
|
|
192
|
+
const tracked = [];
|
|
193
|
+
await page.goto(manifest.url, { waitUntil: 'networkidle' });
|
|
194
|
+
for (const [i, action] of manifest.actions.entries()) {
|
|
195
|
+
try {
|
|
196
|
+
if (action.type === 'wait') {
|
|
197
|
+
await page.waitForTimeout(action.delay || 1000);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (action.type === 'narrate') {
|
|
201
|
+
const timestamp = (Date.now() - startTime) / 1000;
|
|
202
|
+
tracked.push({
|
|
203
|
+
type: 'narrate', timestamp,
|
|
204
|
+
coordinates: { x: 500, y: 500 }, target: '',
|
|
205
|
+
interaction_type: 'narrate',
|
|
206
|
+
description: action.description || 'Scene description',
|
|
207
|
+
context: action.context,
|
|
208
|
+
});
|
|
209
|
+
console.log(` [${i + 1}/${manifest.actions.length}] ${action.description || 'Narrate'} @ ${timestamp.toFixed(1)}s`);
|
|
210
|
+
await page.waitForTimeout(action.delay || 3000);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (action.type === 'navigate') {
|
|
214
|
+
if (action.text)
|
|
215
|
+
await page.goto(action.text, { waitUntil: 'networkidle' });
|
|
216
|
+
tracked.push({
|
|
217
|
+
type: 'navigate', timestamp: (Date.now() - startTime) / 1000,
|
|
218
|
+
coordinates: { x: 500, y: 500 }, target: action.text || manifest.url,
|
|
219
|
+
interaction_type: 'click',
|
|
220
|
+
description: action.description || `Navigate to ${action.text || 'page'}`,
|
|
221
|
+
});
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (action.type === 'scroll') {
|
|
225
|
+
await page.mouse.wheel(0, 300);
|
|
226
|
+
tracked.push({
|
|
227
|
+
type: 'scroll', timestamp: (Date.now() - startTime) / 1000,
|
|
228
|
+
coordinates: { x: 500, y: 500 }, target: 'page',
|
|
229
|
+
interaction_type: 'scroll',
|
|
230
|
+
description: action.description || 'Scroll down',
|
|
231
|
+
});
|
|
232
|
+
if (action.delay)
|
|
233
|
+
await page.waitForTimeout(action.delay);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
await page.waitForSelector(action.selector, { timeout: 10000 });
|
|
237
|
+
const el = await page.$(action.selector);
|
|
238
|
+
const box = el ? await el.boundingBox() : null;
|
|
239
|
+
const timestamp = (Date.now() - startTime) / 1000;
|
|
240
|
+
const coords = box ? {
|
|
241
|
+
x: Math.round((box.x + box.width / 2) / viewport.width * 1000),
|
|
242
|
+
y: Math.round((box.y + box.height / 2) / viewport.height * 1000),
|
|
243
|
+
} : { x: 500, y: 500 };
|
|
244
|
+
switch (action.type) {
|
|
245
|
+
case 'click':
|
|
246
|
+
await el?.click();
|
|
247
|
+
break;
|
|
248
|
+
case 'type':
|
|
249
|
+
await el?.click();
|
|
250
|
+
if (action.text)
|
|
251
|
+
await page.keyboard.type(action.text, { delay: 50 });
|
|
252
|
+
break;
|
|
253
|
+
case 'select':
|
|
254
|
+
if (action.value)
|
|
255
|
+
await page.selectOption(action.selector, action.value);
|
|
256
|
+
break;
|
|
257
|
+
case 'hover':
|
|
258
|
+
await el?.hover();
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
let desc = action.description || '';
|
|
262
|
+
if (!desc) {
|
|
263
|
+
if (action.type === 'type')
|
|
264
|
+
desc = `Type '${action.text || ''}' into ${action.selector}`;
|
|
265
|
+
else if (action.type === 'select')
|
|
266
|
+
desc = `Select '${action.value || ''}' from ${action.selector}`;
|
|
267
|
+
else
|
|
268
|
+
desc = `${action.type} ${action.selector}`;
|
|
269
|
+
}
|
|
270
|
+
tracked.push({
|
|
271
|
+
type: action.type,
|
|
272
|
+
timestamp,
|
|
273
|
+
coordinates: coords,
|
|
274
|
+
target: action.selector,
|
|
275
|
+
interaction_type: action.type === 'type' ? 'type' : action.type,
|
|
276
|
+
description: desc,
|
|
277
|
+
context: action.context,
|
|
278
|
+
typed_text: action.type === 'type' ? action.text : undefined,
|
|
279
|
+
selected_value: action.type === 'select' ? action.value : undefined,
|
|
280
|
+
});
|
|
281
|
+
console.log(` [${i + 1}/${manifest.actions.length}] ${desc} @ ${timestamp.toFixed(1)}s`);
|
|
282
|
+
if (action.delay) {
|
|
283
|
+
await page.waitForTimeout(action.delay);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
await page.waitForLoadState('networkidle').catch(() => { });
|
|
287
|
+
await page.waitForTimeout(500);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
console.error(` [${i + 1}] Failed: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await page.waitForTimeout(1000);
|
|
295
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
296
|
+
const video = page.video();
|
|
297
|
+
await page.close();
|
|
298
|
+
await context.close();
|
|
299
|
+
await browser.close();
|
|
300
|
+
const webmPath = video ? await video.path() : '';
|
|
301
|
+
if (duration < 10) {
|
|
302
|
+
throw new Error(`Recording too short (${duration.toFixed(1)}s). Minimum is 10 seconds. Add more actions or increase delays.`);
|
|
303
|
+
}
|
|
304
|
+
console.log(' Converting to MP4...');
|
|
305
|
+
const mp4Path = webmPath.replace(/\.webm$/, '.mp4');
|
|
306
|
+
execSync(`ffmpeg -y -i "${webmPath}" -c:v libx264 -preset fast -crf 23 -c:a aac "${mp4Path}"`, { stdio: 'pipe' });
|
|
307
|
+
try {
|
|
308
|
+
const { unlinkSync } = await import('node:fs');
|
|
309
|
+
unlinkSync(webmPath);
|
|
310
|
+
}
|
|
311
|
+
catch { }
|
|
312
|
+
writeFileSync('.vorec/tracked-actions.json', JSON.stringify(tracked, null, 2));
|
|
313
|
+
return { videoPath: mp4Path, actions: tracked, duration, viewport };
|
|
314
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { apiCall } from '../api.js';
|
|
3
|
+
import { loadProjectState } from '../config.js';
|
|
4
|
+
export async function statusCommand(opts) {
|
|
5
|
+
const projectId = opts.project || loadProjectState()?.projectId;
|
|
6
|
+
if (!projectId) {
|
|
7
|
+
console.error('No project ID. Use --project <id> or run `vorec run` first.');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
const data = await apiCall('get-status', { projectId });
|
|
11
|
+
console.log(chalk.bold(data.title || 'Untitled'));
|
|
12
|
+
console.log(`Status: ${data.status}`);
|
|
13
|
+
console.log(`Segments: ${data.segments?.length || 0}`);
|
|
14
|
+
console.log(`Clicks: ${data.clicks?.length || 0}`);
|
|
15
|
+
if (data.editor_url)
|
|
16
|
+
console.log(`Editor: ${data.editor_url}`);
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { apiCall, uploadVideo } from '../api.js';
|
|
4
|
+
import { saveProjectState } from '../config.js';
|
|
5
|
+
export async function uploadCommand(videoPath, opts) {
|
|
6
|
+
if (!existsSync(videoPath)) {
|
|
7
|
+
console.error(`File not found: ${videoPath}`);
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
const title = opts.title || videoPath.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
|
|
11
|
+
const spinner = ora('Creating project...').start();
|
|
12
|
+
try {
|
|
13
|
+
// Create project
|
|
14
|
+
const result = await apiCall('create-project', { title });
|
|
15
|
+
spinner.text = 'Uploading video...';
|
|
16
|
+
// Upload to R2
|
|
17
|
+
await uploadVideo(result.upload_url, videoPath);
|
|
18
|
+
spinner.text = 'Confirming upload...';
|
|
19
|
+
// Confirm
|
|
20
|
+
const status = await apiCall('confirm-upload', {
|
|
21
|
+
projectId: result.projectId,
|
|
22
|
+
storage_key: result.storage_key,
|
|
23
|
+
});
|
|
24
|
+
// Save state
|
|
25
|
+
saveProjectState({
|
|
26
|
+
projectId: result.projectId,
|
|
27
|
+
storageKey: result.storage_key,
|
|
28
|
+
videoPath,
|
|
29
|
+
status: status.status,
|
|
30
|
+
});
|
|
31
|
+
spinner.succeed(`Project created: ${result.projectId}`);
|
|
32
|
+
console.log(`Status: ${status.status}`);
|
|
33
|
+
if (status.status === 'analyzing') {
|
|
34
|
+
console.log('Gemini analysis started. Run `vorec status` to check progress.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
spinner.fail(err.message);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { VorecConfig } from './types.js';
|
|
2
|
+
export declare function loadConfig(): VorecConfig;
|
|
3
|
+
export declare function saveConfig(config: Partial<VorecConfig>): void;
|
|
4
|
+
export declare function requireApiKey(): string;
|
|
5
|
+
export declare function loadProjectState(): any;
|
|
6
|
+
export declare function saveProjectState(state: any): void;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
const CONFIG_DIR = join(homedir(), '.vorec');
|
|
5
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
apiKey: '',
|
|
8
|
+
apiBase: process.env.VOREC_API_BASE || 'https://api.vorec.ai/agent',
|
|
9
|
+
};
|
|
10
|
+
export function loadConfig() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(CONFIG_FILE, 'utf-8');
|
|
13
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { ...DEFAULTS };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function saveConfig(config) {
|
|
20
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
21
|
+
const current = loadConfig();
|
|
22
|
+
const merged = { ...current, ...config };
|
|
23
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
|
|
24
|
+
}
|
|
25
|
+
export function requireApiKey() {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
if (!config.apiKey) {
|
|
28
|
+
console.error('No API key configured. Run: vorec init');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
return config.apiKey;
|
|
32
|
+
}
|
|
33
|
+
// Per-project state
|
|
34
|
+
const PROJECT_DIR = '.vorec';
|
|
35
|
+
const PROJECT_FILE = join(PROJECT_DIR, 'project.json');
|
|
36
|
+
export function loadProjectState() {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(PROJECT_FILE, 'utf-8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function saveProjectState(state) {
|
|
45
|
+
mkdirSync(PROJECT_DIR, { recursive: true });
|
|
46
|
+
writeFileSync(PROJECT_FILE, JSON.stringify(state, null, 2) + '\n');
|
|
47
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initCommand } from './commands/init.js';
|
|
3
|
+
import { recordCommand } from './commands/record.js';
|
|
4
|
+
import { uploadCommand } from './commands/upload.js';
|
|
5
|
+
import { runCommand } from './commands/run.js';
|
|
6
|
+
import { statusCommand } from './commands/status.js';
|
|
7
|
+
const program = new Command()
|
|
8
|
+
.name('vorec')
|
|
9
|
+
.description('Turn screen recordings into narrated tutorials')
|
|
10
|
+
.version('0.10.0');
|
|
11
|
+
program
|
|
12
|
+
.command('init')
|
|
13
|
+
.description('Save your Vorec API key')
|
|
14
|
+
.action(initCommand);
|
|
15
|
+
program
|
|
16
|
+
.command('record')
|
|
17
|
+
.description('Record a screen session with Playwright')
|
|
18
|
+
.requiredOption('--url <url>', 'URL to navigate to')
|
|
19
|
+
.requiredOption('--actions <path>', 'Path to actions JSON file')
|
|
20
|
+
.option('--viewport <size>', 'Viewport size (WxH)', '1920x1080')
|
|
21
|
+
.option('--output <path>', 'Output manifest path', 'vorec-recording.json')
|
|
22
|
+
.action(recordCommand);
|
|
23
|
+
program
|
|
24
|
+
.command('upload <video>')
|
|
25
|
+
.description('Upload a video and create a project')
|
|
26
|
+
.option('--title <title>', 'Project title')
|
|
27
|
+
.action(uploadCommand);
|
|
28
|
+
program
|
|
29
|
+
.command('run <manifest>')
|
|
30
|
+
.description('Full pipeline: record → upload → narrate → audio')
|
|
31
|
+
.option('--auto', 'Wait for processing and generate audio', false)
|
|
32
|
+
.option('--skip-record', 'Skip Playwright recording (use --video)', false)
|
|
33
|
+
.option('--video <path>', 'Use existing video file instead of recording')
|
|
34
|
+
.option('--tracked-actions <path>', 'JSON file with tracked actions (for --skip-record)')
|
|
35
|
+
.action(runCommand);
|
|
36
|
+
program
|
|
37
|
+
.command('status')
|
|
38
|
+
.description('Check project status')
|
|
39
|
+
.option('--project <id>', 'Project ID')
|
|
40
|
+
.action(statusCommand);
|
|
41
|
+
program.parse();
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
export function loadManifest(path) {
|
|
3
|
+
const raw = readFileSync(path, 'utf-8');
|
|
4
|
+
const manifest = JSON.parse(raw);
|
|
5
|
+
if (!manifest.title)
|
|
6
|
+
throw new Error('Manifest missing "title"');
|
|
7
|
+
if (!manifest.url)
|
|
8
|
+
throw new Error('Manifest missing "url"');
|
|
9
|
+
if (!manifest.actions || manifest.actions.length === 0) {
|
|
10
|
+
throw new Error('Manifest missing "actions" array');
|
|
11
|
+
}
|
|
12
|
+
for (const [i, action] of manifest.actions.entries()) {
|
|
13
|
+
if (!action.type)
|
|
14
|
+
throw new Error(`Action ${i} missing "type"`);
|
|
15
|
+
const noSelectorNeeded = ['wait', 'navigate', 'scroll', 'narrate'];
|
|
16
|
+
if (!action.selector && !noSelectorNeeded.includes(action.type)) {
|
|
17
|
+
throw new Error(`Action ${i} missing "selector"`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return manifest;
|
|
21
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface VorecConfig {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
apiBase: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Manifest {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
viewport?: {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
storageState?: string;
|
|
13
|
+
language?: string;
|
|
14
|
+
narrationStyle?: string;
|
|
15
|
+
videoContext?: string;
|
|
16
|
+
customPrompt?: string;
|
|
17
|
+
includeIntro?: boolean;
|
|
18
|
+
actions: ManifestAction[];
|
|
19
|
+
}
|
|
20
|
+
export interface ManifestAction {
|
|
21
|
+
type: 'click' | 'type' | 'select' | 'hover' | 'scroll' | 'wait' | 'navigate' | 'narrate';
|
|
22
|
+
selector?: string;
|
|
23
|
+
text?: string;
|
|
24
|
+
value?: string;
|
|
25
|
+
delay?: number;
|
|
26
|
+
description?: string;
|
|
27
|
+
context?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface TrackedAction {
|
|
30
|
+
type: string;
|
|
31
|
+
timestamp: number;
|
|
32
|
+
coordinates: {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
};
|
|
36
|
+
target: string;
|
|
37
|
+
interaction_type: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
context?: string;
|
|
40
|
+
typed_text?: string;
|
|
41
|
+
selected_value?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface ProjectState {
|
|
44
|
+
projectId: string;
|
|
45
|
+
storageKey?: string;
|
|
46
|
+
uploadUrl?: string;
|
|
47
|
+
videoPath?: string;
|
|
48
|
+
status?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface ApiResponse {
|
|
51
|
+
projectId?: string;
|
|
52
|
+
upload_url?: string;
|
|
53
|
+
storage_key?: string;
|
|
54
|
+
status?: string;
|
|
55
|
+
error?: string;
|
|
56
|
+
segments?: any[];
|
|
57
|
+
clicks?: any[];
|
|
58
|
+
editor_url?: string;
|
|
59
|
+
[key: string]: any;
|
|
60
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vorec/cli",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Turn screen recordings into narrated tutorials — CLI for AI coding agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vorec": "./bin/vorec.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"chalk": "^5.3.0",
|
|
20
|
+
"commander": "^12.1.0",
|
|
21
|
+
"ora": "^8.1.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"playwright": "^1.40.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"playwright": {
|
|
28
|
+
"optional": true
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"vorec",
|
|
36
|
+
"screen-recording",
|
|
37
|
+
"tutorial",
|
|
38
|
+
"narration",
|
|
39
|
+
"ai-agent"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^25.5.0"
|
|
44
|
+
}
|
|
45
|
+
}
|