@argo-video/cli 0.1.0 → 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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/dist/asset-server.d.ts +7 -0
  4. package/dist/asset-server.d.ts.map +1 -0
  5. package/dist/asset-server.js +69 -0
  6. package/dist/asset-server.js.map +1 -0
  7. package/dist/captions.d.ts +17 -0
  8. package/dist/captions.d.ts.map +1 -0
  9. package/dist/captions.js +23 -0
  10. package/dist/captions.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +87 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/config.d.ts +49 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +76 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/export.d.ts +19 -0
  20. package/dist/export.d.ts.map +1 -0
  21. package/dist/export.js +66 -0
  22. package/dist/export.js.map +1 -0
  23. package/dist/fixtures.d.ts +13 -0
  24. package/dist/fixtures.d.ts.map +1 -0
  25. package/dist/fixtures.js +49 -0
  26. package/dist/fixtures.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/init.d.ts +2 -0
  32. package/dist/init.d.ts.map +1 -0
  33. package/{src/init.ts → dist/init.js} +39 -54
  34. package/dist/init.js.map +1 -0
  35. package/dist/narration.d.ts +32 -0
  36. package/dist/narration.d.ts.map +1 -0
  37. package/dist/narration.js +86 -0
  38. package/dist/narration.js.map +1 -0
  39. package/dist/overlays/index.d.ts +13 -0
  40. package/dist/overlays/index.d.ts.map +1 -0
  41. package/dist/overlays/index.js +45 -0
  42. package/dist/overlays/index.js.map +1 -0
  43. package/dist/overlays/manifest.d.ts +5 -0
  44. package/dist/overlays/manifest.d.ts.map +1 -0
  45. package/dist/overlays/manifest.js +52 -0
  46. package/dist/overlays/manifest.js.map +1 -0
  47. package/dist/overlays/motion.d.ts +4 -0
  48. package/dist/overlays/motion.d.ts.map +1 -0
  49. package/dist/overlays/motion.js +25 -0
  50. package/dist/overlays/motion.js.map +1 -0
  51. package/dist/overlays/templates.d.ts +8 -0
  52. package/dist/overlays/templates.d.ts.map +1 -0
  53. package/dist/overlays/templates.js +102 -0
  54. package/dist/overlays/templates.js.map +1 -0
  55. package/dist/overlays/types.d.ts +46 -0
  56. package/dist/overlays/types.d.ts.map +1 -0
  57. package/dist/overlays/types.js +25 -0
  58. package/dist/overlays/types.js.map +1 -0
  59. package/dist/overlays/zones.d.ts +23 -0
  60. package/dist/overlays/zones.d.ts.map +1 -0
  61. package/dist/overlays/zones.js +117 -0
  62. package/dist/overlays/zones.js.map +1 -0
  63. package/dist/pipeline.d.ts +3 -0
  64. package/dist/pipeline.d.ts.map +1 -0
  65. package/dist/pipeline.js +109 -0
  66. package/dist/pipeline.js.map +1 -0
  67. package/dist/record.d.ts +15 -0
  68. package/dist/record.d.ts.map +1 -0
  69. package/dist/record.js +110 -0
  70. package/dist/record.js.map +1 -0
  71. package/dist/tts/align.d.ts +26 -0
  72. package/dist/tts/align.d.ts.map +1 -0
  73. package/dist/tts/align.js +53 -0
  74. package/dist/tts/align.js.map +1 -0
  75. package/dist/tts/cache.d.ts +31 -0
  76. package/dist/tts/cache.d.ts.map +1 -0
  77. package/dist/tts/cache.js +51 -0
  78. package/dist/tts/cache.js.map +1 -0
  79. package/dist/tts/engine.d.ts +41 -0
  80. package/dist/tts/engine.d.ts.map +1 -0
  81. package/dist/tts/engine.js +108 -0
  82. package/dist/tts/engine.js.map +1 -0
  83. package/dist/tts/generate.d.ts +21 -0
  84. package/dist/tts/generate.d.ts.map +1 -0
  85. package/dist/tts/generate.js +61 -0
  86. package/dist/tts/generate.js.map +1 -0
  87. package/dist/tts/kokoro.d.ts +30 -0
  88. package/dist/tts/kokoro.d.ts.map +1 -0
  89. package/dist/tts/kokoro.js +66 -0
  90. package/dist/tts/kokoro.js.map +1 -0
  91. package/package.json +13 -1
  92. package/.claude/settings.local.json +0 -34
  93. package/DESIGN.md +0 -261
  94. package/docs/enhancement-proposal.md +0 -262
  95. package/docs/superpowers/plans/2026-03-12-argo.md +0 -208
  96. package/docs/superpowers/plans/2026-03-12-editorial-overlay-system.md +0 -1560
  97. package/docs/superpowers/plans/2026-03-13-npm-rename-skill-showcase.md +0 -499
  98. package/docs/superpowers/specs/2026-03-13-npm-rename-skill-showcase-design.md +0 -109
  99. package/skills/argo-demo-creator.md +0 -355
  100. package/src/asset-server.ts +0 -81
  101. package/src/captions.ts +0 -36
  102. package/src/cli.ts +0 -97
  103. package/src/config.ts +0 -125
  104. package/src/export.ts +0 -93
  105. package/src/fixtures.ts +0 -50
  106. package/src/index.ts +0 -41
  107. package/src/narration.ts +0 -31
  108. package/src/overlays/index.ts +0 -54
  109. package/src/overlays/manifest.ts +0 -68
  110. package/src/overlays/motion.ts +0 -27
  111. package/src/overlays/templates.ts +0 -121
  112. package/src/overlays/types.ts +0 -73
  113. package/src/overlays/zones.ts +0 -82
  114. package/src/pipeline.ts +0 -120
  115. package/src/record.ts +0 -123
  116. package/src/tts/align.ts +0 -75
  117. package/src/tts/cache.ts +0 -65
  118. package/src/tts/engine.ts +0 -147
  119. package/src/tts/generate.ts +0 -83
  120. package/src/tts/kokoro.ts +0 -51
  121. package/tests/asset-server.test.ts +0 -67
  122. package/tests/captions.test.ts +0 -76
  123. package/tests/cli.test.ts +0 -131
  124. package/tests/config.test.ts +0 -150
  125. package/tests/e2e/fake-server.ts +0 -45
  126. package/tests/e2e/record.e2e.test.ts +0 -131
  127. package/tests/export.test.ts +0 -155
  128. package/tests/fixtures.test.ts +0 -74
  129. package/tests/init.test.ts +0 -77
  130. package/tests/narration.test.ts +0 -120
  131. package/tests/overlays/index.test.ts +0 -73
  132. package/tests/overlays/manifest.test.ts +0 -120
  133. package/tests/overlays/motion.test.ts +0 -34
  134. package/tests/overlays/templates.test.ts +0 -69
  135. package/tests/overlays/types.test.ts +0 -36
  136. package/tests/overlays/zones.test.ts +0 -49
  137. package/tests/pipeline.test.ts +0 -177
  138. package/tests/record.test.ts +0 -87
  139. package/tests/tts/align.test.ts +0 -118
  140. package/tests/tts/cache.test.ts +0 -110
  141. package/tests/tts/engine.test.ts +0 -204
  142. package/tests/tts/generate.test.ts +0 -177
  143. package/tests/tts/kokoro.test.ts +0 -25
  144. package/tsconfig.json +0 -19
@@ -1,69 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { renderTemplate } from '../../src/overlays/templates.js';
3
-
4
- describe('renderTemplate', () => {
5
- describe('lower-third', () => {
6
- it('renders text in a styled span', () => {
7
- const result = renderTemplate({ type: 'lower-third', text: 'Hello world' });
8
- expect(result.contentHtml).toContain('Hello world');
9
- expect(result.styles.background).toBeDefined();
10
- expect(result.styles.borderRadius).toBeDefined();
11
- });
12
- it('includes maxWidth for readability', () => {
13
- const result = renderTemplate({ type: 'lower-third', text: 'Test' });
14
- expect(result.styles.maxWidth).toBeDefined();
15
- });
16
- it('escapes HTML in text', () => {
17
- const result = renderTemplate({ type: 'lower-third', text: '<script>alert("xss")</script>' });
18
- expect(result.contentHtml).not.toContain('<script>');
19
- expect(result.contentHtml).toContain('&lt;script&gt;');
20
- });
21
- });
22
-
23
- describe('headline-card', () => {
24
- it('renders title', () => {
25
- const result = renderTemplate({ type: 'headline-card', title: 'Big Title' });
26
- expect(result.contentHtml).toContain('Big Title');
27
- });
28
- it('renders kicker when provided', () => {
29
- const result = renderTemplate({ type: 'headline-card', title: 'Title', kicker: 'LABEL' });
30
- expect(result.contentHtml).toContain('LABEL');
31
- });
32
- it('renders body when provided', () => {
33
- const result = renderTemplate({ type: 'headline-card', title: 'Title', body: 'Details here' });
34
- expect(result.contentHtml).toContain('Details here');
35
- });
36
- it('omits kicker element when not provided', () => {
37
- const result = renderTemplate({ type: 'headline-card', title: 'Title' });
38
- expect(result.contentHtml).not.toContain('uppercase');
39
- });
40
- it('has backdrop blur style', () => {
41
- const result = renderTemplate({ type: 'headline-card', title: 'T' });
42
- expect(result.styles.backdropFilter).toContain('blur');
43
- });
44
- });
45
-
46
- describe('callout', () => {
47
- it('renders text in a compact bubble', () => {
48
- const result = renderTemplate({ type: 'callout', text: 'Note this' });
49
- expect(result.contentHtml).toContain('Note this');
50
- expect(result.styles.borderRadius).toBeDefined();
51
- });
52
- });
53
-
54
- describe('image-card', () => {
55
- it('renders img tag with src', () => {
56
- const result = renderTemplate({ type: 'image-card', src: 'http://localhost:9999/diagram.png' });
57
- expect(result.contentHtml).toContain('<img');
58
- expect(result.contentHtml).toContain('http://localhost:9999/diagram.png');
59
- });
60
- it('renders title when provided', () => {
61
- const result = renderTemplate({ type: 'image-card', src: 'http://x/img.png', title: 'Architecture' });
62
- expect(result.contentHtml).toContain('Architecture');
63
- });
64
- it('renders body when provided', () => {
65
- const result = renderTemplate({ type: 'image-card', src: 'http://x/img.png', body: 'Description' });
66
- expect(result.contentHtml).toContain('Description');
67
- });
68
- });
69
- });
@@ -1,36 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { isValidZone, isValidTemplateType, isValidMotion } from '../../src/overlays/types.js';
3
-
4
- describe('isValidZone', () => {
5
- it('accepts all defined zones', () => {
6
- for (const z of ['bottom-center', 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center']) {
7
- expect(isValidZone(z)).toBe(true);
8
- }
9
- });
10
- it('rejects unknown zones', () => {
11
- expect(isValidZone('middle')).toBe(false);
12
- expect(isValidZone('')).toBe(false);
13
- });
14
- });
15
-
16
- describe('isValidTemplateType', () => {
17
- it('accepts all defined types', () => {
18
- for (const t of ['lower-third', 'headline-card', 'callout', 'image-card']) {
19
- expect(isValidTemplateType(t)).toBe(true);
20
- }
21
- });
22
- it('rejects unknown types', () => {
23
- expect(isValidTemplateType('banner')).toBe(false);
24
- });
25
- });
26
-
27
- describe('isValidMotion', () => {
28
- it('accepts defined motions and none', () => {
29
- expect(isValidMotion('fade-in')).toBe(true);
30
- expect(isValidMotion('slide-in')).toBe(true);
31
- expect(isValidMotion('none')).toBe(true);
32
- });
33
- it('rejects unknown motions', () => {
34
- expect(isValidMotion('bounce')).toBe(false);
35
- });
36
- });
@@ -1,49 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { injectIntoZone, removeZone, ZONE_ID_PREFIX } from '../../src/overlays/zones.js';
3
- import type { Page } from '@playwright/test';
4
-
5
- function createMockPage() {
6
- return {
7
- evaluate: vi.fn(),
8
- waitForTimeout: vi.fn(),
9
- } as unknown as Page;
10
- }
11
-
12
- describe('ZONE_ID_PREFIX', () => {
13
- it('is argo-overlay-', () => {
14
- expect(ZONE_ID_PREFIX).toBe('argo-overlay-');
15
- });
16
- });
17
-
18
- describe('injectIntoZone', () => {
19
- let page: Page;
20
-
21
- beforeEach(() => {
22
- page = createMockPage();
23
- });
24
-
25
- it('calls page.evaluate with zone ID', async () => {
26
- await injectIntoZone(page, 'top-left', '<div>Hello</div>', { color: 'red' });
27
- expect(page.evaluate).toHaveBeenCalledTimes(1);
28
- const [fn, args] = (page.evaluate as any).mock.calls[0];
29
- expect(typeof fn).toBe('function');
30
- expect(args[0]).toBe('argo-overlay-top-left');
31
- });
32
-
33
- it('uses bottom-center zone ID correctly', async () => {
34
- await injectIntoZone(page, 'bottom-center', '<span>text</span>', {});
35
- const [, args] = (page.evaluate as any).mock.calls[0];
36
- expect(args[0]).toBe('argo-overlay-bottom-center');
37
- });
38
- });
39
-
40
- describe('removeZone', () => {
41
- it('calls page.evaluate to remove element by zone ID', async () => {
42
- const page = createMockPage();
43
- await removeZone(page, 'top-left');
44
- expect(page.evaluate).toHaveBeenCalledTimes(1);
45
- const [fn, arg] = (page.evaluate as any).mock.calls[0];
46
- expect(typeof fn).toBe('function');
47
- expect(arg).toBe('argo-overlay-top-left');
48
- });
49
- });
@@ -1,177 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
- import { join } from 'node:path';
4
-
5
- vi.mock('../src/tts/generate.js', () => ({
6
- generateClips: vi.fn(),
7
- }));
8
-
9
- vi.mock('../src/record.js', () => ({
10
- record: vi.fn(),
11
- }));
12
-
13
- vi.mock('../src/export.js', () => ({
14
- checkFfmpeg: vi.fn(),
15
- exportVideo: vi.fn(),
16
- }));
17
-
18
- // Mock execFileSync (used by getVideoDurationMs for ffprobe)
19
- vi.mock('node:child_process', () => ({
20
- execFileSync: vi.fn().mockReturnValue('16.240\n'),
21
- }));
22
-
23
- import { execFileSync } from 'node:child_process';
24
- import { generateClips } from '../src/tts/generate.js';
25
- import { record } from '../src/record.js';
26
- import { checkFfmpeg, exportVideo } from '../src/export.js';
27
- import { runPipeline } from '../src/pipeline.js';
28
- import { createWavBuffer } from '../src/tts/engine.js';
29
- import type { ArgoConfig } from '../src/config.js';
30
-
31
- const mockedExecFileSync = vi.mocked(execFileSync);
32
- const mockedGenerateClips = vi.mocked(generateClips);
33
- const mockedRecord = vi.mocked(record);
34
- const mockedCheckFfmpeg = vi.mocked(checkFfmpeg);
35
- const mockedExportVideo = vi.mocked(exportVideo);
36
-
37
- const DEMO_NAME = 'test-demo';
38
- const ARGO_DIR = join('.argo', DEMO_NAME);
39
- const TIMING_PATH = join(ARGO_DIR, '.timing.json');
40
- const VIDEO_PATH = join(ARGO_DIR, 'video.webm');
41
-
42
- const mockEngine = { generate: vi.fn().mockResolvedValue(Buffer.from('fake')) };
43
-
44
- const defaultConfig: Pick<ArgoConfig, 'baseURL' | 'demosDir' | 'outputDir' | 'tts' | 'video' | 'export'> = {
45
- baseURL: 'http://localhost:3000',
46
- demosDir: 'demos',
47
- outputDir: 'videos',
48
- tts: { defaultVoice: 'af_heart', defaultSpeed: 1.0, engine: mockEngine },
49
- video: { width: 1920, height: 1080, fps: 30 },
50
- export: { preset: 'slow', crf: 16 },
51
- };
52
-
53
- function setupFixtures() {
54
- mkdirSync(join(ARGO_DIR, 'clips'), { recursive: true });
55
- // Write a minimal timing file
56
- writeFileSync(TIMING_PATH, JSON.stringify({ intro: 1000, done: 5000 }));
57
- // Write a minimal video placeholder (ffprobe is mocked)
58
- writeFileSync(VIDEO_PATH, Buffer.from('fake-video'));
59
- // Write clip WAV files
60
- const silence = createWavBuffer(new Float32Array(24000), 24000); // 1s silence
61
- writeFileSync(join(ARGO_DIR, 'clips', 'intro.wav'), silence);
62
- writeFileSync(join(ARGO_DIR, 'clips', 'done.wav'), silence);
63
- }
64
-
65
- beforeEach(() => {
66
- vi.resetAllMocks();
67
- rmSync('.argo', { recursive: true, force: true });
68
- setupFixtures();
69
-
70
- mockedExecFileSync.mockReturnValue('16.240\n');
71
- mockedCheckFfmpeg.mockReturnValue(true);
72
- mockedGenerateClips.mockResolvedValue([
73
- { scene: 'intro', clipPath: join(ARGO_DIR, 'clips', 'intro.wav') },
74
- { scene: 'done', clipPath: join(ARGO_DIR, 'clips', 'done.wav') },
75
- ]);
76
- mockedRecord.mockResolvedValue({ videoPath: VIDEO_PATH, timingPath: TIMING_PATH });
77
- mockedExportVideo.mockResolvedValue(`videos/${DEMO_NAME}.mp4`);
78
- });
79
-
80
- describe('runPipeline', () => {
81
- it('calls checkFfmpeg before other steps', async () => {
82
- const callOrder: string[] = [];
83
- mockedCheckFfmpeg.mockImplementation(() => { callOrder.push('checkFfmpeg'); return true; });
84
- mockedGenerateClips.mockImplementation(async () => {
85
- callOrder.push('generateClips');
86
- return [
87
- { scene: 'intro', clipPath: join(ARGO_DIR, 'clips', 'intro.wav') },
88
- { scene: 'done', clipPath: join(ARGO_DIR, 'clips', 'done.wav') },
89
- ];
90
- });
91
- mockedRecord.mockImplementation(async () => { callOrder.push('record'); return { videoPath: VIDEO_PATH, timingPath: TIMING_PATH }; });
92
- mockedExportVideo.mockImplementation(async () => { callOrder.push('exportVideo'); return 'out.mp4'; });
93
-
94
- await runPipeline(DEMO_NAME, defaultConfig);
95
- expect(callOrder[0]).toBe('checkFfmpeg');
96
- });
97
-
98
- it('calls steps in order: generateClips → record → exportVideo', async () => {
99
- const callOrder: string[] = [];
100
- mockedCheckFfmpeg.mockImplementation(() => { callOrder.push('checkFfmpeg'); return true; });
101
- mockedGenerateClips.mockImplementation(async () => {
102
- callOrder.push('generateClips');
103
- return [
104
- { scene: 'intro', clipPath: join(ARGO_DIR, 'clips', 'intro.wav') },
105
- { scene: 'done', clipPath: join(ARGO_DIR, 'clips', 'done.wav') },
106
- ];
107
- });
108
- mockedRecord.mockImplementation(async () => { callOrder.push('record'); return { videoPath: VIDEO_PATH, timingPath: TIMING_PATH }; });
109
- mockedExportVideo.mockImplementation(async () => { callOrder.push('exportVideo'); return 'out.mp4'; });
110
-
111
- await runPipeline(DEMO_NAME, defaultConfig);
112
- expect(callOrder).toEqual(['checkFfmpeg', 'generateClips', 'record', 'exportVideo']);
113
- });
114
-
115
- it('returns the output path from exportVideo', async () => {
116
- const result = await runPipeline(DEMO_NAME, defaultConfig);
117
- expect(result).toBe(`videos/${DEMO_NAME}.mp4`);
118
- });
119
-
120
- it('passes correct options to generateClips', async () => {
121
- await runPipeline(DEMO_NAME, defaultConfig);
122
- expect(mockedGenerateClips).toHaveBeenCalledWith({
123
- manifestPath: `demos/${DEMO_NAME}.voiceover.json`,
124
- demoName: DEMO_NAME,
125
- engine: mockEngine,
126
- projectRoot: '.',
127
- defaults: { voice: 'af_heart', speed: 1.0 },
128
- });
129
- });
130
-
131
- it('passes correct options to record', async () => {
132
- await runPipeline(DEMO_NAME, defaultConfig);
133
- expect(mockedRecord).toHaveBeenCalledWith(DEMO_NAME, {
134
- demosDir: 'demos',
135
- baseURL: 'http://localhost:3000',
136
- video: { width: 1920, height: 1080 },
137
- });
138
- });
139
-
140
- it('passes correct options to exportVideo', async () => {
141
- await runPipeline(DEMO_NAME, defaultConfig);
142
- expect(mockedExportVideo).toHaveBeenCalledWith({
143
- demoName: DEMO_NAME,
144
- argoDir: '.argo',
145
- outputDir: 'videos',
146
- preset: 'slow',
147
- crf: 16,
148
- fps: 30,
149
- });
150
- });
151
-
152
- it('writes narration-aligned.wav to .argo/<demo>/', async () => {
153
- const { existsSync } = await import('node:fs');
154
- await runPipeline(DEMO_NAME, defaultConfig);
155
- expect(existsSync(join(ARGO_DIR, 'narration-aligned.wav'))).toBe(true);
156
- });
157
-
158
- it('propagates error from checkFfmpeg', async () => {
159
- mockedCheckFfmpeg.mockImplementation(() => { throw new Error('ffmpeg not found'); });
160
- await expect(runPipeline(DEMO_NAME, defaultConfig)).rejects.toThrow('ffmpeg not found');
161
- });
162
-
163
- it('propagates error from generateClips', async () => {
164
- mockedGenerateClips.mockRejectedValue(new Error('TTS failed'));
165
- await expect(runPipeline(DEMO_NAME, defaultConfig)).rejects.toThrow('TTS failed');
166
- });
167
-
168
- it('propagates error from record', async () => {
169
- mockedRecord.mockRejectedValue(new Error('Recording failed'));
170
- await expect(runPipeline(DEMO_NAME, defaultConfig)).rejects.toThrow('Recording failed');
171
- });
172
-
173
- it('propagates error from exportVideo', async () => {
174
- mockedExportVideo.mockRejectedValue(new Error('Export failed'));
175
- await expect(runPipeline(DEMO_NAME, defaultConfig)).rejects.toThrow('Export failed');
176
- });
177
- });
@@ -1,87 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { mkdtemp, rm } from 'node:fs/promises';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
5
- import { tmpdir } from 'node:os';
6
-
7
- const { execFileMock } = vi.hoisted(() => ({
8
- execFileMock: vi.fn(),
9
- }));
10
-
11
- vi.mock('node:child_process', () => ({
12
- execFile: execFileMock,
13
- }));
14
-
15
- import { record } from '../src/record.js';
16
-
17
- const originalCwd = process.cwd();
18
-
19
- describe('record', () => {
20
- let tempDir: string;
21
-
22
- beforeEach(async () => {
23
- execFileMock.mockReset();
24
- tempDir = await mkdtemp(join(tmpdir(), 'argo-record-'));
25
- process.chdir(tempDir);
26
- mkdirSync('custom-demos', { recursive: true });
27
- });
28
-
29
- afterEach(async () => {
30
- process.chdir(originalCwd);
31
- await rm(tempDir, { recursive: true, force: true });
32
- });
33
-
34
- it('generates a Playwright config from record options and copies artifacts', async () => {
35
- execFileMock.mockImplementation((_cmd, args, options, callback) => {
36
- const testResultsDir = resolve(tempDir, 'test-results');
37
- const argoOutputDir = options.env.ARGO_OUTPUT_DIR as string;
38
-
39
- mkdirSync(join(testResultsDir, 'demo-run'), { recursive: true });
40
- writeFileSync(join(testResultsDir, 'demo-run', 'video.webm'), 'video');
41
- mkdirSync(resolve(tempDir, argoOutputDir), { recursive: true });
42
- writeFileSync(resolve(tempDir, argoOutputDir, '.timing.json'), '{}');
43
-
44
- callback(null, '', '');
45
- return {} as never;
46
- });
47
-
48
- const result = await record('demo', {
49
- demosDir: 'custom-demos',
50
- baseURL: 'http://localhost:4321',
51
- video: { width: 1280, height: 720 },
52
- });
53
-
54
- const configPath = join(tempDir, '.argo', 'demo', 'playwright.record.config.mjs');
55
- const config = readFileSync(configPath, 'utf-8');
56
-
57
- expect(config).toContain(`testDir: ${JSON.stringify(resolve('custom-demos'))}`);
58
- expect(config).toContain(`baseURL: ${JSON.stringify('http://localhost:4321')}`);
59
- expect(config).toContain('viewport: { width: 1280, height: 720 }');
60
- expect(config).toContain('size: { width: 1280, height: 720 }');
61
- expect(existsSync(join(tempDir, '.argo', 'demo', 'video.webm'))).toBe(true);
62
- expect(result).toEqual({
63
- videoPath: join('.argo', 'demo', 'video.webm'),
64
- timingPath: join('.argo', 'demo', '.timing.json'),
65
- });
66
- expect(execFileMock).toHaveBeenCalledWith(
67
- 'npx',
68
- [
69
- 'playwright',
70
- 'test',
71
- '--config',
72
- join('.argo', 'demo', 'playwright.record.config.mjs'),
73
- '--grep',
74
- 'demo',
75
- '--project',
76
- 'demos',
77
- ],
78
- expect.objectContaining({
79
- env: expect.objectContaining({
80
- ARGO_OUTPUT_DIR: join('.argo', 'demo'),
81
- BASE_URL: 'http://localhost:4321',
82
- }),
83
- }),
84
- expect.any(Function),
85
- );
86
- });
87
- });
@@ -1,118 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import {
3
- alignClips,
4
- type SceneTiming,
5
- type ClipInfo,
6
- type Placement,
7
- type AlignResult,
8
- } from '../../src/tts/align.js';
9
-
10
- function makeClip(scene: string, durationMs: number, fillValue = 0): ClipInfo {
11
- const sampleRate = 24_000;
12
- const numSamples = Math.round((durationMs / 1000) * sampleRate);
13
- const samples = new Float32Array(numSamples);
14
- if (fillValue !== 0) samples.fill(fillValue);
15
- return { scene, durationMs, samples };
16
- }
17
-
18
- describe('alignClips', () => {
19
- it('places clips at their scene timestamps (no overlap)', () => {
20
- const timing: SceneTiming = { intro: 0, middle: 5000, end: 10000 };
21
- const clips: ClipInfo[] = [
22
- makeClip('intro', 2000),
23
- makeClip('middle', 2000),
24
- makeClip('end', 2000),
25
- ];
26
- const result = alignClips(timing, clips, 15000);
27
- expect(result.placements).toEqual([
28
- { scene: 'intro', startMs: 0, endMs: 2000 },
29
- { scene: 'middle', startMs: 5000, endMs: 7000 },
30
- { scene: 'end', startMs: 10000, endMs: 12000 },
31
- ]);
32
- });
33
-
34
- it('prevents overlap with 100ms gap', () => {
35
- const timing: SceneTiming = { a: 0, b: 1000 };
36
- // Clip 'a' is 2000ms, so it extends past b's timestamp of 1000ms
37
- const clips: ClipInfo[] = [
38
- makeClip('a', 2000),
39
- makeClip('b', 500),
40
- ];
41
- const result = alignClips(timing, clips, 5000);
42
- // 'b' should be pushed to 2000 + 100 = 2100
43
- expect(result.placements).toEqual([
44
- { scene: 'a', startMs: 0, endMs: 2000 },
45
- { scene: 'b', startMs: 2100, endMs: 2600 },
46
- ]);
47
- });
48
-
49
- it('output buffer has correct total duration', () => {
50
- const timing: SceneTiming = { x: 0 };
51
- const clips: ClipInfo[] = [makeClip('x', 500)];
52
- const sampleRate = 24_000;
53
- const totalDurationMs = 10000;
54
- const result = alignClips(timing, clips, totalDurationMs, sampleRate);
55
- const expectedSamples = Math.round((totalDurationMs / 1000) * sampleRate);
56
- expect(result.samples.length).toBe(expectedSamples);
57
- });
58
-
59
- it('mixes samples at correct positions', () => {
60
- const timing: SceneTiming = { a: 1000 };
61
- const fillValue = 0.5;
62
- const clips: ClipInfo[] = [makeClip('a', 100, fillValue)];
63
- const sampleRate = 24_000;
64
- const result = alignClips(timing, clips, 3000, sampleRate);
65
-
66
- const startSample = Math.round((1000 / 1000) * sampleRate);
67
- const clipSamples = Math.round((100 / 1000) * sampleRate);
68
-
69
- // Before clip: silence
70
- expect(result.samples[0]).toBe(0);
71
- // At clip start
72
- expect(result.samples[startSample]).toBe(fillValue);
73
- // At clip end - 1
74
- expect(result.samples[startSample + clipSamples - 1]).toBe(fillValue);
75
- // After clip: silence
76
- expect(result.samples[startSample + clipSamples]).toBe(0);
77
- });
78
-
79
- it('handles empty clips array', () => {
80
- const timing: SceneTiming = { a: 0 };
81
- const result = alignClips(timing, [], 5000);
82
- expect(result.placements).toEqual([]);
83
- expect(result.samples.length).toBe(Math.round((5000 / 1000) * 24_000));
84
- // All silence
85
- expect(result.samples.every((s) => s === 0)).toBe(true);
86
- });
87
-
88
- it('orders by timestamp, not input order', () => {
89
- const timing: SceneTiming = { first: 0, second: 3000, third: 6000 };
90
- // Provide clips in reverse order
91
- const clips: ClipInfo[] = [
92
- makeClip('third', 1000),
93
- makeClip('first', 1000),
94
- makeClip('second', 1000),
95
- ];
96
- const result = alignClips(timing, clips, 10000);
97
- expect(result.placements.map((p) => p.scene)).toEqual([
98
- 'first',
99
- 'second',
100
- 'third',
101
- ]);
102
- });
103
-
104
- it('skips clips whose scene is not in timing data', () => {
105
- const timing: SceneTiming = { a: 0, b: 3000 };
106
- const clips: ClipInfo[] = [
107
- makeClip('a', 1000),
108
- makeClip('unknown', 1000),
109
- makeClip('b', 1000),
110
- ];
111
- const result = alignClips(timing, clips, 6000);
112
- expect(result.placements).toEqual([
113
- { scene: 'a', startMs: 0, endMs: 1000 },
114
- { scene: 'b', startMs: 3000, endMs: 4000 },
115
- ]);
116
- expect(result.placements.some((p) => p.scene === 'unknown')).toBe(false);
117
- });
118
- });
@@ -1,110 +0,0 @@
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 crypto from 'node:crypto';
6
- import { createWavBuffer } from '../../src/tts/engine.js';
7
- import { ClipCache, type ManifestEntry } from '../../src/tts/cache.js';
8
-
9
- function makeTmpDir(): string {
10
- return fs.mkdtempSync(path.join(os.tmpdir(), 'argo-cache-test-'));
11
- }
12
-
13
- function makeHash(entry: ManifestEntry): string {
14
- const { scene, text, voice, speed } = entry;
15
- return crypto.createHash('sha256').update(JSON.stringify({ scene, text, voice, speed })).digest('hex');
16
- }
17
-
18
- describe('ClipCache', () => {
19
- let tmpDir: string;
20
- let cache: ClipCache;
21
-
22
- beforeEach(() => {
23
- tmpDir = makeTmpDir();
24
- cache = new ClipCache(tmpDir);
25
- });
26
-
27
- afterEach(() => {
28
- fs.rmSync(tmpDir, { recursive: true, force: true });
29
- });
30
-
31
- const entry: ManifestEntry = { scene: 'intro', text: 'Hello world' };
32
- const demoName = 'demo1';
33
-
34
- function makeWav(): Buffer {
35
- return createWavBuffer(new Float32Array([0.1, 0.2, 0.3]));
36
- }
37
-
38
- describe('isCached', () => {
39
- it('returns false when not cached', () => {
40
- expect(cache.isCached(demoName, entry)).toBe(false);
41
- });
42
-
43
- it('returns true after caching', () => {
44
- cache.cacheClip(demoName, entry, makeWav());
45
- expect(cache.isCached(demoName, entry)).toBe(true);
46
- });
47
- });
48
-
49
- describe('getCachedClip', () => {
50
- it('returns null when not cached', () => {
51
- expect(cache.getCachedClip(demoName, entry)).toBeNull();
52
- });
53
-
54
- it('returns buffer when cached', () => {
55
- const wav = makeWav();
56
- cache.cacheClip(demoName, entry, wav);
57
- const result = cache.getCachedClip(demoName, entry);
58
- expect(result).not.toBeNull();
59
- expect(Buffer.compare(result!, wav)).toBe(0);
60
- });
61
- });
62
-
63
- describe('cacheClip', () => {
64
- it('creates directory structure and writes file named by hash', () => {
65
- const wav = makeWav();
66
- cache.cacheClip(demoName, entry, wav);
67
-
68
- const hash = makeHash(entry);
69
- const expectedPath = path.join(tmpDir, '.argo', demoName, 'clips', `${hash}.wav`);
70
- expect(fs.existsSync(expectedPath)).toBe(true);
71
-
72
- const contents = fs.readFileSync(expectedPath);
73
- expect(Buffer.compare(contents, wav)).toBe(0);
74
- });
75
- });
76
-
77
- describe('getClipPath', () => {
78
- it('returns correct path', () => {
79
- const hash = makeHash(entry);
80
- const expected = path.join(tmpDir, '.argo', demoName, 'clips', `${hash}.wav`);
81
- expect(cache.getClipPath(demoName, entry)).toBe(expected);
82
- });
83
- });
84
-
85
- describe('invalidation', () => {
86
- it('different text produces different hash', () => {
87
- const entry2: ManifestEntry = { scene: 'intro', text: 'Goodbye world' };
88
- expect(cache.getClipPath(demoName, entry)).not.toBe(cache.getClipPath(demoName, entry2));
89
- });
90
-
91
- it('different voice produces different hash', () => {
92
- const entry2: ManifestEntry = { scene: 'intro', text: 'Hello world', voice: 'alloy' };
93
- expect(cache.getClipPath(demoName, entry)).not.toBe(cache.getClipPath(demoName, entry2));
94
- });
95
-
96
- it('different speed produces different hash', () => {
97
- const entry2: ManifestEntry = { scene: 'intro', text: 'Hello world', speed: 1.5 };
98
- expect(cache.getClipPath(demoName, entry)).not.toBe(cache.getClipPath(demoName, entry2));
99
- });
100
- });
101
-
102
- describe('independent per demo name', () => {
103
- it('caching under one demo does not affect another', () => {
104
- const wav = makeWav();
105
- cache.cacheClip('demoA', entry, wav);
106
- expect(cache.isCached('demoA', entry)).toBe(true);
107
- expect(cache.isCached('demoB', entry)).toBe(false);
108
- });
109
- });
110
- });