Skip to content

Commit c63abea

Browse files
Ffmpeg processing offload (#720)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent b72fabe commit c63abea

44 files changed

Lines changed: 16020 additions & 74 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,36 @@ R2_SECRET_ACCESS_KEY=MOCKR2SECRETACCESSKEY
147147
# Mocked: yes (MSW intercepts the S3-compatible API) when MOCKS=true and the
148148
# access key id opts in (starts with `MOCK`).
149149
CALL_KENT_R2_BUCKET=MOCK_CALL_KENT_R2_BUCKET
150+
# Feature: /calls/admin (offloaded FFmpeg audio generation from the app)
151+
# Required (Fly app env):
152+
# - Cloudflare Queue identifier for `kcd-call-kent-audio`.
153+
# - Get it from Cloudflare dashboard -> Queues -> queue details.
154+
CALL_KENT_AUDIO_CF_QUEUE_ID=replace-with-cloudflare-queue-id
155+
# Optional (Fly app env; defaults to this value)
156+
CALL_KENT_AUDIO_CF_API_BASE_URL=https://api.cloudflare.com/client/v4
157+
# Required (Fly app env):
158+
# - HMAC secret used to verify worker/container callback signatures.
159+
# - Must exactly match Cloudflare Worker's CALL_KENT_AUDIO_CALLBACK_SECRET.
160+
# - Generate: openssl rand -hex 32
161+
# - Set directly in Fly secrets as CALL_KENT_AUDIO_PROCESSOR_CALLBACK_SECRET.
162+
CALL_KENT_AUDIO_PROCESSOR_CALLBACK_SECRET=replace-with-openssl-random-hex
163+
164+
# Feature: call-kent-audio-worker runtime (Cloudflare Worker vars; not read by app)
165+
# Set these in call-kent-audio-worker/wrangler.jsonc (vars) or Cloudflare Worker
166+
# settings. Keep secrets out of plain-text vars.
167+
# - URL for the FFmpeg container service endpoint.
168+
CALL_KENT_AUDIO_CONTAINER_URL=https://replace.example.com
169+
# - Shared bearer token between worker and container.
170+
# - Generate: openssl rand -hex 32
171+
# - Store as a Cloudflare Worker secret named CALL_KENT_AUDIO_CONTAINER_TOKEN.
172+
CALL_KENT_AUDIO_CONTAINER_TOKEN=replace-with-openssl-random-hex
173+
# - Public callback endpoint on the app.
174+
CALL_KENT_AUDIO_CALLBACK_URL=https://kentcdodds.com/resources/calls/episode-audio-callback
175+
# - Shared callback HMAC secret (same value as app's
176+
# CALL_KENT_AUDIO_PROCESSOR_CALLBACK_SECRET).
177+
# - Generate: openssl rand -hex 32
178+
# - Store as a Cloudflare Worker secret named CALL_KENT_AUDIO_CALLBACK_SECRET.
179+
CALL_KENT_AUDIO_CALLBACK_SECRET=replace-with-openssl-random-hex
150180
# Ignore list object key (defaults to manifests/ignore-list.json)
151181
SEMANTIC_SEARCH_IGNORE_LIST_KEY=manifests/ignore-list.json
152182

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: ☁️ Deploy Call Kent Audio Worker
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- call-kent-audio-worker/**
9+
- .github/workflows/deploy-call-kent-audio-worker.yml
10+
workflow_dispatch:
11+
12+
concurrency:
13+
group: deploy-call-kent-audio-worker-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
deploy:
18+
name: 🚀 Deploy Call Kent Audio Worker
19+
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
22+
steps:
23+
- name: ⬇️ Checkout repo
24+
uses: actions/checkout@v6
25+
26+
- name: ⎔ Setup node
27+
uses: actions/setup-node@v6
28+
with:
29+
node-version: 24
30+
cache: npm
31+
cache-dependency-path: call-kent-audio-worker/package-lock.json
32+
33+
- name: 📥 Install audio worker deps
34+
run: npm ci
35+
working-directory: call-kent-audio-worker
36+
37+
- name: 🔬 Lint audio worker
38+
run: npm run lint
39+
working-directory: call-kent-audio-worker
40+
41+
- name: ʦ Typecheck audio worker
42+
run: npx tsc --noEmit
43+
working-directory: call-kent-audio-worker
44+
45+
- name: ☁️ Deploy via Wrangler
46+
uses: cloudflare/wrangler-action@v3
47+
with:
48+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
49+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
50+
workingDirectory: call-kent-audio-worker
51+
command: deploy
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect, test, vi } from 'vitest'
2+
3+
vi.mock('#app/utils/call-kent-audio-processor-callback.server.ts', () => ({
4+
handleCallKentAudioProcessorEvent: vi.fn(),
5+
parseCallKentAudioProcessorEvent: vi.fn(),
6+
verifyCallKentAudioProcessorCallbackSignature: vi.fn(),
7+
}))
8+
9+
import {
10+
handleCallKentAudioProcessorEvent,
11+
parseCallKentAudioProcessorEvent,
12+
verifyCallKentAudioProcessorCallbackSignature,
13+
} from '#app/utils/call-kent-audio-processor-callback.server.ts'
14+
import { action } from '../episode-audio-callback.ts'
15+
16+
test('episode-audio-callback rejects unsigned cloudflare callback', async () => {
17+
vi.clearAllMocks()
18+
process.env.CALL_KENT_AUDIO_PROCESSOR_CALLBACK_SECRET = 'callback-secret'
19+
const request = new Request(
20+
'http://localhost/resources/calls/episode-audio-callback',
21+
{
22+
method: 'POST',
23+
headers: { 'Content-Type': 'application/json' },
24+
body: JSON.stringify({
25+
type: 'audio_generation_started',
26+
draftId: 'draft-1',
27+
}),
28+
},
29+
)
30+
const response = await action({ request })
31+
expect(response.status).toBe(401)
32+
expect(verifyCallKentAudioProcessorCallbackSignature).not.toHaveBeenCalled()
33+
})
34+
35+
test('episode-audio-callback validates, parses, and handles callback payload', async () => {
36+
vi.clearAllMocks()
37+
process.env.CALL_KENT_AUDIO_PROCESSOR_CALLBACK_SECRET = 'callback-secret'
38+
vi.mocked(verifyCallKentAudioProcessorCallbackSignature).mockReturnValue(true)
39+
vi.mocked(parseCallKentAudioProcessorEvent).mockReturnValue({
40+
type: 'audio_generation_started',
41+
draftId: 'draft-1',
42+
})
43+
const request = new Request(
44+
'http://localhost/resources/calls/episode-audio-callback',
45+
{
46+
method: 'POST',
47+
headers: {
48+
'Content-Type': 'application/json',
49+
'x-call-kent-audio-timestamp': '1710000000',
50+
'x-call-kent-audio-signature': 'abcd',
51+
},
52+
body: JSON.stringify({
53+
type: 'audio_generation_started',
54+
draftId: 'draft-1',
55+
}),
56+
},
57+
)
58+
const response = await action({ request })
59+
expect(response.status).toBe(200)
60+
expect(verifyCallKentAudioProcessorCallbackSignature).toHaveBeenCalled()
61+
expect(parseCallKentAudioProcessorEvent).toHaveBeenCalled()
62+
expect(handleCallKentAudioProcessorEvent).toHaveBeenCalledWith({
63+
type: 'audio_generation_started',
64+
draftId: 'draft-1',
65+
})
66+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
handleCallKentAudioProcessorEvent,
3+
parseCallKentAudioProcessorEvent,
4+
verifyCallKentAudioProcessorCallbackSignature,
5+
} from '#app/utils/call-kent-audio-processor-callback.server.ts'
6+
7+
const callbackTimestampHeader = 'x-call-kent-audio-timestamp'
8+
const callbackSignatureHeader = 'x-call-kent-audio-signature'
9+
10+
export async function action({ request }: { request: Request }) {
11+
if (request.method !== 'POST') {
12+
return new Response('Method not allowed', { status: 405 })
13+
}
14+
const rawBody = await request.text()
15+
const timestamp = request.headers.get(callbackTimestampHeader)
16+
const signature = request.headers.get(callbackSignatureHeader)
17+
if (!timestamp || !signature) {
18+
return new Response('Missing callback signature', { status: 401 })
19+
}
20+
const isValid = verifyCallKentAudioProcessorCallbackSignature({
21+
timestamp,
22+
signature,
23+
rawBody,
24+
})
25+
if (!isValid) {
26+
return new Response('Invalid callback signature', { status: 401 })
27+
}
28+
29+
let payload: unknown
30+
try {
31+
payload = rawBody ? JSON.parse(rawBody) : null
32+
} catch {
33+
return new Response('Invalid JSON body', { status: 400 })
34+
}
35+
36+
let event
37+
try {
38+
event = parseCallKentAudioProcessorEvent(payload)
39+
} catch {
40+
return new Response('Invalid callback payload', { status: 400 })
41+
}
42+
43+
await handleCallKentAudioProcessorEvent(event)
44+
return Response.json({ ok: true })
45+
}

app/routes/resources/calls/save.tsx

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { type RecordingFormData } from '#app/components/calls/recording-form.tsx
55
import {
66
deleteAudioObject,
77
getAudioBuffer,
8+
parseBase64DataUrl,
89
putCallAudioFromDataUrl,
10+
putEpisodeDraftResponseAudioFromBuffer,
911
} from '#app/utils/call-kent-audio-storage.server.ts'
1012
import { startCallKentCallerTranscriptProcessing } from '#app/utils/call-kent-caller-transcript.server.ts'
11-
import { startCallKentEpisodeDraftProcessing } from '#app/utils/call-kent-episode-draft.server.ts'
13+
import { requestCallKentEpisodeAudioGeneration } from '#app/utils/call-kent-audio-processor.server.ts'
1214
import { getPublishedCallKentEpisodeEmail } from '#app/utils/call-kent-published-email.ts'
1315
import {
1416
getErrorForAudio,
@@ -355,9 +357,13 @@ async function publishCall({
355357
}
356358

357359
// Best-effort cleanup of stored audio blobs after publish.
358-
const keysToDelete = [call.audioKey, draft.episodeAudioKey].filter(
359-
(k): k is string => typeof k === 'string' && k.length > 0,
360-
)
360+
const keysToDelete = [
361+
call.audioKey,
362+
draft.episodeAudioKey,
363+
draft.responseAudioKey,
364+
draft.callerSegmentAudioKey,
365+
draft.responseSegmentAudioKey,
366+
].filter((k): k is string => typeof k === 'string' && k.length > 0)
361367
await Promise.all(
362368
keysToDelete.map(async (key) =>
363369
deleteAudioObject({ key }).catch(() => {}),
@@ -434,11 +440,24 @@ async function createEpisodeDraft({
434440
// If we're replacing a draft, clean up the old stored audio blob.
435441
const existingDraft = await prisma.callKentEpisodeDraft.findFirst({
436442
where: { callId },
437-
select: { episodeAudioKey: true },
443+
select: {
444+
episodeAudioKey: true,
445+
responseAudioKey: true,
446+
callerSegmentAudioKey: true,
447+
responseSegmentAudioKey: true,
448+
},
438449
})
439-
if (existingDraft?.episodeAudioKey) {
440-
await deleteAudioObject({ key: existingDraft.episodeAudioKey }).catch(
441-
() => {},
450+
const existingKeysToDelete = [
451+
existingDraft?.episodeAudioKey,
452+
existingDraft?.responseAudioKey,
453+
existingDraft?.callerSegmentAudioKey,
454+
existingDraft?.responseSegmentAudioKey,
455+
].filter((key): key is string => typeof key === 'string' && key.length > 0)
456+
if (existingKeysToDelete.length) {
457+
await Promise.all(
458+
existingKeysToDelete.map(async (key) =>
459+
deleteAudioObject({ key }).catch(() => {}),
460+
),
442461
)
443462
}
444463

@@ -452,9 +471,47 @@ async function createEpisodeDraft({
452471
}),
453472
])
454473

455-
void startCallKentEpisodeDraftProcessing(draft.id, {
456-
responseBase64: responseAudio!,
457-
})
474+
try {
475+
if (!call.audioKey) {
476+
throw new Error('Call audio is missing (audioKey is null).')
477+
}
478+
const parsedResponseAudio = parseBase64DataUrl(responseAudio!)
479+
const storedResponseAudio = await putEpisodeDraftResponseAudioFromBuffer({
480+
draftId: draft.id,
481+
audio: parsedResponseAudio.buffer,
482+
contentType: parsedResponseAudio.contentType,
483+
})
484+
await prisma.callKentEpisodeDraft.updateMany({
485+
where: { id: draft.id, status: 'PROCESSING' },
486+
data: {
487+
responseAudioKey: storedResponseAudio.key,
488+
responseAudioContentType: storedResponseAudio.contentType,
489+
responseAudioSize: storedResponseAudio.size,
490+
step: 'GENERATING_AUDIO',
491+
errorMessage: null,
492+
},
493+
})
494+
await requestCallKentEpisodeAudioGeneration({
495+
draftId: draft.id,
496+
callAudioKey: call.audioKey,
497+
responseAudioKey: storedResponseAudio.key,
498+
})
499+
} catch (error: unknown) {
500+
await prisma.callKentEpisodeDraft.updateMany({
501+
where: { id: draft.id, status: 'PROCESSING' },
502+
data: {
503+
status: 'ERROR',
504+
errorMessage: getErrorMessage(error),
505+
step: 'DONE',
506+
},
507+
})
508+
const searchParams = new URLSearchParams()
509+
searchParams.set(
510+
'error',
511+
`Unable to start draft audio generation: ${getErrorMessage(error)}`,
512+
)
513+
return redirect(`/calls/admin/${callId}?${searchParams.toString()}`)
514+
}
458515

459516
return redirect(`/calls/admin/${callId}`)
460517
}
@@ -547,14 +604,26 @@ async function undoEpisodeDraft({
547604

548605
const drafts = await prisma.callKentEpisodeDraft.findMany({
549606
where: { callId },
550-
select: { episodeAudioKey: true },
607+
select: {
608+
episodeAudioKey: true,
609+
responseAudioKey: true,
610+
callerSegmentAudioKey: true,
611+
responseSegmentAudioKey: true,
612+
},
551613
})
552-
if (drafts.some((d) => d.episodeAudioKey)) {
614+
const keysToDelete = drafts
615+
.flatMap((draft) => [
616+
draft.episodeAudioKey,
617+
draft.responseAudioKey,
618+
draft.callerSegmentAudioKey,
619+
draft.responseSegmentAudioKey,
620+
])
621+
.filter((k): k is string => typeof k === 'string' && k.length > 0)
622+
if (keysToDelete.length) {
553623
await Promise.all(
554-
drafts
555-
.map((d) => d.episodeAudioKey)
556-
.filter((k): k is string => typeof k === 'string' && k.length > 0)
557-
.map(async (key) => deleteAudioObject({ key }).catch(() => {})),
624+
keysToDelete.map(async (key) =>
625+
deleteAudioObject({ key }).catch(() => {}),
626+
),
558627
)
559628
}
560629
await prisma.callKentEpisodeDraft.deleteMany({ where: { callId } })
@@ -652,7 +721,14 @@ async function deleteCall({
652721
select: {
653722
id: true,
654723
audioKey: true,
655-
episodeDraft: { select: { episodeAudioKey: true } },
724+
episodeDraft: {
725+
select: {
726+
episodeAudioKey: true,
727+
responseAudioKey: true,
728+
callerSegmentAudioKey: true,
729+
responseSegmentAudioKey: true,
730+
},
731+
},
656732
},
657733
})
658734
if (!call) {
@@ -663,6 +739,9 @@ async function deleteCall({
663739
const keysToDelete = [
664740
call.audioKey,
665741
call.episodeDraft?.episodeAudioKey,
742+
call.episodeDraft?.responseAudioKey,
743+
call.episodeDraft?.callerSegmentAudioKey,
744+
call.episodeDraft?.responseSegmentAudioKey,
666745
].filter((k): k is string => typeof k === 'string' && k.length > 0)
667746
if (keysToDelete.length) {
668747
await Promise.all(

0 commit comments

Comments
 (0)