diff --git a/.env.example b/.env.example index 2df4e58..8c8da71 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ UPLOAD_SECRET_KEY=your-secret-key-here GITHUB_TOKEN=ghp_your-github-personal-access-token -GIT_BRANCH=ta-branch +GIT_BRANCH=main GIT_REPO_URL=https://github.com/your-org/your-repo.git + +# Optional: path to Blender binary (defaults to "blender" in PATH) +# BLENDER_PATH=/usr/bin/blender diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2bf8e81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# ============================================================================= +# Asset Bridge 3D — Dockerfile for Coolify +# Node 20 Debian · Blender (headless) · Multi-stage build +# ============================================================================= + +# --- Stage 1: Dependencies --------------------------------------------------- +FROM node:20-slim AS deps + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +# --- Stage 2: Build ---------------------------------------------------------- +FROM node:20-slim AS builder + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# --- Stage 3: Production ----------------------------------------------------- +FROM node:20-slim AS runner + +LABEL maintainer="Asset Bridge 3D" +LABEL description="Secure 3D asset upload interface with Draco compression and GitHub push" + +# Install Blender (headless) + tini +RUN apt-get update && apt-get install -y --no-install-recommends \ + blender \ + tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Copy build artifacts +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +# Copy the Blender compression script +COPY --from=builder /app/scripts ./scripts + +# Ensure tmp dir for uploads exists +RUN mkdir -p /tmp/assets + +# Copy entrypoint +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +EXPOSE 3000 + +ENTRYPOINT ["/usr/bin/tini", "--", "/docker-entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/README.md b/README.md index ec72f68..08446c2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # upload-GLTF -A secure web interface for uploading 3D assets (GLTF/GLB + textures) and automatically pushing them to a GitHub repository via the GitHub API. Built for La Fabrik Durable. +A secure web interface for uploading 3D assets (GLTF/GLB + textures) with automatic Draco compression and GitHub push. Built for La Fabrik Durable. ## Stack @@ -8,7 +8,8 @@ A secure web interface for uploading 3D assets (GLTF/GLB + textures) and automat - **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview - **Tailwind CSS** for styling - **Octokit** for pushing via the GitHub API -- **Vercel** for hosting +- **Blender** (headless) for Draco mesh compression +- **Coolify** (Docker) for hosting ## Installation @@ -35,6 +36,7 @@ GIT_REPO_URL=https://github.com/your-org/your-repo.git | `GITHUB_TOKEN` | GitHub Personal Access Token (scope `repo`) | Yes | | `GIT_BRANCH` | Target branch (default: main) | No | | `GIT_REPO_URL` | Target GitHub repository URL | Yes | +| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No | > To create a token: GitHub > Settings > Developer settings > Personal access tokens > Generate new token (classic) with the `repo` scope. @@ -48,9 +50,20 @@ npm run dev Access the app at `http://localhost:3000` -### Production (Vercel) +> **Note:** Draco compression requires Blender installed locally. If Blender is not available, models are pushed to GitHub without compression. -Deploy to Vercel and configure environment variables in the dashboard. +### Production (Coolify / Docker) + +```bash +docker build -t upload-gltf . +docker run -p 3000:3000 \ + -e UPLOAD_SECRET_KEY=your-key \ + -e GITHUB_TOKEN=ghp_xxx \ + -e GIT_REPO_URL=https://github.com/org/repo.git \ + upload-gltf +``` + +The Dockerfile includes Blender headless for automatic Draco compression. ## How it works @@ -60,13 +73,15 @@ Deploy to Vercel and configure environment variables in the dashboard. - Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, optional) 3. The model is displayed in a 3D preview 4. On clicking "Upload & Push to GitHub", each file is sent to the `/api/upload` endpoint -5. The API validates the file and pushes it directly to the GitHub repo via the API (no git CLI needed) +5. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed to GitHub +6. For textures: pushed directly to GitHub without compression +7. If Blender is unavailable, the original model is pushed as-is (graceful fallback) ## Project Structure ``` app/ -├── api/upload/route.ts # API: validation + GitHub push via Octokit +├── api/upload/route.ts # API: validation + Draco compression + GitHub push ├── globals.css # Tailwind + Google Fonts ├── layout.tsx # Root layout └── page.tsx # Home page @@ -74,6 +89,8 @@ components/ ├── UploadZone.tsx # UI: key input, folder picker, validation, upload ├── ModelViewer.tsx # Lazy wrapper for the 3D viewer └── SceneViewer.tsx # Three.js Canvas +scripts/ +└── compress.py # Blender Draco compression script ``` ## Supported Formats diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 2fb1211..223d851 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,6 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' import { Octokit } from '@octokit/rest' -import { extname, basename } from 'path' +import { extname, basename, join } from 'path' +import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises' +import { existsSync } from 'fs' +import { execFile } from 'child_process' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -9,6 +15,8 @@ const MODEL_EXTENSIONS = new Set(['.glb', '.gltf']) const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']) const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]) +const TMP_DIR = join('/tmp', 'assets') + function sanitizeFilename(name: string): string { return basename(name) .replace(/[^a-zA-Z0-9._-]/g, '_') @@ -18,15 +26,14 @@ function sanitizeFilename(name: string): string { function getOctokit(): Octokit { const token = process.env.GITHUB_TOKEN - if (!token) throw new Error('GITHUB_TOKEN non configuré') + if (!token) throw new Error('GITHUB_TOKEN non configure') return new Octokit({ auth: token }) } function parseRepoUrl(): { owner: string; repo: string } { const url = process.env.GIT_REPO_URL - if (!url) throw new Error('GIT_REPO_URL non configuré') + if (!url) throw new Error('GIT_REPO_URL non configure') - // Support formats: https://github.com/owner/repo.git, owner/repo, git@github.com:owner/repo.git const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/) const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/) const shortMatch = url.match(/^([^/]+)\/([^/]+)$/) @@ -37,6 +44,48 @@ function parseRepoUrl(): { owner: string; repo: string } { return { owner: match[1], repo: match[2] } } +// --------------------------------------------------------------------------- +// Blender Draco compression +// --------------------------------------------------------------------------- + +async function compressWithBlender( + inputPath: string, + outputPath: string +): Promise<{ success: boolean; error?: string }> { + const blenderPath = process.env.BLENDER_PATH || 'blender' + const scriptPath = join(process.cwd(), 'scripts', 'compress.py') + + if (!existsSync(scriptPath)) { + return { success: false, error: 'scripts/compress.py introuvable' } + } + + try { + await execFileAsync(blenderPath, [ + '--background', + '--python', scriptPath, + '--', + '-i', inputPath, + '-o', outputPath, + '--draco-level', '7', + '--texture-size', '512', + '-q', + ], { timeout: 120_000 }) + + if (!existsSync(outputPath)) { + return { success: false, error: 'Blender n\'a pas produit de fichier compresse' } + } + + return { success: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { success: false, error: `Compression Blender echouee: ${message}` } + } +} + +// --------------------------------------------------------------------------- +// Parse uploaded file from FormData +// --------------------------------------------------------------------------- + async function parseUpload(req: NextRequest) { const formData = await req.formData() const file = formData.get('file') as File | null @@ -45,14 +94,14 @@ async function parseUpload(req: NextRequest) { const textureName = formData.get('textureName') as string | null if (!file || file.size === 0) { - throw new Error('Aucun fichier reçu') + throw new Error('Aucun fichier recu') } const originalSafe = sanitizeFilename(file.name) const ext = extname(originalSafe).toLowerCase() if (!ALL_ALLOWED_EXTENSIONS.has(ext)) { - throw new Error(`Extension non autorisée: "${ext}"`) + throw new Error(`Extension non autorisee: "${ext}"`) } const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') @@ -64,23 +113,26 @@ async function parseUpload(req: NextRequest) { filename = originalSafe } + const isModel = MODEL_EXTENSIONS.has(ext) const buffer = Buffer.from(await file.arrayBuffer()) - const content = buffer.toString('base64') - const path = `public/assets/${safeFolderName}/${filename}` - return { filename, content, path, folderName: safeFolderName } + return { filename, buffer, safeFolderName, isModel } } -async function pushToGitHub( +// --------------------------------------------------------------------------- +// Push a single file to GitHub via the API +// --------------------------------------------------------------------------- + +async function pushFileToGitHub( filePath: string, - content: string, + contentBase64: string, folderName: string ): Promise<{ commitUrl: string }> { const octokit = getOctokit() const { owner, repo } = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' - // 1. Get the current commit SHA on the branch + // 1. Get latest commit on branch const { data: ref } = await octokit.git.getRef({ owner, repo, @@ -88,22 +140,22 @@ async function pushToGitHub( }) const latestCommitSha = ref.object.sha - // 2. Get the tree of the latest commit + // 2. Get that commit's tree const { data: commit } = await octokit.git.getCommit({ owner, repo, commit_sha: latestCommitSha, }) - // 3. Create a blob for the file + // 3. Create blob const { data: blob } = await octokit.git.createBlob({ owner, repo, - content, + content: contentBase64, encoding: 'base64', }) - // 4. Create a new tree with the file added + // 4. Create tree const { data: newTree } = await octokit.git.createTree({ owner, repo, @@ -118,7 +170,7 @@ async function pushToGitHub( ], }) - // 5. Create a new commit + // 5. Create commit const timestamp = new Date().toISOString() const { data: newCommit } = await octokit.git.createCommit({ owner, @@ -128,7 +180,7 @@ async function pushToGitHub( parents: [latestCommitSha], }) - // 6. Update the branch reference + // 6. Update branch ref await octokit.git.updateRef({ owner, repo, @@ -139,49 +191,96 @@ async function pushToGitHub( return { commitUrl: newCommit.html_url } } +// --------------------------------------------------------------------------- +// POST handler +// --------------------------------------------------------------------------- + export async function POST(req: NextRequest) { + // --- Auth --- const secret = req.headers.get('x-upload-secret') const expectedSecret = process.env.UPLOAD_SECRET_KEY if (!expectedSecret) { return NextResponse.json( - { success: false, error: 'Configuration serveur incomplète (UPLOAD_SECRET_KEY manquant)' }, + { success: false, error: 'Configuration serveur incomplete (UPLOAD_SECRET_KEY manquant)' }, { status: 500 } ) } if (!secret || secret !== expectedSecret) { return NextResponse.json( - { success: false, error: "Clé d'authentification invalide" }, + { success: false, error: "Cle d'authentification invalide" }, { status: 401 } ) } + // --- Parse upload --- let filename: string - let content: string - let path: string - let folderName: string + let buffer: Buffer + let safeFolderName: string + let isModel: boolean try { - ;({ filename, content, path, folderName } = await parseUpload(req)) + ;({ filename, buffer, safeFolderName, isModel } = await parseUpload(req)) } catch (err) { const message = err instanceof Error ? err.message : 'Erreur inconnue' return NextResponse.json({ success: false, error: message }, { status: 400 }) } + // --- Write to /tmp --- + const tmpFolder = join(TMP_DIR, safeFolderName) + await mkdir(tmpFolder, { recursive: true }) + const tmpFilePath = join(tmpFolder, filename) + await writeFile(tmpFilePath, buffer) + + let contentToUpload: Buffer = buffer + let compressed = false + let compressionError: string | undefined + + // --- Compress model with Blender (if available) --- + if (isModel) { + const stem = filename.replace(/\.[^.]+$/, '') + const compressedPath = join(tmpFolder, `${stem}_compressed.glb`) + + const result = await compressWithBlender(tmpFilePath, compressedPath) + + if (result.success && existsSync(compressedPath)) { + contentToUpload = await readFile(compressedPath) + compressed = true + // Clean up compressed temp file + await unlink(compressedPath).catch(() => {}) + } else { + // Blender not available or failed — push original + compressionError = result.error + } + } + + // Clean up original temp file + await unlink(tmpFilePath).catch(() => {}) + // Clean up folder if empty + await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) + + // --- Push to GitHub --- + const gitPath = `public/assets/${safeFolderName}/${filename}` + const contentBase64 = contentToUpload.toString('base64') + try { - const { commitUrl } = await pushToGitHub(path, content, folderName) + const { commitUrl } = await pushFileToGitHub(gitPath, contentBase64, safeFolderName) return NextResponse.json({ success: true, filename, - message: `"${filename}" ajouté au dossier "${folderName}" et poussé sur GitHub.`, + compressed, + compressionError: compressionError || undefined, + message: compressed + ? `"${filename}" compresse avec Draco et pousse sur GitHub.` + : `"${filename}" pousse sur GitHub${compressionError ? ' (sans compression: ' + compressionError + ')' : ''}.`, commitUrl, }) } catch (err) { const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue' return NextResponse.json( - { success: false, error: `Push GitHub échoué: ${message}` }, + { success: false, error: `Push GitHub echoue: ${message}` }, { status: 500 } ) } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..636ec0f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +echo "[entrypoint] Starting Asset Bridge 3D..." + +# Ensure tmp directory for uploads exists +mkdir -p /tmp/assets + +echo "[entrypoint] Ready. Launching application..." + +exec "$@" diff --git a/next.config.ts b/next.config.ts index e4f5738..4133a3f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,7 @@ import type { NextConfig } from 'next' -const nextConfig: NextConfig = {} +const nextConfig: NextConfig = { + output: 'standalone', +} export default nextConfig diff --git a/scripts/compress.py b/scripts/compress.py new file mode 100644 index 0000000..a4ad0a2 --- /dev/null +++ b/scripts/compress.py @@ -0,0 +1,422 @@ +#!/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_output = 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_output = 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()