Files
upload-gltf/scripts/compress.py
T

423 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Blender Draco Compression Script
CLI tool to compress 3D meshes with Draco compression using Blender
Usage:
blender --background --python compress.py -- [options]
Options:
-i, --input FILE Input file (required in advanced mode)
-o, --output FILE Output file (default: input_compressed.glb)
--draco-level LEVEL Draco compression level 0-10 (default: 7)
--resize-textures / --no-resize Enable/disable texture resizing (default: enabled)
--texture-size SIZE Max texture size in pixels (default: 512)
--batch Batch mode: input is a directory
--output-dir DIR Output directory for batch mode
--format FORMAT Output format: glb or gltf (default: glb)
-q, --quiet Quiet mode (less output)
-h, --help Show this help message
Examples:
# Simple mode (all defaults)
blender --background --python compress.py -- input.glb
# Advanced mode
blender --background --python compress.py -- -i input.glb -o output.glb --draco-level 10
# Batch mode
blender --background --python compress.py -- --batch ./models/ --output-dir ./compressed/
"""
import os
import sys
import io
import argparse
from contextlib import redirect_stdout
from pathlib import Path
import bpy
try:
import bpy_types
except ImportError:
bpy_types = None
SUPPORTED_IMPORT_FORMATS = {
'.glb': 'gltf',
'.gltf': 'gltf',
'.obj': 'obj',
'.ply': 'ply',
'.stl': 'stl',
'.x3d': 'x3d',
'.wrl': 'x3d',
'.3ds': '3ds',
'.fbx': 'fbx',
'.dae': 'dae',
}
SUPPORTED_OUTPUT_FORMATS = ['glb', 'gltf']
def file_name(filepath):
return os.path.split(filepath)[1]
def file_suffix(filepath):
return os.path.splitext(file_name(filepath))[1].lower()
def dir_path(filepath):
return os.path.split(filepath)[0]
def get_import_operator(suffix):
operators = {
'gltf': bpy.ops.import_scene.gltf,
'obj': bpy.ops.import_scene.obj,
'ply': bpy.ops.import_mesh.ply,
'stl': bpy.ops.import_mesh.stl,
'x3d': bpy.ops.import_scene.x3d,
'3ds': bpy.ops.import_scene.fbx,
'fbx': bpy.ops.import_scene.fbx,
'dae': bpy.ops.import_scene.dae,
}
return operators.get(suffix)
def get_output_extension(format_type):
return '.glb' if format_type == 'glb' else '.gltf'
def import_mesh(filepath):
suffix = file_suffix(filepath)
if suffix not in SUPPORTED_IMPORT_FORMATS:
raise ValueError(f"Unsupported input format: {suffix}")
format_type = SUPPORTED_IMPORT_FORMATS[suffix]
import_op = get_import_operator(format_type)
if import_op is None:
raise ValueError(f"Cannot import {suffix} format")
stdout_buffer = io.StringIO()
with redirect_stdout(stdout_buffer):
import_op(filepath=str(filepath))
output = stdout_buffer.getvalue()
return output
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
return len(bpy.data.objects) == 0
def resize_textures(target_size):
resized_count = 0
for image in bpy.data.images:
if image.size[0] > target_size or image.size[1] > target_size:
old_width = image.size[0]
old_height = image.size[1]
scale = min(target_size / old_width, target_size / old_height)
new_width = int(old_width * scale)
new_height = int(old_height * scale)
image.scale(new_width, new_height)
resized_count += 1
print(f" Resized '{image.name}': {old_width}x{old_height} -> {new_width}x{new_height}")
return resized_count
def export_mesh(filepath, draco_level=7, format_type='glb'):
export_kwargs = {
'filepath': str(filepath),
'export_draco_mesh_compression_enable': True,
'export_draco_mesh_compression_level': draco_level,
'export_format': 'GLB' if format_type == 'glb' else 'GLTF_SEPARATE',
}
stdout_buffer = io.StringIO()
with redirect_stdout(stdout_buffer):
bpy.ops.export_scene.gltf(**export_kwargs)
return stdout_buffer.getvalue()
def get_default_output(input_path, format_type='glb'):
input_file = Path(input_path)
suffix = get_output_extension(format_type)
return str(input_file.parent / f"{input_file.stem}_compressed{suffix}")
def process_file(input_path, output_path=None, draco_level=7,
resize_textures_flag=True, texture_size=512,
format_type='glb', quiet=False):
if not quiet:
print(f"\n{'='*50}")
print(f"Processing: {input_path}")
if not os.path.exists(input_path):
raise FileNotFoundError(f"Input file not found: {input_path}")
if not quiet:
original_size = os.path.getsize(input_path)
print(f"Original size: {original_size / 1024:.2f} KB")
if not clear_scene():
raise RuntimeError("Failed to clear Blender scene")
if not quiet:
print("Importing mesh...")
import_mesh(input_path)
if len(bpy.data.objects) == 0:
raise RuntimeError(f"No objects imported from {input_path}")
if not quiet:
mesh_count = sum(1 for obj in bpy.data.objects if isinstance(obj.data, bpy.types.Mesh))
print(f"Imported {mesh_count} mesh(es)")
if resize_textures_flag:
if not quiet:
print(f"Resizing textures (max: {texture_size}px)...")
resized = resize_textures(texture_size)
if not quiet and resized > 0:
print(f"Resized {resized} texture(s)")
if output_path is None:
output_path = get_default_output(input_path, format_type)
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
if not quiet:
print(f"Exporting with Draco compression (level={draco_level})...")
export_mesh(output_path, draco_level, format_type)
if not os.path.exists(output_path):
raise RuntimeError(f"Export failed: {output_path} not created")
final_size = os.path.getsize(output_path)
if not quiet:
original_size = os.path.getsize(input_path) if os.path.exists(input_path) else 0
reduction = ((original_size - final_size) / original_size * 100) if original_size > 0 else 0
print(f"\nOutput: {output_path}")
print(f"Final size: {final_size / 1024:.2f} KB")
if original_size > 0:
print(f"Reduction: {reduction:.1f}%")
print("Compression complete!")
return output_path, final_size
def process_batch(input_dir, output_dir=None, draco_level=7,
resize_textures_flag=True, texture_size=512,
format_type='glb', quiet=False):
if not os.path.exists(input_dir):
raise FileNotFoundError(f"Input directory not found: {input_dir}")
if output_dir:
os.makedirs(output_dir, exist_ok=True)
files_found = []
for ext in SUPPORTED_IMPORT_FORMATS.keys():
files_found.extend(Path(input_dir).glob(f"*{ext}"))
files_found.extend(Path(input_dir).glob(f"*{ext.upper()}"))
files_found = sorted(set(files_found))
if not files_found:
print(f"No supported files found in {input_dir}")
return []
if not quiet:
print(f"\n{'='*50}")
print(f"BATCH MODE")
print(f"Input directory: {input_dir}")
print(f"Files found: {len(files_found)}")
results = []
for i, file_path in enumerate(files_found, 1):
if output_dir:
input_file = Path(file_path)
suffix = get_output_extension(format_type)
output_path = os.path.join(output_dir, f"{input_file.stem}_compressed{suffix}")
else:
output_path = None
if not quiet:
print(f"\n[{i}/{len(files_found)}]")
try:
result_path, _ = process_file(
str(file_path),
output_path=output_path,
draco_level=draco_level,
resize_textures_flag=resize_textures_flag,
texture_size=texture_size,
format_type=format_type,
quiet=quiet
)
results.append((str(file_path), result_path, True, None))
except Exception as e:
error_msg = str(e)
if not quiet:
print(f"ERROR: {error_msg}")
results.append((str(file_path), None, False, error_msg))
success_count = sum(1 for _, _, success, _ in results if success)
fail_count = len(results) - success_count
if not quiet:
print(f"\n{'='*50}")
print(f"BATCH COMPLETE")
print(f"Total files: {len(results)}")
print(f"Success: {success_count}")
print(f"Failed: {fail_count}")
return results
def main():
argv = sys.argv
if "--" not in argv:
argv = []
else:
argv = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser(
description='Compress 3D meshes with Draco compression using Blender',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Simple mode (all defaults)
blender --background --python compress.py -- input.glb
# With options
blender --background --python compress.py -- -i input.glb -o output.glb --draco-level 10
# Batch mode
blender --background --python compress.py -- --batch ./models/ --output-dir ./compressed/
"""
)
parser.add_argument(
'input',
nargs='?',
help='Input file or directory (for batch mode)'
)
parser.add_argument(
'-i', '--input',
dest='input_file',
help='Input file (alternative to positional argument)'
)
parser.add_argument(
'-o', '--output',
dest='output',
help='Output file (default: input_compressed.glb)'
)
parser.add_argument(
'--draco-level',
type=int,
default=7,
choices=range(0, 11),
help='Draco compression level 0-10 (default: 7)'
)
resize_group = parser.add_mutually_exclusive_group()
resize_group.add_argument(
'--resize-textures',
action='store_true',
default=True,
help='Enable texture resizing (default: enabled)'
)
resize_group.add_argument(
'--no-resize',
action='store_false',
dest='resize_textures',
help='Disable texture resizing'
)
parser.add_argument(
'--texture-size',
type=int,
default=512,
help='Max texture size in pixels (default: 512)'
)
parser.add_argument(
'--batch',
action='store_true',
help='Batch mode: process all files in input directory'
)
parser.add_argument(
'--output-dir', '-d',
dest='output_dir',
help='Output directory for batch mode'
)
parser.add_argument(
'--format', '-f',
choices=SUPPORTED_OUTPUT_FORMATS,
default='glb',
help='Output format (default: glb)'
)
parser.add_argument(
'-q', '--quiet',
action='store_true',
help='Quiet mode (less output)'
)
args = parser.parse_args(argv)
input_path = args.input or args.input_file
if not input_path:
parser.print_help()
print("\nError: Input file or directory is required")
sys.exit(1)
if args.batch or os.path.isdir(input_path):
results = process_batch(
input_path,
output_dir=args.output_dir,
draco_level=args.draco_level,
resize_textures_flag=args.resize_textures,
texture_size=args.texture_size,
format_type=args.format,
quiet=args.quiet
)
failed = [r for r in results if not r[2]]
if failed:
sys.exit(1)
else:
try:
process_file(
input_path,
output_path=args.output,
draco_level=args.draco_level,
resize_textures_flag=args.resize_textures,
texture_size=args.texture_size,
format_type=args.format,
quiet=args.quiet
)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()