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:
+6
-2
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
|
||||||
|
// 1. Mapear trilha musical
|
||||||
|
let bgMusicPath = null;
|
||||||
|
if (musicFile !== 'sem_musica') {
|
||||||
|
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3');
|
||||||
if (musicFile === 'calma') {
|
if (musicFile === 'calma') {
|
||||||
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3');
|
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3');
|
||||||
} else if (musicFile === 'aventura') {
|
} else if (musicFile === 'aventura') {
|
||||||
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'aventura.mp3');
|
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 = [];
|
||||||
|
|
||||||
let concatContent = '';
|
// 2. Se a narração estiver ativada (mulher ou homem)
|
||||||
frames.forEach(frame => {
|
if (voice === 'mulher' || voice === 'homem') {
|
||||||
const localFilePath = path.join(__dirname, 'public', frame.imageUrl);
|
const voiceName = voice === 'homem' ? 'pt-BR-AntonioNeural' : 'pt-BR-FranciscaNeural';
|
||||||
concatContent += `file '${localFilePath}'\nduration ${duration}\n`;
|
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);
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user