diff --git a/tools/ora_editor/.gitignore b/tools/ora_editor/.gitignore new file mode 100644 index 0000000..1633545 --- /dev/null +++ b/tools/ora_editor/.gitignore @@ -0,0 +1,4 @@ +temp/ +__pycache__/ +*.pyc +*.pyo diff --git a/tools/ora_editor/ORA_EDITOR.md b/tools/ora_editor/ORA_EDITOR.md new file mode 100644 index 0000000..f60cc84 --- /dev/null +++ b/tools/ora_editor/ORA_EDITOR.md @@ -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: (e.g., "door") + └─ _0 (e.g., "door_0") +📁 Group: + └─ _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 `` 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 +
+ + + + + +
+``` + +--- + +## 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/` +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/` +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 + / + _0.png # Masked layer +``` + +### stack.xml Format +```xml + + + + + + + + +``` + +### 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) diff --git a/tools/ora_editor/ora_ops.py b/tools/ora_editor/ora_ops.py new file mode 100644 index 0000000..8977694 --- /dev/null +++ b/tools/ora_editor/ora_ops.py @@ -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} diff --git a/tools/ora_editor/requirements.txt b/tools/ora_editor/requirements.txt new file mode 100644 index 0000000..8feb26b --- /dev/null +++ b/tools/ora_editor/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0.0 +pillow>=10.0.0 diff --git a/tools/ora_editor/test_ora_ops.py b/tools/ora_editor/test_ora_ops.py new file mode 100644 index 0000000..a337624 --- /dev/null +++ b/tools/ora_editor/test_ora_ops.py @@ -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)