✨ feat: Adicionado gerenciador de camadas para a geração de cartazes e resolvido bug de resize
This commit is contained in:
+326
-4
@@ -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
|
||||
|
||||
+21
-11
@@ -1885,17 +1885,31 @@
|
||||
|
||||
<!-- 🎬 ESTÚDIO DE CENAS EM CAMADAS -->
|
||||
<div id="scenesStudioPanel" style="background: linear-gradient(135deg, rgba(139,92,246,0.12), rgba(59,130,246,0.08)); border: 1px solid rgba(139,92,246,0.3); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 10px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.3rem;">🎬</span>
|
||||
<div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight: 700; color: var(--text-primary); font-size: 0.9rem;">Estúdio de Cenas em Camadas</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-secondary); margin-top: 1px;">A IA desmonta a cena em elementos editáveis individualmente</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-secondary); margin-top: 1px;">Cada linha = uma camada gerada separadamente pela IA</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="sceneDecomposePrompt" class="obs-input" rows="3" placeholder="Descreva a cena... Ex: Duas crianças e um cachorrinho num gramado de parque, com um escorregador ao fundo" style="resize: vertical; font-size: 0.85rem; padding: 10px; border-radius: 8px; line-height: 1.4;"></textarea>
|
||||
<button id="btnDecomposeScene" class="btn-save-settings" style="background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: white; border: none; padding: 8px 14px; font-size: 0.82rem; font-weight: 700; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: opacity 0.2s;">
|
||||
<span>✨ Criar Cena em Camadas</span>
|
||||
|
||||
<!-- Lista dinâmica de camadas -->
|
||||
<div id="sceneLayerManagerList" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<!-- Camadas inseridas por JS -->
|
||||
</div>
|
||||
|
||||
<!-- Botão Adicionar Camada -->
|
||||
<button id="btnAddSceneLayer" style="background: rgba(139,92,246,0.15); border: 1px dashed rgba(139,92,246,0.5); color: rgba(139,92,246,1); padding: 7px; font-size: 0.82rem; font-weight: 600; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; transition: all 0.2s; width: 100%;">
|
||||
➕ Adicionar Camada
|
||||
</button>
|
||||
|
||||
<!-- Botão Criar Cena -->
|
||||
<button id="btnDecomposeScene" style="background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: white; border: none; padding: 9px 14px; font-size: 0.85rem; font-weight: 700; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: opacity 0.2s;">
|
||||
✨ Criar Cena
|
||||
</button>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div id="sceneDecomposeLoader" style="display: none; flex-direction: column; gap: 8px; align-items: center; padding: 10px; background: rgba(0,0,0,0.15); border-radius: 8px;">
|
||||
<div style="display: flex; gap: 6px; align-items: center;">
|
||||
@@ -1903,13 +1917,9 @@
|
||||
<div class="loading-dot" style="width:8px;height:8px;border-radius:50%;background:#8b5cf6;animation:dot-pulse 1.2s ease-in-out 0.4s infinite;"></div>
|
||||
<div class="loading-dot" style="width:8px;height:8px;border-radius:50%;background:#8b5cf6;animation:dot-pulse 1.2s ease-in-out 0.8s infinite;"></div>
|
||||
</div>
|
||||
<div id="sceneDecomposeStatus" style="font-size: 0.78rem; color: var(--text-secondary); text-align: center;">Planejando a cena com IA...</div>
|
||||
</div>
|
||||
<!-- Camadas geradas -->
|
||||
<div id="sceneLayersList" style="display: none; flex-direction: column; gap: 6px;">
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">📋 Camadas no Canvas:</div>
|
||||
<div id="sceneLayersItems" style="display: flex; flex-direction: column; gap: 4px;"></div>
|
||||
<div id="sceneDecomposeStatus" style="font-size: 0.78rem; color: var(--text-secondary); text-align: center;">Gerando camadas...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
|
||||
Reference in New Issue
Block a user