Restructure ORA editor into modular blueprints with browse dialog

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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