diff --git a/.gitignore b/.gitignore index 783abef..d7dba36 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ tools/venv/** .import addons build/ +tmp/** .tmp/** diff --git a/.opencode/skills/alpha-mask-creator/SKILL.md b/.opencode/skills/alpha-mask-creator/SKILL.md index e8db0e5..8431af8 100644 --- a/.opencode/skills/alpha-mask-creator/SKILL.md +++ b/.opencode/skills/alpha-mask-creator/SKILL.md @@ -8,6 +8,9 @@ This skill creates alpha masks from images based on user requests. Use this when ## Workflow +### 0. Extract layer (conditional) +If the user provided a layer name from an ora file, you should use the ora-editing skill to extract a png of that layer. First use the inspect to list the layers, then use the extract. + ### 1. Draw Polygon Outline Use the `polygon-drawer` skill to draw a polygon outline around the identified object. Save the output to `./tmp/` with a sensible filename: - Format: `..png` @@ -36,6 +39,9 @@ Tell the user: - Location of the mask: `./tmp/..mask.png` - Confirm the mask quality is acceptable +### 6. Integrate into existing ora file +If the user asked to create an alpha mask for a layer from an ora file, offer to add the masked element to the ora file, using the ora-editing skill. + ## Naming Conventions | User Request | Original File | Output Files | diff --git a/.opencode/skills/ora-editing/SKILL.md b/.opencode/skills/ora-editing/SKILL.md new file mode 100644 index 0000000..0323906 --- /dev/null +++ b/.opencode/skills/ora-editing/SKILL.md @@ -0,0 +1,255 @@ +--- +name: ora-editing +description: Creates and edits OpenRaster (ORA) files for game asset management using ora_edit.py. Use when creating layered game assets, masking entities, extracting layers as PNGs, composing scenes with multiple interactive elements, or managing game graphics with alpha masks. +--- + +# ORA Editing for Game Assets + +Manage layered game assets using OpenRaster format. Create ORA files from PNGs, mask entities for interactive objects, extract layers as PNGs, and inspect asset structure. + +## Quick Start + +Create a layered asset from a scene: + +```bash +# Create ORA from scene +python3 tools/ora_edit.py create scene.png scene.ora --layer-name "room" + +# Add masked interactive elements +python3 tools/ora_edit.py mask_element scene.ora --mask door_mask.png --entity "door" +python3 tools/ora_edit.py mask_element scene.ora --mask chest_mask.png --entity "chest" + +# Check structure +python3 tools/ora_edit.py inspect scene.ora +``` + +## Commands + +### create + +Create an ORA file from a PNG image. + +```bash +python3 tools/ora_edit.py create [output.ora] [--layer-name NAME] +``` + +**Examples:** + +```bash +# Create with default "base" layer +python3 tools/ora_edit.py create background.png room.ora + +# Create with custom layer name +python3 tools/ora_edit.py create forest.png forest.ora --layer-name "trees" +``` + +### mask_element + +Create a masked layer for an entity using a black-and-white mask. + +```bash +python3 tools/ora_edit.py mask_element --mask --entity [--layer LAYER] [--output OUTPUT] +``` + +**Examples:** + +```bash +# Mask using default "base" layer +python3 tools/ora_edit.py mask_element room.ora --mask door_alpha.png --entity "door" + +# Specify source layer +python3 tools/ora_edit.py mask_element room.ora --mask window_alpha.png --entity "window" --layer "room" + +# Chain PNG → ORA conversion with masking +python3 tools/ora_edit.py mask_element scene.png --mask tree_mask.png --entity "tree" --output composed.ora +``` + +**Layer naming:** Entities get auto-incremented names: `door_0`, `door_1`, `door_2`... + +### inspect + +Display the structure of an ORA file. + +```bash +python3 tools/ora_edit.py inspect +``` + +### extract_png + +Extract a layer from an ORA file as a PNG image. + +```bash +python3 tools/ora_edit.py extract_png --layer [--output ] +``` + +**Examples:** + +```bash +# Extract layer with default output name +python3 tools/ora_edit.py extract_png scene.ora --layer door_0 + +# Extract with custom output name +python3 tools/ora_edit.py extract_png scene.ora --layer background --output bg.png + +# Extract multiple layers for use in Godot +python3 tools/ora_edit.py extract_png character.ora --layer arm_0 --output arm.png +python3 tools/ora_edit.py extract_png character.ora --layer head_0 --output head.png +``` + +## Workflows + +### Creating Interactive Scene Assets + +Build a scene with multiple interactive elements: + +```bash +# 1. Create base scene +python3 tools/ora_edit.py create room_base.png room.ora --layer-name "background" + +# 2. Add interactive elements +python3 tools/ora_edit.py mask_element room.ora --mask door_closed.png --entity "door" --layer "background" +python3 tools/ora_edit.py mask_element room.ora --mask chest_closed.png --entity "chest" --layer "background" +python3 tools/ora_edit.py mask_element room.ora --mask window_day.png --entity "window" --layer "background" + +# 3. Verify structure +python3 tools/ora_edit.py inspect room.ora +``` + +**Result:** +``` +šŸ“ Group: door + └─ door_0 +šŸ“ Group: chest + └─ chest_0 +šŸ“ Group: window + └─ window_0 +šŸ–¼ļø Base: background +``` + + +### AI-Assisted Masking Workflow + +Combine with AI mask extraction: +1. Use the alpha-mask-creator skill to create an alpha mask + +```bash + +# 2. Apply to ORA +python3 tools/ora_edit.py mask_element scene.ora --mask --entity "door" + +# 3. Verify +python3 tools/ora_edit.py inspect scene.ora +``` + +## Asset Structure Guidelines + +**Layer Organization:** +- Entity groups appear first (toggleable in GIMP/Krita) +- Base layers at the bottom +- Each entity group contains numbered variants + +**Naming Conventions:** +- Use descriptive entity names: `door`, `chest`, `magic_potion` +- Layer variants auto-number: `door_0`, `door_1` +- Base layer should describe the content: `background`, `room`, `forest` + +**Mask Requirements:** +- Grayscale or RGB/RGBA accepted +- White = opaque, Black = transparent +- Partial transparency supported + +## Common Patterns + +### Pattern: Scene Composition + +Multiple interactive objects in one scene: + +```bash +python3 tools/ora_edit.py create bg.png scene.ora --layer-name "room" +python3 tools/ora_edit.py mask_element scene.ora --mask obj1.png --entity "door" +python3 tools/ora_edit.py mask_element scene.ora --mask obj2.png --entity "key" +python3 tools/ora_edit.py mask_element scene.ora --mask obj3.png --entity "chest" +``` + +### Pattern: State Variants + +Multiple states for the same object: + +```bash +# Closed door +python3 tools/ora_edit.py mask_element scene.ora --mask door_closed.png --entity "door" + +# Open door (creates door_1) +python3 tools/ora_edit.py mask_element scene.ora --mask door_open.png --entity "door" +``` + +### Pattern: Inspection Before Export + +Always verify before using in game: + +```bash +python3 tools/ora_edit.py inspect scene.ora +# Confirm structure matches Godot scene requirements +``` + +### Pattern: Extracting Layers for Game Use + +Export individual layers for use as Godot sprites: + +```bash +# Extract all interactive elements +python3 tools/ora_edit.py extract_png scene.ora --layer door_0 --output door_sprite.png +python3 tools/ora_edit.py extract_png scene.ora --layer chest_0 --output chest_sprite.png + +# Extract base layer for background +python3 tools/ora_edit.py extract_png scene.ora --layer background --output room_bg.png +``` + +## Integration with Godot + +After creating ORA assets: + +1. Export layers using `extract_png`: + ```bash + python3 tools/ora_edit.py extract_png scene.ora --layer door_0 --output door.png + python3 tools/ora_edit.py extract_png scene.ora --layer background --output bg.png + ``` +2. Use in Godot scenes as Sprite2D textures +3. Use `mask_to_polygon.py` to generate collision polygons: + ```bash + python3 tools/mask_to_polygon.py door_mask.png --output door_collision.tres + ``` + +## Error Handling + +**Missing layer:** +```bash +$ python3 tools/ora_edit.py mask_element scene.ora --mask x.png --entity door --layer "missing" +Error: Layer 'missing' not found in ORA file +Available layers: + - base + - door_0 +``` + +**Fix:** Use one of the listed layers with `--layer` + +**Invalid ORA:** +```bash +$ python3 tools/ora_edit.py inspect corrupted.ora +Error: Invalid ORA file: corrupted.ora +``` + +**Fix:** Recreate the ORA using `create` command + +## Tips + +- **Preview first:** Use `inspect` to check existing structure before adding elements +- **Descriptive names:** Entity names appear in Godot, use clear identifiers +- **Version control:** ORA files are binary; track source PNGs and masks separately +- **Batch processing:** Chain commands in shell scripts for repetitive tasks + +## See Also + +- `extract_mask.py` - AI-powered mask generation +- `mask_to_polygon.py` - Convert masks to Godot collision shapes +- `ora_edit_README.md` - Full command documentation diff --git a/tools/.output.ora-autosave.kra b/tools/.output.ora-autosave.kra new file mode 100644 index 0000000..b4cc9bf Binary files /dev/null and b/tools/.output.ora-autosave.kra differ diff --git a/tools/make_ora.py b/tools/make_ora.py index 33c5aed..d807bea 100644 --- a/tools/make_ora.py +++ b/tools/make_ora.py @@ -4,7 +4,7 @@ import zipfile import tempfile import shutil import numpy as np -from PIL import Image +from PIL import Image, ImageChops import xml.etree.ElementTree as ET NUM_LAYERS = 4 @@ -40,14 +40,15 @@ def build_stack_xml(w, h, groups): ) for layer in group["layers"]: + layer_attrs = { + "name": layer["name"], + "src": layer["src"], + "opacity": "1.0" + } ET.SubElement( group_el, "layer", - { - "name": layer["name"], - "src": layer["src"], - "opacity": "1.0" - } + layer_attrs ) return ET.tostring(image, encoding="utf-8", xml_declaration=True) @@ -74,24 +75,22 @@ def build_ora(input_png, output_ora): group_layers = [] - base_path = f"data/layer_{i}.png" - base.save(os.path.join(tmp, base_path)) - - group_layers.append({ - "name": "base", - "src": base_path - }) - for j in range(MASKS_PER_LAYER): - mask = noise_mask(w, h, NOISE_SCALES[j]) - mask_path = f"data/layer_{i}_mask_{j}.png" - mask.save(os.path.join(tmp, mask_path)) + # Apply mask to alpha channel of base image + masked_image = base.copy() + r, g, b, a = masked_image.split() + # Multiply existing alpha by mask + new_alpha = ImageChops.multiply(a, mask) + masked_image.putalpha(new_alpha) + + layer_path = f"data/layer_{i}_{j}.png" + masked_image.save(os.path.join(tmp, layer_path)) group_layers.append({ - "name": f"mask {j}", - "src": mask_path + "name": f"layer {j}", + "src": layer_path }) groups.append({ diff --git a/tools/ora_edit.py b/tools/ora_edit.py new file mode 100755 index 0000000..33d04bc --- /dev/null +++ b/tools/ora_edit.py @@ -0,0 +1,608 @@ +#!/usr/bin/env python3 +""" +ORA (OpenRaster) editing utility. + +Supports creating ORA files from PNGs, extracting layers as PNGs, and masking +elements with alpha channels. + +Commands: + create Create an ORA file from a PNG + mask_element Create a masked layer for an entity in an ORA file + inspect Show the structure of an ORA file + extract_png Extract a layer from an ORA file as a PNG +""" + +import argparse +import io +import os +import re +import shutil +import sys +import tempfile +import zipfile +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as ET + +from PIL import Image, ImageChops + + +def parse_stack_xml(ora_path: str) -> ET.Element: + """Parse stack.xml from an ORA file.""" + with zipfile.ZipFile(ora_path, 'r') as zf: + return ET.fromstring(zf.read('stack.xml')) + + +def get_image_size(root: ET.Element) -> tuple[int, int]: + """Get image dimensions from stack.xml root element.""" + return int(root.get('w', 0)), int(root.get('h', 0)) + + +def get_groups_and_layers(root: ET.Element) -> list[dict[str, Any]]: + """ + Extract groups and their layers from stack.xml. + Returns a list of group dicts with 'name', 'layers', and 'is_group' keys. + """ + groups = [] + root_stack = root.find('stack') + if root_stack is None: + return groups + + for child in root_stack: + if child.tag == 'stack': + # This is a group + group_name = child.get('name', 'unnamed') + layers = [] + for layer in child: + if layer.tag == 'layer': + layers.append({ + 'name': layer.get('name', 'unnamed'), + 'src': layer.get('src', ''), + 'opacity': layer.get('opacity', '1.0'), + 'visibility': layer.get('visibility', 'visible') + }) + groups.append({ + 'name': group_name, + 'is_group': True, + 'layers': layers + }) + elif child.tag == 'layer': + # This is a standalone layer (base layer) + groups.append({ + 'name': child.get('name', 'unnamed'), + 'is_group': False, + 'layers': [{ + 'name': child.get('name', 'unnamed'), + 'src': child.get('src', ''), + 'opacity': child.get('opacity', '1.0'), + 'visibility': child.get('visibility', 'visible') + }] + }) + + return groups + + +def get_next_layer_index(group: dict, entity_name: str) -> int: + """ + Get the next auto-increment index for layers in an entity group. + Looks for pattern: entity_name_N + """ + max_index = -1 + pattern = re.compile(rf'^{re.escape(entity_name)}_(\d+)$') + + for layer in group['layers']: + match = pattern.match(layer['name']) + if match: + max_index = max(max_index, int(match.group(1))) + + return max_index + 1 + + +def find_entity_group(groups: list, entity_name: str) -> dict | None: + """Find an existing entity group by name.""" + for group in groups: + if group.get('is_group') and group['name'] == entity_name: + return group + return None + + +def find_layer(groups: list, layer_name: str) -> tuple[dict, dict] | None: + """ + Find a layer by name across all groups. + Returns (group, layer) tuple or None. + """ + for group in groups: + for layer in group['layers']: + if layer['name'] == layer_name: + return group, layer + return None + + +def extract_layer_image(ora_path: str, layer_src: str) -> Image.Image: + """Extract a layer image from the ORA file.""" + with zipfile.ZipFile(ora_path, 'r') as zf: + image_data = zf.read(layer_src) + return Image.open(io.BytesIO(image_data)).convert('RGBA') + + +def apply_mask(source_image: Image.Image, mask_image: Image.Image) -> Image.Image: + """ + Apply a mask as the alpha channel of an image. + Mask can be grayscale or RGB; will be converted to grayscale for alpha. + """ + # Ensure mask is grayscale + if mask_image.mode != 'L': + mask_image = mask_image.convert('L') + + # Resize mask to match source if needed + if mask_image.size != source_image.size: + mask_image = mask_image.resize(source_image.size, Image.Resampling.BILINEAR) + + # Apply mask to alpha channel + result = source_image.copy() + r, g, b, a = result.split() + new_alpha = ImageChops.multiply(a, mask_image) + result.putalpha(new_alpha) + + return result + + +def build_stack_xml(width: int, height: int, groups: list[dict]) -> bytes: + """Build stack.xml content from group structure.""" + image = ET.Element('image', { + 'version': '0.0.3', + 'w': str(width), + 'h': str(height) + }) + + root_stack = ET.SubElement(image, 'stack') + + for group in groups: + if group.get('is_group'): + # Create group element + group_el = ET.SubElement(root_stack, 'stack', { + 'name': group['name'] + }) + + for layer in group['layers']: + layer_attrs = { + 'name': layer['name'], + 'src': layer['src'], + 'opacity': layer.get('opacity', '1.0') + } + if layer.get('visibility') and layer['visibility'] != 'visible': + layer_attrs['visibility'] = layer['visibility'] + ET.SubElement(group_el, 'layer', layer_attrs) + else: + # Standalone layer (base layer) + layer = group['layers'][0] + layer_attrs = { + 'name': layer['name'], + 'src': layer['src'], + 'opacity': layer.get('opacity', '1.0') + } + if layer.get('visibility') and layer['visibility'] != 'visible': + layer_attrs['visibility'] = layer['visibility'] + ET.SubElement(root_stack, 'layer', layer_attrs) + + return ET.tostring(image, encoding='utf-8', xml_declaration=True) + + +def create_ora_from_structure( + groups: list[dict], + width: int, + height: int, + layer_images: dict[str, Image.Image], + output_path: str +) -> None: + """Create an ORA file from the structure and layer images.""" + tmp = tempfile.mkdtemp() + + try: + data_dir = os.path.join(tmp, 'data') + thumb_dir = os.path.join(tmp, 'Thumbnails') + os.makedirs(data_dir) + os.makedirs(thumb_dir) + + # Save all layer images + for src_path, img in layer_images.items(): + full_path = os.path.join(tmp, src_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + img.save(full_path) + + # Build and save stack.xml + stack_xml = build_stack_xml(width, height, groups) + with open(os.path.join(tmp, 'stack.xml'), 'wb') as f: + f.write(stack_xml) + + # Create mimetype + with open(os.path.join(tmp, 'mimetype'), 'w') as f: + f.write('image/openraster') + + # Create mergedimage.png (use first base layer) + merged = None + for group in groups: + if not group.get('is_group') and group['layers']: + layer_src = group['layers'][0]['src'] + merged = layer_images.get(layer_src) + break + + if merged is None: + # Create blank image if no base layer + merged = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + + merged.save(os.path.join(tmp, 'mergedimage.png')) + + # Create thumbnail + thumb = merged.copy() + thumb.thumbnail((256, 256)) + thumb.save(os.path.join(thumb_dir, 'thumbnail.png')) + + # Build ORA zip + with zipfile.ZipFile(output_path, 'w') as zf: + zf.write(os.path.join(tmp, 'mimetype'), 'mimetype', + compress_type=zipfile.ZIP_STORED) + + for root, _, files in os.walk(tmp): + for file in files: + if file == 'mimetype': + continue + full = os.path.join(root, file) + rel = os.path.relpath(full, tmp) + zf.write(full, rel) + + finally: + shutil.rmtree(tmp) + + +def print_ora_summary(groups: list[dict]) -> None: + """Print an itemized summary of the ORA structure.""" + print("\n" + "=" * 50) + print("ORA STRUCTURE SUMMARY") + print("=" * 50) + + for i, group in enumerate(groups, 1): + if group.get('is_group'): + print(f"\nšŸ“ Group {i}: {group['name']}") + for j, layer in enumerate(group['layers'], 1): + print(f" └─ Layer {j}: {layer['name']}") + else: + layer = group['layers'][0] + print(f"\nšŸ–¼ļø Base Layer {i}: {layer['name']}") + + print("\n" + "=" * 50) + + +def cmd_create(args: argparse.Namespace) -> int: + """Handle 'create' subcommand.""" + input_png = args.input_png + output_ora = args.output_ora + layer_name = args.layer_name + + if output_ora is None: + output_ora = str(Path(input_png).with_suffix('.ora')) + + # Load base image + base = Image.open(input_png).convert('RGBA') + width, height = base.size + + # Build structure + layer_src = f'data/{layer_name}.png' + groups = [{ + 'name': layer_name, + 'is_group': False, + 'layers': [{ + 'name': layer_name, + 'src': layer_src, + 'opacity': '1.0', + 'visibility': 'visible' + }] + }] + + layer_images = {layer_src: base} + + create_ora_from_structure(groups, width, height, layer_images, output_ora) + + print(f"āœ“ Created ORA: {output_ora}") + print_ora_summary(groups) + + return 0 + + +def cmd_mask_element(args: argparse.Namespace) -> int: + """Handle 'mask_element' subcommand.""" + import io + + input_path = args.input + mask_path = args.mask + entity_name = args.entity + layer_name = args.layer + output_path = args.output + + # Handle PNG input: auto-create ORA first + is_png_input = input_path.lower().endswith('.png') + ora_path = input_path + + if is_png_input: + ora_path = str(Path(input_path).with_suffix('.ora')) + if output_path is None: + output_path = ora_path + + # Create ORA from PNG with 'base' layer + base = Image.open(input_path).convert('RGBA') + width, height = base.size + + layer_src = 'data/base.png' + groups = [{ + 'name': 'base', + 'is_group': False, + 'layers': [{ + 'name': 'base', + 'src': layer_src, + 'opacity': '1.0', + 'visibility': 'visible' + }] + }] + + layer_images = {layer_src: base} + create_ora_from_structure(groups, width, height, layer_images, ora_path) + print(f"āœ“ Converted PNG to ORA: {ora_path}") + + # Parse existing ORA + root = parse_stack_xml(ora_path) + width, height = get_image_size(root) + groups = get_groups_and_layers(root) + + # Find source layer + layer_result = find_layer(groups, layer_name) + if layer_result is None: + print(f"Error: Layer '{layer_name}' not found in ORA file", file=sys.stderr) + print("Available layers:", file=sys.stderr) + for group in groups: + for layer in group['layers']: + print(f" - {layer['name']}", file=sys.stderr) + return 1 + + source_group, source_layer = layer_result + + # Load source image + with zipfile.ZipFile(ora_path, 'r') as zf: + source_image_data = zf.read(source_layer['src']) + source_image = Image.open(io.BytesIO(source_image_data)).convert('RGBA') + + # Load mask + mask_image = Image.open(mask_path) + if mask_image.mode not in ['L', 'RGBA', 'RGB']: + mask_image = mask_image.convert('RGBA') + + # Apply mask + masked_image = apply_mask(source_image, mask_image) + + # Find or create entity group + entity_group = find_entity_group(groups, entity_name) + if entity_group is None: + # Create new group before base layers + entity_group = { + 'name': entity_name, + 'is_group': True, + 'layers': [] + } + # Insert before first non-group (base layer) + insert_idx = 0 + for i, g in enumerate(groups): + if not g.get('is_group'): + insert_idx = i + break + insert_idx = i + 1 + groups.insert(insert_idx, entity_group) + + # Generate new layer name (auto-increment) + next_index = get_next_layer_index(entity_group, entity_name) + new_layer_name = f"{entity_name}_{next_index}" + new_layer_src = f"data/{entity_name}/{new_layer_name}.png" + + # Add new layer to entity group + entity_group['layers'].append({ + 'name': new_layer_name, + 'src': new_layer_src, + 'opacity': '1.0', + 'visibility': 'visible' + }) + + # Extract all layer images from ORA + layer_images = {} + with zipfile.ZipFile(ora_path, 'r') as zf: + for group in groups: + for layer in group['layers']: + if layer['src'] not in layer_images: + try: + data = zf.read(layer['src']) + layer_images[layer['src']] = Image.open(io.BytesIO(data)) + except KeyError: + pass # New layer, will be added later + + # Add the new masked image + layer_images[new_layer_src] = masked_image + + # Determine output path + if output_path is None: + output_path = ora_path + + # Create new ORA + create_ora_from_structure(groups, width, height, layer_images, output_path) + + print(f"āœ“ Applied mask to create: {entity_name}/{new_layer_name}") + print(f" Source layer: {layer_name}") + print(f" Mask file: {mask_path}") + print(f" Output: {output_path}") + print_ora_summary(groups) + + return 0 + + +def cmd_inspect(args: argparse.Namespace) -> int: + """Handle 'inspect' subcommand.""" + ora_path = args.ora_file + + if not os.path.exists(ora_path): + print(f"Error: File not found: {ora_path}", file=sys.stderr) + return 1 + + # Parse existing ORA + try: + root = parse_stack_xml(ora_path) + except (zipfile.BadZipFile, KeyError) as e: + print(f"Error: Invalid ORA file: {ora_path}", file=sys.stderr) + return 1 + + groups = get_groups_and_layers(root) + + print(f"\nFile: {ora_path}") + print_ora_summary(groups) + + return 0 + + +def cmd_extract_png(args: argparse.Namespace) -> int: + """Handle 'extract_png' subcommand.""" + ora_path = args.ora_file + layer_name = args.layer + output_path = args.output + + if not os.path.exists(ora_path): + print(f"Error: File not found: {ora_path}", file=sys.stderr) + return 1 + + # Parse existing ORA + try: + root = parse_stack_xml(ora_path) + except (zipfile.BadZipFile, KeyError) as e: + print(f"Error: Invalid ORA file: {ora_path}", file=sys.stderr) + return 1 + + groups = get_groups_and_layers(root) + + # Find the layer + layer_result = find_layer(groups, layer_name) + if layer_result is None: + print(f"Error: Layer '{layer_name}' not found in ORA file", file=sys.stderr) + print("Available layers:", file=sys.stderr) + for group in groups: + for layer in group['layers']: + print(f" - {layer['name']}", file=sys.stderr) + return 1 + + _, layer = layer_result + + # Determine output path + if output_path is None: + output_path = f"{layer_name}.png" + + # Extract and save the layer image + image = extract_layer_image(ora_path, layer['src']) + image.save(output_path) + + print(f"āœ“ Extracted layer '{layer_name}' to: {output_path}") + + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + prog='ora_edit.py', + description='ORA (OpenRaster) editing utility' + ) + + subparsers = parser.add_subparsers(dest='command', required=True) + + # Create subcommand + create_parser = subparsers.add_parser( + 'create', + help='Create an ORA file from a PNG' + ) + create_parser.add_argument( + 'input_png', + help='Input PNG file' + ) + create_parser.add_argument( + 'output_ora', + nargs='?', + default=None, + help='Output ORA file (default: same name as input with .ora extension)' + ) + create_parser.add_argument( + '--layer-name', + default='base', + help='Name for the root layer (default: base)' + ) + create_parser.set_defaults(func=cmd_create) + + # Mask element subcommand + mask_parser = subparsers.add_parser( + 'mask_element', + help='Create a masked layer for an entity in an ORA file' + ) + mask_parser.add_argument( + 'input', + help='Input ORA or PNG file' + ) + mask_parser.add_argument( + '--mask', + required=True, + help='Mask file to use as alpha channel' + ) + mask_parser.add_argument( + '--entity', + required=True, + help='Entity name (group name)' + ) + mask_parser.add_argument( + '--layer', + default='base', + help='Source layer to copy and mask (default: base)' + ) + mask_parser.add_argument( + '--output', + default=None, + help='Output ORA file (default: same as input)' + ) + mask_parser.set_defaults(func=cmd_mask_element) + + # Inspect subcommand + inspect_parser = subparsers.add_parser( + 'inspect', + help='Show the structure of an ORA file' + ) + inspect_parser.add_argument( + 'ora_file', + help='ORA file to inspect' + ) + inspect_parser.set_defaults(func=cmd_inspect) + + # Extract PNG subcommand + extract_parser = subparsers.add_parser( + 'extract_png', + help='Extract a layer from an ORA file as a PNG' + ) + extract_parser.add_argument( + 'ora_file', + help='ORA file to extract from' + ) + extract_parser.add_argument( + '--layer', + required=True, + help='Name of the layer to extract' + ) + extract_parser.add_argument( + '--output', + default=None, + help='Output PNG file (default: .png)' + ) + extract_parser.set_defaults(func=cmd_extract_png) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/ora_edit_README.md b/tools/ora_edit_README.md new file mode 100644 index 0000000..0e77863 --- /dev/null +++ b/tools/ora_edit_README.md @@ -0,0 +1,409 @@ +# ora_edit.py + +A command-line utility for editing OpenRaster (ORA) files, supporting layer creation, masking, and entity management. + +## Overview + +`ora_edit.py` provides four main subcommands: +- `create`: Create a new ORA file from a PNG image +- `mask_element`: Add masked layers for specific entities within an ORA file +- `inspect`: Display the structure of an ORA file +- `extract_png`: Extract a layer from an ORA file as a PNG + +## Usage + +### Creating an ORA File + +Create a new ORA file from a PNG: + +```bash +python3 ora_edit.py create input.png [output.ora] [--layer-name NAME] +``` + +**Examples:** + +```bash +# Create ORA with default layer name "base" +python3 ora_edit.py create scene.png scene.ora + +# Create ORA with custom layer name +python3 ora_edit.py create scene.png scene.ora --layer-name "background" + +# Auto-detect output name (creates scene.ora) +python3 ora_edit.py create scene.png +``` + +### Masking Elements + +Add a masked layer for an entity using a black-and-white mask image: + +```bash +python3 ora_edit.py mask_element --mask --entity [--layer LAYER] [--output OUTPUT] +``` + +**Examples:** + +```bash +# Mask an element using default "base" layer +python3 ora_edit.py mask_element scene.ora --mask door_mask.png --entity "door" + +# Specify which layer to copy and mask +python3 ora_edit.py mask_element scene.ora --mask window_mask.png --entity "window" --layer "background" + +# Chain: Convert PNG to ORA, then add masked element +python3 ora_edit.py mask_element scene.png --mask tree_mask.png --entity "tree" --output scene_with_tree.ora +``` + +**How it works:** +- Creates an entity group (or uses existing one) +- Copies the specified source layer +- Applies the mask as the alpha channel +- Saves as `entity_N` (auto-incremented: door_0, door_1, etc.) + +### Inspecting ORA Files + +Display the layer structure and group hierarchy of an ORA file: + +```bash +python3 ora_edit.py inspect +``` + +**Examples:** + +```bash +# View structure of an ORA file +python3 ora_edit.py inspect scene.ora + +# Check what's in a file before adding more elements +python3 ora_edit.py inspect character.ora +``` + +### Extracting Layers as PNG + +Extract a specific layer from an ORA file and save it as a PNG: + +```bash +python3 ora_edit.py extract_png --layer [--output ] +``` + +**Examples:** + +```bash +# Extract a layer with default output name +python3 ora_edit.py extract_png scene.ora --layer door_0 + +# Extract with custom output name +python3 ora_edit.py extract_png scene.ora --layer background --output bg.png + +# Extract all layers from an ORA (use with inspect) +for layer in $(python3 ora_edit.py inspect scene.ora | grep "Layer" | awk '{print $NF}'); do + python3 ora_edit.py extract_png scene.ora --layer "$layer" --output "${layer}.png" +done +``` + +**Output format:** +``` +File: scene.ora + +================================================== +ORA STRUCTURE SUMMARY +================================================== + +šŸ“ Group 1: door + └─ Layer 1: door_0 + └─ Layer 2: door_1 +šŸ“ Group 2: window + └─ Layer 1: window_0 +šŸ–¼ļø Base Layer 3: background + +================================================== +``` + +## Workflow Examples + +### Example 1: Game Scene Composition + +Building a scene with multiple masked elements: + +```bash +# Start with background +python3 ora_edit.py create background.png scene.ora --layer-name "room" + +# Add a door masked from the room layer +python3 ora_edit.py mask_element scene.ora \ + --mask door_alpha.png \ + --entity "door" \ + --layer "room" + +# Add a window +python3 ora_edit.py mask_element scene.ora \ + --mask window_alpha.png \ + --entity "window" \ + --layer "room" + +# Add another door variant (auto-increments to door_1) +python3 ora_edit.py mask_element scene.ora \ + --mask door_open_alpha.png \ + --entity "door" \ + --layer "room" + +# Check the final structure +python3 ora_edit.py inspect scene.ora +``` + +**Resulting structure:** +``` +šŸ“ Group: door + └─ door_0 + └─ door_1 +šŸ“ Group: window + └─ window_0 +šŸ–¼ļø Base Layer: room +``` + +### Example 2: Character Animation + +Creating animation frames with masked body parts: + +```bash +# Create base character +python3 ora_edit.py create character_base.png character.ora --layer-name "body" + +# Add masked arm that can be animated +python3 ora_edit.py mask_element character.ora \ + --mask arm_mask.png \ + --entity "arm" \ + --layer "body" + +# Add masked head +python3 ora_edit.py mask_element character.ora \ + --mask head_mask.png \ + --entity "head" \ + --layer "body" +``` + +### Example 3: AI-Generated Assets + +Working with AI-generated masks: + +```bash +# Generate mask using ComfyUI or other tool +python3 extract_mask.py "the wooden chest" scene.png chest_mask.png + +# Add to ORA +python3 ora_edit.py mask_element scene.ora \ + --mask chest_mask.png \ + --entity "chest" \ + --layer "background" +``` + +### Example 4: Iterative Design + +Refining elements with multiple mask attempts: + +```bash +# First iteration +python3 ora_edit.py mask_element scene.ora \ + --mask door_v1.png \ + --entity "door" \ + --layer "background" + +# Second iteration (creates door_1) +python3 ora_edit.py mask_element scene.ora \ + --mask door_v2.png \ + --entity "door" \ + --layer "background" + +# Third iteration (creates door_2) +python3 ora_edit.py mask_element scene.ora \ + --mask door_v3.png \ + --entity "door" \ + --layer "background" +``` + +The ORA now contains all three versions to compare in GIMP/Krita. + +### Example 5: Layer-Based Masking + +Using different source layers for different entities: + +```bash +# Create ORA with multiple base layers +python3 ora_edit.py create day_scene.png scene.ora --layer-name "day" +python3 ora_edit.py create night_scene.png scene_night.ora --layer-name "night" + +# Extract and use specific layers +# (Assume we've combined these into one ORA manually or via script) + +# Mask using day layer +python3 ora_edit.py mask_element scene.ora \ + --mask sun_mask.png \ + --entity "sun" \ + --layer "day" + +# Mask using night layer +python3 ora_edit.py mask_element scene.ora \ + --mask moon_mask.png \ + --entity "moon" \ + --layer "night" +``` + +### Example 6: Exporting Layers for External Use + +Extracting layers for use in other tools or workflows: + +```bash +# Check what layers exist +python3 ora_edit.py inspect character.ora + +# Extract specific animation frames +python3 ora_edit.py extract_png character.ora --layer arm_0 --output arm_frame1.png +python3 ora_edit.py extract_png character.ora --layer arm_1 --output arm_frame2.png + +# Extract background for compositing +python3 ora_edit.py extract_png scene.ora --layer background --output bg_composite.png +``` + +## Command Reference + +### `create` + +Create an ORA file from a PNG image. + +``` +positional arguments: + input_png Input PNG file + output_ora Output ORA file (default: same name with .ora extension) + +options: + -h, --help Show help message + --layer-name NAME Name for the root layer (default: base) +``` + +### `mask_element` + +Create a masked layer for an entity. + +``` +positional arguments: + input Input ORA or PNG file + +options: + -h, --help Show help message + --mask MASK Mask file to use as alpha channel (required) + --entity ENTITY Entity name/group name (required) + --layer LAYER Source layer to copy and mask (default: base) + --output OUTPUT Output ORA file (default: same as input) +``` + +**Important behaviors:** +- If input is PNG: Automatically converts to ORA first, using "base" as the layer name +- Entity groups are created above base layers in the stack +- Layer names auto-increment within groups (entity_0, entity_1, ...) +- If the specified `--layer` doesn't exist, the command errors and lists available layers + +### `inspect` + +Display the structure of an ORA file without modifying it. + +``` +positional arguments: + ora_file ORA file to inspect + +options: + -h, --help Show help message +``` + +**Output includes:** +- File path +- Entity groups with their layer counts +- Base layers +- Total layer/group statistics + +### `extract_png` + +Extract a layer from an ORA file as a PNG image. + +``` +positional arguments: + ora_file ORA file to extract from + +options: + -h, --help Show help message + --layer LAYER Name of the layer to extract (required) + --output OUTPUT Output PNG file (default: .png) +``` + +**Use cases:** +- Export layers for use in other graphics applications +- Extract masked elements for game development +- Create individual assets from composed ORA files +- Backup or archive specific layers + +**Important behaviors:** +- If the specified `--layer` doesn't exist, the command errors and lists available layers +- Output defaults to `.png` if not specified +- Preserves transparency (RGBA) from the original layer + +## Tips + +### Mask Requirements +- Masks can be grayscale (L mode) or RGB/RGBA +- White = fully opaque, Black = fully transparent +- Grayscale values create partial transparency + +### Layer Organization +- Entity groups appear first in the layer stack +- Base layers appear at the end +- This organization makes it easy to toggle entities on/off in GIMP/Krita + +### Workflow Integration +- Combine with `extract_mask.py` for AI-powered masking +- Use `mask_to_polygon.py` to convert masks to Godot collision polygons +- Chain multiple `mask_element` calls in shell scripts for batch processing + +## Error Handling + +The tool provides clear error messages: + +```bash +# Missing layer +$ python3 ora_edit.py mask_element scene.ora --mask x.png --entity door --layer "missing" +Error: Layer 'missing' not found in ORA file +Available layers: + - base + - door_0 + - door_1 +``` + +## Dependencies + +- Python 3.8+ +- Pillow (PIL) + +Install dependencies: +```bash +pip install Pillow +``` + +## ORA Format + +OpenRaster (.ora) is an open standard for layered raster graphics. This tool creates ORA 0.0.3 compatible files that can be opened in: +- GIMP (with ora plugin) +- Krita +- MyPaint +- Other ORA-supporting applications + +The generated structure: +``` +archive.ora/ +ā”œā”€ā”€ mimetype # "image/openraster" +ā”œā”€ā”€ stack.xml # Layer hierarchy +ā”œā”€ā”€ mergedimage.png # Flattened preview +ā”œā”€ā”€ Thumbnails/ +│ └── thumbnail.png # 256x256 preview +└── data/ + ā”œā”€ā”€ base.png # Layer images + └── entity/ + └── entity_0.png +```