This commit is contained in:
2026-03-09 10:54:10 -07:00
parent 07b979e358
commit 041c0ebbdb
7 changed files with 1297 additions and 19 deletions

1
.gitignore vendored
View File

@@ -3,4 +3,5 @@ tools/venv/**
.import
addons
build/
tmp/**
.tmp/**

View File

@@ -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: `<original_name>.<object>.png`
@@ -36,6 +39,9 @@ Tell the user:
- Location of the mask: `./tmp/<original_name>.<object>.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 |

View File

@@ -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 <input.png> [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 <input> --mask <mask.png> --entity <name> [--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 <ora_file>
```
### extract_png
Extract a layer from an ORA file as a PNG image.
```bash
python3 tools/ora_edit.py extract_png <ora_file> --layer <layer_name> [--output <output.png>]
```
**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 <provided 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

Binary file not shown.

View File

@@ -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({

608
tools/ora_edit.py Executable file
View File

@@ -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: <layer_name>.png)'
)
extract_parser.set_defaults(func=cmd_extract_png)
args = parser.parse_args()
return args.func(args)
if __name__ == '__main__':
sys.exit(main())

409
tools/ora_edit_README.md Normal file
View File

@@ -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 <input> --mask <mask.png> --entity <name> [--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 <ora_file>
```
**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 <ora_file> --layer <layer_name> [--output <output.png>]
```
**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: <layer_name>.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 `<layer_name>.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
```