Adiciona opcao de narracao de video por IA com vozes neurais (Assistente e Antonio), adaptacao automatica de tempo por quadro e conclusao narrativa

This commit is contained in:
2026-08-31 15:18:22 +00:00
parent 1fc7961ed9
commit b0c0a836a1
3 changed files with 159 additions and 31 deletions
+6 -2
View File
@@ -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'
})
});
+13 -4
View File
@@ -2063,21 +2063,30 @@
<div style="display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border-light); padding-top: 14px;">
<span style="font-size: 0.8rem; font-weight: 600; color: var(--text-primary);">Criar Apresentação de Vídeo Animada com Música:</span>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;">
<div class="settings-group" style="gap: 4px;">
<label style="font-size: 0.72rem; color: var(--text-secondary); font-weight: 600;">Trilha Sonora de Fundo</label>
<label style="font-size: 0.72rem; color: var(--text-secondary); font-weight: 600;">🎙️ Voz da Narração (IA)</label>
<select id="comicsVideoVoice" class="obs-select" style="padding: 6px 10px; font-size: 0.8rem;">
<option value="mulher" selected>👩 Mulher (Voz da Assistente Pedagog)</option>
<option value="homem">👨 Homem (Voz do Antônio)</option>
<option value="none">🔇 Apenas Trilha Musical (Sem Voz)</option>
</select>
</div>
<div class="settings-group" style="gap: 4px;">
<label style="font-size: 0.72rem; color: var(--text-secondary); font-weight: 600;">🎵 Trilha Sonora de Fundo</label>
<select id="comicsVideoMusic" class="obs-select" style="padding: 6px 10px; font-size: 0.8rem;">
<option value="alegre">🎸 Trilha 1: Alegre e Divertida</option>
<option value="calma">🌙 Trilha 2: Calma e Relaxante</option>
<option value="aventura">🎮 Trilha 3: Aventura e Mistério</option>
<option value="sem_musica">🔇 Sem Música de Fundo</option>
</select>
</div>
<div class="settings-group" style="gap: 4px;">
<label style="font-size: 0.72rem; color: var(--text-secondary); font-weight: 600;">Duração por Quadrinho</label>
<label style="font-size: 0.72rem; color: var(--text-secondary); font-weight: 600;">⏱️ Tempo Mínimo / Cena</label>
<select id="comicsVideoDuration" class="obs-select" style="padding: 6px 10px; font-size: 0.8rem;">
<option value="3">⏱️ 3 segundos</option>
<option value="5" selected>⏱️ 5 segundos</option>
<option value="8">⏱️ 8 segundos</option>
<option value="10">⏱️ 10 segundos</option>
</select>
</div>
</div>
+138 -23
View File
@@ -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');
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)}`;
const concatFilePath = path.join(__dirname, `concat_${tempFileId}.txt`);
// Estrutura para armazenar tempos e caminhos dos áudios de narração
const panelDurations = [];
const narrationAudioFiles = [];
let concatContent = '';
frames.forEach(frame => {
const localFilePath = path.join(__dirname, 'public', frame.imageUrl);
concatContent += `file '${localFilePath}'\nduration ${duration}\n`;
// 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, idx) => {
const localFilePath = path.join(__dirname, 'public', frame.imageUrl);
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);