+
-
+
+
+
+
+
-
+
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);