opencode-nanobanana 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 (179) hide show
  1. package/.ralph-events.json +151 -0
  2. package/.ralph-last-branch +1 -0
  3. package/.ralph-monitor-state.json +7 -0
  4. package/.ralph-monitor.pid +1 -0
  5. package/.ralph-timing.json +26 -0
  6. package/README.md +708 -0
  7. package/dist/index.d.ts +18 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +21 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/platforms/android.d.ts +94 -0
  12. package/dist/platforms/android.d.ts.map +1 -0
  13. package/dist/platforms/android.js +123 -0
  14. package/dist/platforms/android.js.map +1 -0
  15. package/dist/platforms/ios.d.ts +51 -0
  16. package/dist/platforms/ios.d.ts.map +1 -0
  17. package/dist/platforms/ios.js +149 -0
  18. package/dist/platforms/ios.js.map +1 -0
  19. package/dist/platforms/macos.d.ts +33 -0
  20. package/dist/platforms/macos.d.ts.map +1 -0
  21. package/dist/platforms/macos.js +50 -0
  22. package/dist/platforms/macos.js.map +1 -0
  23. package/dist/platforms/watchos.d.ts +36 -0
  24. package/dist/platforms/watchos.d.ts.map +1 -0
  25. package/dist/platforms/watchos.js +113 -0
  26. package/dist/platforms/watchos.js.map +1 -0
  27. package/dist/platforms/web.d.ts +64 -0
  28. package/dist/platforms/web.d.ts.map +1 -0
  29. package/dist/platforms/web.js +96 -0
  30. package/dist/platforms/web.js.map +1 -0
  31. package/dist/providers/gemini.d.ts +41 -0
  32. package/dist/providers/gemini.d.ts.map +1 -0
  33. package/dist/providers/gemini.js +177 -0
  34. package/dist/providers/gemini.js.map +1 -0
  35. package/dist/tools/analyze/compare.d.ts +12 -0
  36. package/dist/tools/analyze/compare.d.ts.map +1 -0
  37. package/dist/tools/analyze/compare.js +83 -0
  38. package/dist/tools/analyze/compare.js.map +1 -0
  39. package/dist/tools/analyze/mockup.d.ts +12 -0
  40. package/dist/tools/analyze/mockup.d.ts.map +1 -0
  41. package/dist/tools/analyze/mockup.js +88 -0
  42. package/dist/tools/analyze/mockup.js.map +1 -0
  43. package/dist/tools/analyze/screenshot.d.ts +12 -0
  44. package/dist/tools/analyze/screenshot.d.ts.map +1 -0
  45. package/dist/tools/analyze/screenshot.js +61 -0
  46. package/dist/tools/analyze/screenshot.js.map +1 -0
  47. package/dist/tools/app-assets/app-icon.d.ts +9 -0
  48. package/dist/tools/app-assets/app-icon.d.ts.map +1 -0
  49. package/dist/tools/app-assets/app-icon.js +133 -0
  50. package/dist/tools/app-assets/app-icon.js.map +1 -0
  51. package/dist/tools/app-assets/device-mockup.d.ts +9 -0
  52. package/dist/tools/app-assets/device-mockup.d.ts.map +1 -0
  53. package/dist/tools/app-assets/device-mockup.js +139 -0
  54. package/dist/tools/app-assets/device-mockup.js.map +1 -0
  55. package/dist/tools/app-assets/launch-images.d.ts +3 -0
  56. package/dist/tools/app-assets/launch-images.d.ts.map +1 -0
  57. package/dist/tools/app-assets/launch-images.js +171 -0
  58. package/dist/tools/app-assets/launch-images.js.map +1 -0
  59. package/dist/tools/app-assets/resize-devices.d.ts +14 -0
  60. package/dist/tools/app-assets/resize-devices.d.ts.map +1 -0
  61. package/dist/tools/app-assets/resize-devices.js +296 -0
  62. package/dist/tools/app-assets/resize-devices.js.map +1 -0
  63. package/dist/tools/app-assets/screenshots.d.ts +14 -0
  64. package/dist/tools/app-assets/screenshots.d.ts.map +1 -0
  65. package/dist/tools/app-assets/screenshots.js +186 -0
  66. package/dist/tools/app-assets/screenshots.js.map +1 -0
  67. package/dist/tools/core/edit-image.d.ts +12 -0
  68. package/dist/tools/core/edit-image.d.ts.map +1 -0
  69. package/dist/tools/core/edit-image.js +102 -0
  70. package/dist/tools/core/edit-image.js.map +1 -0
  71. package/dist/tools/core/generate-image.d.ts +12 -0
  72. package/dist/tools/core/generate-image.d.ts.map +1 -0
  73. package/dist/tools/core/generate-image.js +96 -0
  74. package/dist/tools/core/generate-image.js.map +1 -0
  75. package/dist/tools/core/restore-image.d.ts +12 -0
  76. package/dist/tools/core/restore-image.d.ts.map +1 -0
  77. package/dist/tools/core/restore-image.js +104 -0
  78. package/dist/tools/core/restore-image.js.map +1 -0
  79. package/dist/tools/design/mockup-to-code.d.ts +3 -0
  80. package/dist/tools/design/mockup-to-code.d.ts.map +1 -0
  81. package/dist/tools/design/mockup-to-code.js +311 -0
  82. package/dist/tools/design/mockup-to-code.js.map +1 -0
  83. package/dist/tools/design/sketch-to-code.d.ts +3 -0
  84. package/dist/tools/design/sketch-to-code.d.ts.map +1 -0
  85. package/dist/tools/design/sketch-to-code.js +325 -0
  86. package/dist/tools/design/sketch-to-code.js.map +1 -0
  87. package/dist/tools/docs/architecture-diagram.d.ts +12 -0
  88. package/dist/tools/docs/architecture-diagram.d.ts.map +1 -0
  89. package/dist/tools/docs/architecture-diagram.js +179 -0
  90. package/dist/tools/docs/architecture-diagram.js.map +1 -0
  91. package/dist/tools/docs/readme-banner.d.ts +6 -0
  92. package/dist/tools/docs/readme-banner.d.ts.map +1 -0
  93. package/dist/tools/docs/readme-banner.js +108 -0
  94. package/dist/tools/docs/readme-banner.js.map +1 -0
  95. package/dist/tools/docs/sequence-diagram.d.ts +12 -0
  96. package/dist/tools/docs/sequence-diagram.d.ts.map +1 -0
  97. package/dist/tools/docs/sequence-diagram.js +161 -0
  98. package/dist/tools/docs/sequence-diagram.js.map +1 -0
  99. package/dist/tools/docs/social-preview.d.ts +11 -0
  100. package/dist/tools/docs/social-preview.d.ts.map +1 -0
  101. package/dist/tools/docs/social-preview.js +111 -0
  102. package/dist/tools/docs/social-preview.js.map +1 -0
  103. package/dist/tools/video/extend-video.d.ts +14 -0
  104. package/dist/tools/video/extend-video.d.ts.map +1 -0
  105. package/dist/tools/video/extend-video.js +39 -0
  106. package/dist/tools/video/extend-video.js.map +1 -0
  107. package/dist/tools/video/generate-video.d.ts +14 -0
  108. package/dist/tools/video/generate-video.d.ts.map +1 -0
  109. package/dist/tools/video/generate-video.js +39 -0
  110. package/dist/tools/video/generate-video.js.map +1 -0
  111. package/dist/tools/video/image-to-video.d.ts +15 -0
  112. package/dist/tools/video/image-to-video.d.ts.map +1 -0
  113. package/dist/tools/video/image-to-video.js +42 -0
  114. package/dist/tools/video/image-to-video.js.map +1 -0
  115. package/dist/tools/video/storyboard-video.d.ts +91 -0
  116. package/dist/tools/video/storyboard-video.d.ts.map +1 -0
  117. package/dist/tools/video/storyboard-video.js +230 -0
  118. package/dist/tools/video/storyboard-video.js.map +1 -0
  119. package/dist/utils/ffmpeg.d.ts +30 -0
  120. package/dist/utils/ffmpeg.d.ts.map +1 -0
  121. package/dist/utils/ffmpeg.js +205 -0
  122. package/dist/utils/ffmpeg.js.map +1 -0
  123. package/dist/utils/file-handler.d.ts +7 -0
  124. package/dist/utils/file-handler.d.ts.map +1 -0
  125. package/dist/utils/file-handler.js +10 -0
  126. package/dist/utils/file-handler.js.map +1 -0
  127. package/dist/utils/image-processing.d.ts +7 -0
  128. package/dist/utils/image-processing.d.ts.map +1 -0
  129. package/dist/utils/image-processing.js +10 -0
  130. package/dist/utils/image-processing.js.map +1 -0
  131. package/docs/PLUGIN-VERIFICATION.md +182 -0
  132. package/logs/notifications.jsonl +46 -0
  133. package/package.json +61 -0
  134. package/prd.json +216 -0
  135. package/progress.txt +145 -0
  136. package/ralph-report.html +297 -0
  137. package/src/index.ts +23 -0
  138. package/src/platforms/android/.gitkeep +0 -0
  139. package/src/platforms/ios/.gitkeep +0 -0
  140. package/src/platforms/web/.gitkeep +0 -0
  141. package/src/providers/.gitkeep +0 -0
  142. package/src/providers/gemini.ts +288 -0
  143. package/src/tools/core/.gitkeep +0 -0
  144. package/src/tools/platform/.gitkeep +0 -0
  145. package/src/tools/video/extend-video.ts +71 -0
  146. package/src/tools/video/generate-video.ts +70 -0
  147. package/src/tools/video/image-to-video.ts +76 -0
  148. package/src/tools/video/storyboard-video.ts +325 -0
  149. package/src/utils/.gitkeep +0 -0
  150. package/src/utils/ffmpeg.ts +266 -0
  151. package/src/utils/file-handler.ts +10 -0
  152. package/src/utils/image-processing.ts +10 -0
  153. package/templates/.gitkeep +0 -0
  154. package/test-analyze-screenshot.ts +50 -0
  155. package/test-app-icons.ts +55 -0
  156. package/test-cat-sunset.ts +30 -0
  157. package/test-full-plugin.ts +88 -0
  158. package/test-icon-gen.ts +30 -0
  159. package/test-output/test-edit.png +0 -0
  160. package/test-output/test-generate.png +0 -0
  161. package/test-output/test-video.mp4 +0 -0
  162. package/test-plugin-load.js +45 -0
  163. package/test-princess-emma-continue.ts +35 -0
  164. package/test-princess-emma-full.ts +38 -0
  165. package/test-princess-emma-short.ts +32 -0
  166. package/test-princess-emma-with-reference.ts +34 -0
  167. package/test-princess-emma.ts +38 -0
  168. package/test-product-ad.ts +66 -0
  169. package/test-ralph-droid.ts +30 -0
  170. package/test-social-preview.ts +61 -0
  171. package/test-veo31-live.ts +187 -0
  172. package/test-video-gen.ts +40 -0
  173. package/test-video-veo.ts +73 -0
  174. package/test-zurich-video.ts +64 -0
  175. package/tests/.gitkeep +0 -0
  176. package/tests/providers/gemini.test.ts +388 -0
  177. package/tests/utils/ffmpeg.test.ts +328 -0
  178. package/tests/video/storyboard.test.ts +469 -0
  179. package/tsconfig.json +25 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * GeminiProvider Tests
3
+ *
4
+ * Tests for Veo 3.1 video generation functionality
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { GeminiProvider } from '../../src/providers/gemini.js';
9
+
10
+ // Mock the @google/genai module
11
+ vi.mock('@google/genai', async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ return {
14
+ ...(actual as any),
15
+ GoogleGenAI: vi.fn().mockImplementation(() => ({
16
+ models: {
17
+ generateVideos: vi.fn(),
18
+ },
19
+ operations: {
20
+ getVideosOperation: vi.fn(),
21
+ },
22
+ })),
23
+ };
24
+ });
25
+
26
+ // Mock fetch
27
+ global.fetch = vi.fn();
28
+
29
+ describe('GeminiProvider', () => {
30
+ let provider: GeminiProvider;
31
+ let mockAi: any;
32
+
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ provider = new GeminiProvider('test-api-key');
36
+ mockAi = (provider as any).ai;
37
+ });
38
+
39
+ describe('generateVideo', () => {
40
+ it('should generate video with default options', async () => {
41
+ const mockCompletedOperation = {
42
+ name: 'operations/test-123',
43
+ done: true,
44
+ response: {
45
+ generatedVideos: [
46
+ {
47
+ video: {
48
+ uri: 'https://example.com/video.mp4',
49
+ },
50
+ },
51
+ ],
52
+ },
53
+ };
54
+
55
+ const mockVideoBuffer = Buffer.from('mock-video-data');
56
+
57
+ // Return completed operation immediately (no polling needed)
58
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockCompletedOperation);
59
+
60
+ (global.fetch as any).mockResolvedValueOnce({
61
+ ok: true,
62
+ arrayBuffer: async () => mockVideoBuffer.buffer,
63
+ });
64
+
65
+ const result = await provider.generateVideo('test prompt');
66
+
67
+ expect(mockAi.models.generateVideos).toHaveBeenCalledWith({
68
+ model: 'veo-3.1-generate-001',
69
+ prompt: 'test prompt',
70
+ config: {
71
+ numberOfVideos: 1,
72
+ aspectRatio: '16:9',
73
+ resolution: '720p',
74
+ durationSeconds: 8,
75
+ generateAudio: true,
76
+ },
77
+ });
78
+
79
+ expect(result.buffer).toBeDefined();
80
+ expect(result.generationTime).toBeGreaterThanOrEqual(0);
81
+ });
82
+
83
+ it('should generate video with custom options', async () => {
84
+ const mockOperation = {
85
+ name: 'operations/test-456',
86
+ done: true,
87
+ response: {
88
+ generatedVideos: [
89
+ {
90
+ video: {
91
+ uri: 'https://example.com/video2.mp4',
92
+ },
93
+ },
94
+ ],
95
+ },
96
+ };
97
+
98
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockOperation);
99
+
100
+ (global.fetch as any).mockResolvedValueOnce({
101
+ ok: true,
102
+ arrayBuffer: async () => Buffer.from('mock-video-data').buffer,
103
+ });
104
+
105
+ await provider.generateVideo('test prompt', {
106
+ aspectRatio: '9:16',
107
+ resolution: '1080p',
108
+ duration: 6,
109
+ generateAudio: false,
110
+ });
111
+
112
+ expect(mockAi.models.generateVideos).toHaveBeenCalledWith({
113
+ model: 'veo-3.1-generate-001',
114
+ prompt: 'test prompt',
115
+ config: {
116
+ numberOfVideos: 1,
117
+ aspectRatio: '9:16',
118
+ resolution: '1080p',
119
+ durationSeconds: 6,
120
+ generateAudio: false,
121
+ },
122
+ });
123
+ });
124
+
125
+ it('should poll until operation is done', async () => {
126
+ vi.useFakeTimers();
127
+
128
+ const mockPendingOperation = {
129
+ name: 'operations/test-789',
130
+ done: false,
131
+ };
132
+
133
+ const mockCompletedOperation = {
134
+ name: 'operations/test-789',
135
+ done: true,
136
+ response: {
137
+ generatedVideos: [
138
+ {
139
+ video: {
140
+ uri: 'https://example.com/video3.mp4',
141
+ },
142
+ },
143
+ ],
144
+ },
145
+ };
146
+
147
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockPendingOperation);
148
+ mockAi.operations.getVideosOperation
149
+ .mockResolvedValueOnce(mockPendingOperation)
150
+ .mockResolvedValueOnce(mockCompletedOperation);
151
+
152
+ (global.fetch as any).mockResolvedValueOnce({
153
+ ok: true,
154
+ arrayBuffer: async () => Buffer.from('mock-video-data').buffer,
155
+ });
156
+
157
+ const promise = provider.generateVideo('test prompt');
158
+
159
+ // Fast-forward through the polling intervals
160
+ await vi.advanceTimersByTimeAsync(20000);
161
+
162
+ await promise;
163
+
164
+ expect(mockAi.operations.getVideosOperation).toHaveBeenCalledTimes(2);
165
+
166
+ vi.useRealTimers();
167
+ });
168
+
169
+ it('should throw error if no video is generated', async () => {
170
+ const mockOperation = {
171
+ name: 'operations/test-error',
172
+ done: true,
173
+ response: {
174
+ generatedVideos: [],
175
+ },
176
+ };
177
+
178
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockOperation);
179
+
180
+ await expect(provider.generateVideo('test prompt')).rejects.toThrow(
181
+ 'No video was generated'
182
+ );
183
+ });
184
+ });
185
+
186
+ describe('animateImage', () => {
187
+ it('should animate image with default options', async () => {
188
+ const mockImageBuffer = Buffer.from([
189
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
190
+ ]); // PNG header
191
+
192
+ const mockOperation = {
193
+ name: 'operations/animate-123',
194
+ done: true,
195
+ response: {
196
+ generatedVideos: [
197
+ {
198
+ video: {
199
+ uri: 'https://example.com/animated.mp4',
200
+ },
201
+ },
202
+ ],
203
+ },
204
+ };
205
+
206
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockOperation);
207
+
208
+ (global.fetch as any).mockResolvedValueOnce({
209
+ ok: true,
210
+ arrayBuffer: async () => Buffer.from('mock-video-data').buffer,
211
+ });
212
+
213
+ const result = await provider.animateImage(
214
+ mockImageBuffer,
215
+ 'animate this image'
216
+ );
217
+
218
+ expect(mockAi.models.generateVideos).toHaveBeenCalledWith({
219
+ model: 'veo-3.1-generate-001',
220
+ prompt: 'animate this image',
221
+ image: {
222
+ imageBytes: mockImageBuffer.toString('base64'),
223
+ mimeType: 'image/png',
224
+ },
225
+ config: {
226
+ numberOfVideos: 1,
227
+ aspectRatio: '16:9',
228
+ resolution: '720p',
229
+ durationSeconds: 8,
230
+ generateAudio: true,
231
+ },
232
+ });
233
+
234
+ expect(result.buffer).toBeDefined();
235
+ });
236
+
237
+ it('should detect JPEG mime type', async () => {
238
+ const mockImageBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); // JPEG header
239
+
240
+ const mockOperation = {
241
+ name: 'operations/animate-jpeg',
242
+ done: true,
243
+ response: {
244
+ generatedVideos: [
245
+ {
246
+ video: {
247
+ uri: 'https://example.com/animated-jpeg.mp4',
248
+ },
249
+ },
250
+ ],
251
+ },
252
+ };
253
+
254
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockOperation);
255
+
256
+ (global.fetch as any).mockResolvedValueOnce({
257
+ ok: true,
258
+ arrayBuffer: async () => Buffer.from('mock-video-data').buffer,
259
+ });
260
+
261
+ await provider.animateImage(mockImageBuffer, 'animate jpeg');
262
+
263
+ const calls = mockAi.models.generateVideos.mock.calls;
264
+ expect(calls[0]?.[0]?.image?.mimeType).toBe('image/jpeg');
265
+ });
266
+ });
267
+
268
+ describe('extendVideo', () => {
269
+ it('should extend video with new content', async () => {
270
+ const mockVideoBuffer = Buffer.from('mock-video-data');
271
+
272
+ const mockOperation = {
273
+ name: 'operations/extend-123',
274
+ done: true,
275
+ response: {
276
+ generatedVideos: [
277
+ {
278
+ video: {
279
+ uri: 'https://example.com/extended.mp4',
280
+ },
281
+ },
282
+ ],
283
+ },
284
+ };
285
+
286
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockOperation);
287
+
288
+ (global.fetch as any).mockResolvedValueOnce({
289
+ ok: true,
290
+ arrayBuffer: async () => Buffer.from('extended-video-data').buffer,
291
+ });
292
+
293
+ const result = await provider.extendVideo(
294
+ mockVideoBuffer,
295
+ 'extend with new scene'
296
+ );
297
+
298
+ expect(mockAi.models.generateVideos).toHaveBeenCalledWith({
299
+ model: 'veo-3.1-generate-001',
300
+ prompt: 'extend with new scene',
301
+ video: {
302
+ videoBytes: mockVideoBuffer.toString('base64'),
303
+ mimeType: 'video/mp4',
304
+ },
305
+ config: {
306
+ numberOfVideos: 1,
307
+ aspectRatio: '16:9',
308
+ resolution: '720p',
309
+ },
310
+ });
311
+
312
+ expect(result.buffer).toBeDefined();
313
+ });
314
+ });
315
+
316
+ describe('generateVideoWithReferences', () => {
317
+ it('should generate video with reference images', async () => {
318
+ const referenceImages = [
319
+ {
320
+ buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47]), // PNG header
321
+ description: 'Character reference',
322
+ },
323
+ {
324
+ buffer: Buffer.from([0xff, 0xd8, 0xff, 0xe0]), // JPEG header
325
+ description: 'Scene reference',
326
+ },
327
+ ];
328
+
329
+ const mockOperation = {
330
+ name: 'operations/ref-123',
331
+ done: true,
332
+ response: {
333
+ generatedVideos: [
334
+ {
335
+ video: {
336
+ uri: 'https://example.com/with-refs.mp4',
337
+ },
338
+ },
339
+ ],
340
+ },
341
+ };
342
+
343
+ mockAi.models.generateVideos.mockResolvedValueOnce(mockOperation);
344
+
345
+ (global.fetch as any).mockResolvedValueOnce({
346
+ ok: true,
347
+ arrayBuffer: async () => Buffer.from('mock-video-data').buffer,
348
+ });
349
+
350
+ const result = await provider.generateVideoWithReferences(
351
+ 'video with character consistency',
352
+ referenceImages
353
+ );
354
+
355
+ const calls = mockAi.models.generateVideos.mock.calls;
356
+ const call = calls[0]?.[0];
357
+
358
+ expect(call?.model).toBe('veo-3.1-generate-001');
359
+ expect(call?.prompt).toBe('video with character consistency');
360
+ expect(call?.config?.referenceImages).toHaveLength(2);
361
+ expect(call?.config?.referenceImages?.[0]?.image?.imageBytes).toBe(
362
+ referenceImages[0].buffer.toString('base64')
363
+ );
364
+ expect(call?.config?.referenceImages?.[0]?.image?.mimeType).toBe('image/png');
365
+ expect(call?.config?.durationSeconds).toBe(8);
366
+ expect(call?.config?.generateAudio).toBe(true);
367
+
368
+ expect(result.buffer).toBeDefined();
369
+ });
370
+
371
+ it('should throw error if reference images count is invalid', async () => {
372
+ await expect(
373
+ provider.generateVideoWithReferences('test', [])
374
+ ).rejects.toThrow('Reference images must be between 1 and 3');
375
+
376
+ const tooManyRefs = Array(4)
377
+ .fill(null)
378
+ .map(() => ({
379
+ buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
380
+ description: 'test',
381
+ }));
382
+
383
+ await expect(
384
+ provider.generateVideoWithReferences('test', tooManyRefs)
385
+ ).rejects.toThrow('Reference images must be between 1 and 3');
386
+ });
387
+ });
388
+ });
@@ -0,0 +1,328 @@
1
+ /**
2
+ * FFmpeg Utility Tests
3
+ *
4
+ * Tests for FFmpeg video processing operations
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import * as ffmpeg from '../../src/utils/ffmpeg.js';
9
+ import { exec } from 'child_process';
10
+ import { existsSync } from 'fs';
11
+ import * as fs from 'fs/promises';
12
+
13
+ // Mock child_process
14
+ vi.mock('child_process', () => ({
15
+ exec: vi.fn(),
16
+ }));
17
+
18
+ // Mock fs
19
+ vi.mock('fs', () => ({
20
+ existsSync: vi.fn(),
21
+ }));
22
+
23
+ // Mock fs/promises
24
+ vi.mock('fs/promises', () => ({
25
+ writeFile: vi.fn(),
26
+ unlink: vi.fn(),
27
+ copyFile: vi.fn(),
28
+ }));
29
+
30
+ describe('FFmpeg Utils', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ // Default: files exist
34
+ vi.mocked(existsSync).mockReturnValue(true);
35
+ });
36
+
37
+ describe('checkFfmpegInstalled', () => {
38
+ it('should return true when ffmpeg is installed', async () => {
39
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
40
+ callback(null, { stdout: 'ffmpeg version 4.4.0', stderr: '' });
41
+ return {} as any;
42
+ });
43
+
44
+ const result = await ffmpeg.checkFfmpegInstalled();
45
+ expect(result).toBe(true);
46
+ expect(exec).toHaveBeenCalledWith('ffmpeg -version', expect.any(Function));
47
+ });
48
+
49
+ it('should return false when ffmpeg is not installed', async () => {
50
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
51
+ callback(new Error('Command not found'), { stdout: '', stderr: '' });
52
+ return {} as any;
53
+ });
54
+
55
+ const result = await ffmpeg.checkFfmpegInstalled();
56
+ expect(result).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe('getVideoDuration', () => {
61
+ it('should return video duration in seconds', async () => {
62
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
63
+ callback(null, { stdout: '10.5\n', stderr: '' });
64
+ return {} as any;
65
+ });
66
+
67
+ const duration = await ffmpeg.getVideoDuration('/path/to/video.mp4');
68
+ expect(duration).toBe(10.5);
69
+ expect(exec).toHaveBeenCalledWith(
70
+ expect.stringContaining('ffprobe'),
71
+ expect.any(Function)
72
+ );
73
+ });
74
+
75
+ it('should throw error when video file does not exist', async () => {
76
+ vi.mocked(existsSync).mockReturnValue(false);
77
+
78
+ await expect(ffmpeg.getVideoDuration('/nonexistent.mp4')).rejects.toThrow(
79
+ 'Video file not found'
80
+ );
81
+ });
82
+
83
+ it('should throw error when ffprobe fails', async () => {
84
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
85
+ callback(new Error('ffprobe error'), { stdout: '', stderr: '' });
86
+ return {} as any;
87
+ });
88
+
89
+ await expect(ffmpeg.getVideoDuration('/path/to/video.mp4')).rejects.toThrow(
90
+ 'Failed to get video duration'
91
+ );
92
+ });
93
+
94
+ it('should throw error when duration cannot be parsed', async () => {
95
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
96
+ callback(null, { stdout: 'invalid\n', stderr: '' });
97
+ return {} as any;
98
+ });
99
+
100
+ await expect(ffmpeg.getVideoDuration('/path/to/video.mp4')).rejects.toThrow(
101
+ 'Failed to parse video duration'
102
+ );
103
+ });
104
+ });
105
+
106
+ describe('trimVideo', () => {
107
+ it('should trim video from startTime for duration', async () => {
108
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
109
+ callback(null, { stdout: '', stderr: '' });
110
+ return {} as any;
111
+ });
112
+
113
+ await ffmpeg.trimVideo('/input.mp4', 5, 10, '/output.mp4');
114
+
115
+ expect(exec).toHaveBeenCalledWith(
116
+ expect.stringContaining('-ss 5 -t 10'),
117
+ expect.any(Function)
118
+ );
119
+ });
120
+
121
+ it('should throw error when video file does not exist', async () => {
122
+ vi.mocked(existsSync).mockReturnValue(false);
123
+
124
+ await expect(ffmpeg.trimVideo('/nonexistent.mp4', 0, 5, '/output.mp4')).rejects.toThrow(
125
+ 'Video file not found'
126
+ );
127
+ });
128
+
129
+ it('should throw error for invalid startTime', async () => {
130
+ await expect(ffmpeg.trimVideo('/input.mp4', -1, 5, '/output.mp4')).rejects.toThrow(
131
+ 'Invalid trim parameters'
132
+ );
133
+ });
134
+
135
+ it('should throw error for invalid duration', async () => {
136
+ await expect(ffmpeg.trimVideo('/input.mp4', 0, -5, '/output.mp4')).rejects.toThrow(
137
+ 'Invalid trim parameters'
138
+ );
139
+ });
140
+
141
+ it('should throw error when ffmpeg fails', async () => {
142
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
143
+ callback(new Error('ffmpeg error'), { stdout: '', stderr: '' });
144
+ return {} as any;
145
+ });
146
+
147
+ await expect(ffmpeg.trimVideo('/input.mp4', 0, 5, '/output.mp4')).rejects.toThrow(
148
+ 'Failed to trim video'
149
+ );
150
+ });
151
+ });
152
+
153
+ describe('addAudioTrack', () => {
154
+ it('should add audio track to video', async () => {
155
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
156
+ callback(null, { stdout: '', stderr: '' });
157
+ return {} as any;
158
+ });
159
+
160
+ await ffmpeg.addAudioTrack('/video.mp4', '/audio.mp3', '/output.mp4');
161
+
162
+ expect(exec).toHaveBeenCalledWith(
163
+ expect.stringContaining('amix'),
164
+ expect.any(Function)
165
+ );
166
+ });
167
+
168
+ it('should apply custom volume to audio track', async () => {
169
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
170
+ callback(null, { stdout: '', stderr: '' });
171
+ return {} as any;
172
+ });
173
+
174
+ await ffmpeg.addAudioTrack('/video.mp4', '/audio.mp3', '/output.mp4', 0.5);
175
+
176
+ expect(exec).toHaveBeenCalledWith(
177
+ expect.stringContaining('volume=0.5'),
178
+ expect.any(Function)
179
+ );
180
+ });
181
+
182
+ it('should throw error when video file does not exist', async () => {
183
+ vi.mocked(existsSync).mockReturnValueOnce(false);
184
+
185
+ await expect(
186
+ ffmpeg.addAudioTrack('/nonexistent.mp4', '/audio.mp3', '/output.mp4')
187
+ ).rejects.toThrow('Video file not found');
188
+ });
189
+
190
+ it('should throw error when audio file does not exist', async () => {
191
+ vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
192
+
193
+ await expect(
194
+ ffmpeg.addAudioTrack('/video.mp4', '/nonexistent.mp3', '/output.mp4')
195
+ ).rejects.toThrow('Audio file not found');
196
+ });
197
+
198
+ it('should throw error for invalid volume', async () => {
199
+ await expect(
200
+ ffmpeg.addAudioTrack('/video.mp4', '/audio.mp3', '/output.mp4', 1.5)
201
+ ).rejects.toThrow('Volume must be between 0 and 1');
202
+ });
203
+
204
+ it('should throw error when ffmpeg fails', async () => {
205
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
206
+ callback(new Error('ffmpeg error'), { stdout: '', stderr: '' });
207
+ return {} as any;
208
+ });
209
+
210
+ await expect(
211
+ ffmpeg.addAudioTrack('/video.mp4', '/audio.mp3', '/output.mp4')
212
+ ).rejects.toThrow('Failed to add audio track');
213
+ });
214
+ });
215
+
216
+ describe('concatenateVideos', () => {
217
+ beforeEach(() => {
218
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
219
+ callback(null, { stdout: '', stderr: '' });
220
+ return {} as any;
221
+ });
222
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
223
+ vi.mocked(fs.unlink).mockResolvedValue(undefined);
224
+ vi.mocked(fs.copyFile).mockResolvedValue(undefined);
225
+ });
226
+
227
+ it('should concatenate videos with cut transition', async () => {
228
+ await ffmpeg.concatenateVideos(
229
+ ['/video1.mp4', '/video2.mp4'],
230
+ '/output.mp4',
231
+ { transition: 'cut' }
232
+ );
233
+
234
+ expect(fs.writeFile).toHaveBeenCalledWith(
235
+ expect.stringContaining('.concat.txt'),
236
+ expect.stringContaining("file '/video1.mp4'")
237
+ );
238
+ expect(exec).toHaveBeenCalledWith(
239
+ expect.stringContaining('-f concat'),
240
+ expect.any(Function)
241
+ );
242
+ });
243
+
244
+ it('should concatenate videos with crossfade transition', async () => {
245
+ await ffmpeg.concatenateVideos(
246
+ ['/video1.mp4', '/video2.mp4'],
247
+ '/output.mp4',
248
+ { transition: 'crossfade', transitionDuration: 1.0 }
249
+ );
250
+
251
+ expect(exec).toHaveBeenCalledWith(
252
+ expect.stringContaining('xfade'),
253
+ expect.any(Function)
254
+ );
255
+ });
256
+
257
+ it('should concatenate videos with fade transition', async () => {
258
+ await ffmpeg.concatenateVideos(
259
+ ['/video1.mp4', '/video2.mp4'],
260
+ '/output.mp4',
261
+ { transition: 'fade', transitionDuration: 0.5 }
262
+ );
263
+
264
+ expect(exec).toHaveBeenCalledWith(
265
+ expect.stringContaining('fade'),
266
+ expect.any(Function)
267
+ );
268
+ });
269
+
270
+ it('should use default crossfade transition', async () => {
271
+ await ffmpeg.concatenateVideos(['/video1.mp4', '/video2.mp4'], '/output.mp4');
272
+
273
+ expect(exec).toHaveBeenCalledWith(
274
+ expect.stringContaining('xfade'),
275
+ expect.any(Function)
276
+ );
277
+ });
278
+
279
+ it('should throw error for empty video list', async () => {
280
+ await expect(ffmpeg.concatenateVideos([], '/output.mp4')).rejects.toThrow(
281
+ 'No videos provided'
282
+ );
283
+ });
284
+
285
+ it('should throw error when a video file does not exist', async () => {
286
+ vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
287
+
288
+ await expect(
289
+ ffmpeg.concatenateVideos(['/video1.mp4', '/nonexistent.mp4'], '/output.mp4')
290
+ ).rejects.toThrow('Video file not found');
291
+ });
292
+
293
+ it('should throw error for unknown transition type', async () => {
294
+ await expect(
295
+ ffmpeg.concatenateVideos(['/video1.mp4', '/video2.mp4'], '/output.mp4', {
296
+ transition: 'invalid' as any,
297
+ })
298
+ ).rejects.toThrow('Unknown transition type');
299
+ });
300
+
301
+ it('should handle single video with crossfade (copy file)', async () => {
302
+ await ffmpeg.concatenateVideos(['/video1.mp4'], '/output.mp4', {
303
+ transition: 'crossfade',
304
+ });
305
+
306
+ expect(fs.copyFile).toHaveBeenCalledWith('/video1.mp4', '/output.mp4');
307
+ });
308
+
309
+ it('should clean up concat file after cut concatenation', async () => {
310
+ await ffmpeg.concatenateVideos(['/video1.mp4', '/video2.mp4'], '/output.mp4', {
311
+ transition: 'cut',
312
+ });
313
+
314
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('.concat.txt'));
315
+ });
316
+
317
+ it('should throw error when ffmpeg fails', async () => {
318
+ vi.mocked(exec).mockImplementation((cmd: any, callback: any) => {
319
+ callback(new Error('ffmpeg error'), { stdout: '', stderr: '' });
320
+ return {} as any;
321
+ });
322
+
323
+ await expect(
324
+ ffmpeg.concatenateVideos(['/video1.mp4', '/video2.mp4'], '/output.mp4')
325
+ ).rejects.toThrow('Failed to concatenate videos');
326
+ });
327
+ });
328
+ });