feat: implementa a Fabrica de Quadrinhos com geracao de PDF e video animado via FFmpeg

This commit is contained in:
2026-06-03 12:01:03 +00:00
parent 4690672245
commit 0dbf687ec9
7 changed files with 914 additions and 1 deletions
+187
View File
@@ -317,6 +317,193 @@ Retorne APENAS a letra formatada em versos com quebras de linha. Não adicione n
}
});
// ============================================================
// ROTAS DO ESTÚDIO: FÁBRICA DE QUADRINHOS
// ============================================================
// 1. Gerar o roteiro (script) dos quadrinhos usando MiniMax-M3
app.post('/api/comics/generate-script', requireAuth, async (req, res) => {
const { tema, quantidade, baloes, proporcao, personagens, cenario } = req.body;
if (!tema) {
return res.status(400).json({ error: 'O tema dos quadrinhos é obrigatório.' });
}
try {
const prompt = `Crie uma história em quadrinhos pedagógica infantil com o tema: "${tema}".
A história deve conter exatamente ${quantidade} quadrinhos.
Baloes de falas: ${baloes ? 'Sim, a história deve ter falas/diálogos curtos para os personagens' : 'Não, a história é silenciosa, sem falas, apenas a descrição da cena'}.
Proporção visual: ${proporcao}.
Personagens participantes: ${JSON.stringify(personagens)}.
Cenário/Local principal: ${cenario}.
Gere um JSON perfeitamente válido contendo:
{
"title": "Título da História",
"character_description": "Descrição visual constante dos personagens em inglês (para usar em geradores de imagens, detalhando roupas específicas, cores, características físicas de forma fixa para manter a consistência).",
"panels": [
{
"panel_number": 1,
"image_prompt": "Prompt visual detalhado em inglês para o gerador de imagens da MiniMax descrevendo a cena deste quadrinho (ex: 'Lucas and Mariana standing in a colorful sunny park, holding hands...'). Deve incluir a descrição física constante de cada personagem participante neste quadrinho. O estilo deve ser de ilustração de livro infantil de alta qualidade, 3D Pixar, traço limpo.",
"dialogue": "Falas do quadrinho em português (ex: 'Lucas: Olá, Mariana!') ou narração curta caso não tenha balões."
},
...
]
}
Responda APENAS o JSON válido, sem qualquer texto explicativo antes ou depois.`;
const r = await callMinimax({
system: "Você é um roteirista pedagógico especialista em histórias em quadrinhos infantis para educação básica. Você responde apenas com estruturas JSON válidas.",
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 2500
});
let jsonText = r.text;
// Tenta limpar possíveis tags markdown de código
jsonText = jsonText.replace(/```json/gi, '').replace(/```/g, '').trim();
const scriptData = JSON.parse(jsonText);
res.json(scriptData);
} catch (error) {
console.error('Erro ao gerar roteiro dos quadrinhos:', error);
res.status(500).json({ error: 'Erro ao compor a história em quadrinhos: ' + error.message });
}
});
// 2. Gerar uma imagem individual para um quadrinho usando MiniMax image-01
app.post('/api/comics/generate-frame', requireAuth, async (req, res) => {
const { prompt } = req.body;
if (!prompt) {
return res.status(400).json({ error: 'O prompt de imagem é obrigatório.' });
}
try {
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const genResp = await fetch(`${minmBase}/v1/image_generation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'image-01',
prompt: prompt,
n: 1
})
});
if (!genResp.ok) {
const errText = await genResp.text();
throw new Error(`MiniMax image-01 respondeu erro: ${errText}`);
}
const genData = await genResp.json();
const imageUrl = genData.data?.image_urls?.[0];
if (!imageUrl) {
throw new Error('Nenhuma URL de imagem retornada pela MiniMax.');
}
// Baixar e salvar localmente
const fs = require('fs');
const path = require('path');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
const imgFetch = await fetch(imageUrl);
if (!imgFetch.ok) {
throw new Error(`Falha ao baixar imagem: ${imgFetch.statusText}`);
}
const imgBuffer = Buffer.from(await imgFetch.arrayBuffer());
const fileName = `quadrinho_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`;
const filePath = path.join(mediaDir, fileName);
fs.writeFileSync(filePath, imgBuffer);
res.json({ imageUrl: `/generated-media/${fileName}` });
} catch (error) {
console.error('Erro ao gerar quadrinho:', error);
res.status(500).json({ error: error.message || 'Erro ao gerar ilustração do painel' });
}
});
// 3. Compilar a apresentação de vídeo usando FFmpeg a partir dos painéis de quadrinhos
app.post('/api/comics/generate-video', requireAuth, async (req, res) => {
const { frames, musicSelection, frameDuration } = 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 musicFile = musicSelection || 'alegre';
try {
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
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');
}
// 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`);
let concatContent = '';
frames.forEach(frame => {
const localFilePath = path.join(__dirname, 'public', frame.imageUrl);
concatContent += `file '${localFilePath}'\nduration ${duration}\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);
const outputFileName = `video_quadrinhos_${tempFileId}.mp4`;
const outputFilePath = path.join(mediaDir, outputFileName);
const totalDuration = frames.length * duration;
// Comando FFmpeg: concatena as imagens, adiciona o áudio, ajusta o codec para h264/aac e encerra na duração final
const ffmpegCommand = `/usr/bin/ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${bgMusicPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`;
console.log('[FFmpeg] Executando comando de vídeo:', ffmpegCommand);
exec(ffmpegCommand, (error, stdout, stderr) => {
// Remove o arquivo concat temporário
try {
fs.unlinkSync(concatFilePath);
} catch (e) {
console.error('Falha ao remover arquivo concat temporário:', e);
}
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.' });
}
res.json({ videoUrl: `/generated-media/${outputFileName}` });
});
} catch (error) {
console.error('Erro ao compilar vídeo de apresentação:', error);
res.status(500).json({ error: 'Erro ao gerar o vídeo: ' + error.message });
}
});
// Rota GET para obter configurações do agente
app.get('/api/agent-config', requireAuth, (req, res) => {
const config = readAgentConfig();