All files / web/src/app/api/audio/generate-clip route.ts

0% Statements 0/117
0% Branches 0/1
0% Functions 0/1
0% Lines 0/117

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118                                                                                                                                                                                                                                           
/**
 * API route for on-demand TTS clip generation.
 *
 * POST /api/audio/generate-clip
 * Body: { voice: string, clipId: string, text: string, tone: string }
 * Returns: audio/mpeg bytes (or JSON error)
 *
 * Generates a clip via OpenAI TTS, caches it to disk, and returns the audio.
 * If the clip already exists on disk, returns the cached version.
 */

import { createHash } from 'crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { NextResponse } from 'next/server'
import { join } from 'path'
import { recordTtsUsage } from '@/lib/ai-usage/helpers'
import { AiFeature } from '@/lib/ai-usage/features'
import { withAuth } from '@/lib/auth/withAuth'

const AUDIO_DIR = join(process.cwd(), 'data', 'audio')

/** Max filename length (bytes) — conservative limit for all filesystems */
const MAX_FILENAME_LEN = 200

/** If a clipId is too long for a filename, hash it to a safe short form. */
function safeClipId(clipId: string): string {
  // +4 for ".mp3" extension
  if (Buffer.byteLength(clipId, 'utf8') + 4 <= MAX_FILENAME_LEN) return clipId
  const hash = createHash('sha256').update(clipId).digest('hex').slice(0, 16)
  return `hc-${hash}`
}

export const POST = withAuth(async (request, { userId }) => {
  try {
    const body = await request.json()
    const { voice, text, tone } = body
    const clipId = safeClipId(body.clipId ?? '')

    // Validate required fields
    if (!voice || !clipId || !text) {
      return NextResponse.json({ error: 'voice, clipId, and text are required' }, { status: 400 })
    }

    // Prevent directory traversal
    if (
      voice.includes('/') ||
      voice.includes('..') ||
      clipId.includes('/') ||
      clipId.includes('..')
    ) {
      return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 })
    }

    // Check API key
    const apiKey = process.env.LLM_OPENAI_API_KEY || process.env.OPENAI_API_KEY
    if (!apiKey) {
      return NextResponse.json({ error: 'OpenAI API key not configured' }, { status: 503 })
    }

    // Disk cache check — return existing file if present
    const voiceDir = join(AUDIO_DIR, voice)
    const candidates = [join(voiceDir, `${clipId}.mp3`), join(voiceDir, `cc-${clipId}.mp3`)]
    for (const path of candidates) {
      if (existsSync(path)) {
        const fileBuffer = readFileSync(path)
        return new NextResponse(new Uint8Array(fileBuffer), {
          headers: {
            'Content-Type': 'audio/mpeg',
            'Content-Length': fileBuffer.byteLength.toString(),
            'Cache-Control': 'public, max-age=31536000, immutable',
          },
        })
      }
    }

    // Call OpenAI TTS
    const response = await fetch('https://api.openai.com/v1/audio/speech', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini-tts',
        voice,
        input: text,
        instructions: tone || undefined,
        response_format: 'mp3',
      }),
    })

    if (!response.ok) {
      const errText = await response.text()
      console.error('[generate-clip] OpenAI error:', response.status, errText)
      return NextResponse.json({ error: `OpenAI error: ${response.status}` }, { status: 502 })
    }

    const arrayBuffer = await response.arrayBuffer()
    const buffer = Buffer.from(arrayBuffer)
    recordTtsUsage(text, 'gpt-4o-mini-tts', { userId, feature: AiFeature.TTS_CLIP })

    // Save to disk cache
    mkdirSync(voiceDir, { recursive: true })
    writeFileSync(join(voiceDir, `${clipId}.mp3`), buffer)

    return new NextResponse(new Uint8Array(buffer), {
      headers: {
        'Content-Type': 'audio/mpeg',
        'Content-Length': buffer.byteLength.toString(),
        'Cache-Control': 'public, max-age=31536000, immutable',
      },
    })
  } catch (error) {
    console.error('[generate-clip] Error:', error)
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
})