CognxSafeTrack commited on
Commit
29ead2c
ยท
1 Parent(s): 818c5f2

feat(audio): Enforce strict AI_API_BASE_URL and add FFmpeg MP3 STT conversion

Browse files
apps/api/src/routes/ai.ts CHANGED
@@ -3,6 +3,7 @@ import { aiService } from '../services/ai';
3
  import { PdfOnePagerRenderer } from '../services/renderers/pdf-renderer';
4
  import { PptxDeckRenderer } from '../services/renderers/pptx-renderer';
5
  import { uploadFile } from '../services/storage';
 
6
  import { z } from 'zod';
7
 
8
 
@@ -98,7 +99,8 @@ export async function aiRoutes(fastify: FastifyInstance) {
98
  const buffer = Buffer.from(audioBase64, 'base64');
99
 
100
  try {
101
- const text = await aiService.transcribeAudio(buffer, filename);
 
102
  return { success: true, text };
103
  } catch (err: any) {
104
  if (err?.name === 'QuotaExceededError') {
 
3
  import { PdfOnePagerRenderer } from '../services/renderers/pdf-renderer';
4
  import { PptxDeckRenderer } from '../services/renderers/pptx-renderer';
5
  import { uploadFile } from '../services/storage';
6
+ import { convertToMp3IfNeeded } from '../services/ai/ffmpeg';
7
  import { z } from 'zod';
8
 
9
 
 
99
  const buffer = Buffer.from(audioBase64, 'base64');
100
 
101
  try {
102
+ const { buffer: audioToTranscribe, format } = await convertToMp3IfNeeded(buffer, filename);
103
+ const text = await aiService.transcribeAudio(audioToTranscribe, `message.${format}`);
104
  return { success: true, text };
105
  } catch (err: any) {
106
  if (err?.name === 'QuotaExceededError') {
apps/api/src/services/ai/ffmpeg.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { writeFile, readFile, unlink } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { randomBytes } from 'crypto';
6
+
7
+ const execAsync = promisify(exec);
8
+
9
+ /**
10
+ * Converts an audio buffer to MP3 using local FFmpeg if the file is OGG/OPUS.
11
+ * This ensures compatibility with OpenAI Whisper, which frequently rejects native WhatsApp OPUS files.
12
+ * Returns the original buffer if ffmpeg fails or isn't installed to attempt best-effort transcription.
13
+ */
14
+ export async function convertToMp3IfNeeded(inputBuffer: Buffer, filename: string): Promise<{ buffer: Buffer; format: string }> {
15
+ // We only strictly convert OGG or OPUS files (which are typical for WhatsApp).
16
+ // If it's already an MP4/AAC or M4A, Whisper usually accepts it fine.
17
+ if (!filename.toLowerCase().endsWith('.ogg') && !filename.toLowerCase().endsWith('.opus') && !filename.toLowerCase().endsWith('.oga')) {
18
+ return { buffer: inputBuffer, format: filename.split('.').pop()! };
19
+ }
20
+
21
+ const tempId = randomBytes(8).toString('hex');
22
+ const inputPath = join('/tmp', `in_${tempId}_${filename}`);
23
+ const outputPath = join('/tmp', `out_${tempId}.mp3`);
24
+
25
+ try {
26
+ console.log(`[FFMPEG] Starting conversion for ${filename}...`);
27
+
28
+ // Write the inbound buffer to a temp file
29
+ await writeFile(inputPath, inputBuffer);
30
+
31
+ // Run FFmpeg to convert it to a standard 128k MP3
32
+ // -y overwrites without prompting
33
+ // -i specifies input file
34
+ // -vn disables video just in case
35
+ // -ar sets audio sample rate
36
+ // -ac sets channels to mono (perfect for voice)
37
+ // -b:a sets audio bitrate to 64k (sufficient for voice, saves bandwidth)
38
+ await execAsync(`ffmpeg -y -i "${inputPath}" -vn -ar 44100 -ac 1 -b:a 64k "${outputPath}"`);
39
+
40
+ // Read the converted file back into a buffer
41
+ const mp3Buffer = await readFile(outputPath);
42
+ console.log(`[FFMPEG] โœ… Successfully converted ${filename} to MP3.`);
43
+
44
+ return { buffer: mp3Buffer, format: 'mp3' };
45
+
46
+ } catch (err: any) {
47
+ console.error(`[FFMPEG] โš ๏ธ Conversion failed for ${filename}. Proceeding with original buffer. Error: ${err.message}`);
48
+ // If FFMPEG isn't installed or fails, we return the original buffer
49
+ return { buffer: inputBuffer, format: filename.split('.').pop()! };
50
+ } finally {
51
+ // Cleanup temp files asynchronously
52
+ unlink(inputPath).catch(() => { });
53
+ unlink(outputPath).catch(() => { });
54
+ }
55
+ }
apps/whatsapp-worker/src/config.ts CHANGED
@@ -2,10 +2,10 @@ import dotenv from 'dotenv';
2
  dotenv.config();
3
 
4
  /**
5
- * Normalizes a base URL by ensuring it starts with https://
6
- * and removing any trailing slashes.
7
  */
8
- export function normalizeBaseUrl(url: string | undefined, keyName: string): string {
9
  if (!url) {
10
  throw new Error(`[CONFIG] Missing environment variable: ${keyName}`);
11
  }
@@ -15,11 +15,12 @@ export function normalizeBaseUrl(url: string | undefined, keyName: string): stri
15
  // Auto-prefix with https:// if it doesn't start with http
16
  if (!normalized.startsWith('http')) {
17
  normalized = `https://${normalized}`;
 
18
  }
19
 
20
- // Warn if it's explicitly http in production (except localhost)
21
- if (normalized.startsWith('http://') && !normalized.includes('localhost')) {
22
- console.warn(`[CONFIG] Warning: ${keyName} is using http:// instead of https:// (${normalized})`);
23
  }
24
 
25
  // Remove trailing slashes
@@ -31,10 +32,10 @@ export function normalizeBaseUrl(url: string | undefined, keyName: string): stri
31
  }
32
 
33
  /**
34
- * Gets the Railway API URL for internal webhook processing and AI endpoints.
35
  */
36
  export function getApiUrl(): string {
37
- return normalizeBaseUrl(process.env.API_URL, 'API_URL');
38
  }
39
 
40
  /**
@@ -56,7 +57,8 @@ export function validateEnvironment() {
56
  console.log('[CONFIG] Validating environment variables...');
57
 
58
  const requiredVars = [
59
- 'API_URL',
 
60
  'ADMIN_API_KEY',
61
  'WHATSAPP_ACCESS_TOKEN',
62
  'WHATSAPP_PHONE_NUMBER_ID',
@@ -84,6 +86,6 @@ export function validateEnvironment() {
84
 
85
  // Validate and print effective API URL
86
  const effectiveApiUrl = getApiUrl();
87
- console.log(`[CONFIG] โœ… API_URL effective: ${effectiveApiUrl}`);
88
  console.log(`[CONFIG] โœ… Environment validation passed.`);
89
  }
 
2
  dotenv.config();
3
 
4
  /**
5
+ * Strictly requires a base URL to start with https://
6
+ * and removes any trailing slashes.
7
  */
8
+ export function requireHttpUrl(url: string | undefined, keyName: string): string {
9
  if (!url) {
10
  throw new Error(`[CONFIG] Missing environment variable: ${keyName}`);
11
  }
 
15
  // Auto-prefix with https:// if it doesn't start with http
16
  if (!normalized.startsWith('http')) {
17
  normalized = `https://${normalized}`;
18
+ console.warn(`[CONFIG] Warning: Auto-prefixed ${keyName} with https://`);
19
  }
20
 
21
+ // Strictly forbid http:// in production
22
+ if (normalized.startsWith('http://') && !normalized.includes('localhost') && !normalized.includes('127.0.0.1')) {
23
+ throw new Error(`[CONFIG] โŒ CRITICAL: ${keyName} MUST use https:// (got ${normalized})`);
24
  }
25
 
26
  // Remove trailing slashes
 
32
  }
33
 
34
  /**
35
+ * Gets the AI API URL for internal webhook processing and endpoints.
36
  */
37
  export function getApiUrl(): string {
38
+ return requireHttpUrl(process.env.AI_API_BASE_URL, 'AI_API_BASE_URL');
39
  }
40
 
41
  /**
 
57
  console.log('[CONFIG] Validating environment variables...');
58
 
59
  const requiredVars = [
60
+ 'AI_API_BASE_URL',
61
+ 'OPENAI_API_KEY',
62
  'ADMIN_API_KEY',
63
  'WHATSAPP_ACCESS_TOKEN',
64
  'WHATSAPP_PHONE_NUMBER_ID',
 
86
 
87
  // Validate and print effective API URL
88
  const effectiveApiUrl = getApiUrl();
89
+ console.log(`[CONFIG] โœ… AI_API_BASE_URL effective: ${effectiveApiUrl}`);
90
  console.log(`[CONFIG] โœ… Environment validation passed.`);
91
  }
apps/whatsapp-worker/src/index.ts CHANGED
@@ -51,12 +51,12 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
51
  if (!user?.phone) return;
52
 
53
  console.log(`[WORKER] Generating feedback for User ${userId}`);
54
- const API_URL = getApiUrl();
55
  const apiKey = getAdminApiKey();
56
 
57
  let feedbackMsg = '';
58
  try {
59
- const feedbackRes = await fetch(`${API_URL}/v1/ai/generate-feedback`, {
60
  method: 'POST',
61
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
62
  body: JSON.stringify({ answers: text, lessonText, exercisePrompt })
@@ -144,9 +144,9 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
144
  if (track.isPremium) {
145
  console.log(`[WORKER] User ${userId} requested Premium Track ${trackId}. Generating Payment Link...`);
146
  try {
147
- const API_URL = getApiUrl();
148
  const apiKey = getAdminApiKey();
149
- const checkoutRes = await fetch(`${API_URL}/v1/payments/checkout`, {
150
  method: 'POST',
151
  headers: {
152
  'Content-Type': 'application/json',
@@ -210,28 +210,32 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
210
  let audioUrl = '';
211
  try {
212
  const { buffer } = await downloadMedia(mediaId, accessToken);
 
213
 
214
- const API_URL = getApiUrl();
215
  const apiKey = getAdminApiKey();
216
 
217
  // Store audio on R2 via the API
218
  try {
219
- const storeRes = await fetch(`${API_URL}/v1/ai/store-audio`, {
220
  method: 'POST',
221
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
222
  body: JSON.stringify({ audioBase64: buffer.toString('base64'), mimeType, phone })
223
  });
224
  if (storeRes.ok) {
225
  const storeData = await storeRes.json() as any;
226
- if (storeData.url) audioUrl = storeData.url;
 
 
 
227
  }
228
  } catch (err) {
229
  console.error('[WORKER] store-audio failed to save inbound audio archive (non-blocking)');
230
  }
231
 
232
  // Transcribe with Whisper via API
233
- console.log(`[WORKER] Calling /v1/ai/transcribe at ${API_URL}`);
234
- const transcribeRes = await fetch(`${API_URL}/v1/ai/transcribe`, {
235
  method: 'POST',
236
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
237
  body: JSON.stringify({ audioBase64: buffer.toString('base64'), filename: `msg.${mimeType.includes('mp4') ? 'mp4' : 'ogg'}` })
@@ -240,14 +244,12 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
240
  if (transcribeRes.ok) {
241
  const data = await transcribeRes.json() as any;
242
  transcribedText = data.text || '';
243
- console.log(`[WORKER] โœ… Transcribed: "${transcribedText.substring(0, 80)}"`);
244
 
245
  // Send an immediate confirmation to the user that the audio was understood
246
  const user = await prisma.user.findFirst({ where: { phone } });
247
  if (user && transcribedText) {
248
- const confirmationText = user.language === 'WOLOF'
249
- ? `โœ… Dรฉgg naa la. Lii laa ci jร pp:\n_"${transcribedText}"_`
250
- : `โœ… J'ai bien reรงu. Voici ce que j'ai compris :\n_"${transcribedText}"_`;
251
  await sendTextMessage(phone, confirmationText);
252
  }
253
  } else if (transcribeRes.status === 429) {
@@ -269,7 +271,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
269
 
270
  // Process the transcribed text as a normal incoming message via API
271
  if (transcribedText) {
272
- await fetch(`${API_URL}/v1/internal/handle-message`, {
273
  method: 'POST',
274
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
275
  body: JSON.stringify({ phone, text: transcribedText, audioUrl })
@@ -316,28 +318,28 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
316
  // Update userContext to explicitly reference the user's sector/activity
317
  const userContext = `User ${userId} completed the Business Pitch track. Their business activity/sector is: ${user?.activity || 'Unknown'}. They want to build a business in this sector using the concepts learned in the track.`;
318
 
319
- const API_URL = getApiUrl();
320
  const apiKey = getAdminApiKey();
321
  const authHeaders = {
322
  'Content-Type': 'application/json',
323
  'Authorization': `Bearer ${apiKey}`
324
  };
325
 
326
- // Trigger OnePager (PDF)
327
- const pdfRes = await fetch(`${API_URL}/v1/ai/onepager`, {
328
  method: 'POST',
329
  headers: authHeaders,
330
  body: JSON.stringify({ userContext })
331
  });
332
- const pdfData = await pdfRes.json() as any;
333
 
334
- // Trigger Pitch Deck (PPTX)
335
- const pptxRes = await fetch(`${API_URL}/v1/ai/deck`, {
336
  method: 'POST',
337
  headers: authHeaders,
338
  body: JSON.stringify({ userContext })
339
  });
340
- const pptxData = await pptxRes.json() as any;
341
 
342
  console.log(`[AI DOCS READY] ๐Ÿ“„ PDF: ${pdfData.url}`);
343
  console.log(`[AI DOCS READY] ๐Ÿ“Š PPTX: ${pptxData.url}`);
 
51
  if (!user?.phone) return;
52
 
53
  console.log(`[WORKER] Generating feedback for User ${userId}`);
54
+ const AI_API_BASE_URL = getApiUrl();
55
  const apiKey = getAdminApiKey();
56
 
57
  let feedbackMsg = '';
58
  try {
59
+ const feedbackRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/generate-feedback`, {
60
  method: 'POST',
61
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
62
  body: JSON.stringify({ answers: text, lessonText, exercisePrompt })
 
144
  if (track.isPremium) {
145
  console.log(`[WORKER] User ${userId} requested Premium Track ${trackId}. Generating Payment Link...`);
146
  try {
147
+ const AI_API_BASE_URL = getApiUrl();
148
  const apiKey = getAdminApiKey();
149
+ const checkoutRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/payments/checkout`, {
150
  method: 'POST',
151
  headers: {
152
  'Content-Type': 'application/json',
 
210
  let audioUrl = '';
211
  try {
212
  const { buffer } = await downloadMedia(mediaId, accessToken);
213
+ console.log(`[MEDIA] downloaded file size=${buffer.length} contentType=${mimeType}`);
214
 
215
+ const AI_API_BASE_URL = getApiUrl();
216
  const apiKey = getAdminApiKey();
217
 
218
  // Store audio on R2 via the API
219
  try {
220
+ const storeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/store-audio`, {
221
  method: 'POST',
222
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
223
  body: JSON.stringify({ audioBase64: buffer.toString('base64'), mimeType, phone })
224
  });
225
  if (storeRes.ok) {
226
  const storeData = await storeRes.json() as any;
227
+ if (storeData.url) {
228
+ audioUrl = storeData.url;
229
+ console.log(`[R2] uploaded url=${audioUrl}`);
230
+ }
231
  }
232
  } catch (err) {
233
  console.error('[WORKER] store-audio failed to save inbound audio archive (non-blocking)');
234
  }
235
 
236
  // Transcribe with Whisper via API
237
+ console.log(`[STT] transcribe start calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`);
238
+ const transcribeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`, {
239
  method: 'POST',
240
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
241
  body: JSON.stringify({ audioBase64: buffer.toString('base64'), filename: `msg.${mimeType.includes('mp4') ? 'mp4' : 'ogg'}` })
 
244
  if (transcribeRes.ok) {
245
  const data = await transcribeRes.json() as any;
246
  transcribedText = data.text || '';
247
+ console.log(`[STT] transcribe result="${transcribedText.substring(0, 80)}"`);
248
 
249
  // Send an immediate confirmation to the user that the audio was understood
250
  const user = await prisma.user.findFirst({ where: { phone } });
251
  if (user && transcribedText) {
252
+ const confirmationText = `J'ai compris :\n"${transcribedText}"`;
 
 
253
  await sendTextMessage(phone, confirmationText);
254
  }
255
  } else if (transcribeRes.status === 429) {
 
271
 
272
  // Process the transcribed text as a normal incoming message via API
273
  if (transcribedText) {
274
+ await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/internal/handle-message`, {
275
  method: 'POST',
276
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
277
  body: JSON.stringify({ phone, text: transcribedText, audioUrl })
 
318
  // Update userContext to explicitly reference the user's sector/activity
319
  const userContext = `User ${userId} completed the Business Pitch track. Their business activity/sector is: ${user?.activity || 'Unknown'}. They want to build a business in this sector using the concepts learned in the track.`;
320
 
321
+ const AI_API_BASE_URL = getApiUrl();
322
  const apiKey = getAdminApiKey();
323
  const authHeaders = {
324
  'Content-Type': 'application/json',
325
  'Authorization': `Bearer ${apiKey}`
326
  };
327
 
328
+ console.log(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager...`);
329
+ const opRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager`, {
330
  method: 'POST',
331
  headers: authHeaders,
332
  body: JSON.stringify({ userContext })
333
  });
334
+ const pdfData = await opRes.json() as any;
335
 
336
+ console.log(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck...`);
337
+ const deckRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck`, {
338
  method: 'POST',
339
  headers: authHeaders,
340
  body: JSON.stringify({ userContext })
341
  });
342
+ const pptxData = await deckRes.json() as any;
343
 
344
  console.log(`[AI DOCS READY] ๐Ÿ“„ PDF: ${pdfData.url}`);
345
  console.log(`[AI DOCS READY] ๐Ÿ“Š PPTX: ${pptxData.url}`);
apps/whatsapp-worker/src/pedagogy.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { PrismaClient } from '@prisma/client';
2
  import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage, sendInteractiveListMessage } from './whatsapp-cloud';
3
- import { getApiUrl, getAdminApiKey } from './config';
4
 
5
  const prisma = new PrismaClient();
6
 
@@ -28,9 +28,9 @@ export async function sendLessonDay(userId: string, trackId: string, dayNumber:
28
  if (user.activity && lessonText) {
29
  try {
30
  console.log(`[PEDAGOGY] Personalizing lesson for User ${userId}'s activity: ${user.activity}`);
31
- const API_URL = getApiUrl();
32
  const apiKey = getAdminApiKey();
33
- const personalizeRes = await fetch(`${API_URL}/v1/ai/personalize-lesson`, {
34
  method: 'POST',
35
  headers: {
36
  'Content-Type': 'application/json',
@@ -56,9 +56,9 @@ export async function sendLessonDay(userId: string, trackId: string, dayNumber:
56
  if (!finalAudioUrl && lessonText) {
57
  try {
58
  console.log(`[PEDAGOGY] Generating TTS Audio for User ${userId}...`);
59
- const API_URL = getApiUrl();
60
  const apiKey = getAdminApiKey();
61
- const ttsRes = await fetch(`${API_URL}/v1/ai/tts`, {
62
  method: 'POST',
63
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
64
  body: JSON.stringify({ text: lessonText })
@@ -75,8 +75,10 @@ export async function sendLessonDay(userId: string, trackId: string, dayNumber:
75
 
76
  if (finalAudioUrl) {
77
  try {
 
78
  await sendAudioMessage(user.phone, finalAudioUrl);
79
- console.log(`[TTS] Audio generated: ${finalAudioUrl}`);
 
80
  // Send the text as a separate short message
81
  if (lessonText) {
82
  await sendTextMessage(user.phone, lessonText);
 
1
  import { PrismaClient } from '@prisma/client';
2
  import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage, sendInteractiveListMessage } from './whatsapp-cloud';
3
+ import { requireHttpUrl, getAdminApiKey } from './config';
4
 
5
  const prisma = new PrismaClient();
6
 
 
28
  if (user.activity && lessonText) {
29
  try {
30
  console.log(`[PEDAGOGY] Personalizing lesson for User ${userId}'s activity: ${user.activity}`);
31
+ const AI_API_BASE_URL = requireHttpUrl(process.env.AI_API_BASE_URL, 'AI_API_BASE_URL');
32
  const apiKey = getAdminApiKey();
33
+ const personalizeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/personalize-lesson`, {
34
  method: 'POST',
35
  headers: {
36
  'Content-Type': 'application/json',
 
56
  if (!finalAudioUrl && lessonText) {
57
  try {
58
  console.log(`[PEDAGOGY] Generating TTS Audio for User ${userId}...`);
59
+ const AI_API_BASE_URL = requireHttpUrl(process.env.AI_API_BASE_URL, 'AI_API_BASE_URL');
60
  const apiKey = getAdminApiKey();
61
+ const ttsRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/tts`, {
62
  method: 'POST',
63
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
64
  body: JSON.stringify({ text: lessonText })
 
75
 
76
  if (finalAudioUrl) {
77
  try {
78
+ console.log(`[TTS] audioUrl=${finalAudioUrl} sending to WhatsApp...`);
79
  await sendAudioMessage(user.phone, finalAudioUrl);
80
+ console.log(`[WhatsApp] โœ… Audio message sent to ${user.phone}`);
81
+
82
  // Send the text as a separate short message
83
  if (lessonText) {
84
  await sendTextMessage(user.phone, lessonText);
nixpacks.toml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ providers = ["node"]
2
+
3
+ [phases.setup]
4
+ nixPkgs = ["...", "ffmpeg"]