diff --git a/.gitattributes b/.gitattributes index b0062b6..f45fa9a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +*.glb filter=lfs diff=lfs merge=lfs -text *.gltf filter=lfs diff=lfs merge=lfs -text *.bin filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index 7ffd58e..6fee568 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # ============================================================================= # Upload GLTF — Dockerfile for Coolify -# Node 20 Debian · Multi-stage build +# Node 20 Debian · Blender (headless) · Multi-stage build # ============================================================================= # --- Stage 1: Dependencies --------------------------------------------------- @@ -28,10 +28,11 @@ RUN npm run build FROM node:20-slim AS runner LABEL maintainer="La Fabrik Durable" -LABEL description="Secure GLTF upload interface with texture compression and GitHub push" +LABEL description="Secure GLTF upload interface with Draco compression and GitHub push" -# Install runtime helpers +# Install Blender (headless) + runtime helpers RUN apt-get update && apt-get install -y --no-install-recommends \ + blender \ tini \ curl \ && rm -rf /var/lib/apt/lists/* @@ -48,6 +49,9 @@ 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 e4fc4c4..3125ce1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A secure web interface for uploading `model.gltf` with its associated `.bin` file and textures with two outputs: - **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions -- **GitHub** — Delivers GLTF assets and compressed textures to the dev team's repository, ready for integration +- **GitHub** — Delivers Draco-compressed GLB assets by default, with an optional GLTF delivery mode for specific models Built for La Fabrik Durable's internal use, but open-sourced for anyone looking for a similar solution. The app validates the upload locally, stages it server-side, then applies intelligent diffs to avoid unnecessary uploads and commits. The Drive upload serves as the source of truth and version history, while the GitHub upload delivers the prepared assets to developers @@ -34,7 +34,7 @@ The app runs on `http://localhost:3000` with hot reload. The upload API routes a - Any associated binary buffer (`.bin`, for example `model.bin`) - Any associated textures (`.png/.jpg/.jpeg/.webp`) 3. The folder is validated locally. `.glb` files are not accepted. -4. On clicking "Envoyer": +4. On clicking "Envoyer" or "Envoyer en GLTF": - The app uploads the folder once to a temporary server-side staging area - The app prepares the final Git payload from this staging area - The app checks the remote Git repo for existing files and computes diffs @@ -57,7 +57,7 @@ Invalid or unknown asset names still block the upload. ### Upload flow: Drive first, then Git 5. **Drive upload (archiving)** — Original files from the staging area are uploaded to the Nextcloud Drive with automatic versioning (see below). This serves as the artists' source of truth and version history. If the Drive upload fails, a modal asks the user whether to send to Git only or cancel entirely. -6. **Git upload (delivery to devs)** — The prepared Git payload is reused from staging: `model.gltf` and `.bin` files are preserved, textures are compressed server-side, then all changed files are pushed to GitHub in a single commit. This is what the dev team consumes in the application. +6. **Git upload (delivery to devs)** — The prepared Git payload is reused from staging. By default, Blender exports a single `model.glb` with Draco compression. For specific models, "Envoyer en GLTF" keeps the current separate `model.gltf` + `.bin` + compressed textures workflow. ### Drive versioning (Nextcloud WebDAV) @@ -99,16 +99,7 @@ All changes are pushed in a **single commit** with a grouped formatted message: update: upload-gltf add a new model -> my-model 📦 Model - ✅ model.gltf -🎨 Textures (color) - ✅ color_porte.jpg (compressed) - -🪶 Textures (roughness) - ✅ roughness_tuyaux.png (compressed) - -🧩 Assets - ✅ model.bin - ✅ opacity_fenetre.png (compressed) + ✅ model.glb (compressed) ``` **Update (only one texture changed):** @@ -116,10 +107,7 @@ update: upload-gltf add a new model -> my-model update: upload-gltf update -> coffeetest 📦 Model - ↔️ model.gltf - -🎨 Textures (color) - 🔄 color_tuyaux.jpg (compressed) + 🔄 model.glb (compressed) ``` Commit sections: @@ -135,7 +123,7 @@ Commit sections: Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` deleted 7. Orphan files (present on remote but not in the new upload) are deleted in the same commit -8. `model.gltf` is pushed as-is so companion files like `model.bin` remain valid +8. Default Git delivery pushes `model.glb` only; "Envoyer en GLTF" pushes separate `model.gltf`, `.bin`, and compressed textures. Uploaded models are pushed to `public/models//` in the target repo. @@ -143,6 +131,7 @@ Uploaded models are pushed to `public/models//` in the target repo. - Large uploads are faster than before because the folder is staged only once, but the Drive upload remains sequential. - Git LFS batch uploads are sequential by batch. +- Default GLB Draco delivery reduces Git LFS usage by replacing many support files with one compressed model file. - Uploads expect a single `model.gltf` file plus optional flat support files (`.bin`, `.png`, `.jpg`, `.jpeg`, `.webp`). ## Project Structure @@ -193,11 +182,14 @@ lib/ ├── upload-lock.ts # Lightweight in-memory per-folder upload lock ├── asset-classification.ts # Group assets by family for commit messages ├── asset-naming.ts # Allowed asset families and naming convention helpers +├── blender.ts # Blender Draco 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 -Dockerfile # Multi-stage build: Node 20 slim + tini +scripts/ +└── compress.py # Blender Draco compression script +Dockerfile # Multi-stage build: Node 20 slim + Blender + tini docker-entrypoint.sh # Startup check + launch ``` @@ -253,7 +245,7 @@ docker run -p 3000:3000 \ upload-gltf ``` -The Docker image runs the Next.js app and server-side asset preparation in a single container. The `docker-entrypoint.sh` script checks for required environment variables before launching the app +The Docker image runs the Next.js app, Blender Draco compression, and server-side asset preparation in a single container. The `docker-entrypoint.sh` script checks for required environment variables before launching the app ## Supported Formats @@ -263,6 +255,8 @@ The Docker image runs the Next.js app and server-side asset preparation in a sin | Binary buffers | `.bin` | | Textures | `.png`, `.jpg`, `.jpeg`, `.webp` | +Git delivery outputs `.glb` by default, or keeps the source `.gltf` structure when "Envoyer en GLTF" is selected. + ## License See [MIT](LICENSE) License diff --git a/app/api/upload/check/route.ts b/app/api/upload/check/route.ts index a6c343c..85140de 100644 --- a/app/api/upload/check/route.ts +++ b/app/api/upload/check/route.ts @@ -6,6 +6,7 @@ import { getModelFolderPath } from '@/lib/model-paths' import { ensurePreparedStagingAssets } from '@/lib/upload-staging' import { parseStagingRequestBody } from '@/lib/upload-request' import { getErrorMessage } from '@/lib/guards' +import type { FileDiff } from '@/lib/types' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -37,7 +38,7 @@ export async function POST(req: NextRequest) { const remoteFileMap = new Map(files.map((file) => [file.name.toLowerCase(), file.size])) const { fileChanges, deletedFileNames } = classifyFileChanges(filesToPush, remoteFileMap, folderPath) - const diffs: Array<{ name: string; status: 'new' | 'changed' | 'deleted' }> = [] + const diffs: FileDiff[] = [] for (const [name, status] of fileChanges.entries()) { if (status === 'new' || status === 'changed') { diff --git a/app/api/upload/drive/route.ts b/app/api/upload/drive/route.ts index 273fedb..61c232d 100644 --- a/app/api/upload/drive/route.ts +++ b/app/api/upload/drive/route.ts @@ -10,6 +10,7 @@ import { import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock' import { parseDriveRequestBody } from '@/lib/upload-request' import { getErrorMessage } from '@/lib/guards' +import type { DriveAction } from '@/lib/types' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -27,7 +28,7 @@ export async function POST(req: NextRequest) { let folderName: string let parsedFiles: Awaited>['files'] - let action: 'new' | 'replace' + let action: DriveAction try { const body: unknown = await req.json() diff --git a/app/api/upload/stage/route.ts b/app/api/upload/stage/route.ts index 44a4f56..9e12b9e 100644 --- a/app/api/upload/stage/route.ts +++ b/app/api/upload/stage/route.ts @@ -13,7 +13,7 @@ export async function POST(req: NextRequest) { try { const parsed = await parseMultiUpload(req) - const staged = await createStagingUpload(parsed.folderName, parsed.files) + const staged = await createStagingUpload(parsed.folderName, parsed.files, parsed.gitModelMode) return NextResponse.json({ success: true, ...staged }) } catch (err) { const message = getErrorMessage(err) diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx index 95fbed6..5b99e20 100644 --- a/components/SceneViewer.tsx +++ b/components/SceneViewer.tsx @@ -29,6 +29,8 @@ interface AlphaMapMaterial extends Material { alphaTest: number } +type AlphaImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap + const alphaMapTextureCache = new WeakMap() function getRequestedFilename(requestedUrl: string) { @@ -91,14 +93,19 @@ function supportsAlphaMap(material: Material): material is AlphaMapMaterial { return 'alphaMap' in material } +function isAlphaImageSource(image: object | null | undefined): image is AlphaImageSource { + return image instanceof HTMLImageElement + || image instanceof HTMLCanvasElement + || (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) +} + function createAlphaMapTexture(texture: Texture) { const cachedTexture = alphaMapTextureCache.get(texture) if (cachedTexture) return cachedTexture - const image = texture.image as unknown - const isImageBitmap = typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap + const image = texture.image as object | null | undefined - if (!(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || isImageBitmap)) { + if (!isAlphaImageSource(image)) { texture.flipY = false alphaMapTextureCache.set(texture, texture) return texture diff --git a/components/ui/Modal.tsx b/components/ui/Modal.tsx index 2eac426..5c988ab 100644 --- a/components/ui/Modal.tsx +++ b/components/ui/Modal.tsx @@ -1,7 +1,3 @@ -// --------------------------------------------------------------------------- -// Shared modal wrapper — handles overlay, centering, dialog role, aria -// --------------------------------------------------------------------------- - import type { ReactNode } from 'react' interface ModalProps { @@ -24,16 +20,11 @@ export default function Modal({ ariaLabelledBy, children }: ModalProps) { ) } -// --------------------------------------------------------------------------- -// Shared modal footer with two buttons -// --------------------------------------------------------------------------- - interface ModalActionsProps { cancelLabel: string confirmLabel: string onCancel: () => void onConfirm: () => void - /** Tailwind classes for the confirm button (default: white bg) */ confirmClassName?: string disabled?: boolean } diff --git a/components/ui/icons.tsx b/components/ui/icons.tsx index ac021d5..d8b663e 100644 --- a/components/ui/icons.tsx +++ b/components/ui/icons.tsx @@ -1,7 +1,3 @@ -// --------------------------------------------------------------------------- -// Shared SVG icon components -// --------------------------------------------------------------------------- - interface IconProps { className?: string } diff --git a/components/upload/ActionButtons.tsx b/components/upload/ActionButtons.tsx index fed04be..dc3595a 100644 --- a/components/upload/ActionButtons.tsx +++ b/components/upload/ActionButtons.tsx @@ -1,4 +1,5 @@ -import { SpinnerIcon } from '@/components/ui/icons' +import { SpinnerIcon, WarningIcon } from '@/components/ui/icons' +import type { GitModelMode } from '@/lib/types' interface ActionButtonsProps { isUploading: boolean @@ -7,7 +8,7 @@ interface ActionButtonsProps { hasPendingOrErrors: boolean allDone: boolean hasErrors: boolean - onUpload: () => void + onUpload: (gitModelMode: GitModelMode) => void onCancel: () => void onReset: () => void } @@ -27,20 +28,38 @@ export default function ActionButtons({ const isBusy = isUploading || isChecking return ( -
+
{!isBusy && hasPendingOrErrors && ( - + <> + + + + )} {isBusy && ( diff --git a/components/upload/DriveStatusLine.tsx b/components/upload/DriveStatusLine.tsx index d3dc1e4..544b25b 100644 --- a/components/upload/DriveStatusLine.tsx +++ b/components/upload/DriveStatusLine.tsx @@ -1,7 +1,3 @@ -// --------------------------------------------------------------------------- -// Drive/Git status sub-line for FolderCard -// --------------------------------------------------------------------------- - import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons' import type { FolderEntry } from '@/lib/client-types' diff --git a/components/upload/FolderCard.tsx b/components/upload/FolderCard.tsx index d8ac58c..e63ab0e 100644 --- a/components/upload/FolderCard.tsx +++ b/components/upload/FolderCard.tsx @@ -59,7 +59,6 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F )}
- {/* Drive status sub-line (only during upload, not after success) */} {entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && ( )} @@ -97,7 +96,7 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F > diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ddee99f..96f7f8b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,6 +6,15 @@ 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. GLB Draco uploads will fail; use 'Envoyer en GLTF' if needed." +fi + echo "[upload-gltf] Ready. Launching application..." exec "$@" diff --git a/hooks/useUploadOrchestrator.ts b/hooks/useUploadOrchestrator.ts index 709f957..5b1b3b3 100644 --- a/hooks/useUploadOrchestrator.ts +++ b/hooks/useUploadOrchestrator.ts @@ -3,20 +3,22 @@ import { useState, useRef, useCallback } from 'react' import { getErrorMessage } from '@/lib/guards' import type { FolderEntry } from '@/lib/client-types' -import type { FileDiff } from '@/lib/types' +import type { DriveAction, FileDiff, GitModelMode } from '@/lib/types' import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api' import type { CheckResult } from '@/lib/upload-api' +type UploadLogDetails = Record + function formatElapsed(startedAt: number) { return `${((performance.now() - startedAt) / 1000).toFixed(1)}s` } -function logUpload(level: 'INFO' | 'ERROR', step: string, action: string, startedAt: number, details?: Record) { +function logUpload(level: 'INFO' | 'ERROR', step: string, action: string, startedAt: number, details?: UploadLogDetails) { const log = level === 'ERROR' ? console.error : console.info log(`[${level}] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '') } -function startTimedLog(step: string, action: string, details?: Record) { +function startTimedLog(step: string, action: string, details?: UploadLogDetails) { const startedAt = performance.now() logUpload('INFO', step, `${action} started`, startedAt, details) @@ -24,7 +26,7 @@ function startTimedLog(step: string, action: string, details?: Record) => { + return (status: 'done' | 'failed' | 'cancelled' = 'done', extra?: UploadLogDetails) => { window.clearInterval(interval) logUpload(status === 'failed' ? 'ERROR' : 'INFO', step, `${action} ${status}`, startedAt, { ...details, ...extra }) } @@ -122,7 +124,7 @@ export function useUploadOrchestrator({ if (controller.signal.aborted) break const folderEntry = currentEntries[i] - const driveAction = checkResultRef.current.exists ? 'replace' : 'new' + const driveAction: DriveAction = checkResultRef.current.exists ? 'replace' : 'new' const stagingId = stagingIdRef.current if (!stagingId) { updateEntry(i, { status: 'error', error: 'Preparation serveur introuvable' }) @@ -148,7 +150,7 @@ export function useUploadOrchestrator({ driveResult = await uploadDrive( stagingId, secretRef.current, - driveAction as 'new' | 'replace', + driveAction, controller.signal, ) endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error }) @@ -176,7 +178,7 @@ export function useUploadOrchestrator({ } }, [updateEntry, pushGit]) - const handleUpload = useCallback(async () => { + const handleUpload = useCallback(async (gitModelMode: GitModelMode) => { if (uploadActionRef.current || isChecking || isUploading) return if (!secretRef.current.trim()) { @@ -199,11 +201,12 @@ export function useUploadOrchestrator({ folderName: folder.folderName, files: 1 + folder.textures.length, modelSize: folder.modelFile.size, + gitModelMode, }) let staged: Awaited> try { - staged = await stageUpload(folder, secretRef.current, controller.signal) + staged = await stageUpload(folder, gitModelMode, secretRef.current, controller.signal) endStageLog('done', { stagingId: staged.stagingId, filesCount: staged.filesCount }) } catch (err) { endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', { diff --git a/lib/asset-classification.ts b/lib/asset-classification.ts index 23eb8a1..cc44490 100644 --- a/lib/asset-classification.ts +++ b/lib/asset-classification.ts @@ -1,46 +1,9 @@ import { getAssetFamily } from './asset-naming' - -export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets' +import type { AssetCategory } from './types' export function classifyAssetCategory(filename: string): AssetCategory { const name = filename.replace(/\.[^.]+$/, '') const family = getAssetFamily(name.split('_')[0]) - if (family === 'color') { - return 'color' - } - - if (family === 'diffuse') { - return 'diffuse' - } - - if (family === 'roughness') { - return 'roughness' - } - - if (family === 'normal') { - return 'normal' - } - - if (family === 'metalness') { - return 'metalness' - } - - if (family === 'height') { - return 'height' - } - - if (family === 'opacity') { - return 'opacity' - } - - if (family === 'orm') { - return 'orm' - } - - if (family === 'ao') { - return 'ao' - } - - return 'assets' + return family || 'assets' } diff --git a/lib/asset-naming.ts b/lib/asset-naming.ts index 13110e0..9983f05 100644 --- a/lib/asset-naming.ts +++ b/lib/asset-naming.ts @@ -10,7 +10,7 @@ export const ASSET_FAMILIES = [ 'ao', ] as const -export type AssetFamily = typeof ASSET_FAMILIES[number] +type AssetFamily = typeof ASSET_FAMILIES[number] const ASSET_FAMILY_BY_KEY = new Map(ASSET_FAMILIES.map((family) => [family.toLowerCase(), family])) const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap = new Map([ @@ -44,7 +44,7 @@ export function getAssetFamily(value: string): AssetFamily | undefined { return ASSET_FAMILY_BY_KEY.get(value.toLowerCase()) } -export function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undefined { +function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undefined { return FORBIDDEN_ASSET_FAMILY_ALIASES.get(value.toLowerCase()) } @@ -143,6 +143,6 @@ export function getTextureNamingError(filename: string) { return `Asset inconnu : ${filename}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.` } -export function formatAssetFamilies() { +function formatAssetFamilies() { return ASSET_FAMILIES.join(', ') } diff --git a/lib/blender.ts b/lib/blender.ts new file mode 100644 index 0000000..f6a66c4 --- /dev/null +++ b/lib/blender.ts @@ -0,0 +1,50 @@ +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. + * GLB Draco is explicit: callers should fail instead of silently pushing heavy assets. + */ +export async function compressWithBlender( + inputPath: string, + outputPath: string, +): Promise<{ success: boolean; error?: string }> { + const blenderPath = process.env.BLENDER_PATH || 'blender' + const timeout = Number(process.env.BLENDER_TIMEOUT_MS || 600_000) + 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 }, + ) + + 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/client-object-urls.ts b/lib/client-object-urls.ts index befb81b..38e3930 100644 --- a/lib/client-object-urls.ts +++ b/lib/client-object-urls.ts @@ -4,6 +4,6 @@ export function revokeEntryUrls(entry: FolderEntry) { const urls = new Set() if (entry.modelUrl) urls.add(entry.modelUrl) - Object.values(entry.assetUrls || {}).forEach((url) => urls.add(url)) + Object.values(entry.assetUrls).forEach((url) => urls.add(url)) urls.forEach((url) => URL.revokeObjectURL(url)) } diff --git a/lib/client-types.ts b/lib/client-types.ts index 36625e9..73be2d6 100644 --- a/lib/client-types.ts +++ b/lib/client-types.ts @@ -1,7 +1,3 @@ -// --------------------------------------------------------------------------- -// Client-side types — used by components and hooks (no Node.js Buffer) -// --------------------------------------------------------------------------- - type FileStatus = 'pending' | 'uploading' | 'success' | 'error' export interface TextureFile { @@ -20,7 +16,7 @@ export interface FolderEntry { error?: string filename?: string modelUrl?: string - assetUrls?: Record + assetUrls: Record viewerOpen?: boolean warnings: string[] driveStatus?: DriveStatus diff --git a/lib/commit-message.ts b/lib/commit-message.ts index 407cbe2..10b1e8c 100644 --- a/lib/commit-message.ts +++ b/lib/commit-message.ts @@ -1,6 +1,23 @@ -import type { AssetCategory } from './asset-classification' -import type { FileChange } from './types' -import type { PreparedAssetSummary } from './types' +import { ASSET_FAMILIES } from './asset-naming' +import type { AssetCategory, FileChange, PreparedAssetSummary } from './types' + +const ASSET_SECTION_ORDER: AssetCategory[] = [...ASSET_FAMILIES, 'assets'] + +function getChangePrefix(change: FileChange) { + if (change === 'new') return '✅' + if (change === 'changed') return '🔄' + return null +} + +function addGroupedAssetLine( + grouped: Map, + category: AssetCategory, + line: string, +) { + const current = grouped.get(category) || [] + current.push(line) + grouped.set(category, current) +} /** * Build a formatted commit message based on the upload context. @@ -25,7 +42,6 @@ export function buildCommitMessage( const lines: string[] = [title, ''] - // Model section — show status for new, changed, or unchanged const modelSummary = assetSummaries.find((asset) => asset.kind === 'model') const modelChange = fileChanges.get(modelFilename.toLowerCase()) if (modelChange === 'new') { @@ -45,15 +61,16 @@ export function buildCommitMessage( if (asset.kind === 'model' || !asset.category) continue const change = fileChanges.get(asset.filename.toLowerCase()) - if (change === 'new') { - const current = grouped.get(asset.category) || [] - current.push(` ✅ ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`) - grouped.set(asset.category, current) - } else if (change === 'changed') { - const current = grouped.get(asset.category) || [] - current.push(` 🔄 ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`) - grouped.set(asset.category, current) - } + if (!change) continue + + const prefix = getChangePrefix(change) + if (!prefix) continue + + addGroupedAssetLine( + grouped, + asset.category, + ` ${prefix} ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`, + ) } const sectionTitles: Record = { @@ -69,7 +86,7 @@ export function buildCommitMessage( assets: '🧩 Assets', } - for (const category of ['color', 'diffuse', 'roughness', 'normal', 'metalness', 'height', 'opacity', 'orm', 'ao', 'assets'] as const) { + for (const category of ASSET_SECTION_ORDER) { const entries = grouped.get(category) if (!entries || entries.length === 0) continue lines.push('') diff --git a/lib/constants.ts b/lib/constants.ts index 197a727..3f053a6 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -4,7 +4,7 @@ export const ASSET_EXTENSIONS = new Set(['.bin']) export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]) /** Extensions tracked by Git LFS (must match .gitattributes) */ -export const LFS_EXTENSIONS = new Set(['.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp']) +export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp']) export const TMP_DIR = '/tmp/assets' diff --git a/lib/diff-files.ts b/lib/diff-files.ts index 8c7f437..a3e13d1 100644 --- a/lib/diff-files.ts +++ b/lib/diff-files.ts @@ -13,9 +13,7 @@ interface DiffResult { * the remote file map. * * Rules: - * - Models: always re-pushed, - * but marked as 'unchanged' in the commit message when the folder already - * exists (we keep the current behavior of always delivering the model file). + * - Models: always re-pushed, but marked as unchanged when the remote file exists. * - Textures: compared by size (not compressed, reliable). * - Orphan remote files: classified as deletions. */ diff --git a/lib/format-bytes.ts b/lib/format-bytes.ts index b14456b..0a979bf 100644 --- a/lib/format-bytes.ts +++ b/lib/format-bytes.ts @@ -1,7 +1,3 @@ -// --------------------------------------------------------------------------- -// Format bytes to human-readable string -// --------------------------------------------------------------------------- - export function formatBytes(bytes: number): string { if (bytes <= 0) return '0 B' const k = 1024 diff --git a/lib/github.ts b/lib/github.ts index 01775b8..dd4ab1f 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -6,8 +6,10 @@ import type { PushFile, RemoteFile } from './types' const LFS_BATCH_SIZE = 100 +type LogDetails = Record + function isHttpError(err: unknown): err is { status: number } { - return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record).status === 'number' + return isRecord(err) && typeof err.status === 'number' } function getOctokit(): Octokit { @@ -51,7 +53,7 @@ function formatElapsed(startedAt: number) { return `${((performance.now() - startedAt) / 1000).toFixed(1)}s` } -function logInfo(step: string, action: string, startedAt: number, details?: Record) { +function logInfo(step: string, action: string, startedAt: number, details?: LogDetails) { console.info(`[INFO] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '') } diff --git a/lib/parse-upload.ts b/lib/parse-upload.ts index 60366cf..b2e58fb 100644 --- a/lib/parse-upload.ts +++ b/lib/parse-upload.ts @@ -3,11 +3,17 @@ import { NextRequest } from 'next/server' import { sanitizeFilename } from './sanitize' import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE, TEXTURE_EXTENSIONS } from './constants' import { getTextureNamingError } from './asset-naming' -import type { ParsedFile } from './types' +import type { GitModelMode, ParsedFile } from './types' interface ParsedUpload { folderName: string files: ParsedFile[] + gitModelMode: GitModelMode +} + +function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode { + if (value === 'draco-glb' || value === 'keep-gltf') return value + throw new Error('Mode Git invalide') } export async function parseMultiUpload(req: NextRequest): Promise { @@ -15,6 +21,7 @@ export async function parseMultiUpload(req: NextRequest): Promise const folderValue = formData.get('folderName') const folderName = typeof folderValue === 'string' ? folderValue.trim() || 'assets' : 'assets' const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') + const gitModelMode = parseGitModelMode(formData.get('gitModelMode')) const rawFiles = formData.getAll('files') const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string') @@ -95,5 +102,5 @@ export async function parseMultiUpload(req: NextRequest): Promise throw new Error('Un seul fichier model.gltf est autorise') } - return { folderName: safeFolderName, files: parsed } + return { folderName: safeFolderName, files: parsed, gitModelMode } } diff --git a/lib/prepare-git-assets.ts b/lib/prepare-git-assets.ts index 5762e62..6a96a1e 100644 --- a/lib/prepare-git-assets.ts +++ b/lib/prepare-git-assets.ts @@ -1,23 +1,22 @@ -import { extname } from 'path' +import { randomUUID } from 'crypto' +import { existsSync } from 'fs' +import { mkdir, readFile, rm, writeFile } from 'fs/promises' +import { extname, join } from 'path' +import { compressWithBlender } from '@/lib/blender' import { compressTextureBuffer } from '@/lib/texture-compression' import { classifyAssetCategory } from '@/lib/asset-classification' import { normalizeTextureFilename } from '@/lib/asset-naming' -import { TEXTURE_EXTENSIONS } from '@/lib/constants' +import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants' import { getModelAssetPath } from '@/lib/model-paths' -import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types' +import type { GitModelMode, ParsedFile, PreparedAssetSummary, PreparedGitAssetsResult, PushFile } from '@/lib/types' interface PrepareGitAssetsParams { folderName: string parsedFiles: ParsedFile[] + gitModelMode: GitModelMode } -interface PrepareGitAssetsResult { - filesToPush: PushFile[] - modelFilename: string - assetSummaries: PreparedAssetSummary[] - compressed: boolean - compressionError?: string -} +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } function getTextureFilenameMap(parsedFiles: ParsedFile[]) { const filenameMap = new Map() @@ -51,14 +50,14 @@ function getReferencedFilename(uri: string) { return cleanUri.split(/[\\/]/).pop()?.toLowerCase() } -function rewriteGltfUris(value: unknown, filenameMap: Map): unknown { +function rewriteGltfUris(value: JsonValue, filenameMap: Map): JsonValue { if (Array.isArray(value)) { return value.map((entry) => rewriteGltfUris(entry, filenameMap)) } if (!value || typeof value !== 'object') return value - const rewritten: Record = {} + const rewritten: Record = {} for (const [key, entry] of Object.entries(value)) { if (key === 'uri' && typeof entry === 'string') { @@ -76,20 +75,20 @@ function rewriteGltfUris(value: unknown, filenameMap: Map): unkn function prepareModelBuffer(buffer: Buffer, filenameMap: Map) { if (filenameMap.size === 0) return buffer - const parsed: unknown = JSON.parse(buffer.toString('utf-8')) + const parsed = JSON.parse(buffer.toString('utf-8')) as JsonValue return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), 'utf-8') } -export async function prepareGitAssets({ - folderName, - parsedFiles, -}: PrepareGitAssetsParams): Promise { +async function prepareSeparateFiles( + folderName: string, + parsedFiles: ParsedFile[], + textureFilenameMap: Map, +) { const filesToPush: PushFile[] = [] const assetSummaries: PreparedAssetSummary[] = [] let modelFilename = '' let compressed = false let compressionError: string | undefined - const textureFilenameMap = getTextureFilenameMap(parsedFiles) for (const pf of parsedFiles) { let content = pf.buffer @@ -131,11 +130,68 @@ export async function prepareGitAssets({ }) } - return { - filesToPush, - modelFilename, - assetSummaries, - compressed, - compressionError, + return { filesToPush, modelFilename, assetSummaries, compressed, compressionError } +} + +async function prepareDracoGlb( + folderName: string, + parsedFiles: ParsedFile[], + textureFilenameMap: Map, +): Promise { + const tmpFolder = join(TMP_DIR, 'blender', `${folderName}-${randomUUID()}`) + const inputModelPath = join(tmpFolder, 'model.gltf') + const outputModelPath = join(tmpFolder, 'model.glb') + + await mkdir(tmpFolder, { recursive: true }) + + try { + for (const pf of parsedFiles) { + if (pf.isModel) { + await writeFile(inputModelPath, prepareModelBuffer(pf.buffer, textureFilenameMap)) + continue + } + + const filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename + const ext = extname(filename).toLowerCase() + const content = TEXTURE_EXTENSIONS.has(ext) + ? (await compressTextureBuffer(filename, pf.buffer)).buffer + : pf.buffer + + await writeFile(join(tmpFolder, filename), content) + } + + const result = await compressWithBlender(inputModelPath, outputModelPath) + if (!result.success || !existsSync(outputModelPath)) { + throw new Error(result.error || 'Compression Blender echouee') + } + + const content = await readFile(outputModelPath) + const modelFilename = 'model.glb' + + return { + filesToPush: [{ + path: getModelAssetPath(folderName, modelFilename), + contentBase64: content.toString('base64'), + }], + modelFilename, + assetSummaries: [{ filename: modelFilename, kind: 'model', compressed: true }], + compressed: true, + } + } finally { + await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) } } + +export async function prepareGitAssets({ + folderName, + parsedFiles, + gitModelMode, +}: PrepareGitAssetsParams): Promise { + const textureFilenameMap = getTextureFilenameMap(parsedFiles) + + if (gitModelMode === 'keep-gltf') { + return prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap) + } + + return prepareDracoGlb(folderName, parsedFiles, textureFilenameMap) +} diff --git a/lib/types.ts b/lib/types.ts index ab02b84..53fee88 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,3 @@ -import type { AssetCategory } from './asset-classification' - export interface ParsedFile { filename: string buffer: Buffer @@ -11,8 +9,14 @@ export interface PushFile { contentBase64: string } +export type GitModelMode = 'draco-glb' | 'keep-gltf' + +export type DriveAction = 'new' | 'replace' + export type FileChange = 'new' | 'changed' | 'unchanged' +export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets' + export interface FileDiff { name: string status: 'changed' | 'new' | 'deleted' @@ -29,3 +33,21 @@ export interface PreparedAssetSummary { category?: AssetCategory compressed: boolean } + +export interface StagingUploadResult { + stagingId: string + folderName: string + filesCount: number +} + +export interface PreparedGitAssetsResult { + filesToPush: PushFile[] + modelFilename: string + assetSummaries: PreparedAssetSummary[] + compressed: boolean + compressionError?: string +} + +export interface PreparedStageAssetsResult extends PreparedGitAssetsResult { + folderName: string +} diff --git a/lib/upload-api.ts b/lib/upload-api.ts index 12783ad..1a5fd4e 100644 --- a/lib/upload-api.ts +++ b/lib/upload-api.ts @@ -1,31 +1,37 @@ import { isRecord } from './guards' import type { FolderEntry } from './client-types' -import type { FileDiff } from './types' +import type { DriveAction, FileDiff, GitModelMode, StagingUploadResult } from './types' export interface CheckResult { exists: boolean diffs: FileDiff[] } -interface StageResult { - stagingId: string - folderName: string - filesCount: number -} - function getApiError(data: unknown, fallback: string) { return isRecord(data) && typeof data.error === 'string' ? data.error : fallback } +function getUploadJsonHeaders(secret: string) { + return { + 'Content-Type': 'application/json', + 'x-upload-secret': secret.trim(), + } +} + +function isAbortError(err: unknown) { + return err instanceof DOMException && err.name === 'AbortError' +} + function isFileDiff(value: unknown): value is FileDiff { return isRecord(value) && typeof value.name === 'string' && (value.status === 'new' || value.status === 'changed' || value.status === 'deleted') } -function buildUploadFormData(folder: FolderEntry): FormData { +function buildUploadFormData(folder: FolderEntry, gitModelMode: GitModelMode): FormData { const formData = new FormData() formData.append('folderName', folder.folderName) + formData.append('gitModelMode', gitModelMode) formData.append('files', folder.modelFile) formData.append('fileTypes', 'model') @@ -47,10 +53,7 @@ export async function checkFolderDiffs( ): Promise { const res = await fetch('/api/upload/check', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-upload-secret': secret.trim(), - }, + headers: getUploadJsonHeaders(secret), body: JSON.stringify({ stagingId }), signal, }) @@ -72,10 +75,11 @@ export async function checkFolderDiffs( export async function stageUpload( folder: FolderEntry, + gitModelMode: GitModelMode, secret: string, signal?: AbortSignal, -): Promise { - const formData = buildUploadFormData(folder) +): Promise { + const formData = buildUploadFormData(folder, gitModelMode) const res = await fetch('/api/upload/stage', { method: 'POST', headers: { 'x-upload-secret': secret.trim() }, @@ -103,16 +107,13 @@ export async function stageUpload( export async function uploadDrive( stagingId: string, secret: string, - action: 'new' | 'replace', + action: DriveAction, signal?: AbortSignal, ): Promise<{ success: boolean; error?: string }> { try { const res = await fetch('/api/upload/drive', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-upload-secret': secret.trim(), - }, + headers: getUploadJsonHeaders(secret), body: JSON.stringify({ stagingId, action }), signal, }) @@ -122,7 +123,7 @@ export async function uploadDrive( } return { success: true } } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { + if (isAbortError(err)) { return { success: false, error: 'Upload annule' } } return { success: false, error: 'Erreur reseau (Drive)' } @@ -140,10 +141,7 @@ export async function uploadGit( try { const res = await fetch('/api/upload/git', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-upload-secret': secret.trim(), - }, + headers: getUploadJsonHeaders(secret), body: JSON.stringify({ stagingId }), signal, }) @@ -158,7 +156,7 @@ export async function uploadGit( onProgress(100) return { success: true, filename: typeof data.folderName === 'string' ? data.folderName : undefined } } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { + if (isAbortError(err)) { return { success: false, error: 'Upload annule' } } return { success: false, error: 'Erreur reseau' } diff --git a/lib/upload-request.ts b/lib/upload-request.ts index ae9dd28..34f0846 100644 --- a/lib/upload-request.ts +++ b/lib/upload-request.ts @@ -1,6 +1,5 @@ import { isRecord } from './guards' - -type DriveAction = 'new' | 'replace' +import type { DriveAction } from './types' interface StagingRequestBody { stagingId: string diff --git a/lib/upload-staging.ts b/lib/upload-staging.ts index 5a7b159..6b76bc6 100644 --- a/lib/upload-staging.ts +++ b/lib/upload-staging.ts @@ -5,7 +5,14 @@ import { existsSync } from 'fs' import { TMP_DIR } from '@/lib/constants' import { getModelAssetPath } from '@/lib/model-paths' import { prepareGitAssets } from '@/lib/prepare-git-assets' -import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types' +import type { + GitModelMode, + ParsedFile, + PreparedAssetSummary, + PreparedStageAssetsResult, + PushFile, + StagingUploadResult, +} from '@/lib/types' const STAGING_ROOT = join(TMP_DIR, 'staging') const STAGING_TTL_MS = 60 * 60 * 1000 @@ -26,20 +33,12 @@ interface StagedPreparedData { interface StagingManifest { stagingId: string folderName: string + gitModelMode: GitModelMode createdAt: number originals: StagedOriginalFile[] prepared?: StagedPreparedData } -interface PreparedStageAssetsResult { - folderName: string - filesToPush: PushFile[] - modelFilename: string - assetSummaries: PreparedAssetSummary[] - compressed: boolean - compressionError?: string -} - function getStageDir(stagingId: string) { return join(STAGING_ROOT, stagingId) } @@ -87,7 +86,11 @@ async function cleanupExpiredStagingUploads() { } } -export async function createStagingUpload(folderName: string, parsedFiles: ParsedFile[]) { +export async function createStagingUpload( + folderName: string, + parsedFiles: ParsedFile[], + gitModelMode: GitModelMode, +): Promise { await cleanupExpiredStagingUploads() const stagingId = randomUUID() @@ -106,6 +109,7 @@ export async function createStagingUpload(folderName: string, parsedFiles: Parse const manifest: StagingManifest = { stagingId, folderName, + gitModelMode, createdAt: Date.now(), originals, } @@ -137,12 +141,11 @@ async function readOriginalParsedFiles(stagingId: string, manifest: StagingManif ) } -async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise { +async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest, prepared: StagedPreparedData): Promise { const preparedDir = getPreparedDir(stagingId) - const preparedFiles = manifest.prepared?.assetSummaries || [] return Promise.all( - preparedFiles.map(async (file) => { + prepared.assetSummaries.map(async (file) => { const buffer = await readFile(join(preparedDir, file.filename)) return { path: getModelAssetPath(manifest.folderName, file.filename), @@ -157,7 +160,11 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise 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()