diff --git a/public/app.js b/public/app.js index 4bf59c0..a315c26 100644 --- a/public/app.js +++ b/public/app.js @@ -4562,8 +4562,12 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` if (!comicsProjectSelect) return; try { const resp = await fetch('/api/comics/projects'); - if (!resp.ok) throw new Error('Falha ao listar projetos'); + if (!resp.ok) { + console.warn('Projetos de quadrinhos temporariamente indisponíveis:', resp.status); + return; + } const projects = await resp.json(); + if (!Array.isArray(projects)) return; comicsProjectSelect.innerHTML = ''; projects.forEach(proj => { @@ -4577,7 +4581,7 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` comicsProjectSelect.value = currentComicsProjectId; } } catch (err) { - console.error('Erro ao listar projetos:', err); + console.warn('Aviso ao listar projetos:', err.message); } }; @@ -4588,7 +4592,10 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` } try { const resp = await fetch(`/api/comics/projects/${id}`); - if (!resp.ok) throw new Error('Falha ao carregar projeto'); + if (!resp.ok) { + const errMsg = await safeExtractError(resp, 'Falha ao carregar projeto'); + throw new Error(errMsg); + } const { project, panels } = await resp.json(); currentComicsProjectId = project.id; @@ -4692,19 +4699,13 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` const caption = document.createElement('div'); caption.className = 'comic-caption-text'; - caption.style.cssText = 'padding: 10px 14px; background: var(--bg-primary); border-top: 1px solid var(--border-light);'; const textarea = document.createElement('textarea'); textarea.className = 'comic-caption-input'; textarea.value = panel.dialogue || ''; textarea.placeholder = 'Digite a fala ou legenda do quadrinho...'; - textarea.style.cssText = 'width: 100%; border: 1px solid var(--border-light); background: var(--bg-secondary); color: var(--text-primary); border-radius: 6px; padding: 8px; font-family: inherit; font-size: 0.85rem; resize: vertical; box-sizing: border-box; min-height: 50px;'; - textarea.addEventListener('input', (e) => { panel.dialogue = e.target.value; - if (generatedPanelsData[index]) { - generatedPanelsData[index].dialogue = e.target.value; - } }); caption.appendChild(textarea); @@ -4714,8 +4715,6 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` }); comicsResultArea.style.display = 'flex'; - } else { - comicsResultArea.style.display = 'none'; } comicsVideoResult.style.display = 'none'; @@ -4729,23 +4728,18 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` }; const saveComicsProjectFlow = async (forceNew = false) => { - const titulo = comicsProjectTitleInput ? comicsProjectTitleInput.value.trim() : ''; + let titulo = comicsProjectTitleInput ? comicsProjectTitleInput.value.trim() : ''; if (!titulo) { - await showCustomAlert('Campo Obrigatório', 'Por favor, digite um título para salvar seu projeto.'); - return; + titulo = comicsResultTitle ? comicsResultTitle.textContent.trim() : 'Minha História em Quadrinhos'; } const characters = []; document.querySelectorAll('.comic-char-row').forEach(row => { - const typeInput = row.querySelector('.char-type-input'); - const nameInput = row.querySelector('.char-name-input'); - if (typeInput && nameInput) { - const type = typeInput.value.trim(); - const name = nameInput.value.trim(); - const isAnimal = row.querySelector('span').textContent === '🐾'; - if (type || name) { - characters.push({ type, name, isAnimal }); - } + const type = row.querySelector('.char-type-input').value.trim(); + const name = row.querySelector('.char-name-input').value.trim(); + const isAnimal = row.querySelector('.comic-char-animal-toggle').classList.contains('active'); + if (type && name) { + characters.push({ type, name, isAnimal }); } }); @@ -4780,8 +4774,8 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` }); if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Erro ao salvar projeto'); + const errMsg = await safeExtractError(resp, 'Erro ao salvar projeto'); + throw new Error(errMsg); } const data = await resp.json(); @@ -4814,7 +4808,10 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` method: 'DELETE' }); - if (!resp.ok) throw new Error('Falha ao excluir projeto'); + if (!resp.ok) { + const errMsg = await safeExtractError(resp, 'Falha ao excluir projeto'); + throw new Error(errMsg); + } await showCustomAlert('Excluído', 'Projeto excluído com sucesso!'); startNewComicsProject(); @@ -4900,6 +4897,21 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` }); }; + // Helper para execução concorrente controlada + const mapConcurrent = async (items, limit, fn) => { + const results = new Array(items.length); + let currentIdx = 0; + const worker = async () => { + while (currentIdx < items.length) { + const i = currentIdx++; + results[i] = await fn(items[i], i); + } + }; + const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker()); + await Promise.all(workers); + return results; + }; + // Ação de geração da história e imagens if (btnGenerateComics) { btnGenerateComics.addEventListener('click', async () => { @@ -4968,44 +4980,48 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` }); if (!scriptResp.ok) { - const err = await scriptResp.json(); - throw new Error(err.error || 'Erro ao planejar roteiro'); + const errMsg = await safeExtractError(scriptResp, 'Erro ao planejar roteiro'); + throw new Error(errMsg); } const scriptData = await scriptResp.json(); - comicsLoaderProgress.style.width = '15%'; + comicsLoaderProgress.style.width = '20%'; if (scriptData.character_description) { currentComicsCharDescEnglish = scriptData.character_description; } - // 2. Gerar quadrinhos em PARALELO com tracking de progresso em tempo real + // 2. Gerar quadrinhos com concorrência balanceada (2 por vez) e tracking em tempo real if (genMode !== 'extend') { generatedPanelsData = []; } const totalPanels = scriptData.panels.length; let completedCount = 0; - const panelPromises = scriptData.panels.map(async (panel, idx) => { - // Injetamos a proporção e estilo de forma reforçada no prompt + const generateSinglePanel = async (panel) => { const fullPrompt = `${panel.image_prompt}, high resolution children book illustration, cute Pixar style, ${selectedComicsRatio === '16:9' ? '16:9 aspect ratio' : '4:3 aspect ratio'}`; - const frameResp = await fetch('/api/comics/generate-frame', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: fullPrompt }) - }); + let frameResp; + try { + frameResp = await fetch('/api/comics/generate-frame', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: fullPrompt, proporcao: selectedComicsRatio }) + }); + } catch (netErr) { + throw new Error(`Falha de conexão no quadro ${panel.panel_number}: ${netErr.message}`); + } if (!frameResp.ok) { - const err = await frameResp.json(); - throw new Error(`Erro no quadrinho ${panel.panel_number}: ${err.error}`); + const errMsg = await safeExtractError(frameResp, 'Erro ao ilustrar quadro'); + throw new Error(`Erro no quadrinho ${panel.panel_number}: ${errMsg}`); } const frameData = await frameResp.json(); completedCount++; - const progressPercent = 15 + Math.floor((completedCount / totalPanels) * 80); + const progressPercent = 20 + Math.floor((completedCount / totalPanels) * 75); if (comicsLoaderProgress) comicsLoaderProgress.style.width = `${progressPercent}%`; - if (comicsLoaderText) comicsLoaderText.textContent = `⚡ Ilustrando em paralelo: ${completedCount}/${totalPanels} quadros concluídos...`; + if (comicsLoaderText) comicsLoaderText.textContent = `🎨 Ilustrando quadrinhos: ${completedCount}/${totalPanels} quadros concluídos...`; return { panel_number: panel.panel_number, @@ -5013,9 +5029,10 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` dialogue: panel.dialogue || '', image_prompt: panel.image_prompt || '' }; - }); + }; - const newPanels = await Promise.all(panelPromises); + // Executa com no máximo 2 quadros em paralelo para estabilidade máxima e resposta fluida + const newPanels = await mapConcurrent(scriptData.panels, 2, generateSinglePanel); // Ordena por panel_number para garantir a sequência correta newPanels.sort((a, b) => a.panel_number - b.panel_number); @@ -5028,10 +5045,6 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` // Atualizar cabeçalho e metadados comicsResultTitle.textContent = scriptData.title || comicsResultTitle.textContent || 'Fábrica de Quadrinhos Pedagog'; - const metaPanels = document.getElementById('comicsMetaPanels'); - if (metaPanels) metaPanels.textContent = `🖼️ ${generatedPanelsData.length} Quadros`; - const metaRatio = document.getElementById('comicsMetaRatio'); - if (metaRatio) metaRatio.textContent = `📐 ${selectedComicsRatio} ${selectedComicsRatio === '16:9' ? 'HD' : 'Retrô'}`; // Renderizar Storyboard Interativo renderComicsStoryboard(); @@ -5204,8 +5217,8 @@ Se algum dado não for mencionado, deixe a string vazia ou false para boolean.` }); if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Erro na regeneração'); + const errMsg = await safeExtractError(resp, 'Erro na regeneração'); + throw new Error(errMsg); } const data = await resp.json(); diff --git a/scratch/test_minimax_image.js b/scratch/test_minimax_image.js new file mode 100644 index 0000000..b9183d7 --- /dev/null +++ b/scratch/test_minimax_image.js @@ -0,0 +1,33 @@ +require('dotenv').config(); + +async function testMiniMaxImage() { + const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); + const apiKey = process.env.MINIMAX_API_KEY; + + console.log('Testando MiniMax Image Generation...'); + console.log('Base URL:', minmBase); + console.log('Chave presente:', !!apiKey); + + try { + const res = await fetch(`${minmBase}/v1/image_generation`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'image-01', + prompt: 'A cute little cartoon boy in a bright colorful playground, 3D Pixar style, child book illustration, high resolution, 16:9 aspect ratio', + n: 1 + }) + }); + + console.log('Status HTTP:', res.status, res.statusText); + const text = await res.text(); + console.log('Resposta bruta:', text); + } catch (err) { + console.error('Erro na requisição:', err); + } +} + +testMiniMaxImage(); diff --git a/server.js b/server.js index d4d2b7e..0e8627c 100644 --- a/server.js +++ b/server.js @@ -76,10 +76,26 @@ const requireAuth = (req, res, next) => { } }; +// 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 + 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) { @@ -94,15 +110,19 @@ async function backupMediaFile(filePath, buffer) { else if (ext === '.mp4') contentType = 'video/mp4'; else if (ext === '.wav') contentType = 'audio/wav'; - await dbPool.query( + // 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.error(`[Media Backup] Erro ao fazer backup do arquivo ${filePath}:`, err); + console.warn(`[Media Backup] Aviso ao fazer backup do arquivo ${filePath} no banco:`, err.message); } } @@ -502,62 +522,116 @@ Responda APENAS o JSON válido, sem qualquer texto explicativo antes ou depois.` } }); -// 2. Gerar uma imagem individual para um quadrinho usando MiniMax image-01 +// 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 } = req.body; + 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 }); + } + + const fileName = `quadrinho_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`; + const filePath = path.join(mediaDir, fileName); + + let imageBuffer = null; + let providerUsed = 'minimax'; + + // 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 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 - }) + 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: prompt, + 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): Pollinations FLUX de alta resolução estilizado 3D Pixar + if (!imageBuffer) { + providerUsed = 'pollinations-flux'; + try { + const is169 = proporcao === '16:9' || prompt.includes('16:9'); + const width = is169 ? 1280 : 1024; + const height = is169 ? 720 : 768; + const enhancedPrompt = encodeURIComponent(`${prompt}, 3d pixar disney style, digital children book illustration, vibrant colors, masterpiece, cinematic lighting`); + const fallbackUrl = `https://image.pollinations.ai/prompt/${enhancedPrompt}?width=${width}&height=${height}&nologo=true&seed=${Math.floor(Math.random() * 100000)}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + 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 Inteligente (Pollinations Flux).'); + } else { + throw new Error(`Fallback HTTP ${fbResp.status}`); + } + } catch (fbErr) { + console.error('[Comics Image] Erro também no provedor Fallback:', fbErr.message); + } + } + + if (!imageBuffer) { + return res.status(500).json({ error: 'Não foi possível gerar a ilustração do quadro no momento. Por favor, tente novamente.' }); + } + + 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); }); - 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' }); + res.json({ + imageUrl: `/generated-media/${fileName}`, + provider: providerUsed + }); + } catch (saveErr) { + console.error('Erro ao salvar imagem localmente:', saveErr); + res.status(500).json({ error: 'Erro ao persistir imagem gerada: ' + saveErr.message }); } }); @@ -627,7 +701,7 @@ app.post('/api/comics/generate-video', requireAuth, async (req, res) => { 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 + // Backup do vídeo gerado no banco de dados em background (async () => { try { const videoBuffer = fs.readFileSync(outputFilePath); @@ -656,10 +730,10 @@ app.get('/api/comics/projects', requireAuth, async (req, res) => { '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); + 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.' }); + console.error('Erro ao listar projetos de quadrinhos:', error.message); + res.json([]); } }); @@ -683,7 +757,7 @@ app.get('/api/comics/projects/:id', requireAuth, async (req, res) => { res.json({ project: projectRes.rows[0], - panels: panelsRes.rows + panels: panelsRes.rows || [] }); } catch (error) { console.error('Erro ao obter projeto de quadrinhos:', error); @@ -700,8 +774,9 @@ app.post('/api/comics/projects', requireAuth, async (req, res) => { return res.status(400).json({ error: 'O título do projeto é obrigatório.' }); } - const client = await dbPool.connect(); + let client = null; try { + client = await dbPool.connect(); await client.query('BEGIN'); let projetoId = id; @@ -749,11 +824,19 @@ app.post('/api/comics/projects', requireAuth, async (req, res) => { await client.query('COMMIT'); res.json({ success: true, id: projetoId, message: 'Projeto salvo com sucesso!' }); } catch (error) { - await client.query('ROLLBACK'); + 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 { - client.release(); + if (client) { + client.release(); + } } });