@argo-video/cli 0.1.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 (57) hide show
  1. package/.claude/settings.local.json +34 -0
  2. package/DESIGN.md +261 -0
  3. package/README.md +192 -0
  4. package/bin/argo.js +2 -0
  5. package/docs/enhancement-proposal.md +262 -0
  6. package/docs/superpowers/plans/2026-03-12-argo.md +208 -0
  7. package/docs/superpowers/plans/2026-03-12-editorial-overlay-system.md +1560 -0
  8. package/docs/superpowers/plans/2026-03-13-npm-rename-skill-showcase.md +499 -0
  9. package/docs/superpowers/specs/2026-03-13-npm-rename-skill-showcase-design.md +109 -0
  10. package/package.json +38 -0
  11. package/skills/argo-demo-creator.md +355 -0
  12. package/src/asset-server.ts +81 -0
  13. package/src/captions.ts +36 -0
  14. package/src/cli.ts +97 -0
  15. package/src/config.ts +125 -0
  16. package/src/export.ts +93 -0
  17. package/src/fixtures.ts +50 -0
  18. package/src/index.ts +41 -0
  19. package/src/init.ts +114 -0
  20. package/src/narration.ts +31 -0
  21. package/src/overlays/index.ts +54 -0
  22. package/src/overlays/manifest.ts +68 -0
  23. package/src/overlays/motion.ts +27 -0
  24. package/src/overlays/templates.ts +121 -0
  25. package/src/overlays/types.ts +73 -0
  26. package/src/overlays/zones.ts +82 -0
  27. package/src/pipeline.ts +120 -0
  28. package/src/record.ts +123 -0
  29. package/src/tts/align.ts +75 -0
  30. package/src/tts/cache.ts +65 -0
  31. package/src/tts/engine.ts +147 -0
  32. package/src/tts/generate.ts +83 -0
  33. package/src/tts/kokoro.ts +51 -0
  34. package/tests/asset-server.test.ts +67 -0
  35. package/tests/captions.test.ts +76 -0
  36. package/tests/cli.test.ts +131 -0
  37. package/tests/config.test.ts +150 -0
  38. package/tests/e2e/fake-server.ts +45 -0
  39. package/tests/e2e/record.e2e.test.ts +131 -0
  40. package/tests/export.test.ts +155 -0
  41. package/tests/fixtures.test.ts +74 -0
  42. package/tests/init.test.ts +77 -0
  43. package/tests/narration.test.ts +120 -0
  44. package/tests/overlays/index.test.ts +73 -0
  45. package/tests/overlays/manifest.test.ts +120 -0
  46. package/tests/overlays/motion.test.ts +34 -0
  47. package/tests/overlays/templates.test.ts +69 -0
  48. package/tests/overlays/types.test.ts +36 -0
  49. package/tests/overlays/zones.test.ts +49 -0
  50. package/tests/pipeline.test.ts +177 -0
  51. package/tests/record.test.ts +87 -0
  52. package/tests/tts/align.test.ts +118 -0
  53. package/tests/tts/cache.test.ts +110 -0
  54. package/tests/tts/engine.test.ts +204 -0
  55. package/tests/tts/generate.test.ts +177 -0
  56. package/tests/tts/kokoro.test.ts +25 -0
  57. package/tsconfig.json +19 -0
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createWavBuffer,
4
+ parseWavHeader,
5
+ createMockTTSEngine,
6
+ type TTSEngine,
7
+ type TTSEngineOptions,
8
+ } from '../../src/tts/engine.js';
9
+
10
+ describe('createWavBuffer', () => {
11
+ it('produces a buffer starting with RIFF....WAVE', () => {
12
+ const samples = new Float32Array(100);
13
+ const buf = createWavBuffer(samples);
14
+ expect(buf.toString('ascii', 0, 4)).toBe('RIFF');
15
+ expect(buf.toString('ascii', 8, 12)).toBe('WAVE');
16
+ });
17
+
18
+ it('writes correct fmt chunk for 32-bit float', () => {
19
+ const samples = new Float32Array(10);
20
+ const buf = createWavBuffer(samples, 24000);
21
+
22
+ // fmt chunk starts at byte 12
23
+ expect(buf.toString('ascii', 12, 16)).toBe('fmt ');
24
+ const fmtSize = buf.readUInt32LE(16);
25
+ expect(fmtSize).toBe(16);
26
+
27
+ const audioFormat = buf.readUInt16LE(20);
28
+ expect(audioFormat).toBe(3); // IEEE float
29
+
30
+ const numChannels = buf.readUInt16LE(22);
31
+ expect(numChannels).toBe(1); // mono
32
+
33
+ const sampleRate = buf.readUInt32LE(24);
34
+ expect(sampleRate).toBe(24000);
35
+
36
+ const bitsPerSample = buf.readUInt16LE(34);
37
+ expect(bitsPerSample).toBe(32);
38
+ });
39
+
40
+ it('has correct RIFF chunk size', () => {
41
+ const samples = new Float32Array(50);
42
+ const buf = createWavBuffer(samples);
43
+ const riffSize = buf.readUInt32LE(4);
44
+ expect(riffSize).toBe(buf.length - 8);
45
+ });
46
+
47
+ it('has correct data chunk size', () => {
48
+ const samples = new Float32Array(50);
49
+ const buf = createWavBuffer(samples);
50
+ // data chunk id at byte 36
51
+ expect(buf.toString('ascii', 36, 40)).toBe('data');
52
+ const dataSize = buf.readUInt32LE(40);
53
+ expect(dataSize).toBe(50 * 4); // 50 samples * 4 bytes each
54
+ });
55
+
56
+ it('total buffer length = 44 + sample data', () => {
57
+ const samples = new Float32Array(100);
58
+ const buf = createWavBuffer(samples);
59
+ expect(buf.length).toBe(44 + 100 * 4);
60
+ });
61
+
62
+ it('preserves sample values', () => {
63
+ const samples = new Float32Array([0.5, -0.5, 1.0, -1.0, 0.0]);
64
+ const buf = createWavBuffer(samples);
65
+ for (let i = 0; i < samples.length; i++) {
66
+ const val = buf.readFloatLE(44 + i * 4);
67
+ expect(val).toBeCloseTo(samples[i], 5);
68
+ }
69
+ });
70
+
71
+ it('respects custom sample rate', () => {
72
+ const samples = new Float32Array(10);
73
+ const buf = createWavBuffer(samples, 44100);
74
+ const sampleRate = buf.readUInt32LE(24);
75
+ expect(sampleRate).toBe(44100);
76
+ });
77
+
78
+ it('uses default sample rate of 24000', () => {
79
+ const samples = new Float32Array(10);
80
+ const buf = createWavBuffer(samples);
81
+ expect(buf.readUInt32LE(24)).toBe(24000);
82
+ });
83
+ });
84
+
85
+ describe('parseWavHeader', () => {
86
+ it('round-trips with createWavBuffer', () => {
87
+ const samples = new Float32Array(200);
88
+ const buf = createWavBuffer(samples, 16000);
89
+ const header = parseWavHeader(buf);
90
+
91
+ expect(header.sampleRate).toBe(16000);
92
+ expect(header.numChannels).toBe(1);
93
+ expect(header.bitsPerSample).toBe(32);
94
+ expect(header.audioFormat).toBe(3);
95
+ expect(header.dataSize).toBe(200 * 4);
96
+ expect(header.dataOffset).toBe(44);
97
+ });
98
+
99
+ it('computes durationMs correctly', () => {
100
+ const sampleRate = 24000;
101
+ const numSamples = sampleRate; // 1 second worth
102
+ const samples = new Float32Array(numSamples);
103
+ const buf = createWavBuffer(samples, sampleRate);
104
+ const header = parseWavHeader(buf);
105
+ expect(header.durationMs).toBeCloseTo(1000, 0);
106
+ });
107
+
108
+ it('throws on buffer smaller than 44 bytes', () => {
109
+ const small = Buffer.alloc(20);
110
+ expect(() => parseWavHeader(small)).toThrow();
111
+ });
112
+
113
+ it('throws on non-WAV buffer', () => {
114
+ const notWav = Buffer.alloc(100, 0);
115
+ notWav.write('NOTRIFF', 0, 'ascii');
116
+ expect(() => parseWavHeader(notWav)).toThrow();
117
+ });
118
+
119
+ it('throws when WAVE marker is missing', () => {
120
+ const buf = Buffer.alloc(100, 0);
121
+ buf.write('RIFF', 0, 'ascii');
122
+ buf.write('NOOO', 8, 'ascii');
123
+ expect(() => parseWavHeader(buf)).toThrow();
124
+ });
125
+
126
+ it('finds data chunk even with extra chunks', () => {
127
+ // Build a WAV with an extra chunk between fmt and data
128
+ const samples = new Float32Array(10);
129
+ const normal = createWavBuffer(samples);
130
+
131
+ // Insert a dummy chunk after fmt (at byte 36)
132
+ const dummyChunkId = Buffer.from('LIST', 'ascii');
133
+ const dummyChunkSize = Buffer.alloc(4);
134
+ dummyChunkSize.writeUInt32LE(8, 0);
135
+ const dummyChunkData = Buffer.alloc(8, 0xAB);
136
+
137
+ const before = normal.subarray(0, 36); // RIFF header + fmt chunk
138
+ const dataChunk = normal.subarray(36); // data chunk
139
+
140
+ // Fix RIFF size
141
+ const extended = Buffer.concat([before, dummyChunkId, dummyChunkSize, dummyChunkData, dataChunk]);
142
+ extended.writeUInt32LE(extended.length - 8, 4);
143
+
144
+ const header = parseWavHeader(extended);
145
+ expect(header.dataSize).toBe(10 * 4);
146
+ expect(header.dataOffset).toBe(44 + 16); // shifted by dummy chunk
147
+ });
148
+ });
149
+
150
+ describe('createMockTTSEngine', () => {
151
+ it('implements TTSEngine interface', async () => {
152
+ const engine = createMockTTSEngine();
153
+ // Should have generate method
154
+ expect(typeof engine.generate).toBe('function');
155
+ const result = await engine.generate('hello', {});
156
+ expect(Buffer.isBuffer(result)).toBe(true);
157
+ });
158
+
159
+ it('returns a valid WAV buffer', async () => {
160
+ const engine = createMockTTSEngine(500);
161
+ const buf = await engine.generate('test', {});
162
+ const header = parseWavHeader(buf);
163
+ expect(header.sampleRate).toBe(24000);
164
+ expect(header.numChannels).toBe(1);
165
+ expect(header.audioFormat).toBe(3);
166
+ });
167
+
168
+ it('records calls for assertions', async () => {
169
+ const engine = createMockTTSEngine();
170
+ expect(engine.calls).toEqual([]);
171
+
172
+ await engine.generate('first', { voice: 'alloy' });
173
+ await engine.generate('second', { speed: 1.5 });
174
+
175
+ expect(engine.calls).toHaveLength(2);
176
+ expect(engine.calls[0]).toEqual({ text: 'first', options: { voice: 'alloy' } });
177
+ expect(engine.calls[1]).toEqual({ text: 'second', options: { speed: 1.5 } });
178
+ });
179
+
180
+ it('respects durationMs parameter', async () => {
181
+ const engine = createMockTTSEngine(1000);
182
+ const buf = await engine.generate('test', {});
183
+ const header = parseWavHeader(buf);
184
+ // 1000ms at 24000 Hz = 24000 samples
185
+ expect(header.durationMs).toBeCloseTo(1000, -1);
186
+ });
187
+
188
+ it('defaults to 500ms duration', async () => {
189
+ const engine = createMockTTSEngine();
190
+ const buf = await engine.generate('test', {});
191
+ const header = parseWavHeader(buf);
192
+ expect(header.durationMs).toBeCloseTo(500, -1);
193
+ });
194
+
195
+ it('produces silent audio (all zeros)', async () => {
196
+ const engine = createMockTTSEngine(100);
197
+ const buf = await engine.generate('test', {});
198
+ const header = parseWavHeader(buf);
199
+ for (let i = 0; i < header.dataSize; i += 4) {
200
+ const sample = buf.readFloatLE(header.dataOffset + i);
201
+ expect(sample).toBe(0);
202
+ }
203
+ });
204
+ });
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { createMockTTSEngine } from '../../src/tts/engine.js';
6
+ import { generateClips } from '../../src/tts/generate.js';
7
+
8
+ function makeTmpDir(): string {
9
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'argo-generate-test-'));
10
+ }
11
+
12
+ describe('generateClips', () => {
13
+ let tmpDir: string;
14
+ let manifestPath: string;
15
+
16
+ beforeEach(() => {
17
+ tmpDir = makeTmpDir();
18
+ manifestPath = path.join(tmpDir, 'manifest.json');
19
+ });
20
+
21
+ afterEach(() => {
22
+ fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ const demoName = 'test-demo';
26
+
27
+ function writeManifest(entries: unknown[]) {
28
+ fs.writeFileSync(manifestPath, JSON.stringify(entries));
29
+ }
30
+
31
+ it('generates clips for all entries', async () => {
32
+ const entries = [
33
+ { scene: 'intro', text: 'Hello world' },
34
+ { scene: 'outro', text: 'Goodbye world' },
35
+ ];
36
+ writeManifest(entries);
37
+ const engine = createMockTTSEngine();
38
+
39
+ const results = await generateClips({
40
+ manifestPath,
41
+ demoName,
42
+ engine,
43
+ projectRoot: tmpDir,
44
+ });
45
+
46
+ expect(results).toHaveLength(2);
47
+ expect(engine.calls).toHaveLength(2);
48
+ expect(results[0].scene).toBe('intro');
49
+ expect(results[1].scene).toBe('outro');
50
+ // Files should exist on disk
51
+ for (const r of results) {
52
+ expect(fs.existsSync(r.clipPath)).toBe(true);
53
+ }
54
+ });
55
+
56
+ it('uses cached clips on second run', async () => {
57
+ const entries = [
58
+ { scene: 'intro', text: 'Hello world' },
59
+ ];
60
+ writeManifest(entries);
61
+ const engine = createMockTTSEngine();
62
+
63
+ // First run — generates
64
+ await generateClips({ manifestPath, demoName, engine, projectRoot: tmpDir });
65
+ expect(engine.calls).toHaveLength(1);
66
+
67
+ // Reset calls
68
+ engine.calls.length = 0;
69
+
70
+ // Second run — should use cache
71
+ const results = await generateClips({ manifestPath, demoName, engine, projectRoot: tmpDir });
72
+ expect(engine.calls).toHaveLength(0);
73
+ expect(results).toHaveLength(1);
74
+ expect(fs.existsSync(results[0].clipPath)).toBe(true);
75
+ });
76
+
77
+ it('regenerates only changed entries', async () => {
78
+ const entries = [
79
+ { scene: 'intro', text: 'Hello world' },
80
+ { scene: 'outro', text: 'Goodbye world' },
81
+ ];
82
+ writeManifest(entries);
83
+ const engine = createMockTTSEngine();
84
+
85
+ // First run
86
+ await generateClips({ manifestPath, demoName, engine, projectRoot: tmpDir });
87
+ expect(engine.calls).toHaveLength(2);
88
+
89
+ // Change one entry's text
90
+ engine.calls.length = 0;
91
+ const updatedEntries = [
92
+ { scene: 'intro', text: 'Hello world' }, // unchanged
93
+ { scene: 'outro', text: 'See you later' }, // changed
94
+ ];
95
+ writeManifest(updatedEntries);
96
+
97
+ const results = await generateClips({ manifestPath, demoName, engine, projectRoot: tmpDir });
98
+ expect(engine.calls).toHaveLength(1);
99
+ expect(engine.calls[0].text).toBe('See you later');
100
+ expect(results).toHaveLength(2);
101
+ });
102
+
103
+ it('throws on missing manifest file', async () => {
104
+ const engine = createMockTTSEngine();
105
+ await expect(
106
+ generateClips({
107
+ manifestPath: path.join(tmpDir, 'nonexistent.json'),
108
+ demoName,
109
+ engine,
110
+ projectRoot: tmpDir,
111
+ }),
112
+ ).rejects.toThrow('Manifest file not found');
113
+ });
114
+
115
+ it('throws on invalid JSON', async () => {
116
+ fs.writeFileSync(manifestPath, '{ not valid json }}}');
117
+ const engine = createMockTTSEngine();
118
+ await expect(
119
+ generateClips({ manifestPath, demoName, engine, projectRoot: tmpDir }),
120
+ ).rejects.toThrow('Failed to parse manifest');
121
+ });
122
+
123
+ it('throws on missing required field scene', async () => {
124
+ writeManifest([{ text: 'Hello world' }]);
125
+ const engine = createMockTTSEngine();
126
+ await expect(
127
+ generateClips({ manifestPath, demoName, engine, projectRoot: tmpDir }),
128
+ ).rejects.toThrow('missing required field');
129
+ });
130
+
131
+ it('throws on missing required field text', async () => {
132
+ writeManifest([{ scene: 'intro' }]);
133
+ const engine = createMockTTSEngine();
134
+ await expect(
135
+ generateClips({ manifestPath, demoName, engine, projectRoot: tmpDir }),
136
+ ).rejects.toThrow('missing required field');
137
+ });
138
+
139
+ it('applies defaults for voice and speed when entry does not specify them', async () => {
140
+ const entries = [
141
+ { scene: 'intro', text: 'Hello world' },
142
+ ];
143
+ writeManifest(entries);
144
+ const engine = createMockTTSEngine();
145
+
146
+ await generateClips({
147
+ manifestPath,
148
+ demoName,
149
+ engine,
150
+ projectRoot: tmpDir,
151
+ defaults: { voice: 'alloy', speed: 1.2 },
152
+ });
153
+
154
+ expect(engine.calls).toHaveLength(1);
155
+ expect(engine.calls[0].options.voice).toBe('alloy');
156
+ expect(engine.calls[0].options.speed).toBe(1.2);
157
+ });
158
+
159
+ it('entry-level voice/speed overrides defaults', async () => {
160
+ const entries = [
161
+ { scene: 'intro', text: 'Hello', voice: 'nova', speed: 0.8 },
162
+ ];
163
+ writeManifest(entries);
164
+ const engine = createMockTTSEngine();
165
+
166
+ await generateClips({
167
+ manifestPath,
168
+ demoName,
169
+ engine,
170
+ projectRoot: tmpDir,
171
+ defaults: { voice: 'alloy', speed: 1.2 },
172
+ });
173
+
174
+ expect(engine.calls[0].options.voice).toBe('nova');
175
+ expect(engine.calls[0].options.speed).toBe(0.8);
176
+ });
177
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { KokoroEngine } from '../../src/tts/kokoro.js';
3
+ import { parseWavHeader } from '../../src/tts/engine.js';
4
+
5
+ const describeIntegration = process.env.CI ? describe.skip : describe;
6
+
7
+ describeIntegration('KokoroEngine (integration)', () => {
8
+ const engine = new KokoroEngine();
9
+
10
+ it('generates valid WAV from text', async () => {
11
+ const wav = await engine.generate('Hello world', { voice: 'af_heart' });
12
+ expect(wav).toBeInstanceOf(Buffer);
13
+ expect(wav.length).toBeGreaterThan(44);
14
+
15
+ const header = parseWavHeader(wav);
16
+ expect(header.sampleRate).toBeGreaterThan(0);
17
+ expect(header.numChannels).toBe(1);
18
+ expect(header.durationMs).toBeGreaterThan(0);
19
+ }, 120_000);
20
+
21
+ it('throws on empty text', async () => {
22
+ await expect(engine.generate('', {})).rejects.toThrow('TTS text must not be empty');
23
+ await expect(engine.generate(' ', {})).rejects.toThrow('TTS text must not be empty');
24
+ });
25
+ }, 180_000);
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true
16
+ },
17
+ "include": ["src/**/*.ts"],
18
+ "exclude": ["node_modules", "dist", "tests"]
19
+ }