Add ora_ops module for ORA file operations
- Implements core ORA reading/writing functions - Layer add, rename, delete, reorder, visibility operations - Full test suite with 8 passing tests
This commit is contained in:
4
tools/ora_editor/.gitignore
vendored
Normal file
4
tools/ora_editor/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
temp/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
753
tools/ora_editor/ORA_EDITOR.md
Normal file
753
tools/ora_editor/ORA_EDITOR.md
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
# ORA Editor - Browser-Based Image Layer Editor
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A Flask + Alpine.js web application for editing OpenRaster (ORA) files and PNG images with layer management, polygon-based mask extraction hints, and Krita integration.
|
||||||
|
|
||||||
|
**Location:** `tools/ora_editor/`
|
||||||
|
|
||||||
|
**Tech Stack:**
|
||||||
|
- Backend: Flask (Python 3)
|
||||||
|
- Frontend: Alpine.js + Tailwind CSS (CDN)
|
||||||
|
- Image Processing: Pillow
|
||||||
|
- Custom JS: Minimal (~50 lines for polygon canvas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/ora_editor/
|
||||||
|
├── app.py # Flask application
|
||||||
|
├── ora_ops.py # ORA operations wrapper (functions from ora_edit.py)
|
||||||
|
├── templates/
|
||||||
|
│ └── editor.html # Single-page Alpine.js UI
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── temp/ # Krita temp files (gitignored)
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Python Packages (`requirements.txt`)
|
||||||
|
```
|
||||||
|
flask>=3.0.0
|
||||||
|
pillow>=10.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### External Requirements
|
||||||
|
- **ComfyUI** running at configurable address (default: `127.0.0.1:8188`)
|
||||||
|
- **Krita** desktop application (for "Open in Krita" feature)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Choices
|
||||||
|
|
||||||
|
### Why Flask + Alpine.js?
|
||||||
|
- Flask: Minimal Python web framework, mirrors the CLI tool workflow
|
||||||
|
- Alpine.js: Reactive UI with zero custom JS, perfect for this use case
|
||||||
|
- Tailwind CSS (CDN): Rapid styling without build step
|
||||||
|
|
||||||
|
### Why Minimal Custom JS?
|
||||||
|
The polygon drawing requires capturing mouse clicks on a canvas. This is the only interaction that Alpine.js cannot handle natively. Everything else (layer toggles, modals, forms) is pure Alpine.js.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Operations
|
||||||
|
|
||||||
|
### Root Directory
|
||||||
|
All file paths are relative to the project root: `/home/noti/dev/ai-game-2`
|
||||||
|
|
||||||
|
### Supported Formats
|
||||||
|
- **ORA** (OpenRaster): Native format with layer support
|
||||||
|
- **PNG**: Auto-converted to ORA on open
|
||||||
|
|
||||||
|
### Open Flow
|
||||||
|
1. User enters path relative to project root (e.g., `scenes/kq4_010/pic.png`)
|
||||||
|
2. Backend checks if file exists
|
||||||
|
3. If PNG: auto-create ORA with single `base` layer
|
||||||
|
4. Parse ORA structure, extract layers
|
||||||
|
5. Return layer list + image data URLs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer System
|
||||||
|
|
||||||
|
### Layer Data Structure
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "door_0", # Layer identifier
|
||||||
|
"is_group": False, # False for regular layers
|
||||||
|
"visibility": True, # Visible by default
|
||||||
|
"src": "data/door_0.png", # Path inside ORA zip
|
||||||
|
"opacity": 1.0, # Opacity 0.0-1.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ORA Structure Created
|
||||||
|
```
|
||||||
|
📁 Group: <entity_name> (e.g., "door")
|
||||||
|
└─ <entity_name>_0 (e.g., "door_0")
|
||||||
|
📁 Group: <another_entity>
|
||||||
|
└─ <another_entity>_0
|
||||||
|
🖼️ Base: base
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layer Operations
|
||||||
|
|
||||||
|
| Operation | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **Toggle Visibility** | Show/hide layer on canvas |
|
||||||
|
| **Rename** | Change layer/entity name |
|
||||||
|
| **Delete** | Remove layer from ORA |
|
||||||
|
| **Reorder** | Move layer up/down in stack (changes z-index) |
|
||||||
|
| **Add Masked** | Add new layer with alpha mask |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- 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">
|
||||||
|
<!-- Polygon overlay when active -->
|
||||||
|
<canvas id="polygon-canvas" class="absolute inset-0 pointer-events-auto"></canvas>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Polygon Drawing
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
The polygon is **temporary** - it's a visual guide to help the AI mask extraction understand what area to focus on. It does NOT affect the final ORA file directly.
|
||||||
|
|
||||||
|
### Coordinate System
|
||||||
|
- Points stored as percentages (0.0-1.0) of image dimensions
|
||||||
|
- Sent to backend for mask extraction hint
|
||||||
|
- Drawn on a canvas overlay
|
||||||
|
|
||||||
|
### User Interaction
|
||||||
|
1. Click "Start Drawing" button
|
||||||
|
2. Click on image to add points (shown as small circles)
|
||||||
|
3. Right-click or double-click to close polygon
|
||||||
|
4. Polygon renders with selected color and line width
|
||||||
|
5. "Clear" removes polygon
|
||||||
|
|
||||||
|
### Backend Storage
|
||||||
|
Polygon points stored in memory on server (per-request, not session). Used only when submitting mask extraction with `use_polygon: true`.
|
||||||
|
|
||||||
|
### Implementation (Minimal JS)
|
||||||
|
```javascript
|
||||||
|
// Canvas click handler (only custom JS needed)
|
||||||
|
canvas.addEventListener('click', (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / rect.width;
|
||||||
|
const y = (e.clientY - rect.top) / rect.height;
|
||||||
|
alpineStore.addPolygonPoint(x, y);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mask Extraction (ComfyUI)
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
1. User describes subject (e.g., "the wooden door")
|
||||||
|
2. Optionally enable "Use polygon hint"
|
||||||
|
3. Click "Extract Mask"
|
||||||
|
|
||||||
|
### API: `POST /api/mask/extract`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": "the wooden door",
|
||||||
|
"use_polygon": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Process
|
||||||
|
1. If `use_polygon: true`, fetch stored polygon points
|
||||||
|
2. Draw polygon overlay on input image (using `draw_polygon.py` logic)
|
||||||
|
3. Build prompt: "Create a black and white alpha mask of {subject}"
|
||||||
|
4. Queue workflow to ComfyUI at configured server URL
|
||||||
|
5. Poll for completion (timeout: 4 minutes)
|
||||||
|
6. Download resulting mask to `temp/` directory
|
||||||
|
7. Return mask path
|
||||||
|
|
||||||
|
### ComfyUI Integration
|
||||||
|
- Server address stored in localStorage (client-side setting)
|
||||||
|
- Uses existing `tools/image_mask_extraction.json` workflow
|
||||||
|
- Same extraction logic as `extract_mask.py`
|
||||||
|
|
||||||
|
### Mask Preview Modal
|
||||||
|
When mask is ready:
|
||||||
|
1. Full-screen modal appears
|
||||||
|
2. Shows base image with mask applied as colored tint overlay
|
||||||
|
3. Three buttons:
|
||||||
|
- **Re-roll**: Re-run extraction with same params
|
||||||
|
- **Use This Mask**: Add masked layer to ORA, close modal
|
||||||
|
- **Cancel**: Discard mask, close modal
|
||||||
|
|
||||||
|
### Modal Display
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [Base image with mask applied as tint overlay] │
|
||||||
|
│ │
|
||||||
|
│ [Re-roll] [Use This Mask] [Cancel] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krita Integration
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
1. User selects a layer and clicks "Open in Krita"
|
||||||
|
2. Backend extracts layer PNG to `temp/{layer_name}_{timestamp}.png`
|
||||||
|
3. Returns `file://` URL
|
||||||
|
4. Browser opens URL (Krita launches with file)
|
||||||
|
5. User edits in Krita, saves
|
||||||
|
|
||||||
|
### File Watching
|
||||||
|
Since browser cannot detect when Krita saves:
|
||||||
|
- **On tab visibility change**: Check file mtime, auto-reload if changed
|
||||||
|
- **Manual "Refresh" button**: Force reload from disk
|
||||||
|
- **No automatic save back**: User explicitly saves in Krita, then refreshes
|
||||||
|
|
||||||
|
### Temp File Management
|
||||||
|
- Temp files stored in `tools/ora_editor/temp/`
|
||||||
|
- Named: `{layer_name}_{unix_timestamp}.png`
|
||||||
|
- Not automatically deleted (low priority)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### File Operations
|
||||||
|
|
||||||
|
#### `POST /api/open`
|
||||||
|
Open a PNG or ORA file.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"path": "scenes/kq4_010/pic.png"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"file_path": "/home/noti/dev/ai-game-2/scenes/kq4_010/pic.ora",
|
||||||
|
"ora_path": "/home/noti/dev/ai-game-2/scenes/kq4_010/pic.ora",
|
||||||
|
"layers": [
|
||||||
|
{"name": "base", "visibility": true, "src": "data/base.png"},
|
||||||
|
{"name": "door_0", "visibility": true, "src": "data/door/door_0.png"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/save`
|
||||||
|
Persist current layer state to ORA file.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"ora_path": "scenes/kq4_010/pic.ora"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Layer Operations
|
||||||
|
|
||||||
|
#### `GET /api/layers`
|
||||||
|
Get current layer structure.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"layers": [
|
||||||
|
{"name": "base", "visibility": true, "src": "data/base.png"},
|
||||||
|
{"name": "door_0", "visibility": true, "src": "data/door/door_0.png"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/layer/add`
|
||||||
|
Add a new masked layer.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity_name": "door",
|
||||||
|
"mask_path": "/tmp/mask.png",
|
||||||
|
"source_layer": "base"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true, "layer_name": "door_1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/layer/rename`
|
||||||
|
Rename a layer.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"old_name": "door_0", "new_name": "wooden_door_0"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/layer/delete`
|
||||||
|
Delete a layer.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"layer_name": "door_0"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/layer/reorder`
|
||||||
|
Move layer up or down.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"layer_name": "door_0", "direction": "up"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Image Serving
|
||||||
|
|
||||||
|
#### `GET /api/image/<layer_name>`
|
||||||
|
Serve a layer PNG. Returns image data.
|
||||||
|
|
||||||
|
#### `GET /api/image/base`
|
||||||
|
Serve base/merged image.
|
||||||
|
|
||||||
|
#### `GET /api/image/polygon`
|
||||||
|
Serve polygon overlay image (for drawing mode).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Polygon
|
||||||
|
|
||||||
|
#### `POST /api/polygon`
|
||||||
|
Draw polygon overlay on current image.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"points": [{"x": 0.1, "y": 0.2}, {"x": 0.5, "y": 0.8}],
|
||||||
|
"color": "#FF0000",
|
||||||
|
"width": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true, "overlay_url": "/api/image/polygon"}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/polygon/points`
|
||||||
|
Store polygon points for mask extraction.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"points": [{"x": 0.1, "y": 0.2}, {"x": 0.5, "y": 0.8}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/polygon/clear`
|
||||||
|
Clear polygon overlay.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{"success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Mask Extraction
|
||||||
|
|
||||||
|
#### `POST /api/mask/extract`
|
||||||
|
Extract mask using ComfyUI.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": "the wooden door",
|
||||||
|
"use_polygon": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"mask_path": "/home/noti/dev/ai-game-2/tools/ora_editor/temp/mask.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Krita
|
||||||
|
|
||||||
|
#### `POST /api/krita/open`
|
||||||
|
Copy layer to temp and return file URL.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{"layer_name": "base"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"file_url": "file:///home/noti/dev/ai-game-2/tools/ora_editor/temp/base_1234567890.png",
|
||||||
|
"temp_path": "/home/noti/dev/ai-game-2/tools/ora_editor/temp/base_1234567890.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/krita/status/<layer_name>`
|
||||||
|
Check if temp file was modified.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modified": true,
|
||||||
|
"mtime": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend State (Alpine.js)
|
||||||
|
|
||||||
|
### Main Store (`x-data`)
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
// File state
|
||||||
|
filePath: '',
|
||||||
|
oraPath: '',
|
||||||
|
layers: [],
|
||||||
|
baseImage: '',
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
selectedLayer: null,
|
||||||
|
isDrawingPolygon: false,
|
||||||
|
polygonPoints: [],
|
||||||
|
polygonColor: '#FF0000',
|
||||||
|
polygonWidth: 2,
|
||||||
|
|
||||||
|
// Mask extraction
|
||||||
|
maskSubject: '',
|
||||||
|
usePolygonHint: true,
|
||||||
|
isExtracting: false,
|
||||||
|
showMaskModal: false,
|
||||||
|
tempMaskPath: null,
|
||||||
|
|
||||||
|
// Krita
|
||||||
|
kritaLayer: null,
|
||||||
|
kritaMtime: null,
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
comfyUrl: localStorage.getItem('comfy_url') || '127.0.0.1:8188',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layer Object
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
name: 'door_0',
|
||||||
|
visibility: true,
|
||||||
|
src: 'data/door/door_0.png'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [Open: ________________________] [Open File] [Settings ⚙] │
|
||||||
|
├───────────────────┬──────────────────────────────────────────────────┤
|
||||||
|
│ LAYERS │ │
|
||||||
|
│ ☑ base │ │
|
||||||
|
│ ☑ door_0 │ [Image Canvas with │
|
||||||
|
│ ☐ chest_0 │ stacked layers] │
|
||||||
|
│ │ │
|
||||||
|
│ [Rename] [Delete] │ │
|
||||||
|
│ [▲ Up] [▼ Down] │ │
|
||||||
|
│ │ │
|
||||||
|
│ ─────────────────│ │
|
||||||
|
│ POLYGON TOOL │ │
|
||||||
|
│ Color: [#FF0000] │ │
|
||||||
|
│ Width: [2____] │ │
|
||||||
|
│ [Start Drawing] │ │
|
||||||
|
│ Points: 5 │ │
|
||||||
|
│ [Clear] │ │
|
||||||
|
│ │ │
|
||||||
|
│ ─────────────────│ │
|
||||||
|
│ MASK EXTRACTION │ │
|
||||||
|
│ Subject: [______]│ │
|
||||||
|
│ ☑ Use polygon │ │
|
||||||
|
│ [Extract Mask] │ │
|
||||||
|
│ │ │
|
||||||
|
│ ─────────────────│ │
|
||||||
|
│ [Open in Krita] │ │
|
||||||
|
│ │ │
|
||||||
|
├───────────────────┴──────────────────────────────────────────────────┤
|
||||||
|
│ [Save] [Refresh from Krita] │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mask Preview Modal
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ EXTRACTED MASK │
|
||||||
|
│ ───────────────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ [Base image with mask applied as tinted overlay] │
|
||||||
|
│ │
|
||||||
|
│ [Re-roll] [Use This Mask] [Cancel] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Re-roll**: Re-runs extraction with same params
|
||||||
|
- **Use This Mask**: Calls `POST /api/layer/add`, closes modal
|
||||||
|
- **Cancel**: Closes modal, mask discarded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Modal
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ SETTINGS │
|
||||||
|
│ ───────────────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ ComfyUI Server: [127.0.0.1:8188____________] │
|
||||||
|
│ │
|
||||||
|
│ [Save] [Cancel] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Settings stored in `localStorage`
|
||||||
|
- ComfyURL stored as `comfy_url` key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Phase 1: Project Setup
|
||||||
|
- [ ] Create directory structure (`templates/`, `temp/`)
|
||||||
|
- [ ] Create `requirements.txt`
|
||||||
|
- [ ] Create `.gitignore`
|
||||||
|
- [ ] Test virtualenv/dependencies
|
||||||
|
|
||||||
|
### Phase 2: Backend Core
|
||||||
|
- [ ] Create `ora_ops.py` with wrapper functions:
|
||||||
|
- [ ] `load_ora(path)` - Parse ORA, return layers + images
|
||||||
|
- [ ] `save_ora(ora_path)` - Rebuild ORA from current state
|
||||||
|
- [ ] `add_masked_layer(ora_path, entity, mask_path, source)` - Apply mask
|
||||||
|
- [ ] `rename_layer(ora_path, old, new)` - Update layer name
|
||||||
|
- [ ] `delete_layer(ora_path, name)` - Remove layer
|
||||||
|
- [ ] `reorder_layer(ora_path, name, direction)` - Move up/down
|
||||||
|
- [ ] `get_layer_image(ora_path, layer_name)` - Extract PNG
|
||||||
|
- [ ] `get_base_image(ora_path)` - Get merged/composited base
|
||||||
|
- [ ] Create `app.py` with Flask routes:
|
||||||
|
- [ ] File operations (`/api/open`, `/api/save`)
|
||||||
|
- [ ] Layer operations (all `/api/layer/*`)
|
||||||
|
- [ ] Image serving (`/api/image/*`)
|
||||||
|
- [ ] Polygon (`/api/polygon*`)
|
||||||
|
- [ ] Mask extraction (`/api/mask/extract`)
|
||||||
|
- [ ] Krita (`/api/krita/*`)
|
||||||
|
|
||||||
|
### Phase 3: Frontend UI
|
||||||
|
- [ ] Create `editor.html` with Tailwind + Alpine.js
|
||||||
|
- [ ] File open input and handler
|
||||||
|
- [ ] Layer list with visibility toggles
|
||||||
|
- [ ] Canvas area with stacked layer images
|
||||||
|
- [ ] Layer operations (rename, delete, reorder buttons)
|
||||||
|
|
||||||
|
### Phase 4: Polygon Drawing
|
||||||
|
- [ ] Polygon tool UI (color, width, start/clear)
|
||||||
|
- [ ] Canvas overlay for polygon rendering
|
||||||
|
- [ ] Minimal JS for click-to-add-points
|
||||||
|
- [ ] Backend polygon drawing endpoint
|
||||||
|
|
||||||
|
### Phase 5: Mask Extraction
|
||||||
|
- [ ] Mask extraction form UI
|
||||||
|
- [ ] Backend ComfyUI integration
|
||||||
|
- [ ] Mask preview modal
|
||||||
|
- [ ] Re-roll / Use This Mask / Cancel logic
|
||||||
|
|
||||||
|
### Phase 6: Krita Integration
|
||||||
|
- [ ] "Open in Krita" button and endpoint
|
||||||
|
- [ ] File URL generation
|
||||||
|
- [ ] Refresh button and logic
|
||||||
|
- [ ] Visibility-change auto-reload (JS)
|
||||||
|
|
||||||
|
### Phase 7: Polish
|
||||||
|
- [ ] Settings modal for ComfyUI URL
|
||||||
|
- [ ] Error handling and user feedback
|
||||||
|
- [ ] Loading states
|
||||||
|
- [ ] Empty states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### ORA File Structure
|
||||||
|
ORA is a ZIP file containing:
|
||||||
|
```
|
||||||
|
mimetype # "image/openraster"
|
||||||
|
stack.xml # Layer structure XML
|
||||||
|
mergedimage.png # Composited full image
|
||||||
|
Thumbnails/thumbnail.png # Preview thumbnail
|
||||||
|
data/
|
||||||
|
base.png # Base layer
|
||||||
|
<entity>/
|
||||||
|
<entity>_0.png # Masked layer
|
||||||
|
```
|
||||||
|
|
||||||
|
### stack.xml Format
|
||||||
|
```xml
|
||||||
|
<image version="0.0.3" w="800" h="600">
|
||||||
|
<stack>
|
||||||
|
<layer name="base" src="data/base.png" opacity="1.0"/>
|
||||||
|
<stack name="door">
|
||||||
|
<layer name="door_0" src="data/door/door_0.png" opacity="1.0"/>
|
||||||
|
</stack>
|
||||||
|
</stack>
|
||||||
|
</image>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Compositing for Preview
|
||||||
|
- Frontend receives individual layer PNG URLs
|
||||||
|
- Layers stacked with CSS `position: absolute`
|
||||||
|
- Visibility toggled via CSS `opacity`
|
||||||
|
- No server-side compositing needed
|
||||||
|
|
||||||
|
### Polygon Overlay Rendering
|
||||||
|
1. Backend receives points as `[{x: 0.1, y: 0.2}, ...]`
|
||||||
|
2. Convert to absolute pixel coordinates
|
||||||
|
3. Use Pillow `ImageDraw.polygon()` to draw
|
||||||
|
4. Return overlay as PNG
|
||||||
|
|
||||||
|
### ComfyUI Workflow
|
||||||
|
Uses existing `tools/image_mask_extraction.json`:
|
||||||
|
1. Load input image
|
||||||
|
2. If polygon hint: composite polygon overlay
|
||||||
|
3. Set prompt text
|
||||||
|
4. Queue prompt to ComfyUI
|
||||||
|
5. Poll for completion
|
||||||
|
6. Download output mask
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Reference
|
||||||
|
|
||||||
|
### Default Polygon Colors
|
||||||
|
| Name | Hex |
|
||||||
|
|------|-----|
|
||||||
|
| Red | #FF0000 |
|
||||||
|
| Green | #00FF00 |
|
||||||
|
| Blue | #0000FF |
|
||||||
|
| Yellow | #FFFF00 |
|
||||||
|
|
||||||
|
### Mask Tint Colors (Preview)
|
||||||
|
Default: #00FF0080 (semi-transparent green)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | User Feedback |
|
||||||
|
|-------|---------------|
|
||||||
|
| File not found | "File not found: {path}" |
|
||||||
|
| Invalid ORA | "Invalid ORA file format" |
|
||||||
|
| ComfyUI offline | "Cannot connect to ComfyUI at {url}" |
|
||||||
|
| ComfyUI timeout | "Mask extraction timed out (4 min)" |
|
||||||
|
| Layer not found | "Layer '{name}' not found" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
Tested/supported:
|
||||||
|
- Modern browsers with ES6+ support
|
||||||
|
- Chrome/Edge (primary)
|
||||||
|
- Firefox
|
||||||
|
- Safari
|
||||||
|
|
||||||
|
Required features:
|
||||||
|
- ES6 modules
|
||||||
|
- CSS Grid/Flexbox
|
||||||
|
- Canvas API (for polygon)
|
||||||
|
- localStorage (for settings)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/ora_editor
|
||||||
|
source ../venv/bin/activate # or your Python env
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
# Opens at http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations (Out of Scope)
|
||||||
|
|
||||||
|
- Multi-select layers
|
||||||
|
- Drag-and-drop reordering
|
||||||
|
- Undo/redo history
|
||||||
|
- Collaborative editing
|
||||||
|
- Additional export formats
|
||||||
|
- Layer groups in UI (show hierarchy)
|
||||||
626
tools/ora_editor/ora_ops.py
Normal file
626
tools/ora_editor/ora_ops.py
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ORA operations wrapper for the web editor."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stack_xml(ora_path: str) -> ET.Element:
|
||||||
|
"""Parse stack.xml from an ORA file."""
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
return ET.fromstring(zf.read('stack.xml'))
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_size(root: ET.Element) -> tuple[int, int]:
|
||||||
|
"""Get image dimensions from stack.xml root element."""
|
||||||
|
return int(root.get('w', 0)), int(root.get('h', 0))
|
||||||
|
|
||||||
|
|
||||||
|
def get_groups_and_layers(root: ET.Element) -> list[dict[str, Any]]:
|
||||||
|
"""Extract groups and their layers from stack.xml."""
|
||||||
|
groups = []
|
||||||
|
root_stack = root.find('stack')
|
||||||
|
if root_stack is None:
|
||||||
|
return groups
|
||||||
|
|
||||||
|
for child in root_stack:
|
||||||
|
if child.tag == 'stack':
|
||||||
|
group_name = child.get('name', 'unnamed')
|
||||||
|
layers = []
|
||||||
|
for layer in child:
|
||||||
|
if layer.tag == 'layer':
|
||||||
|
layers.append({
|
||||||
|
'name': layer.get('name', 'unnamed'),
|
||||||
|
'src': layer.get('src', ''),
|
||||||
|
'opacity': layer.get('opacity', '1.0'),
|
||||||
|
'visibility': layer.get('visibility', 'visible')
|
||||||
|
})
|
||||||
|
groups.append({
|
||||||
|
'name': group_name,
|
||||||
|
'is_group': True,
|
||||||
|
'layers': layers
|
||||||
|
})
|
||||||
|
elif child.tag == 'layer':
|
||||||
|
groups.append({
|
||||||
|
'name': child.get('name', 'unnamed'),
|
||||||
|
'is_group': False,
|
||||||
|
'layers': [{
|
||||||
|
'name': child.get('name', 'unnamed'),
|
||||||
|
'src': child.get('src', ''),
|
||||||
|
'opacity': child.get('opacity', '1.0'),
|
||||||
|
'visibility': child.get('visibility', 'visible')
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_layer_index(group: dict, entity_name: str) -> int:
|
||||||
|
"""Get the next auto-increment index for layers in an entity group."""
|
||||||
|
max_index = -1
|
||||||
|
pattern = re.compile(rf'^{re.escape(entity_name)}_(\d+)$')
|
||||||
|
|
||||||
|
for layer in group['layers']:
|
||||||
|
match = pattern.match(layer['name'])
|
||||||
|
if match:
|
||||||
|
max_index = max(max_index, int(match.group(1)))
|
||||||
|
|
||||||
|
return max_index + 1
|
||||||
|
|
||||||
|
|
||||||
|
def find_entity_group(groups: list, entity_name: str) -> dict | None:
|
||||||
|
"""Find an existing entity group by name."""
|
||||||
|
for group in groups:
|
||||||
|
if group.get('is_group') and group['name'] == entity_name:
|
||||||
|
return group
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_layer(groups: list, layer_name: str) -> tuple[dict, dict] | None:
|
||||||
|
"""Find a layer by name across all groups. Returns (group, layer) tuple or None."""
|
||||||
|
for group in groups:
|
||||||
|
for layer in group['layers']:
|
||||||
|
if layer['name'] == layer_name:
|
||||||
|
return group, layer
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_layer_image(ora_path: str, layer_src: str) -> Image.Image:
|
||||||
|
"""Extract a layer image from the ORA file."""
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
image_data = zf.read(layer_src)
|
||||||
|
return Image.open(io.BytesIO(image_data)).convert('RGBA')
|
||||||
|
|
||||||
|
|
||||||
|
def apply_mask(source_image: Image.Image, mask_image: Image.Image) -> Image.Image:
|
||||||
|
"""Apply a mask as the alpha channel of an image."""
|
||||||
|
if mask_image.mode != 'L':
|
||||||
|
mask_image = mask_image.convert('L')
|
||||||
|
|
||||||
|
if mask_image.size != source_image.size:
|
||||||
|
mask_image = mask_image.resize(source_image.size, Image.Resampling.BILINEAR)
|
||||||
|
|
||||||
|
result = source_image.copy()
|
||||||
|
r, g, b, a = result.split()
|
||||||
|
new_alpha = ImageChops.multiply(a, mask_image)
|
||||||
|
result.putalpha(new_alpha)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_stack_xml(width: int, height: int, groups: list[dict]) -> bytes:
|
||||||
|
"""Build stack.xml content from group structure."""
|
||||||
|
image = ET.Element('image', {
|
||||||
|
'version': '0.0.3',
|
||||||
|
'w': str(width),
|
||||||
|
'h': str(height)
|
||||||
|
})
|
||||||
|
|
||||||
|
root_stack = ET.SubElement(image, 'stack')
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
if group.get('is_group'):
|
||||||
|
group_el = ET.SubElement(root_stack, 'stack', {
|
||||||
|
'name': group['name']
|
||||||
|
})
|
||||||
|
|
||||||
|
for layer in group['layers']:
|
||||||
|
layer_attrs = {
|
||||||
|
'name': layer['name'],
|
||||||
|
'src': layer['src'],
|
||||||
|
'opacity': layer.get('opacity', '1.0')
|
||||||
|
}
|
||||||
|
if layer.get('visibility') and layer['visibility'] != 'visible':
|
||||||
|
layer_attrs['visibility'] = layer['visibility']
|
||||||
|
ET.SubElement(group_el, 'layer', layer_attrs)
|
||||||
|
else:
|
||||||
|
layer = group['layers'][0]
|
||||||
|
layer_attrs = {
|
||||||
|
'name': layer['name'],
|
||||||
|
'src': layer['src'],
|
||||||
|
'opacity': layer.get('opacity', '1.0')
|
||||||
|
}
|
||||||
|
if layer.get('visibility') and layer['visibility'] != 'visible':
|
||||||
|
layer_attrs['visibility'] = layer['visibility']
|
||||||
|
ET.SubElement(root_stack, 'layer', layer_attrs)
|
||||||
|
|
||||||
|
return ET.tostring(image, encoding='utf-8', xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_ora_from_structure(
|
||||||
|
groups: list[dict],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
layer_images: dict[str, Image.Image],
|
||||||
|
output_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Create an ORA file from the structure and layer images."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data_dir = os.path.join(tmp, 'data')
|
||||||
|
thumb_dir = os.path.join(tmp, 'Thumbnails')
|
||||||
|
os.makedirs(data_dir)
|
||||||
|
os.makedirs(thumb_dir)
|
||||||
|
|
||||||
|
for src_path, img in layer_images.items():
|
||||||
|
full_path = os.path.join(tmp, src_path)
|
||||||
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
|
img.save(full_path)
|
||||||
|
|
||||||
|
stack_xml = build_stack_xml(width, height, groups)
|
||||||
|
with open(os.path.join(tmp, 'stack.xml'), 'wb') as f:
|
||||||
|
f.write(stack_xml)
|
||||||
|
|
||||||
|
with open(os.path.join(tmp, 'mimetype'), 'w') as f:
|
||||||
|
f.write('image/openraster')
|
||||||
|
|
||||||
|
merged = None
|
||||||
|
for group in groups:
|
||||||
|
if not group.get('is_group') and group['layers']:
|
||||||
|
layer_src = group['layers'][0]['src']
|
||||||
|
merged = layer_images.get(layer_src)
|
||||||
|
break
|
||||||
|
|
||||||
|
if merged is None:
|
||||||
|
merged = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
||||||
|
|
||||||
|
merged.save(os.path.join(tmp, 'mergedimage.png'))
|
||||||
|
|
||||||
|
thumb = merged.copy()
|
||||||
|
thumb.thumbnail((256, 256))
|
||||||
|
thumb.save(os.path.join(thumb_dir, 'thumbnail.png'))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output_path, 'w') as zf:
|
||||||
|
zf.write(os.path.join(tmp, 'mimetype'), 'mimetype',
|
||||||
|
compress_type=zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
for root, _, files in os.walk(tmp):
|
||||||
|
for file in files:
|
||||||
|
if file == 'mimetype':
|
||||||
|
continue
|
||||||
|
full = os.path.join(root, file)
|
||||||
|
rel = os.path.relpath(full, tmp)
|
||||||
|
zf.write(full, rel)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def load_ora(ora_path: str) -> dict[str, Any]:
|
||||||
|
"""Load an ORA file and return its structure and layer images."""
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
width, height = get_image_size(root)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
# Flatten layers with their group info
|
||||||
|
layers = []
|
||||||
|
for group in groups:
|
||||||
|
for layer in group['layers']:
|
||||||
|
layers.append({
|
||||||
|
'name': layer['name'],
|
||||||
|
'src': layer['src'],
|
||||||
|
'group': group['name'] if group.get('is_group') else None,
|
||||||
|
'visible': layer.get('visibility', 'visible') != 'hidden'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'layers': layers,
|
||||||
|
'groups': groups
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_layer_image(ora_path: str, layer_name: str) -> Image.Image | None:
|
||||||
|
"""Get a specific layer image from the ORA."""
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
result = find_layer(groups, layer_name)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_, layer = result
|
||||||
|
return extract_layer_image(ora_path, layer['src'])
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_image(ora_path: str) -> Image.Image | None:
|
||||||
|
"""Get the base/merged image from the ORA."""
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
return Image.open(zf.open('mergedimage.png')).convert('RGBA')
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_ora(ora_path: str) -> bool:
|
||||||
|
"""Save the ORA structure back to disk (uses existing content)."""
|
||||||
|
if not os.path.exists(ora_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
width, height = get_image_size(root)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
# Extract all layer images from the current ORA
|
||||||
|
layer_images = {}
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
for group in groups:
|
||||||
|
for layer in group['layers']:
|
||||||
|
if layer['src'] not in layer_images:
|
||||||
|
try:
|
||||||
|
data = zf.read(layer['src'])
|
||||||
|
layer_images[layer['src']] = Image.open(io.BytesIO(data))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, ora_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving ORA: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def add_masked_layer(ora_path: str, entity_name: str, mask_path: str, source_layer: str = 'base') -> dict[str, Any]:
|
||||||
|
"""Add a new masked layer to the ORA."""
|
||||||
|
# Check if ORA exists, create from PNG if not
|
||||||
|
if not os.path.exists(ora_path):
|
||||||
|
png_path = ora_path.replace('.ora', '.png')
|
||||||
|
if os.path.exists(png_path):
|
||||||
|
base = Image.open(png_path).convert('RGBA')
|
||||||
|
width, height = base.size
|
||||||
|
|
||||||
|
layer_src = 'data/base.png'
|
||||||
|
groups = [{
|
||||||
|
'name': 'base',
|
||||||
|
'is_group': False,
|
||||||
|
'layers': [{
|
||||||
|
'name': 'base',
|
||||||
|
'src': layer_src,
|
||||||
|
'opacity': '1.0',
|
||||||
|
'visibility': 'visible'
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
|
||||||
|
layer_images = {layer_src: base}
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, ora_path)
|
||||||
|
else:
|
||||||
|
return {'success': False, 'error': f"No ORA or PNG found at {ora_path}"}
|
||||||
|
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
width, height = get_image_size(root)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
# Find source layer
|
||||||
|
layer_result = find_layer(groups, source_layer)
|
||||||
|
if layer_result is None:
|
||||||
|
return {'success': False, 'error': f"Layer '{source_layer}' not found"}
|
||||||
|
|
||||||
|
source_group, source_layer_info = layer_result
|
||||||
|
|
||||||
|
# Load source image
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
source_image_data = zf.read(source_layer_info['src'])
|
||||||
|
source_image = Image.open(io.BytesIO(source_image_data)).convert('RGBA')
|
||||||
|
|
||||||
|
# Load mask
|
||||||
|
if not os.path.exists(mask_path):
|
||||||
|
return {'success': False, 'error': f"Mask file not found: {mask_path}"}
|
||||||
|
|
||||||
|
mask_image = Image.open(mask_path)
|
||||||
|
if mask_image.mode not in ['L', 'RGBA', 'RGB']:
|
||||||
|
mask_image = mask_image.convert('RGBA')
|
||||||
|
|
||||||
|
# Apply mask
|
||||||
|
masked_image = apply_mask(source_image, mask_image)
|
||||||
|
|
||||||
|
# Find or create entity group
|
||||||
|
entity_group = find_entity_group(groups, entity_name)
|
||||||
|
if entity_group is None:
|
||||||
|
entity_group = {
|
||||||
|
'name': entity_name,
|
||||||
|
'is_group': True,
|
||||||
|
'layers': []
|
||||||
|
}
|
||||||
|
insert_idx = 0
|
||||||
|
for i, g in enumerate(groups):
|
||||||
|
if not g.get('is_group'):
|
||||||
|
insert_idx = i
|
||||||
|
break
|
||||||
|
insert_idx = i + 1
|
||||||
|
groups.insert(insert_idx, entity_group)
|
||||||
|
|
||||||
|
# Generate new layer name
|
||||||
|
next_index = get_next_layer_index(entity_group, entity_name)
|
||||||
|
new_layer_name = f"{entity_name}_{next_index}"
|
||||||
|
new_layer_src = f"data/{entity_name}/{new_layer_name}.png"
|
||||||
|
|
||||||
|
# Add new layer to entity group
|
||||||
|
entity_group['layers'].append({
|
||||||
|
'name': new_layer_name,
|
||||||
|
'src': new_layer_src,
|
||||||
|
'opacity': '1.0',
|
||||||
|
'visibility': 'visible'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Extract all layer images from ORA
|
||||||
|
layer_images = {}
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
for group in groups:
|
||||||
|
for layer in group['layers']:
|
||||||
|
if layer['src'] not in layer_images:
|
||||||
|
try:
|
||||||
|
data = zf.read(layer['src'])
|
||||||
|
layer_images[layer['src']] = Image.open(io.BytesIO(data))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add the new masked image
|
||||||
|
layer_images[new_layer_src] = masked_image
|
||||||
|
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, ora_path)
|
||||||
|
|
||||||
|
return {'success': True, 'layer_name': new_layer_name}
|
||||||
|
|
||||||
|
|
||||||
|
def rename_layer(ora_path: str, old_name: str, new_name: str) -> dict[str, Any]:
|
||||||
|
"""Rename a layer in the ORA."""
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
width, height = get_image_size(root)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
# Find the layer
|
||||||
|
result = find_layer(groups, old_name)
|
||||||
|
if result is None:
|
||||||
|
return {'success': False, 'error': f"Layer '{old_name}' not found"}
|
||||||
|
|
||||||
|
group, layer = result
|
||||||
|
|
||||||
|
# Update name in both places
|
||||||
|
old_src = layer['src']
|
||||||
|
layer['name'] = new_name
|
||||||
|
|
||||||
|
# Extract all layer images
|
||||||
|
layer_images = {}
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
for g in groups:
|
||||||
|
for l in g['layers']:
|
||||||
|
if l['src'] not in layer_images:
|
||||||
|
try:
|
||||||
|
data = zf.read(l['src'])
|
||||||
|
layer_images[l['src']] = Image.open(io.BytesIO(data))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get the image and create new entry with updated name
|
||||||
|
if old_src in layer_images:
|
||||||
|
img = layer_images.pop(old_src)
|
||||||
|
ext = Path(old_src).suffix
|
||||||
|
layer_dir = str(Path(old_src).parent)
|
||||||
|
new_src = f"{layer_dir}/{new_name}{ext}"
|
||||||
|
layer['src'] = new_src
|
||||||
|
layer_images[new_src] = img
|
||||||
|
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, ora_path)
|
||||||
|
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_layer(ora_path: str, layer_name: str) -> dict[str, Any]:
|
||||||
|
"""Delete a layer from the ORA."""
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
width, height = get_image_size(root)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
# Find and remove the layer
|
||||||
|
result = find_layer(groups, layer_name)
|
||||||
|
if result is None:
|
||||||
|
return {'success': False, 'error': f"Layer '{layer_name}' not found"}
|
||||||
|
|
||||||
|
group, _ = result
|
||||||
|
group['layers'] = [l for l in group['layers'] if l['name'] != layer_name]
|
||||||
|
|
||||||
|
# Remove empty groups
|
||||||
|
groups = [g for g in groups if len(g['layers']) > 0]
|
||||||
|
|
||||||
|
# Extract remaining layer images
|
||||||
|
layer_images = {}
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
for g in groups:
|
||||||
|
for l in g['layers']:
|
||||||
|
if l['src'] not in layer_images:
|
||||||
|
try:
|
||||||
|
data = zf.read(l['src'])
|
||||||
|
layer_images[l['src']] = Image.open(io.BytesIO(data))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, ora_path)
|
||||||
|
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_layer(ora_path: str, layer_name: str, direction: str) -> dict[str, Any]:
|
||||||
|
"""Move a layer up or down in the stack."""
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
width, height = get_image_size(root)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
# Find the layer
|
||||||
|
result = find_layer(groups, layer_name)
|
||||||
|
if result is None:
|
||||||
|
return {'success': False, 'error': f"Layer '{layer_name}' not found"}
|
||||||
|
|
||||||
|
current_group, _ = result
|
||||||
|
|
||||||
|
# Flatten all layers with their group info for reordering
|
||||||
|
flat_layers = []
|
||||||
|
for g in groups:
|
||||||
|
for l in g['layers']:
|
||||||
|
flat_layers.append({'group': g, 'layer': l})
|
||||||
|
|
||||||
|
# Find index of the layer
|
||||||
|
idx = None
|
||||||
|
for i, item in enumerate(flat_layers):
|
||||||
|
if item['layer']['name'] == layer_name:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if idx is None:
|
||||||
|
return {'success': False, 'error': f"Layer '{layer_name}' not found"}
|
||||||
|
|
||||||
|
# Determine swap index
|
||||||
|
swap_idx = None
|
||||||
|
if direction == 'up' and idx > 0:
|
||||||
|
swap_idx = idx - 1
|
||||||
|
elif direction == 'down' and idx < len(flat_layers) - 1:
|
||||||
|
swap_idx = idx + 1
|
||||||
|
|
||||||
|
if swap_idx is None:
|
||||||
|
return {'success': False, 'error': "Cannot move layer further in that direction"}
|
||||||
|
|
||||||
|
# Swap the layers
|
||||||
|
flat_layers[idx], flat_layers[swap_idx] = flat_layers[swap_idx], flat_layers[idx]
|
||||||
|
|
||||||
|
# Rebuild groups
|
||||||
|
new_groups = []
|
||||||
|
current_group_obj = None
|
||||||
|
|
||||||
|
for item in flat_layers:
|
||||||
|
g_name = item['group']['name']
|
||||||
|
is_group = item['group'].get('is_group')
|
||||||
|
|
||||||
|
if is_group:
|
||||||
|
# Entity layer - add to or create group
|
||||||
|
existing_group = next((g for g in new_groups if g['name'] == g_name and g.get('is_group')), None)
|
||||||
|
if existing_group is None:
|
||||||
|
existing_group = {'name': g_name, 'is_group': True, 'layers': []}
|
||||||
|
new_groups.append(existing_group)
|
||||||
|
existing_group['layers'].append(item['layer'])
|
||||||
|
else:
|
||||||
|
# Base layer - standalone
|
||||||
|
new_groups.append({
|
||||||
|
'name': item['group']['name'],
|
||||||
|
'is_group': False,
|
||||||
|
'layers': [item['layer']]
|
||||||
|
})
|
||||||
|
|
||||||
|
groups = new_groups
|
||||||
|
|
||||||
|
# Extract layer images
|
||||||
|
layer_images = {}
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
for g in groups:
|
||||||
|
for l in g['layers']:
|
||||||
|
if l['src'] not in layer_images:
|
||||||
|
try:
|
||||||
|
data = zf.read(l['src'])
|
||||||
|
layer_images[l['src']] = Image.open(io.BytesIO(data))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, ora_path)
|
||||||
|
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
|
def create_ora_from_png(png_path: str, output_ora: str, layer_name: str = 'base') -> dict[str, Any]:
|
||||||
|
"""Create an ORA file from a PNG."""
|
||||||
|
if not os.path.exists(png_path):
|
||||||
|
return {'success': False, 'error': f"File not found: {png_path}"}
|
||||||
|
|
||||||
|
base = Image.open(png_path).convert('RGBA')
|
||||||
|
width, height = base.size
|
||||||
|
|
||||||
|
layer_src = f'data/{layer_name}.png'
|
||||||
|
groups = [{
|
||||||
|
'name': layer_name,
|
||||||
|
'is_group': False,
|
||||||
|
'layers': [{
|
||||||
|
'name': layer_name,
|
||||||
|
'src': layer_src,
|
||||||
|
'opacity': '1.0',
|
||||||
|
'visibility': 'visible'
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
|
||||||
|
layer_images = {layer_src: base}
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, output_ora)
|
||||||
|
|
||||||
|
return {'success': True, 'ora_path': output_ora}
|
||||||
|
|
||||||
|
|
||||||
|
def get_layer_visibility(ora_path: str, layer_name: str) -> bool | None:
|
||||||
|
"""Get the visibility state of a layer."""
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
result = find_layer(groups, layer_name)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_, layer = result
|
||||||
|
return layer.get('visibility', 'visible') != 'hidden'
|
||||||
|
|
||||||
|
|
||||||
|
def set_layer_visibility(ora_path: str, layer_name: str, visible: bool) -> dict[str, Any]:
|
||||||
|
"""Set the visibility of a layer."""
|
||||||
|
root = parse_stack_xml(ora_path)
|
||||||
|
width, height = get_image_size(root)
|
||||||
|
groups = get_groups_and_layers(root)
|
||||||
|
|
||||||
|
# Find and update the layer
|
||||||
|
result = find_layer(groups, layer_name)
|
||||||
|
if result is None:
|
||||||
|
return {'success': False, 'error': f"Layer '{layer_name}' not found"}
|
||||||
|
|
||||||
|
_, layer = result
|
||||||
|
layer['visibility'] = 'visible' if visible else 'hidden'
|
||||||
|
|
||||||
|
# Extract layer images
|
||||||
|
layer_images = {}
|
||||||
|
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||||
|
for g in groups:
|
||||||
|
for l in g['layers']:
|
||||||
|
if l['src'] not in layer_images:
|
||||||
|
try:
|
||||||
|
data = zf.read(l['src'])
|
||||||
|
layer_images[l['src']] = Image.open(io.BytesIO(data))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
create_ora_from_structure(groups, width, height, layer_images, ora_path)
|
||||||
|
|
||||||
|
return {'success': True}
|
||||||
2
tools/ora_editor/requirements.txt
Normal file
2
tools/ora_editor/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
pillow>=10.0.0
|
||||||
356
tools/ora_editor/test_ora_ops.py
Normal file
356
tools/ora_editor/test_ora_ops.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for ora_ops module."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from ora_editor.ora_ops import (
|
||||||
|
load_ora, create_ora_from_png, add_masked_layer, rename_layer,
|
||||||
|
delete_layer, reorder_layer, get_layer_visibility, set_layer_visibility,
|
||||||
|
save_ora, parse_stack_xml, get_image_size, get_groups_and_layers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_ora_from_png():
|
||||||
|
"""Test creating an ORA from a PNG file."""
|
||||||
|
# Create a simple test PNG
|
||||||
|
test_img = Image.new('RGBA', (100, 100), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
test_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.ora', delete=False) as ora_f:
|
||||||
|
ora_path = ora_f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = create_ora_from_png(png_path, ora_path)
|
||||||
|
|
||||||
|
assert result['success'], f"Failed to create ORA: {result.get('error')}"
|
||||||
|
assert os.path.exists(ora_path), "ORA file was not created"
|
||||||
|
|
||||||
|
# Verify we can load it back
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
assert 'layers' in loaded, "Loaded ORA has no layers"
|
||||||
|
assert len(loaded['layers']) == 1, f"Expected 1 layer, got {len(loaded['layers'])}"
|
||||||
|
|
||||||
|
print("✓ create_ora_from_png passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_ora():
|
||||||
|
"""Test loading an existing ORA file."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.ora', delete=False) as ora_f:
|
||||||
|
ora_path = ora_f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a test PNG first
|
||||||
|
test_img = Image.new('RGBA', (200, 150), (0, 255, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
test_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_ora_from_png(png_path, ora_path)
|
||||||
|
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
|
||||||
|
assert loaded['width'] == 200, f"Expected width 200, got {loaded['width']}"
|
||||||
|
assert loaded['height'] == 150, f"Expected height 150, got {loaded['height']}"
|
||||||
|
assert len(loaded['layers']) > 0, "No layers found in ORA"
|
||||||
|
|
||||||
|
print("✓ load_ora passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_masked_layer():
|
||||||
|
"""Test adding a masked layer to an ORA."""
|
||||||
|
# Create base PNG
|
||||||
|
base_img = Image.new('RGBA', (100, 100), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
base_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
ora_path = png_path.replace('.png', '.ora')
|
||||||
|
|
||||||
|
# Create mask (white where visible, black where transparent)
|
||||||
|
mask_img = Image.new('L', (100, 100), 255)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mask.png', delete=False) as mask_f:
|
||||||
|
mask_img.save(mask_f.name)
|
||||||
|
mask_path = mask_f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = add_masked_layer(ora_path, 'door', mask_path)
|
||||||
|
|
||||||
|
assert result['success'], f"Failed to add masked layer: {result.get('error')}"
|
||||||
|
assert 'layer_name' in result, "No layer_name in result"
|
||||||
|
assert result['layer_name'] == 'door_0', f"Expected door_0, got {result['layer_name']}"
|
||||||
|
|
||||||
|
# Verify the layer was added
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
layer_names = [l['name'] for l in loaded['layers']]
|
||||||
|
assert 'base' in layer_names, "Base layer missing"
|
||||||
|
assert 'door_0' in layer_names, "Door layer not found"
|
||||||
|
|
||||||
|
print("✓ add_masked_layer passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
os.unlink(mask_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_layer():
|
||||||
|
"""Test renaming a layer."""
|
||||||
|
# Create base PNG
|
||||||
|
base_img = Image.new('RGBA', (100, 100), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
base_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
ora_path = png_path.replace('.png', '.ora')
|
||||||
|
|
||||||
|
# Create mask for a new layer
|
||||||
|
mask_img = Image.new('L', (100, 100), 255)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mask.png', delete=False) as mask_f:
|
||||||
|
mask_img.save(mask_f.name)
|
||||||
|
mask_path = mask_f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First add a layer to rename
|
||||||
|
result = add_masked_layer(ora_path, 'door', mask_path)
|
||||||
|
assert result['success']
|
||||||
|
|
||||||
|
# Now rename it
|
||||||
|
result = rename_layer(ora_path, 'door_0', 'wooden_door_0')
|
||||||
|
|
||||||
|
assert result['success'], f"Failed to rename: {result.get('error')}"
|
||||||
|
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
layer_names = [l['name'] for l in loaded['layers']]
|
||||||
|
assert 'wooden_door_0' in layer_names, "Renamed layer not found"
|
||||||
|
assert 'door_0' not in layer_names, "Old layer name still exists"
|
||||||
|
|
||||||
|
print("✓ rename_layer passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
os.unlink(mask_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_layer():
|
||||||
|
"""Test deleting a layer."""
|
||||||
|
# Create base PNG
|
||||||
|
base_img = Image.new('RGBA', (100, 100), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
base_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
ora_path = png_path.replace('.png', '.ora')
|
||||||
|
|
||||||
|
# Create mask for a new layer
|
||||||
|
mask_img = Image.new('L', (100, 100), 255)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mask.png', delete=False) as mask_f:
|
||||||
|
mask_img.save(mask_f.name)
|
||||||
|
mask_path = mask_f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First add a layer to delete
|
||||||
|
result = add_masked_layer(ora_path, 'door', mask_path)
|
||||||
|
assert result['success']
|
||||||
|
|
||||||
|
# Now delete it
|
||||||
|
result = delete_layer(ora_path, 'door_0')
|
||||||
|
|
||||||
|
assert result['success'], f"Failed to delete: {result.get('error')}"
|
||||||
|
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
layer_names = [l['name'] for l in loaded['layers']]
|
||||||
|
assert 'door_0' not in layer_names, "Deleted layer still exists"
|
||||||
|
|
||||||
|
print("✓ delete_layer passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
os.unlink(mask_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_layer():
|
||||||
|
"""Test reordering layers."""
|
||||||
|
# Create base PNG
|
||||||
|
base_img = Image.new('RGBA', (100, 100), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
base_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
ora_path = png_path.replace('.png', '.ora')
|
||||||
|
|
||||||
|
# Create mask for new layers
|
||||||
|
mask_img = Image.new('L', (100, 100), 255)
|
||||||
|
|
||||||
|
layer_paths = []
|
||||||
|
for i in range(3):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mask.png', delete=False) as mask_f:
|
||||||
|
mask_img.save(mask_f.name)
|
||||||
|
layer_paths.append(mask_f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Add multiple layers
|
||||||
|
for i, mask_path in enumerate(layer_paths):
|
||||||
|
result = add_masked_layer(ora_path, f'layer{i}', mask_path)
|
||||||
|
assert result['success'], f"Failed to add layer {i}"
|
||||||
|
|
||||||
|
# Get initial order
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
initial_order = [l['name'] for l in loaded['layers']]
|
||||||
|
|
||||||
|
# Move layer0_0 down (it should stay or move one position)
|
||||||
|
result = reorder_layer(ora_path, 'layer0_0', 'down')
|
||||||
|
assert result['success'], f"Failed to reorder: {result.get('error')}"
|
||||||
|
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
new_order = [l['name'] for l in loaded['layers']]
|
||||||
|
|
||||||
|
# Verify order changed or stayed valid
|
||||||
|
assert len(new_order) == len(initial_order), "Layer count changed during reorder"
|
||||||
|
|
||||||
|
print("✓ reorder_layer passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
for path in layer_paths:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_layer_visibility():
|
||||||
|
"""Test setting layer visibility."""
|
||||||
|
base_img = Image.new('RGBA', (100, 100), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
base_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
ora_path = png_path.replace('.png', '.ora')
|
||||||
|
|
||||||
|
mask_img = Image.new('L', (100, 100), 255)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mask.png', delete=False) as mask_f:
|
||||||
|
mask_img.save(mask_f.name)
|
||||||
|
mask_path = mask_f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
add_masked_layer(ora_path, 'door', mask_path)
|
||||||
|
|
||||||
|
# Set visibility to false
|
||||||
|
result = set_layer_visibility(ora_path, 'door_0', False)
|
||||||
|
assert result['success'], f"Failed to set visibility: {result.get('error')}"
|
||||||
|
|
||||||
|
visible = get_layer_visibility(ora_path, 'door_0')
|
||||||
|
assert visible is False, "Layer should be hidden"
|
||||||
|
|
||||||
|
# Set visibility back to true
|
||||||
|
result = set_layer_visibility(ora_path, 'door_0', True)
|
||||||
|
assert result['success']
|
||||||
|
|
||||||
|
visible = get_layer_visibility(ora_path, 'door_0')
|
||||||
|
assert visible is True, "Layer should be visible"
|
||||||
|
|
||||||
|
print("✓ set_layer_visibility passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
os.unlink(mask_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_ora():
|
||||||
|
"""Test saving an ORA file."""
|
||||||
|
base_img = Image.new('RGBA', (100, 100), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as png_f:
|
||||||
|
base_img.save(png_f.name)
|
||||||
|
png_path = png_f.name
|
||||||
|
|
||||||
|
ora_path = png_path.replace('.png', '.ora')
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_ora_from_png(png_path, ora_path)
|
||||||
|
|
||||||
|
# Save should work without errors
|
||||||
|
result = save_ora(ora_path)
|
||||||
|
assert result, "Failed to save ORA"
|
||||||
|
|
||||||
|
# Verify we can still load it
|
||||||
|
loaded = load_ora(ora_path)
|
||||||
|
assert len(loaded['layers']) > 0, "Layers missing after save"
|
||||||
|
|
||||||
|
print("✓ save_ora passed")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.unlink(png_path)
|
||||||
|
if os.path.exists(ora_path):
|
||||||
|
os.unlink(ora_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
tests = [
|
||||||
|
test_create_ora_from_png,
|
||||||
|
test_load_ora,
|
||||||
|
test_add_masked_layer,
|
||||||
|
test_rename_layer,
|
||||||
|
test_delete_layer,
|
||||||
|
test_reorder_layer,
|
||||||
|
test_set_layer_visibility,
|
||||||
|
test_save_ora,
|
||||||
|
]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
if test():
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {test.__name__} failed with exception: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"\n{passed}/{len(tests)} tests passed")
|
||||||
|
|
||||||
|
if failed > 0:
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user