diff --git a/.env.example b/.env.example index 614bd19..fdb424c 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,9 @@ UPLOAD_SECRET_KEY=your-secret-key-here GITHUB_TOKEN=ghp_your-github-personal-access-token GIT_BRANCH=main GIT_REPO_URL=https://github.com/your-org/your-repo.git -BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender # Nextcloud Drive (public share WebDAV) NEXTCLOUD_URL=https://cloud.example.com NEXTCLOUD_SHARE_TOKEN=your-public-share-token NEXTCLOUD_SHARE_PASSWORD= -NEXTCLOUD_BASE_PATH=Models \ No newline at end of file +NEXTCLOUD_BASE_PATH=Models diff --git a/Dockerfile b/Dockerfile index 0c4a3e3..7ffd58e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # ============================================================================= # Upload GLTF — Dockerfile for Coolify -# Node 20 Debian · Blender (headless) · Multi-stage build +# Node 20 Debian · Multi-stage build # ============================================================================= # --- Stage 1: Dependencies --------------------------------------------------- @@ -28,11 +28,10 @@ RUN npm run build FROM node:20-slim AS runner LABEL maintainer="La Fabrik Durable" -LABEL description="Secure 3D asset upload interface with Draco compression and GitHub push" +LABEL description="Secure GLTF upload interface with texture compression and GitHub push" -# Install Blender (headless) + tini +# Install runtime helpers RUN apt-get update && apt-get install -y --no-install-recommends \ - blender \ tini \ curl \ && rm -rf /var/lib/apt/lists/* @@ -49,9 +48,6 @@ 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 diff --git a/README.md b/README.md index fc8835e..4ee5610 100644 --- a/README.md +++ b/README.md @@ -237,13 +237,10 @@ lib/ ├── upload-staging.ts # Temporary server-side staging and prepared asset reuse ├── upload-lock.ts # Lightweight in-memory per-folder upload lock ├── asset-classification.ts # Group assets by family for commit messages -├── blender.ts # Legacy Blender compression helper ├── commit-message.ts # Commit message builder ├── parse-upload.ts # FormData parser + validation ├── validate-folder.ts # Client-side folder validation (discriminated union) └── format-bytes.ts # Byte formatting utility -scripts/ -└── compress.py # Legacy Blender compression script Dockerfile # Multi-stage build: Node 20 slim + tini docker-entrypoint.sh # Startup check + launch ``` diff --git a/components/ui/icons.tsx b/components/ui/icons.tsx index a271bc2..ac021d5 100644 --- a/components/ui/icons.tsx +++ b/components/ui/icons.tsx @@ -62,11 +62,3 @@ export function FolderIcon({ className = 'w-6 h-6' }: IconProps) { ) } - -export function InfoIcon({ className = 'w-5 h-5' }: IconProps) { - return ( - - - - ) -} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index f0f2ea1..ddee99f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,15 +6,6 @@ echo "[upload-gltf] Starting Upload GLTF..." # Ensure tmp directory for uploads exists mkdir -p /tmp/assets -# Check if Blender is available for Draco compression -if command -v blender > /dev/null 2>&1; then - BLENDER_VERSION=$(blender --version 2>/dev/null | head -n 1) - echo "[upload-gltf] Blender found: $BLENDER_VERSION" - echo "[upload-gltf] Draco compression is enabled." -else - echo "[upload-gltf] WARNING: Blender not found. Models will be pushed without compression." -fi - echo "[upload-gltf] Ready. Launching application..." exec "$@" diff --git a/lib/blender.ts b/lib/blender.ts deleted file mode 100644 index 6a035e6..0000000 --- a/lib/blender.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { join } from 'path' -import { existsSync } from 'fs' -import { execFile } from 'child_process' -import { promisify } from 'util' - -const execFileAsync = promisify(execFile) - -/** - * Compress a GLTF/GLB model using Blender's Draco compression. - * Returns { success: true } on success, or { success: false, error } on failure. - * Callers should fall back to the original file on failure. - */ -export 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}` } - } -} diff --git a/lib/prepare-git-assets.ts b/lib/prepare-git-assets.ts index c31e3d5..dc7484a 100644 --- a/lib/prepare-git-assets.ts +++ b/lib/prepare-git-assets.ts @@ -46,6 +46,7 @@ export async function prepareGitAssets({ const textureResult = await compressTextureBuffer(pf.filename, pf.buffer) content = textureResult.buffer + compressed ||= textureResult.compressed if (textureResult.error && !compressionError) { compressionError = textureResult.error diff --git a/lib/upload-staging.ts b/lib/upload-staging.ts index 9daee4a..17913e0 100644 --- a/lib/upload-staging.ts +++ b/lib/upload-staging.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto' import { dirname, join } from 'path' -import { mkdir, readdir, readFile, rm, stat, writeFile } from 'fs/promises' +import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises' import { existsSync } from 'fs' import { TMP_DIR } from '@/lib/constants' import { prepareGitAssets } from '@/lib/prepare-git-assets' @@ -203,12 +203,3 @@ export async function readStagedOriginalFiles(stagingId: string): Promise<{ fold export async function cleanupStagingUpload(stagingId: string) { await rm(getStageDir(stagingId), { recursive: true, force: true }) } - -export async function stagingExists(stagingId: string): Promise { - try { - const info = await stat(getStageDir(stagingId)) - return info.isDirectory() - } catch { - return false - } -} diff --git a/scripts/compress.py b/scripts/compress.py deleted file mode 100644 index 8a53eb8..0000000 --- a/scripts/compress.py +++ /dev/null @@ -1,422 +0,0 @@ -#!/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()