feat: Estúdio de Cenas em Camadas - IA desmonta imagem em elementos editáveis individualmente no canvas

This commit is contained in:
2026-06-08 10:34:41 +00:00
parent f16c1dc286
commit 837bda8c88
5 changed files with 484 additions and 7 deletions
+192
View File
@@ -7601,6 +7601,198 @@ document.addEventListener('DOMContentLoaded', () => {
let dragStartWidth, dragStartHeight;
let activeAssetTab = 'postit';
// --- ESTÚDIO DE CENAS EM CAMADAS ---
const btnDecomposeScene = document.getElementById('btnDecomposeScene');
const sceneDecomposePrompt = document.getElementById('sceneDecomposePrompt');
const sceneDecomposeLoader = document.getElementById('sceneDecomposeLoader');
const sceneDecomposeStatus = document.getElementById('sceneDecomposeStatus');
const sceneLayersList = document.getElementById('sceneLayersList');
const sceneLayersItems = document.getElementById('sceneLayersItems');
// Cria um item de camada de imagem direto no canvas (background ou sticker)
function addSceneLayerToCanvas(layerData) {
if (!canvasModeActive || !cartazPrintableArea) return;
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.position = 'absolute';
item.style.left = (layerData.left || 10) + '%';
item.style.top = (layerData.top || 10) + '%';
item.style.width = (layerData.width || 35) + '%';
item.style.height = (layerData.height || 35) + '%';
item.style.zIndex = layerData.isBackground ? '1' : String(10 + (layerData.zOffset || 0));
item.style.background = 'transparent';
if (layerData.isBackground) {
// Fundo: cobrindo 100% e não selecionável como item de controle
item.style.left = '0';
item.style.top = '0';
item.style.width = '100%';
item.style.height = '100%';
item.style.zIndex = '0';
item.style.pointerEvents = 'none'; // Por padrão, o fundo não captura eventos para facilitar a seleção dos elementos acima
item.innerHTML = `<img src="${layerData.url}"
style="width:100%; height:100%; object-fit:cover; pointer-events:none; display:block;">`;
item.title = '🖼️ Cenário de Fundo';
} else {
item.innerHTML = `<img src="${layerData.url}"
style="width:100%; height:100%; object-fit:contain; pointer-events:none;">`;
item.title = '🎬 ' + (layerData.name || 'Elemento');
}
const handle = document.createElement('div');
handle.className = 'canvas-resize-handle';
item.appendChild(handle);
cartazPrintableArea.appendChild(item);
if (!layerData.isBackground) {
setupCanvasItemEvents(item);
// Permitir que o fundo seja selecionável após clicar nele diretamente
} else {
// para o fundo, adicionar capacidade de clicar para habilitar arrastar
item.style.pointerEvents = 'auto';
setupCanvasItemEvents(item);
}
return item;
}
// Renderiza a lista de camadas no painel lateral
function renderSceneLayers(layers) {
if (!sceneLayersItems) return;
sceneLayersItems.innerHTML = '';
const allLayers = [{ name: '🖼️ Cenário de Fundo', isBackground: true }, ...layers.map(l => ({ ...l }))];
allLayers.forEach((layer, index) => {
const chip = document.createElement('div');
chip.style.cssText = `display:flex; align-items:center; gap:8px; padding:5px 8px;
background:rgba(255,255,255,0.05); border:1px solid rgba(139,92,246,0.2);
border-radius:6px; font-size:0.78rem; color:var(--text-primary); cursor:pointer;`;
chip.innerHTML = `
<span style="font-size:1rem;">${layer.isBackground ? '🌄' : '✨'}</span>
<span style="flex:1;">${layer.name}</span>
<span style="font-size:0.68rem; color:var(--text-secondary);">Camada ${index}</span>
`;
chip.title = `${layer.isBackground ? 'Fundo da cena' : 'Elemento: ' + layer.name}`;
sceneLayersItems.appendChild(chip);
});
}
if (btnDecomposeScene) {
btnDecomposeScene.addEventListener('click', async () => {
const prompt = sceneDecomposePrompt ? sceneDecomposePrompt.value.trim() : '';
if (!prompt) {
showToast('Descreva a cena antes de gerar!', 'warning');
return;
}
// Garantir que o modo canvas está ativo
if (!canvasModeActive) {
if (cartazEditArea && cartazEditArea.style.display !== 'none') {
btnEditCartazToggle && btnEditCartazToggle.click();
}
enterCanvasMode();
}
// Mostrar loading
btnDecomposeScene.disabled = true;
btnDecomposeScene.style.opacity = '0.6';
if (sceneDecomposeLoader) {
sceneDecomposeLoader.style.display = 'flex';
}
if (sceneLayersList) sceneLayersList.style.display = 'none';
const statusSteps = [
'Planejando a cena com IA...',
'Identificando elementos e cenário...',
'Gerando imagem de fundo...',
'Gerando elementos individuais...',
'Montando as camadas no canvas...'
];
let stepIdx = 0;
const statusInterval = setInterval(() => {
if (sceneDecomposeStatus && stepIdx < statusSteps.length) {
sceneDecomposeStatus.textContent = statusSteps[stepIdx++];
}
}, 3500);
try {
const titulo = activeCartazData?.titulo || '';
const response = await fetch('/api/cartazes/decompose-scene', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ tema: prompt, titulo })
});
clearInterval(statusInterval);
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...';
// Limpar canvas antes de montar (remover outros items de cena se houver)
const oldLayers = cartazPrintableArea.querySelectorAll('.canvas-scene-layer');
oldLayers.forEach(l => l.remove());
// Adicionar background
if (data.backgroundUrl) {
addSceneLayerToCanvas({ url: data.backgroundUrl, name: 'Cenário de Fundo', isBackground: true });
}
// Adicionar cada elemento com posição escalonada
if (Array.isArray(data.layers)) {
data.layers.forEach((layer, idx) => {
if (layer.url) {
const spread = [
{ left: 10, top: 30 },
{ left: 38, top: 35 },
{ left: 62, top: 28 },
{ left: 25, top: 55 }
];
const pos = spread[idx % spread.length];
addSceneLayerToCanvas({
url: layer.url,
name: layer.name || `Elemento ${idx + 1}`,
isBackground: false,
left: pos.left,
top: pos.top,
width: 28,
height: 28,
zOffset: idx + 1
});
}
});
// Renderizar lista de camadas no painel
renderSceneLayers(data.layers);
if (sceneLayersList) sceneLayersList.style.display = 'flex';
}
showToast('🎬 Cena montada em camadas! Clique nos elementos para mover, girar e redimensionar.', 'success');
} catch (err) {
clearInterval(statusInterval);
console.error('[Decompose Scene]', err);
showToast('Erro ao criar a cena em camadas: ' + err.message, 'error');
} finally {
btnDecomposeScene.disabled = false;
btnDecomposeScene.style.opacity = '1';
if (sceneDecomposeLoader) sceneDecomposeLoader.style.display = 'none';
}
});
}
function enterCanvasMode() {
canvasModeActive = true;
if (btnCanvasModeToggle) {
+34 -5
View File
@@ -1330,7 +1330,7 @@
<!-- GERENCIADOR DE PROJETOS -->
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border-light); padding: 14px; border-radius: 12px; display: flex; flex-direction: column; gap: 10px;">
<label style="color: var(--text-secondary); font-size: 0.82rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">📁 Meus Projetos de Quadrinhos</label>
<div style="display: flex; gap: 10px; align-items: center;">
<div class="comics-project-row" style="display: flex; gap: 10px; align-items: center;">
<select id="comicsProjectSelect" class="obs-select" style="flex: 1; padding: 8px 12px; font-size: 0.9rem;">
<option value="">-- Carregar projeto salvo... --</option>
</select>
@@ -1338,7 +1338,7 @@
<button type="button" id="btnDeleteComicsProject" class="btn-music-option" style="padding: 8px 14px; font-size: 0.82rem; border-color: #ef4444; color: #ef4444; background: rgba(239, 68, 68, 0.05); margin: 0; white-space: nowrap; display: none;">🗑️ Excluir</button>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<div class="comics-project-row" style="display: flex; align-items: center; gap: 10px;">
<input type="text" id="comicsProjectTitleInput" class="obs-input" placeholder="Título do Projeto (Ex: Lucas e Mariana no Parque)" style="flex: 1; margin: 0; padding: 8px 12px; font-size: 0.9rem;">
<button type="button" id="btnSaveComicsProject" class="btn-music-option" style="padding: 8px 14px; font-size: 0.82rem; border-color: #10a37f; color: white; background: #10a37f; margin: 0; white-space: nowrap; font-weight: 600; display: none;">💾 Salvar Alterações</button>
<button type="button" id="btnSaveNewComicsProject" class="btn-music-option" style="padding: 8px 14px; font-size: 0.82rem; border-color: var(--brand-pink); color: white; background: var(--brand-pink); margin: 0; white-space: nowrap; font-weight: 600;">💾 Salvar como Novo</button>
@@ -1882,6 +1882,35 @@
<!-- Lado Esquerdo: Painel de Assets para Canvas -->
<div id="cartazLeftCanvasPanel" style="display: none; flex: 1; min-width: 320px; flex-direction: column; gap: 16px; padding-right: 12px;">
<h4 style="margin: 0; color: var(--text-primary); border-bottom: 1px dashed var(--border-light); padding-bottom: 8px;">🎨 Elementos do Canvas</h4>
<!-- 🎬 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;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.3rem;">🎬</span>
<div>
<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>
</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>
</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;">
<div class="loading-dot" style="width:8px;height:8px;border-radius:50%;background:#8b5cf6;animation:dot-pulse 1.2s ease-in-out 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.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>
</div>
<div class="settings-group">
<input type="text" id="canvasAssetSearch" placeholder="🔍 Buscar elementos..." class="obs-input" style="width: 100%; padding: 10px;">
@@ -1901,10 +1930,10 @@
</div>
<!-- Lado Direito: Preview & Editor de Código -->
<div style="flex: 1.3; min-width: 360px; display: flex; flex-direction: column; gap: 14px;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-light); padding-bottom: 8px;">
<div class="cartaz-preview-panel" style="flex: 1.3; min-width: 360px; display: flex; flex-direction: column; gap: 14px;">
<div class="cartaz-header-container" style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-light); padding-bottom: 8px;">
<h4 style="margin: 0; color: var(--text-primary);">👁️ Visualização do Cartaz</h4>
<div style="display: flex; gap: 8px;">
<div class="cartaz-header-actions" style="display: flex; gap: 8px;">
<button id="btnCanvasModeToggle" class="btn-save-settings" style="background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); padding: 5px 10px; font-size: 0.75rem;">🎨 Design Canvas</button>
<button id="btnEditCartazToggle" class="btn-save-settings" style="background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); padding: 5px 10px; font-size: 0.75rem;">✏️ Editar HTML</button>
<button id="btnPrintCartaz" class="btn-save-settings" style="background: #3b82f6; border: none; color: white; padding: 5px 12px; font-size: 0.75rem; font-weight: bold; border-radius: 6px;">🖨️ Imprimir Cartaz</button>
+120
View File
@@ -1555,6 +1555,84 @@ code {
padding-bottom: 16px !important;
}
#minhaTurmaModal .settings-modal-body {
overflow-y: auto !important;
flex: none !important;
min-height: auto !important;
max-height: calc(92dvh - 120px) !important;
}
#turmaConfigSection, #turmaAlunosSection {
flex-direction: column !important;
overflow-y: visible !important;
height: auto !important;
}
#turmaConfigSection > div, #turmaAlunosSection > div {
border-right: none !important;
border-bottom: 1px solid var(--border-light) !important;
padding-right: 0 !important;
padding-bottom: 16px !important;
flex: none !important;
height: auto !important;
width: 100% !important;
min-width: 0 !important;
}
#turmaConfigSection > div:last-child, #turmaAlunosSection > div:last-child {
border-bottom: none !important;
padding-bottom: 0 !important;
padding-left: 0 !important;
}
#turmaConfigSection select, #turmaConfigSection input,
#turmaAlunosSection select, #turmaAlunosSection input {
width: 100% !important;
}
.comics-project-row {
flex-direction: column !important;
align-items: stretch !important;
gap: 8px !important;
}
.comics-project-row select,
.comics-project-row input,
.comics-project-row button {
width: 100% !important;
margin: 0 !important;
}
.cartaz-preview-panel {
min-width: 0 !important;
width: 100% !important;
flex: none !important;
}
#cartazLeftConfigPanel, #cartazLeftCanvasPanel {
min-width: 0 !important;
width: 100% !important;
padding-right: 0 !important;
flex: none !important;
}
.cartaz-header-container {
flex-direction: column !important;
align-items: flex-start !important;
gap: 10px !important;
}
.cartaz-header-actions {
flex-wrap: wrap !important;
width: 100% !important;
justify-content: flex-start !important;
}
.cartaz-header-actions button {
flex: 1 1 auto !important;
text-align: center !important;
}
#modelosRelatorioModal .settings-modal-body > div:last-child {
padding-left: 0 !important;
padding-top: 16px !important;
@@ -3739,6 +3817,48 @@ code {
}
}
/* Camadas de Cena (Estúdio de Cenas em Camadas) */
.canvas-scene-layer {
padding: 0 !important;
background: transparent !important;
border-radius: 0 !important;
overflow: hidden;
}
.canvas-scene-layer:hover {
border-color: rgba(139, 92, 246, 0.8);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
}
.canvas-scene-layer.active-item {
border: 2.5px solid #8b5cf6 !important;
box-shadow: 0 0 14px rgba(139, 92, 246, 0.5);
}
/* Animação dos dots de loading */
@keyframes dot-pulse {
0%, 60%, 100% { transform: scale(1); opacity: 0.5; }
30% { transform: scale(1.4); opacity: 1; }
}
/* Botão Estúdio de Cenas */
#btnDecomposeScene:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
}
#btnDecomposeScene:disabled {
cursor: not-allowed;
}
/* Lista de camadas no painel */
#sceneLayersItems > div:hover {
background: rgba(139, 92, 246, 0.12) !important;
border-color: rgba(139, 92, 246, 0.4) !important;
}
+4 -2
View File
@@ -64,8 +64,9 @@ self.addEventListener('fetch', (event) => {
return fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, networkResponse.clone());
cache.put(event.request, responseToCache);
});
}
return networkResponse;
@@ -78,8 +79,9 @@ self.addEventListener('fetch', (event) => {
fetch(event.request)
.then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, networkResponse.clone());
cache.put(event.request, responseToCache);
});
}
return networkResponse;