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:
2026-03-27 08:46:49 -07:00
parent 6487b00655
commit 319c2565c6
5 changed files with 1741 additions and 0 deletions

4
tools/ora_editor/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
temp/
__pycache__/
*.pyc
*.pyo

View 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
View 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}

View File

@@ -0,0 +1,2 @@
flask>=3.0.0
pillow>=10.0.0

View 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)