Fix ORA editor layer visibility and mask preview

- Layer visibility toggles now show/hide individual layer images on canvas
- Added tint red checkbox per layer for manual mask verification
- Mask preview modal uses CSS mask-image to show red overlay only on selected areas (white pixels become semi-transparent red, black pixels remain transparent)
- Added spinner icons to Open and Extract Mask buttons during long operations
This commit is contained in:
2026-03-27 15:26:25 -07:00
parent 0c1fb8ccca
commit efe72ff065
2 changed files with 227 additions and 96 deletions

View File

@@ -99,7 +99,8 @@ All file paths are relative to the project root: `/home/noti/dev/ai-game-2`
| Operation | Description |
|-----------|-------------|
| **Toggle Visibility** | Show/hide layer on canvas |
| **Toggle Visibility** | Show/hide specific layer image on canvas |
| **Tint Red Preview** | Apply red tint to layer for mask verification (client-only) |
| **Rename** | Change layer/entity name |
| **Delete** | Remove layer from ORA |
| **Reorder** | Move layer up/down in stack (changes z-index) |
@@ -109,18 +110,25 @@ All file paths are relative to the project root: `/home/noti/dev/ai-game-2`
## Canvas Display
### Image Rendering
- Layers rendered as stacked `<img>` elements with CSS positioning
- Base layer at bottom, entity layers stacked above
- Visibility controlled by `opacity: 0` or `opacity: 1`
### Image Rendering
- Individual layers rendered as stacked `<img>` elements with CSS positioning
- Layers stacked in order from list (visual z-index matches layer order)
- Visibility togglecheckbox hides/shows each layer's image on canvas
- Tint checkbox applies semi-transparent red overlay for mask verification (client-only, visual aid)
- No server-side compositing for preview
### Layer DOM Structure
```html
<div class="relative w-full h-full">
<img src="/api/image/base" class="absolute inset-0">
<img src="/api/image/door_0" class="absolute inset-0" style="opacity: 1">
<img src="/api/image/chest_0" class="absolute inset-0" style="opacity: 0">
<!-- Individual layers rendered in order (x-show controls visibility) -->
<template x-for="layer in layers">
<img
x-show="layer.visible"
:src="'/api/image/layer/' + layer.name"
class="absolute inset-0"
:style="layer.tintRed ? 'mix-blend-multiply; opacity: 0.6;' : ''"
>
</template>
<!-- Polygon overlay when active -->
<canvas id="polygon-canvas" class="absolute inset-0 pointer-events-auto"></canvas>
</div>
@@ -193,9 +201,12 @@ canvas.addEventListener('click', (e) => {
### Mask Preview Modal
When mask is ready:
1. Full-screen modal appears
2. Shows base image with mask applied as colored tint overlay
2. Shows base image with semi-transparent red overlay where the mask is white (selected area)
- Uses CSS `mask-image` property to use grayscale values as alpha channel
- White pixels = fully opaque red tint, black/dark pixels = transparent (no tint)
- Dark/gray areas of the mask remain invisible so you can clearly see the mask boundary
3. Three buttons:
- **Re-roll**: Re-run extraction with same params
- **Re-roll**: Re-run extraction with same params
- **Use This Mask**: Add masked layer to ORA, close modal
- **Cancel**: Discard mask, close modal
@@ -355,6 +366,14 @@ Serve a layer PNG. Returns image data.
#### `GET /api/image/base`
Serve base/merged image.
#### `GET /api/image/layer/<layer_name>`
Serve a specific layer as image.
**Query params:**
- `ora_path`: Path to ORA file
**Response:** PNG image data or 404 if layer not found.
#### `GET /api/image/polygon`
Serve polygon overlay image (for drawing mode).
@@ -506,12 +525,13 @@ Check if temp file was modified.
```
┌──────────────────────────────────────────────────────────────────────┐
│ [Open: ________________________] [Open File] [Settings ⚙] │
│ [Open: ________________________] [🌀 Open] [Settings ⚙]
├───────────────────┬──────────────────────────────────────────────────┤
│ LAYERS │ │
│ ☑ base │ │
│ ☑ door_0 │ [Image Canvas with │
│ ☐ chest_0 │ stacked layers]
│ ☑ base │ │
│ ☑ door_0 │ [Image Canvas with │
│ ☐ chest_0 │ stacked layers - visibility toggles
│ │ show/hide individual layer images] │
│ │ │
│ [Rename] [Delete] │ │
│ [▲ Up] [▼ Down] │ │
@@ -528,7 +548,7 @@ Check if temp file was modified.
│ MASK EXTRACTION │ │
│ Subject: [______]│ │
│ ☑ Use polygon │ │
│ [Extract Mask]
│ [🌀 Extract Mask]│ (spinner shown when extracting)
│ │ │
│ ─────────────────│ │
│ [Open in Krita] │ │
@@ -538,6 +558,8 @@ Check if temp file was modified.
└──────────────────────────────────────────────────────────────────────┘
```
Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked)
---
## Mask Preview Modal
@@ -547,7 +569,8 @@ Check if temp file was modified.
│ EXTRACTED MASK │
│ ───────────────────────────────────────────────────── │
│ │
│ [Base image with mask applied as tinted overlay]
│ [Base image with RED mask overlay]
│ (mask shown in semi-transparent red) │
│ │
│ [Re-roll] [Use This Mask] [Cancel] │
│ │
@@ -557,6 +580,7 @@ Check if temp file was modified.
- **Re-roll**: Re-runs extraction with same params
- **Use This Mask**: Calls `POST /api/layer/add`, closes modal
- **Cancel**: Closes modal, mask discarded
- **Red tint**: Uses CSS filter to render grayscale mask as semi-transparent red
---

View File

@@ -10,7 +10,7 @@
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #374151; }
::-webkit-scrollbar-thumb { background: #6B7280; border-radius: 4px; }
.polygon-point { position: absolute; width: 8px; height: 8px; background: #0F0; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }
.polygon-point { position: absolute; width: 20px; height: 20px; background: #FFFFFF; border: 3px solid #FF0000; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; z-index: 100; }
</style>
<!-- Alpine data component - MUST be defined before x-data directive -->
@@ -26,15 +26,21 @@
imageWidth: 800,
imageHeight: 600,
// Display scale (percentage, default 25%)
scale: 25,
// Selection state
selectedLayer: null,
// Polygon state
isDrawing: false,
polygonPoints: [],
polygonColor: '#FF0000',
polygonWidth: 2,
polygonPreviewUrl: null,
clickTimeout: null,
pendingPoint: null,
// Mask extraction state
maskSubject: '',
@@ -184,13 +190,27 @@
});
},
startDrawing() {
startDrawing() {
console.log('[ORA EDITOR] Starting polygon drawing mode');
this.isDrawing = true;
this.polygonPoints = [];
// Reset click/double-click state for new drawing session
canvasDoubleClickPending = false;
// Clear the preview image from previous drawings (hide old overlay)
this.polygonPreviewUrl = null;
// Note: We don't clear backend storage here - let updatePolygonPreview() overwrite it later
// Setup canvas after it's created
setTimeout(() => this.setupCanvas(), 50);
setTimeout(() => {
this.setupCanvas();
const canvas = document.getElementById('polygonCanvas');
if (canvas) {
canvas.style.display = 'block';
}
}, 50);
},
setupCanvas() {
@@ -220,18 +240,74 @@
y = Math.max(0, Math.min(1, y));
this.polygonPoints.push({ x, y });
this.updatePolygonPreview();
this.drawPolygonOnCanvas();
},
async updatePolygonPreview() {
drawPolygonOnCanvas() {
const canvas = document.getElementById('polygonCanvas');
if (!canvas) return;
if (this.polygonPoints.length < 2) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (this.polygonPoints.length < 2) return;
// Draw line segments connecting points
ctx.beginPath();
ctx.strokeStyle = this.polygonColor;
ctx.lineWidth = this.polygonWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const startPoint = this.polygonPoints[0];
ctx.moveTo(startPoint.x * canvas.width, startPoint.y * canvas.height);
for (let i = 1; i < this.polygonPoints.length; i++) {
const point = this.polygonPoints[i];
ctx.lineTo(point.x * canvas.width, point.y * canvas.height);
}
// Close the polygon if we have 3+ points
if (this.polygonPoints.length >= 3) {
ctx.closePath();
}
ctx.stroke();
// Draw point markers
ctx.fillStyle = '#FFFFFF';
for (const point of this.polygonPoints) {
const px = point.x * canvas.width;
const py = point.y * canvas.height;
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.strokeStyle = this.polygonColor;
ctx.lineWidth = 2;
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.stroke();
}
},
async updatePolygonPreview() {
console.log('[UPDATE] Updating preview with', this.polygonPoints.length, 'points');
if (!this.oraPath || this.polygonPoints.length < 3) return;
const pointsToSend = [...this.polygonPoints]; // Make a copy
console.log('[UPDATE] Sending points:', JSON.stringify(pointsToSend));
const response = await fetch('/api/polygon', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ora_path: this.oraPath,
points: this.polygonPoints,
points: pointsToSend,
color: this.polygonColor,
width: this.polygonWidth
})
@@ -239,24 +315,47 @@
if (response.ok) {
const data = await response.json();
this.polygonPreviewUrl = data.overlay_url;
console.log('[UPDATE] Got preview URL:', data.overlay_url);
this.polygonPreviewUrl = data.overlay_url + '&ts=' + Date.now(); // Bust cache
}
},
finishDrawing() {
this.isDrawing = false;
async finishDrawing() {
console.log('[FINISH] finishDrawing called with', this.polygonPoints.length, 'points');
// Don't change isDrawing until after we've updated the preview!
if (this.polygonPoints.length >= 3) {
// Keep polygon visible
await this.updatePolygonPreview();
console.log('[FINISH] Preview URL set to:', this.polygonPreviewUrl);
// Now exit drawing mode - this will show the img with new URL
this.isDrawing = false;
// Hide canvas
const canvas = document.getElementById('polygonCanvas');
if (canvas) {
canvas.style.display = 'none';
}
} else {
this.clearPolygon();
}
},
clearPolygon() {
clearPolygon() {
this.isDrawing = false;
this.polygonPoints = [];
this.polygonPreviewUrl = null;
// Hide canvas
const canvas = document.getElementById('polygonCanvas');
if (canvas) {
canvas.style.display = 'none';
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
if (this.oraPath) {
fetch('/api/polygon/clear', {
method: 'POST',
@@ -299,8 +398,7 @@
this.showMaskModal = true;
} catch (e) {
console.error('[ORA EDITOR] Error extracting mask:', e);
document.getElementById('maskError').innerHTML = e.message;
document.getElementById('maskError').style.display = 'block';
this.lastError = e.message;
} finally {
this.isExtracting = false;
}
@@ -383,8 +481,11 @@
<button
@click="openFile()"
:disabled="!filePath || isLoading"
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
>Open</button>
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded transition flex items-center justify-center gap-2"
>
<span x-show="isLoading" class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></span>
<span x-text="isLoading ? 'Opening...' : 'Open'"></span>
</button>
</div>
<button
@click="openInKrita()"
@@ -401,6 +502,18 @@
:disabled="!oraPath || isLoading"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
>Save</button>
<div class="flex items-center gap-2 bg-gray-800 rounded px-3 py-2 border border-gray-700">
<span class="text-sm text-gray-300">Scale:</span>
<input
type="range"
x-model.number="scale"
min="10"
max="100"
step="5"
class="w-32 accent-blue-500"
>
<span class="text-sm text-gray-300 w-12" x-text="scale + '%'"></span>
</div>
<button
@click="showSettings = true"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded transition"
@@ -430,8 +543,16 @@
:checked="layer.visible"
@change="toggleVisibility(layer.name, $event.target.checked)"
class="w-4 h-4 rounded"
title="Toggle visibility"
>
<span class="flex-1 text-sm truncate" x-text="layer.name"></span>
<input
type="checkbox"
:checked="layer.tintRed || false"
@change="layer.tintRed = $event.target.checked"
class="w-4 h-4 rounded"
title="Tint red for preview"
>
</div>
</template>
</div>
@@ -496,8 +617,9 @@
<button
@click="extractMask()"
:disabled="!maskSubject.trim() || isExtracting || !oraPath"
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 px-4 py-2 rounded transition flex items-center justify-center gap-2"
>
<span x-show="isExtracting" class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></span>
<span x-show="!isExtracting">Extract Mask</span>
<span x-show="isExtracting">Extracting...</span>
</button>
@@ -510,18 +632,25 @@
<!-- Main canvas area -->
<main class="flex-1 bg-gray-800 rounded-lg border border-gray-700 p-4 relative overflow-auto" style="min-height: 600px;">
<div id="imageContainer" x-show="!isLoading && !error && layers.length > 0" class="relative inline-block"
:style="`width: ${imageWidth}px; height: ${imageHeight}px; position: relative;`">
<div id="imageContainer" x-show="!isLoading && !error && layers.length > 0" class="relative inline-block origin-top-left"
:style="`width: ${imageWidth}px; height: ${imageHeight}px; transform: scale(${scale / 100});`">
<!-- Layer display -->
<div class="relative w-full h-full">
<!-- Base image -->
<img
:src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)"
class="absolute inset-0 w-full h-full object-contain"
@load="initCanvas"
id="baseImage"
>
<!-- Individual layer images (rendered in order) -->
<template x-for="layer in layers" :key="'layer-' + layer.name">
<img
x-show="layer.visible"
:src="'/api/image/layer/' + encodeURIComponent(layer.name) + '?ora_path=' + encodeURIComponent(oraPath)"
class="absolute inset-0 w-full h-full object-contain"
>
<!-- Red tint overlay when enabled -->
<div
x-show="layer.visible && layer.tintRed"
class="absolute inset-0 bg-red-500 mix-blend-multiply"
style="opacity: 0.5;"
></div>
</template>
<!-- Polygon points markers when drawing -->
<template x-for="(point, idx) in polygonPoints" :key="idx">
@@ -545,8 +674,8 @@
:width="imageWidth"
:height="imageHeight"
class="absolute inset-0 cursor-crosshair pointer-events-auto border-2 border-dashed border-blue-500 opacity-90"
@click="handleCanvasClick"
@dblclick="handleCanvasDoubleClick"
@click="handleCanvasClick($event)"
@dblclick="handleCanvasDoubleClick($event)"
></canvas>
</div>
@@ -578,13 +707,21 @@
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 border border-gray-600" style="max-height: 90vh; display: flex; flex-direction: column;">
<h2 class="text-xl font-bold mb-4">Extracted Mask</h2>
<div class="flex-1 overflow-auto bg-gray-700 rounded mb-4 relative" :style="`min-height: 300px; max-height: 50vh;`">
<!-- Base image -->
<img :src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)" class="relative w-full h-auto object-contain">
<!-- Mask overlay with tint -->
<div x-show="tempMaskUrl" class="absolute inset-0 bg-green-500 opacity-30 pointer-events-none">
<img :src="tempMaskUrl" class="w-full h-auto object-contain mix-blend-screen">
<div class="flex-1 overflow-auto bg-gray-700 rounded mb-4 flex items-center justify-center p-4" :style="`min-height: 300px; max-height: 50vh;`">
<!-- Container for properly aligned base + mask -->
<div class="relative inline-block">
<!-- Base image -->
<img
:src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)"
class="border border-gray-600"
style="max-width: 100%; max-height: calc(50vh - 2rem);"
>
<!-- Semi-transparent red overlay with mask applied as alpha channel -->
<div x-show="tempMaskUrl"
class="absolute inset-0 pointer-events-none border border-gray-600"
:style="`background-color: rgba(255, 80, 80, 0.6); -webkit-mask-image: url(${tempMaskUrl}); mask-image: url(${tempMaskUrl}); -webkit-mask-size: cover; mask-size: cover; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;`">
</div>
</div>
</div>
@@ -621,8 +758,10 @@
</div>
<!-- Minimal JS for canvas polygon drawing -->
<!-- Canvas click handler - works with Alpine -->
<script>
let canvasDoubleClickPending = false;
function initCanvas() {
const canvas = document.getElementById('polygonCanvas');
if (!canvas) return;
@@ -632,39 +771,13 @@ function initCanvas() {
canvas.style.height = canvas.parentElement.offsetHeight + 'px';
}
// Access Alpine store for canvas click
function handleCanvasClick(e) {
const container = e.target.closest('[x-data]');
if (!container) return;
// Get click position
const rect = container.getBoundingClientRect();
let x = (e.clientX - rect.left) / rect.width;
let y = (e.clientY - rect.top) / rect.height;
// Normalize to 0-1 range
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
// Get Alpine component and add point
const alpineComponent = $data(container);
if (alpineComponent) {
alpineComponent.addPolygonPoint(x, y);
// Skip click if it was part of a double-click
if (canvasDoubleClickPending) {
return;
}
}
function handleCanvasDoubleClick(e) {
e.preventDefault();
const alpineComponent = $data(e.target.closest('[x-data]'));
if (alpineComponent) {
alpineComponent.finishDrawing();
}
}
<!-- Canvas click handler - works with Alpine -->
<script>
function handleCanvasClick(e) {
const container = e.target.closest('#imageContainer');
const container = document.getElementById('imageContainer');
if (!container) return;
const rect = container.getBoundingClientRect();
@@ -679,8 +792,8 @@ function handleCanvasClick(e) {
const alpineEl = container.closest('[x-data]');
if (!alpineEl) return;
// Get the Alpine component instance using $data
const store = $data(alpineEl);
// Get Alpine component instance using Alpine.$data
const store = Alpine.$data(alpineEl);
if (!store) return;
store.addPolygonPoint(x, y);
@@ -688,13 +801,16 @@ function handleCanvasClick(e) {
function handleCanvasDoubleClick(e) {
e.preventDefault();
const container = e.target.closest('#imageContainer');
// Mark that a double-click happened so we skip the subsequent clicks
canvasDoubleClickPending = true;
const container = document.getElementById('imageContainer');
if (!container) return;
const alpineEl = container.closest('[x-data]');
if (!alpineEl) return;
const store = $data(alpineEl);
const store = Alpine.$data(alpineEl);
if (!store) return;
if (store.isDrawing && store.polygonPoints.length >= 3) {
@@ -702,16 +818,7 @@ function handleCanvasDoubleClick(e) {
}
}
// Wait for Alpine to initialize, then setup canvas
document.addEventListener('alpine:init', () => {
setTimeout(() => {
const canvas = document.getElementById('polygonCanvas');
if (canvas) {
canvas.addEventListener('click', handleCanvasClick);
canvas.addEventListener('dblclick', handleCanvasDoubleClick);
}
}, 200);
});
</script>
</body>
</html>