diff --git a/public/app.js b/public/app.js index 5293338..16b1cc4 100644 --- a/public/app.js +++ b/public/app.js @@ -7601,13 +7601,335 @@ document.addEventListener('DOMContentLoaded', () => { let dragStartWidth, dragStartHeight; let activeAssetTab = 'postit'; - // --- ESTÚDIO DE CENAS EM CAMADAS --- + // --- ESTÚDIO DE CENAS EM CAMADAS (novo layer manager) --- const btnDecomposeScene = document.getElementById('btnDecomposeScene'); - const sceneDecomposePrompt = document.getElementById('sceneDecomposePrompt'); + const btnAddSceneLayer = document.getElementById('btnAddSceneLayer'); + const sceneLayerManagerList = document.getElementById('sceneLayerManagerList'); const sceneDecomposeLoader = document.getElementById('sceneDecomposeLoader'); const sceneDecomposeStatus = document.getElementById('sceneDecomposeStatus'); - const sceneLayersList = document.getElementById('sceneLayersList'); - const sceneLayersItems = document.getElementById('sceneLayersItems'); + + // Estado das camadas + let sceneLayers = []; + let sceneLayerCounter = 0; + + // Cria uma linha de camada no painel + function createSceneLayerRow(id, isBackground) { + const row = document.createElement('div'); + row.dataset.layerId = id; + row.style.cssText = `display:flex; align-items:center; gap:6px; padding:6px 8px; + background:rgba(255,255,255,0.04); border:1px solid rgba(139,92,246,0.2); + border-radius:8px; transition: border-color 0.2s;`; + + const badge = document.createElement('div'); + badge.style.cssText = `min-width:22px; height:22px; border-radius:50%; + background:${isBackground ? 'rgba(16,163,127,0.3)' : 'rgba(139,92,246,0.3)'}; + display:flex; align-items:center; justify-content:center; font-size:0.7rem; font-weight:700; + color:${isBackground ? '#10a37f' : '#a78bfa'}; flex-shrink:0;`; + badge.textContent = isBackground ? '🌄' : sceneLayers.filter(l => !l.isBackground).length; + + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = isBackground + ? 'Cenário de fundo (Ex: parque de diversões)' + : 'Elemento (Ex: menino de 6 anos)'; + input.style.cssText = `flex:1; background:transparent; border:none; outline:none; + color:var(--text-primary); font-size:0.82rem; padding:2px 0; min-width:0;`; + input.addEventListener('input', () => { + const layer = sceneLayers.find(l => l.id === id); + if (layer) layer.description = input.value; + }); + + // Botões de ação + const btnVisibility = document.createElement('button'); + btnVisibility.title = 'Ocultar/Mostrar camada no canvas'; + btnVisibility.innerHTML = '👁️'; + btnVisibility.style.cssText = `background:none; border:none; cursor:pointer; font-size:0.9rem; + padding:2px; opacity:1; transition:opacity 0.2s; flex-shrink:0;`; + let visible = true; + btnVisibility.addEventListener('click', () => { + const layer = sceneLayers.find(l => l.id === id); + if (!layer || !layer.canvasItem) return; + visible = !visible; + layer.visible = visible; + layer.canvasItem.style.display = visible ? '' : 'none'; + btnVisibility.style.opacity = visible ? '1' : '0.3'; + btnVisibility.title = visible ? 'Ocultar camada' : 'Mostrar camada'; + }); + + const btnPin = document.createElement('button'); + btnPin.title = 'Fixar posição (impede arrastar)'; + btnPin.innerHTML = '📌'; + btnPin.style.cssText = `background:none; border:none; cursor:pointer; font-size:0.9rem; + padding:2px; opacity:0.4; transition:opacity 0.2s; flex-shrink:0;`; + let pinned = false; + btnPin.addEventListener('click', () => { + const layer = sceneLayers.find(l => l.id === id); + if (!layer || !layer.canvasItem) return; + pinned = !pinned; + layer.pinned = pinned; + layer.canvasItem.style.pointerEvents = pinned ? 'none' : ''; + layer.canvasItem.style.cursor = pinned ? 'default' : 'move'; + btnPin.style.opacity = pinned ? '1' : '0.4'; + btnPin.title = pinned ? 'Desafixar posição' : 'Fixar posição'; + }); + + const btnDelete = document.createElement('button'); + btnDelete.title = 'Remover camada'; + btnDelete.innerHTML = '🗑️'; + btnDelete.style.cssText = `background:none; border:none; cursor:pointer; font-size:0.9rem; + padding:2px; opacity:0.6; transition:opacity 0.2s; flex-shrink:0;`; + btnDelete.addEventListener('mouseenter', () => btnDelete.style.opacity = '1'); + btnDelete.addEventListener('mouseleave', () => btnDelete.style.opacity = '0.6'); + btnDelete.addEventListener('click', () => { + // Remove do canvas se já gerado + const layer = sceneLayers.find(l => l.id === id); + if (layer?.canvasItem) layer.canvasItem.remove(); + // Remove do state + sceneLayers = sceneLayers.filter(l => l.id !== id); + // Remove do DOM + row.remove(); + // Reindexar badges dos elementos + reindexLayerBadges(); + }); + + row.appendChild(badge); + row.appendChild(input); + row.appendChild(btnVisibility); + row.appendChild(btnPin); + if (!isBackground) row.appendChild(btnDelete); // Fundo não pode ser deletado da lista + + return { row, input, badge }; + } + + function reindexLayerBadges() { + let elIdx = 0; + sceneLayers.forEach(l => { + if (!l.isBackground && l.rowBadge) { + l.rowBadge.textContent = ++elIdx; + } + }); + } + + function addSceneLayerEntry(isBackground = false) { + const id = `sl_${++sceneLayerCounter}`; + const layerObj = { id, description: '', isBackground, visible: true, pinned: false, canvasItem: null }; + sceneLayers.push(layerObj); + + const { row, input, badge } = createSceneLayerRow(id, isBackground); + layerObj.rowInput = input; + layerObj.rowBadge = badge; + if (sceneLayerManagerList) sceneLayerManagerList.appendChild(row); + input.focus(); + return layerObj; + } + + // Inicializa com camada de fundo padrão + addSceneLayerEntry(true); + + if (btnAddSceneLayer) { + btnAddSceneLayer.addEventListener('click', () => { + addSceneLayerEntry(false); + reindexLayerBadges(); + }); + } + + // ===== REMOÇÃO DE FUNDO BRANCO VIA CANVAS API ===== + async function removeWhiteBackground(imgUrl, threshold = 235) { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const offscreen = document.createElement('canvas'); + offscreen.width = img.naturalWidth; + offscreen.height = img.naturalHeight; + const ctx = offscreen.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(img, 0, 0); + + const imageData = ctx.getImageData(0, 0, offscreen.width, offscreen.height); + const data = imageData.data; + const softRange = 20; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i], g = data[i + 1], b = data[i + 2]; + const minChannel = Math.min(r, g, b); + + if (minChannel >= threshold) { + data[i + 3] = 0; + } else if (minChannel >= threshold - softRange) { + const ratio = (minChannel - (threshold - softRange)) / softRange; + data[i + 3] = Math.round((1 - ratio) * data[i + 3]); + } + } + + ctx.putImageData(imageData, 0, 0); + resolve(offscreen.toDataURL('image/png')); + }; + img.onerror = () => resolve(imgUrl); + img.src = imgUrl; + }); + } + + // ===== ADICIONA ITEM DE CAMADA NO CANVAS ===== + function addSceneLayerToCanvas(layerData) { + if (!cartazPrintableArea) return null; + + const item = document.createElement('div'); + item.className = 'canvas-item canvas-scene-layer'; + item.setAttribute('data-layer-name', layerData.name || ''); + item.setAttribute('data-rotation', '0'); + item.style.cssText = `position:absolute; background:transparent; box-sizing:border-box;`; + item.style.zIndex = layerData.isBackground ? '0' : String(10 + (layerData.zOffset || 0)); + + // Cria a imagem SEM usar innerHTML (preserva o handle de resize) + const imgEl = document.createElement('img'); + imgEl.style.cssText = `width:100%; height:100%; object-fit:${layerData.isBackground ? 'cover' : 'contain'}; pointer-events:none; display:block;`; + imgEl.src = layerData.url; + item.appendChild(imgEl); + + // Handle de resize — criado uma única vez e permanece + const handle = document.createElement('div'); + handle.className = 'canvas-resize-handle'; + item.appendChild(handle); + + if (layerData.isBackground) { + item.style.left = '0'; + item.style.top = '0'; + item.style.width = '100%'; + item.style.height = '100%'; + item.title = '🖼️ Cenário de Fundo'; + } else { + item.style.left = (layerData.left || 10) + '%'; + item.style.top = (layerData.top || 10) + '%'; + item.style.width = (layerData.width || 30) + '%'; + item.style.height = (layerData.height || 30) + '%'; + item.title = '🎬 ' + (layerData.name || 'Elemento'); + + // Remove fundo branco: atualiza apenas o src da img (preserva o handle) + removeWhiteBackground(layerData.url).then(transparentUrl => { + imgEl.src = transparentUrl; + }); + } + + cartazPrintableArea.appendChild(item); + setupCanvasItemEvents(item); + return item; + } + + // ===== BOTÃO CRIAR CENA ===== + if (btnDecomposeScene) { + btnDecomposeScene.addEventListener('click', async () => { + // Validar que há pelo menos fundo + 1 elemento com descrição + const bgLayer = sceneLayers.find(l => l.isBackground); + if (!bgLayer?.description?.trim()) { + showToast('Descreva o cenário de fundo (Camada 🌄)!', 'warning'); + if (bgLayer?.rowInput) bgLayer.rowInput.focus(); + return; + } + const elementLayers = sceneLayers.filter(l => !l.isBackground && l.description?.trim()); + if (elementLayers.length === 0) { + showToast('Adicione pelo menos um elemento (➕ Adicionar Camada)!', 'warning'); + return; + } + + if (!canvasModeActive) { + if (cartazEditArea && cartazEditArea.style.display !== 'none') { + btnEditCartazToggle && btnEditCartazToggle.click(); + } + enterCanvasMode(); + } + + btnDecomposeScene.disabled = true; + btnDecomposeScene.style.opacity = '0.6'; + if (sceneDecomposeLoader) sceneDecomposeLoader.style.display = 'flex'; + + // Remover camadas anteriores do canvas + cartazPrintableArea.querySelectorAll('.canvas-scene-layer').forEach(el => el.remove()); + sceneLayers.forEach(l => { l.canvasItem = null; }); + + // Gerar cada camada individualmente (uma por uma para acompanhar progresso) + const totalLayers = 1 + elementLayers.length; // fundo + elementos + let done = 0; + + const updateStatus = () => { + done++; + if (sceneDecomposeStatus) sceneDecomposeStatus.textContent = `Gerando camada ${done}/${totalLayers}...`; + }; + + try { + const titulo = activeCartazData?.titulo || ''; + const token = localStorage.getItem('token'); + + // Gera todas as imagens em paralelo via API (usando os prompts diretos das camadas) + const allPrompts = [ + { isBackground: true, description: bgLayer.description, layerRef: bgLayer }, + ...elementLayers.map(l => ({ isBackground: false, description: l.description, layerRef: l })) + ]; + + if (sceneDecomposeStatus) sceneDecomposeStatus.textContent = `Enviando para a IA...`; + + const response = await fetch('/api/cartazes/decompose-scene', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ + titulo, + // Mandamos a estrutura de camadas diretamente (novo campo layers_manual) + layers_manual: allPrompts.map(p => ({ + isBackground: p.isBackground, + description: p.description + })) + }) + }); + + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + throw new Error(errData.error || 'Erro na geração da cena.'); + } + + const data = await response.json(); + if (!data.success) throw new Error('Resposta inválida do servidor.'); + + if (sceneDecomposeStatus) sceneDecomposeStatus.textContent = 'Montando as camadas no canvas...'; + + // Fundo + if (data.backgroundUrl && bgLayer) { + bgLayer.canvasItem = addSceneLayerToCanvas({ + url: data.backgroundUrl, name: bgLayer.description, isBackground: true + }); + } + + // Elementos + const spread = [ + { left: 8, top: 32 }, { left: 42, top: 38 }, + { left: 64, top: 28 }, { left: 22, top: 56 } + ]; + if (Array.isArray(data.layers)) { + data.layers.forEach((layer, idx) => { + if (!layer.url) return; + const elLayerRef = elementLayers[idx]; + const pos = spread[idx % spread.length]; + const canvasItem = addSceneLayerToCanvas({ + url: layer.url, + name: layer.name || elLayerRef?.description || `Elemento ${idx + 1}`, + isBackground: false, + left: pos.left, top: pos.top, width: 30, height: 30, + zOffset: idx + 1 + }); + if (elLayerRef) elLayerRef.canvasItem = canvasItem; + }); + } + + showToast('🎬 Cena montada! Use os botões 👁️ 📌 🗑️ para controlar cada camada.', 'success'); + + } catch (err) { + console.error('[Cenas em Camadas]', err); + showToast('Erro ao criar a cena: ' + err.message, 'error'); + } finally { + btnDecomposeScene.disabled = false; + btnDecomposeScene.style.opacity = '1'; + if (sceneDecomposeLoader) sceneDecomposeLoader.style.display = 'none'; + } + }); + } // Cria um item de camada de imagem direto no canvas (background ou sticker) // Remoção de fundo branco via Canvas API — gera PNG com transparência real diff --git a/public/index.html b/public/index.html index 340afd6..dacca9a 100644 --- a/public/index.html +++ b/public/index.html @@ -1885,17 +1885,31 @@
+ +
🎬 -
+
Estúdio de Cenas em Camadas
-
A IA desmonta a cena em elementos editáveis individualmente
+
Cada linha = uma camada gerada separadamente pela IA
- - + + + + - - +
diff --git a/server.js b/server.js index b5650cf..997d9c3 100644 --- a/server.js +++ b/server.js @@ -1556,46 +1556,103 @@ Return ONLY the English prompt, no extra chat.` }); app.post('/api/cartazes/decompose-scene', requireAuth, async (req, res) => { - const { tema, titulo } = req.body; - if (!tema) { - return res.status(400).json({ error: 'Tema é obrigatório.' }); - } + const { tema, titulo, layers_manual } = req.body; try { - console.log('[Decompose] Iniciando engenharia reversa do tema:', tema); + console.log('[Decompose] Recebida requisição:', { hasLayersManual: !!layers_manual, tema }); + const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); + + // Função interna para traduzir uma descrição PT→EN e gerar a imagem + const translateAndGenerate = async (descPT, isBackground) => { + let engPrompt = descPT; + try { + const refineContent = isBackground + ? `Translate this Portuguese description of a scene BACKGROUND into a clean English image generation prompt. +The background must be COMPLETELY EMPTY — no people, no animals, no characters. Just the environment/scenery. +Specify cartoon vector illustration style, vibrant colors, kid-friendly, no text. +Portuguese: "${descPT}" +Return ONLY the English prompt, nothing else.` + : `Translate this Portuguese description of a single character or object into a clean English image generation prompt. +The element must be COMPLETELY ISOLATED on a PURE WHITE background (#FFFFFF), full body visible, no shadows, no background details. +Specify 3D Pixar cartoon sticker style, soft pastel colors, no text. +Portuguese: "${descPT}" +Return ONLY the English prompt, nothing else.`; + + const r = await callMinimax({ messages: [{ role: 'user', content: refineContent }], temperature: 0.2, max_tokens: 150 }); + if (r?.text) engPrompt = r.text.trim(); + } catch (e) { console.error('[Decompose] Translate error:', e.message); } + + try { + const suffix = isBackground + ? 'Empty background scene, no people, no animals, cartoon illustration, vibrant colors, no text.' + : 'Full body visible, pure solid white background #FFFFFF, no shadows, no gradients, 3D Pixar sticker clipart style, no text.'; + + 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: `${engPrompt}. ${suffix}`, n: 1 }) + }); + + if (genResp.ok) { + const genData = await genResp.json(); + const rawUrl = genData.data?.image_urls?.[0]; + if (rawUrl) { + const imgFetch = await fetch(rawUrl); + if (imgFetch.ok) { + const buf = Buffer.from(await imgFetch.arrayBuffer()); + const fname = `layer_${Date.now()}_${Math.floor(Math.random()*1000)}.jpg`; + const mediaDir = require('path').join(__dirname, 'public', 'generated-media'); + if (!require('fs').existsSync(mediaDir)) require('fs').mkdirSync(mediaDir, { recursive: true }); + const fpath = require('path').join(mediaDir, fname); + require('fs').writeFileSync(fpath, buf); + await backupMediaFile(fpath, buf); + return `/generated-media/${fname}`; + } + } + } + } catch (e) { console.error('[Decompose] Generate error:', e.message); } + return null; + }; + + // MODO NOVO: layers_manual enviado pelo layer manager + if (layers_manual && Array.isArray(layers_manual) && layers_manual.length > 0) { + const bgLayerDef = layers_manual.find(l => l.isBackground); + const elementLayerDefs = layers_manual.filter(l => !l.isBackground); + + if (!bgLayerDef) return res.status(400).json({ error: 'Nenhuma camada de fundo definida.' }); + + console.log('[Decompose] Modo Manual - gerando', layers_manual.length, 'camadas em paralelo...'); + + // Gerar tudo em paralelo + const [backgroundUrl, ...elementUrls] = await Promise.all([ + translateAndGenerate(bgLayerDef.description, true), + ...elementLayerDefs.map(el => translateAndGenerate(el.description, false)) + ]); + + const layers = elementLayerDefs.map((el, i) => ({ + name: el.description, + url: elementUrls[i], + left: 10 + i * 25, top: 35, width: 30, height: 30 + })).filter(l => l.url); + + return res.json({ success: true, backgroundUrl, layers }); + } + + // MODO LEGADO: campo tema (compatibilidade) + if (!tema) return res.status(400).json({ error: 'Tema ou layers_manual são obrigatórios.' }); + const agentConfig = readAgentConfig(); const agentName = agentConfig.agentName || "Kemily"; const segmentPrompt = `Você é a ${agentName}, assistente pedagógica. -Dada a descrição de uma imagem de cartaz infantil: -Tema: "${tema}" -Título: "${titulo || ''}" - -Por favor, divida essa cena em componentes em camadas para que possamos montar um Canvas interativo. -Eu preciso de: -1. Um prompt para a imagem de fundo (background) sem NENHUM personagem, pessoa ou animal. Deve ser apenas o cenário de fundo vazio (ex: "an empty lawn inside a park with some trees under a blue sky, cartoon vector illustration style, no people, no animals, no text"). -2. Uma lista de 2 a 3 elementos individuais importantes da cena que serão colocados como adesivos (stickers) por cima. Cada elemento deve ser descrito de forma isolada com fundo branco sólido (ex: "two happy cartoon kids holding hands, isolated on a solid white background, sticker style, no text"). - -Retorne a resposta estritamente no formato JSON abaixo, sem blocos de código markdown (\`\`\`json ou \`\`\$) e sem nenhum texto explicativo extra: -{ - "backgroundPrompt": "descrição do fundo em inglês...", - "elements": [ - { - "name": "Nome do Elemento (Ex: Crianças)", - "prompt": "descrição do elemento em inglês isolado em fundo branco..." - } - ] -}`; +Tema: "${tema}" / Título: "${titulo || ''}" +Divida em: 1 fundo vazio + 2-3 elementos stickers (fundo branco puro). +Retorne JSON: { "backgroundPrompt":"...", "elements":[{"name":"...","prompt":"..."}] }`; let jsonResponse = null; try { - const chatRes = await callMinimax({ - messages: [{ role: 'user', content: segmentPrompt }], - temperature: 0.2, - max_tokens: 800 - }); - let text = chatRes.text.trim(); - text = text.replace(/```json|```/g, '').trim(); + const chatRes = await callMinimax({ messages: [{ role: 'user', content: segmentPrompt }], temperature: 0.2, max_tokens: 600 }); + let text = chatRes.text.trim().replace(/```json|```/g, '').trim(); jsonResponse = JSON.parse(text); } catch (err) { console.error('[Decompose] Erro ao obter JSON do Minimax:', err); @@ -1603,68 +1660,20 @@ Retorne a resposta estritamente no formato JSON abaixo, sem blocos de código ma if (!jsonResponse || !jsonResponse.backgroundPrompt || !jsonResponse.elements) { jsonResponse = { - backgroundPrompt: `empty scene backdrop for ${tema}, cartoon vector style, no people, no animals, no text`, + backgroundPrompt: `${tema}, empty scene backdrop, cartoon vector style, no people, no animals, no text`, elements: [ { name: "Elemento Principal", prompt: `${tema}, cartoon sticker clipart style, completely isolated on a pure white background, no shadows, no background elements, no text` } ] }; } - console.log('[Decompose] Planejamento da cena em camadas:', jsonResponse); - - const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); - - // Função para gerar uma imagem individual via Minimax - const generateAndDownload = async (promptText) => { - try { - console.log('[Decompose] Gerando imagem para o prompt:', promptText); - 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: `${promptText}. Complete full-body figure visible from head to toe without any cutoffs. Pure solid white background #FFFFFF, no shadows, no gradients, no gray areas, no drop shadows, no environment, kid-friendly 3D Pixar illustration style, soft pastel colors on the character only, no text, no words, no letters, no writing, no labels.`, - n: 1 - }) - }); - - if (genResp.ok) { - const genData = await genResp.json(); - const rawImgUrl = genData.data?.image_urls?.[0]; - if (rawImgUrl) { - const imgFetch = await fetch(rawImgUrl); - if (imgFetch.ok) { - const imgBuffer = Buffer.from(await imgFetch.arrayBuffer()); - const fileName = `layer_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`; - const mediaDir = path.join(__dirname, 'public', 'generated-media'); - if (!fs.existsSync(mediaDir)) fs.mkdirSync(mediaDir, { recursive: true }); - const filePath = path.join(mediaDir, fileName); - fs.writeFileSync(filePath, imgBuffer); - await backupMediaFile(filePath, imgBuffer); - return `/generated-media/${fileName}`; - } - } - } - } catch (err) { - console.error('[Decompose] Erro na geração de camada:', err); - } - return null; - }; + console.log('[Decompose] Planejamento da cena em camadas (Legado):', jsonResponse); // Gerar fundo e stickers paralelamente - const promises = []; - promises.push(generateAndDownload(jsonResponse.backgroundPrompt)); - for (const el of jsonResponse.elements) { - promises.push(generateAndDownload(el.prompt)); - } - - const results = await Promise.all(promises); - - const backgroundUrl = results[0]; - const elementUrls = results.slice(1); + const [backgroundUrl, ...elementUrls] = await Promise.all([ + translateAndGenerate(jsonResponse.backgroundPrompt, true), + ...jsonResponse.elements.map(el => translateAndGenerate(el.prompt, false)) + ]); const layers = jsonResponse.elements.map((el, index) => { return {