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:
@@ -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
|
- **PNG**: Auto-converted to ORA on open
|
||||||
|
|
||||||
### Open Flow
|
### Open Flow
|
||||||
1. User enters path relative to project root (e.g., `scenes/kq4_010/pic.png`)
|
1. User clicks "Browse" button to open file browser dialog
|
||||||
2. Backend checks if file exists
|
2. Backend lists directories and files (PNG, ORA) from project root
|
||||||
3. If PNG: auto-create ORA with single `base` layer
|
3. User navigates directories, selects file
|
||||||
4. Parse ORA structure, extract layers
|
4. Backend checks if file exists
|
||||||
5. Return layer list + image data URLs
|
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
|
## 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
|
### File Operations
|
||||||
|
|
||||||
#### `POST /api/open`
|
#### `POST /api/open`
|
||||||
@@ -525,7 +619,7 @@ Check if temp file was modified.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ [Open: ________________________] [🌀 Open] [Settings ⚙] │
|
│ [Open: ________________________] [📁 Browse] [🌀 Open] [Settings ⚙] │
|
||||||
├───────────────────┬──────────────────────────────────────────────────┤
|
├───────────────────┬──────────────────────────────────────────────────┤
|
||||||
│ LAYERS │ │
|
│ LAYERS │ │
|
||||||
│ ☑ □ base │ │
|
│ ☑ □ 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
|
## 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_layer_image(ora_path, layer_name)` - Extract PNG
|
||||||
- [ ] `get_base_image(ora_path)` - Get merged/composited base
|
- [ ] `get_base_image(ora_path)` - Get merged/composited base
|
||||||
- [ ] Create `app.py` with Flask routes:
|
- [ ] Create `app.py` with Flask routes:
|
||||||
|
- [ ] File browser (`/api/browse`)
|
||||||
- [ ] File operations (`/api/open`, `/api/save`)
|
- [ ] File operations (`/api/open`, `/api/save`)
|
||||||
- [ ] Layer operations (all `/api/layer/*`)
|
- [ ] Layer operations (all `/api/layer/*`)
|
||||||
- [ ] Image serving (`/api/image/*`)
|
- [ ] Image serving (`/api/image/*`)
|
||||||
@@ -632,6 +752,7 @@ Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked)
|
|||||||
### Phase 3: Frontend UI
|
### Phase 3: Frontend UI
|
||||||
- [ ] Create `editor.html` with Tailwind + Alpine.js
|
- [ ] Create `editor.html` with Tailwind + Alpine.js
|
||||||
- [ ] File open input and handler
|
- [ ] File open input and handler
|
||||||
|
- [ ] Browse modal for file selection
|
||||||
- [ ] Layer list with visibility toggles
|
- [ ] Layer list with visibility toggles
|
||||||
- [ ] Canvas area with stacked layer images
|
- [ ] Canvas area with stacked layer images
|
||||||
- [ ] Layer operations (rename, delete, reorder buttons)
|
- [ ] Layer operations (rename, delete, reorder buttons)
|
||||||
|
|||||||
@@ -1,858 +1,39 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Flask web application for ORA editing."""
|
"""Flask web application for ORA editing."""
|
||||||
|
|
||||||
import base64
|
import logging
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
from flask import (
|
from flask import Flask, render_template
|
||||||
Flask, request, jsonify, send_file, Response, render_template,
|
|
||||||
make_response
|
# 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)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
logger = logging.getLogger(__name__)
|
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')
|
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('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Serve the main editor UI."""
|
"""Serve the main editor UI."""
|
||||||
return render_template('editor.html')
|
return render_template('editor.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/open', methods=['POST'])
|
# Register blueprints
|
||||||
def api_open():
|
app.register_blueprint(files_bp)
|
||||||
"""Open a file (PNG or ORA)."""
|
app.register_blueprint(layers_bp)
|
||||||
data = request.get_json()
|
app.register_blueprint(images_bp)
|
||||||
|
app.register_blueprint(polygon_bp)
|
||||||
if not data or 'path' not in data:
|
app.register_blueprint(mask_bp)
|
||||||
return jsonify({'success': False, 'error': 'Missing path parameter'}), 400
|
app.register_blueprint(krita_bp)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
12
tools/ora_editor/config.py
Normal file
12
tools/ora_editor/config.py
Normal 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)
|
||||||
17
tools/ora_editor/routes/__init__.py
Normal file
17
tools/ora_editor/routes/__init__.py
Normal 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'
|
||||||
|
]
|
||||||
100
tools/ora_editor/routes/files.py
Normal file
100
tools/ora_editor/routes/files.py
Normal 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
|
||||||
165
tools/ora_editor/routes/images.py
Normal file
165
tools/ora_editor/routes/images.py
Normal 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')
|
||||||
108
tools/ora_editor/routes/krita.py
Normal file
108
tools/ora_editor/routes/krita.py
Normal 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}'
|
||||||
|
})
|
||||||
111
tools/ora_editor/routes/layers.py
Normal file
111
tools/ora_editor/routes/layers.py
Normal 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)
|
||||||
161
tools/ora_editor/routes/mask.py
Normal file
161
tools/ora_editor/routes/mask.py
Normal 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
|
||||||
46
tools/ora_editor/routes/polygon.py
Normal file
46
tools/ora_editor/routes/polygon.py
Normal 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})
|
||||||
36
tools/ora_editor/services/__init__.py
Normal file
36
tools/ora_editor/services/__init__.py
Normal 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()
|
||||||
187
tools/ora_editor/services/comfyui.py
Normal file
187
tools/ora_editor/services/comfyui.py
Normal 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
|
||||||
75
tools/ora_editor/services/file_browser.py
Normal file
75
tools/ora_editor/services/file_browser.py
Normal 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)
|
||||||
|
}
|
||||||
21
tools/ora_editor/templates/base.html
Normal file
21
tools/ora_editor/templates/base.html
Normal 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>
|
||||||
72
tools/ora_editor/templates/components/canvas.html
Normal file
72
tools/ora_editor/templates/components/canvas.html
Normal 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>
|
||||||
135
tools/ora_editor/templates/components/sidebar.html
Normal file
135
tools/ora_editor/templates/components/sidebar.html
Normal 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>
|
||||||
File diff suppressed because it is too large
Load Diff
68
tools/ora_editor/templates/modals/browse.html
Normal file
68
tools/ora_editor/templates/modals/browse.html
Normal 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>
|
||||||
24
tools/ora_editor/templates/modals/krita.html
Normal file
24
tools/ora_editor/templates/modals/krita.html
Normal 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>
|
||||||
49
tools/ora_editor/templates/modals/mask_preview.html
Normal file
49
tools/ora_editor/templates/modals/mask_preview.html
Normal 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>
|
||||||
23
tools/ora_editor/templates/modals/settings.html
Normal file
23
tools/ora_editor/templates/modals/settings.html
Normal 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>
|
||||||
Reference in New Issue
Block a user