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; // Tentativa 1: MiniMax M3 com timeout de 45s try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 45000); 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 }), signal: controller.signal }); clearTimeout(timeoutId); if (res.ok) { const data = await res.json(); const text = (data.content || []) .filter(b => b.type === 'text') .map(b => b.text) .join('') .trim(); if (text) return { text, raw: data, provider: 'minimax' }; } else { const txt = await res.text(); console.warn(`[AI Text] MiniMax respondeu ${res.status}: ${txt}. Acionando fallback...`); } } catch (err) { console.warn('[AI Text] Falha/Timeout no MiniMax (' + err.message + '). Acionando fallback...'); } // Fallback 1: Groq (Llama 3.3 70b / Llama 3.1 8b) if (process.env.GROQ_API_KEY) { try { console.log('[AI Text Fallback] Acionando Groq...'); const groqMessages = system ? [{ role: 'system', content: system }, ...messages] : messages; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const res = 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: 'llama-3.3-70b-versatile', messages: groqMessages, temperature, max_tokens }), signal: controller.signal }); clearTimeout(timeoutId); if (res.ok) { const data = await res.json(); const text = data.choices?.[0]?.message?.content?.trim(); if (text) return { text, raw: data, provider: 'groq' }; } } catch (groqErr) { console.warn('[AI Text Fallback] Falha no Groq:', groqErr.message); } } // Fallback 2: Google AI Gemini if (process.env.GOOGLE_AI_API_KEY) { try { console.log('[AI Text Fallback] Acionando Google AI Gemini...'); const geminiModel = process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'; const promptText = (system ? `System: ${system}\n\n` : '') + messages.map(m => `${m.role}: ${m.content}`).join('\n'); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: promptText }] }], generationConfig: { temperature, maxOutputTokens: max_tokens } }), signal: controller.signal }); clearTimeout(timeoutId); if (res.ok) { const data = await res.json(); const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim(); if (text) return { text, raw: data, provider: 'gemini' }; } } catch (gemErr) { console.warn('[AI Text Fallback] Falha no Gemini:', gemErr.message); } } throw new Error('Todas as IAs de geração de texto estão indisponíveis no momento. Por favor, tente novamente.'); } 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'); } } }; // Handlers globais para evitar crash do processo Node.js por falhas transitórias de conexão process.on('uncaughtException', (err) => { console.error('[CRITICAL] Exceção não capturada no processo (evitado crash):', err); }); process.on('unhandledRejection', (reason, promise) => { console.error('[CRITICAL] Rejeição de Promise não tratada (evitado crash):', reason); }); const fs = require('fs'); const { Pool } = require('pg'); const dbPool = new Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 10000, idleTimeoutMillis: 30000, max: 25 }); dbPool.on('error', (err) => { console.error('[Database Pool Error] Erro inesperado em cliente de banco (não fatal):', err.message); }); 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'; // Timeout para não travar em caso de lentidão temporária do banco const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout no backup de mídia')), 5000)); const queryPromise = 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] ); await Promise.race([queryPromise, timeoutPromise]); console.log(`[Media Backup] Backup do arquivo ${filename} salvo no banco escola.arquivos.`); } catch (err) { console.warn(`[Media Backup] Aviso ao fazer backup do arquivo ${filePath} no banco:`, err.message); } } 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' }); } } }); // ============================================================================ // HELPER: Síntese Musical Pedagógica com Voz Melódica, Canto & Arranjo Instrumental // ============================================================================ async function synthesizePedagogicalSong({ lyrics, ritmo = 'Cantiga de Roda', tema = '', voz = 'mulher', instrumentos = '' }) { const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } // 1. Limpar a letra de marcações extras de texto e formatar métrica rítmica let cleanLyrics = (lyrics || tema || 'Vamos cantar e aprender!') .replace(/\(.*?\)/g, '') .replace(/\[.*?\]/g, '') .replace(/[#*]/g, '') .trim(); // Ajustar pontuação para cadência musical e respiração rítmica const rawLines = cleanLyrics.split('\n').map(l => l.trim()).filter(l => l.length > 0); if (rawLines.length > 0) { cleanLyrics = rawLines .map(line => { if (!/[.!?,]$/.test(line)) return line + '!'; return line; }) .join(' '); } // 2. Configurações de voz e afinação (Pitch / Rate) para canto autêntico let voiceName = 'pt-BR-FranciscaNeural'; let ttsOptions = { pitch: '+8%', rate: '+4%' }; let isChildVoice = false; let isChoir = false; const vozNorm = (voz || 'mulher').toLowerCase(); if (vozNorm === 'crianca' || vozNorm === 'menino' || vozNorm === 'ludica') { voiceName = 'pt-BR-ThalitaNeural'; // Afinação e modulação infantil real (timbre agudo, alegre e infantilizado) ttsOptions = { pitch: '+44%', rate: '+6%' }; isChildVoice = true; } else if (vozNorm === 'menina' || vozNorm === 'doce') { voiceName = 'pt-BR-ThalitaNeural'; // Timbre infantil meigo e doce ttsOptions = { pitch: '+50%', rate: '+4%' }; isChildVoice = true; } else if (vozNorm === 'coral' || vozNorm === 'criancas' || vozNorm === 'coro') { voiceName = 'pt-BR-ThalitaNeural'; // Coral infantil polifônico ttsOptions = { pitch: '+42%', rate: '+5%' }; isChildVoice = true; isChoir = true; } else if (vozNorm === 'homem' || vozNorm === 'professor') { voiceName = 'pt-BR-AntonioNeural'; ttsOptions = { pitch: '+0%', rate: '+2%' }; } else { // Mulher / Professora cantora voiceName = 'pt-BR-FranciscaNeural'; ttsOptions = { pitch: '+8%', rate: '+4%' }; } const tts = new MsEdgeTTS(); await tts.setMetadata(voiceName, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); const voiceTempPath = path.join(mediaDir, `voice_temp_${Date.now()}_${Math.floor(Math.random() * 10000)}.mp3`); // 3. Sintetizar stream vocal da cantiga com prosódia musical const { audioStream } = await tts.toStream(cleanLyrics, ttsOptions); await new Promise((resolve, reject) => { const ws = fs.createWriteStream(voiceTempPath); audioStream.pipe(ws); ws.on('finish', resolve); ws.on('error', reject); audioStream.on('error', reject); }); // 4. Seleção da melhor trilha instrumental temática let bgTrack = 'alegre.mp3'; const rLower = (ritmo + ' ' + tema + ' ' + instrumentos).toLowerCase(); if (rLower.includes('calm') || rLower.includes('ninar') || rLower.includes('suave') || rLower.includes('berço') || rLower.includes('dormir') || rLower.includes('naninha')) { bgTrack = 'calma.mp3'; } else if (rLower.includes('circo') || rLower.includes('palha')) { bgTrack = 'circo.mp3'; } else if (rLower.includes('festa') || rLower.includes('balão') || rLower.includes('pipoca') || rLower.includes('dançante') || rLower.includes('pop')) { bgTrack = 'festa.mp3'; } else if (rLower.includes('bichinho') || rLower.includes('animal') || rLower.includes('fazenda') || rLower.includes('horta') || rLower.includes('planta')) { bgTrack = 'animais.mp3'; } else if (rLower.includes('brinquedo') || rLower.includes('fantasia') || rLower.includes('bonec')) { bgTrack = 'brinquedos.mp3'; } else if (rLower.includes('amig') || rLower.includes('escola') || rLower.includes('roda') || rLower.includes('cordão')) { bgTrack = 'amizade.mp3'; } else if (rLower.includes('aventura') || rLower.includes('marcha') || rLower.includes('soldad')) { bgTrack = 'aventura.mp3'; } let bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', bgTrack); if (!fs.existsSync(bgMusicPath)) { bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3'); } const outFileName = `musica_${Date.now()}_${Math.floor(Math.random() * 10000)}.mp3`; const outFilePath = path.join(mediaDir, outFileName); // 5. Cadeia de Efeitos Vocais Musicais com FFmpeg (Harmonia, Micro-Vibrato, Chorus de Estúdio, Reverb Espacial e Mixagem) let vocalFilter = ''; if (isChoir) { // Efeito Coral Infantil de Sala de Aula (Múltiplas vozes de crianças em uníssono) vocalFilter = '[0:a]volume=1.35,highpass=f=130,treble=g=4:f=3600,vibrato=f=4.5:d=0.18,chorus=0.6:0.9:40|55|70:0.4|0.3|0.25:0.25|0.2|0.18:3,aecho=0.8:0.7:35|55:0.25|0.18[v]'; } else if (isChildVoice) { // Voz infantil solo: cristalina, afinada, com micro-vibrato musical e chorus duplo de estúdio vocalFilter = '[0:a]volume=1.32,highpass=f=140,treble=g=4:f=3800,vibrato=f=4.2:d=0.15,chorus=0.7:0.9:42:0.35:0.25:2,aecho=0.8:0.7:30|50:0.22|0.15[v]'; } else { // Voz adulta (Professora / Professor): vocal de estúdio com brilho e reverberação acústica vocalFilter = '[0:a]volume=1.28,highpass=f=100,treble=g=2.5:f=3200,vibrato=f=3.8:d=0.12,chorus=0.7:0.9:45:0.3:0.22:2,aecho=0.8:0.7:32|52:0.22|0.15[v]'; } const bgFilter = '[1:a]volume=0.38[bg]'; const mixFilter = '[v][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]'; if (fs.existsSync(bgMusicPath) && fs.existsSync(voiceTempPath)) { await new Promise((resolve) => { const ffmpegCmd = `ffmpeg -y -i "${voiceTempPath}" -stream_loop -1 -i "${bgMusicPath}" -filter_complex "${vocalFilter};${bgFilter};${mixFilter}" -map "[aout]" -c:a libmp3lame -b:a 128k "${outFilePath}"`; exec(ffmpegCmd, (err) => { if (err) console.warn('[SynthesizeSong] Erro FFmpeg mix:', err.message); try { if (fs.existsSync(voiceTempPath)) fs.unlinkSync(voiceTempPath); } catch (e) {} resolve(); }); }); } else if (fs.existsSync(voiceTempPath)) { fs.renameSync(voiceTempPath, outFilePath); } if (fs.existsSync(outFilePath)) { const buf = fs.readFileSync(outFilePath); backupMediaFile(outFilePath, buf).catch(() => {}); return `/generated-media/${outFileName}`; } return null; } // 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 tamanho das letras baseado na duração (duracao) let lengthInstructions = 'curta, de apenas 6 a 8 versos cantados'; if (duracao === '1min') { lengthInstructions = 'média, de 12 a 16 versos cantados (com verso 1, refrão marcante, verso 2 e refrão final)'; } else if (duracao === '2min') { lengthInstructions = 'completa e longa, de 20 a 28 versos cantados (com estrutura completa: verso 1, refrão, verso 2, refrão, ponte e refrão final)'; } // 2. Chamar a IA para compor a letra musicalmente rimada e rítmica const lyricsPrompt = `Componha uma letra de CANTIGA INFANTIL CANTO E RITMO sobre o tema: "${tema}". IMPORTANTE PARA A MUSICALIDADE E CANTO: - Estrutura de versos curtos e fáceis de cantar (4 a 6 palavras por verso), com métrica rítmica e rimas sonoras adequadas para Educação Infantil. - Inclua refrão cantável, alegre e cativante com exclamações musicais (ex: "Lá, lá, lá!", "Vem brincar!", "Bate palminha!"). - Tamanho: ${lengthInstructions}. - Retorne APENAS os versos da letra da música para serem cantados. Não coloque títulos, numerações ou anotações entre parênteses.`; const aiResponse = await callMinimax({ system: "Você é um compositor musical infantil brasileiro de excelência. Escreva letras de cantigas que sejam naturalmente ritmadas e cantáveis para crianças da Educação Infantil. Retorne estritamente os versos cantados da música, sem introduções ou explicações.", 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.'); } // 3. Sintetizar a canção com arranjo instrumental completo e vocal de canto console.log('[Estúdio Musical] Sintetizando música infantil com arranjo instrumental e voz melódica...'); const audioUrl = await synthesizePedagogicalSong({ lyrics, ritmo: ritmo || 'Cantiga de Roda', tema, voz: voz || 'mulher', instrumentos: 'Violão acústico, flauta e percussão' }); // 4. Salvar no Banco 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 || 'mulher', ritmo || 'roda', 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 // ============================================================ // Constantes do Estilo Visual Infantil (Meigo e Fofo - 2 a 6 anos) const CUTE_COMIC_STYLE_ANCHOR = "adorable cute 3D animated style, Pixar and modern claymation aesthetic, friendly and innocent character design, soft rounded features, big expressive friendly eyes, smooth textures, vibrant warm comforting color palette, whimsical storytelling, sweet and gentle atmosphere, perfect for toddlers and preschoolers (ages 2 to 6), clean studio lighting, 8k render"; const CUTE_COMIC_NEGATIVE_GUARDRAIL = "scary, horror, terrifying, creepy, ugly, grotesque, monster, sharp teeth, blood, dark gritty realism, sharp hyperrealistic skin, deformed, aggressive, mature, photorealistic"; // 1. Gerar o roteiro (script) dos quadrinhos usando MiniMax com Pipeline de Consistência 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 { const isContinuation = existingPanels && Array.isArray(existingPanels) && existingPanels.length > 0 && continuationType && continuationType !== 'new'; const startPanelNum = (existingPanels && Array.isArray(existingPanels)) ? existingPanels.length + 1 : 1; const systemPrompt = `Você é o Diretor de Arte Master e Roteirista do pipeline "ComicCharacterAndSceneConsistencyPipeline", especializado em histórias em quadrinhos pedagógicas e lúdicas para crianças pequenas de 2 a 6 anos (Maternal e Educação Infantil). SUA MISSÃO MANDATÓRIA: Garantir CONSISTÊNCIA VISUAL RIGOROSA E ABSOLUTA dos personagens e cenários ao longo de TODOS os quadros da história, mantendo sempre uma estética doce, meiga e encantadora. ESTILO VISUAL OBRIGATÓRIO (MEIGO E FOFO - IDADE 2 A 6 ANOS): - Estilo: Adorável animação 3D estilo Pixar misturado com modelagem macia claymation / Toy Art de vinil colecionável. - Design de Personagens: Rosto redondo muito fofo (proporções chibi/infantis), bochechas fofas e rosadas, olhos grandes e expressivos cheios de afeto e curiosidade, traços suaves e sem cantos pontiagudos. - REGRA DE OURO ANTI-TERROR / ANTI-GROTESCO: Crianças pequenas de 2 a 6 anos não podem ver figuras aterrorizantes! Vilões ou antagonistas (como bruxas, lobos, monstros ou animais selvagens) NUNCA devem ser assustadores, feios, com dentes afiados ou ogros. Eles devem ser SEMPRE estilizados como bonequinhos fofos, cômicos ou personagens inofensivos de conto de fadas infantil (ex: bruxinha fofa de chapéu pontudo com fivela e vestes roxas bonitas; lobinho simpático e atrapalhado). - Iluminação e Cores: Cores vibrantes, quentes, acolhedoras e mágicas, iluminação suave de estúdio, atmosfera segura e reconfortante. PIPELINE DE 3 FASES PARA GERAÇÃO: Fase 1 - Extração do DNA Visual (Visual DNA): - Identifique todos os personagens recorrentes. - Crie para CADA personagem uma ficha 'Visual DNA' hiper-específica e imutável em inglês: [Nome]: [Idade aproximada, traços físicos fofos, tipo e cor exata do cabelo com acessórios como laços/boné, peças de roupa exatas com suas cores fixas, calçados]. - Crie o 'Scene DNA' do cenário principal com elementos encantadores e cores suaves. Fase 2 - Construção Modular dos Prompts dos Quadros: - NUNCA use prompts curtos ou genéricos! - Cada 'image_prompt' DEVE ser montado estritamente pela fórmula: [Visual DNA dos Personagens presentes no quadro] + [Ação específica e expressão facial fofa] + [Scene DNA do Cenário] + [Estilo Global: "${CUTE_COMIC_STYLE_ANCHOR}"] Fase 3 - Diálogos e Falas: - Falas curtas, carinhosas e expressivas em português com no MÁXIMO 12 palavras por balão.`; let userPrompt = ''; if (isContinuation) { userPrompt = `Continue a história em quadrinhos pedagógica infantil com o tema: "${tema}". A história deve conter mais ${quantidade} novos quadrinhos dando continuidade cronológica a partir do quadro ${startPanelNum}. Baloes de falas: ${baloes ? 'Sim, falas curtas em português com no máximo 12 palavras.' : 'Não, história silenciosa.'} Proporção visual: ${proporcao}. Cenário/Local principal da nova fase: ${cenario || 'cenário encantador e colorido'}. DNA Visual prévio dos personagens que DEVE ser mantido idêntico: "${character_description || ''}". 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 || 'Ficha Visual DNA dos personagens em inglês'}", "scene_description": "Ficha Visual DNA do cenário em inglês", "panels": [ { "panel_number": ${startPanelNum}, "image_prompt": "Prompt em inglês seguindo a fórmula: [Visual DNA dos personagens presentes] + [Ação/expressão fofa] + [Scene DNA] + [Sufixo de estilo: ${CUTE_COMIC_STYLE_ANCHOR}]", "dialogue": "Falas do quadrinho em português com no máximo 12 palavras." } ] } Responda APENAS o JSON válido.`; } else { userPrompt = `Crie uma história em quadrinhos pedagógica infantil com o tema: "${tema}". Quantidade de quadrinhos: exatamente ${quantidade}. Baloes de falas: ${baloes ? 'Sim, falas curtas em português com no máximo 12 palavras.' : 'Não, história silenciosa.'} Proporção visual: ${proporcao}. Personagens informados pelo usuário: ${JSON.stringify(personagens || [])}. Cenário principal: ${cenario || 'ambiente mágico, ensolarado e acolhedor'}. Gere um JSON perfeitamente válido contendo: { "title": "Título Criativo e Encantador da História", "character_description": "Ficha Visual DNA completa em inglês de CADA personagem (ex: Little Boy: cute 5-year-old toddler boy with chubby cheeks, messy short brown hair, wearing a red backward cap, bright blue short-sleeved t-shirt, khaki shorts, brown soft boots. Little Girl: cute 4-year-old toddler girl with blonde hair in two sweet pigtails with red ribbon scrunchies, wearing a soft yellow dress with a white pinafore apron, white socks and red shoes. Friendly Witch: cute stylized cartoon witch doll, gentle smiling face, purple robe, cute black pointed hat with a gold buckle, not scary at all).", "scene_description": "Descrição Visual DNA detalhada do cenário principal em inglês.", "panels": [ { "panel_number": 1, "image_prompt": "Prompt COMPLETO em inglês seguindo a fórmula: [Visual DNA dos personagens presentes no quadro] + [Ação e expressão facial fofa do momento] + [Scene DNA do ambiente] + [Sufixo de estilo: ${CUTE_COMIC_STYLE_ANCHOR}]", "dialogue": "Falas do quadrinho em português com no máximo 12 palavras." } ] } Responda APENAS o JSON válido.`; } const r = await callMinimax({ system: systemPrompt, messages: [{ role: 'user', content: userPrompt }], temperature: 0.4, max_tokens: 3500 }); 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 }); } }); // 1b. Re-sincronizar o prompt visual de um quadrinho quando o texto/fala for alterado na regeneração app.post('/api/comics/resync-frame-prompt', requireAuth, async (req, res) => { const { currentDialogue, oldPrompt, character_description, cenario } = req.body; if (!currentDialogue || !currentDialogue.trim()) { return res.json({ image_prompt: oldPrompt }); } try { const prompt = `Você é o Diretor de Arte Master do pipeline ComicCharacterAndSceneConsistencyPipeline (estilo 3D Pixar/Claymation fofo para crianças de 2 a 6 anos). O diálogo/texto do quadrinho foi editado para: "${currentDialogue}". Ficha Visual DNA dos personagens: "${character_description || ''}". Cenário fixo da história: "${cenario || ''}". Prompt antigo da cena: "${oldPrompt || ''}". Re-escreva a descrição visual da cena (image_prompt) em inglês para o gerador de imagens mantendo RIGOROSA consistência: 1. Inclua o Visual DNA exato de quem estiver na cena (mesmas roupas, cabelos, cores e traços fofos). 2. NUNCA gere traços assustadores, monstros ou feiúra (sempre meigo, fofo e inocente para crianças pequenas). 3. Adapte a pose e expressão facial para combinar perfeitamente com a fala: "${currentDialogue}". 4. Conclua com o sufixo de estilo: "${CUTE_COMIC_STYLE_ANCHOR}". Retorne APENAS a nova descrição visual da cena em inglês, sem explicações.`; const r = await callMinimax({ messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 450 }); const newPrompt = r.text.trim(); res.json({ image_prompt: newPrompt || oldPrompt }); } catch (err) { console.warn('[Comics Resync] Falha ao re-sincronizar prompt, usando original:', err.message); res.json({ image_prompt: oldPrompt }); } }); // 2. Gerar uma imagem individual para um quadrinho usando MiniMax image-01 com Fallback Automático app.post('/api/comics/generate-frame', requireAuth, async (req, res) => { const { prompt, proporcao } = req.body; if (!prompt) { return res.status(400).json({ error: 'O prompt de imagem é obrigatório.' }); } 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 }); } let imageBuffer = null; let providerUsed = 'minimax'; // Assegurar que o prompt contenha o estilo fofo let finalPrompt = prompt; if (!finalPrompt.toLowerCase().includes('claymation') && !finalPrompt.toLowerCase().includes('pixar')) { finalPrompt = `${finalPrompt}, ${CUTE_COMIC_STYLE_ANCHOR}`; } // Tentativa 1: MiniMax image-01 com timeout de 38s try { const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); const apiKey = process.env.MINIMAX_API_KEY; if (apiKey) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 38000); const genResp = await fetch(`${minmBase}/v1/image_generation`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'image-01', prompt: finalPrompt, n: 1 }), signal: controller.signal }); clearTimeout(timeoutId); if (genResp.ok) { const genData = await genResp.json(); const imageUrl = genData.data?.image_urls?.[0]; if (imageUrl) { const imgFetch = await fetch(imageUrl); if (imgFetch.ok) { imageBuffer = Buffer.from(await imgFetch.arrayBuffer()); } } } else { const errText = await genResp.text(); console.warn('[Comics Image] MiniMax retornou erro, acionando fallback inteligente:', errText); } } } catch (minimaxErr) { console.warn('[Comics Image] Falha/Timeout no MiniMax image-01 (' + minimaxErr.message + '), acionando gerador fallback...'); } // Tentativa 2 (Fallback Resiliente Multi-Engine com Retries e Proteção de Estilo Fofo): if (!imageBuffer) { const is169 = proporcao === '16:9' || (prompt && prompt.includes('16:9')); const width = is169 ? 1280 : 1024; const height = is169 ? 720 : 768; const cleanPrompt = (finalPrompt || 'cute children book illustration').replace(/[^\w\s,.-]/gi, ''); const enhancedPrompt = encodeURIComponent(`${cleanPrompt}, adorable cute 3d pixar claymation style, soft rounded features, innocent children book illustration, vibrant colors, gentle atmosphere`); const negativeEncoded = encodeURIComponent(CUTE_COMIC_NEGATIVE_GUARDRAIL); const engines = ['flux', 'turbo']; for (const engine of engines) { if (imageBuffer) break; for (let attempt = 1; attempt <= 2; attempt++) { try { providerUsed = `pollinations-${engine}`; const seed = Math.floor(Math.random() * 1000000); const fallbackUrl = `https://image.pollinations.ai/prompt/${enhancedPrompt}?width=${width}&height=${height}&model=${engine}&nologo=true&seed=${seed}&negative=${negativeEncoded}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 25000); const fbResp = await fetch(fallbackUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (fbResp.ok) { imageBuffer = Buffer.from(await fbResp.arrayBuffer()); console.log(`[Comics Image] Imagem gerada com sucesso via Fallback (${providerUsed}, tentativa ${attempt}).`); break; } else { console.warn(`[Comics Image] Fallback ${providerUsed} tentativa ${attempt} respondeu ${fbResp.status}`); if (fbResp.status === 429) { await new Promise(r => setTimeout(r, 700 * attempt)); } } } catch (fbErr) { console.warn(`[Comics Image] Erro no fallback ${engine} tentativa ${attempt}:`, fbErr.message); } } } } if (!imageBuffer) { console.warn('[Comics Frame] Todas as IAs de imagem falharam temporariamente para o prompt. Gerando placeholder de fallback resiliente.'); const is169 = proporcao === '16:9' || (prompt && prompt.includes('16:9')); const w = is169 ? 1280 : 1024; const h = is169 ? 720 : 768; const svgFallback = ` 🎨 Quadro Pronto para Ilustrar Clique no botão 🔄 para gerar a imagem deste quadrinho `; imageBuffer = Buffer.from(svgFallback, 'utf-8'); providerUsed = 'fallback-placeholder'; } const fileExt = (providerUsed === 'fallback-placeholder') ? 'svg' : 'jpg'; const fileName = `quadrinho_${Date.now()}_${Math.floor(Math.random() * 1000)}.${fileExt}`; const filePath = path.join(mediaDir, fileName); try { fs.writeFileSync(filePath, imageBuffer); // Backup assíncrono não bloqueante backupMediaFile(filePath, imageBuffer).catch(err => { console.warn('[Comics Backup] Falha em background no backup da imagem:', err.message); }); res.json({ imageUrl: `/generated-media/${fileName}`, provider: providerUsed, needsRegeneration: providerUsed === 'fallback-placeholder' }); } catch (saveErr) { console.error('Erro ao salvar imagem localmente:', saveErr); res.status(500).json({ error: 'Erro ao persistir imagem gerada: ' + saveErr.message }); } }); // 3. Compilar a apresentação de vídeo usando FFmpeg a partir dos painéis de quadrinhos (Com Narração IA e Trilha Sonora) app.post('/api/comics/generate-video', requireAuth, async (req, res) => { const { frames, musicSelection, frameDuration, narrationVoice, titulo } = 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 baseDuration = frameDuration ? parseInt(frameDuration) : 5; const musicFile = musicSelection || 'alegre'; const voice = narrationVoice || 'none'; // 'none', 'mulher', 'homem' try { const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`; // 1. Mapear trilha musical entre as 22 opções disponíveis let bgMusicPath = null; if (musicFile && musicFile !== 'sem_musica') { const candidatePath = path.join(__dirname, 'public', 'assets', 'audio', `${musicFile}.mp3`); if (fs.existsSync(candidatePath)) { bgMusicPath = candidatePath; } else { const fallbackPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3'); if (fs.existsSync(fallbackPath)) bgMusicPath = fallbackPath; } } // Estrutura para armazenar tempos e caminhos dos áudios de narração const panelDurations = []; const narrationAudioFiles = []; // 2. Se a narração estiver ativada (mulher ou homem) if (voice === 'mulher' || voice === 'homem') { const voiceName = voice === 'homem' ? 'pt-BR-AntonioNeural' : 'pt-BR-FranciscaNeural'; console.log(`[Video Comics] Gerando narração infantil carinhosa por IA (${voiceName})...`); // Gerar textos narrativos adaptados por IA para acompanhar a duração e fechar com carinho no final let narrations = []; try { const narrPrompt = `Você é uma contadora de histórias e pedagoga especialista em audiolivros para crianças pequenas de 2 a 6 anos. Abaixo estão as falas e descrições dos quadros de uma História em Quadrinhos infantil: Título: "${titulo || 'História em Quadrinhos'}" ${frames.map((f, idx) => `Quadro ${idx + 1}: Fala/Diálogo: "${f.dialogue || ''}" | Cena: "${f.image_prompt || ''}"`).join('\n')} Crie um texto de narração falado, acolhedor, doce e expressivo em português do Brasil (pt-BR) para CADA QUADRO. - Linguagem carinhosa e afetuosa para crianças pequenas (2 a 6 anos). - O texto de cada quadro deve ser fluido e natural para leitura em voz alta. - O ÚLTIMO QUADRO deve fechar a história com uma frase carinhosa de conclusão moral/pedagógica. - Retorne estritamente um JSON no formato: { "narrations": [ { "panel_index": 0, "text": "Texto da narração do quadro 1..." } ] }`; const aiResp = await callMinimax({ messages: [{ role: 'user', content: narrPrompt }], temperature: 0.6, 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 narrativo por IA, usando diálogos originais:', aiErr.message); } // Sintetizar o áudio TTS de cada quadro via Stream direta e segura 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 tipo "João: ..." narrText = narrText.replace(/^[A-Za-zÀ-ÖØ-öø-ÿ\s]+:\s*/, '').trim(); if (!narrText) narrText = `Era uma vez, no quadro ${i + 1}.`; const audioPath = path.join(mediaDir, `narr_${tempFileId}_p${i}.mp3`); try { const { audioStream } = tts.toStream(narrText); await new Promise((resolve, reject) => { const ws = fs.createWriteStream(audioPath); audioStream.pipe(ws); ws.on('finish', resolve); ws.on('error', reject); audioStream.on('error', reject); }); if (fs.existsSync(audioPath) && fs.statSync(audioPath).size > 300) { narrationAudioFiles.push(audioPath); // Calcular duração estimada do áudio (~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); console.log(`[Video Comics] Narração gerada com sucesso para quadro ${i + 1} (${audioSecs}s)`); } else { console.warn(`[Video Comics] Áudio TTS vazio ou corrompido para quadro ${i + 1}`); panelDurations.push(baseDuration); } } catch (ttsErr) { console.warn(`[Video Comics] Falha TTS quadro ${i + 1}:`, 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 cleanRelPath = (frame.imageUrl || frame.image_url || '').replace(/^\/+/, ''); const localFilePath = path.join(__dirname, 'public', cleanRelPath); concatContent += `file '${localFilePath}'\nduration ${panelDurations[idx]}\n`; }); const lastRelPath = (frames[frames.length - 1].imageUrl || frames[frames.length - 1].image_url || '').replace(/^\/+/, ''); const lastLocalFilePath = path.join(__dirname, 'public', lastRelPath); concatContent += `file '${lastLocalFilePath}'\n`; 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 outputFilePath = path.join(mediaDir, outputFileName); const totalDuration = panelDurations.reduce((a, b) => a + b, 0); let ffmpegCommand = ''; if (concatAudioPath && bgMusicPath) { // Narração + Música de fundo (mixagem com volume da voz em 1.3 e música de fundo suave em 0.12) ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${concatAudioPath}" -stream_loop -1 -i "${bgMusicPath}" -filter_complex "[1:a]volume=1.3[narr];[2:a]volume=0.12[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}" -stream_loop -1 -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); exec(ffmpegCommand, (error, stdout, stderr) => { // Limpeza de arquivos temporários try { if (fs.existsSync(concatFilePath)) fs.unlinkSync(concatFilePath); if (concatAudioPath && fs.existsSync(concatAudioPath)) fs.unlinkSync(concatAudioPath); 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) { console.error('Erro ao processar FFmpeg:', error, stderr); return res.status(500).json({ error: 'Erro ao gerar o vídeo da apresentação.' }); } // Backup do vídeo gerado no banco em background (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.message); res.json([]); } }); // 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 || req.user?.id || '00000000-0000-0000-0000-000000000000'; const { id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, panels } = req.body; const safeTitulo = (titulo || '').trim() || 'Minha História em Quadrinhos'; const safeTema = (tema || '').trim() || safeTitulo; const safeCenario = (cenario || '').trim() || 'no parque de diversões colorido'; const safeProporcao = proporcao || '16:9'; const safeCharGlobal = typeof character_description_global === 'string' ? character_description_global : JSON.stringify(character_description_global || []); const safeCharEng = character_description_english || ''; let client = null; try { client = await dbPool.connect(); await client.query('BEGIN'); let projetoId = id; if (projetoId) { // Verificar se o projeto existe const check = await client.query( 'SELECT id FROM escola.projetos_comics WHERE id = $1;', [projetoId] ); if (check.rows.length === 0) { projetoId = null; // Não existe no banco, criar como novo } } if (projetoId) { // 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;`, [safeTitulo, safeTema, safeCenario, safeProporcao, safeCharGlobal, safeCharEng, 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, safeTitulo, safeTema, safeCenario, safeProporcao, safeCharGlobal, safeCharEng] ); 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 || 1, p.image_url || p.imageUrl || '', p.image_prompt || '', p.dialogue || ''] ); } } await client.query('COMMIT'); res.json({ success: true, id: projetoId, message: 'Projeto salvo com sucesso!' }); } catch (error) { if (client) { try { await client.query('ROLLBACK'); } catch (rbErr) { console.warn('Erro ao executar ROLLBACK:', rbErr.message); } } console.error('Erro ao salvar projeto de quadrinhos:', error); res.status(500).json({ error: 'Erro ao salvar projeto: ' + error.message }); } finally { if (client) { 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 + Fallback MsEdgeTTS com ambiência poética) let audioUrl = null; if (gerarAudio !== false && process.env.MINIMAX_API_KEY) { try { console.log('[Poesia] Tentando á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' || voz === 'menina') { 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 MiniMax:', audioErr.message); } } // Fallback garantido: Síntese de Recitação com MsEdgeTTS + Trilha Sonora Acústica if (!audioUrl && gerarAudio !== false) { try { console.log('[Poesia] Sintetizando recitação poética com MsEdgeTTS...'); const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) fs.mkdirSync(mediaDir, { recursive: true }); const poemText = (poesiaData.poesia || tema) .replace(/\(.*?\)/g, '') .replace(/\[.*?\]/g, '') .replace(/[*#]/g, '') .trim(); let vName = 'pt-BR-FranciscaNeural'; let ttsOpt = { pitch: '+0%', rate: '-4%' }; let isChild = false; const vNorm = (voz || 'mulher').toLowerCase(); if (vNorm === 'crianca' || vNorm === 'menino') { vName = 'pt-BR-ThalitaNeural'; ttsOpt = { pitch: '+42%', rate: '+2%' }; isChild = true; } else if (vNorm === 'menina') { vName = 'pt-BR-ThalitaNeural'; ttsOpt = { pitch: '+48%', rate: '+0%' }; isChild = true; } else if (vNorm === 'homem') { vName = 'pt-BR-AntonioNeural'; ttsOpt = { pitch: '+0%', rate: '-4%' }; } const tts = new MsEdgeTTS(); await tts.setMetadata(vName, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); const voiceTemp = path.join(mediaDir, `poesia_voice_${Date.now()}.mp3`); const { audioStream } = await tts.toStream(poemText, ttsOpt); await new Promise((resolve, reject) => { const ws = fs.createWriteStream(voiceTemp); audioStream.pipe(ws); ws.on('finish', resolve); ws.on('error', reject); audioStream.on('error', reject); }); const bgPoesia = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3'); const fileName = `poesia_${Date.now()}.mp3`; const outPoesiaPath = path.join(mediaDir, fileName); if (fs.existsSync(bgPoesia) && fs.existsSync(voiceTemp)) { const vFilter = isChild ? '[0:a]volume=1.30,highpass=f=130,treble=g=3:f=3500,aecho=0.8:0.7:30|50:0.2|0.12[v]' : '[0:a]volume=1.25,highpass=f=100,aecho=0.8:0.7:35|55:0.2|0.15[v]'; const ffmpegCmd = `ffmpeg -y -i "${voiceTemp}" -stream_loop -1 -i "${bgPoesia}" -filter_complex "${vFilter};[1:a]volume=0.25[bg];[v][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]" -map "[aout]" -c:a libmp3lame -b:a 128k "${outPoesiaPath}"`; await new Promise((resFf) => { exec(ffmpegCmd, (e) => { if (e) console.warn('[Poesia] Erro FFmpeg mix:', e.message); try { if (fs.existsSync(voiceTemp)) fs.unlinkSync(voiceTemp); } catch (e) {} resFf(); }); }); } else if (fs.existsSync(voiceTemp)) { fs.renameSync(voiceTemp, outPoesiaPath); } if (fs.existsSync(outPoesiaPath)) { const buf = fs.readFileSync(outPoesiaPath); backupMediaFile(outPoesiaPath, buf).catch(() => {}); audioUrl = `/generated-media/${fileName}`; } } catch (eTTS) { console.error('[Poesia] Erro fallback MsEdgeTTS:', eTTS.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
,

,