feat: Adicionado gerenciador de camadas para a geração de cartazes e resolvido bug de resize

This commit is contained in:
2026-06-08 11:23:51 +00:00
parent cf5a46c86c
commit 002218fe13
3 changed files with 441 additions and 100 deletions
+326 -4
View File
@@ -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