diff --git a/.env.example b/.env.example index 8c8da71..09c38e5 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,4 @@ 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 - -# Optional: path to Blender binary (defaults to "blender" in PATH) -# BLENDER_PATH=/usr/bin/blender +GIT_REPO_URL=https://github.com/your-org/your-repo.git \ No newline at end of file diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 223d851..7f0b8d1 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -14,6 +14,7 @@ export const dynamic = 'force-dynamic' 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 REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] const TMP_DIR = join('/tmp', 'assets') @@ -83,50 +84,109 @@ async function compressWithBlender( } // --------------------------------------------------------------------------- -// Parse uploaded file from FormData +// Build commit message with checklist // --------------------------------------------------------------------------- -async function parseUpload(req: NextRequest) { - const formData = await req.formData() - const file = formData.get('file') as File | null - const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' - const fileType = formData.get('fileType') as string | null - const textureName = formData.get('textureName') as string | null +function buildCommitMessage( + folderName: string, + modelFilename: string, + textureNames: string[], + compressed: boolean, +): string { + const title = `update: from upload-gltf add a new model -> ${folderName}` - if (!file || file.size === 0) { - throw new Error('Aucun fichier recu') - } + const foundTextures = new Set( + textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, '')) + ) - const originalSafe = sanitizeFilename(file.name) - const ext = extname(originalSafe).toLowerCase() + const textureLines = REQUIRED_TEXTURES.map(tex => { + if (foundTextures.has(tex)) { + const actual = textureNames.find(t => t.toLowerCase().replace(/\.[^.]+$/, '') === tex) + return ` ✅ ${actual}` + } + return ` ❌ ${tex} (manquant)` + }) - if (!ALL_ALLOWED_EXTENSIONS.has(ext)) { - throw new Error(`Extension non autorisee: "${ext}"`) - } + const lines = [ + title, + '', + '📦 Model', + ` ✅ ${modelFilename}${compressed ? ' (Draco)' : ''}`, + '', + '🎨 Textures', + ...textureLines, + ] - const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') - - let filename: string - if (fileType === 'texture' && textureName) { - filename = sanitizeFilename(textureName) - } else { - filename = originalSafe - } - - const isModel = MODEL_EXTENSIONS.has(ext) - const buffer = Buffer.from(await file.arrayBuffer()) - - return { filename, buffer, safeFolderName, isModel } + return lines.join('\n') } // --------------------------------------------------------------------------- -// Push a single file to GitHub via the API +// Parse all files from a multi-file FormData // --------------------------------------------------------------------------- -async function pushFileToGitHub( - filePath: string, - contentBase64: string, +interface ParsedFile { + filename: string + buffer: Buffer + isModel: boolean + textureName?: string +} + +async function parseMultiUpload(req: NextRequest): Promise<{ folderName: string + files: ParsedFile[] +}> { + const formData = await req.formData() + const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' + const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') + + const fileEntries = formData.getAll('files') as File[] + const fileTypes = formData.getAll('fileTypes') as string[] + const textureNames = formData.getAll('textureNames') as string[] + + if (fileEntries.length === 0) { + throw new Error('Aucun fichier recu') + } + + const parsed: ParsedFile[] = [] + + for (let i = 0; i < fileEntries.length; i++) { + const file = fileEntries[i] + if (!file || file.size === 0) continue + + const fileType = fileTypes[i] || 'model' + const texName = textureNames[i] || '' + + const originalSafe = sanitizeFilename(file.name) + const ext = extname(originalSafe).toLowerCase() + + if (!ALL_ALLOWED_EXTENSIONS.has(ext)) { + throw new Error(`Extension non autorisee: "${ext}"`) + } + + let filename: string + if (fileType === 'texture' && texName) { + filename = sanitizeFilename(texName) + } else { + filename = originalSafe + } + + const isModel = MODEL_EXTENSIONS.has(ext) + const buffer = Buffer.from(await file.arrayBuffer()) + + parsed.push({ filename, buffer, isModel, textureName: texName || undefined }) + } + + return { folderName: safeFolderName, files: parsed } +} + +// --------------------------------------------------------------------------- +// Push all files in a single commit +// --------------------------------------------------------------------------- + +async function pushAllToGitHub( + folderName: string, + files: { path: string; contentBase64: string }[], + commitMessage: string ): Promise<{ commitUrl: string }> { const octokit = getOctokit() const { owner, repo } = parseRepoUrl() @@ -147,35 +207,36 @@ async function pushFileToGitHub( commit_sha: latestCommitSha, }) - // 3. Create blob - const { data: blob } = await octokit.git.createBlob({ - owner, - repo, - content: contentBase64, - encoding: 'base64', - }) + // 3. Create all blobs in parallel + const blobResults = await Promise.all( + files.map(f => + octokit.git.createBlob({ + owner, + repo, + content: f.contentBase64, + encoding: 'base64', + }) + ) + ) - // 4. Create tree + // 4. Create a single tree with all files const { data: newTree } = await octokit.git.createTree({ owner, repo, base_tree: commit.tree.sha, - tree: [ - { - path: filePath, - mode: '100644', - type: 'blob', - sha: blob.sha, - }, - ], + tree: files.map((f, i) => ({ + path: f.path, + mode: '100644' as const, + type: 'blob' as const, + sha: blobResults[i].data.sha, + })), }) - // 5. Create commit - const timestamp = new Date().toISOString() + // 5. Create a single commit const { data: newCommit } = await octokit.git.createCommit({ owner, repo, - message: `Design Update: ${folderName} [${timestamp}]`, + message: commitMessage, tree: newTree.sha, parents: [latestCommitSha], }) @@ -214,67 +275,75 @@ export async function POST(req: NextRequest) { ) } - // --- Parse upload --- - let filename: string - let buffer: Buffer - let safeFolderName: string - let isModel: boolean + // --- Parse all files --- + let folderName: string + let parsedFiles: ParsedFile[] try { - ;({ filename, buffer, safeFolderName, isModel } = await parseUpload(req)) + ;({ folderName, files: parsedFiles } = await parseMultiUpload(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 + // --- Process files (compress model if possible) --- + const filesToPush: { path: string; contentBase64: string }[] = [] + let modelFilename = '' let compressed = false let compressionError: string | undefined + const textureNames: string[] = [] - // --- Compress model with Blender (if available) --- - if (isModel) { - const stem = filename.replace(/\.[^.]+$/, '') - const compressedPath = join(tmpFolder, `${stem}_compressed.glb`) + for (const pf of parsedFiles) { + let content = pf.buffer - const result = await compressWithBlender(tmpFilePath, compressedPath) + if (pf.isModel) { + modelFilename = pf.filename - if (result.success && existsSync(compressedPath)) { - contentToUpload = await readFile(compressedPath) - compressed = true - // Clean up compressed temp file - await unlink(compressedPath).catch(() => {}) + // Write to /tmp for Blender compression + const tmpFolder = join(TMP_DIR, folderName) + await mkdir(tmpFolder, { recursive: true }) + const tmpFilePath = join(tmpFolder, pf.filename) + await writeFile(tmpFilePath, pf.buffer) + + const stem = pf.filename.replace(/\.[^.]+$/, '') + const compressedPath = join(tmpFolder, `${stem}_compressed.glb`) + + const result = await compressWithBlender(tmpFilePath, compressedPath) + + if (result.success && existsSync(compressedPath)) { + content = await readFile(compressedPath) + compressed = true + await unlink(compressedPath).catch(() => {}) + } else { + compressionError = result.error + } + + await unlink(tmpFilePath).catch(() => {}) + await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) } else { - // Blender not available or failed — push original - compressionError = result.error + textureNames.push(pf.filename) } + + filesToPush.push({ + path: `public/assets/${folderName}/${pf.filename}`, + contentBase64: content.toString('base64'), + }) } - // 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') + // --- Build commit message --- + const commitMessage = buildCommitMessage(folderName, modelFilename, textureNames, compressed) + // --- Push all in one commit --- try { - const { commitUrl } = await pushFileToGitHub(gitPath, contentBase64, safeFolderName) + const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, commitMessage) return NextResponse.json({ success: true, - filename, + folderName, + filesCount: filesToPush.length, compressed, compressionError: compressionError || undefined, - message: compressed - ? `"${filename}" compresse avec Draco et pousse sur GitHub.` - : `"${filename}" pousse sur GitHub${compressionError ? ' (sans compression: ' + compressionError + ')' : ''}.`, + message: `${filesToPush.length} fichier(s) envoye(s) sur GitHub en un seul commit.`, commitUrl, }) } catch (err) { diff --git a/app/layout.tsx b/app/layout.tsx index ecdc04f..1a9aa47 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,7 @@ import './globals.css' export const metadata: Metadata = { title: ' Upload GLTF', - description: 'Interface de dépôt sécurisé pour fichiers 3D (.glb, .gltf, .fbx) et de versionnement automatique', + description: 'Interface de depot securise pour fichiers 3D (.glb, .gltf) avec versionnement automatique sur GitHub', } export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index d11a3c2..dfd65de 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,19 +8,17 @@ export default function Home() { Upload GLTF
- Drop your 3D files — they will be automatically versioned
-
and pushed to your GitHub repository via Git LFS.
+ Deposez vos fichiers 3D — ils seront automatiquement versionnes
+
et envoyes sur votre depot GitHub.
{secretError}
+ )}- Drop ton dossier ici - ou click pour parcourir + Deposez votre dossier ici + ou cliquez pour parcourir
- Contient: model.glb/gltf + textures (roughness, normal, metalness, color, displace) + Contenu attendu : model.glb/gltf + textures (roughness, normal, metalness, color, displace)
)} @@ -366,10 +348,10 @@ export default function UploadZone() {