@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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/index.js');
package/dist/api.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { ApiResponse } from './types.js';
2
+ export declare function apiCall(action: string, body?: Record<string, any>): Promise<ApiResponse>;
3
+ export declare function uploadVideo(uploadUrl: string, videoPath: string, contentType?: string): Promise<void>;
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,8 @@
1
+ interface RecordOptions {
2
+ url: string;
3
+ actions: string;
4
+ viewport: string;
5
+ output: string;
6
+ }
7
+ export declare function recordCommand(opts: RecordOptions): Promise<void>;
8
+ export {};
@@ -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,8 @@
1
+ interface RunOptions {
2
+ auto: boolean;
3
+ skipRecord: boolean;
4
+ video: string;
5
+ trackedActions: string;
6
+ }
7
+ export declare function runCommand(manifestPath: string, opts: RunOptions): Promise<void>;
8
+ export {};
@@ -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,5 @@
1
+ interface StatusOptions {
2
+ project?: string;
3
+ }
4
+ export declare function statusCommand(opts: StatusOptions): Promise<void>;
5
+ export {};
@@ -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,5 @@
1
+ interface UploadOptions {
2
+ title?: string;
3
+ }
4
+ export declare function uploadCommand(videoPath: string, opts: UploadOptions): Promise<void>;
5
+ export {};
@@ -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
+ }
@@ -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
+ }
@@ -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();
@@ -0,0 +1,2 @@
1
+ import type { Manifest } from './types.js';
2
+ export declare function loadManifest(path: string): Manifest;
@@ -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
+ }
@@ -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
+ }