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 }) } }) |