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) { if (btnCompileComicsVideo) {
btnCompileComicsVideo.addEventListener('click', async () => { btnCompileComicsVideo.addEventListener('click', async () => {
if (generatedPanelsData.length === 0) return; 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'; comicsVideoLoader.style.display = 'flex';
comicsVideoResult.style.display = 'none'; comicsVideoResult.style.display = 'none';
const voice = document.getElementById('comicsVideoVoice')?.value || 'mulher';
try { try {
const response = await fetch('/api/comics/generate-video', { const response = await fetch('/api/comics/generate-video', {
method: 'POST', method: 'POST',
@@ -5647,7 +5649,9 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.`
body: JSON.stringify({ body: JSON.stringify({
frames: generatedPanelsData, frames: generatedPanelsData,
musicSelection: comicsVideoMusic.value, 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;"> <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> <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;"> <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;"> <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="alegre">🎸 Trilha 1: Alegre e Divertida</option>
<option value="calma">🌙 Trilha 2: Calma e Relaxante</option> <option value="calma">🌙 Trilha 2: Calma e Relaxante</option>
<option value="aventura">🎮 Trilha 3: Aventura e Mistério</option> <option value="aventura">🎮 Trilha 3: Aventura e Mistério</option>
<option value="sem_musica">🔇 Sem Música de Fundo</option>
</select> </select>
</div> </div>
<div class="settings-group" style="gap: 4px;"> <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;"> <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="5" selected>⏱️ 5 segundos</option>
<option value="8">⏱️ 8 segundos</option> <option value="8">⏱️ 8 segundos</option>
<option value="10">⏱️ 10 segundos</option>
</select> </select>
</div> </div>
</div> </div>
+140 -25
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) => { 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) { if (!frames || !Array.isArray(frames) || frames.length === 0) {
return res.status(400).json({ error: 'Nenhum painel enviado para geração de vídeo.' }); 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 musicFile = musicSelection || 'alegre';
const voice = narrationVoice || 'none'; // 'none', 'mulher', 'homem'
try { try {
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { exec } = require('child_process'); const { exec } = require('child_process');
const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts');
const mediaDir = path.join(__dirname, 'public', 'generated-media'); const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) { if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true }); fs.mkdirSync(mediaDir, { recursive: true });
} }
// Mapear trilha musical const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
let bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3');
if (musicFile === 'calma') { // 1. Mapear trilha musical
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3'); let bgMusicPath = null;
} else if (musicFile === 'aventura') { if (musicFile !== 'sem_musica') {
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'aventura.mp3'); 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 // Estrutura para armazenar tempos e caminhos dos áudios de narração
const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`; const panelDurations = [];
const concatFilePath = path.join(__dirname, `concat_${tempFileId}.txt`); 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 = ''; let concatContent = '';
frames.forEach(frame => { frames.forEach((frame, idx) => {
const localFilePath = path.join(__dirname, 'public', frame.imageUrl); 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); const lastLocalFilePath = path.join(__dirname, 'public', frames[frames.length - 1].imageUrl);
concatContent += `file '${lastLocalFilePath}'\n`; concatContent += `file '${lastLocalFilePath}'\n`;
fs.writeFileSync(concatFilePath, concatContent); 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 outputFileName = `video_quadrinhos_${tempFileId}.mp4`;
const outputFilePath = path.join(mediaDir, outputFileName); 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 let ffmpegCommand = '';
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}"`;
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); console.log('[FFmpeg] Executando comando de vídeo:', ffmpegCommand);
exec(ffmpegCommand, (error, stdout, stderr) => { exec(ffmpegCommand, (error, stdout, stderr) => {
// Remove o arquivo concat temporário // Limpeza de arquivos temporários
try { try {
fs.unlinkSync(concatFilePath); if (fs.existsSync(concatFilePath)) fs.unlinkSync(concatFilePath);
} catch (e) { if (concatAudioPath && fs.existsSync(concatAudioPath)) fs.unlinkSync(concatAudioPath);
console.error('Falha ao remover arquivo concat temporário:', e); 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) { if (error) {
console.error('Erro ao processar FFmpeg:', error, stderr); 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 () => { (async () => {
try { try {
const videoBuffer = fs.readFileSync(outputFilePath); const videoBuffer = fs.readFileSync(outputFilePath);