require('dotenv').config(); const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); const multer = require('multer'); const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts'); const { YoutubeTranscript } = require('youtube-transcript'); const app = express(); const PORT = process.env.PORT || 3000; const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || 'puglindo26'; // Configuração do multer para uploads de áudio const multerUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } // 25MB max }); // ============================================================================ // HELPER: chamada à API MiniMax (texto / classificação / reescrita de prompt) // Substitui OpenRouter para todas as funções de TEXTO a partir de jun/2026. // Imagem, música e vídeo continuam usando endpoints nativos MiniMax em /v1/ // ============================================================================ async function callMinimax({ messages, temperature = 0.5, max_tokens = 1500, system = null, model = null }) { const baseUrl = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); const chosenModel = model || process.env.MINIMAX_MODEL || 'MiniMax-M3'; const finalMessages = system ? [{ role: 'system', content: system }, ...messages] : messages; const res = await fetch(`${baseUrl}/anthropic/v1/messages`, { method: 'POST', headers: { 'X-Api-Key': process.env.MINIMAX_API_KEY, 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: chosenModel, max_tokens, temperature, messages: finalMessages }) }); if (!res.ok) { const txt = await res.text(); throw new Error(`MiniMax respondeu ${res.status}: ${txt}`); } const data = await res.json(); // Extrai só os blocos de texto (ignora thinking blocks) const text = (data.content || []) .filter(b => b.type === 'text') .map(b => b.text) .join('') .trim(); return { text, raw: data }; } app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use(cookieParser(process.env.SESSION_SECRET || 'pedagog-secret')); // Middleware para verificar autenticação const requireAuth = (req, res, next) => { const session = req.signedCookies.pedagog_session || req.cookies.pedagog_session || req.signedCookies.camila_session || req.cookies.camila_session; if (session === 'authenticated') { next(); } else { if (req.xhr || req.path.startsWith('/api/')) { res.status(401).json({ error: 'Não autorizado' }); } else { res.redirect('/login'); } } }; const fs = require('fs'); const { Pool } = require('pg'); const dbPool = new Pool({ connectionString: process.env.DATABASE_URL }); async function backupMediaFile(filePath, buffer) { try { const filename = path.basename(filePath); let contentType = 'application/octet-stream'; const ext = path.extname(filename).toLowerCase(); if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg'; else if (ext === '.png') contentType = 'image/png'; else if (ext === '.gif') contentType = 'image/gif'; else if (ext === '.mp3') contentType = 'audio/mpeg'; else if (ext === '.mp4') contentType = 'video/mp4'; else if (ext === '.wav') contentType = 'audio/wav'; await dbPool.query( `INSERT INTO escola.arquivos (filename, content_type, dados) VALUES ($1, $2, $3) ON CONFLICT (filename) DO UPDATE SET dados = $3, content_type = $2`, [filename, contentType, buffer] ); console.log(`[Media Backup] Backup do arquivo ${filename} salvo no banco escola.arquivos.`); } catch (err) { console.error(`[Media Backup] Erro ao fazer backup do arquivo ${filePath}:`, err); } } const CONFIG_FILE_PATH = path.join(__dirname, 'agent_config.json'); const KNOWLEDGE_FILE_PATH = path.join(__dirname, 'conhecimento_adquirido.json'); const TEMPERATURE_PRESETS = { tecnico: { value: 0.2, label: 'Técnico e Direto', description: 'Respostas curtas, objetivas, sem rodeios. Ideal para quando precisa só do essencial.' }, equilibrado: { value: 0.6, label: 'Equilibrado', description: 'Bom para uso geral — nem muito curto, nem muito longo.' }, detalhista: { value: 1.0, label: 'Detalhista e Falante', description: 'Respostas extensas, explicações completas,contextualização máxima. Ideal para estudo e revisão.' } }; let agentConfigCache = { agentName: "PedagogIA", iaAvatarUrl: "assets/pedagog_ia.png", userAvatarUrl: "assets/pedagog_user.png", temperaturePreset: "equilibrado" }; function readAgentConfig() { return agentConfigCache; } async function writeAgentConfig(config) { agentConfigCache = { agentName: config.agentName || "PedagogIA", iaAvatarUrl: config.iaAvatarUrl || config.kemilyAvatarUrl || "assets/pedagog_ia.png", userAvatarUrl: config.userAvatarUrl || config.camilaAvatarUrl || "assets/pedagog_user.png", temperaturePreset: config.temperaturePreset || 'equilibrado' }; try { await dbPool.query( `INSERT INTO escola.config (key, agent_name, ia_avatar_url, user_avatar_url, temperature_preset, updated_at) VALUES ('current', $1, $2, $3, $4, NOW()) ON CONFLICT (key) DO UPDATE SET agent_name = EXCLUDED.agent_name, ia_avatar_url = EXCLUDED.ia_avatar_url, user_avatar_url = EXCLUDED.user_avatar_url, temperature_preset = EXCLUDED.temperature_preset, updated_at = NOW();`, [agentConfigCache.agentName, agentConfigCache.iaAvatarUrl, agentConfigCache.userAvatarUrl, agentConfigCache.temperaturePreset] ); fs.writeFileSync(CONFIG_FILE_PATH, JSON.stringify(agentConfigCache, null, 2), 'utf8'); const botConfigDir = path.join(__dirname, '..', 'BotVPS', 'data'); if (fs.existsSync(botConfigDir)) { fs.writeFileSync(path.join(botConfigDir, 'agent_config.json'), JSON.stringify(agentConfigCache, null, 2), 'utf8'); } } catch (e) { console.error('Erro ao salvar agent_config no banco:', e); } } // Leitura/escrita do conhecimento adquirido function readConhecimento() { // Mantido apenas para compatibilidade de migração na inicialização try { if (fs.existsSync(KNOWLEDGE_FILE_PATH)) { return JSON.parse(fs.readFileSync(KNOWLEDGE_FILE_PATH, 'utf8')); } } catch (e) { console.error('Erro ao ler conhecimento_adquirido.json:', e); } return { facts: [], lastUpdated: null }; } async function addKnowledgeFact(fact, source = 'conversa') { try { const res = await dbPool.query("SELECT content FROM escola.conhecimento;"); const exists = res.rows.some(f => f.content.toLowerCase().includes(fact.toLowerCase()) || fact.toLowerCase().includes(f.content.toLowerCase()) ); if (!exists) { await dbPool.query( "INSERT INTO escola.conhecimento (content, source, added_at) VALUES ($1, $2, NOW());", [fact, source] ); } return !exists; } catch (err) { console.error('Erro ao adicionar fato no banco:', err); return false; } } async function removeKnowledgeFact(id) { try { await dbPool.query("DELETE FROM escola.conhecimento WHERE id = $1;", [id]); } catch (err) { console.error('Erro ao remover fato do banco:', err); } } async function getKnowledgeAsText() { try { const res = await dbPool.query("SELECT content, source FROM escola.conhecimento ORDER BY added_at DESC;"); if (!res.rows.length) return ''; return '\n\n════════════════════════════════════════\nCONHECIMENTO ADQUIRIDO (Aprendido com a Camila):\n════════════════════════════════════════\n' + res.rows.map(f => `[${f.source}] ${f.content}`).join('\n') + '\n════════════════════════════════════════\n'; } catch (err) { console.error('Erro ao ler conhecimento do banco:', err); return ''; } } // Rota POST para sintetizar fala com MsEdgeTTS e retornar áudio app.post('/api/tts', requireAuth, async (req, res) => { const { text } = req.body; if (!text) return res.status(400).json({ error: 'Texto é obrigatório' }); try { const tts = new MsEdgeTTS(); await tts.setMetadata("pt-BR-FranciscaNeural", OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); const { audioStream } = tts.toStream(text); res.setHeader('Content-Type', 'audio/mpeg'); audioStream.pipe(res); audioStream.on('error', (err) => { console.error('Erro na stream do MsEdgeTTS:', err); if (!res.headersSent) { res.status(500).json({ error: 'Erro ao processar áudio' }); } }); } catch (error) { console.error('Erro ao gerar TTS:', error); if (!res.headersSent) { res.status(500).json({ error: error.message || 'Erro ao gerar áudio' }); } } }); // Rota POST para gerar música customizada (Estúdio Musical) app.post('/api/music/generate', requireAuth, async (req, res) => { const { tema, voz, ritmo, duracao } = req.body; if (!tema) { return res.status(400).json({ error: 'Tema da música é obrigatório' }); } try { // 1. Determinar o prompt de estilo baseado nos inputs let voiceDesc = 'young female vocals, singing in clear native brazilian portuguese accent, no european accent, sweet and warm voice'; if (voz === 'homem') { voiceDesc = 'male vocals, singing in clear native brazilian portuguese accent, warm and gentle singing voice'; } else if (voz === 'crianca') { voiceDesc = 'real young kid vocals, little child singing, boy or girl child voice, playful children voice, native brazilian portuguese kid accent, definitely no adult voices'; } let rhythmDesc = 'happy acoustic children song, simple melody, acoustic guitar'; if (ritmo === 'roda') { rhythmDesc = 'traditional Brazilian cantiga de roda, folk children rhythm, acoustic guitar, percussion'; } else if (ritmo === 'pop') { rhythmDesc = 'upbeat pop for children, modern synth, happy rhythm, claps'; } else if (ritmo === 'ninar') { rhythmDesc = 'gentle lullaby for children, music box, soft strings, calm, sleeping mood'; } else if (ritmo === 'marcha') { rhythmDesc = 'marching children rhythm, playful snare drums, acoustic guitar, brass accent'; } const stylePrompt = `${rhythmDesc}, ${voiceDesc}, educational children song style, singing exclusively in native Brazilian Portuguese (PT-BR) accent, authentic Brazilian vocals, clear pronunciation`; // 2. Determinar o tamanho das letras baseado na duração (duracao) let lengthInstructions = 'curta, de apenas 6 a 8 versos'; if (duracao === '1min') { lengthInstructions = 'média, de 12 a 16 versos (com verso 1, refrão, verso 2, refrão)'; } else if (duracao === '2min') { lengthInstructions = 'completa e longa, de 24 a 32 versos (com estrutura completa: verso 1, refrão, verso 2, refrão, ponte e refrão final)'; } // 3. Chamar a IA (MiniMax-M3) para compor a letra const lyricsPrompt = `Gere uma letra de cantiga escolar infantil sobre o tema: "${tema}". A letra deve ser escrita para o público infantil (linguagem lúdica, alegre e educativa). A estrutura de versos deve ser: ${lengthInstructions}. Retorne APENAS a letra formatada em versos com quebras de linha. Não adicione notas, comentários ou títulos.`; const aiResponse = await callMinimax({ system: "Você é um compositor pedagógico infantil que escreve letras de músicas educativas em português brasileiro. Retorne estritamente a letra da música, sem introduções ou observações extras.", messages: [{ role: 'user', content: lyricsPrompt }], temperature: 0.7, max_tokens: 1000 }); const lyrics = aiResponse.text ? aiResponse.text.trim() : ''; if (!lyrics) { throw new Error('Falha ao compor a letra da música.'); } // 4. Chamar a API da MiniMax para gerar a música (canto + melodia) const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); const musicResp = await fetch(`${minmBase}/v1/music_generation`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'music-2.0', prompt: stylePrompt, lyrics: lyrics, audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' } }) }); if (!musicResp.ok) { const errText = await musicResp.text(); throw new Error(`MiniMax music-2.0 respondeu erro: ${errText}`); } const musicData = await musicResp.json(); const audioHex = musicData.data?.audio; if (!audioHex) { throw new Error('Nenhum dado de áudio retornado pela MiniMax.'); } // 5. Salvar o arquivo 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 fileName = `estudio_musica_${Date.now()}.mp3`; const filePath = path.join(mediaDir, fileName); const audioBuffer = Buffer.from(audioHex, 'hex'); fs.writeFileSync(filePath, audioBuffer); await backupMediaFile(filePath, audioBuffer); const audioUrl = `/generated-media/${fileName}`; // 6. Retornar dados para o frontend const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; const dbRes = await dbPool.query( `INSERT INTO escola.estudio_musicas (usuario_id, voz, ritmo, tema, letra, audio_url) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, [usuarioId, voz, ritmo, tema, lyrics, audioUrl] ); res.json({ success: true, id: dbRes.rows[0].id, lyrics: lyrics, audioUrl: audioUrl }); } catch (error) { console.error('Erro no Estúdio Musical:', error); res.status(500).json({ error: error.message || 'Ocorreu um erro ao gerar a música' }); } }); // ============================================================ // ROTAS DE HISTÓRICO DO ESTÚDIO MUSICAL // ============================================================ app.get('/api/music/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query(`SELECT id, voz, ritmo, tema, created_at FROM escola.estudio_musicas WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId]); res.json(dbRes.rows); } catch (err) { res.status(500).json({ error: 'Erro ao listar histórico.' }); } }); app.get('/api/music/:id', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query(`SELECT * FROM escola.estudio_musicas WHERE id = $1 AND usuario_id = $2`, [req.params.id, usuarioId]); if (dbRes.rows.length === 0) return res.status(404).json({ error: 'Não encontrada.' }); res.json(dbRes.rows[0]); } catch (err) { res.status(500).json({ error: 'Erro ao buscar.' }); } }); app.delete('/api/music/:id', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { // Apagar também o arquivo físico se quiser const fileRes = await dbPool.query(`SELECT audio_url FROM escola.estudio_musicas WHERE id = $1 AND usuario_id = $2`, [req.params.id, usuarioId]); if (fileRes.rows.length > 0 && fileRes.rows[0].audio_url) { const fs = require('fs'); const path = require('path'); const filePath = path.join(__dirname, 'public', fileRes.rows[0].audio_url); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } await dbPool.query(`DELETE FROM escola.estudio_musicas WHERE id = $1 AND usuario_id = $2`, [req.params.id, usuarioId]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Erro ao deletar.' }); } }); // ============================================================ // 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, existingPanels, character_description, continuationType } = req.body; if (!tema) { return res.status(400).json({ error: 'O tema dos quadrinhos é obrigatório.' }); } try { let prompt = ''; if (existingPanels && Array.isArray(existingPanels) && existingPanels.length > 0) { const startPanelNum = continuationType === 'extend' ? existingPanels.length + 1 : 1; prompt = `Você está continuando ou se baseando em um projeto de história em quadrinhos existente. DADOS DO PROJETO EXISTENTE: - Descrição visual física e de estilo constante dos personagens (em inglês): "${character_description || ''}" - Painéis já existentes (roteiro anterior): ${existingPanels.map(p => ` * Quadrinho ${p.panel_number || p.panelNumber}: [Visual] ${p.image_prompt || p.imagePrompt || 'N/A'} | [Fala/Legenda] ${p.dialogue}`).join('\n')} INSTRUÇÕES DE GERAÇÃO: Tipo de continuidade: ${continuationType === 'extend' ? 'Estender a mesma história (continuação cronológica direta, criando novos painéis sequenciais)' : 'Criar uma nova história separada (spin-off com os mesmos personagens, recomeçando do painel 1)'}. Tema/Objetivo da nova fase: "${tema}". Gere exatamente ${quantidade} novos quadrinhos. Baloes de falas: ${baloes ? 'Sim, cada quadrinho deve conter um diálogo/fala extremamente conciso e curto, contendo no máximo 10 palavras no total.' : 'Não, a história é silenciosa, sem falas, apenas a descrição da cena'}. Proporção visual: ${proporcao}. Cenário/Local principal da nova fase: ${cenario}. Gere um JSON perfeitamente válido contendo: { "title": "${continuationType === 'extend' ? 'Manter o título original ou propor um subtítulo condizente' : 'Novo Título da História'}", "character_description": "${character_description || 'Descrição visual constante dos personagens em inglês'}", "panels": [ { "panel_number": ${startPanelNum}, "image_prompt": "Prompt visual detalhado em inglês para o gerador de imagens da MiniMax descrevendo a cena deste quadrinho. IMPORTANTE PARA CONSISTÊNCIA: Substitua TODOS os nomes de personagens por sua descrição física minuciosa em inglês (obtida de character_description) em cada quadrinho que aparecerem (ex: em vez de apenas escrever 'Lucas', escreva a descrição detalhada dele com roupas e cabelos específicos, etc). 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: Veja aquilo!') com NO MÁXIMO 10 palavras, ou narração curta caso não tenha balões." }, ... ] } Responda APENAS o JSON válido, sem qualquer texto explicativo antes ou depois.`; } else { 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, cada quadrinho deve conter um diálogo/fala extremamente conciso e curto, contendo no máximo 10 palavras no total.' : '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. IMPORTANTE PARA CONSISTÊNCIA: Substitua TODOS os nomes de personagens por sua descrição física minuciosa em inglês (do character_description) em cada quadrinho que aparecerem (ex: não escreva apenas 'Mariana', escreva a descrição dela detalhando cabelo e roupas de forma fixa e idêntica em todos os quadrinhos). 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: Veja aquilo!') com NO MÁXIMO 10 palavras, 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.3, max_tokens: 3000 }); let jsonText = r.text; let scriptData; try { const firstBrace = jsonText.indexOf('{'); const lastBrace = jsonText.lastIndexOf('}'); if (firstBrace !== -1 && lastBrace !== -1) { const jsonString = jsonText.substring(firstBrace, lastBrace + 1); scriptData = JSON.parse(jsonString); } else { jsonText = jsonText.replace(/```json/gi, '').replace(/```/g, '').trim(); scriptData = JSON.parse(jsonText); } } catch (parseErr) { console.error('Falha ao parsear JSON do roteiro de quadrinhos. Resposta original:', jsonText); throw new Error('Formato de resposta inválido retornado pela IA: ' + parseErr.message); } 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) { const msg = genData.base_resp?.status_msg || 'desconhecido'; throw new Error(`Nenhuma URL de imagem. Status MiniMax: ${msg}`); } // 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); await backupMediaFile(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 = `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. 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.' }); } // Backup do vídeo gerado no banco de dados (async () => { try { const videoBuffer = fs.readFileSync(outputFilePath); await backupMediaFile(outputFilePath, videoBuffer); } catch (backupErr) { console.error('[Media Backup] Erro ao fazer backup do vídeo do FFmpeg:', backupErr); } })(); 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 }); } }); // --- ROTAS DE PROJETOS DE HQ (SALVAR, LISTAR, CARREGAR, EXCLUIR) --- // 1. Listar projetos app.get('/api/comics/projects', requireAuth, async (req, res) => { const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000'; try { const result = await dbPool.query( 'SELECT id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, created_at, updated_at FROM escola.projetos_comics WHERE usuario_id = $1 ORDER BY updated_at DESC;', [usuarioId] ); res.json(result.rows); } catch (error) { console.error('Erro ao listar projetos de quadrinhos:', error); res.status(500).json({ error: 'Erro ao listar projetos de quadrinhos.' }); } }); // 2. Carregar projeto específico app.get('/api/comics/projects/:id', requireAuth, async (req, res) => { const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000'; const { id } = req.params; try { const projectRes = await dbPool.query( 'SELECT * FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2;', [id, usuarioId] ); if (projectRes.rows.length === 0) { return res.status(404).json({ error: 'Projeto não encontrado.' }); } const panelsRes = await dbPool.query( 'SELECT id, panel_number, image_url, image_prompt, dialogue FROM escola.comics_panels WHERE projeto_id = $1 ORDER BY panel_number ASC;', [id] ); res.json({ project: projectRes.rows[0], panels: panelsRes.rows }); } catch (error) { console.error('Erro ao obter projeto de quadrinhos:', error); res.status(500).json({ error: 'Erro ao obter projeto de quadrinhos.' }); } }); // 3. Salvar / Atualizar projeto app.post('/api/comics/projects', requireAuth, async (req, res) => { const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000'; const { id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, panels } = req.body; if (!titulo) { return res.status(400).json({ error: 'O título do projeto é obrigatório.' }); } const client = await dbPool.connect(); try { await client.query('BEGIN'); let projetoId = id; if (projetoId) { // Verificar propriedade const check = await client.query( 'SELECT id FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2;', [projetoId, usuarioId] ); if (check.rows.length === 0) { throw new Error('Projeto não encontrado para edição.'); } // Atualizar metadados await client.query( `UPDATE escola.projetos_comics SET titulo = $1, tema = $2, cenario = $3, proporcao = $4, character_description_global = $5, character_description_english = $6, updated_at = NOW() WHERE id = $7;`, [titulo, tema, cenario, proporcao, character_description_global, character_description_english, projetoId] ); } else { // Criar novo projeto const insertProj = await client.query( `INSERT INTO escola.projetos_comics (usuario_id, titulo, tema, cenario, proporcao, character_description_global, character_description_english) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;`, [usuarioId, titulo, tema, cenario, proporcao, character_description_global, character_description_english] ); projetoId = insertProj.rows[0].id; } // Atualizar painéis: remover antigos se existiam e salvar novos await client.query('DELETE FROM escola.comics_panels WHERE projeto_id = $1;', [projetoId]); if (panels && Array.isArray(panels)) { for (const p of panels) { await client.query( `INSERT INTO escola.comics_panels (projeto_id, panel_number, image_url, image_prompt, dialogue) VALUES ($1, $2, $3, $4, $5);`, [projetoId, p.panel_number, p.image_url, p.image_prompt, p.dialogue] ); } } await client.query('COMMIT'); res.json({ success: true, id: projetoId, message: 'Projeto salvo com sucesso!' }); } catch (error) { await client.query('ROLLBACK'); console.error('Erro ao salvar projeto de quadrinhos:', error); res.status(500).json({ error: 'Erro ao salvar projeto: ' + error.message }); } finally { client.release(); } }); // 4. Excluir projeto app.delete('/api/comics/projects/:id', requireAuth, async (req, res) => { const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000'; const { id } = req.params; try { const result = await dbPool.query( 'DELETE FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2 RETURNING id;', [id, usuarioId] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Projeto não encontrado ou sem permissão para exclusão.' }); } res.json({ success: true, message: 'Projeto excluído com sucesso!' }); } catch (error) { console.error('Erro ao excluir projeto de quadrinhos:', error); res.status(500).json({ error: 'Erro ao excluir projeto de quadrinhos.' }); } }); // ============================================================ // ROTAS DO VIDEOMIND (TRANSCRIÇÃO E ANÁLISE DE VÍDEO) // ============================================================ function getYouTubeId(url) { const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=|live\/|shorts\/)([^#\&\?]*).*/; const match = url.match(regExp); return (match && match[2].length === 11) ? match[2] : null; } app.post('/api/videomind/analyze', requireAuth, async (req, res) => { const { url, lines, pastedTranscript } = req.body; if (!url) { return res.status(400).json({ error: 'A URL do vídeo é obrigatória.' }); } const videoId = getYouTubeId(url); if (!videoId) { return res.status(400).json({ error: 'A URL inserida não é uma URL válida do YouTube.' }); } const numLines = parseInt(lines) || 20; let transcriptText = ''; let metadata = { title: 'Vídeo do YouTube', author: 'Desconhecido', thumbnail: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg` }; try { // Tenta obter metadados via oEmbed oficial do YouTube const oEmbedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; const oEmbedResp = await fetch(oEmbedUrl); if (oEmbedResp.ok) { const oEmbedData = await oEmbedResp.json(); metadata.title = oEmbedData.title || metadata.title; metadata.author = oEmbedData.author_name || metadata.author; metadata.thumbnail = oEmbedData.thumbnail_url || metadata.thumbnail; } } catch (metaErr) { console.error('Erro ao buscar metadados do oEmbed:', metaErr); } let isFallbackMode = false; let systemNotice = ''; if (pastedTranscript && pastedTranscript.trim().length > 10) { // Se o usuário colou a transcrição, ignorar a extração automática transcriptText = pastedTranscript.trim(); systemNotice = `> 📝 **Nota do Sistema**: A análise abaixo foi elaborada com base na transcrição manual que você forneceu.\n\n`; } else { try { // Tenta obter legenda const transcriptObj = await YoutubeTranscript.fetchTranscript(videoId); transcriptText = transcriptObj.map(t => t.text).join(' '); } catch (transcErr) { console.warn(`[VideoMind] Erro ao obter legenda para o vídeo ${videoId}:`, transcErr.message); isFallbackMode = true; if (transcErr.message && transcErr.message.toLowerCase().includes('disabled')) { transcriptText = `[Aviso: O autor deste vídeo desativou as legendas ou elas ainda não foram geradas pelo YouTube. Por favor, analise e descreva os objetivos pedagógicos com base estritamente no título do vídeo, canal do autor e no seu vasto conhecimento pedagógico sobre o tema indicado no título]`; systemNotice = `> ⚠️ **Nota do Sistema**: Este vídeo não possui legendas disponíveis (foram desativadas pelo autor ou ainda não geradas pelo YouTube). A análise e o resumo abaixo foram elaborados a partir do título do vídeo, nome do canal e fundamentação pedagógica correspondente.\n\n`; } else { transcriptText = `[Aviso: Devido a restrições de conexão do YouTube, a legenda de áudio não pôde ser lida diretamente. Por favor, analise e descreva os objetivos pedagógicos com base estritamente no título do vídeo, canal do autor e no seu vasto conhecimento pedagógico sobre o tema indicado no título]`; systemNotice = `> ⚠️ **Nota do Sistema**: Devido a restrições de conexão do YouTube ou indisponibilidade temporária, a transcrição não pôde ser lida. A análise e o resumo abaixo foram elaborados a partir do título do vídeo, canal do autor e fundamentação psicopedagógica correspondente.\n\n`; } } } try { const prompt = `Analise este vídeo do YouTube intitulado "${metadata.title}" (Canal: ${metadata.author}): ID do Vídeo no YouTube: ${videoId} Link do vídeo: https://www.youtube.com/watch?v=${videoId} Conteúdo ou descrição da transcrição: """ ${transcriptText} """ Instruções para o conteúdo de saída: 1. Escreva um RESUMO detalhado do vídeo. Esse resumo precisa ter EXATAMENTE ou APROXIMADAMENTE ${numLines} linhas de texto. Se a transcrição direta não estiver disponível, faça um resumo conceitual com base no título e no tema geral do vídeo com a mesma quantidade de linhas. 2. Faça uma ANÁLISE PEDAGÓGICA COMPLEMENTAR detalhando: - Relevância do conteúdo para fins educacionais. - Indicação de faixa etária/segmento dos alunos recomendado. - Conexão direta com a BNCC (mencione os códigos de competências, habilidades ou campos de experiência correspondentes). - Sugestões práticas de aplicação em sala de aula (atividades pedagógicas, dinâmicas de grupo ou perguntas para debate com os alunos). Retorne a resposta estruturada estritamente em Markdown. Separe as seções usando títulos claros: # Resumo Pedagógico (${numLines} Linhas) [Insira o resumo aqui, ocupando aproximadamente ${numLines} linhas de texto] # Análise Pedagógica e Sugestões Práticas [Insira a análise pedagógica, os pontos da BNCC e as sugestões de atividades práticas aqui]`; let reportText = ''; const systemInstruction = "Você é um pedagogo experiente e especialista em planejamento de aulas no Brasil, alinhado à Base Nacional Comum Curricular (BNCC)."; // Se estiver em fallback, damos preferência ao Gemini pela capacidade de grounding e busca if (isFallbackMode && process.env.GOOGLE_AI_API_KEY) { try { console.log('[VideoMind] Usando fallback com Google Gemini...'); const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`; const response = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: `${systemInstruction}\n\nTarefa:\n${prompt}` }] }], generationConfig: { temperature: 0.5, maxOutputTokens: 4000 } }) }); if (response.ok) { const data = await response.json(); reportText = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; } else { const errTxt = await response.text(); console.warn('[VideoMind] Fallback do Gemini respondeu com erro:', errTxt); } } catch (geminiErr) { console.error('[VideoMind] Erro no fallback do Gemini:', geminiErr.message); } } // Se o Gemini não foi usado ou falhou, usamos o callMinimax padrão if (!reportText) { const aiResponse = await callMinimax({ system: systemInstruction, messages: [{ role: 'user', content: prompt }], temperature: 0.5, max_tokens: 3000 }); reportText = aiResponse.text || ''; } res.json({ success: true, metadata, report: systemNotice + reportText }); } catch (err) { console.error('Erro ao processar análise do VideoMind:', err); res.status(500).json({ error: 'Erro ao gerar análise com a IA: ' + err.message }); } }); // ============================================================ // ROTAS DO GARATUJAS (ANALISADOR DE TRAÇADO INFANTIL) // ============================================================ app.post('/api/garatujas/analyze', requireAuth, multerUpload.single('image'), async (req, res) => { const { alunoName, alunoIdade } = req.body; const file = req.file; if (!alunoName) { return res.status(400).json({ error: 'O nome do aluno é obrigatório.' }); } if (!file) { return res.status(400).json({ error: 'A foto do desenho é obrigatória.' }); } try { const fs = require('fs'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } const ext = path.extname(file.originalname) || '.png'; const fileName = `garatuja_${Date.now()}${ext}`; const filePath = path.join(mediaDir, fileName); fs.writeFileSync(filePath, file.buffer); await backupMediaFile(filePath, file.buffer); const imageUrl = `/generated-media/${fileName}`; const base64Image = file.buffer.toString('base64'); const mimeType = file.mimetype || 'image/png'; let analise = ''; if (process.env.GOOGLE_AI_API_KEY) { console.log('[Garatujas] Usando API direta do Google Gemini para análise de imagem...'); const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`; const idadeContexto = alunoIdade ? `de ${alunoIdade} de idade` : '(até 6 anos de idade)'; const promptText = `Você é um psicopedagogo e especialista em desenvolvimento infantil no Brasil. Analise a imagem anexada, que é um desenho/rabisco feito pelo aluno(a) "${alunoName}" ${idadeContexto}. Identifique visualmente os padrões dos traços, uso do espaço, pressão do lápis (se perceptível) e formas criadas. Descreva com base na literatura científica (como Piaget, Luquet, Lowenfeld): 1. O estágio exato do desenho infantil em que a criança se encontra (ex: Garatuja Desordenada, Garatuja Ordenada, Pré-Esquematismo, etc.) justificando com os elementos visuais que você está vendo na imagem. Lembre-se que a criança tem ${idadeContexto}. 2. A análise do desenvolvimento cognitivo e da coordenação motora fina expressa nos traços vistos na imagem. 3. Propostas práticas de estimulação pedagógica personalizadas para o professor aplicar com o aluno "${alunoName}" em sala de aula para avançar para o próximo estágio. Retorne a resposta estruturada em Markdown, usando títulos claros: # Estágio do Desenho e Análise Psicomotora [Sua análise baseada na imagem aqui] # Diagnóstico de Desenvolvimento Cognitivo [Seu diagnóstico aqui] # Sugestões de Atividades para Estimular ${alunoName} [Suas propostas aqui]`; const response = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [ { parts: [ { text: promptText }, { inlineData: { mimeType: mimeType, data: base64Image } } ] } ], generationConfig: { temperature: 0.4, maxOutputTokens: 8192 } }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`Erro na API oficial do Gemini: ${errText}`); } const resData = await response.json(); analise = resData.candidates?.[0]?.content?.parts?.[0]?.text || ''; } else { throw new Error('Chave GOOGLE_AI_API_KEY não configurada no servidor.'); } const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; const dbRes = await dbPool.query( `INSERT INTO escola.garatujas (usuario_id, aluno_name, image_url, analise) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [usuarioId, alunoName, imageUrl, analise] ); res.json({ success: true, id: dbRes.rows[0].id, alunoName, imageUrl, analise, createdAt: dbRes.rows[0].created_at }); } catch (err) { console.error('Erro na análise de garatujas:', err); res.status(500).json({ error: 'Erro ao analisar imagem: ' + err.message }); } }); app.get('/api/garatujas/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, aluno_name as "alunoName", image_url as "imageUrl", created_at as "createdAt" FROM escola.garatujas WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes.rows); } catch (err) { console.error('Erro ao listar garatujas:', err); res.status(500).json({ error: 'Erro ao obter histórico de desenhos.' }); } }); app.get('/api/garatujas/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, aluno_name as "alunoName", image_url as "imageUrl", analise, created_at as "createdAt" FROM escola.garatujas WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rows.length === 0) { return res.status(404).json({ error: 'Análise não encontrada.' }); } res.json(dbRes.rows[0]); } catch (err) { console.error('Erro ao buscar garatuja:', err); res.status(500).json({ error: 'Erro ao buscar análise.' }); } }); app.delete('/api/garatujas/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const imageRes = await dbPool.query( `SELECT image_url FROM escola.garatujas WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); const dbRes = await dbPool.query( `DELETE FROM escola.garatujas WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rowCount === 0) { return res.status(404).json({ error: 'Análise não encontrada ou não pertence ao usuário.' }); } if (imageRes.rows.length > 0 && imageRes.rows[0].image_url) { const imgUrl = imageRes.rows[0].image_url; if (imgUrl.startsWith('/generated-media/')) { const fs = require('fs'); const filename = imgUrl.replace('/generated-media/', ''); const filepath = path.join(__dirname, 'public', 'generated-media', filename); if (fs.existsSync(filepath)) { try { fs.unlinkSync(filepath); } catch (unlinkErr) { console.error('Erro ao deletar arquivo de imagem fisicamente:', unlinkErr); } } } } res.json({ success: true, message: 'Análise de desenho deletada com sucesso.' }); } catch (err) { console.error('Erro ao deletar garatuja:', err); res.status(500).json({ error: 'Erro ao deletar análise.' }); } }); // ============================================================ // ROTAS DO BRINCARATIVO (CIRCUITOS PSICOMOTORES E BRINCADEIRAS) // ============================================================ app.post('/api/brincar-ativo/generate', requireAuth, async (req, res) => { const { faixaEtaria, recursos } = req.body; if (!faixaEtaria) { return res.status(400).json({ error: 'A faixa etária é obrigatória.' }); } if (!recursos) { return res.status(400).json({ error: 'A descrição dos recursos é obrigatória.' }); } try { const prompt = `Você é um educador físico e pedagogo especializado na Educação Infantil (crianças de 0 a 6 anos) de acordo com a BNCC no Brasil. Gere uma proposta de circuito psicomotor ou brincadeira ativa adequada para a seguinte faixa etária e com os recursos disponíveis: Faixa Etária: ${faixaEtaria} Recursos e Materiais Disponíveis: ${recursos} Instruções para o conteúdo: 1. Dê um título lúdico e atraente para a atividade. 2. Especifique os Objetivos de Aprendizagem e Desenvolvimento da BNCC (incluindo códigos, como EI01CG02, EI02CG05, etc.). 3. Descreva o Passo a Passo detalhado de como montar o circuito/brincadeira no espaço e como guiar as crianças. 4. Diga o que o professor deve observar durante a atividade para avaliar o desenvolvimento psicomotor dos alunos. Retorne a resposta estritamente em Markdown estruturado: # Atividade: [Nome Lúdico] **Faixa Etária:** ${faixaEtaria} **Campos de Experiência (BNCC):** [Campos e Códigos] ## 📋 Objetivos [Objetivos] ## 🛠️ Como Montar e Conduzir [Passo a passo] ## 🔍 O que Observar (Avaliação Formativa) [Dicas de observação]`; const aiResponse = await callMinimax({ system: "Você é um especialista em educação física e psicomotricidade na educação infantil brasileira.", messages: [{ role: 'user', content: prompt }], temperature: 0.5, max_tokens: 3000 }); const atividade = aiResponse.text || ''; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; const dbRes = await dbPool.query( `INSERT INTO escola.brincar_ativo (usuario_id, faixa_etaria, recursos, atividade) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [usuarioId, faixaEtaria, recursos, atividade] ); res.json({ success: true, id: dbRes.rows[0].id, faixaEtaria, recursos, atividade, createdAt: dbRes.rows[0].created_at }); } catch (err) { console.error('Erro no BrincarAtivo:', err); res.status(500).json({ error: 'Erro ao gerar atividade com a IA: ' + err.message }); } }); app.get('/api/brincar-ativo/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, faixa_etaria as "faixaEtaria", recursos, created_at as "createdAt" FROM escola.brincar_ativo WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes.rows); } catch (err) { console.error('Erro ao listar atividades:', err); res.status(500).json({ error: 'Erro ao obter lista de atividades.' }); } }); app.get('/api/brincar-ativo/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, faixa_etaria as "faixaEtaria", recursos, atividade, created_at as "createdAt" FROM escola.brincar_ativo WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rows.length === 0) { return res.status(404).json({ error: 'Atividade não encontrada.' }); } res.json(dbRes.rows[0]); } catch (err) { console.error('Erro ao buscar atividade:', err); res.status(500).json({ error: 'Erro ao buscar atividade.' }); } }); app.delete('/api/brincar-ativo/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `DELETE FROM escola.brincar_ativo WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rowCount === 0) { return res.status(404).json({ error: 'Atividade não encontrada ou não pertence ao usuário.' }); } res.json({ success: true, message: 'Atividade deletada com sucesso.' }); } catch (err) { console.error('Erro ao deletar brincar-ativo:', err); res.status(500).json({ error: 'Erro ao deletar atividade.' }); } }); // ============================================================ // ============================================================ // ROTAS DO ESTÚDIO DE POESIAS (ESTÚDIO PROFISSIONAL) // ============================================================ app.post('/api/poesias/generate', requireAuth, async (req, res) => { const { faixaEtaria, estrofes, tema, estrutura, estilo, voz, turmaId, gerarAudio } = req.body; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; if (!tema) { return res.status(400).json({ error: 'O tema da poesia é obrigatório.' }); } try { const agentConfig = readAgentConfig(); const agentName = agentConfig.agentName || "PedagogIA"; const promptPoesia = `Você é a ${agentName}, poetisa, linguista e assistente pedagógica da professora Camila Martella Gasparini Reifonas. A professora Camila deseja compor uma poesia pedagógica para seus alunos na Educação Infantil. PARÂMETROS DA POESIA: - Tema / Ideia Central: "${tema}" - Faixa Etária: "${faixaEtaria || 'Crianças pequenas (4 a 5 anos)'}" - Quantidade de Estrofes: ${estrofes || 3} - Estrutura Poética: "${estrutura || 'Quadras (Rimas A-B-A-B)'}" - Estilo Pedagógico: "${estilo || 'Lúdico-cantado e sonoro'}" INSTRUÇÕES DE COMPOSIÇÃO: 1. Componha uma poesia envolvente, com ritmo marcante, musicalidade e rimas expressivas adequadas para a idade informada. 2. Realize uma análise literária detalhada com estimativas percentuais (0-100%) para: - Rima (precisão das terminações sonoras) - Ritmo (cadência e compasso dos versos) - Aliteração (repetição harmoniosa de sons consonantais) - Musicalidade (sonoridade geral) 3. Crie uma versão com guia fonético / marcação de leitura (com acentos tônicos em negrito e marcações de [pausa] para a leitura em voz alta). 4. Mapeie o campo de experiência da BNCC e as habilidades trabalhadas (ex: EI02EF01 / EI03EF02 - Escuta, Fala, Pensamento e Imaginação). 5. Forneça uma vivência pedagógica prática (dica para a professora Camila conduzir sarau, gestos ou dinâmicas). Retorne ESTRITAMENTE um JSON válido (sem texto fora do JSON): { "titulo": "Título Criativo do Poema", "poesia": "Texto completo da poesia com quebras de linha e separação de estrofes.", "esquemaRima": "A-B-A-B", "analiseLiteraria": { "rima": 85, "ritmo": 80, "aliteracao": 65, "musicalidade": 90, "explicacaoMetrica": "Versos regulares com cadência agradável, rimas cruzadas e sonoridade envolvente para contação de histórias." }, "versaoFonetica": "Versão da poesia com sílabas tônicas marcadas e [pausa] para orientação de leitura em sala de aula.", "bnccCampos": "EI02EF01 / EI03EF02 • Escuta, Fala, Pensamento e Imaginação", "dicasPedagogicas": "Sugestão de 2 a 3 linhas para a professora aplicar com as crianças em sala." }`; let jsonResponseText = ''; // 1. Tentar MiniMax M3 try { console.log('[Poesia] Compondo poesia com MiniMax M3...'); const r = await callMinimax({ system: "Você é uma poetisa infantil e linguista pedagógica de excelência no Brasil. Responda estritamente em formato JSON válido.", messages: [{ role: 'user', content: promptPoesia }], temperature: 0.7, max_tokens: 1800 }); jsonResponseText = r.text; } catch (err) { console.error('[Poesia] Erro MiniMax M3:', err.message); } // 2. Fallback Groq if (!jsonResponseText && process.env.GROQ_API_KEY) { try { console.log('[Poesia] Fallback Groq...'); const r = await fetch('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.GROQ_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: process.env.GROQ_MODEL || 'openai/gpt-oss-120b', messages: [{ role: 'user', content: promptPoesia }], temperature: 0.7, max_tokens: 1800, response_format: { type: 'json_object' } }) }); if (r.ok) { const data = await r.json(); jsonResponseText = data.choices?.[0]?.message?.content || ''; } } catch (err) { console.error('[Poesia] Erro Groq:', err.message); } } // 3. Fallback OpenRouter if (!jsonResponseText && process.env.OPENROUTER_API_KEY) { try { console.log('[Poesia] Fallback OpenRouter...'); const r = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano', messages: [{ role: 'user', content: promptPoesia }], temperature: 0.7, max_tokens: 1800 }) }); if (r.ok) { const data = await r.json(); jsonResponseText = data.choices?.[0]?.message?.content || ''; } } catch (err) { console.error('[Poesia] Erro OpenRouter:', err.message); } } // 4. Fallback Google Gemini if (!jsonResponseText && process.env.GOOGLE_AI_API_KEY) { try { console.log('[Poesia] Fallback Gemini...'); const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`; const response = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: promptPoesia }] }], generationConfig: { temperature: 0.7, maxOutputTokens: 2000 } }) }); if (response.ok) { const resData = await response.json(); jsonResponseText = resData.candidates?.[0]?.content?.parts?.[0]?.text || ''; } } catch (err) { console.error('[Poesia] Erro Gemini:', err.message); } } // Parse JSON let poesiaData = {}; try { const cleanJson = jsonResponseText.replace(/```json|```/g, '').trim(); poesiaData = JSON.parse(cleanJson); } catch (parseErr) { console.warn('[Poesia] Erro parse JSON, usando fallback texto:', parseErr.message); poesiaData = { titulo: `Poema: ${tema}`, poesia: jsonResponseText || `Na escola a gente brinca,\nNa escola a gente aprende,\nCom ${tema} todo dia,\nNosso coração se acende!`, esquemaRima: "A-B-C-B", analiseLiteraria: { rima: 80, ritmo: 75, aliteracao: 60, musicalidade: 85, explicacaoMetrica: "Métrica ritmada ideal para contação infantil e memorização em roda." }, versaoFonetica: `Na es**co**la a gente **brin**ca, [pausa]\nNa es**co**la a gente a**pren**de, [pausa]\nCom ${tema} todo **di**a, [pausa]\nNosso cora**ção** se a**cen**de!`, bnccCampos: "EI02EF01 / EI03EF02 • Escuta, Fala, Pensamento e Imaginação", dicasPedagogicas: "Faça gestos de mãos no peito e palmas suaves ao recitar as rimas finais." }; } // GERAÇÃO DE ÁUDIO TTS / RECITADO (MiniMax) let audioUrl = null; if (gerarAudio !== false && process.env.MINIMAX_API_KEY) { try { console.log('[Poesia] Gerando áudio recitado com MiniMax...'); let voiceDesc = 'sweet gentle native brazilian female teacher voice reciting warm nursery poem, calm clear tempo'; if (voz === 'homem') { voiceDesc = 'warm friendly native brazilian male educator voice storytelling poem, soft and clear'; } else if (voz === 'crianca') { voiceDesc = 'cute enthusiastic young brazilian child voice reciting poetry, joyful'; } const audioPrompt = `Spoken poetry recitation, ${voiceDesc}, clear pronunciation, acoustic ambient background`; const lyricsToRecite = (poesiaData.poesia || tema).replace(/[*#]/g, '').trim(); const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); const musicResp = await fetch(`${minmBase}/v1/music_generation`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'music-2.0', prompt: audioPrompt, lyrics: lyricsToRecite.substring(0, 500), audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' } }) }); if (musicResp.ok) { const mData = await musicResp.json(); const audioHex = mData.data?.audio; if (audioHex) { 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 fileName = `poesia_${Date.now()}.mp3`; const filePath = path.join(mediaDir, fileName); const audioBuffer = Buffer.from(audioHex, 'hex'); fs.writeFileSync(filePath, audioBuffer); await backupMediaFile(filePath, audioBuffer); audioUrl = `/generated-media/${fileName}`; } } } catch (audioErr) { console.error('[Poesia] Erro ao gerar áudio recitado:', audioErr.message); } } // Salvar no Banco PostgreSQL let savedId = null; try { const dbRes = await dbPool.query( `INSERT INTO escola.poesias ( usuario_id, faixa_etaria, estrofes, tema, poesia, estrutura, estilo, audio_url, analise_metrica, versao_fonetica, bncc, turma_id, dicas ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [ usuarioId, faixaEtaria || 'Crianças pequenas', parseInt(estrofes) || 3, tema, poesiaData.poesia || '', estrutura || 'Quadras', estilo || 'Lúdico-cantado', audioUrl, JSON.stringify(poesiaData.analiseLiteraria || {}), poesiaData.versaoFonetica || '', poesiaData.bnccCampos || '', turmaId || null, poesiaData.dicasPedagogicas || '' ] ); savedId = dbRes.rows[0]?.id; } catch (dbErr) { console.warn('[Poesia] Erro ao salvar colunas estendidas, tentando salvar básico:', dbErr.message); try { const dbRes2 = await dbPool.query( `INSERT INTO escola.poesias (usuario_id, faixa_etaria, estrofes, tema, poesia) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [usuarioId, faixaEtaria || 'Crianças', parseInt(estrofes) || 3, tema, poesiaData.poesia || ''] ); savedId = dbRes2.rows[0]?.id; } catch (err2) { console.error('[Poesia] Erro ao salvar poesia:', err2.message); } } res.json({ success: true, id: savedId, titulo: poesiaData.titulo || `Poema: ${tema}`, poesia: poesiaData.poesia || '', esquemaRima: poesiaData.esquemaRima || 'A-B-A-B', analiseLiteraria: poesiaData.analiseLiteraria || { rima: 80, ritmo: 80, aliteracao: 60, musicalidade: 85 }, versaoFonetica: poesiaData.versaoFonetica || '', bnccCampos: poesiaData.bnccCampos || 'EI02EF01 / EI03EF02 • Escuta, Fala, Pensamento e Imaginação', dicasPedagogicas: poesiaData.dicasPedagogicas || '', audioUrl: audioUrl, tema: tema, faixaEtaria: faixaEtaria, estrutura: estrutura, estilo: estilo }); } catch (err) { console.error('Erro geral no Estúdio de Poesia:', err); res.status(500).json({ error: 'Erro ao compor poesia: ' + err.message }); } }); app.get('/api/poesias/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, faixa_etaria, estrofes, tema, audio_url, created_at FROM escola.poesias WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes.rows); } catch (err) { try { const dbRes2 = await dbPool.query( `SELECT id, faixa_etaria, estrofes, tema, created_at FROM escola.poesias WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes2.rows); } catch (err2) { res.status(500).json({ error: 'Erro ao listar histórico de poesias.' }); } } }); app.get('/api/poesias/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT * FROM escola.poesias WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rows.length === 0) { return res.status(404).json({ error: 'Poesia não encontrada.' }); } res.json(dbRes.rows[0]); } catch (err) { res.status(500).json({ error: 'Erro ao buscar poesia.' }); } }); app.delete('/api/poesias/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `DELETE FROM escola.poesias WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rowCount === 0) { return res.status(404).json({ error: 'Poesia não encontrada.' }); } res.json({ success: true, message: 'Poesia deletada com sucesso.' }); } catch (err) { res.status(500).json({ error: 'Erro ao deletar poesia.' }); } }); // ============================================================ // ROTAS DO CRIAR CARTAZES // ============================================================ app.post('/api/cartazes/generate', requireAuth, async (req, res) => { const { titulo, tema, corFundo, corTexto, layout, promptImagem, tamanho, orientacao } = req.body; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; if (!titulo || !tema || !layout) { return res.status(400).json({ error: 'Título, tema e layout são obrigatórios.' }); } try { const agentConfig = readAgentConfig(); const agentName = agentConfig.agentName || "PedagogIA"; // 1. Gerar Conteúdo do Cartaz const posterPrompt = `Você é a ${agentName}, assistente pedagógica da professora Camila Martella Gasparini Reifonas. A professora Camila quer criar um Cartaz para sala de aula da Educação Infantil. Detalhes do Cartaz: - Título: "${titulo}" - Tema / Foco Pedagógico: "${tema}" - Tipo de Cartaz: "${layout}" (educativo, informativo, conscientizacao, cultural) - Tamanho: "${tamanho || 'A4'}" - Orientação: "${orientacao === 'paisagem' ? 'Paisagem/Horizontal' : 'Retrato/Vertical'}" Por favor, gere o CONTEÚDO textual e estrutural do cartaz em HTML. REGRAS DE IDIOMA E LOCALIZAÇÃO: - O conteúdo gerado deve ser IMPRETERIVELMENTE em português do Brasil (pt-BR). Não use termos em outros idiomas de forma desnecessária. REGRAS FUNDAMENTAIS E ESTÉTICAS DE DIAGRAMAÇÃO: - O cartaz deve caber ESTRITAMENTE em UMA ÚNICA PÁGINA FÍSICA (Tamanho ${tamanho || 'A4'}, orientação ${orientacao === 'paisagem' ? 'Horizontal/Paisagem' : 'Vertical/Retrato'}). Não extrapole este espaço de forma alguma. - Limite a quantidade de conteúdo: gere no máximo 2 ou 3 cards (divs de conteúdo) no total. Cada card deve ter pouquíssimo texto (no máximo 3 ou 4 itens curtos por card). - Distribua a informação em CARDS (pequenos cartões ou blocos curtos) bem espaçados, parecidos com pôsteres infantis modernos, onde o texto NÃO ocupa uma folha corrida. - Os textos devem ser extremamente curtos, diretos, visuais e fáceis de ler. - Use emojis apropriados para crianças. - Se for "educativo", estruture o conteúdo pedagógico de forma visual (ex: caixas para alfabeto, tabuada, regras gramaticais). - Se for "informativo", destaque avisos gerais e orientações (ex: horários, cardápio, provas) em caixas curtas e legíveis. - Se for "conscientizacao", use frases curtas de impacto sobre campanhas (ex: prevenção, saúde, coleta seletiva), acompanhadas de dicas em pequenos blocos. - Se for "cultural", promova a atividade de lazer, esporte ou feira destacando o evento de forma animada e pontual. - Considere a orientação ${orientacao === 'paisagem' ? 'Paisagem (horizontal)' : 'Retrato (vertical)'} ao organizar o fluxo: para paisagem, os cards devem ficar lado a lado (2 colunas horizontais); para retrato, eles se empilham verticalmente. - Retorne apenas o formato HTML (use
,

,