diff --git a/public/app.js b/public/app.js index 4c0093d..74092d6 100644 --- a/public/app.js +++ b/public/app.js @@ -5631,7 +5631,7 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` }); } - // Compilar Vídeo de Apresentação (FFmpeg) + // Compilar Vídeo de Apresentação (FFmpeg com Narração IA) if (btnCompileComicsVideo) { btnCompileComicsVideo.addEventListener('click', async () => { if (generatedPanelsData.length === 0) return; @@ -5640,6 +5640,8 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` comicsVideoLoader.style.display = 'flex'; comicsVideoResult.style.display = 'none'; + const voice = document.getElementById('comicsVideoVoice')?.value || 'mulher'; + try { const response = await fetch('/api/comics/generate-video', { method: 'POST', @@ -5647,7 +5649,9 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` body: JSON.stringify({ frames: generatedPanelsData, musicSelection: comicsVideoMusic.value, - frameDuration: comicsVideoDuration.value + frameDuration: comicsVideoDuration.value, + narrationVoice: voice, + titulo: comicsResultTitle?.textContent || 'História em Quadrinhos' }) }); diff --git a/public/index.html b/public/index.html index 735c9c5..17e2fb7 100644 --- a/public/index.html +++ b/public/index.html @@ -2063,21 +2063,30 @@
Criar Apresentação de Vídeo Animada com Música: -
+
- + + +
+
+
- +
diff --git a/server.js b/server.js index e9913d4..6ad1d1c 100644 --- a/server.js +++ b/server.js @@ -728,73 +728,188 @@ app.post('/api/comics/generate-frame', requireAuth, async (req, res) => { } }); -// 3. Compilar a apresentação de vídeo usando FFmpeg a partir dos painéis de quadrinhos +// 3. Compilar a apresentação de vídeo usando FFmpeg a partir dos painéis de quadrinhos (Com Narração IA) app.post('/api/comics/generate-video', requireAuth, async (req, res) => { - const { frames, musicSelection, frameDuration } = req.body; + const { frames, musicSelection, frameDuration, narrationVoice, titulo } = req.body; if (!frames || !Array.isArray(frames) || frames.length === 0) { return res.status(400).json({ error: 'Nenhum painel enviado para geração de vídeo.' }); } - const duration = frameDuration ? parseInt(frameDuration) : 5; + const baseDuration = frameDuration ? parseInt(frameDuration) : 5; const musicFile = musicSelection || 'alegre'; + const voice = narrationVoice || 'none'; // 'none', 'mulher', 'homem' try { const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); + const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } - // Mapear trilha musical - let bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3'); - if (musicFile === 'calma') { - bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3'); - } else if (musicFile === 'aventura') { - bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'aventura.mp3'); + const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`; + + // 1. Mapear trilha musical + let bgMusicPath = null; + if (musicFile !== 'sem_musica') { + bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3'); + if (musicFile === 'calma') { + bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3'); + } else if (musicFile === 'aventura') { + bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'aventura.mp3'); + } + if (!fs.existsSync(bgMusicPath)) bgMusicPath = null; } - // Criar arquivo de input para o FFmpeg Concat - const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`; + // Estrutura para armazenar tempos e caminhos dos áudios de narração + const panelDurations = []; + const narrationAudioFiles = []; + + // 2. Se a narração estiver ativada (mulher ou homem) + if (voice === 'mulher' || voice === 'homem') { + const voiceName = voice === 'homem' ? 'pt-BR-AntonioNeural' : 'pt-BR-FranciscaNeural'; + console.log(`[Video Comics] Gerando narração por IA (${voiceName})...`); + + // Gerar textos narrativos adaptados por IA para acompanhar a duração e fechar no final + let narrations = []; + try { + const narrPrompt = `Você é um narrador especialista em audiolivros infantis e histórias em quadrinhos pedagógicas. +Abaixo estão as cenas e falas dos quadros de uma História em Quadrinhos: +Título: "${titulo || 'História em Quadrinhos'}" +${frames.map((f, idx) => `Quadro ${idx + 1}: Fala/Diálogo: "${f.dialogue || ''}" | Descrição visual: "${f.image_prompt || ''}"`).join('\n')} + +Por favor, crie um texto de narração falado, acolhedor e envolvente em português do Brasil (pt-BR) para CADA QUADRO. +- O texto de cada quadro deve ser fluido e natural para ser lido em voz alta. +- Expanda as falas para contar a história com ritmo infantil acolhedor. +- O ÚLTIMO QUADRO deve conter uma frase final marcante e carinhosa de conclusão da história. +- Retorne estritamente um JSON no seguinte formato: +{ + "narrations": [ + { "panel_index": 0, "text": "Texto da narração do quadro 1..." } + ] +}`; + + const aiResp = await callMinimax({ + messages: [{ role: 'user', content: narrPrompt }], + temperature: 0.7, + max_tokens: 1500 + }); + + const jsonMatch = aiResp.text.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + narrations = parsed.narrations || []; + } + } catch (aiErr) { + console.warn('[Video Comics] Erro ao gerar roteiro por IA, usando diálogos originais:', aiErr.message); + } + + // Sintetizar o áudio TTS de cada quadro + const tts = new MsEdgeTTS(); + await tts.setMetadata(voiceName, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); + + for (let i = 0; i < frames.length; i++) { + const item = narrations.find(n => n.panel_index === i); + let narrText = item?.text || frames[i].dialogue || frames[i].image_prompt || `Quadro ${i + 1}`; + // Limpar prefixos de falas + narrText = narrText.replace(/^(Maria|João|Lucas|Pedro|Ana|Professora):\s*/i, '').trim(); + + const audioPath = path.join(mediaDir, `narr_${tempFileId}_p${i}.mp3`); + try { + await tts.toFile(audioPath, narrText); + narrationAudioFiles.push(audioPath); + + // Calcular duração estimada do MP3 (~6000 bytes por segundo a 48kbps mono MP3) + const stats = fs.statSync(audioPath); + const audioSecs = Math.max(3, Math.ceil(stats.size / 6000)); + // O tempo do quadro é o máximo entre a duração base e o tempo da narração + 1s de respiro + const frameTime = Math.max(baseDuration, audioSecs + 1); + panelDurations.push(frameTime); + } catch (ttsErr) { + console.warn(`[Video Comics] Falha TTS quadro ${i}:`, ttsErr.message); + panelDurations.push(baseDuration); + } + } + } else { + // Sem narração de voz + frames.forEach(() => panelDurations.push(baseDuration)); + } + + // 3. Montar o arquivo concat de imagens do FFmpeg const concatFilePath = path.join(__dirname, `concat_${tempFileId}.txt`); - let concatContent = ''; - frames.forEach(frame => { + frames.forEach((frame, idx) => { const localFilePath = path.join(__dirname, 'public', frame.imageUrl); - concatContent += `file '${localFilePath}'\nduration ${duration}\n`; + concatContent += `file '${localFilePath}'\nduration ${panelDurations[idx]}\n`; }); - // O concat do FFmpeg requer repetir o último arquivo no final const lastLocalFilePath = path.join(__dirname, 'public', frames[frames.length - 1].imageUrl); concatContent += `file '${lastLocalFilePath}'\n`; - fs.writeFileSync(concatFilePath, concatContent); + // 4. Se houver narração de voz, concatenar os áudios individuais da narração + let concatAudioPath = null; + if (narrationAudioFiles.length > 0) { + const concatAudioTxtPath = path.join(__dirname, `concat_audio_${tempFileId}.txt`); + let audioConcatStr = ''; + narrationAudioFiles.forEach(aPath => { + audioConcatStr += `file '${aPath}'\n`; + }); + fs.writeFileSync(concatAudioTxtPath, audioConcatStr); + + concatAudioPath = path.join(mediaDir, `narr_full_${tempFileId}.mp3`); + await new Promise((resolve) => { + exec(`ffmpeg -y -f concat -safe 0 -i "${concatAudioTxtPath}" -c copy "${concatAudioPath}"`, () => { + try { fs.unlinkSync(concatAudioTxtPath); } catch (e) {} + resolve(); + }); + }); + } + + // 5. Construir o comando do FFmpeg final const outputFileName = `video_quadrinhos_${tempFileId}.mp4`; const outputFilePath = path.join(mediaDir, outputFileName); - const totalDuration = frames.length * duration; + const totalDuration = panelDurations.reduce((a, b) => a + b, 0); - // Comando FFmpeg: concatena as imagens, adiciona o áudio, ajusta o codec para h264/aac e encerra na duração final - const ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${bgMusicPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`; + let ffmpegCommand = ''; + + if (concatAudioPath && bgMusicPath) { + // Narração + Música de fundo (mixagem com volume suave na música) + ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${concatAudioPath}" -i "${bgMusicPath}" -filter_complex "[1:a]volume=1.2[narr];[2:a]volume=0.15[bg];[narr][bg]amix=inputs=2:duration=first[aout]" -map 0:v -map "[aout]" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`; + } else if (concatAudioPath) { + // Narração apenas (sem música de fundo) + ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${concatAudioPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`; + } else if (bgMusicPath) { + // Apenas música de fundo (sem voz) + ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${bgMusicPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`; + } else { + // Sem áudio + ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -c:v libx264 -pix_fmt yuv420p -t ${totalDuration} "${outputFilePath}"`; + } console.log('[FFmpeg] Executando comando de vídeo:', ffmpegCommand); exec(ffmpegCommand, (error, stdout, stderr) => { - // Remove o arquivo concat temporário + // Limpeza de arquivos temporários try { - fs.unlinkSync(concatFilePath); - } catch (e) { - console.error('Falha ao remover arquivo concat temporário:', e); + if (fs.existsSync(concatFilePath)) fs.unlinkSync(concatFilePath); + if (concatAudioPath && fs.existsSync(concatAudioPath)) fs.unlinkSync(concatAudioPath); + narrationAudioFiles.forEach(f => { + if (fs.existsSync(f)) fs.unlinkSync(f); + }); + } catch (cleanErr) { + console.warn('Erro ao limpar arquivos temporários do vídeo:', cleanErr.message); } if (error) { console.error('Erro ao processar FFmpeg:', error, stderr); - return res.status(500).json({ error: 'Erro ao gerar o vídeo da apresentação. Como o servidor foi atualizado e reiniciado recentemente, por favor crie/gere a história em quadrinhos novamente para recriar as imagens temporárias antes de exportar o vídeo.' }); + return res.status(500).json({ error: 'Erro ao gerar o vídeo da apresentação.' }); } - // Backup do vídeo gerado no banco de dados em background + // Backup do vídeo gerado no banco em background (async () => { try { const videoBuffer = fs.readFileSync(outputFilePath);