Restructure ORA editor into modular blueprints with browse dialog

- Split app.py into route blueprints (files, layers, images, polygon, mask, krita)
- Create services layer (polygon_storage, comfyui, file_browser)
- Extract config constants to config.py
- Split templates into Jinja partials (base, components, modals)
- Add browse dialog for visual file navigation
- Add /api/browse endpoint for directory listing
This commit is contained in:
2026-03-27 21:29:27 -07:00
parent 17da8c475e
commit fb812e57bc
21 changed files with 2269 additions and 1794 deletions

View File

@@ -65,11 +65,82 @@ All file paths are relative to the project root: `/home/noti/dev/ai-game-2`
- **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
1. User clicks "Browse" button to open file browser dialog
2. Backend lists directories and files (PNG, ORA) from project root
3. User navigates directories, selects file
4. Backend checks if file exists
5. If PNG: auto-create ORA with single `base` layer
6. Parse ORA structure, extract layers
7. Return layer list + image data URLs
---
## Browse Dialog
### Purpose
Allows users to visually navigate the project directory and select PNG/ORA files without manually typing paths.
### UI Layout
```
┌─────────────────────────────────────────────────────────┐
│ OPEN FILE │
│ ───────────────────────────────────────────────────── │
│ 📁 .. │
│ 📁 scenes/ │
│ 📁 tools/ │
│ 🖼️ pic_010.ora │
│ 🖼️ pic_011.png │
│ │
│ Current: scenes/kq4_010/ │
│ │
│ [Cancel] [Open Selected] │
└─────────────────────────────────────────────────────────┘
```
### Behavior
- **Double-click folder**: Navigate into folder
- **Double-click file**: Select and open immediately
- **Single-click + Enter**: Select and open
- ** ".." entry**: Navigate to parent directory
- **File types**: Only show `.png` and `.ora` files
- **Hidden files**: Excluded (dot-prefixed)
### Backend Implementation
#### `GET /api/browse`
List directory contents for browsing.
**Query params:**
- `path`: Directory path relative to project root (default: "")
**Response:**
```json
{
"current_path": "scenes/kq4_010",
"parent_path": "scenes",
"directories": [
{"name": "subdir1", "path": "scenes/kq4_010/subdir1"}
],
"files": [
{"name": "pic.png", "path": "scenes/kq4_010/pic.png", "type": "png"},
{"name": "scene.ora", "path": "scenes/kq4_010/scene.ora", "type": "ora"}
]
}
```
### Frontend State
```javascript
// Added to main store
browseModal: false,
browsePath: '', // Current directory being browsed
browseDirectories: [], // List of {name, path}
browseFiles: [], // List of {name, path, type}
browseSelected: null, // Currently selected file path
```
### Entry Point
- Button next to path input: `[Browse...]`
- Or clicking into the path input field (if empty)
---
@@ -247,6 +318,29 @@ Since browser cannot detect when Krita saves:
## API Reference
### File Browser
#### `GET /api/browse`
List directory contents for file browsing.
**Query params:**
- `path`: Directory path relative to project root (default: "")
**Response:**
```json
{
"current_path": "scenes/kq4_010",
"parent_path": "scenes",
"directories": [
{"name": "subdir1", "path": "scenes/kq4_010/subdir1"}
],
"files": [
{"name": "pic.png", "path": "scenes/kq4_010/pic.png", "type": "png"},
{"name": "scene.ora", "path": "scenes/kq4_010/scene.ora", "type": "ora"}
]
}
```
### File Operations
#### `POST /api/open`
@@ -525,7 +619,7 @@ Check if temp file was modified.
```
┌──────────────────────────────────────────────────────────────────────┐
│ [Open: ________________________] [🌀 Open] [Settings ⚙]
│ [Open: ________________________] [📁 Browse] [🌀 Open] [Settings ⚙] │
├───────────────────┬──────────────────────────────────────────────────┤
│ LAYERS │ │
│ ☑ □ base │ │
@@ -584,6 +678,31 @@ Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked)
---
## Browse Modal
```
┌─────────────────────────────────────────────────────────┐
│ OPEN FILE │
│ ───────────────────────────────────────────────────── │
│ 📁 .. (parent directory) │
│ 📁 scenes/ │
│ 📁 tools/ │
│ 🖼️ pic_010.ora │
│ 🖼️ pic_011.png │
│ │
│ Path: scenes/kq4_010/ │
│ │
│ [Cancel] [Open Selected] │
└─────────────────────────────────────────────────────────┘
```
- Double-click folder to navigate
- Double-click file to open immediately
- Single-click to select, then click "Open Selected"
- Only shows `.png` and `.ora` files
---
## Settings Modal
```
@@ -622,6 +741,7 @@ Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked)
- [ ] `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 browser (`/api/browse`)
- [ ] File operations (`/api/open`, `/api/save`)
- [ ] Layer operations (all `/api/layer/*`)
- [ ] Image serving (`/api/image/*`)
@@ -632,6 +752,7 @@ Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked)
### Phase 3: Frontend UI
- [ ] Create `editor.html` with Tailwind + Alpine.js
- [ ] File open input and handler
- [ ] Browse modal for file selection
- [ ] Layer list with visibility toggles
- [ ] Canvas area with stacked layer images
- [ ] Layer operations (rename, delete, reorder buttons)

View File

@@ -1,858 +1,39 @@
#!/usr/bin/env python3
"""Flask web application for ORA editing."""
import base64
import io
import json
import os
import shutil
import logging
import sys
import threading
import time
import zipfile
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree as ET
from flask import (
Flask, request, jsonify, send_file, Response, render_template,
make_response
from flask import Flask, render_template
# Ensure the package can be imported
sys.path.insert(0, str(Path(__file__).parent.parent))
from ora_editor.config import TEMP_DIR
from ora_editor.routes import (
files_bp, layers_bp, images_bp, polygon_bp, mask_bp, krita_bp
)
from PIL import Image, ImageDraw
import logging
import urllib.parse
import urllib.request
# Configure logging first (before imports that might need logger)
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
# Configuration from environment variables
APP_DIR = Path(__file__).parent
PROJECT_ROOT = Path("/home/noti/dev/ai-game-2")
TEMP_DIR = APP_DIR / "temp"
# ComfyUI configuration via environment variables
COMFYUI_BASE_URL = os.environ.get('COMFYUI_BASE_URL', '127.0.0.1:8188')
logger.info(f"[CONFIG] COMFYUI_BASE_URL={COMFYUI_BASE_URL}")
# Ensure temp directory exists
TEMP_DIR.mkdir(exist_ok=True)
# Import ora operations from sibling directory
sys.path.insert(0, str(APP_DIR.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
)
# In-memory storage for polygon points (per-request basis)
_polygon_storage = {}
# Webhook response holder (single request at a time per user spec)
_webhook_response = None
_webhook_ready = threading.Event()
app = Flask(__name__, template_folder='templates')
def ensure_ora_exists(input_path: str) -> str | None:
"""Ensure an ORA file exists, creating from PNG if necessary."""
full_path = PROJECT_ROOT / input_path
if not full_path.exists():
return None
# If it's a PNG, create ORA
if full_path.suffix.lower() == '.png':
ora_path = str(full_path.with_suffix('.ora'))
result = create_ora_from_png(str(full_path), ora_path)
if not result.get('success'):
return None
return ora_path
# It's an ORA, verify it's valid
try:
parse_stack_xml(str(full_path))
return str(full_path)
except Exception:
return None
@app.route('/')
def index():
"""Serve the main editor UI."""
return render_template('editor.html')
@app.route('/api/open', methods=['POST'])
def api_open():
"""Open a file (PNG or ORA)."""
data = request.get_json()
if not data or 'path' not in data:
return jsonify({'success': False, 'error': 'Missing path parameter'}), 400
input_path = data['path']
ora_path = ensure_ora_exists(input_path)
if not ora_path:
return jsonify({'success': False, 'error': f'File not found or invalid: {input_path}'}), 404
try:
loaded = load_ora(ora_path)
# Build layers list with visibility
layers = []
for layer in loaded['layers']:
layers.append({
'name': layer['name'],
'src': layer['src'],
'group': layer.get('group'),
'visible': layer.get('visible', True)
})
return jsonify({
'success': True,
'ora_path': ora_path,
'width': loaded['width'],
'height': loaded['height'],
'layers': layers
})
except Exception as e:
return jsonify({'success': False, 'error': f'Error loading ORA: {str(e)}'}), 500
@app.route('/api/save', methods=['POST'])
def api_save():
"""Save the current ORA state."""
data = request.get_json()
if not data or 'ora_path' not in data:
return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400
ora_path = data['ora_path']
# Save by re-reading and re-writing (preserves current state)
result = save_ora(ora_path)
if result:
return jsonify({'success': True, 'ora_path': ora_path})
else:
return jsonify({'success': False, 'error': 'Failed to save ORA'}), 500
@app.route('/api/layers', methods=['GET'])
def api_layers():
"""Get the current layer structure."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400
try:
loaded = load_ora(ora_path)
layers = []
for layer in loaded['layers']:
layers.append({
'name': layer['name'],
'src': layer['src'],
'group': layer.get('group'),
'visible': layer.get('visible', True)
})
return jsonify({'success': True, 'layers': layers})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/layer/add', methods=['POST'])
def api_layer_add():
"""Add a new masked layer."""
data = request.get_json()
required = ['ora_path', 'entity_name', 'mask_path']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
ora_path = data['ora_path']
entity_name = data['entity_name']
mask_path = data['mask_path']
source_layer = data.get('source_layer', 'base')
result = add_masked_layer(ora_path, entity_name, mask_path, source_layer)
return jsonify(result)
@app.route('/api/layer/rename', methods=['POST'])
def api_layer_rename():
"""Rename a layer."""
data = request.get_json()
required = ['ora_path', 'old_name', 'new_name']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = rename_layer(data['ora_path'], data['old_name'], data['new_name'])
return jsonify(result)
@app.route('/api/layer/delete', methods=['POST'])
def api_layer_delete():
"""Delete a layer."""
data = request.get_json()
required = ['ora_path', 'layer_name']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = delete_layer(data['ora_path'], data['layer_name'])
return jsonify(result)
@app.route('/api/layer/reorder', methods=['POST'])
def api_layer_reorder():
"""Reorder a layer."""
data = request.get_json()
required = ['ora_path', 'layer_name', 'direction']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = reorder_layer(data['ora_path'], data['layer_name'], data['direction'])
return jsonify(result)
@app.route('/api/layer/visibility', methods=['POST'])
def api_layer_visibility():
"""Set layer visibility."""
data = request.get_json()
required = ['ora_path', 'layer_name', 'visible']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = set_layer_visibility(data['ora_path'], data['layer_name'], data['visible'])
return jsonify(result)
@app.route('/api/image/<layer_name>')
def api_image(layer_name):
"""Serve a specific layer image."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'error': 'Missing ora_path'}), 400
try:
root = parse_stack_xml(ora_path)
with zipfile.ZipFile(ora_path, 'r') as zf:
# Get the image from mergedimage.png
img_data = zf.read('mergedimage.png')
return send_file(io.BytesIO(img_data), mimetype='image/png')
except Exception as e:
return Response(f"Error loading image: {e}", status=500)
@app.route('/api/image/base')
def api_image_base():
"""Serve the base/merged image."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'error': 'Missing ora_path'}), 400
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
img_data = zf.read('mergedimage.png')
return send_file(io.BytesIO(img_data), mimetype='image/png')
except Exception as e:
return Response(f"Error loading image: {e}", status=500)
@app.route('/api/image/polygon')
def api_image_polygon():
"""Serve polygon overlay if stored."""
ora_path = request.args.get('ora_path')
if not ora_path or ora_path not in _polygon_storage:
return Response("No polygon data", status=404)
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
base_img = Image.open(zf.open('mergedimage.png')).convert('RGBA')
points = _polygon_storage[ora_path].get('points', [])
color = _polygon_storage[ora_path].get('color', '#FF0000')
width = _polygon_storage[ora_path].get('width', 2)
if len(points) < 3:
return Response("Not enough points", status=404)
# Convert percentage points to pixel coordinates
w, h = base_img.size
pixel_points = [(int(p['x'] * w), int(p['y'] * h)) for p in points]
draw = ImageDraw.Draw(base_img)
# Draw as hex color with alpha
hex_color = color if len(color) == 7 else color + 'FF'
draw.polygon(pixel_points, outline=hex_color, width=width)
img_io = io.BytesIO()
base_img.save(img_io, format='PNG')
img_io.seek(0)
return send_file(img_io, mimetype='image/png')
except Exception as e:
return Response(f"Error drawing polygon: {e}", status=500)
@app.route('/api/polygon', methods=['POST'])
def api_polygon():
"""Store polygon points for overlay."""
data = request.get_json()
required = ['ora_path', 'points']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
ora_path = data['ora_path']
points = data['points']
color = data.get('color', '#FF0000')
width = data.get('width', 2)
logger.info(f"[POLYGON] Storing polygon: {len(points)} points, color: {color}, width: {width}")
_polygon_storage[ora_path] = {
'points': points,
'color': color,
'width': width
}
return jsonify({
'success': True,
'overlay_url': f'/api/image/polygon?ora_path={ora_path}'
})
@app.route('/api/polygon/clear', methods=['POST'])
def api_polygon_clear():
"""Clear stored polygon points."""
ora_path = request.json.get('ora_path') if request.data else None
if ora_path and ora_path in _polygon_storage:
del _polygon_storage[ora_path]
return jsonify({'success': True})
@app.route('/api/webhook/comfyui', methods=['POST'])
def api_webhook_comfyui():
"""Webhook endpoint for ComfyUI to post completed mask images."""
logger.info("[WEBHOOK] Received webhook from ComfyUI")
global _webhook_response, _webhook_ready
# Reset the event in case we're reusing
_webhook_response = None
_webhook_ready.clear()
try:
# Get JSON metadata if present (from form field or request body)
metadata = request.form.get('metadata', '') if request.form else ''
external_uid = request.form.get('external_uid', '') if request.form else ''
logger.info(f"[WEBHOOK] Metadata: {metadata}")
logger.info(f"[WEBHOOK] External UID: {external_uid}")
# Webhook sends image as multipart form file with key "file"
if 'file' in request.files:
img_file = request.files['file']
timestamp = str(int(time.time()))
final_mask_path = TEMP_DIR / f"mask_{timestamp}.png"
# Re-save as PNG (webhook sends JPEG)
from PIL import Image as PILImage
img = PILImage.open(img_file).convert('RGBA')
img.save(str(final_mask_path), format='PNG')
logger.info(f"[WEBHOOK] Image file saved to {final_mask_path}")
_webhook_response = {'success': True, 'path': final_mask_path}
elif 'image' in request.files:
# Fallback for "image" key
img_file = request.files['image']
timestamp = str(int(time.time()))
final_mask_path = TEMP_DIR / f"mask_{timestamp}.png"
with open(final_mask_path, 'wb') as f:
img_file.save(str(final_mask_path))
logger.info(f"[WEBHOOK] Image file (fallback) saved to {final_mask_path}")
_webhook_response = {'success': True, 'path': final_mask_path}
elif request.files:
# Try first available file if 'image' key not present
img_file = list(request.files.values())[0]
timestamp = str(int(time.time()))
final_mask_path = TEMP_DIR / f"mask_{timestamp}.png"
img_file.save(str(final_mask_path))
logger.info(f"[WEBHOOK] First file saved to {final_mask_path}")
_webhook_response = {'success': True, 'path': final_mask_path}
elif request.data:
# Fallback to raw data
timestamp = str(int(time.time()))
final_mask_path = TEMP_DIR / f"mask_{timestamp}.png"
with open(final_mask_path, 'wb') as f:
f.write(request.data)
logger.info(f"[WEBHOOK] Raw image data saved to {final_mask_path}")
_webhook_response = {'success': True, 'path': final_mask_path}
else:
logger.error("[WEBHOOK] No image file or data in request")
logger.debug(f"[WEBHOOK] Request form keys: {request.form.keys() if request.form else 'None'}")
logger.debug(f"[WEBHOOK] Request files keys: {request.files.keys() if request.files else 'None'}")
_webhook_response = {'success': False, 'error': 'No image data received'}
# Signal that webhook is ready
_webhook_ready.set()
# Return success to ComfyUI
response = make_response(jsonify({'status': 'ok', 'message': 'Image received'}))
response.status_code = 200
return response
except Exception as e:
logger.error(f"[WEBHOOK] Error processing webhook: {e}")
import traceback
traceback.print_exc()
_webhook_response = {'success': False, 'error': str(e)}
_webhook_ready.set()
response = make_response(jsonify({'status': 'error', 'message': str(e)}))
response.status_code = 500
return response
@app.route('/api/mask/extract', methods=['POST'])
def api_mask_extract():
"""Extract a mask from the current base image using ComfyUI."""
data = request.get_json()
required = ['subject', 'ora_path']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
subject = data['subject']
use_polygon = data.get('use_polygon', False)
ora_path = data['ora_path']
comfy_url = data.get('comfy_url', COMFYUI_BASE_URL)
logger.info(f"[MASK EXTRACT] Subject: {subject}")
logger.info(f"[MASK EXTRACT] Use polygon: {use_polygon}")
logger.info(f"[MASK EXTRACT] ORA path: {ora_path}")
logger.info(f"[MASK EXTRACT] ComfyUI URL: {comfy_url}")
# Load workflow template
workflow_path = APP_DIR.parent / "image_mask_extraction.json"
if not workflow_path.exists():
logger.error(f"[MASK EXTRACT] Workflow file not found: {workflow_path}")
return jsonify({'success': False, 'error': f'Workflow file not found: {workflow_path}'}), 500
with open(workflow_path) as f:
workflow = json.load(f)
logger.info(f"[MASK EXTRACT] Loaded workflow")
# Load base image from ORA
base_img = None
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
img_data = zf.read('mergedimage.png')
base_img = Image.open(io.BytesIO(img_data)).convert('RGBA')
logger.info(f"[MASK EXTRACT] Loaded base image: {base_img.size}")
except Exception as e:
logger.error(f"[MASK EXTRACT] Error loading base image: {e}")
return jsonify({'success': False, 'error': f'Error loading image: {str(e)}'}), 500
# Apply polygon overlay if requested
if use_polygon:
polygon_data = _polygon_storage.get(ora_path, {})
polygon_points = polygon_data.get('points', [])
if not polygon_points or len(polygon_points) < 3:
logger.warning(f"[MASK EXTRACT] Use polygon requested but no valid polygon points stored")
return jsonify({'success': False, 'error': 'No valid polygon points. Please draw polygon first.'}), 400
logger.info(f"[MASK EXTRACT] Applying polygon overlay: {len(polygon_points)} points")
# Convert percentage points to pixel coordinates
w, h = base_img.size
pixel_points = [(int(p['x'] * w), int(p['y'] * h)) for p in polygon_points]
# Draw polygon on image
polygon_color = polygon_data.get('color', '#FF0000')
polygon_width = polygon_data.get('width', 2)
draw = ImageDraw.Draw(base_img)
# Draw the polygon
hex_color = polygon_color if len(polygon_color) == 7 else polygon_color + 'FF'
draw.polygon(pixel_points, outline=hex_color, width=polygon_width)
logger.info(f"[MASK EXTRACT] Drew polygon on image")
# Encode (possibly modified) image
img_io = io.BytesIO()
base_img.save(img_io, format='PNG')
img_io.seek(0)
base64_image = base64.b64encode(img_io.read()).decode('utf-8')
logger.info(f"[MASK EXTRACT] Encoded image as base64: {len(base64_image)} chars")
# Set image input in workflow (node 87 is ETN_LoadImageBase64)
workflow["87"]["inputs"]["image"] = base64_image
# Update prompt text in workflow (node 1:68 is TextEncodeQwenImageEditPlus)
if "1:68" in workflow and 'inputs' in workflow["1:68"]:
workflow["1:68"]["inputs"]["prompt"] = f"Create a black and white alpha mask of {subject}, leaving everything else black"
# Set webhook URL for node 96 (Webhook Image Saver)
webhook_url = f"http://localhost:5001/api/webhook/comfyui"
if "96" in workflow and 'inputs' in workflow["96"]:
workflow["96"]["inputs"]["webhook_url"] = webhook_url
logger.info(f"[MASK EXTRACT] Webhook URL set to: {webhook_url}")
# Set random seed to prevent cache hits (node 50 is Seed (rgthree))
import random
random_seed = random.randint(0, 2**31-1)
if "50" in workflow and 'inputs' in workflow["50"]:
workflow["50"]["inputs"]["seed"] = random_seed
logger.info(f"[MASK EXTRACT] Random seed set to: {random_seed}")
logger.info(f"[MASK EXTRACT] Workflow prepared, sending to ComfyUI at http://{comfy_url}")
# Prepare headers for ComfyUI requests
headers = {'Content-Type': 'application/json'}
# Submit workflow to ComfyUI
try:
payload = json.dumps({"prompt": workflow}).encode('utf-8')
submit_req = urllib.request.Request(
f'http://{comfy_url}/prompt',
data=payload,
headers=headers,
method='POST'
)
with urllib.request.urlopen(submit_req, timeout=30) as response:
result = json.loads(response.read().decode())
prompt_id = result.get('prompt_id')
if not prompt_id:
logger.error("[MASK EXTRACT] No prompt_id returned from ComfyUI")
return jsonify({'success': False, 'error': 'Failed to submit workflow to ComfyUI'}), 500
logger.info(f"[MASK EXTRACT] Prompt submitted with ID: {prompt_id}")
except urllib.error.HTTPError as e:
logger.error(f"[MASK EXTRACT] HTTP Error submitting prompt: {e.code} - {e.read().decode()}")
return jsonify({'success': False, 'error': f'ComfyUI error: {str(e)}'}), 500
except Exception as e:
logger.error(f"[MASK EXTRACT] Error submitting to ComfyUI: {e}")
return jsonify({'success': False, 'error': f'Failed to connect to ComfyUI: {str(e)}'}), 500
# Poll for completion (up to 4 minutes)
start_time = time.time()
timeout = 240
while time.time() - start_time < timeout:
try:
req = urllib.request.Request(
f'http://{comfy_url}/history/{prompt_id}',
headers=headers,
method='GET'
)
with urllib.request.urlopen(req, timeout=30) as response:
history = json.loads(response.read().decode())
if prompt_id in history:
outputs = history[prompt_id].get('outputs', {})
status = history[prompt_id].get('status', {})
if status.get('status_str') == 'success':
logger.info("[MASK EXTRACT] Workflow completed successfully!")
break # Exit polling loop, workflow is done
logger.info(f"[MASK EXTRACT] Prompt_id in history but no outputs yet, waiting...")
else:
logger.info("[MASK EXTRACT] Prompt_id not in history yet, waiting...")
time.sleep(2)
except urllib.error.HTTPError as e:
if e.code == 404:
logger.debug("[MASK EXTRACT] Prompt not ready yet (404)")
time.sleep(2)
else:
logger.error(f"[MASK EXTRACT] HTTP Error polling: {e.code}")
break
except Exception as e:
logger.error(f"[MASK EXTRACT] Error polling history: {e}")
time.sleep(2)
# Check if we timed out
if time.time() - start_time >= timeout:
logger.error("[MASK EXTRACT] Timeout waiting for ComfyUI to complete")
return jsonify({'success': False, 'error': 'Mask extraction timed out'}), 500
# Workflow completed - wait for webhook callback
logger.info(f"[MASK EXTRACT] Checking/waiting for webhook callback from ComfyUI...")
global _webhook_response, _webhook_ready
# Check if webhook already arrived (can happen before history check completes)
if _webhook_ready.is_set() and _webhook_response is not None:
logger.info("[MASK EXTRACT] Webhook already received!")
webhook_received = True
else:
# Clear event for this wait, but preserve response if it arrives now
_webhook_ready.clear()
_webhook_response = None
# Wait for webhook with timeout (up to 60 seconds)
webhook_timeout = 60.0
webhook_received = _webhook_ready.wait(timeout=webhook_timeout)
if not webhook_received:
logger.error(f"[MASK EXTRACT] Timeout waiting for webhook callback")
return jsonify({'success': False, 'error': 'Webhook timeout - mask extraction may have failed'}), 500
# Check if webhook was successful
logger.info(f"[MASK EXTRACT] Webhook received: {_webhook_response}")
if not _webhook_response or not _webhook_response.get('success'):
error_msg = _webhook_response.get('error', 'Unknown error') if _webhook_response else 'Empty response'
logger.error(f"[MASK EXTRACT] Webhook failed: {error_msg}")
return jsonify({'success': False, 'error': f'Webhook error: {error_msg}'}), 500
# Get the mask path from webhook response
final_mask_path = _webhook_response.get('path')
if not final_mask_path:
logger.error("[MASK EXTRACT] No mask path in webhook response")
return jsonify({'success': False, 'error': 'No mask path returned from webhook'}), 500
try:
# Verify file exists
if not Path(final_mask_path).exists():
logger.error(f"[MASK EXTRACT] Mask file not found at {final_mask_path}")
return jsonify({'success': False, 'error': f'Mask file not found: {final_mask_path}'}), 500
logger.info(f"[MASK EXTRACT] Mask received via webhook at {final_mask_path}")
return jsonify({
'success': True,
'mask_path': str(final_mask_path),
'mask_url': f'/api/file/mask?path={final_mask_path}'
})
except Exception as e:
logger.error(f"[MASK EXTRACT] Error processing mask file: {e}")
return jsonify({'success': False, 'error': f'Error accessing mask: {str(e)}'}), 500
@app.route('/api/krita/open', methods=['POST'])
def api_krita_open():
"""Open in Krita - behavior depends on mode.
Review mode: Open the full ORA file for layer editing.
Add mode: Export base image with polygon overlay for manual annotation.
"""
data = request.get_json()
if not data or 'ora_path' not in data:
return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400
ora_path = data['ora_path']
try:
# Review mode: Open full ORA file
if data.get('open_full_ora'):
timestamp = str(int(time.time()))
temp_file = TEMP_DIR / f"edit_{timestamp}.ora"
shutil.copy2(ora_path, temp_file)
return jsonify({
'success': True,
'file_url': f'file://{temp_file}',
'temp_path': str(temp_file),
'base_mtime': os.path.getmtime(temp_file)
})
# Add mode: Open base with polygon overlay for annotation
elif data.get('open_base_with_polygon'):
points = data.get('points', [])
color = data.get('color', '#FF0000')
width = data.get('width', 2)
# Load base image from ORA
with zipfile.ZipFile(ora_path, 'r') as zf:
img = Image.open(zf.open('mergedimage.png')).convert('RGBA')
# Draw polygon overlay if points exist and we have 3+ points
if points and len(points) >= 3:
w, h = img.size
pixel_points = [(int(p['x'] * w), int(p['y'] * h)) for p in points]
draw = ImageDraw.Draw(img)
hex_color = color if len(color) == 7 else color + 'FF'
draw.polygon(pixel_points, outline=hex_color, width=width)
# Save to temp directory
timestamp = str(int(time.time()))
temp_file = TEMP_DIR / f"annotation_{timestamp}.png"
img.save(str(temp_file), format='PNG')
return jsonify({
'success': True,
'file_url': f'file://{temp_file}',
'temp_path': str(temp_file),
'base_mtime': os.path.getmtime(temp_file)
})
else:
return jsonify({'success': False, 'error': 'Invalid request - specify open_full_ora or open_base_with_polygon'}), 400
except Exception as e:
import traceback
logger.error(f"[KRITA OPEN] Error: {e}")
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/krita/status/<layer_name>')
def api_krita_status(layer_name):
"""Check if a Krita temp file has been modified."""
# Find the temp file for this layer
temp_files = list(TEMP_DIR.glob(f"{layer_name}_*.png"))
if not temp_files:
return jsonify({'success': False, 'error': 'No temp file found'}), 404
# Get most recent file
temp_file = max(temp_files, key=lambda f: f.stat().st_mtime)
mtime = temp_file.stat().st_mtime
stored_mtime = request.args.get('stored_mtime')
if stored_mtime:
modified = float(stored_mtime) != mtime
else:
modified = True
return jsonify({
'success': True,
'modified': modified,
'mtime': mtime,
'file_url': f'file://{temp_file}'
})
@app.route('/api/image/masked')
def api_image_masked():
"""Serve the base image with mask applied as alpha channel."""
ora_path = request.args.get('ora_path')
mask_path = request.args.get('mask_path')
if not ora_path or not mask_path:
return jsonify({'error': 'Missing ora_path or mask_path'}), 400
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
base_img = Image.open(zf.open('mergedimage.png')).convert('RGBA')
mask_img = Image.open(mask_path).convert('L')
if mask_img.size != base_img.size:
mask_img = mask_img.resize(base_img.size, Image.Resampling.BILINEAR)
r, g, b, _ = base_img.split()
result = Image.merge('RGBA', (r, g, b, mask_img))
img_io = io.BytesIO()
result.save(img_io, format='PNG')
img_io.seek(0)
return send_file(img_io, mimetype='image/png')
except Exception as e:
return Response(f"Error creating masked image: {e}", status=500)
@app.route('/api/file/mask')
def api_file_mask():
"""Serve a mask file from temp directory."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Missing path'}), 400
full_path = Path(path)
if not full_path.exists():
return jsonify({'error': 'File not found'}), 404
return send_file(full_path, mimetype='image/png')
@app.route('/api/image/layer/<layer_name>')
def api_image_layer(layer_name):
"""Serve a specific layer as image."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'error': 'Missing ora_path'}), 400
try:
# Find the layer source path
root = parse_stack_xml(ora_path)
with zipfile.ZipFile(ora_path, 'r') as zf:
stack = ET.fromstring(zf.read('stack.xml'))
for child in stack.find('stack'):
if child.tag == 'layer' and child.get('name') == layer_name:
src = child.get('src')
img_data = zf.read(src)
return send_file(io.BytesIO(img_data), mimetype='image/png')
elif child.tag == 'stack': # Group
for layer in child:
if layer.tag == 'layer' and layer.get('name') == layer_name:
src = layer.get('src')
img_data = zf.read(src)
return send_file(io.BytesIO(img_data), mimetype='image/png')
return jsonify({'error': 'Layer not found'}), 404
except Exception as e:
import traceback
traceback.print_exc()
return Response(f"Error loading layer: {e}", status=500)
# Register blueprints
app.register_blueprint(files_bp)
app.register_blueprint(layers_bp)
app.register_blueprint(images_bp)
app.register_blueprint(polygon_bp)
app.register_blueprint(mask_bp)
app.register_blueprint(krita_bp)
if __name__ == '__main__':

View File

@@ -0,0 +1,12 @@
"""Configuration for ORA Editor application."""
import os
from pathlib import Path
APP_DIR = Path(__file__).parent
PROJECT_ROOT = Path("/home/noti/dev/ai-game-2")
TEMP_DIR = APP_DIR / "temp"
COMFYUI_BASE_URL = os.environ.get('COMFYUI_BASE_URL', '127.0.0.1:8188')
TEMP_DIR.mkdir(exist_ok=True)

View File

@@ -0,0 +1,17 @@
"""Route blueprints for ORA Editor."""
from .files import files_bp
from .layers import layers_bp
from .images import images_bp
from .polygon import polygon_bp
from .mask import mask_bp
from .krita import krita_bp
__all__ = [
'files_bp',
'layers_bp',
'images_bp',
'polygon_bp',
'mask_bp',
'krita_bp'
]

View File

@@ -0,0 +1,100 @@
"""File operations routes for ORA Editor."""
import zipfile
from flask import Blueprint, request, jsonify
from pathlib import Path
from ora_editor.config import PROJECT_ROOT, TEMP_DIR
from ora_editor.ora_ops import (
load_ora, create_ora_from_png, parse_stack_xml, save_ora
)
from ora_editor.services.file_browser import FileBrowserService
from ora_editor.services import polygon_storage
import logging
logger = logging.getLogger(__name__)
files_bp = Blueprint('files', __name__)
browser_service = FileBrowserService(PROJECT_ROOT)
def ensure_ora_exists(input_path: str) -> str | None:
"""Ensure an ORA file exists, creating from PNG if necessary."""
full_path = PROJECT_ROOT / input_path
if not full_path.exists():
return None
if full_path.suffix.lower() == '.png':
ora_path = str(full_path.with_suffix('.ora'))
result = create_ora_from_png(str(full_path), ora_path)
if not result.get('success'):
return None
return ora_path
try:
parse_stack_xml(str(full_path))
return str(full_path)
except Exception:
return None
@files_bp.route('/api/browse')
def api_browse():
"""Browse project directory structure."""
path = request.args.get('path', '')
return jsonify(browser_service.list_directory(path))
@files_bp.route('/api/open', methods=['POST'])
def api_open():
"""Open a file (PNG or ORA)."""
data = request.get_json()
if not data or 'path' not in data:
return jsonify({'success': False, 'error': 'Missing path parameter'}), 400
input_path = data['path']
ora_path = ensure_ora_exists(input_path)
if not ora_path:
return jsonify({'success': False, 'error': f'File not found or invalid: {input_path}'}), 404
try:
loaded = load_ora(ora_path)
layers = []
for layer in loaded['layers']:
layers.append({
'name': layer['name'],
'src': layer['src'],
'group': layer.get('group'),
'visible': layer.get('visible', True)
})
return jsonify({
'success': True,
'ora_path': ora_path,
'width': loaded['width'],
'height': loaded['height'],
'layers': layers
})
except Exception as e:
return jsonify({'success': False, 'error': f'Error loading ORA: {str(e)}'}), 500
@files_bp.route('/api/save', methods=['POST'])
def api_save():
"""Save the current ORA state."""
data = request.get_json()
if not data or 'ora_path' not in data:
return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400
ora_path = data['ora_path']
result = save_ora(ora_path)
if result:
return jsonify({'success': True, 'ora_path': ora_path})
else:
return jsonify({'success': False, 'error': 'Failed to save ORA'}), 500

View File

@@ -0,0 +1,165 @@
"""Image serving routes for ORA Editor."""
import io
import zipfile
from flask import Blueprint, request, jsonify, send_file, Response
from pathlib import Path
from PIL import Image, ImageDraw
from ora_editor.ora_ops import parse_stack_xml
from ora_editor.config import PROJECT_ROOT
images_bp = Blueprint('images', __name__)
@images_bp.route('/api/image/<layer_name>')
def api_image(layer_name):
"""Serve a specific layer image."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'error': 'Missing ora_path'}), 400
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
img_data = zf.read('mergedimage.png')
return send_file(io.BytesIO(img_data), mimetype='image/png')
except Exception as e:
return Response(f"Error loading image: {e}", status=500)
@images_bp.route('/api/image/base')
def api_image_base():
"""Serve the base/merged image."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'error': 'Missing ora_path'}), 400
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
img_data = zf.read('mergedimage.png')
return send_file(io.BytesIO(img_data), mimetype='image/png')
except Exception as e:
return Response(f"Error loading image: {e}", status=500)
@images_bp.route('/api/image/layer/<layer_name>')
def api_image_layer(layer_name):
"""Serve a specific layer as image."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'error': 'Missing ora_path'}), 400
try:
from xml.etree import ElementTree as ET
with zipfile.ZipFile(ora_path, 'r') as zf:
stack = ET.fromstring(zf.read('stack.xml'))
for child in stack.find('stack'):
if child.tag == 'layer' and child.get('name') == layer_name:
src = child.get('src')
img_data = zf.read(src)
return send_file(io.BytesIO(img_data), mimetype='image/png')
elif child.tag == 'stack':
for layer in child:
if layer.tag == 'layer' and layer.get('name') == layer_name:
src = layer.get('src')
img_data = zf.read(src)
return send_file(io.BytesIO(img_data), mimetype='image/png')
return jsonify({'error': 'Layer not found'}), 404
except Exception as e:
import traceback
traceback.print_exc()
return Response(f"Error loading layer: {e}", status=500)
@images_bp.route('/api/image/polygon')
def api_image_polygon():
"""Serve polygon overlay if stored."""
from ora_editor.services import polygon_storage
ora_path = request.args.get('ora_path')
if not ora_path:
return Response("Missing ora_path", status=400)
poly_data = polygon_storage.get(ora_path)
if not poly_data:
return Response("No polygon data", status=404)
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
base_img = Image.open(zf.open('mergedimage.png')).convert('RGBA')
points = poly_data.get('points', [])
color = poly_data.get('color', '#FF0000')
width = poly_data.get('width', 2)
if len(points) < 3:
return Response("Not enough points", status=404)
w, h = base_img.size
pixel_points = [(int(p['x'] * w), int(p['y'] * h)) for p in points]
draw = ImageDraw.Draw(base_img)
hex_color = color if len(color) == 7 else color + 'FF'
draw.polygon(pixel_points, outline=hex_color, width=width)
img_io = io.BytesIO()
base_img.save(img_io, format='PNG')
img_io.seek(0)
return send_file(img_io, mimetype='image/png')
except Exception as e:
return Response(f"Error drawing polygon: {e}", status=500)
@images_bp.route('/api/image/masked')
def api_image_masked():
"""Serve the base image with mask applied as alpha channel."""
ora_path = request.args.get('ora_path')
mask_path = request.args.get('mask_path')
if not ora_path or not mask_path:
return jsonify({'error': 'Missing ora_path or mask_path'}), 400
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
base_img = Image.open(zf.open('mergedimage.png')).convert('RGBA')
mask_img = Image.open(mask_path).convert('L')
if mask_img.size != base_img.size:
mask_img = mask_img.resize(base_img.size, Image.Resampling.BILINEAR)
r, g, b, _ = base_img.split()
result = Image.merge('RGBA', (r, g, b, mask_img))
img_io = io.BytesIO()
result.save(img_io, format='PNG')
img_io.seek(0)
return send_file(img_io, mimetype='image/png')
except Exception as e:
return Response(f"Error creating masked image: {e}", status=500)
@images_bp.route('/api/file/mask')
def api_file_mask():
"""Serve a mask file from temp directory."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Missing path'}), 400
full_path = Path(path)
if not full_path.exists():
return jsonify({'error': 'File not found'}), 404
return send_file(full_path, mimetype='image/png')

View File

@@ -0,0 +1,108 @@
"""Krita integration routes for ORA Editor."""
import os
import shutil
import time
import zipfile
from pathlib import Path
from flask import Blueprint, request, jsonify
from PIL import Image, ImageDraw
from ora_editor.config import TEMP_DIR
import logging
logger = logging.getLogger(__name__)
krita_bp = Blueprint('krita', __name__)
@krita_bp.route('/api/krita/open', methods=['POST'])
def api_krita_open():
"""Open in Krita - behavior depends on mode.
Review mode: Open the full ORA file for layer editing.
Add mode: Export base image with polygon overlay for manual annotation.
"""
data = request.get_json()
if not data or 'ora_path' not in data:
return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400
ora_path = data['ora_path']
try:
if data.get('open_full_ora'):
timestamp = str(int(time.time()))
temp_file = TEMP_DIR / f"edit_{timestamp}.ora"
shutil.copy2(ora_path, temp_file)
return jsonify({
'success': True,
'file_url': f'file://{temp_file}',
'temp_path': str(temp_file),
'base_mtime': os.path.getmtime(temp_file)
})
elif data.get('open_base_with_polygon'):
from ora_editor.services import polygon_storage
points = data.get('points', [])
color = data.get('color', '#FF0000')
width = data.get('width', 2)
with zipfile.ZipFile(ora_path, 'r') as zf:
img = Image.open(zf.open('mergedimage.png')).convert('RGBA')
if points and len(points) >= 3:
w, h = img.size
pixel_points = [(int(p['x'] * w), int(p['y'] * h)) for p in points]
draw = ImageDraw.Draw(img)
hex_color = color if len(color) == 7 else color + 'FF'
draw.polygon(pixel_points, outline=hex_color, width=width)
timestamp = str(int(time.time()))
temp_file = TEMP_DIR / f"annotation_{timestamp}.png"
img.save(str(temp_file), format='PNG')
return jsonify({
'success': True,
'file_url': f'file://{temp_file}',
'temp_path': str(temp_file),
'base_mtime': os.path.getmtime(temp_file)
})
else:
return jsonify({'success': False, 'error': 'Invalid request - specify open_full_ora or open_base_with_polygon'}), 400
except Exception as e:
import traceback
logger.error(f"[KRITA OPEN] Error: {e}")
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
@krita_bp.route('/api/krita/status/<layer_name>')
def api_krita_status(layer_name):
"""Check if a Krita temp file has been modified."""
temp_files = list(TEMP_DIR.glob(f"{layer_name}_*.png"))
if not temp_files:
return jsonify({'success': False, 'error': 'No temp file found'}), 404
temp_file = max(temp_files, key=lambda f: f.stat().st_mtime)
mtime = temp_file.stat().st_mtime
stored_mtime = request.args.get('stored_mtime')
if stored_mtime:
modified = float(stored_mtime) != mtime
else:
modified = True
return jsonify({
'success': True,
'modified': modified,
'mtime': mtime,
'file_url': f'file://{temp_file}'
})

View File

@@ -0,0 +1,111 @@
"""Layer operations routes for ORA Editor."""
from flask import Blueprint, request, jsonify
from ora_editor.ora_ops import (
load_ora, add_masked_layer, rename_layer, delete_layer,
reorder_layer, set_layer_visibility
)
layers_bp = Blueprint('layers', __name__)
@layers_bp.route('/api/layers', methods=['GET'])
def api_layers():
"""Get the current layer structure."""
ora_path = request.args.get('ora_path')
if not ora_path:
return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400
try:
loaded = load_ora(ora_path)
layers = []
for layer in loaded['layers']:
layers.append({
'name': layer['name'],
'src': layer['src'],
'group': layer.get('group'),
'visible': layer.get('visible', True)
})
return jsonify({'success': True, 'layers': layers})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@layers_bp.route('/api/layer/add', methods=['POST'])
def api_layer_add():
"""Add a new masked layer."""
data = request.get_json()
required = ['ora_path', 'entity_name', 'mask_path']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
ora_path = data['ora_path']
entity_name = data['entity_name']
mask_path = data['mask_path']
source_layer = data.get('source_layer', 'base')
result = add_masked_layer(ora_path, entity_name, mask_path, source_layer)
return jsonify(result)
@layers_bp.route('/api/layer/rename', methods=['POST'])
def api_layer_rename():
"""Rename a layer."""
data = request.get_json()
required = ['ora_path', 'old_name', 'new_name']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = rename_layer(data['ora_path'], data['old_name'], data['new_name'])
return jsonify(result)
@layers_bp.route('/api/layer/delete', methods=['POST'])
def api_layer_delete():
"""Delete a layer."""
data = request.get_json()
required = ['ora_path', 'layer_name']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = delete_layer(data['ora_path'], data['layer_name'])
return jsonify(result)
@layers_bp.route('/api/layer/reorder', methods=['POST'])
def api_layer_reorder():
"""Reorder a layer."""
data = request.get_json()
required = ['ora_path', 'layer_name', 'direction']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = reorder_layer(data['ora_path'], data['layer_name'], data['direction'])
return jsonify(result)
@layers_bp.route('/api/layer/visibility', methods=['POST'])
def api_layer_visibility():
"""Set layer visibility."""
data = request.get_json()
required = ['ora_path', 'layer_name', 'visible']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
result = set_layer_visibility(data['ora_path'], data['layer_name'], data['visible'])
return jsonify(result)

View File

@@ -0,0 +1,161 @@
"""Mask extraction routes for ORA Editor."""
import io
import json
import time
import zipfile
from pathlib import Path
from flask import Blueprint, request, jsonify, make_response
from ora_editor.config import APP_DIR, COMFYUI_BASE_URL, TEMP_DIR
from ora_editor.services import polygon_storage
from ora_editor.services.comfyui import ComfyUIService
from ora_editor.ora_ops import parse_stack_xml
import logging
logger = logging.getLogger(__name__)
mask_bp = Blueprint('mask', __name__)
comfy_service = ComfyUIService(COMFYUI_BASE_URL)
@mask_bp.route('/api/webhook/comfyui', methods=['POST'])
def api_webhook_comfyui():
"""Webhook endpoint for ComfyUI to post completed mask images."""
logger.info("[WEBHOOK] Received webhook from ComfyUI")
result = comfy_service.handle_webhook(
request.files,
request.form if request.form else {},
request.data,
TEMP_DIR
)
if result.get('success'):
response = make_response(jsonify({'status': 'ok', 'message': 'Image received'}))
response.status_code = 200
else:
response = make_response(jsonify({'status': 'error', 'message': result.get('error', 'Unknown error')}))
response.status_code = 500
return response
@mask_bp.route('/api/mask/extract', methods=['POST'])
def api_mask_extract():
"""Extract a mask from the current base image using ComfyUI."""
data = request.get_json()
required = ['subject', 'ora_path']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
subject = data['subject']
use_polygon = data.get('use_polygon', False)
ora_path = data['ora_path']
comfy_url = data.get('comfy_url', COMFYUI_BASE_URL)
logger.info(f"[MASK EXTRACT] Subject: {subject}")
logger.info(f"[MASK EXTRACT] Use polygon: {use_polygon}")
logger.info(f"[MASK EXTRACT] ORA path: {ora_path}")
logger.info(f"[MASK EXTRACT] ComfyUI URL: {comfy_url}")
workflow_path = APP_DIR.parent / "image_mask_extraction.json"
if not workflow_path.exists():
logger.error(f"[MASK EXTRACT] Workflow file not found: {workflow_path}")
return jsonify({'success': False, 'error': f'Workflow file not found: {workflow_path}'}), 500
with open(workflow_path) as f:
workflow_template = json.load(f)
logger.info(f"[MASK EXTRACT] Loaded workflow")
base_img = None
try:
with zipfile.ZipFile(ora_path, 'r') as zf:
img_data = zf.read('mergedimage.png')
base_img = __import__('PIL').Image.open(io.BytesIO(img_data)).convert('RGBA')
logger.info(f"[MASK EXTRACT] Loaded base image: {base_img.size}")
except Exception as e:
logger.error(f"[MASK EXTRACT] Error loading base image: {e}")
return jsonify({'success': False, 'error': f'Error loading image: {str(e)}'}), 500
polygon_points = None
polygon_color = '#FF0000'
polygon_width = 2
if use_polygon:
poly_data = polygon_storage.get(ora_path)
if poly_data:
polygon_points = poly_data.get('points', [])
polygon_color = poly_data.get('color', '#FF0000')
polygon_width = poly_data.get('width', 2)
if not polygon_points or len(polygon_points) < 3:
logger.warning(f"[MASK EXTRACT] Use polygon requested but no valid polygon points stored")
return jsonify({'success': False, 'error': 'No valid polygon points. Please draw polygon first.'}), 400
webhook_url = f"http://localhost:5001/api/webhook/comfyui"
workflow = comfy_service.prepare_mask_workflow(
base_image=base_img,
subject=subject,
webhook_url=webhook_url,
polygon_points=polygon_points,
polygon_color=polygon_color,
polygon_width=polygon_width,
workflow_template=workflow_template
)
logger.info(f"[MASK EXTRACT] Workflow prepared, sending to ComfyUI at http://{comfy_url}")
try:
prompt_id = comfy_service.submit_workflow(workflow, comfy_url)
logger.info(f"[MASK EXTRACT] Prompt submitted with ID: {prompt_id}")
except Exception as e:
logger.error(f"[MASK EXTRACT] Error submitting to ComfyUI: {e}")
return jsonify({'success': False, 'error': f'Failed to connect to ComfyUI: {str(e)}'}), 500
completed = comfy_service.poll_for_completion(prompt_id, comfy_url, timeout=240)
if not completed:
logger.error("[MASK EXTRACT] Timeout waiting for ComfyUI to complete")
return jsonify({'success': False, 'error': 'Mask extraction timed out'}), 500
logger.info(f"[MASK EXTRACT] Checking/waiting for webhook callback from ComfyUI...")
webhook_result = comfy_service.wait_for_webhook(timeout=60.0)
if not webhook_result:
logger.error(f"[MASK EXTRACT] Timeout waiting for webhook callback")
return jsonify({'success': False, 'error': 'Webhook timeout - mask extraction may have failed'}), 500
logger.info(f"[MASK EXTRACT] Webhook received: {webhook_result}")
if not webhook_result.get('success'):
error_msg = webhook_result.get('error', 'Unknown error')
logger.error(f"[MASK EXTRACT] Webhook failed: {error_msg}")
return jsonify({'success': False, 'error': f'Webhook error: {error_msg}'}), 500
final_mask_path = webhook_result.get('path')
if not final_mask_path:
logger.error("[MASK EXTRACT] No mask path in webhook response")
return jsonify({'success': False, 'error': 'No mask path returned from webhook'}), 500
try:
if not Path(final_mask_path).exists():
logger.error(f"[MASK EXTRACT] Mask file not found at {final_mask_path}")
return jsonify({'success': False, 'error': f'Mask file not found: {final_mask_path}'}), 500
logger.info(f"[MASK EXTRACT] Mask received via webhook at {final_mask_path}")
return jsonify({
'success': True,
'mask_path': str(final_mask_path),
'mask_url': f'/api/file/mask?path={final_mask_path}'
})
except Exception as e:
logger.error(f"[MASK EXTRACT] Error processing mask file: {e}")
return jsonify({'success': False, 'error': f'Error accessing mask: {str(e)}'}), 500

View File

@@ -0,0 +1,46 @@
"""Polygon operations routes for ORA Editor."""
from flask import Blueprint, request, jsonify
import logging
from ora_editor.services import polygon_storage
logger = logging.getLogger(__name__)
polygon_bp = Blueprint('polygon', __name__)
@polygon_bp.route('/api/polygon', methods=['POST'])
def api_polygon():
"""Store polygon points for overlay."""
data = request.get_json()
required = ['ora_path', 'points']
for field in required:
if field not in data:
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
ora_path = data['ora_path']
points = data['points']
color = data.get('color', '#FF0000')
width = data.get('width', 2)
logger.info(f"[POLYGON] Storing polygon: {len(points)} points, color: {color}, width: {width}")
polygon_storage.store(ora_path, points, color, width)
return jsonify({
'success': True,
'overlay_url': f'/api/image/polygon?ora_path={ora_path}'
})
@polygon_bp.route('/api/polygon/clear', methods=['POST'])
def api_polygon_clear():
"""Clear stored polygon points."""
ora_path = request.json.get('ora_path') if request.data else None
if ora_path:
polygon_storage.clear(ora_path)
return jsonify({'success': True})

View File

@@ -0,0 +1,36 @@
"""Polygon storage service for in-memory polygon points."""
from typing import Any
class PolygonStorage:
"""In-memory storage for polygon points per ORA file."""
def __init__(self):
self._storage: dict[str, dict[str, Any]] = {}
def store(self, ora_path: str, points: list, color: str = '#FF0000', width: int = 2) -> None:
"""Store polygon data for an ORA file."""
self._storage[ora_path] = {
'points': points,
'color': color,
'width': width
}
def get(self, ora_path: str) -> dict[str, Any] | None:
"""Get polygon data for an ORA file."""
return self._storage.get(ora_path)
def clear(self, ora_path: str) -> bool:
"""Clear polygon data for an ORA file. Returns True if existed."""
if ora_path in self._storage:
del self._storage[ora_path]
return True
return False
def has_polygon(self, ora_path: str) -> bool:
"""Check if polygon exists for an ORA file."""
return ora_path in self._storage
polygon_storage = PolygonStorage()

View File

@@ -0,0 +1,187 @@
"""ComfyUI integration service for mask extraction."""
import base64
import io
import json
import random
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
import logging
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
class ComfyUIService:
"""Service for interacting with ComfyUI API."""
def __init__(self, base_url: str):
self.base_url = base_url
self._webhook_response: dict | None = None
self._webhook_ready = threading.Event()
def submit_workflow(self, workflow: dict, comfy_url: str | None = None) -> str:
"""Submit a workflow to ComfyUI and return the prompt_id."""
url = comfy_url or self.base_url
headers = {'Content-Type': 'application/json'}
payload = json.dumps({"prompt": workflow}).encode('utf-8')
req = urllib.request.Request(
f'http://{url}/prompt',
data=payload,
headers=headers,
method='POST'
)
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
prompt_id = result.get('prompt_id')
if not prompt_id:
raise RuntimeError("No prompt_id returned from ComfyUI")
return prompt_id
def poll_for_completion(self, prompt_id: str, comfy_url: str | None = None, timeout: int = 240) -> bool:
"""Poll ComfyUI history for workflow completion."""
url = comfy_url or self.base_url
headers = {'Content-Type': 'application/json'}
start_time = time.time()
while time.time() - start_time < timeout:
try:
req = urllib.request.Request(
f'http://{url}/history/{prompt_id}',
headers=headers,
method='GET'
)
with urllib.request.urlopen(req, timeout=30) as response:
history = json.loads(response.read().decode())
if prompt_id in history:
status = history[prompt_id].get('status', {})
if status.get('status_str') == 'success':
return True
time.sleep(2)
except urllib.error.HTTPError as e:
if e.code == 404:
time.sleep(2)
else:
raise
except Exception as e:
logger.error(f"Error polling history: {e}")
time.sleep(2)
return False
def wait_for_webhook(self, timeout: float = 60.0) -> dict | None:
"""Wait for webhook callback from ComfyUI."""
if self._webhook_ready.is_set() and self._webhook_response is not None:
return self._webhook_response
self._webhook_ready.clear()
self._webhook_response = None
webhook_received = self._webhook_ready.wait(timeout=timeout)
if webhook_received:
return self._webhook_response
return None
def handle_webhook(self, request_files, request_form, request_data, temp_dir: Path) -> dict:
"""Handle incoming webhook from ComfyUI."""
self._webhook_response = None
self._webhook_ready.clear()
try:
img_file = None
if 'file' in request_files:
img_file = request_files['file']
elif 'image' in request_files:
img_file = request_files['image']
elif request_files:
img_file = list(request_files.values())[0]
if img_file:
timestamp = str(int(time.time()))
final_mask_path = temp_dir / f"mask_{timestamp}.png"
img = Image.open(img_file).convert('RGBA')
img.save(str(final_mask_path), format='PNG')
logger.info(f"[WEBHOOK] Image saved to {final_mask_path}")
self._webhook_response = {'success': True, 'path': final_mask_path}
elif request_data:
timestamp = str(int(time.time()))
final_mask_path = temp_dir / f"mask_{timestamp}.png"
with open(final_mask_path, 'wb') as f:
f.write(request_data)
logger.info(f"[WEBHOOK] Raw data saved to {final_mask_path}")
self._webhook_response = {'success': True, 'path': final_mask_path}
else:
logger.error("[WEBHOOK] No image data in request")
self._webhook_response = {'success': False, 'error': 'No image data received'}
self._webhook_ready.set()
return self._webhook_response
except Exception as e:
logger.error(f"[WEBHOOK] Error: {e}")
self._webhook_response = {'success': False, 'error': str(e)}
self._webhook_ready.set()
return self._webhook_response
def prepare_mask_workflow(
self,
base_image: Image.Image,
subject: str,
webhook_url: str,
polygon_points: list | None = None,
polygon_color: str = '#FF0000',
polygon_width: int = 2,
workflow_template: dict | None = None
) -> dict:
"""Prepare the mask extraction workflow."""
workflow = workflow_template.copy() if workflow_template else {}
img = base_image.copy()
if polygon_points and len(polygon_points) >= 3:
w, h = img.size
pixel_points = [(int(p['x'] * w), int(p['y'] * h)) for p in polygon_points]
draw = ImageDraw.Draw(img)
hex_color = polygon_color if len(polygon_color) == 7 else polygon_color + 'FF'
draw.polygon(pixel_points, outline=hex_color, width=polygon_width)
img_io = io.BytesIO()
img.save(img_io, format='PNG')
img_io.seek(0)
base64_image = base64.b64encode(img_io.read()).decode('utf-8')
if "87" in workflow:
workflow["87"]["inputs"]["image"] = base64_image
if "1:68" in workflow and 'inputs' in workflow["1:68"]:
workflow["1:68"]["inputs"]["prompt"] = f"Create a black and white alpha mask of {subject}, leaving everything else black"
if "96" in workflow and 'inputs' in workflow["96"]:
workflow["96"]["inputs"]["webhook_url"] = webhook_url
if "50" in workflow and 'inputs' in workflow["50"]:
workflow["50"]["inputs"]["seed"] = random.randint(0, 2**31-1)
return workflow

View File

@@ -0,0 +1,75 @@
"""File browsing service for project directory navigation."""
import os
from pathlib import Path
from typing import Any
class FileBrowserService:
"""Service for browsing project files and directories."""
SUPPORTED_EXTENSIONS = {'.png', '.ora'}
def __init__(self, project_root: Path):
self.project_root = project_root
def list_directory(self, relative_path: str = "") -> dict[str, Any]:
"""List contents of a directory relative to project root."""
if relative_path:
dir_path = self.project_root / relative_path
else:
dir_path = self.project_root
if not dir_path.exists() or not dir_path.is_dir():
return {
'success': False,
'error': f'Directory not found: {relative_path}'
}
directories = []
files = []
try:
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if entry.name.startswith('.'):
continue
if entry.is_dir():
rel_path = str(entry.relative_to(self.project_root))
directories.append({
'name': entry.name,
'path': rel_path
})
elif entry.is_file():
suffix = entry.suffix.lower()
if suffix in self.SUPPORTED_EXTENSIONS:
rel_path = str(entry.relative_to(self.project_root))
files.append({
'name': entry.name,
'path': rel_path,
'type': suffix[1:]
})
parent_path = None
if relative_path:
parent = Path(relative_path).parent
parent_path = str(parent) if parent != Path('.') else ""
return {
'success': True,
'current_path': relative_path,
'parent_path': parent_path,
'directories': directories,
'files': files
}
except PermissionError:
return {
'success': False,
'error': f'Permission denied: {relative_path}'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ORA Editor{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #374151; }
::-webkit-scrollbar-thumb { background: #6B7280; border-radius: 4px; }
</style>
{% block head %}{% endblock %}
</head>
<body class="bg-gray-900 text-white">
{% block body %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!-- Main canvas area component for ORA Editor -->
<main class="flex-1 bg-gray-800 rounded-lg border border-gray-700 p-4 relative overflow-auto" style="min-height: 600px;">
<div id="imageContainer"
class="relative inline-block origin-top-left"
:style="`width: ${imageWidth}px; height: ${imageHeight}px; transform: scale(${scale / 100});`">
<!-- Layer images stacked -->
<div class="relative w-full h-full">
<template x-for="layer in layers" :key="'layer-' + layer.name">
<div x-show="layer.visible" class="absolute inset-0 w-full h-full">
<img
:src="'/api/image/layer/' + encodeURIComponent(layer.name) + '?ora_path=' + encodeURIComponent(oraPath)"
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
>
</div>
</template>
<!-- Polygon points markers (draggable) - shown in add mode -->
<template x-if="mode === 'add' && polygonPoints.length > 0">
<template x-for="(point, idx) in polygonPoints" :key="'point-' + idx">
<div
class="absolute w-4 h-4 bg-white border-2 border-red-500 rounded-full cursor-move z-10"
style="transform: translate(-50%, -50%);"
:style="`left: ${point.x * 100}%; top: ${point.y * 100}%`"
@mousedown="startDragPoint($event, idx)"
@touchstart.prevent="startDragPoint($event, idx)"
></div>
</template>
</template>
<!-- Polygon overlay after drawing -->
<img
x-show="!isDrawing && polygonPreviewUrl"
:src="polygonPreviewUrl"
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
alt="Polygon overlay"
>
<!-- Drawing canvas (only when actively drawing) -->
<canvas
id="polygonCanvas"
x-show="isDrawing"
:width="imageWidth"
:height="imageHeight"
class="absolute inset-0 cursor-crosshair pointer-events-auto border-2 border-dashed border-blue-500 opacity-90"
></canvas>
</div>
</div>
<!-- Loading indicator -->
<div x-show="isLoading && !error" class="flex items-center justify-center h-full">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
<!-- Error message -->
<div x-show="error" class="text-red-400 text-center mt-8" x-text="error"></div>
<!-- Mode-specific instructions -->
<div x-show="isDrawing" class="mt-2 text-sm text-gray-400">
Click to add points. Drag points to adjust. Double-click or Enter to finish, Escape to cancel.
</div>
<div x-show="mode === 'add' && !isDrawing && polygonPoints.length >= 3" class="mt-2 text-sm text-gray-400">
Drag points to adjust polygon, then extract mask or open in Krita.
</div>
<div x-show="mode === 'add' && !isDrawing && !polygonPreviewUrl && polygonPoints.length < 3" class="mt-2 text-sm text-gray-400">
Draw a polygon (optional) then extract mask, or use Open in Krita to annotation manually.
</div>
</main>

View File

@@ -0,0 +1,135 @@
<!-- Sidebar component for ORA Editor -->
<!-- REVIEW MODE SIDEBAR -->
<template x-if="mode === 'review'">
<div>
<!-- Layers panel -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">Layers</h3>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template x-for="layer in layers" :key="layer.name">
<div
@click="selectedLayer = layer.name"
class="flex items-center gap-2 bg-gray-700 hover:bg-gray-650 rounded px-2 py-1 transition cursor-pointer"
:class="{ 'bg-gray-600': selectedLayer === layer.name }"
>
<input
type="checkbox"
:checked="layer.visible"
@change="toggleVisibility(layer.name, $event.target.checked)"
class="w-4 h-4 rounded"
title="Toggle visibility"
>
<span class="flex-1 text-sm truncate" x-text="layer.name"></span>
</div>
</template>
</div>
<!-- Layer edit controls -->
<div x-show="selectedLayer" class="mt-3 pt-3 border-t border-gray-600 space-y-2">
<div class="grid grid-cols-2 gap-2">
<button @click="renameLayer()" class="bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded text-sm">Rename</button>
<button @click="deleteLayer()" class="bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm">Delete</button>
</div>
<div class="grid grid-cols-2 gap-2">
<button @click="reorderLayer('up')" class="bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded text-sm">▲ Up</button>
<button @click="reorderLayer('down')" class="bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded text-sm">▼ Down</button>
</div>
</div>
</div>
<!-- Add masked element button -->
<button
@click="enterAddMode()"
class="w-full bg-indigo-600 hover:bg-indigo-700 px-4 py-3 rounded-lg font-bold text-lg"
>
+ Add Masked Element
</button>
</div>
</template>
<!-- ADD MASKED ELEMENT MODE SIDEBAR -->
<template x-if="mode === 'add'">
<div>
<!-- Entity name input -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">New Element</h3>
<input
type="text"
x-model="entityName"
placeholder="Element name (e.g., 'door')"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:border-blue-500 outline-none"
>
<p class="text-xs text-gray-400 mt-1">Will create layer: <span x-text="entityName ? entityName + '_0' : 'element_0'"></span></p>
</div>
<!-- Polygon tool -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">Polygon (Optional)</h3>
<div class="space-y-3">
<p class="text-xs text-gray-400">Draw polygon to hint AI where subject is. Leave blank for manual drawing in Krita.</p>
<label class="block text-xs text-gray-400 mb-1">Color: <input type="color" x-model="polygonColor" class="h-8 w-full rounded cursor-pointer"></label>
<label class="block text-xs text-gray-400 mb-1">Width: <input type="number" x-model.number="polygonWidth" min="1" max="10" class="w-full bg-gray-700 border border-gray-600 rounded px-2 py-1"></label>
<div class="flex gap-2">
<button @click="startDrawing()" :disabled="isDrawing" class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-3 py-1.5 rounded text-sm transition">Start Drawing</button>
<button @click="finishDrawing()" :disabled="!isDrawing || polygonPoints.length < 3" class="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-3 py-1.5 rounded text-sm transition">Done</button>
</div>
<div x-show="polygonPoints.length > 0">
<span class="text-xs text-gray-400">Points: </span><span x-text="polygonPoints.length"></span>
</div>
<button @click="clearPolygon()" :disabled="!isDrawing && polygonPoints.length === 0" class="w-full bg-gray-600 hover:bg-gray-500 disabled:bg-gray-700 px-3 py-1.5 rounded text-sm transition">Clear</button>
</div>
</div>
<!-- Mask extraction -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">Mask Extraction</h3>
<div class="space-y-3">
<input
type="text"
x-model="maskSubject"
placeholder="e.g., 'the wooden door'"
:disabled="isExtracting"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:border-blue-500 outline-none disabled:bg-gray-800"
>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
x-model="usePolygonHint"
:disabled="isExtracting || polygonPoints.length < 3"
class="w-4 h-4 rounded"
>
<span class="text-sm">Use polygon hint</span>
</label>
<button
@click="extractMask()"
:disabled="!maskSubject.trim() || isExtracting"
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 px-4 py-2 rounded transition flex items-center justify-center gap-2"
>
<span x-show="isExtracting" class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></span>
<span x-show="!isExtracting">Extract Mask</span>
<span x-show="isExtracting">Extracting...</span>
</button>
<div x-show="lastError" class="text-red-400 text-xs" x-text="lastError"></div>
</div>
</div>
<!-- Back to review mode -->
<button
@click="cancelAddMode()"
class="w-full bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded-lg"
>
← Back to Review Mode
</button>
</div>
</template>

View File

@@ -1,19 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ORA Editor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #374151; }
::-webkit-scrollbar-thumb { background: #6B7280; border-radius: 4px; }
</style>
</head>
<body class="bg-gray-900 text-white">
<div x-data="oraEditor()" x-init="init()" class="min-h-screen p-4">
{% extends "base.html" %}
{% block body %}
<div x-data="oraEditor()" x-init="init()" class="min-h-screen p-4">
<!-- Header -->
<header class="mb-4 flex items-center gap-4">
@@ -25,6 +13,13 @@
class="flex-1 bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:border-blue-500 outline-none"
@keydown.enter="openFile()"
>
<button
@click="openBrowseModal()"
class="bg-gray-600 hover:bg-gray-500 px-3 py-2 rounded transition"
title="Browse files"
>
📁 Browse
</button>
<button
@click="openFile()"
:disabled="!filePath || isLoading"
@@ -35,7 +30,7 @@
</button>
</div>
<!-- Krita button - behavior changes based on mode -->
<!-- Krita button -->
<button
@click="openInKrita()"
:disabled="!oraPath || isExtracting"
@@ -59,7 +54,7 @@
x-text="saveNotification"
></div>
<!-- Scale slider - visible when file loaded -->
<!-- Scale slider -->
<div x-show="oraPath" class="flex items-center gap-2 bg-gray-800 rounded px-3 py-2 border border-gray-700">
<span class="text-sm text-gray-300">Scale:</span>
<input
@@ -80,218 +75,13 @@
<template x-if="oraPath">
<div class="flex gap-4">
<!-- SIDEBAR - DIFFERENT CONTENT FOR EACH MODE -->
<!-- SIDEBAR -->
<aside class="w-72 flex-shrink-0 space-y-4">
<!-- REVIEW MODE SIDEBAR -->
<template x-if="mode === 'review'">
<div>
<!-- Layers panel -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">Layers</h3>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template x-for="layer in layers" :key="layer.name">
<div
@click="selectedLayer = layer.name"
class="flex items-center gap-2 bg-gray-700 hover:bg-gray-650 rounded px-2 py-1 transition cursor-pointer"
:class="{ 'bg-gray-600': selectedLayer === layer.name }"
>
<!-- Visibility checkbox -->
<input
type="checkbox"
:checked="layer.visible"
@change="toggleVisibility(layer.name, $event.target.checked)"
class="w-4 h-4 rounded"
title="Toggle visibility"
>
<!-- Layer name -->
<span class="flex-1 text-sm truncate" x-text="layer.name"></span>
</div>
</template>
</div>
<!-- Layer edit controls (only when layer selected) -->
<div x-show="selectedLayer" class="mt-3 pt-3 border-t border-gray-600 space-y-2">
<div class="grid grid-cols-2 gap-2">
<button @click="renameLayer()" class="bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded text-sm">Rename</button>
<button @click="deleteLayer()" class="bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm">Delete</button>
</div>
<div class="grid grid-cols-2 gap-2">
<button @click="reorderLayer('up')" class="bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded text-sm">▲ Up</button>
<button @click="reorderLayer('down')" class="bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded text-sm">▼ Down</button>
</div>
</div>
</div>
<!-- Add masked element button -->
<button
@click="enterAddMode()"
class="w-full bg-indigo-600 hover:bg-indigo-700 px-4 py-3 rounded-lg font-bold text-lg"
>
+ Add Masked Element
</button>
</div>
</template>
<!-- ADD MASKED ELEMENT MODE SIDEBAR -->
<template x-if="mode === 'add'">
<div>
<!-- Entity name input -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">New Element</h3>
<input
type="text"
x-model="entityName"
placeholder="Element name (e.g., 'door')"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:border-blue-500 outline-none"
>
<p class="text-xs text-gray-400 mt-1">Will create layer: <span x-text="entityName ? entityName + '_0' : 'element_0'"></span></p>
</div>
<!-- Polygon tool -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">Polygon (Optional)</h3>
<div class="space-y-3">
<p class="text-xs text-gray-400">Draw polygon to hint AI where subject is. Leave blank for manual drawing in Krita.</p>
<label class="block text-xs text-gray-400 mb-1">Color: <input type="color" x-model="polygonColor" class="h-8 w-full rounded cursor-pointer"></label>
<label class="block text-xs text-gray-400 mb-1">Width: <input type="number" x-model.number="polygonWidth" min="1" max="10" class="w-full bg-gray-700 border border-gray-600 rounded px-2 py-1"></label>
<div class="flex gap-2">
<button @click="startDrawing()" :disabled="isDrawing" class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-3 py-1.5 rounded text-sm transition">Start Drawing</button>
<button @click="finishDrawing()" :disabled="!isDrawing || polygonPoints.length < 3" class="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-3 py-1.5 rounded text-sm transition">Done</button>
</div>
<div x-show="polygonPoints.length > 0">
<span class="text-xs text-gray-400">Points: </span><span x-text="polygonPoints.length"></span>
</div>
<button @click="clearPolygon()" :disabled="!isDrawing && polygonPoints.length === 0" class="w-full bg-gray-600 hover:bg-gray-500 disabled:bg-gray-700 px-3 py-1.5 rounded text-sm transition">Clear</button>
</div>
</div>
<!-- Mask extraction -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h3 class="font-bold mb-3 text-gray-300">Mask Extraction</h3>
<div class="space-y-3">
<input
type="text"
x-model="maskSubject"
placeholder="e.g., 'the wooden door'"
:disabled="isExtracting"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:border-blue-500 outline-none disabled:bg-gray-800"
>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
x-model="usePolygonHint"
:disabled="isExtracting || polygonPoints.length < 3"
class="w-4 h-4 rounded"
>
<span class="text-sm">Use polygon hint</span>
</label>
<button
@click="extractMask()"
:disabled="!maskSubject.trim() || isExtracting"
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 px-4 py-2 rounded transition flex items-center justify-center gap-2"
>
<span x-show="isExtracting" class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></span>
<span x-show="!isExtracting">Extract Mask</span>
<span x-show="isExtracting">Extracting...</span>
</button>
<div x-show="lastError" class="text-red-400 text-xs" x-text="lastError"></div>
</div>
</div>
<!-- Back to review mode -->
<button
@click="cancelAddMode()"
class="w-full bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded-lg"
>
← Back to Review Mode
</button>
</div>
</template>
{% include "components/sidebar.html" %}
</aside>
<!-- MAIN CANVAS AREA -->
<main class="flex-1 bg-gray-800 rounded-lg border border-gray-700 p-4 relative overflow-auto" style="min-height: 600px;">
<div id="imageContainer"
class="relative inline-block origin-top-left"
:style="`width: ${imageWidth}px; height: ${imageHeight}px; transform: scale(${scale / 100});`">
<!-- Layer images stacked -->
<div class="relative w-full h-full">
<template x-for="layer in layers" :key="'layer-' + layer.name">
<div x-show="layer.visible" class="absolute inset-0 w-full h-full">
<img
:src="'/api/image/layer/' + encodeURIComponent(layer.name) + '?ora_path=' + encodeURIComponent(oraPath)"
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
>
</div>
</template>
<!-- Polygon points markers (draggable) - shown in add mode -->
<template x-if="mode === 'add' && polygonPoints.length > 0">
<template x-for="(point, idx) in polygonPoints" :key="'point-' + idx">
<div
class="absolute w-4 h-4 bg-white border-2 border-red-500 rounded-full cursor-move z-10"
style="transform: translate(-50%, -50%);"
:style="`left: ${point.x * 100}%; top: ${point.y * 100}%`"
@mousedown="startDragPoint($event, idx)"
@touchstart.prevent="startDragPoint($event, idx)"
></div>
</template>
</template>
<!-- Polygon overlay after drawing -->
<img
x-show="!isDrawing && polygonPreviewUrl"
:src="polygonPreviewUrl"
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
alt="Polygon overlay"
>
<!-- Drawing canvas (only when actively drawing) -->
<canvas
id="polygonCanvas"
x-show="isDrawing"
:width="imageWidth"
:height="imageHeight"
class="absolute inset-0 cursor-crosshair pointer-events-auto border-2 border-dashed border-blue-500 opacity-90"
></canvas>
</div>
</div>
<!-- Loading indicator -->
<div x-show="isLoading && !error" class="flex items-center justify-center h-full">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
<!-- Error message -->
<div x-show="error" class="text-red-400 text-center mt-8" x-text="error"></div>
<!-- Mode-specific instructions -->
<div x-show="isDrawing" class="mt-2 text-sm text-gray-400">
Click to add points. Drag points to adjust. Double-click or Enter to finish, Escape to cancel.
</div>
<div x-show="mode === 'add' && !isDrawing && polygonPoints.length >= 3" class="mt-2 text-sm text-gray-400">
Drag points to adjust polygon, then extract mask or open in Krita.
</div>
<div x-show="mode === 'add' && !isDrawing && !polygonPreviewUrl && polygonPoints.length < 3" class="mt-2 text-sm text-gray-400">
Draw a polygon (optional) then extract mask, or use Open in Krita to annotation manually.
</div>
</main>
{% include "components/canvas.html" %}
</div>
</template>
@@ -300,107 +90,19 @@
<p class="text-xl">Open a PNG or ORA file to start editing</p>
</div>
<!-- Mask preview modal -->
<div x-show="showMaskModal" @keydown.escape.window="closeMaskModal()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 border border-gray-600" style="max-height: 90vh; display: flex; flex-direction: column;">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Extracted Mask</h2>
<!-- View mode toggle -->
<div class="flex gap-2">
<button
@click="maskViewMode = 'with-bg'"
:class="maskViewMode === 'with-bg' ? 'bg-blue-600' : 'bg-gray-600'"
class="px-3 py-1 rounded text-sm font-medium"
>With Background</button>
<button
@click="maskViewMode = 'masked-only'"
:class="maskViewMode === 'masked-only' ? 'bg-green-600' : 'bg-gray-600'"
class="px-3 py-1 rounded text-sm font-medium"
>Masked Only</button>
</div>
</div>
<!-- Modals -->
{% include "modals/browse.html" %}
{% include "modals/settings.html" %}
{% include "modals/mask_preview.html" %}
{% include "modals/krita.html" %}
<div class="flex-1 overflow-auto bg-gray-700 rounded mb-4 flex items-center justify-center p-4" style="min-height: 300px; max-height: 50vh;">
<div class="relative" style="max-width: 100%; max-height: calc(50vh - 2rem);">
<!-- Base image (shown in "with-bg" mode) -->
<img
x-show="maskViewMode === 'with-bg'"
:src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)"
class="border border-gray-600"
style="max-width: 100%; max-height: calc(50vh - 2rem);"
>
<!-- Masked image (transparent where mask is black) -->
<img
x-show="tempMaskPath"
:src="'/api/image/masked?ora_path=' + encodeURIComponent(oraPath) + '&mask_path=' + encodeURIComponent(tempMaskPath)"
class="border border-gray-600"
:class="maskViewMode === 'with-bg' ? 'absolute inset-0' : ''"
style="max-width: 100%; max-height: calc(50vh - 2rem);"
>
</div>
</div>
</div>
{% endblock %}
<div class="flex gap-4 justify-end">
<button @click="rerollMask()" :disabled="isExtracting" class="bg-gray-600 hover:bg-gray-500 disabled:bg-gray-700 px-4 py-2 rounded transition">Re-roll</button>
<button @click="useMask()" :disabled="isExtracting || !tempMaskPath" class="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded transition">Use This Mask</button>
<button @click="closeMaskModal()" :disabled="isExtracting" class="bg-red-600 hover:bg-red-700 disabled:bg-gray-600 px-4 py-2 rounded transition">Cancel</button>
</div>
</div>
</div>
<!-- Settings modal -->
<div x-show="showSettings" @keydown.escape.window="showSettings = false"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 border border-gray-600">
<h2 class="text-xl font-bold mb-4">Settings</h2>
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-2">ComfyUI Server URL</label>
<input
type="text"
x-model="comfyUrl"
placeholder="127.0.0.1:8188"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500"
>
</div>
<div class="flex gap-4 justify-end mt-4">
<button @click="showSettings = false" class="bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded transition">Cancel</button>
<button @click="saveSettings()" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded transition">Save</button>
</div>
</div>
</div>
<!-- Krita modal -->
<div x-show="showKritaModal" @keydown.escape.window="closeKritaModal()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 rounded-lg p-6 max-w-lg w-full mx-4 border border-gray-600">
<h2 class="text-xl font-bold mb-4">Open in Krita</h2>
<p class="text-gray-300 mb-4">File exported to:</p>
<div class="bg-gray-700 rounded p-3 mb-4 font-mono text-sm text-green-400 break-all" x-text="kritaTempPath"></div>
<p class="text-gray-400 text-sm mb-4">Copy the path and open it in Krita manually. Browsers cannot open local files directly.</p>
<div class="flex gap-4 justify-end">
<button
@click="copyKritaPath()"
:class="kritaPathCopied ? 'bg-green-600' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 rounded transition"
x-text="kritaPathCopied ? 'Copied!' : 'Copy Path'"
></button>
<button @click="closeKritaModal()" class="bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded transition">Close</button>
</div>
</div>
</div>
</div>
<!-- Alpine.js data object -->
<script>
function oraEditor() {
{% block scripts %}
<!-- Alpine.js data object -->
<script>
function oraEditor() {
return {
// File state
filePath: '',
@@ -411,13 +113,13 @@
imageWidth: 800,
imageHeight: 600,
// Display scale - only used in review mode
// Display scale
scale: 25,
// Mode: 'review' or 'add'
mode: 'review',
// Selected layer for editing (review mode)
// Selected layer for editing
selectedLayer: null,
// Add masked element mode
@@ -434,7 +136,7 @@
maskSubject: '',
usePolygonHint: true,
isExtracting: false,
maskViewMode: 'with-bg', // 'with-bg' or 'masked-only'
maskViewMode: 'with-bg',
showMaskModal: false,
tempMaskPath: null,
tempMaskUrl: null,
@@ -450,6 +152,13 @@
kritaTempPath: null,
kritaPathCopied: false,
// Browse modal
showBrowseModal: false,
browsePath: '',
browseDirectories: [],
browseFiles: [],
browseSelectedPath: null,
// Loading/error
isLoading: false,
error: '',
@@ -466,6 +175,65 @@
});
},
// === File Browser ===
async openBrowseModal() {
this.browsePath = '';
this.browseSelectedPath = null;
await this.loadBrowseDirectory('');
this.showBrowseModal = true;
},
closeBrowseModal() {
this.showBrowseModal = false;
},
async loadBrowseDirectory(path) {
try {
const response = await fetch('/api/browse?path=' + encodeURIComponent(path));
const data = await response.json();
if (data.success) {
this.browsePath = data.current_path;
this.browseDirectories = data.directories;
this.browseFiles = data.files;
} else {
console.error('Browse error:', data.error);
}
} catch (e) {
console.error('Browse error:', e);
}
},
navigateBrowseDirectory(path) {
this.browseSelectedPath = null;
this.loadBrowseDirectory(path);
},
navigateBrowseParent() {
const parent = this.browsePath.split('/').slice(0, -1).join('/');
this.browseSelectedPath = null;
this.loadBrowseDirectory(parent);
},
selectBrowseFile(file) {
this.browseSelectedPath = file.path;
},
openBrowseFile(file) {
this.filePath = file.path;
this.showBrowseModal = false;
this.openFile();
},
openSelectedFile() {
if (this.browseSelectedPath) {
this.filePath = this.browseSelectedPath;
this.showBrowseModal = false;
this.openFile();
}
},
// === File Operations ===
async openFile() {
if (!this.filePath || this.isLoading) return;
@@ -493,7 +261,7 @@
}));
this.imageWidth = data.width;
this.imageHeight = data.height;
this.mode = 'review'; // Start in review mode
this.mode = 'review';
this.clearPolygon();
} catch (e) {
console.error('[ORA EDITOR] Error opening file:', e);
@@ -506,7 +274,6 @@
async saveCurrent() {
if (!this.oraPath) return;
// Get the original PNG path (same directory, .ora -> .png or keep .ora)
let savePath = this.oraPath;
const response = await fetch('/api/save', {
@@ -524,13 +291,14 @@
}
},
// === Mode Management ===
enterAddMode() {
console.log('[ORA EDITOR] Entering add masked element mode');
this.mode = 'add';
this.entityName = '';
this.maskSubject = '';
this.clearPolygon();
this.scale = 100; // Full size for precision drawing
this.scale = 100;
},
exitAddMode() {
@@ -553,6 +321,7 @@
}
},
// === Layer Operations ===
async toggleVisibility(layerName, visible) {
const idx = this.layers.findIndex(l => l.name === layerName);
if (idx >= 0) {
@@ -631,6 +400,7 @@
});
},
// === Polygon Drawing ===
startDrawing() {
console.log('[ORA EDITOR] Starting polygon drawing mode');
this.isDrawing = true;
@@ -658,7 +428,6 @@
addPolygonPoint(x, y) {
if (!this.isDrawing) return;
// Allow points slightly outside bounds (-0.1 to 1.1)
x = Math.max(-0.1, Math.min(1.1, x));
y = Math.max(-0.1, Math.min(1.1, y));
@@ -684,7 +453,6 @@
y = (moveEvent.clientY - rect.top) / rect.height;
}
// Allow points slightly outside bounds
x = Math.max(-0.1, Math.min(1.1, x));
y = Math.max(-0.1, Math.min(1.1, y));
@@ -698,7 +466,6 @@
document.removeEventListener('touchmove', moveHandler);
document.removeEventListener('touchend', upHandler);
// Update preview after drag
if (this.polygonPoints.length >= 3) {
this.updatePolygonPreview();
}
@@ -815,6 +582,7 @@
}
},
// === Mask Extraction ===
async extractMask() {
if (!this.maskSubject.trim()) return;
@@ -897,19 +665,18 @@
this.tempMaskUrl = null;
},
// === Krita Integration ===
async openInKrita() {
if (!this.oraPath) return;
let requestBody;
if (this.mode === 'review') {
// Open full ORA file
requestBody = {
ora_path: this.oraPath,
open_full_ora: true
};
} else {
// Open base with polygon overlay for manual annotation
requestBody = {
ora_path: this.oraPath,
open_base_with_polygon: true,
@@ -927,7 +694,6 @@
const data = await response.json();
if (data.success) {
// Show the temp file path - user can open in Krita manually
this.kritaTempPath = data.temp_path;
this.showKritaModal = true;
} else {
@@ -949,20 +715,20 @@
this.kritaPathCopied = false;
},
// === Settings ===
saveSettings() {
localStorage.setItem('ora_comfy_url', this.comfyUrl);
this.showSettings = false;
}
};
}
</script>
}
</script>
<!-- Custom JS for canvas click handling -->
<script>
let canvasDoubleClickPending = false;
<!-- Custom JS for canvas click handling -->
<script>
let canvasDoubleClickPending = false;
document.addEventListener('alpine:init', () => {
// Set up canvas event listeners after Alpine initializes
document.addEventListener('alpine:init', () => {
const observer = new MutationObserver(() => {
const canvas = document.getElementById('polygonCanvas');
if (canvas && !canvas.hasAttribute('data-handlers-setup')) {
@@ -978,7 +744,6 @@
let x = (e.clientX - rect.left) / rect.width;
let y = (e.clientY - rect.top) / rect.height;
// Allow points slightly outside bounds (-0.1 to 1.1)
x = Math.max(-0.1, Math.min(1.1, x));
y = Math.max(-0.1, Math.min(1.1, y));
@@ -1007,13 +772,11 @@
}
});
// Clear pending flag after a short delay
setTimeout(() => { canvasDoubleClickPending = false; }, 200);
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
</script>
</body>
</html>
});
</script>
{% endblock %}

View File

@@ -0,0 +1,68 @@
<!-- Browse modal for file selection -->
<div x-show="showBrowseModal" @keydown.escape.window="closeBrowseModal()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 border border-gray-600" style="max-height: 80vh; display: flex; flex-direction: column;">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Open File</h2>
<div class="text-sm text-gray-400">
Current: <span x-text="browsePath || '/'"></span>
</div>
</div>
<!-- Directory listing -->
<div class="flex-1 overflow-auto bg-gray-700 rounded mb-4 p-2" style="min-height: 300px;">
<!-- Parent directory -->
<div
x-show="browsePath"
@click="navigateBrowseParent()"
class="flex items-center gap-2 p-2 hover:bg-gray-600 rounded cursor-pointer text-gray-300"
>
<span class="text-lg">📁</span>
<span>..</span>
</div>
<!-- Directories -->
<template x-for="dir in browseDirectories" :key="'dir-' + dir.path">
<div
@click="navigateBrowseDirectory(dir.path)"
class="flex items-center gap-2 p-2 hover:bg-gray-600 rounded cursor-pointer"
>
<span class="text-lg">📁</span>
<span x-text="dir.name"></span>
</div>
</template>
<!-- Files -->
<template x-for="file in browseFiles" :key="'file-' + file.path">
<div
@click="selectBrowseFile(file)"
@dblclick="openBrowseFile(file)"
class="flex items-center gap-2 p-2 hover:bg-gray-600 rounded cursor-pointer"
:class="{ 'bg-blue-600': browseSelectedPath === file.path }"
>
<span class="text-lg" x-text="file.type === 'ora' ? '📄' : '🖼️'"></span>
<span x-text="file.name"></span>
<span class="text-xs text-gray-400 ml-auto uppercase" x-text="file.type"></span>
</div>
</template>
<!-- Empty state -->
<div x-show="browseDirectories.length === 0 && browseFiles.length === 0" class="text-gray-400 text-center py-8">
No PNG or ORA files in this directory
</div>
</div>
<!-- Actions -->
<div class="flex gap-4 justify-end">
<button @click="closeBrowseModal()" class="bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded transition">Cancel</button>
<button
@click="openSelectedFile()"
:disabled="!browseSelectedPath"
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
>
Open Selected
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
<!-- Krita modal for ORA Editor -->
<div x-show="showKritaModal" @keydown.escape.window="closeKritaModal()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 rounded-lg p-6 max-w-lg w-full mx-4 border border-gray-600">
<h2 class="text-xl font-bold mb-4">Open in Krita</h2>
<p class="text-gray-300 mb-4">File exported to:</p>
<div class="bg-gray-700 rounded p-3 mb-4 font-mono text-sm text-green-400 break-all" x-text="kritaTempPath"></div>
<p class="text-gray-400 text-sm mb-4">Copy the path and open it in Krita manually. Browsers cannot open local files directly.</p>
<div class="flex gap-4 justify-end">
<button
@click="copyKritaPath()"
:class="kritaPathCopied ? 'bg-green-600' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 rounded transition"
x-text="kritaPathCopied ? 'Copied!' : 'Copy Path'"
></button>
<button @click="closeKritaModal()" class="bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded transition">Close</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,49 @@
<!-- Mask preview modal for ORA Editor -->
<div x-show="showMaskModal" @keydown.escape.window="closeMaskModal()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 border border-gray-600" style="max-height: 90vh; display: flex; flex-direction: column;">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Extracted Mask</h2>
<!-- View mode toggle -->
<div class="flex gap-2">
<button
@click="maskViewMode = 'with-bg'"
:class="maskViewMode === 'with-bg' ? 'bg-blue-600' : 'bg-gray-600'"
class="px-3 py-1 rounded text-sm font-medium"
>With Background</button>
<button
@click="maskViewMode = 'masked-only'"
:class="maskViewMode === 'masked-only' ? 'bg-green-600' : 'bg-gray-600'"
class="px-3 py-1 rounded text-sm font-medium"
>Masked Only</button>
</div>
</div>
<div class="flex-1 overflow-auto bg-gray-700 rounded mb-4 flex items-center justify-center p-4" style="min-height: 300px; max-height: 50vh;">
<div class="relative" style="max-width: 100%; max-height: calc(50vh - 2rem);">
<!-- Base image (shown in "with-bg" mode) -->
<img
x-show="maskViewMode === 'with-bg'"
:src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)"
class="border border-gray-600"
style="max-width: 100%; max-height: calc(50vh - 2rem);"
>
<!-- Masked image (transparent where mask is black) -->
<img
x-show="tempMaskPath"
:src="'/api/image/masked?ora_path=' + encodeURIComponent(oraPath) + '&mask_path=' + encodeURIComponent(tempMaskPath)"
class="border border-gray-600"
:class="maskViewMode === 'with-bg' ? 'absolute inset-0' : ''"
style="max-width: 100%; max-height: calc(50vh - 2rem);"
>
</div>
</div>
<div class="flex gap-4 justify-end">
<button @click="rerollMask()" :disabled="isExtracting" class="bg-gray-600 hover:bg-gray-500 disabled:bg-gray-700 px-4 py-2 rounded transition">Re-roll</button>
<button @click="useMask()" :disabled="isExtracting || !tempMaskPath" class="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded transition">Use This Mask</button>
<button @click="closeMaskModal()" :disabled="isExtracting" class="bg-red-600 hover:bg-red-700 disabled:bg-gray-600 px-4 py-2 rounded transition">Cancel</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<!-- Settings modal for ORA Editor -->
<div x-show="showSettings" @keydown.escape.window="showSettings = false"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div class="bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 border border-gray-600">
<h2 class="text-xl font-bold mb-4">Settings</h2>
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-2">ComfyUI Server URL</label>
<input
type="text"
x-model="comfyUrl"
placeholder="127.0.0.1:8188"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500"
>
</div>
<div class="flex gap-4 justify-end mt-4">
<button @click="showSettings = false" class="bg-gray-600 hover:bg-gray-500 px-4 py-2 rounded transition">Cancel</button>
<button @click="saveSettings()" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded transition">Save</button>
</div>
</div>
</div>