feat: implementa a Fabrica de Quadrinhos com geracao de PDF e video animado via FFmpeg
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user