diff --git a/tools/ora_editor/ORA_EDITOR.md b/tools/ora_editor/ORA_EDITOR.md index a228bce..1588fd6 100644 --- a/tools/ora_editor/ORA_EDITOR.md +++ b/tools/ora_editor/ORA_EDITOR.md @@ -65,11 +65,82 @@ All file paths are relative to the project root: `/home/noti/dev/ai-game-2` - **PNG**: Auto-converted to ORA on open ### Open Flow -1. User enters path relative to project root (e.g., `scenes/kq4_010/pic.png`) -2. Backend checks if file exists -3. If PNG: auto-create ORA with single `base` layer -4. Parse ORA structure, extract layers -5. Return layer list + image data URLs +1. User clicks "Browse" button to open file browser dialog +2. Backend lists directories and files (PNG, ORA) from project root +3. User navigates directories, selects file +4. Backend checks if file exists +5. If PNG: auto-create ORA with single `base` layer +6. Parse ORA structure, extract layers +7. Return layer list + image data URLs + +--- + +## Browse Dialog + +### Purpose +Allows users to visually navigate the project directory and select PNG/ORA files without manually typing paths. + +### UI Layout +``` +┌─────────────────────────────────────────────────────────┐ +│ OPEN FILE │ +│ ───────────────────────────────────────────────────── │ +│ 📁 .. │ +│ 📁 scenes/ │ +│ 📁 tools/ │ +│ 🖼️ pic_010.ora │ +│ 🖼️ pic_011.png │ +│ │ +│ Current: scenes/kq4_010/ │ +│ │ +│ [Cancel] [Open Selected] │ +└─────────────────────────────────────────────────────────┘ +``` + +### Behavior +- **Double-click folder**: Navigate into folder +- **Double-click file**: Select and open immediately +- **Single-click + Enter**: Select and open +- ** ".." entry**: Navigate to parent directory +- **File types**: Only show `.png` and `.ora` files +- **Hidden files**: Excluded (dot-prefixed) + +### Backend Implementation + +#### `GET /api/browse` +List directory contents for browsing. + +**Query params:** +- `path`: Directory path relative to project root (default: "") + +**Response:** +```json +{ + "current_path": "scenes/kq4_010", + "parent_path": "scenes", + "directories": [ + {"name": "subdir1", "path": "scenes/kq4_010/subdir1"} + ], + "files": [ + {"name": "pic.png", "path": "scenes/kq4_010/pic.png", "type": "png"}, + {"name": "scene.ora", "path": "scenes/kq4_010/scene.ora", "type": "ora"} + ] +} +``` + +### Frontend State +```javascript +// Added to main store +browseModal: false, +browsePath: '', // Current directory being browsed +browseDirectories: [], // List of {name, path} +browseFiles: [], // List of {name, path, type} +browseSelected: null, // Currently selected file path +``` + +### Entry Point +- Button next to path input: `[Browse...]` +- Or clicking into the path input field (if empty) --- @@ -247,6 +318,29 @@ Since browser cannot detect when Krita saves: ## API Reference +### File Browser + +#### `GET /api/browse` +List directory contents for file browsing. + +**Query params:** +- `path`: Directory path relative to project root (default: "") + +**Response:** +```json +{ + "current_path": "scenes/kq4_010", + "parent_path": "scenes", + "directories": [ + {"name": "subdir1", "path": "scenes/kq4_010/subdir1"} + ], + "files": [ + {"name": "pic.png", "path": "scenes/kq4_010/pic.png", "type": "png"}, + {"name": "scene.ora", "path": "scenes/kq4_010/scene.ora", "type": "ora"} + ] +} +``` + ### File Operations #### `POST /api/open` @@ -525,7 +619,7 @@ Check if temp file was modified. ``` ┌──────────────────────────────────────────────────────────────────────┐ -│ [Open: ________________________] [🌀 Open] [Settings ⚙] │ +│ [Open: ________________________] [📁 Browse] [🌀 Open] [Settings ⚙] │ ├───────────────────┬──────────────────────────────────────────────────┤ │ LAYERS │ │ │ ☑ □ base │ │ @@ -584,6 +678,31 @@ Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked) --- +## Browse Modal + +``` +┌─────────────────────────────────────────────────────────┐ +│ OPEN FILE │ +│ ───────────────────────────────────────────────────── │ +│ 📁 .. (parent directory) │ +│ 📁 scenes/ │ +│ 📁 tools/ │ +│ 🖼️ pic_010.ora │ +│ 🖼️ pic_011.png │ +│ │ +│ Path: scenes/kq4_010/ │ +│ │ +│ [Cancel] [Open Selected] │ +└─────────────────────────────────────────────────────────┘ +``` + +- Double-click folder to navigate +- Double-click file to open immediately +- Single-click to select, then click "Open Selected" +- Only shows `.png` and `.ora` files + +--- + ## Settings Modal ``` @@ -622,6 +741,7 @@ Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked) - [ ] `get_layer_image(ora_path, layer_name)` - Extract PNG - [ ] `get_base_image(ora_path)` - Get merged/composited base - [ ] Create `app.py` with Flask routes: + - [ ] File browser (`/api/browse`) - [ ] File operations (`/api/open`, `/api/save`) - [ ] Layer operations (all `/api/layer/*`) - [ ] Image serving (`/api/image/*`) @@ -632,6 +752,7 @@ Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked) ### Phase 3: Frontend UI - [ ] Create `editor.html` with Tailwind + Alpine.js - [ ] File open input and handler +- [ ] Browse modal for file selection - [ ] Layer list with visibility toggles - [ ] Canvas area with stacked layer images - [ ] Layer operations (rename, delete, reorder buttons) diff --git a/tools/ora_editor/app.py b/tools/ora_editor/app.py index df3682c..fc16538 100644 --- a/tools/ora_editor/app.py +++ b/tools/ora_editor/app.py @@ -1,858 +1,39 @@ #!/usr/bin/env python3 """Flask web application for ORA editing.""" -import base64 -import io -import json -import os -import shutil +import logging import sys -import threading -import time -import zipfile -from datetime import datetime from pathlib import Path -from xml.etree import ElementTree as ET -from flask import ( - Flask, request, jsonify, send_file, Response, render_template, - make_response +from flask import Flask, render_template + +# Ensure the package can be imported +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from ora_editor.config import TEMP_DIR +from ora_editor.routes import ( + files_bp, layers_bp, images_bp, polygon_bp, mask_bp, krita_bp ) -from PIL import Image, ImageDraw -import logging -import urllib.parse -import urllib.request - -# Configure logging first (before imports that might need logger) logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) -# Configuration from environment variables -APP_DIR = Path(__file__).parent -PROJECT_ROOT = Path("/home/noti/dev/ai-game-2") -TEMP_DIR = APP_DIR / "temp" - -# ComfyUI configuration via environment variables -COMFYUI_BASE_URL = os.environ.get('COMFYUI_BASE_URL', '127.0.0.1:8188') - -logger.info(f"[CONFIG] COMFYUI_BASE_URL={COMFYUI_BASE_URL}") - -# Ensure temp directory exists -TEMP_DIR.mkdir(exist_ok=True) - -# Import ora operations from sibling directory -sys.path.insert(0, str(APP_DIR.parent)) -from ora_editor.ora_ops import ( - load_ora, create_ora_from_png, add_masked_layer, rename_layer, - delete_layer, reorder_layer, get_layer_visibility, set_layer_visibility, - save_ora, parse_stack_xml -) - -# In-memory storage for polygon points (per-request basis) -_polygon_storage = {} - -# Webhook response holder (single request at a time per user spec) -_webhook_response = None -_webhook_ready = threading.Event() - app = Flask(__name__, template_folder='templates') -def ensure_ora_exists(input_path: str) -> str | None: - """Ensure an ORA file exists, creating from PNG if necessary.""" - full_path = PROJECT_ROOT / input_path - - if not full_path.exists(): - return None - - # If it's a PNG, create ORA - if full_path.suffix.lower() == '.png': - ora_path = str(full_path.with_suffix('.ora')) - result = create_ora_from_png(str(full_path), ora_path) - if not result.get('success'): - return None - return ora_path - - # It's an ORA, verify it's valid - try: - parse_stack_xml(str(full_path)) - return str(full_path) - except Exception: - return None - - @app.route('/') def index(): """Serve the main editor UI.""" return render_template('editor.html') -@app.route('/api/open', methods=['POST']) -def api_open(): - """Open a file (PNG or ORA).""" - data = request.get_json() - - if not data or 'path' not in data: - return jsonify({'success': False, 'error': 'Missing path parameter'}), 400 - - input_path = data['path'] - ora_path = ensure_ora_exists(input_path) - - if not ora_path: - return jsonify({'success': False, 'error': f'File not found or invalid: {input_path}'}), 404 - - try: - loaded = load_ora(ora_path) - - # Build layers list with visibility - layers = [] - for layer in loaded['layers']: - layers.append({ - 'name': layer['name'], - 'src': layer['src'], - 'group': layer.get('group'), - 'visible': layer.get('visible', True) - }) - - return jsonify({ - 'success': True, - 'ora_path': ora_path, - 'width': loaded['width'], - 'height': loaded['height'], - 'layers': layers - }) - except Exception as e: - return jsonify({'success': False, 'error': f'Error loading ORA: {str(e)}'}), 500 - - -@app.route('/api/save', methods=['POST']) -def api_save(): - """Save the current ORA state.""" - data = request.get_json() - - if not data or 'ora_path' not in data: - return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400 - - ora_path = data['ora_path'] - - # Save by re-reading and re-writing (preserves current state) - result = save_ora(ora_path) - - if result: - return jsonify({'success': True, 'ora_path': ora_path}) - else: - return jsonify({'success': False, 'error': 'Failed to save ORA'}), 500 - - -@app.route('/api/layers', methods=['GET']) -def api_layers(): - """Get the current layer structure.""" - ora_path = request.args.get('ora_path') - - if not ora_path: - return jsonify({'success': False, 'error': 'Missing ora_path parameter'}), 400 - - try: - loaded = load_ora(ora_path) - - layers = [] - for layer in loaded['layers']: - layers.append({ - 'name': layer['name'], - 'src': layer['src'], - 'group': layer.get('group'), - 'visible': layer.get('visible', True) - }) - - return jsonify({'success': True, 'layers': layers}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/layer/add', methods=['POST']) -def api_layer_add(): - """Add a new masked layer.""" - data = request.get_json() - - required = ['ora_path', 'entity_name', 'mask_path'] - for field in required: - if field not in data: - return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400 - - ora_path = data['ora_path'] - entity_name = data['entity_name'] - mask_path = data['mask_path'] - source_layer = data.get('source_layer', 'base') - - result = add_masked_layer(ora_path, entity_name, mask_path, source_layer) - - return jsonify(result) - - -@app.route('/api/layer/rename', methods=['POST']) -def api_layer_rename(): - """Rename a layer.""" - data = request.get_json() - - required = ['ora_path', 'old_name', 'new_name'] - for field in required: - if field not in data: - return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400 - - result = rename_layer(data['ora_path'], data['old_name'], data['new_name']) - return jsonify(result) - - -@app.route('/api/layer/delete', methods=['POST']) -def api_layer_delete(): - """Delete a layer.""" - data = request.get_json() - - required = ['ora_path', 'layer_name'] - for field in required: - if field not in data: - return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400 - - result = delete_layer(data['ora_path'], data['layer_name']) - return jsonify(result) - - -@app.route('/api/layer/reorder', methods=['POST']) -def api_layer_reorder(): - """Reorder a layer.""" - data = request.get_json() - - required = ['ora_path', 'layer_name', 'direction'] - for field in required: - if field not in data: - return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400 - - result = reorder_layer(data['ora_path'], data['layer_name'], data['direction']) - return jsonify(result) - - -@app.route('/api/layer/visibility', methods=['POST']) -def api_layer_visibility(): - """Set layer visibility.""" - data = request.get_json() - - required = ['ora_path', 'layer_name', 'visible'] - for field in required: - if field not in data: - return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400 - - result = set_layer_visibility(data['ora_path'], data['layer_name'], data['visible']) - return jsonify(result) - - -@app.route('/api/image/') -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/') -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/') -def api_image_layer(layer_name): - """Serve a specific layer as image.""" - ora_path = request.args.get('ora_path') - - if not ora_path: - return jsonify({'error': 'Missing ora_path'}), 400 - - try: - # Find the layer source path - root = parse_stack_xml(ora_path) - - with zipfile.ZipFile(ora_path, 'r') as zf: - stack = ET.fromstring(zf.read('stack.xml')) - - for child in stack.find('stack'): - if child.tag == 'layer' and child.get('name') == layer_name: - src = child.get('src') - img_data = zf.read(src) - return send_file(io.BytesIO(img_data), mimetype='image/png') - - elif child.tag == 'stack': # Group - for layer in child: - if layer.tag == 'layer' and layer.get('name') == layer_name: - src = layer.get('src') - img_data = zf.read(src) - return send_file(io.BytesIO(img_data), mimetype='image/png') - - return jsonify({'error': 'Layer not found'}), 404 - except Exception as e: - import traceback - traceback.print_exc() - return Response(f"Error loading layer: {e}", status=500) +# Register blueprints +app.register_blueprint(files_bp) +app.register_blueprint(layers_bp) +app.register_blueprint(images_bp) +app.register_blueprint(polygon_bp) +app.register_blueprint(mask_bp) +app.register_blueprint(krita_bp) if __name__ == '__main__': diff --git a/tools/ora_editor/config.py b/tools/ora_editor/config.py new file mode 100644 index 0000000..127e5b1 --- /dev/null +++ b/tools/ora_editor/config.py @@ -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) diff --git a/tools/ora_editor/routes/__init__.py b/tools/ora_editor/routes/__init__.py new file mode 100644 index 0000000..9c52099 --- /dev/null +++ b/tools/ora_editor/routes/__init__.py @@ -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' +] diff --git a/tools/ora_editor/routes/files.py b/tools/ora_editor/routes/files.py new file mode 100644 index 0000000..731a854 --- /dev/null +++ b/tools/ora_editor/routes/files.py @@ -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 diff --git a/tools/ora_editor/routes/images.py b/tools/ora_editor/routes/images.py new file mode 100644 index 0000000..abe290f --- /dev/null +++ b/tools/ora_editor/routes/images.py @@ -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/') +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/') +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') diff --git a/tools/ora_editor/routes/krita.py b/tools/ora_editor/routes/krita.py new file mode 100644 index 0000000..c267eb7 --- /dev/null +++ b/tools/ora_editor/routes/krita.py @@ -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/') +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}' + }) diff --git a/tools/ora_editor/routes/layers.py b/tools/ora_editor/routes/layers.py new file mode 100644 index 0000000..7585030 --- /dev/null +++ b/tools/ora_editor/routes/layers.py @@ -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) diff --git a/tools/ora_editor/routes/mask.py b/tools/ora_editor/routes/mask.py new file mode 100644 index 0000000..8eac804 --- /dev/null +++ b/tools/ora_editor/routes/mask.py @@ -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 diff --git a/tools/ora_editor/routes/polygon.py b/tools/ora_editor/routes/polygon.py new file mode 100644 index 0000000..98b8514 --- /dev/null +++ b/tools/ora_editor/routes/polygon.py @@ -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}) diff --git a/tools/ora_editor/services/__init__.py b/tools/ora_editor/services/__init__.py new file mode 100644 index 0000000..73f0006 --- /dev/null +++ b/tools/ora_editor/services/__init__.py @@ -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() diff --git a/tools/ora_editor/services/comfyui.py b/tools/ora_editor/services/comfyui.py new file mode 100644 index 0000000..bfc11dd --- /dev/null +++ b/tools/ora_editor/services/comfyui.py @@ -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 diff --git a/tools/ora_editor/services/file_browser.py b/tools/ora_editor/services/file_browser.py new file mode 100644 index 0000000..0d9fd5e --- /dev/null +++ b/tools/ora_editor/services/file_browser.py @@ -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) + } diff --git a/tools/ora_editor/templates/base.html b/tools/ora_editor/templates/base.html new file mode 100644 index 0000000..23567e0 --- /dev/null +++ b/tools/ora_editor/templates/base.html @@ -0,0 +1,21 @@ + + + + + + {% block title %}ORA Editor{% endblock %} + + + + {% block head %}{% endblock %} + + + {% block body %}{% endblock %} + + {% block scripts %}{% endblock %} + + diff --git a/tools/ora_editor/templates/components/canvas.html b/tools/ora_editor/templates/components/canvas.html new file mode 100644 index 0000000..6d32692 --- /dev/null +++ b/tools/ora_editor/templates/components/canvas.html @@ -0,0 +1,72 @@ + + +
+ +
+ + +
+ + + + + + + Polygon overlay + + + + +
+ +
+ + +
+
+
+ + +
+ + +
+ Click to add points. Drag points to adjust. Double-click or Enter to finish, Escape to cancel. +
+
+ Drag points to adjust polygon, then extract mask or open in Krita. +
+
+ Draw a polygon (optional) then extract mask, or use Open in Krita to annotation manually. +
+
\ No newline at end of file diff --git a/tools/ora_editor/templates/components/sidebar.html b/tools/ora_editor/templates/components/sidebar.html new file mode 100644 index 0000000..6e16109 --- /dev/null +++ b/tools/ora_editor/templates/components/sidebar.html @@ -0,0 +1,135 @@ + + + + + + + diff --git a/tools/ora_editor/templates/editor.html b/tools/ora_editor/templates/editor.html index 682ba48..9a15677 100644 --- a/tools/ora_editor/templates/editor.html +++ b/tools/ora_editor/templates/editor.html @@ -1,1019 +1,782 @@ - - - - - - ORA Editor - - - - - -
+{% extends "base.html" %} - -
-
- - -
- - - - - - -
- - -
- Scale: - - -
- - -
- - - + + + + + + + +
- -
-

Open a PNG or ORA file to start editing

+ +
+ Scale: + +
- -
-
-
-

Extracted Mask

- -
- - -
-
- -
-
- - - - -
-
- -
- - - -
-
-
+ + - -
-
-

Settings

- -
- - -
- -
- - -
-
-
+ + + +
+

Open a PNG or ORA file to start editing

- - + }, - - + + + - - + + observer.observe(document.body, { childList: true, subtree: true }); +}); + +{% endblock %} \ No newline at end of file diff --git a/tools/ora_editor/templates/modals/browse.html b/tools/ora_editor/templates/modals/browse.html new file mode 100644 index 0000000..a224476 --- /dev/null +++ b/tools/ora_editor/templates/modals/browse.html @@ -0,0 +1,68 @@ + + +
+
+
+

Open File

+
+ Current: +
+
+ + +
+ +
+ 📁 + .. +
+ + + + + + + + +
+ No PNG or ORA files in this directory +
+
+ + +
+ + +
+
+
diff --git a/tools/ora_editor/templates/modals/krita.html b/tools/ora_editor/templates/modals/krita.html new file mode 100644 index 0000000..96e2285 --- /dev/null +++ b/tools/ora_editor/templates/modals/krita.html @@ -0,0 +1,24 @@ + + +
+
+

Open in Krita

+ +

File exported to:

+ +
+ +

Copy the path and open it in Krita manually. Browsers cannot open local files directly.

+ +
+ + +
+
+
\ No newline at end of file diff --git a/tools/ora_editor/templates/modals/mask_preview.html b/tools/ora_editor/templates/modals/mask_preview.html new file mode 100644 index 0000000..dc6a76f --- /dev/null +++ b/tools/ora_editor/templates/modals/mask_preview.html @@ -0,0 +1,49 @@ + + +
+
+
+

Extracted Mask

+ +
+ + +
+
+ +
+
+ + + + +
+
+ +
+ + + +
+
+
\ No newline at end of file diff --git a/tools/ora_editor/templates/modals/settings.html b/tools/ora_editor/templates/modals/settings.html new file mode 100644 index 0000000..68c3483 --- /dev/null +++ b/tools/ora_editor/templates/modals/settings.html @@ -0,0 +1,23 @@ + + +
+
+

Settings

+ +
+ + +
+ +
+ + +
+
+
\ No newline at end of file