Compare commits
23 Commits
31c05a35fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dcbcecbb32 | |||
| 72e12a6e3d | |||
| 83b2b405b4 | |||
| 3cfb3a21a9 | |||
| 81c513ee1f | |||
| 377ed7cfb3 | |||
| 71b4b2c905 | |||
| f53f606daa | |||
| 23253c2277 | |||
| 3fdbad7bdf | |||
| be4cf502d1 | |||
| f6ac71dad2 | |||
| 30ff9826dc | |||
| 606df93b69 | |||
| 101af23418 | |||
| 498765db61 | |||
| 097b8f6486 | |||
| a7155547c5 | |||
| 41e04002b8 | |||
| 497b0853c5 | |||
| 9dc0232e4a | |||
| 2679d29ab4 | |||
| dddecbb11c |
+10
-3
@@ -1,6 +1,13 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
.git
|
.git
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
*.md
|
*.md
|
||||||
.env*
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
+8
-2
@@ -1,7 +1,13 @@
|
|||||||
UPLOAD_SECRET_KEY=your-secret-key-here
|
UPLOAD_SECRET_KEY=your-secret-key-here
|
||||||
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
GIT_PROVIDER=gitea
|
||||||
|
GIT_USERNAME=your-gitea-username
|
||||||
|
GIT_TOKEN=your-git-provider-token
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
GIT_REPO_URL=https://git.example.com/your-org/your-repo
|
||||||
|
|
||||||
|
# Optional texture optimization
|
||||||
|
TOKTX_PATH=toktx
|
||||||
|
TEXTURE_MAX_SIZE=1024
|
||||||
|
|
||||||
# Nextcloud Drive (public share WebDAV)
|
# Nextcloud Drive (public share WebDAV)
|
||||||
NEXTCLOUD_URL=https://cloud.example.com
|
NEXTCLOUD_URL=https://cloud.example.com
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
*.glb filter=lfs diff=lfs merge=lfs -text
|
||||||
*.gltf filter=lfs diff=lfs merge=lfs -text
|
*.gltf filter=lfs diff=lfs merge=lfs -text
|
||||||
*.bin filter=lfs diff=lfs merge=lfs -text
|
*.bin filter=lfs diff=lfs merge=lfs -text
|
||||||
*.png filter=lfs diff=lfs merge=lfs -text
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ignore-scripts=true
|
||||||
|
save-exact=true
|
||||||
|
fund=false
|
||||||
|
audit=false
|
||||||
|
|
||||||
|
# npm >= 11.10: avoid freshly published packages during dependency resolution.
|
||||||
|
min-release-age=1
|
||||||
+21
-13
@@ -1,15 +1,12 @@
|
|||||||
# =============================================================================
|
# Coolify production image: Next.js standalone app with Blender for Draco compression.
|
||||||
# Upload GLTF — Dockerfile for Coolify
|
|
||||||
# Node 20 Debian · Multi-stage build
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# --- Stage 1: Dependencies ---------------------------------------------------
|
# --- Stage 1: Dependencies ---------------------------------------------------
|
||||||
FROM node:20-slim AS deps
|
FROM node:20-slim AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* .npmrc ./
|
||||||
RUN npm ci --ignore-scripts
|
RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
|
||||||
# --- Stage 2: Build ----------------------------------------------------------
|
# --- Stage 2: Build ----------------------------------------------------------
|
||||||
FROM node:20-slim AS builder
|
FROM node:20-slim AS builder
|
||||||
@@ -27,14 +24,26 @@ RUN npm run build
|
|||||||
# --- Stage 3: Production -----------------------------------------------------
|
# --- Stage 3: Production -----------------------------------------------------
|
||||||
FROM node:20-slim AS runner
|
FROM node:20-slim AS runner
|
||||||
|
|
||||||
LABEL maintainer="La Fabrik Durable"
|
ARG KTX_SOFTWARE_VERSION=4.4.2
|
||||||
LABEL description="Secure GLTF upload interface with texture compression and GitHub push"
|
|
||||||
|
|
||||||
# Install runtime helpers
|
LABEL maintainer="La Fabrik Durable"
|
||||||
|
LABEL description="Secure GLTF upload interface with Draco/KTX2 compression and Git push"
|
||||||
|
|
||||||
|
# Blender is required for server-side Draco GLB export.
|
||||||
|
# KTX-Software provides toktx for optional KTX2 texture delivery.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
bzip2 \
|
||||||
|
blender \
|
||||||
|
ca-certificates \
|
||||||
tini \
|
tini \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& curl -fsSL \
|
||||||
|
"https://github.com/KhronosGroup/KTX-Software/releases/download/v${KTX_SOFTWARE_VERSION}/KTX-Software-${KTX_SOFTWARE_VERSION}-Linux-x86_64.tar.bz2" \
|
||||||
|
-o /tmp/ktx-software.tar.bz2 \
|
||||||
|
&& mkdir -p /opt/ktx-software \
|
||||||
|
&& tar -xjf /tmp/ktx-software.tar.bz2 -C /opt/ktx-software --strip-components=1 \
|
||||||
|
&& ln -s /opt/ktx-software/bin/toktx /usr/local/bin/toktx \
|
||||||
|
&& rm -rf /tmp/ktx-software.tar.bz2 /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -43,15 +52,14 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# Copy build artifacts
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
# Ensure tmp dir for uploads exists
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
|
||||||
RUN mkdir -p /tmp/assets
|
RUN mkdir -p /tmp/assets
|
||||||
|
|
||||||
# Copy entrypoint
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 La Fabrik Durable
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -2,100 +2,60 @@
|
|||||||
|
|
||||||
A secure web interface for uploading `model.gltf` with its associated `.bin` file and textures with two outputs:
|
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.
|
- **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.
|
- **Git remote** — Delivers Draco-compressed GLB assets by default, with an optional GLTF delivery mode using KTX2 textures, then WebP/original fallbacks for specific models
|
||||||
|
|
||||||
Built for La Fabrik Durable.
|
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 compares file diffs to avoid unnecessary uploads and commits. The Drive upload serves as the source of truth and version history, while the Git upload delivers the prepared assets to developers.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Next.js 16** (App Router) + React 19 + TypeScript
|
- [**Next.js 16.2.5** (App Router)](https://nextjs.org/docs/app/getting-started/installation) + [React 19](https://react.dev/learn/creating-a-react-app) + [TypeScript](https://www.typescriptlang.org/docs/)
|
||||||
- **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview
|
- [**Three.js**](https://threejs.org/docs/#manual/en/introduction/Creating-a-scene) ([React Three Fiber](https://r3f.docs.pmnd.rs/getting-started/introduction) + [Drei](https://drei.docs.pmnd.rs/getting-started/introduction)) for 3D preview
|
||||||
- **Tailwind CSS** for styling
|
- [**Tailwind CSS**](https://v3.tailwindcss.com/docs/installation) for styling
|
||||||
- **Octokit** for pushing via the GitHub API
|
- [**Octokit**](https://github.com/octokit/rest.js/#readme) + provider adapters for GitHub and Gitea uploads
|
||||||
- **Nextcloud WebDAV** for Drive archiving with automatic versioning
|
- [**Nextcloud WebDAV**](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html) for Drive archiving with automatic versioning
|
||||||
- **Sharp** for server-side texture compression
|
- [**Sharp**](https://sharp.pixelplumbing.com/install/) for server-side WebP texture fallback
|
||||||
- **Coolify** (Docker) for hosting
|
- [**KTX-Software**](https://github.com/KhronosGroup/KTX-Software) (`toktx`) for optional KTX2 texture delivery
|
||||||
|
- [**npm lockfile + Coolify** (Docker)](https://coolify.io/docs/applications/build-packs/dockerfile) for hosting
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/La-Fabrik-Durable/upload-GLTF.git
|
|
||||||
cd upload-GLTF
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env.local` and fill in the values:
|
|
||||||
|
|
||||||
```env
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
| Variable | Description | Required |
|
|
||||||
|----------|-------------|----------|
|
|
||||||
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
|
|
||||||
| `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes |
|
|
||||||
| `GIT_BRANCH` | Target branch (default: main) | No |
|
|
||||||
| `GIT_REPO_URL` | Target GitHub repository URL | Yes |
|
|
||||||
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
|
|
||||||
| `NEXTCLOUD_SHARE_TOKEN` | Public share token (the part after `/s/` in the share link) | Yes |
|
|
||||||
| `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No |
|
|
||||||
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
|
|
||||||
|
|
||||||
> To create a GitHub token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
The app runs on `http://localhost:3000` with hot reload. The upload API routes are available under `http://localhost:3000/api/upload/*`
|
||||||
|
|
||||||
Access the app at `http://localhost:3000`
|
Use npm for this repo. `package-lock.json` is the source of truth for local installs and Coolify builds; no pnpm/yarn lockfile should be committed here.
|
||||||
|
|
||||||
> **Note:** Uploads accept `model.gltf` and preserve it as GLTF. `.glb` uploads are rejected by validation.
|
### Dependency and security policy
|
||||||
>
|
|
||||||
> Local 3D preview supports `model.gltf` folders by resolving dropped companion files such as `model.bin` and textures through local object URLs.
|
|
||||||
> The preview also shows a small model stats helper with estimated draw calls, meshes, triangles, materials, and texture count.
|
|
||||||
> Opacity helper textures can be named `opacity.png` for the whole model or `opacity_part-name.png` to target a mesh/material whose name contains `part-name`; alpha-channel PNGs are converted to alpha maps for the preview.
|
|
||||||
> If `model.gltf` references a missing `.bin` but the folder contains exactly one other `.bin`, the preview can use it as a local fallback and shows a warning because the final upload may still be broken until the `.bin` filename matches the GLTF reference.
|
|
||||||
|
|
||||||
### Asset naming convention
|
The project pins `next` to `16.2.5` to include the May 2026 WebSocket SSRF fix (`CVE-2026-44578` / `GHSA-c4j6-fc7j-m34r`). Do not loosen this back to `^16.2.4` or any range that can resolve below `16.2.5`.
|
||||||
|
|
||||||
Texture filenames must start with a known asset family. Use `asset.png` to apply a texture to the whole model, or `asset_object.png` to target a specific object.
|
This repo also keeps install-time package scripts disabled by default through `.npmrc` and Docker:
|
||||||
|
|
||||||
Allowed families are defined in `lib/asset-naming.ts`: `color`, `diffuse`, `roughness`, `normal`, `metalness`, `height`, `opacity`.
|
|
||||||
|
|
||||||
Valid examples: `color.png`, `diffuse_lampe.png`, `normal_cable1.png`, `opacity_lampe.png`.
|
|
||||||
|
|
||||||
Invalid examples: `lampe_opacity.png`, `cable1_base_color.png`, `normal_opengl_cable1.png`, `metallic_pied.png`. Invalid or unknown asset names block the upload.
|
|
||||||
|
|
||||||
### Production (Coolify / Docker)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t upload-gltf .
|
npm ci --ignore-scripts --no-audit --no-fund
|
||||||
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 \
|
|
||||||
-e NEXTCLOUD_URL=https://cloud.example.com \
|
|
||||||
-e NEXTCLOUD_SHARE_TOKEN=your-share-token \
|
|
||||||
upload-gltf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker image runs the Next.js app and server-side asset preparation in a single container.
|
When a dependency update is needed, prefer a lockfile-only update in a clean environment with no `.env`, no Git token, and no cloud credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/npm-clean-home /tmp/npm-clean-cache
|
||||||
|
env -i \
|
||||||
|
HOME=/tmp/npm-clean-home \
|
||||||
|
PATH="$PATH" \
|
||||||
|
npm_config_userconfig=/tmp/npm-clean-home/.npmrc \
|
||||||
|
npm_config_cache=/tmp/npm-clean-cache \
|
||||||
|
npm_config_ignore_scripts=true \
|
||||||
|
npm_config_audit=false \
|
||||||
|
npm_config_fund=false \
|
||||||
|
npm install <package>@<version> --package-lock-only --ignore-scripts --no-audit --no-fund --save-exact
|
||||||
|
```
|
||||||
|
|
||||||
|
The May 2026 TanStack incident affected malicious package versions published to npm, not the npm CLI itself. This repo does not depend on `@tanstack/*`, but other projects should be checked for `@tanstack/setup`, `github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c`, `router_init.js`, `tanstack_runner.js`, and the fake unscoped package `tanstack@2.0.4` through `2.0.7`.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -105,7 +65,7 @@ The Docker image runs the Next.js app and server-side asset preparation in a sin
|
|||||||
- Any associated binary buffer (`.bin`, for example `model.bin`)
|
- Any associated binary buffer (`.bin`, for example `model.bin`)
|
||||||
- Any associated textures (`.png/.jpg/.jpeg/.webp`)
|
- Any associated textures (`.png/.jpg/.jpeg/.webp`)
|
||||||
3. The folder is validated locally. `.glb` files are not accepted.
|
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 uploads the folder once to a temporary server-side staging area
|
||||||
- The app prepares the final Git payload from this 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
|
- The app checks the remote Git repo for existing files and computes diffs
|
||||||
@@ -113,10 +73,22 @@ The Docker image runs the Next.js app and server-side asset preparation in a sin
|
|||||||
- If the folder exists and files differ, a confirmation dialog shows **only the actual changes**
|
- If the folder exists and files differ, a confirmation dialog shows **only the actual changes**
|
||||||
- If nothing changed, the upload is skipped entirely
|
- If nothing changed, the upload is skipped entirely
|
||||||
|
|
||||||
|
### Asset naming convention
|
||||||
|
|
||||||
|
Texture filenames can either use the internal convention or common GLTF export suffixes. The Git payload is normalized automatically and `model.gltf` texture URIs are rewritten to match the normalized filenames. Drive archiving keeps the original artist files.
|
||||||
|
|
||||||
|
Internal convention: use `asset.png` to apply a texture to the whole model, or `asset_object.png` to target a specific object.
|
||||||
|
Allowed families are defined in `lib/asset-naming.ts`: `color`, `diffuse`, `roughness`, `normal`, `metalness`, `height`, `opacity`, `orm`, `ao`.
|
||||||
|
Valid internal examples: `color.png`, `diffuse_lampe.png`, `normal_cable1.png`, `opacity_lampe.png`.
|
||||||
|
Accepted export examples: `lampe_baseColor.png`, `lampe_base_color.png`, `lampe_normal_opengl.png`, `lampe_metallic.png`, `lampe_occlusionRoughnessMetallic.png`, `lampe_mixed_ao.png`.
|
||||||
|
Git normalization examples: `lampe_baseColor.png` becomes `color_lampe.png`, `lampe_metallic.png` becomes `metalness_lampe.png`, `lampe_occlusionRoughnessMetallic.png` becomes `orm_lampe.png`, and `lampe_mixed_ao.png` becomes `ao_lampe.png`.
|
||||||
|
When several exported textures would normalize to the same filename, all colliding variants keep their original names to avoid data loss. For example, `chap_normal.png` and `chap_normal_opengl.png` are both kept as-is.
|
||||||
|
Invalid or unknown asset names still block the upload.
|
||||||
|
|
||||||
### Upload flow: Drive first, then Git
|
### 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.
|
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. If Blender cannot process a specific model, the upload falls back to the separate `model.gltf` + `.bin` + optimized textures workflow and shows a warning. In separate GLTF delivery, each texture tries one optimized output in order: KTX2 with `KHR_texture_basisu`, then WebP through Sharp, then the original texture if all optimization fails. For specific models, "Envoyer en GLTF" keeps that separate GLTF delivery mode from the start.
|
||||||
|
|
||||||
### Drive versioning (Nextcloud WebDAV)
|
### Drive versioning (Nextcloud WebDAV)
|
||||||
|
|
||||||
@@ -146,6 +118,9 @@ All files are uploaded to `VF/` (not just diffs), because the move operation emp
|
|||||||
- The upload flow prevents duplicate submissions on the client (`Envoyer`, overwrite confirmation, and "Git only" confirmation are locked while processing)
|
- The upload flow prevents duplicate submissions on the client (`Envoyer`, overwrite confirmation, and "Git only" confirmation are locked while processing)
|
||||||
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
|
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
|
||||||
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
|
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
|
||||||
|
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
|
||||||
|
- The client shows texture diagnostics before upload: missing GLTF image files, unsupported texture formats, duplicate flat filenames, unused textures, broad opacity maps, and all-transparent material exports
|
||||||
|
- Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit
|
||||||
|
|
||||||
### Commit messages
|
### Commit messages
|
||||||
|
|
||||||
@@ -156,16 +131,7 @@ All changes are pushed in a **single commit** with a grouped formatted message:
|
|||||||
update: upload-gltf add a new model -> my-model
|
update: upload-gltf add a new model -> my-model
|
||||||
|
|
||||||
📦 Model
|
📦 Model
|
||||||
✅ model.gltf
|
✅ model.glb (compressed)
|
||||||
🎨 Textures (color)
|
|
||||||
✅ color_porte.jpg (compressed)
|
|
||||||
|
|
||||||
🪶 Textures (roughness)
|
|
||||||
✅ roughness_tuyaux.png (compressed)
|
|
||||||
|
|
||||||
🧩 Assets
|
|
||||||
✅ model.bin
|
|
||||||
✅ opacity_fenetre.png (compressed)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Update (only one texture changed):**
|
**Update (only one texture changed):**
|
||||||
@@ -173,10 +139,7 @@ update: upload-gltf add a new model -> my-model
|
|||||||
update: upload-gltf update -> coffeetest
|
update: upload-gltf update -> coffeetest
|
||||||
|
|
||||||
📦 Model
|
📦 Model
|
||||||
↔️ model.gltf
|
🔄 model.glb (compressed)
|
||||||
|
|
||||||
🎨 Textures (color)
|
|
||||||
🔄 color_tuyaux.jpg (compressed)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Commit sections:
|
Commit sections:
|
||||||
@@ -192,14 +155,16 @@ Commit sections:
|
|||||||
Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` deleted
|
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
|
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` when Blender compression succeeds. If Blender fails, the app falls back to separate `model.gltf`, `.bin`, and one delivered texture per source: `.ktx2` when possible, `.webp` when KTX2 fails, or the original file when both optimizations fail. "Envoyer en GLTF" always uses separate GLTF delivery.
|
||||||
|
|
||||||
Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
|
Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Large uploads are faster than before because the folder is staged only once, but the Drive upload remains sequential.
|
- Large uploads are staged once, but the Drive upload remains sequential.
|
||||||
- Git LFS uploads are still 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.
|
||||||
|
- KTX2 is currently applied to separate GLTF delivery only. Embedding KTX2 inside the default GLB would require a glTF-aware optimizer step after Blender, not just image preprocessing before Blender.
|
||||||
- Uploads expect a single `model.gltf` file plus optional flat support files (`.bin`, `.png`, `.jpg`, `.jpeg`, `.webp`).
|
- Uploads expect a single `model.gltf` file plus optional flat support files (`.bin`, `.png`, `.jpg`, `.jpeg`, `.webp`).
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -210,7 +175,7 @@ app/
|
|||||||
│ ├── stage/route.ts # POST: upload folder once to temporary staging
|
│ ├── stage/route.ts # POST: upload folder once to temporary staging
|
||||||
│ ├── check/route.ts # POST: prepare staged Git assets and compare with remote files
|
│ ├── check/route.ts # POST: prepare staged Git assets and compare with remote files
|
||||||
│ ├── drive/route.ts # POST: upload staged originals to Nextcloud Drive (VF/Vx versioning)
|
│ ├── drive/route.ts # POST: upload staged originals to Nextcloud Drive (VF/Vx versioning)
|
||||||
│ └── git/route.ts # POST: push staged prepared assets to GitHub
|
│ └── git/route.ts # POST: push staged prepared assets to the Git remote
|
||||||
├── globals.css # Tailwind + CSS variable fonts
|
├── globals.css # Tailwind + CSS variable fonts
|
||||||
├── layout.tsx # Root layout (next/font/google)
|
├── layout.tsx # Root layout (next/font/google)
|
||||||
└── page.tsx # Home page
|
└── page.tsx # Home page
|
||||||
@@ -224,13 +189,14 @@ components/
|
|||||||
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
|
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
|
||||||
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
|
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
|
||||||
│ ├── WarningBanner.tsx # Missing texture warnings
|
│ ├── WarningBanner.tsx # Missing texture warnings
|
||||||
|
│ ├── TextureDiagnosticsPanel.tsx # Texture loading/export diagnostics
|
||||||
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
|
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
|
||||||
│ ├── NoChangesModal.tsx # "No changes detected" dialog
|
│ ├── NoChangesModal.tsx # "No changes detected" dialog
|
||||||
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
|
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
|
||||||
│ └── ActionButtons.tsx # Upload / Cancel / Reset buttons
|
│ └── ActionButtons.tsx # Upload / Cancel / Reset buttons
|
||||||
├── UploadZone.tsx # Main upload page (rendering only)
|
├── UploadZone.tsx # Main upload page (rendering only)
|
||||||
├── ModelViewer.tsx # Lazy wrapper for 3D viewer
|
├── ModelViewer.tsx # 3D viewer shell, stats panel, and hierarchy panel
|
||||||
└── SceneViewer.tsx # Three.js Canvas
|
└── SceneViewer.tsx # Three.js Canvas, model stats, and scene hierarchy extraction
|
||||||
hooks/
|
hooks/
|
||||||
├── useSecret.ts # Secret key state management
|
├── useSecret.ts # Secret key state management
|
||||||
├── useFolderEntries.ts # Folder entries state management
|
├── useFolderEntries.ts # Folder entries state management
|
||||||
@@ -238,23 +204,170 @@ hooks/
|
|||||||
lib/
|
lib/
|
||||||
├── constants.ts # Shared constants and extensions
|
├── constants.ts # Shared constants and extensions
|
||||||
├── types.ts # Server types (ParsedFile, FileDiff, staged asset metadata, etc.)
|
├── types.ts # Server types (ParsedFile, FileDiff, staged asset metadata, etc.)
|
||||||
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
|
├── client-types.ts # Client types (FolderEntry, DriveStatus, viewer contracts, etc.)
|
||||||
├── upload-api.ts # Client-side API helpers (stage, check, uploadDrive, uploadGit)
|
├── upload-api.ts # Client-side API helpers (stage, check, uploadDrive, uploadGit)
|
||||||
|
├── guards.ts # Shared runtime guards and error message helpers
|
||||||
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
|
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
|
||||||
├── sanitize.ts # Filename sanitization
|
├── sanitize.ts # Filename sanitization
|
||||||
├── auth.ts # Upload secret validation (timing-safe)
|
├── auth.ts # Upload secret validation (timing-safe)
|
||||||
├── github.ts # Octokit helpers (getRemoteFolder, pushAllToGitHub)
|
├── git/ # Git provider layer selected by env
|
||||||
|
│ ├── config.ts # GIT_PROVIDER/GIT_REPO_URL/GIT_TOKEN parsing
|
||||||
|
│ ├── content.ts # Shared remote folder/file content helpers
|
||||||
|
│ ├── http.ts # Shared Git API request helpers
|
||||||
|
│ ├── index.ts # Public getRemoteFolder/pushAllToGit facade
|
||||||
|
│ ├── lfs.ts # Shared Git LFS upload helpers
|
||||||
|
│ └── providers/
|
||||||
|
│ ├── gitea.ts # Gitea Contents API implementation
|
||||||
|
│ └── github.ts # GitHub Git Data API implementation
|
||||||
├── nextcloud.ts # Nextcloud WebDAV client (native fetch, cached config)
|
├── nextcloud.ts # Nextcloud WebDAV client (native fetch, cached config)
|
||||||
├── upload-staging.ts # Temporary server-side staging and prepared asset reuse
|
├── upload-staging.ts # Temporary server-side staging and prepared asset reuse
|
||||||
├── upload-lock.ts # Lightweight in-memory per-folder upload lock
|
├── upload-lock.ts # Lightweight in-memory per-folder upload lock
|
||||||
├── asset-classification.ts # Group assets by family for commit messages
|
├── asset-classification.ts # Group assets by family for commit messages
|
||||||
├── asset-naming.ts # Allowed asset families and naming convention helpers
|
├── asset-naming.ts # Allowed asset families and naming convention helpers
|
||||||
|
├── blender.ts # Blender Draco compression helper
|
||||||
|
├── texture-compression.ts # KTX2/WebP texture delivery helpers
|
||||||
├── commit-message.ts # Commit message builder
|
├── commit-message.ts # Commit message builder
|
||||||
├── parse-upload.ts # FormData parser + validation
|
├── parse-upload.ts # FormData parser + validation
|
||||||
├── validate-folder.ts # Client-side folder validation (discriminated union)
|
├── validate-folder.ts # Client-side folder validation (discriminated union)
|
||||||
└── format-bytes.ts # Byte formatting utility
|
└── format-bytes.ts # Byte formatting utility
|
||||||
Dockerfile # Multi-stage build: Node 20 slim + tini
|
scripts/
|
||||||
docker-entrypoint.sh # Startup check + launch
|
└── compress.py # Blender Draco compression script
|
||||||
|
Dockerfile # Multi-stage build: Node 20 slim + Blender + KTX-Software + tini
|
||||||
|
docker-entrypoint.sh # Upload temp setup + Blender/toktx availability check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/La-Fabrik-Durable/upload-GLTF.git
|
||||||
|
cd upload-GLTF
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env.local` and fill in the values:
|
||||||
|
|
||||||
|
```env
|
||||||
|
UPLOAD_SECRET_KEY=your-secret-key-here
|
||||||
|
GIT_PROVIDER=gitea
|
||||||
|
GIT_USERNAME=your-gitea-username
|
||||||
|
GIT_TOKEN=your-git-provider-token
|
||||||
|
GIT_BRANCH=main
|
||||||
|
GIT_REPO_URL=https://git.example.com/your-org/your-repo
|
||||||
|
# Optional texture optimization
|
||||||
|
TOKTX_PATH=toktx
|
||||||
|
TEXTURE_MAX_SIZE=1024
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Description | Required |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
|
||||||
|
| `GIT_PROVIDER` | Git provider adapter to use: `github` or `gitea`. If omitted, it is inferred from `GIT_REPO_URL` (`github.com` → GitHub, anything else → Gitea). | No |
|
||||||
|
| `GIT_USERNAME` | Git username for Git LFS Basic auth on Gitea. Required for Gitea when LFS files are uploaded. | Gitea LFS |
|
||||||
|
| `GIT_TOKEN` | Git provider token with repository read/write access. `GITHUB_TOKEN` is still accepted for backward compatibility. | Yes |
|
||||||
|
| `GIT_BRANCH` | Target branch (default: main) | No |
|
||||||
|
| `GIT_REPO_URL` | Target GitHub or Gitea repository URL (`owner/repo`, HTTPS, or SSH) | Yes |
|
||||||
|
| `TOKTX_PATH` | Path to the `toktx` binary used for KTX2 texture generation (default: `toktx`). | No |
|
||||||
|
| `TEXTURE_MAX_SIZE` | Maximum texture dimension used for KTX2/WebP separate GLTF delivery (default: `1024`). | No |
|
||||||
|
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
|
||||||
|
| `NEXTCLOUD_SHARE_TOKEN` | Public share token (the part after `/s/` in the share link) | Yes |
|
||||||
|
| `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No |
|
||||||
|
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
|
||||||
|
|
||||||
|
> GitHub tokens need `Contents: Read and write`. Gitea tokens need repository read/write access.
|
||||||
|
|
||||||
|
### Git provider selection
|
||||||
|
|
||||||
|
The upload routes call a small provider layer in `lib/git/`:
|
||||||
|
|
||||||
|
- `lib/git/config.ts` reads `GIT_PROVIDER`, `GIT_REPO_URL`, `GIT_TOKEN`, `GIT_USERNAME`, and `GIT_BRANCH`
|
||||||
|
- `lib/git/providers/github.ts` handles GitHub commits with the Git Data API
|
||||||
|
- `lib/git/providers/gitea.ts` handles Gitea commits with the Contents API
|
||||||
|
- `lib/git/lfs.ts` handles Git LFS upload/auth for both providers
|
||||||
|
|
||||||
|
For GitHub:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GIT_PROVIDER=github
|
||||||
|
GIT_TOKEN=ghp_xxx
|
||||||
|
GIT_REPO_URL=https://github.com/org/repo.git
|
||||||
|
GIT_BRANCH=main
|
||||||
|
```
|
||||||
|
|
||||||
|
For Gitea:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GIT_PROVIDER=gitea
|
||||||
|
GIT_USERNAME=your-gitea-username
|
||||||
|
GIT_TOKEN=token_xxx
|
||||||
|
GIT_REPO_URL=https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik
|
||||||
|
GIT_BRANCH=main
|
||||||
|
```
|
||||||
|
|
||||||
|
> To create a Nextcloud public share token: Nextcloud > Files > select folder > Share > Create public share > set permissions (write access required) > copy the share link and extract the token
|
||||||
|
|
||||||
|
### Production (Coolify / Docker)
|
||||||
|
|
||||||
|
Coolify must build this repository with the included `Dockerfile`. The dependency stage copies `.npmrc` and runs `npm ci --ignore-scripts --no-audit --no-fund`, so the deployed dependency tree comes from `package-lock.json`.
|
||||||
|
|
||||||
|
After a security patch:
|
||||||
|
|
||||||
|
1. Push the commit to both remotes.
|
||||||
|
2. In Coolify, trigger a rebuild with cache disabled when possible.
|
||||||
|
3. Confirm the build logs show `npm ci --ignore-scripts --no-audit --no-fund`.
|
||||||
|
4. Confirm the app starts and the upload flow still reaches staging, Drive, and Git.
|
||||||
|
5. Rotate secrets in Coolify, then redeploy once more with the new values.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t upload-gltf .
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e UPLOAD_SECRET_KEY=your-key \
|
||||||
|
-e GIT_PROVIDER=gitea \
|
||||||
|
-e GIT_USERNAME=your-gitea-username \
|
||||||
|
-e GIT_TOKEN=token_xxx \
|
||||||
|
-e GIT_REPO_URL=https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik \
|
||||||
|
-e NEXTCLOUD_URL=https://cloud.example.com \
|
||||||
|
-e NEXTCLOUD_SHARE_TOKEN=your-share-token \
|
||||||
|
upload-gltf
|
||||||
|
```
|
||||||
|
|
||||||
|
The Docker image runs the Next.js app, Blender Draco compression, KTX2 texture generation, and server-side asset preparation in a single container. The `docker-entrypoint.sh` script creates the upload temp directory and reports Blender/toktx availability before launching the app.
|
||||||
|
|
||||||
|
### Secret rotation
|
||||||
|
|
||||||
|
Rotate secrets after patching if the previous deployment exposed a vulnerable Next.js version, if a suspicious dependency install happened on a machine with credentials, or if you cannot prove the install host was clean.
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Generate a new `GIT_TOKEN` limited to the target model repository with read/write access.
|
||||||
|
2. Generate a new long random `UPLOAD_SECRET_KEY`.
|
||||||
|
3. Regenerate the Nextcloud public share token or password when possible.
|
||||||
|
4. Update the variables in Coolify.
|
||||||
|
5. Redeploy the patched image.
|
||||||
|
6. Revoke the old Git token and old Nextcloud share credentials after the new deployment is healthy.
|
||||||
|
|
||||||
|
Do not commit real `.env` files. `.dockerignore` excludes `.env` and `.env.*`, while keeping `.env.example` as documentation.
|
||||||
|
|
||||||
|
### Publishing to remotes
|
||||||
|
|
||||||
|
This repo is mirrored to GitHub and Gitea:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
git push origin main
|
||||||
|
git push gitea main
|
||||||
|
```
|
||||||
|
|
||||||
|
If your local git config has the `pushall` alias, it should be equivalent to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main && git push gitea main
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Formats
|
## Supported Formats
|
||||||
@@ -263,8 +376,13 @@ docker-entrypoint.sh # Startup check + launch
|
|||||||
|------|------------|
|
|------|------------|
|
||||||
| 3D Models | `.gltf` |
|
| 3D Models | `.gltf` |
|
||||||
| Binary buffers | `.bin` |
|
| Binary buffers | `.bin` |
|
||||||
| Textures | `.png`, `.jpg`, `.jpeg`, `.webp` |
|
| Input textures | `.png`, `.jpg`, `.jpeg`, `.webp` |
|
||||||
|
| Generated Git textures | `.ktx2`, `.webp`, or original source extension as fallback |
|
||||||
|
|
||||||
|
Git delivery outputs `.glb` by default, or keeps the source `.gltf` structure when "Envoyer en GLTF" is selected. Separate GLTF delivery tries KTX2 first, then WebP, then the original texture. When KTX2 is selected, `model.gltf` references it through `KHR_texture_basisu`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
See [MIT](LICENSE).
|
||||||
|
|
||||||
|
Copyright 2026 La Fabrik Durable. All rights reserved.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { validateUploadSecret } from '@/lib/auth'
|
import { validateUploadSecret } from '@/lib/auth'
|
||||||
import { getRemoteFolder } from '@/lib/github'
|
import { getRemoteFolder } from '@/lib/git'
|
||||||
import { classifyFileChanges } from '@/lib/diff-files'
|
import { classifyFileChanges } from '@/lib/diff-files'
|
||||||
import { getModelFolderPath } from '@/lib/model-paths'
|
import { getModelFolderPath } from '@/lib/model-paths'
|
||||||
import { ensurePreparedStagingAssets } from '@/lib/upload-staging'
|
import { ensurePreparedStagingAssets } from '@/lib/upload-staging'
|
||||||
import { parseStagingRequestBody } from '@/lib/upload-request'
|
import { readStagingRequestBody, uploadErrorResponse } from '@/lib/upload-request'
|
||||||
|
import type { FileDiff } from '@/lib/types'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -20,15 +21,13 @@ export async function POST(req: NextRequest) {
|
|||||||
let stagingId: string
|
let stagingId: string
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: unknown = await req.json()
|
stagingId = (await readStagingRequestBody(req)).stagingId
|
||||||
stagingId = parseStagingRequestBody(body).stagingId
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { folderName, filesToPush } = await ensurePreparedStagingAssets(stagingId)
|
const { folderName, filesToPush, deliveryMode, compressionError } = await ensurePreparedStagingAssets(stagingId)
|
||||||
const folderPath = getModelFolderPath(folderName)
|
const folderPath = getModelFolderPath(folderName)
|
||||||
const { exists, files } = await getRemoteFolder(folderPath)
|
const { exists, files } = await getRemoteFolder(folderPath)
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const remoteFileMap = new Map(files.map((file) => [file.name.toLowerCase(), file.size]))
|
const remoteFileMap = new Map(files.map((file) => [file.name.toLowerCase(), file.size]))
|
||||||
const { fileChanges, deletedFileNames } = classifyFileChanges(filesToPush, remoteFileMap, folderPath)
|
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()) {
|
for (const [name, status] of fileChanges.entries()) {
|
||||||
if (status === 'new' || status === 'changed') {
|
if (status === 'new' || status === 'changed') {
|
||||||
@@ -51,12 +50,18 @@ export async function POST(req: NextRequest) {
|
|||||||
exists: true,
|
exists: true,
|
||||||
path: folderPath,
|
path: folderPath,
|
||||||
diffs,
|
diffs,
|
||||||
|
deliveryMode,
|
||||||
|
compressionError,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, exists: false })
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
exists: false,
|
||||||
|
deliveryMode,
|
||||||
|
compressionError,
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
return uploadErrorResponse(err, 500)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,64 +8,46 @@ import {
|
|||||||
findNextVersion,
|
findNextVersion,
|
||||||
} from '@/lib/nextcloud'
|
} from '@/lib/nextcloud'
|
||||||
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
||||||
import { parseDriveRequestBody } from '@/lib/upload-request'
|
import {
|
||||||
|
readDriveRequestBody,
|
||||||
|
uploadErrorMessageResponse,
|
||||||
|
uploadErrorResponse,
|
||||||
|
uploadLockConflictResponse,
|
||||||
|
} from '@/lib/upload-request'
|
||||||
|
import { getErrorMessage } from '@/lib/guards'
|
||||||
|
import type { DriveAction } from '@/lib/types'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /api/upload/drive
|
|
||||||
//
|
|
||||||
// Upload **original** files to Nextcloud Drive.
|
|
||||||
//
|
|
||||||
// JSON body:
|
|
||||||
// - stagingId
|
|
||||||
// - action: "new" | "replace"
|
|
||||||
//
|
|
||||||
// Versioning logic:
|
|
||||||
// VF/{folderName} <- latest version
|
|
||||||
// V1/{folderName} <- first archive, V2/ second, etc.
|
|
||||||
//
|
|
||||||
// action="new" -> just mkdir + upload into VF/
|
|
||||||
// action="replace" -> archive VF -> Vx, then re-upload all files into VF/
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// --- Auth ---
|
|
||||||
const authError = validateUploadSecret(req)
|
const authError = validateUploadSecret(req)
|
||||||
if (authError) return authError
|
if (authError) return authError
|
||||||
|
|
||||||
// --- Check Nextcloud config ---
|
|
||||||
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) {
|
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) {
|
||||||
return NextResponse.json(
|
return uploadErrorMessageResponse(
|
||||||
{ success: false, error: 'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)' },
|
'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)',
|
||||||
{ status: 500 },
|
500,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Parse staging request ---
|
|
||||||
let folderName: string
|
let folderName: string
|
||||||
let parsedFiles: Awaited<ReturnType<typeof readStagedOriginalFiles>>['files']
|
let parsedFiles: Awaited<ReturnType<typeof readStagedOriginalFiles>>['files']
|
||||||
let action: 'new' | 'replace'
|
let action: DriveAction
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: unknown = await req.json()
|
const parsedBody = await readDriveRequestBody(req)
|
||||||
const parsedBody = parseDriveRequestBody(body)
|
|
||||||
action = parsedBody.action
|
action = parsedBody.action
|
||||||
const stagingId = parsedBody.stagingId
|
const stagingId = parsedBody.stagingId
|
||||||
const staged = await readStagedOriginalFiles(stagingId)
|
const staged = await readStagedOriginalFiles(stagingId)
|
||||||
folderName = staged.folderName
|
folderName = staged.folderName
|
||||||
parsedFiles = staged.files
|
parsedFiles = staged.files
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!acquireUploadLock(folderName)) {
|
if (!acquireUploadLock(folderName)) {
|
||||||
return NextResponse.json(
|
return uploadLockConflictResponse()
|
||||||
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
|
|
||||||
{ status: 409 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
||||||
@@ -73,23 +55,17 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === 'replace') {
|
if (action === 'replace') {
|
||||||
// 1. Find the next available Vx
|
|
||||||
const nextVersion = await findNextVersion(basePath, folderName)
|
const nextVersion = await findNextVersion(basePath, folderName)
|
||||||
|
|
||||||
// 2. Ensure Vx/ exists
|
|
||||||
await mkdirRecursive(`${basePath}/${nextVersion}`)
|
await mkdirRecursive(`${basePath}/${nextVersion}`)
|
||||||
|
|
||||||
// 3. Move VF/{folderName} -> Vx/{folderName}
|
|
||||||
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`)
|
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`)
|
||||||
|
|
||||||
// 4. Re-create VF/{folderName}
|
|
||||||
await mkdirRecursive(vfFolderPath)
|
await mkdirRecursive(vfFolderPath)
|
||||||
} else {
|
} else {
|
||||||
// action === 'new': just ensure VF/{folderName} exists
|
|
||||||
await mkdirRecursive(vfFolderPath)
|
await mkdirRecursive(vfFolderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Upload all original files ---
|
|
||||||
for (const pf of parsedFiles) {
|
for (const pf of parsedFiles) {
|
||||||
const remotePath = `${vfFolderPath}/${pf.filename}`
|
const remotePath = `${vfFolderPath}/${pf.filename}`
|
||||||
await uploadFile(remotePath, pf.buffer)
|
await uploadFile(remotePath, pf.buffer)
|
||||||
@@ -102,11 +78,8 @@ export async function POST(req: NextRequest) {
|
|||||||
message: `${parsedFiles.length} fichier(s) envoye(s) sur le Drive.`,
|
message: `${parsedFiles.length} fichier(s) envoye(s) sur le Drive.`,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur Nextcloud inconnue'
|
const message = getErrorMessage(err, 'Erreur Nextcloud inconnue')
|
||||||
return NextResponse.json(
|
return uploadErrorMessageResponse(`Drive echoue: ${message}`, 500)
|
||||||
{ success: false, error: `Drive echoue: ${message}` },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseUploadLock(folderName)
|
releaseUploadLock(folderName)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-28
@@ -1,22 +1,36 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { validateUploadSecret } from '@/lib/auth'
|
import { validateUploadSecret } from '@/lib/auth'
|
||||||
import { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
|
import { getRemoteFolder, pushAllToGit } from '@/lib/git'
|
||||||
import { buildCommitMessage } from '@/lib/commit-message'
|
import { buildCommitMessage } from '@/lib/commit-message'
|
||||||
import { classifyFileChanges } from '@/lib/diff-files'
|
import { classifyFileChanges } from '@/lib/diff-files'
|
||||||
import { getModelFolderPath } from '@/lib/model-paths'
|
import { getModelFolderPath } from '@/lib/model-paths'
|
||||||
import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging'
|
import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging'
|
||||||
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
||||||
import { parseStagingRequestBody } from '@/lib/upload-request'
|
import {
|
||||||
|
readStagingRequestBody,
|
||||||
|
uploadErrorMessageResponse,
|
||||||
|
uploadErrorResponse,
|
||||||
|
uploadLockConflictResponse,
|
||||||
|
} from '@/lib/upload-request'
|
||||||
|
import { getErrorMessage } from '@/lib/guards'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
async function cleanupCompletedStagingUpload(stagingId: string) {
|
||||||
|
await cleanupStagingUpload(stagingId).catch((err) => {
|
||||||
|
console.warn('[WARN] Git upload -> staging cleanup failed', {
|
||||||
|
stagingId,
|
||||||
|
error: getErrorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/upload/git
|
* POST /api/upload/git
|
||||||
* Upload prepared files and push to GitHub via Octokit.
|
* Upload prepared files and push to the configured Git remote via Octokit.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// --- Auth ---
|
|
||||||
const authError = validateUploadSecret(req)
|
const authError = validateUploadSecret(req)
|
||||||
if (authError) return authError
|
if (authError) return authError
|
||||||
|
|
||||||
@@ -24,33 +38,27 @@ export async function POST(req: NextRequest) {
|
|||||||
let stagingId: string
|
let stagingId: string
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: unknown = await req.json()
|
stagingId = (await readStagingRequestBody(req)).stagingId
|
||||||
stagingId = parseStagingRequestBody(body).stagingId
|
|
||||||
const manifest = await readStagedManifest(stagingId)
|
const manifest = await readStagedManifest(stagingId)
|
||||||
folderName = manifest.folderName
|
folderName = manifest.folderName
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!acquireUploadLock(folderName)) {
|
if (!acquireUploadLock(folderName)) {
|
||||||
return NextResponse.json(
|
return uploadLockConflictResponse()
|
||||||
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
|
|
||||||
{ status: 409 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- Process files (preserve model + buffers, compress textures for Git) ---
|
|
||||||
const {
|
const {
|
||||||
filesToPush,
|
filesToPush,
|
||||||
modelFilename,
|
modelFilename,
|
||||||
compressed,
|
compressed,
|
||||||
|
deliveryMode,
|
||||||
compressionError,
|
compressionError,
|
||||||
assetSummaries,
|
assetSummaries,
|
||||||
} = await ensurePreparedStagingAssets(stagingId)
|
} = await ensurePreparedStagingAssets(stagingId)
|
||||||
|
|
||||||
// --- Detect existing files and classify changes ---
|
|
||||||
const folderPath = getModelFolderPath(folderName)
|
const folderPath = getModelFolderPath(folderName)
|
||||||
const remote = await getRemoteFolder(folderPath)
|
const remote = await getRemoteFolder(folderPath)
|
||||||
const remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size]))
|
const remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size]))
|
||||||
@@ -60,20 +68,19 @@ export async function POST(req: NextRequest) {
|
|||||||
const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
|
const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
|
||||||
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
|
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
|
||||||
|
|
||||||
// If nothing changed, don't create an empty commit
|
|
||||||
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
|
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
|
||||||
await cleanupStagingUpload(stagingId).catch(() => {})
|
await cleanupCompletedStagingUpload(stagingId)
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
folderName,
|
folderName,
|
||||||
filesCount: 0,
|
filesCount: 0,
|
||||||
compressed,
|
compressed,
|
||||||
|
deliveryMode,
|
||||||
compressionError: compressionError || undefined,
|
compressionError: compressionError || undefined,
|
||||||
message: 'Aucun fichier modifie — rien a envoyer.',
|
message: 'Aucun fichier modifie — rien a envoyer.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Build commit message ---
|
|
||||||
const commitMessage = buildCommitMessage(
|
const commitMessage = buildCommitMessage(
|
||||||
folderName,
|
folderName,
|
||||||
modelFilename,
|
modelFilename,
|
||||||
@@ -83,27 +90,22 @@ export async function POST(req: NextRequest) {
|
|||||||
deletedFileNames,
|
deletedFileNames,
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Push all in one commit ---
|
const { commitUrl } = await pushAllToGit(changedFilesToPush, deletePaths, commitMessage)
|
||||||
try {
|
await cleanupCompletedStagingUpload(stagingId)
|
||||||
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
|
|
||||||
await cleanupStagingUpload(stagingId).catch(() => {})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
folderName,
|
folderName,
|
||||||
filesCount: changedFilesToPush.length,
|
filesCount: changedFilesToPush.length,
|
||||||
compressed,
|
compressed,
|
||||||
|
deliveryMode,
|
||||||
compressionError: compressionError || undefined,
|
compressionError: compressionError || undefined,
|
||||||
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`,
|
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur Git en un seul commit.`,
|
||||||
commitUrl,
|
commitUrl,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue'
|
const message = getErrorMessage(err, 'Erreur Git inconnue')
|
||||||
return NextResponse.json(
|
return uploadErrorMessageResponse(`Upload Git echoue: ${message}`, 500)
|
||||||
{ success: false, error: `Push GitHub echoue: ${message}` },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseUploadLock(folderName)
|
releaseUploadLock(folderName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { validateUploadSecret } from '@/lib/auth'
|
import { validateUploadSecret } from '@/lib/auth'
|
||||||
import { parseMultiUpload } from '@/lib/parse-upload'
|
import { parseMultiUpload } from '@/lib/parse-upload'
|
||||||
import { createStagingUpload } from '@/lib/upload-staging'
|
import { createStagingUpload } from '@/lib/upload-staging'
|
||||||
|
import { uploadErrorResponse } from '@/lib/upload-request'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -12,10 +13,9 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = await parseMultiUpload(req)
|
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 })
|
return NextResponse.json({ success: true, ...staged })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Upload GLTF',
|
title: 'Upload GLTF',
|
||||||
description: 'Interface de depot securise pour fichiers 3D (.gltf) avec versionnement automatique sur GitHub',
|
description: 'Interface de depot securise pour fichiers 3D (.gltf) avec versionnement automatique sur Git',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@ export default function Home() {
|
|||||||
Upload GLTF
|
Upload GLTF
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-base leading-relaxed">
|
<p className="text-gray-400 text-base leading-relaxed">
|
||||||
Deposez vos fichiers 3D — ils seront archives sur le Drive
|
Deposez vos fichiers 3D, ils seront archives sur le Drive
|
||||||
<br />avec versioning, puis envoyes aux devs via GitHub
|
avec versioning, puis envoyes aux devs via Git
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+202
-34
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { Component, useEffect, useState } from 'react'
|
||||||
import type { ModelStats } from './SceneViewer'
|
import type { ComponentType, ReactNode } from 'react'
|
||||||
|
import type { ModelHierarchyNode, ModelStats, SceneViewerProps } from '@/lib/client-types'
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -10,30 +11,194 @@ interface ModelViewerProps {
|
|||||||
size: string
|
size: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIEWER_FRAME_CLASS = 'w-full h-[450px] bg-black-800 border border-white/20 rounded-xl overflow-hidden'
|
||||||
|
const CENTERED_VIEWER_FRAME_CLASS = `${VIEWER_FRAME_CLASS} flex items-center justify-center`
|
||||||
|
|
||||||
|
const MODEL_STAT_ROWS = [
|
||||||
|
{ label: 'Draw calls', getValue: (stats: ModelStats) => stats.drawCalls },
|
||||||
|
{ label: 'Children', getValue: (stats: ModelStats) => stats.childObjects },
|
||||||
|
{ label: 'Meshes', getValue: (stats: ModelStats) => stats.meshes },
|
||||||
|
{ label: 'Triangles', getValue: (stats: ModelStats) => stats.triangles.toLocaleString('fr-FR') },
|
||||||
|
{ label: 'Materials', getValue: (stats: ModelStats) => stats.materials },
|
||||||
|
{ label: 'Textures', getValue: (stats: ModelStats) => stats.textures },
|
||||||
|
] satisfies Array<{
|
||||||
|
label: string
|
||||||
|
getValue: (stats: ModelStats) => number | string
|
||||||
|
}>
|
||||||
|
|
||||||
|
function getPreviewErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : 'Erreur preview inconnue'
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewFallback({ message }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center px-6 text-center">
|
||||||
|
<div className="max-w-sm space-y-2">
|
||||||
|
<p className="text-sm font-medium text-gray-300">Preview 3D indisponible pour ce modele.</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
L'upload reste possible. {message ? `Detail technique : ${message}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTransformValue(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTransform(values: [number, number, number]) {
|
||||||
|
return `[${values.map(formatTransformValue).join(', ')}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHierarchyNodes(node: ModelHierarchyNode): number {
|
||||||
|
return 1 + node.children.reduce((count, child) => count + countHierarchyNodes(child), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HierarchyTree({
|
||||||
|
node,
|
||||||
|
depth = 0,
|
||||||
|
}: {
|
||||||
|
node: ModelHierarchyNode
|
||||||
|
depth?: number
|
||||||
|
}) {
|
||||||
|
const hasChildren = node.children.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 py-1 text-xs"
|
||||||
|
style={{ paddingLeft: `${depth * 14}px` }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span className="w-3 shrink-0 text-gray-500">{hasChildren ? 'v' : '-'}</span>
|
||||||
|
<span className="truncate font-semibold text-gray-200">{node.name}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-gray-500">{node.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 mt-0.5 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[10px] text-gray-600">
|
||||||
|
<span>pos {formatTransform(node.position)}</span>
|
||||||
|
<span>rot {formatTransform(node.rotation)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full border px-2 py-0.5 text-[10px] ${
|
||||||
|
node.visible
|
||||||
|
? 'border-white/10 bg-white/5 text-gray-400'
|
||||||
|
: 'border-red-500/20 bg-red-500/10 text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node.visible ? 'Visible' : 'Hidden'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="border-l border-white/10">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<HierarchyTree key={child.id} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HierarchyPanel({
|
||||||
|
hierarchy,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
hierarchy: ModelHierarchyNode
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const nodeCount = countHierarchyNodes(hierarchy)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-y-3 right-3 z-30 flex w-[min(22rem,calc(100%-1.5rem))] flex-col overflow-hidden rounded-xl border border-white/15 bg-black-900/90 text-gray-300 shadow-2xl backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-100">Hierarchy</p>
|
||||||
|
<p className="text-[10px] text-gray-600">{nodeCount} nodes</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full border border-white/10 px-2 py-1 text-xs text-gray-400 transition hover:bg-white/10 hover:text-gray-100"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto px-3 py-2">
|
||||||
|
<HierarchyTree node={hierarchy} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelStatsPanel({ stats }: { stats: ModelStats }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-lg border border-white/10 bg-black-900/75 p-2 text-xs text-gray-300 backdrop-blur">
|
||||||
|
{MODEL_STAT_ROWS.map(({ label, getValue }) => (
|
||||||
|
<span key={label} className="flex justify-between gap-3">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="font-mono text-gray-200">{getValue(stats)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreviewErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode },
|
||||||
|
{ message: string | null }
|
||||||
|
> {
|
||||||
|
state = { message: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: unknown) {
|
||||||
|
return { message: getPreviewErrorMessage(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: unknown) {
|
||||||
|
console.error('[ERROR] Preview 3D indisponible', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.message) {
|
||||||
|
return <PreviewFallback message={this.state.message} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
|
export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
|
||||||
const canPreview = filename.toLowerCase().endsWith('.gltf')
|
const canPreview = filename.toLowerCase().endsWith('.gltf')
|
||||||
const [stats, setStats] = useState<ModelStats | null>(null)
|
const [stats, setStats] = useState<ModelStats | null>(null)
|
||||||
const [Scene, setScene] = useState<React.ComponentType<{
|
const [hierarchy, setHierarchy] = useState<ModelHierarchyNode | null>(null)
|
||||||
url: string
|
const [hierarchyOpen, setHierarchyOpen] = useState(false)
|
||||||
assetUrls: Record<string, string>
|
const [sceneError, setSceneError] = useState<string | null>(null)
|
||||||
onStatsReady: (stats: ModelStats) => void
|
const [Scene, setScene] = useState<ComponentType<SceneViewerProps> | null>(null)
|
||||||
}> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canPreview) return
|
if (!canPreview) return
|
||||||
|
|
||||||
let cancel = false
|
let cancel = false
|
||||||
|
setSceneError(null)
|
||||||
|
setStats(null)
|
||||||
|
setHierarchy(null)
|
||||||
|
setHierarchyOpen(false)
|
||||||
|
|
||||||
import('./SceneViewer').then((mod) => {
|
import('./SceneViewer')
|
||||||
|
.then((mod) => {
|
||||||
if (!cancel) setScene(() => mod.default)
|
if (!cancel) setScene(() => mod.default)
|
||||||
})
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!cancel) setSceneError(getPreviewErrorMessage(error))
|
||||||
|
})
|
||||||
|
|
||||||
return () => { cancel = true }
|
return () => { cancel = true }
|
||||||
}, [canPreview])
|
}, [canPreview, url])
|
||||||
|
|
||||||
if (!canPreview) {
|
if (!canPreview) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[450px] bg-black-800 border border-white/20 rounded-xl overflow-hidden flex items-center justify-center">
|
<div className={CENTERED_VIEWER_FRAME_CLASS}>
|
||||||
<p className="text-sm text-gray-400 px-6 text-center">
|
<p className="text-sm text-gray-400 px-6 text-center">
|
||||||
La preview 3D locale n'est pas disponible pour les dossiers <span className="font-mono">model.gltf</span> avec fichiers associes.
|
La preview 3D locale n'est pas disponible pour les dossiers <span className="font-mono">model.gltf</span> avec fichiers associes.
|
||||||
</p>
|
</p>
|
||||||
@@ -43,14 +208,14 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
|
|||||||
|
|
||||||
if (!Scene) {
|
if (!Scene) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[450px] bg-black-800 border border-white/20 rounded-xl overflow-hidden flex items-center justify-center">
|
<div className={CENTERED_VIEWER_FRAME_CLASS}>
|
||||||
<div className="w-6 h-6 border-2 border-gray-500 border-t-gray-300 rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-gray-500 border-t-gray-300 rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[450px] bg-black-800 border border-white/20 rounded-xl overflow-hidden relative">
|
<div className={`${VIEWER_FRAME_CLASS} relative`}>
|
||||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-400 font-mono bg-black-900/60 px-2 py-1 rounded">
|
<span className="text-xs text-gray-400 font-mono bg-black-900/60 px-2 py-1 rounded">
|
||||||
{filename}
|
{filename}
|
||||||
@@ -60,30 +225,33 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="absolute top-3 right-3 z-10 flex w-44 flex-col gap-1.5 rounded-lg border border-white/10 bg-black-900/75 p-2 text-xs text-gray-300 backdrop-blur">
|
<div className="absolute top-3 right-3 z-20 flex w-44 flex-col gap-2">
|
||||||
<span className="flex justify-between gap-3">
|
<ModelStatsPanel stats={stats} />
|
||||||
<span className="text-gray-500">Draw calls</span>
|
<button
|
||||||
<span className="font-mono text-gray-200">{stats.drawCalls}</span>
|
type="button"
|
||||||
</span>
|
disabled={!hierarchy}
|
||||||
<span className="flex justify-between gap-3">
|
onClick={() => setHierarchyOpen((open) => !open)}
|
||||||
<span className="text-gray-500">Meshes</span>
|
className="rounded-lg border border-white/10 bg-black-900/75 px-3 py-1.5 text-xs font-medium text-gray-300 backdrop-blur transition hover:bg-white/10 hover:text-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
<span className="font-mono text-gray-200">{stats.meshes}</span>
|
>
|
||||||
</span>
|
Hierarchy
|
||||||
<span className="flex justify-between gap-3">
|
</button>
|
||||||
<span className="text-gray-500">Triangles</span>
|
|
||||||
<span className="font-mono text-gray-200">{stats.triangles.toLocaleString('fr-FR')}</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex justify-between gap-3">
|
|
||||||
<span className="text-gray-500">Materials</span>
|
|
||||||
<span className="font-mono text-gray-200">{stats.materials}</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex justify-between gap-3">
|
|
||||||
<span className="text-gray-500">Textures</span>
|
|
||||||
<span className="font-mono text-gray-200">{stats.textures}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Scene url={url} assetUrls={assetUrls} onStatsReady={setStats} />
|
{hierarchy && hierarchyOpen && (
|
||||||
|
<HierarchyPanel hierarchy={hierarchy} onClose={() => setHierarchyOpen(false)} />
|
||||||
|
)}
|
||||||
|
{sceneError ? (
|
||||||
|
<PreviewFallback message={sceneError} />
|
||||||
|
) : (
|
||||||
|
<PreviewErrorBoundary key={url}>
|
||||||
|
<Scene
|
||||||
|
url={url}
|
||||||
|
assetUrls={assetUrls}
|
||||||
|
onStatsReady={setStats}
|
||||||
|
onHierarchyReady={setHierarchy}
|
||||||
|
/>
|
||||||
|
</PreviewErrorBoundary>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-26
@@ -7,14 +7,8 @@ import { useLoader } from '@react-three/fiber'
|
|||||||
import { CanvasTexture, Mesh, TextureLoader } from 'three'
|
import { CanvasTexture, Mesh, TextureLoader } from 'three'
|
||||||
import type { Material, Object3D, Texture } from 'three'
|
import type { Material, Object3D, Texture } from 'three'
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||||
|
import { normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
export interface ModelStats {
|
import type { ModelHierarchyNode, ModelStats, SceneViewerProps } from '@/lib/client-types'
|
||||||
drawCalls: number
|
|
||||||
materials: number
|
|
||||||
meshes: number
|
|
||||||
textures: number
|
|
||||||
triangles: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpacityMapEntry {
|
interface OpacityMapEntry {
|
||||||
target: string
|
target: string
|
||||||
@@ -27,6 +21,8 @@ interface AlphaMapMaterial extends Material {
|
|||||||
alphaTest: number
|
alphaTest: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlphaImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap
|
||||||
|
|
||||||
const alphaMapTextureCache = new WeakMap<Texture, Texture>()
|
const alphaMapTextureCache = new WeakMap<Texture, Texture>()
|
||||||
|
|
||||||
function getRequestedFilename(requestedUrl: string) {
|
function getRequestedFilename(requestedUrl: string) {
|
||||||
@@ -55,7 +51,8 @@ function resolveAssetUrl(requestedUrl: string, assetUrls: Record<string, string>
|
|||||||
|
|
||||||
function getOpacityMapEntries(assetUrls: Record<string, string>) {
|
function getOpacityMapEntries(assetUrls: Record<string, string>) {
|
||||||
return Object.entries(assetUrls).reduce<OpacityMapEntry[]>((entries, [filename, url]) => {
|
return Object.entries(assetUrls).reduce<OpacityMapEntry[]>((entries, [filename, url]) => {
|
||||||
const match = filename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
|
const normalizedFilename = normalizeTextureFilename(filename) || filename
|
||||||
|
const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
|
||||||
|
|
||||||
if (!match) return entries
|
if (!match) return entries
|
||||||
|
|
||||||
@@ -88,14 +85,19 @@ function supportsAlphaMap(material: Material): material is AlphaMapMaterial {
|
|||||||
return 'alphaMap' in material
|
return 'alphaMap' in material
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAlphaImageSource(image: unknown): image is AlphaImageSource {
|
||||||
|
return image instanceof HTMLImageElement
|
||||||
|
|| image instanceof HTMLCanvasElement
|
||||||
|
|| (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
function createAlphaMapTexture(texture: Texture) {
|
function createAlphaMapTexture(texture: Texture) {
|
||||||
const cachedTexture = alphaMapTextureCache.get(texture)
|
const cachedTexture = alphaMapTextureCache.get(texture)
|
||||||
if (cachedTexture) return cachedTexture
|
if (cachedTexture) return cachedTexture
|
||||||
|
|
||||||
const image = texture.image as unknown
|
const image: unknown = texture.image
|
||||||
const isImageBitmap = typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap
|
|
||||||
|
|
||||||
if (!(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || isImageBitmap)) {
|
if (!isAlphaImageSource(image)) {
|
||||||
texture.flipY = false
|
texture.flipY = false
|
||||||
alphaMapTextureCache.set(texture, texture)
|
alphaMapTextureCache.set(texture, texture)
|
||||||
return texture
|
return texture
|
||||||
@@ -188,6 +190,7 @@ function getModelStats(scene: Object3D, assetUrls: Record<string, string>): Mode
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
childObjects: scene.children.length,
|
||||||
drawCalls,
|
drawCalls,
|
||||||
materials: materials.size,
|
materials: materials.size,
|
||||||
meshes,
|
meshes,
|
||||||
@@ -196,6 +199,30 @@ function getModelStats(scene: Object3D, assetUrls: Record<string, string>): Mode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundTransformValue(value: number) {
|
||||||
|
return Number(value.toFixed(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectHierarchy(object: Object3D): ModelHierarchyNode {
|
||||||
|
return {
|
||||||
|
children: object.children.map(getObjectHierarchy),
|
||||||
|
id: object.uuid,
|
||||||
|
name: object.name || object.type,
|
||||||
|
position: [
|
||||||
|
roundTransformValue(object.position.x),
|
||||||
|
roundTransformValue(object.position.y),
|
||||||
|
roundTransformValue(object.position.z),
|
||||||
|
],
|
||||||
|
rotation: [
|
||||||
|
roundTransformValue(object.rotation.x),
|
||||||
|
roundTransformValue(object.rotation.y),
|
||||||
|
roundTransformValue(object.rotation.z),
|
||||||
|
],
|
||||||
|
type: object.type,
|
||||||
|
visible: object.visible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pickOpacityMap(
|
function pickOpacityMap(
|
||||||
mesh: Mesh,
|
mesh: Mesh,
|
||||||
material: Material,
|
material: Material,
|
||||||
@@ -216,27 +243,25 @@ function pickOpacityMap(
|
|||||||
const genericIndex = entries.findIndex((entry) => entry.target === '')
|
const genericIndex = entries.findIndex((entry) => entry.target === '')
|
||||||
if (genericIndex >= 0) return textures[genericIndex]
|
if (genericIndex >= 0) return textures[genericIndex]
|
||||||
|
|
||||||
return entries.length === 1 ? textures[0] : undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function Model({
|
function Model({
|
||||||
url,
|
url,
|
||||||
assetUrls,
|
assetUrls,
|
||||||
onStatsReady,
|
onStatsReady,
|
||||||
}: {
|
onHierarchyReady,
|
||||||
url: string
|
}: SceneViewerProps) {
|
||||||
assetUrls: Record<string, string>
|
|
||||||
onStatsReady: (stats: ModelStats) => void
|
|
||||||
}) {
|
|
||||||
const { scene } = useLoader(GLTFLoader, url, (loader) => {
|
const { scene } = useLoader(GLTFLoader, url, (loader) => {
|
||||||
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
|
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
|
||||||
})
|
})
|
||||||
const opacityMapEntries = getOpacityMapEntries(assetUrls)
|
const opacityMapEntries = getOpacityMapEntries(assetUrls)
|
||||||
const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url)) as Texture[]
|
const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatsReady(getModelStats(scene, assetUrls))
|
onStatsReady(getModelStats(scene, assetUrls))
|
||||||
}, [assetUrls, onStatsReady, scene])
|
onHierarchyReady(getObjectHierarchy(scene))
|
||||||
|
}, [assetUrls, onHierarchyReady, onStatsReady, scene])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opacityMapEntries.length === 0) return
|
if (opacityMapEntries.length === 0) return
|
||||||
@@ -259,16 +284,18 @@ export default function SceneViewer({
|
|||||||
url,
|
url,
|
||||||
assetUrls,
|
assetUrls,
|
||||||
onStatsReady,
|
onStatsReady,
|
||||||
}: {
|
onHierarchyReady,
|
||||||
url: string
|
}: SceneViewerProps) {
|
||||||
assetUrls: Record<string, string>
|
|
||||||
onStatsReady: (stats: ModelStats) => void
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Canvas dpr={[1, 2]} camera={{ fov: 50 }}>
|
<Canvas dpr={[1, 2]} camera={{ fov: 50 }}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Stage environment="city" intensity={0.6} adjustCamera={1.2}>
|
<Stage environment="city" intensity={0.6} adjustCamera={1.2}>
|
||||||
<Model url={url} assetUrls={assetUrls} onStatsReady={onStatsReady} />
|
<Model
|
||||||
|
url={url}
|
||||||
|
assetUrls={assetUrls}
|
||||||
|
onStatsReady={onStatsReady}
|
||||||
|
onHierarchyReady={onHierarchyReady}
|
||||||
|
/>
|
||||||
</Stage>
|
</Stage>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<OrbitControls makeDefault autoRotate autoRotateSpeed={0.5} />
|
<OrbitControls makeDefault autoRotate autoRotateSpeed={0.5} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ActionButtons from './upload/ActionButtons'
|
|||||||
import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
|
import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
|
||||||
import NoChangesModal from './upload/NoChangesModal'
|
import NoChangesModal from './upload/NoChangesModal'
|
||||||
import DriveErrorModal from './upload/DriveErrorModal'
|
import DriveErrorModal from './upload/DriveErrorModal'
|
||||||
|
import { WarningIcon } from './ui/icons'
|
||||||
|
|
||||||
export default function UploadZone() {
|
export default function UploadZone() {
|
||||||
const {
|
const {
|
||||||
@@ -41,7 +42,6 @@ export default function UploadZone() {
|
|||||||
globalError,
|
globalError,
|
||||||
setGlobalError,
|
setGlobalError,
|
||||||
overwriteConfirm,
|
overwriteConfirm,
|
||||||
setOverwriteConfirm,
|
|
||||||
noChangesFolder,
|
noChangesFolder,
|
||||||
setNoChangesFolder,
|
setNoChangesFolder,
|
||||||
driveError,
|
driveError,
|
||||||
@@ -76,8 +76,9 @@ export default function UploadZone() {
|
|||||||
const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error')
|
const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-2xl space-y-4">
|
<div className="w-full max-w-3xl space-y-4">
|
||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
|
<>
|
||||||
<p className="rounded-2xl border border-white/20 bg-black-800 px-4 py-3 text-xs text-gray-400 leading-relaxed text-center mb-3">
|
<p className="rounded-2xl border border-white/20 bg-black-800 px-4 py-3 text-xs text-gray-400 leading-relaxed text-center mb-3">
|
||||||
Deposez un dossier complet contenant votre modele 3D nomme
|
Deposez un dossier complet contenant votre modele 3D nomme
|
||||||
{' '}<span className="font-mono text-gray-200">model.gltf</span>
|
{' '}<span className="font-mono text-gray-200">model.gltf</span>
|
||||||
@@ -91,8 +92,22 @@ export default function UploadZone() {
|
|||||||
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>,
|
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>,
|
||||||
{' '}<span className="font-mono text-gray-200">roughness_tuyaux.png</span>,
|
{' '}<span className="font-mono text-gray-200">roughness_tuyaux.png</span>,
|
||||||
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</span>
|
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</span>
|
||||||
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>
|
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>.
|
||||||
|
{' '}Les exports classiques comme <span className="font-mono text-gray-200">porte_baseColor.png</span>
|
||||||
|
{' '}ou <span className="font-mono text-gray-200">porte_normal_opengl.png</span> sont normalises automatiquement pour Git.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mb-3 flex items-start gap-2 rounded-2xl border border-yellow-500/25 bg-yellow-500/10 px-4 py-3 text-xs leading-relaxed text-yellow-200">
|
||||||
|
<WarningIcon className="mt-0.5 h-4 w-4 shrink-0 text-yellow-300" />
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-yellow-100">Attention :</span>
|
||||||
|
{' '}<span className="font-semibold text-yellow-100">Envoyer</span>
|
||||||
|
{' '}genere un GLB quand possible, plus performant pour la deco et les modeles simples.
|
||||||
|
{' '}<span className="font-semibold text-yellow-100">Envoyer en GLTF</span>
|
||||||
|
{' '}garde des fichiers separes, moins optimises mais plus pratiques pour inspecter et ajuster les noeuds.
|
||||||
|
{' '}A privilegier pour les packs de relance, ebikes, pylones et modeles que les devs doivent modifier.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entries.length > 0 && (
|
{entries.length > 0 && (
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared modal wrapper — handles overlay, centering, dialog role, aria
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
@@ -24,16 +20,11 @@ export default function Modal({ ariaLabelledBy, children }: ModalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared modal footer with two buttons
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface ModalActionsProps {
|
interface ModalActionsProps {
|
||||||
cancelLabel: string
|
cancelLabel: string
|
||||||
confirmLabel: string
|
confirmLabel: string
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
/** Tailwind classes for the confirm button (default: white bg) */
|
|
||||||
confirmClassName?: string
|
confirmClassName?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared SVG icon components
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface IconProps {
|
interface IconProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
interface ActionButtonsProps {
|
||||||
isUploading: boolean
|
isUploading: boolean
|
||||||
@@ -7,7 +8,7 @@ interface ActionButtonsProps {
|
|||||||
hasPendingOrErrors: boolean
|
hasPendingOrErrors: boolean
|
||||||
allDone: boolean
|
allDone: boolean
|
||||||
hasErrors: boolean
|
hasErrors: boolean
|
||||||
onUpload: () => void
|
onUpload: (gitModelMode: GitModelMode) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
}
|
}
|
||||||
@@ -27,10 +28,11 @@ export default function ActionButtons({
|
|||||||
const isBusy = isUploading || isChecking
|
const isBusy = isUploading || isChecking
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
{!isBusy && hasPendingOrErrors && (
|
{!isBusy && hasPendingOrErrors && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={onUpload}
|
onClick={() => onUpload('draco-glb')}
|
||||||
disabled={cantUpload}
|
disabled={cantUpload}
|
||||||
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl transition-all duration-150
|
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl transition-all duration-150
|
||||||
focus:outline-none focus:ring-2 focus:ring-white/50 border border-white/20
|
focus:outline-none focus:ring-2 focus:ring-white/50 border border-white/20
|
||||||
@@ -41,6 +43,23 @@ export default function ActionButtons({
|
|||||||
>
|
>
|
||||||
Envoyer
|
Envoyer
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onUpload('keep-gltf')}
|
||||||
|
disabled={cantUpload}
|
||||||
|
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl border transition-all duration-150
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-white/30
|
||||||
|
${cantUpload
|
||||||
|
? 'bg-black-800 text-gray-600 border-white/10 cursor-not-allowed'
|
||||||
|
: 'bg-black-700 text-gray-300 border-black-600 hover:bg-black-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<WarningIcon className="w-4 h-4 text-yellow-400" />
|
||||||
|
<span>Envoyer en GLTF</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isBusy && (
|
{isBusy && (
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function DriveErrorModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ?
|
Voulez-vous quand meme envoyer les fichiers aux devs via Git ?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ModalActions
|
<ModalActions
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Drive/Git status sub-line for FolderCard
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
|
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { DriveStatus } from '@/lib/client-types'
|
||||||
|
|
||||||
interface DriveStatusLineProps {
|
interface DriveStatusLineProps {
|
||||||
driveStatus: NonNullable<FolderEntry['driveStatus']>
|
driveStatus: DriveStatus
|
||||||
driveError?: string
|
driveError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { FolderEntry } from '@/lib/client-types'
|
||||||
import { formatBytes } from '@/lib/format-bytes'
|
import { formatBytes } from '@/lib/format-bytes'
|
||||||
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon } from '@/components/ui/icons'
|
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons'
|
||||||
import DriveStatusLine from './DriveStatusLine'
|
import DriveStatusLine from './DriveStatusLine'
|
||||||
import WarningBanner from './WarningBanner'
|
import WarningBanner from './WarningBanner'
|
||||||
|
import TextureDiagnosticsPanel from './TextureDiagnosticsPanel'
|
||||||
|
|
||||||
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
|
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
|
||||||
|
|
||||||
@@ -59,11 +60,17 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drive status sub-line (only during upload, not after success) */}
|
|
||||||
{entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && (
|
{entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && (
|
||||||
<DriveStatusLine driveStatus={entry.driveStatus} driveError={entry.driveError} />
|
<DriveStatusLine driveStatus={entry.driveStatus} driveError={entry.driveError} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{entry.uploadWarning && (
|
||||||
|
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-yellow-400">
|
||||||
|
<WarningIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="line-clamp-2">{entry.uploadWarning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{entry.status === 'uploading' && (
|
{entry.status === 'uploading' && (
|
||||||
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
|
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -95,13 +102,16 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
|
|||||||
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none'
|
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="grid gap-2 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start">
|
||||||
|
<TextureDiagnosticsPanel report={entry.textureReport} />
|
||||||
<ModelViewer
|
<ModelViewer
|
||||||
url={entry.modelUrl}
|
url={entry.modelUrl}
|
||||||
assetUrls={entry.assetUrls || {}}
|
assetUrls={entry.assetUrls}
|
||||||
filename={entry.modelFile.name}
|
filename={entry.modelFile.name}
|
||||||
size={formatBytes(entry.modelFile.size)}
|
size={formatBytes(entry.modelFile.size)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { FolderEntry } from '@/lib/client-types'
|
||||||
import { validateFolder } from '@/lib/validate-folder'
|
import { validateFolder } from '@/lib/validate-folder'
|
||||||
|
import { getErrorMessage } from '@/lib/guards'
|
||||||
import { FolderIcon } from '@/components/ui/icons'
|
import { FolderIcon } from '@/components/ui/icons'
|
||||||
|
|
||||||
function buildAssetUrls(model: File, supportFiles: File[]) {
|
function buildAssetUrls(model: File, supportFiles: File[]) {
|
||||||
@@ -95,6 +96,7 @@ export default function FolderDropzone({
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
warnings: validation.warnings,
|
warnings: validation.warnings,
|
||||||
|
textureReport: validation.textureReport,
|
||||||
modelUrl,
|
modelUrl,
|
||||||
assetUrls,
|
assetUrls,
|
||||||
viewerOpen: true,
|
viewerOpen: true,
|
||||||
@@ -156,8 +158,8 @@ export default function FolderDropzone({
|
|||||||
if (droppedFiles.length === 0) return
|
if (droppedFiles.length === 0) return
|
||||||
|
|
||||||
await processFiles(droppedFiles)
|
await processFiles(droppedFiles)
|
||||||
} catch {
|
} catch (err) {
|
||||||
onError('Impossible de lire le dossier depose')
|
onError(`Impossible de lire le dossier depose: ${getErrorMessage(err)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { CheckIcon, ChevronIcon, WarningIcon, XIcon } from '@/components/ui/icons'
|
||||||
|
import type { TextureDiagnosticReport } from '@/lib/client-types'
|
||||||
|
|
||||||
|
interface TextureDiagnosticsPanelProps {
|
||||||
|
report?: TextureDiagnosticReport
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleReport: TextureDiagnosticReport = {
|
||||||
|
status: 'idle',
|
||||||
|
summary: 'Deposez un dossier pour analyser les textures du model.gltf.',
|
||||||
|
issues: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
idle: {
|
||||||
|
icon: <WarningIcon className="h-4 w-4" />,
|
||||||
|
label: 'En attente',
|
||||||
|
tone: 'border-white/15 bg-white/5 text-gray-400',
|
||||||
|
},
|
||||||
|
ok: {
|
||||||
|
icon: <CheckIcon className="h-4 w-4" />,
|
||||||
|
label: 'OK',
|
||||||
|
tone: 'border-green-500/30 bg-green-500/10 text-green-300',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: <WarningIcon className="h-4 w-4" />,
|
||||||
|
label: 'A verifier',
|
||||||
|
tone: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: <XIcon className="h-4 w-4" />,
|
||||||
|
label: 'Probleme',
|
||||||
|
tone: 'border-red-500/30 bg-red-500/10 text-red-300',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueStyles = {
|
||||||
|
error: 'border-red-500/25 bg-red-500/10 text-red-200',
|
||||||
|
warning: 'border-yellow-500/25 bg-yellow-500/10 text-yellow-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextureDiagnosticsPanel({
|
||||||
|
report,
|
||||||
|
}: TextureDiagnosticsPanelProps) {
|
||||||
|
const currentReport = report || idleReport
|
||||||
|
const style = statusStyles[currentReport.status]
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="overflow-hidden rounded-xl border border-white/15 bg-black-800 text-xs text-gray-400 lg:max-h-[450px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<ChevronIcon className={`h-4 w-4 shrink-0 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
<span className="truncate text-sm font-semibold text-gray-100">Diagnostic textures</span>
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 ${style.tone}`}>
|
||||||
|
{style.icon}
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="max-h-[380px] overflow-auto border-t border-white/10 px-4 py-3">
|
||||||
|
<p className="leading-relaxed">{currentReport.summary}</p>
|
||||||
|
|
||||||
|
{currentReport.issues.length > 0 ? (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{currentReport.issues.map((issue, index) => (
|
||||||
|
<li
|
||||||
|
key={`${issue.title}-${index}`}
|
||||||
|
className={`rounded-xl border px-3 py-2 ${issueStyles[issue.severity]}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{issue.title}</p>
|
||||||
|
<p className="mt-1 leading-relaxed text-gray-300">{issue.detail}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : currentReport.status === 'idle' ? (
|
||||||
|
<p className="mt-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2 leading-relaxed">
|
||||||
|
Les resultats apparaitront ici apres selection du dossier.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2 leading-relaxed">
|
||||||
|
Aucun probleme texture detecte cote app.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,23 @@ echo "[upload-gltf] Starting Upload GLTF..."
|
|||||||
# Ensure tmp directory for uploads exists
|
# Ensure tmp directory for uploads exists
|
||||||
mkdir -p /tmp/assets
|
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 compression will fall back to separate GLTF delivery."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v toktx > /dev/null 2>&1; then
|
||||||
|
TOKTX_VERSION=$(toktx --version 2>/dev/null | head -n 1)
|
||||||
|
echo "[upload-gltf] toktx found: $TOKTX_VERSION"
|
||||||
|
echo "[upload-gltf] KTX2 texture delivery is enabled."
|
||||||
|
else
|
||||||
|
echo "[upload-gltf] WARNING: toktx not found. Texture delivery will fall back to WebP."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[upload-gltf] Ready. Launching application..."
|
echo "[upload-gltf] Ready. Launching application..."
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Upload orchestration hook — manages the Drive→Git upload pipeline
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from 'react'
|
import { useState, useRef, useCallback } from 'react'
|
||||||
|
import { getErrorMessage } from '@/lib/guards'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { FolderEntry } from '@/lib/client-types'
|
||||||
import type { FileDiff } from '@/lib/types'
|
import type {
|
||||||
|
CheckUploadResult,
|
||||||
|
DriveAction,
|
||||||
|
FileDiff,
|
||||||
|
GitModelMode,
|
||||||
|
} from '@/lib/types'
|
||||||
import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api'
|
import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api'
|
||||||
import type { CheckResult } from '@/lib/upload-api'
|
|
||||||
|
type UploadLogDetails = Record<string, string | number | boolean | undefined>
|
||||||
|
|
||||||
function formatElapsed(startedAt: number) {
|
function formatElapsed(startedAt: number) {
|
||||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
function logUpload(level: 'INFO' | 'ERROR', step: string, action: string, startedAt: number, details?: Record<string, unknown>) {
|
function logUpload(level: 'INFO' | 'ERROR', step: string, action: string, startedAt: number, details?: UploadLogDetails) {
|
||||||
const log = level === 'ERROR' ? console.error : console.info
|
const log = level === 'ERROR' ? console.error : console.info
|
||||||
log(`[${level}] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
|
log(`[${level}] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTimedLog(step: string, action: string, details?: Record<string, unknown>) {
|
function startTimedLog(step: string, action: string, details?: UploadLogDetails) {
|
||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
logUpload('INFO', step, `${action} started`, startedAt, details)
|
logUpload('INFO', step, `${action} started`, startedAt, details)
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ function startTimedLog(step: string, action: string, details?: Record<string, un
|
|||||||
logUpload('INFO', step, `${action} running`, startedAt, details)
|
logUpload('INFO', step, `${action} running`, startedAt, details)
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
|
|
||||||
return (status: 'done' | 'failed' | 'cancelled' = 'done', extra?: Record<string, unknown>) => {
|
return (status: 'done' | 'failed' | 'cancelled' = 'done', extra?: UploadLogDetails) => {
|
||||||
window.clearInterval(interval)
|
window.clearInterval(interval)
|
||||||
logUpload(status === 'failed' ? 'ERROR' : 'INFO', step, `${action} ${status}`, startedAt, { ...details, ...extra })
|
logUpload(status === 'failed' ? 'ERROR' : 'INFO', step, `${action} ${status}`, startedAt, { ...details, ...extra })
|
||||||
}
|
}
|
||||||
@@ -63,17 +66,15 @@ export function useUploadOrchestrator({
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
|
const checkResultRef = useRef<CheckUploadResult>({ exists: false, diffs: [] })
|
||||||
const uploadActionRef = useRef(false)
|
const uploadActionRef = useRef(false)
|
||||||
const stagingIdRef = useRef<string | null>(null)
|
const stagingIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
// Refs for values used inside callbacks to avoid stale closures
|
|
||||||
const secretRef = useRef(secret)
|
const secretRef = useRef(secret)
|
||||||
secretRef.current = secret
|
secretRef.current = secret
|
||||||
const entriesRef = useRef(entries)
|
const entriesRef = useRef(entries)
|
||||||
entriesRef.current = entries
|
entriesRef.current = entries
|
||||||
|
|
||||||
// ---- Internal: push a single folder to Git ----
|
|
||||||
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
|
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
|
||||||
const stagingId = stagingIdRef.current
|
const stagingId = stagingIdRef.current
|
||||||
if (!stagingId) {
|
if (!stagingId) {
|
||||||
@@ -95,7 +96,7 @@ export function useUploadOrchestrator({
|
|||||||
endGitLog(gitResult.success ? 'done' : 'failed', { error: gitResult.error })
|
endGitLog(gitResult.success ? 'done' : 'failed', { error: gitResult.error })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
endGitLog(signal?.aborted ? 'cancelled' : 'failed', {
|
endGitLog(signal?.aborted ? 'cancelled' : 'failed', {
|
||||||
error: err instanceof Error ? err.message : 'Erreur inconnue',
|
error: getErrorMessage(err),
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -104,11 +105,11 @@ export function useUploadOrchestrator({
|
|||||||
status: gitResult.success ? 'success' : 'error',
|
status: gitResult.success ? 'success' : 'error',
|
||||||
progress: gitResult.success ? 100 : 0,
|
progress: gitResult.success ? 100 : 0,
|
||||||
error: gitResult.success ? undefined : gitResult.error,
|
error: gitResult.success ? undefined : gitResult.error,
|
||||||
|
uploadWarning: gitResult.success ? gitResult.warning : undefined,
|
||||||
filename: gitResult.filename,
|
filename: gitResult.filename,
|
||||||
})
|
})
|
||||||
}, [updateEntry])
|
}, [updateEntry])
|
||||||
|
|
||||||
// ---- Main upload flow: Drive first, then Git ----
|
|
||||||
const proceedUpload = useCallback(async () => {
|
const proceedUpload = useCallback(async () => {
|
||||||
if (uploadActionRef.current) return
|
if (uploadActionRef.current) return
|
||||||
uploadActionRef.current = true
|
uploadActionRef.current = true
|
||||||
@@ -128,18 +129,18 @@ export function useUploadOrchestrator({
|
|||||||
if (controller.signal.aborted) break
|
if (controller.signal.aborted) break
|
||||||
|
|
||||||
const folderEntry = currentEntries[i]
|
const folderEntry = currentEntries[i]
|
||||||
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
|
const driveAction: DriveAction = checkResultRef.current.exists ? 'replace' : 'new'
|
||||||
const stagingId = stagingIdRef.current
|
const stagingId = stagingIdRef.current
|
||||||
if (!stagingId) {
|
if (!stagingId) {
|
||||||
updateEntry(i, { status: 'error', error: 'Preparation serveur introuvable' })
|
updateEntry(i, { status: 'error', error: 'Preparation serveur introuvable' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Step 1: Drive upload ----
|
|
||||||
updateEntry(i, {
|
updateEntry(i, {
|
||||||
status: 'uploading',
|
status: 'uploading',
|
||||||
progress: 1,
|
progress: 1,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
uploadWarning: undefined,
|
||||||
driveStatus: 'uploading',
|
driveStatus: 'uploading',
|
||||||
driveError: undefined,
|
driveError: undefined,
|
||||||
})
|
})
|
||||||
@@ -155,13 +156,13 @@ export function useUploadOrchestrator({
|
|||||||
driveResult = await uploadDrive(
|
driveResult = await uploadDrive(
|
||||||
stagingId,
|
stagingId,
|
||||||
secretRef.current,
|
secretRef.current,
|
||||||
driveAction as 'new' | 'replace',
|
driveAction,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
)
|
)
|
||||||
endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error })
|
endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
endDriveLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
endDriveLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
||||||
error: err instanceof Error ? err.message : 'Erreur inconnue',
|
error: getErrorMessage(err),
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,6 @@ export function useUploadOrchestrator({
|
|||||||
|
|
||||||
updateEntry(i, { driveStatus: 'success', progress: 50 })
|
updateEntry(i, { driveStatus: 'success', progress: 50 })
|
||||||
|
|
||||||
// ---- Step 2: Git upload ----
|
|
||||||
await pushGit(i, controller.signal)
|
await pushGit(i, controller.signal)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -184,9 +184,7 @@ export function useUploadOrchestrator({
|
|||||||
}
|
}
|
||||||
}, [updateEntry, pushGit])
|
}, [updateEntry, pushGit])
|
||||||
|
|
||||||
// ---- Handlers ----
|
const handleUpload = useCallback(async (gitModelMode: GitModelMode) => {
|
||||||
|
|
||||||
const handleUpload = useCallback(async () => {
|
|
||||||
if (uploadActionRef.current || isChecking || isUploading) return
|
if (uploadActionRef.current || isChecking || isUploading) return
|
||||||
|
|
||||||
if (!secretRef.current.trim()) {
|
if (!secretRef.current.trim()) {
|
||||||
@@ -209,26 +207,27 @@ export function useUploadOrchestrator({
|
|||||||
folderName: folder.folderName,
|
folderName: folder.folderName,
|
||||||
files: 1 + folder.textures.length,
|
files: 1 + folder.textures.length,
|
||||||
modelSize: folder.modelFile.size,
|
modelSize: folder.modelFile.size,
|
||||||
|
gitModelMode,
|
||||||
})
|
})
|
||||||
let staged: Awaited<ReturnType<typeof stageUpload>>
|
let staged: Awaited<ReturnType<typeof stageUpload>>
|
||||||
|
|
||||||
try {
|
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 })
|
endStageLog('done', { stagingId: staged.stagingId, filesCount: staged.filesCount })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
||||||
error: err instanceof Error ? err.message : 'Erreur inconnue',
|
error: getErrorMessage(err),
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
stagingIdRef.current = staged.stagingId
|
stagingIdRef.current = staged.stagingId
|
||||||
|
|
||||||
const endCheckLog = startTimedLog('Verification', 'GitHub diff', {
|
const endCheckLog = startTimedLog('Verification', 'Git diff', {
|
||||||
folderName: folder.folderName,
|
folderName: folder.folderName,
|
||||||
stagingId: staged.stagingId,
|
stagingId: staged.stagingId,
|
||||||
})
|
})
|
||||||
let check: CheckResult
|
let check: CheckUploadResult
|
||||||
|
|
||||||
try {
|
try {
|
||||||
check = await checkFolderDiffs(
|
check = await checkFolderDiffs(
|
||||||
@@ -239,12 +238,13 @@ export function useUploadOrchestrator({
|
|||||||
endCheckLog('done', { exists: check.exists, diffs: check.diffs.length })
|
endCheckLog('done', { exists: check.exists, diffs: check.diffs.length })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
endCheckLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
endCheckLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
||||||
error: err instanceof Error ? err.message : 'Erreur inconnue',
|
error: getErrorMessage(err),
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
checkResultRef.current = check
|
checkResultRef.current = check
|
||||||
|
updateEntry(0, { uploadWarning: check.warning })
|
||||||
|
|
||||||
if (check.exists) {
|
if (check.exists) {
|
||||||
if (check.diffs.length === 0) {
|
if (check.diffs.length === 0) {
|
||||||
@@ -261,7 +261,7 @@ export function useUploadOrchestrator({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = getErrorMessage(err)
|
||||||
setGlobalError(message)
|
setGlobalError(message)
|
||||||
uploadActionRef.current = false
|
uploadActionRef.current = false
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
@@ -296,6 +296,7 @@ export function useUploadOrchestrator({
|
|||||||
status: 'uploading',
|
status: 'uploading',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
uploadWarning: undefined,
|
||||||
driveStatus: 'skipped',
|
driveStatus: 'skipped',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -364,7 +365,6 @@ export function useUploadOrchestrator({
|
|||||||
globalError,
|
globalError,
|
||||||
setGlobalError,
|
setGlobalError,
|
||||||
overwriteConfirm,
|
overwriteConfirm,
|
||||||
setOverwriteConfirm,
|
|
||||||
noChangesFolder,
|
noChangesFolder,
|
||||||
setNoChangesFolder,
|
setNoChangesFolder,
|
||||||
driveError,
|
driveError,
|
||||||
|
|||||||
@@ -1,26 +1,9 @@
|
|||||||
import { getAssetFamily } from './asset-naming'
|
import { getAssetFamily } from './asset-naming'
|
||||||
|
import type { AssetCategory } from './types'
|
||||||
export type AssetCategory = 'color' | 'roughness' | 'normal' | 'metalness' | 'assets'
|
|
||||||
|
|
||||||
export function classifyAssetCategory(filename: string): AssetCategory {
|
export function classifyAssetCategory(filename: string): AssetCategory {
|
||||||
const name = filename.replace(/\.[^.]+$/, '')
|
const name = filename.replace(/\.[^.]+$/, '')
|
||||||
const family = getAssetFamily(name.split('_')[0])
|
const family = getAssetFamily(name.split('_')[0])
|
||||||
|
|
||||||
if (family === 'color' || family === 'diffuse') {
|
return family || 'assets'
|
||||||
return 'color'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (family === 'roughness') {
|
|
||||||
return 'roughness'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (family === 'normal') {
|
|
||||||
return 'normal'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (family === 'metalness') {
|
|
||||||
return 'metalness'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'assets'
|
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-3
@@ -6,9 +6,11 @@ export const ASSET_FAMILIES = [
|
|||||||
'metalness',
|
'metalness',
|
||||||
'height',
|
'height',
|
||||||
'opacity',
|
'opacity',
|
||||||
|
'orm',
|
||||||
|
'ao',
|
||||||
] as const
|
] 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 ASSET_FAMILY_BY_KEY = new Map(ASSET_FAMILIES.map((family) => [family.toLowerCase(), family]))
|
||||||
const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap<string, AssetFamily> = new Map([
|
const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap<string, AssetFamily> = new Map([
|
||||||
@@ -21,14 +23,126 @@ const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap<string, AssetFamily> = new Map
|
|||||||
['occlusion_roughness_metallic', 'roughness'],
|
['occlusion_roughness_metallic', 'roughness'],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const EXPORTED_SUFFIX_ALIASES: Array<{ suffix: string; family: AssetFamily }> = [
|
||||||
|
{ suffix: 'occlusionroughnessmetallic', family: 'orm' },
|
||||||
|
{ suffix: 'occlusion_roughness_metallic', family: 'orm' },
|
||||||
|
{ suffix: 'normal_opengl', family: 'normal' },
|
||||||
|
{ suffix: 'normalopengl', family: 'normal' },
|
||||||
|
{ suffix: 'base_color', family: 'color' },
|
||||||
|
{ suffix: 'basecolor', family: 'color' },
|
||||||
|
{ suffix: 'mixed_ao', family: 'ao' },
|
||||||
|
{ suffix: 'metallic', family: 'metalness' },
|
||||||
|
{ suffix: 'roughness', family: 'roughness' },
|
||||||
|
{ suffix: 'normal', family: 'normal' },
|
||||||
|
{ suffix: 'height', family: 'height' },
|
||||||
|
{ suffix: 'opacity', family: 'opacity' },
|
||||||
|
{ suffix: 'diffuse', family: 'diffuse' },
|
||||||
|
{ suffix: 'color', family: 'color' },
|
||||||
|
]
|
||||||
|
|
||||||
export function getAssetFamily(value: string): AssetFamily | undefined {
|
export function getAssetFamily(value: string): AssetFamily | undefined {
|
||||||
return ASSET_FAMILY_BY_KEY.get(value.toLowerCase())
|
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())
|
return FORBIDDEN_ASSET_FAMILY_ALIASES.get(value.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAssetFamilies() {
|
function getFileStem(filename: string) {
|
||||||
|
return filename.replace(/\.[^.]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(filename: string) {
|
||||||
|
return filename.split('.').pop()?.toLowerCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTargetName(target: string) {
|
||||||
|
return target
|
||||||
|
.trim()
|
||||||
|
.replace(/[\s-]+/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextureFilename(family: AssetFamily, target: string, extension: string) {
|
||||||
|
const normalizedTarget = normalizeTargetName(target)
|
||||||
|
return `${family}${normalizedTarget ? `_${normalizedTarget}` : ''}.${extension}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExportedTextureAlias(stem: string) {
|
||||||
|
const lowerStem = stem.toLowerCase()
|
||||||
|
|
||||||
|
for (const alias of EXPORTED_SUFFIX_ALIASES) {
|
||||||
|
const lowerSuffix = alias.suffix.toLowerCase()
|
||||||
|
if (lowerStem === lowerSuffix) {
|
||||||
|
return { family: alias.family, target: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerStem.endsWith(`_${lowerSuffix}`)) {
|
||||||
|
return {
|
||||||
|
family: alias.family,
|
||||||
|
target: stem.slice(0, -lowerSuffix.length - 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTextureFilename(filename: string) {
|
||||||
|
const stem = getFileStem(filename)
|
||||||
|
const extension = getFileExtension(filename)
|
||||||
|
const [prefix, ...targetParts] = stem.split('_')
|
||||||
|
const family = getAssetFamily(prefix)
|
||||||
|
|
||||||
|
if (family) {
|
||||||
|
return buildTextureFilename(family, targetParts.join('_'), extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportedAlias = getExportedTextureAlias(stem)
|
||||||
|
if (exportedAlias) {
|
||||||
|
return buildTextureFilename(exportedAlias.family, exportedAlias.target, extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextureNamingError(filename: string) {
|
||||||
|
const stem = getFileStem(filename)
|
||||||
|
const [prefix, ...targetParts] = stem.split('_')
|
||||||
|
const family = getAssetFamily(prefix)
|
||||||
|
const extension = getFileExtension(filename)
|
||||||
|
|
||||||
|
if (normalizeTextureFilename(filename)) return null
|
||||||
|
|
||||||
|
if (family && targetParts.every(Boolean)) return null
|
||||||
|
|
||||||
|
const aliasSuggestion = getForbiddenAssetFamilyAlias(prefix)
|
||||||
|
|
||||||
|
if (aliasSuggestion && targetParts.every(Boolean)) {
|
||||||
|
const target = targetParts.join('_')
|
||||||
|
return `Convention invalide : ${filename}. Utilisez ${aliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${aliasSuggestion}.${extension} pour tout le modele.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const reversedParts = stem.split('_')
|
||||||
|
const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined
|
||||||
|
const reversedAliasSuggestion = reversedParts.length > 1
|
||||||
|
? getForbiddenAssetFamilyAlias(reversedParts[reversedParts.length - 1])
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (reversedFamily) {
|
||||||
|
const target = reversedParts.slice(0, -1).join('_')
|
||||||
|
return `Convention invalide : ${filename}. Utilisez ${reversedFamily}_${target}.${extension} pour cibler "${target}", ou ${reversedFamily}.${extension} pour tout le modele.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reversedAliasSuggestion) {
|
||||||
|
const target = reversedParts.slice(0, -1).join('_')
|
||||||
|
return `Convention invalide : ${filename}. Utilisez ${reversedAliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${reversedAliasSuggestion}.${extension} pour tout le modele.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Asset inconnu : ${filename}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAssetFamilies() {
|
||||||
return ASSET_FAMILIES.join(', ')
|
return ASSET_FAMILIES.join(', ')
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -2,8 +2,7 @@ import { timingSafeEqual } from 'crypto'
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the upload secret from request headers.
|
* Validate the shared upload secret before accepting mutation routes.
|
||||||
* Returns null if valid, or a NextResponse error if invalid.
|
|
||||||
*/
|
*/
|
||||||
export function validateUploadSecret(req: NextRequest): NextResponse | null {
|
export function validateUploadSecret(req: NextRequest): NextResponse | null {
|
||||||
const secret = req.headers.get('x-upload-secret')
|
const secret = req.headers.get('x-upload-secret')
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 a structured result so callers can decide whether to fall back or stop.
|
||||||
|
*/
|
||||||
|
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}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@ export function revokeEntryUrls(entry: FolderEntry) {
|
|||||||
const urls = new Set<string>()
|
const urls = new Set<string>()
|
||||||
|
|
||||||
if (entry.modelUrl) urls.add(entry.modelUrl)
|
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))
|
urls.forEach((url) => URL.revokeObjectURL(url))
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-6
@@ -1,7 +1,3 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Client-side types — used by components and hooks (no Node.js Buffer)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
|
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
|
||||||
|
|
||||||
export interface TextureFile {
|
export interface TextureFile {
|
||||||
@@ -9,7 +5,21 @@ export interface TextureFile {
|
|||||||
file: File
|
file: File
|
||||||
}
|
}
|
||||||
|
|
||||||
type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
export type TextureDiagnosticSeverity = 'error' | 'warning'
|
||||||
|
|
||||||
|
export interface TextureDiagnosticIssue {
|
||||||
|
severity: TextureDiagnosticSeverity
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextureDiagnosticReport {
|
||||||
|
status: 'idle' | 'ok' | 'warning' | 'error'
|
||||||
|
summary: string
|
||||||
|
issues: TextureDiagnosticIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
||||||
|
|
||||||
export interface FolderEntry {
|
export interface FolderEntry {
|
||||||
folderName: string
|
folderName: string
|
||||||
@@ -18,11 +28,39 @@ export interface FolderEntry {
|
|||||||
status: FileStatus
|
status: FileStatus
|
||||||
progress: number
|
progress: number
|
||||||
error?: string
|
error?: string
|
||||||
|
uploadWarning?: string
|
||||||
filename?: string
|
filename?: string
|
||||||
modelUrl?: string
|
modelUrl?: string
|
||||||
assetUrls?: Record<string, string>
|
assetUrls: Record<string, string>
|
||||||
viewerOpen?: boolean
|
viewerOpen?: boolean
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
|
textureReport: TextureDiagnosticReport
|
||||||
driveStatus?: DriveStatus
|
driveStatus?: DriveStatus
|
||||||
driveError?: string
|
driveError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelStats {
|
||||||
|
childObjects: number
|
||||||
|
drawCalls: number
|
||||||
|
materials: number
|
||||||
|
meshes: number
|
||||||
|
textures: number
|
||||||
|
triangles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelHierarchyNode {
|
||||||
|
children: ModelHierarchyNode[]
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
position: [number, number, number]
|
||||||
|
rotation: [number, number, number]
|
||||||
|
type: string
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneViewerProps {
|
||||||
|
url: string
|
||||||
|
assetUrls: Record<string, string>
|
||||||
|
onStatsReady: (stats: ModelStats) => void
|
||||||
|
onHierarchyReady: (hierarchy: ModelHierarchyNode) => void
|
||||||
|
}
|
||||||
|
|||||||
+36
-14
@@ -1,6 +1,23 @@
|
|||||||
import type { AssetCategory } from './asset-classification'
|
import { ASSET_FAMILIES } from './asset-naming'
|
||||||
import type { FileChange } from './types'
|
import type { AssetCategory, FileChange, PreparedAssetSummary } from './types'
|
||||||
import type { 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<AssetCategory, string[]>,
|
||||||
|
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.
|
* Build a formatted commit message based on the upload context.
|
||||||
@@ -25,7 +42,6 @@ export function buildCommitMessage(
|
|||||||
|
|
||||||
const lines: string[] = [title, '']
|
const lines: string[] = [title, '']
|
||||||
|
|
||||||
// Model section — show status for new, changed, or unchanged
|
|
||||||
const modelSummary = assetSummaries.find((asset) => asset.kind === 'model')
|
const modelSummary = assetSummaries.find((asset) => asset.kind === 'model')
|
||||||
const modelChange = fileChanges.get(modelFilename.toLowerCase())
|
const modelChange = fileChanges.get(modelFilename.toLowerCase())
|
||||||
if (modelChange === 'new') {
|
if (modelChange === 'new') {
|
||||||
@@ -45,26 +61,32 @@ export function buildCommitMessage(
|
|||||||
if (asset.kind === 'model' || !asset.category) continue
|
if (asset.kind === 'model' || !asset.category) continue
|
||||||
|
|
||||||
const change = fileChanges.get(asset.filename.toLowerCase())
|
const change = fileChanges.get(asset.filename.toLowerCase())
|
||||||
if (change === 'new') {
|
if (!change) continue
|
||||||
const current = grouped.get(asset.category) || []
|
|
||||||
current.push(` ✅ ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
|
const prefix = getChangePrefix(change)
|
||||||
grouped.set(asset.category, current)
|
if (!prefix) continue
|
||||||
} else if (change === 'changed') {
|
|
||||||
const current = grouped.get(asset.category) || []
|
addGroupedAssetLine(
|
||||||
current.push(` 🔄 ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
|
grouped,
|
||||||
grouped.set(asset.category, current)
|
asset.category,
|
||||||
}
|
` ${prefix} ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionTitles: Record<AssetCategory, string> = {
|
const sectionTitles: Record<AssetCategory, string> = {
|
||||||
color: '🎨 Textures (color)',
|
color: '🎨 Textures (color)',
|
||||||
|
diffuse: '🖌 Textures (diffuse)',
|
||||||
roughness: '🪶 Textures (roughness)',
|
roughness: '🪶 Textures (roughness)',
|
||||||
normal: '🧭 Textures (normal)',
|
normal: '🧭 Textures (normal)',
|
||||||
metalness: '🔩 Textures (metalness)',
|
metalness: '🔩 Textures (metalness)',
|
||||||
|
height: '⛰ Textures (height)',
|
||||||
|
opacity: '🪟 Textures (opacity)',
|
||||||
|
orm: '🧱 Textures (orm)',
|
||||||
|
ao: '🌑 Textures (ao)',
|
||||||
assets: '🧩 Assets',
|
assets: '🧩 Assets',
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const category of ['color', 'roughness', 'normal', 'metalness', 'assets'] as const) {
|
for (const category of ASSET_SECTION_ORDER) {
|
||||||
const entries = grouped.get(category)
|
const entries = grouped.get(category)
|
||||||
if (!entries || entries.length === 0) continue
|
if (!entries || entries.length === 0) continue
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ export const ASSET_EXTENSIONS = new Set(['.bin'])
|
|||||||
export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS])
|
export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS])
|
||||||
|
|
||||||
/** Extensions tracked by Git LFS (must match .gitattributes) */
|
/** 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', '.ktx2'])
|
||||||
|
|
||||||
export const TMP_DIR = '/tmp/assets'
|
export const TMP_DIR = '/tmp/assets'
|
||||||
|
|
||||||
|
|||||||
+2
-16
@@ -1,18 +1,10 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// File diff classification — compares local files against a remote file map
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import { MODEL_EXTENSIONS } from './constants'
|
import { MODEL_EXTENSIONS } from './constants'
|
||||||
import type { FileChange, PushFile } from './types'
|
import type { FileChange, PushFile } from './types'
|
||||||
|
|
||||||
export interface DiffResult {
|
interface DiffResult {
|
||||||
/** Map of lowercase filename → change status (for commit message) */
|
|
||||||
fileChanges: Map<string, FileChange>
|
fileChanges: Map<string, FileChange>
|
||||||
/** Files that actually need to be pushed (new or changed) */
|
|
||||||
changedFilesToPush: PushFile[]
|
changedFilesToPush: PushFile[]
|
||||||
/** Filenames that were on remote but not in the new upload */
|
|
||||||
deletedFileNames: string[]
|
deletedFileNames: string[]
|
||||||
/** Full paths for deletion on remote */
|
|
||||||
deletePaths: string[]
|
deletePaths: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +13,7 @@ export interface DiffResult {
|
|||||||
* the remote file map.
|
* the remote file map.
|
||||||
*
|
*
|
||||||
* Rules:
|
* Rules:
|
||||||
* - Models: always re-pushed,
|
* - Models: always re-pushed, but marked as unchanged when the remote file exists.
|
||||||
* but marked as 'unchanged' in the commit message when the folder already
|
|
||||||
* exists (we keep the current behavior of always delivering the model file).
|
|
||||||
* - Textures: compared by size (not compressed, reliable).
|
* - Textures: compared by size (not compressed, reliable).
|
||||||
* - Orphan remote files: classified as deletions.
|
* - Orphan remote files: classified as deletions.
|
||||||
*/
|
*/
|
||||||
@@ -41,13 +31,10 @@ export function classifyFileChanges(
|
|||||||
const isModel = MODEL_EXTENSIONS.has(ext)
|
const isModel = MODEL_EXTENSIONS.has(ext)
|
||||||
|
|
||||||
if (isModel) {
|
if (isModel) {
|
||||||
// Model: always re-push since compression makes size comparison unreliable.
|
|
||||||
// Mark as 'unchanged' for the commit message when the folder already exists.
|
|
||||||
const remoteSize = remoteFileMap.get(filename.toLowerCase())
|
const remoteSize = remoteFileMap.get(filename.toLowerCase())
|
||||||
fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged')
|
fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged')
|
||||||
changedFilesToPush.push(f)
|
changedFilesToPush.push(f)
|
||||||
} else {
|
} else {
|
||||||
// Texture: compare by size
|
|
||||||
const localSize = Buffer.from(f.contentBase64, 'base64').length
|
const localSize = Buffer.from(f.contentBase64, 'base64').length
|
||||||
const remoteSize = remoteFileMap.get(filename.toLowerCase())
|
const remoteSize = remoteFileMap.get(filename.toLowerCase())
|
||||||
|
|
||||||
@@ -63,7 +50,6 @@ export function classifyFileChanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files on remote not in the new upload → deleted (orphans)
|
|
||||||
const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase()))
|
const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase()))
|
||||||
const deletedFileNames: string[] = []
|
const deletedFileNames: string[] = []
|
||||||
const deletePaths: string[] = []
|
const deletePaths: string[] = []
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Format bytes to human-readable string
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
if (bytes <= 0) return '0 B'
|
if (bytes <= 0) return '0 B'
|
||||||
const k = 1024
|
const k = 1024
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { GitProviderName, GitRemoteConfig } from './types'
|
||||||
|
|
||||||
|
const DEFAULT_GIT_BRANCH = 'main'
|
||||||
|
|
||||||
|
function getGitProviderOverride(): GitProviderName | undefined {
|
||||||
|
const provider = process.env.GIT_PROVIDER?.trim().toLowerCase()
|
||||||
|
if (!provider) return undefined
|
||||||
|
|
||||||
|
if (provider !== 'github' && provider !== 'gitea') {
|
||||||
|
throw new Error(`GIT_PROVIDER invalide: "${provider}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGitToken() {
|
||||||
|
const token = process.env.GIT_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim()
|
||||||
|
if (!token) throw new Error('GIT_TOKEN non configure')
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanRepoName(repo: string) {
|
||||||
|
return repo.replace(/\/+$/, '').replace(/\.git$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProvider(host: string): GitProviderName {
|
||||||
|
const override = getGitProviderOverride()
|
||||||
|
if (override) return override
|
||||||
|
|
||||||
|
return host.toLowerCase() === 'github.com' ? 'github' : 'gitea'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteConfig(host: string, owner: string, repo: string, protocol = 'https:'): GitRemoteConfig {
|
||||||
|
const provider = resolveProvider(host)
|
||||||
|
const origin = `${protocol === 'http:' ? 'http' : 'https'}://${host}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiBaseUrl: provider === 'github' ? 'https://api.github.com' : `${origin}/api/v1`,
|
||||||
|
lfsBatchUrl: `${origin}/${owner}/${repo}.git/info/lfs/objects/batch`,
|
||||||
|
owner,
|
||||||
|
provider,
|
||||||
|
repo,
|
||||||
|
token: getGitToken(),
|
||||||
|
username: process.env.GIT_USERNAME?.trim() || undefined,
|
||||||
|
webUrl: `${origin}/${owner}/${repo}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitBranch() {
|
||||||
|
return process.env.GIT_BRANCH?.trim() || DEFAULT_GIT_BRANCH
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readGitRemoteConfig(): GitRemoteConfig {
|
||||||
|
const url = process.env.GIT_REPO_URL?.trim()
|
||||||
|
if (!url) throw new Error('GIT_REPO_URL non configure')
|
||||||
|
|
||||||
|
const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/)
|
||||||
|
if (shortMatch) {
|
||||||
|
const providerOverride = getGitProviderOverride()
|
||||||
|
if (providerOverride === 'gitea') {
|
||||||
|
throw new Error('GIT_REPO_URL doit inclure le host pour Gitea')
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildRemoteConfig('github.com', shortMatch[1], cleanRepoName(shortMatch[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const scpLikeMatch = !url.includes('://') ? url.match(/^(?:[^@\s]+@)?([^:\s]+):([^/\s]+)\/([^/\s]+)$/) : null
|
||||||
|
if (scpLikeMatch) {
|
||||||
|
return buildRemoteConfig(scpLikeMatch[1], scpLikeMatch[2], cleanRepoName(scpLikeMatch[3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (URL.canParse(url)) {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const pathParts = parsed.pathname
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if ((parsed.protocol === 'https:' || parsed.protocol === 'http:' || parsed.protocol === 'ssh:') && pathParts.length >= 2) {
|
||||||
|
return buildRemoteConfig(parsed.hostname, pathParts[0], cleanRepoName(pathParts[1]), parsed.protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Format GIT_REPO_URL invalide: "${url}"`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { isRecord } from '@/lib/guards'
|
||||||
|
import type { RemoteFile } from '@/lib/types'
|
||||||
|
import { decodeBase64Content, encodePath, getRepoApiPath, isHttpError, requestGitJson } from './http'
|
||||||
|
import { isLfsFile, parseLfsPointer } from './lfs'
|
||||||
|
import type { GitRemoteConfig } from './types'
|
||||||
|
|
||||||
|
interface GitContentEntry {
|
||||||
|
content?: string
|
||||||
|
name: string
|
||||||
|
path?: string
|
||||||
|
sha?: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitContentEntry(value: unknown): value is GitContentEntry {
|
||||||
|
return isRecord(value)
|
||||||
|
&& typeof value.name === 'string'
|
||||||
|
&& (value.path === undefined || typeof value.path === 'string')
|
||||||
|
&& (value.sha === undefined || typeof value.sha === 'string')
|
||||||
|
&& (value.size === undefined || typeof value.size === 'number')
|
||||||
|
&& (value.content === undefined || typeof value.content === 'string')
|
||||||
|
&& (value.type === undefined || typeof value.type === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentEntrySize(entry: GitContentEntry) {
|
||||||
|
return entry.size ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRemoteContent(remote: GitRemoteConfig, path: string, branch: string) {
|
||||||
|
const query = new URLSearchParams({ ref: branch })
|
||||||
|
return requestGitJson(remote, `${getRepoApiPath(remote)}/contents/${encodePath(path)}?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequiredContentEntrySha(entry: GitContentEntry, path: string) {
|
||||||
|
if (!entry.sha) {
|
||||||
|
throw new Error(`SHA Git manquant pour ${path}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.sha
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRemoteFileEntry(remote: GitRemoteConfig, path: string, branch: string): Promise<GitContentEntry | null> {
|
||||||
|
try {
|
||||||
|
const data = await getRemoteContent(remote, path, branch)
|
||||||
|
return isGitContentEntry(data) ? data : null
|
||||||
|
} catch (err) {
|
||||||
|
if (isHttpError(err) && err.status === 404) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRemoteFolder(
|
||||||
|
remote: GitRemoteConfig,
|
||||||
|
branch: string,
|
||||||
|
folderPath: string,
|
||||||
|
): Promise<{ exists: boolean; files: RemoteFile[] }> {
|
||||||
|
try {
|
||||||
|
const data = await getRemoteContent(remote, folderPath, branch)
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error(`Le chemin distant ${folderPath} existe mais ce n'est pas un dossier`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: RemoteFile[] = await Promise.all(
|
||||||
|
data.map(async (entry: unknown): Promise<RemoteFile> => {
|
||||||
|
if (!isGitContentEntry(entry)) {
|
||||||
|
throw new Error(`Reponse Git invalide pour ${folderPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = getContentEntrySize(entry)
|
||||||
|
|
||||||
|
if (!isLfsFile(entry.name) || size > 1024) {
|
||||||
|
return { name: entry.name, size }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileData = await getRemoteFileEntry(remote, `${folderPath}/${entry.name}`, branch)
|
||||||
|
|
||||||
|
if (fileData?.content) {
|
||||||
|
const content = decodeBase64Content(fileData.content)
|
||||||
|
const pointer = parseLfsPointer(content)
|
||||||
|
if (pointer) {
|
||||||
|
return { name: entry.name, size: pointer.size }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!isHttpError(err) || err.status !== 404) throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: entry.name, size }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { exists: true, files }
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isHttpError(err) && err.status === 404) {
|
||||||
|
return { exists: false, files: [] }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { isRecord } from '@/lib/guards'
|
||||||
|
import type { GitRemoteConfig } from './types'
|
||||||
|
|
||||||
|
export class GitApiError extends Error {
|
||||||
|
constructor(message: string, public status: number) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHttpError(err: unknown): err is { status: number } {
|
||||||
|
return isRecord(err) && typeof err.status === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodePath(path: string) {
|
||||||
|
return path.split('/').map(encodeURIComponent).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepoApiPath(remote: GitRemoteConfig) {
|
||||||
|
return `/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.repo)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBase64Content(content: string) {
|
||||||
|
return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestGitJson(
|
||||||
|
remote: GitRemoteConfig,
|
||||||
|
path: string,
|
||||||
|
init: { method?: string; body?: unknown } = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await fetch(`${remote.apiBaseUrl}${path}`, {
|
||||||
|
method: init.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `token ${remote.token}`,
|
||||||
|
},
|
||||||
|
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new GitApiError(text || `Git API request failed (${res.status})`, res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getGitBranch, readGitRemoteConfig } from './config'
|
||||||
|
import { createGiteaProvider } from './providers/gitea'
|
||||||
|
import { createGitHubProvider } from './providers/github'
|
||||||
|
import type { GitProvider } from './types'
|
||||||
|
import type { PushFile } from '@/lib/types'
|
||||||
|
|
||||||
|
function createGitProvider(): GitProvider {
|
||||||
|
const remote = readGitRemoteConfig()
|
||||||
|
const branch = getGitBranch()
|
||||||
|
|
||||||
|
if (remote.provider === 'github') {
|
||||||
|
return createGitHubProvider(remote, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createGiteaProvider(remote, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRemoteFolder(folderPath: string) {
|
||||||
|
return createGitProvider().getRemoteFolder(folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushAllToGit(files: PushFile[], deletePaths: string[], commitMessage: string) {
|
||||||
|
return createGitProvider().pushFiles({ files, deletePaths, commitMessage })
|
||||||
|
}
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { LFS_EXTENSIONS } from '@/lib/constants'
|
||||||
|
import { isRecord } from '@/lib/guards'
|
||||||
|
import type { GitRemoteConfig, LfsObject, LfsPushFile } from './types'
|
||||||
|
import type { PushFile } from '@/lib/types'
|
||||||
|
|
||||||
|
const LFS_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
type LogDetails = Record<string, string | number | boolean | undefined>
|
||||||
|
|
||||||
|
interface LfsBatchAction {
|
||||||
|
href: string
|
||||||
|
header?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LfsBatchObject {
|
||||||
|
oid: string
|
||||||
|
size: number
|
||||||
|
actions?: {
|
||||||
|
upload?: LfsBatchAction
|
||||||
|
verify?: LfsBatchAction
|
||||||
|
}
|
||||||
|
error?: { code: number; message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLfsFile(filePath: string): boolean {
|
||||||
|
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
|
||||||
|
return LFS_EXTENSIONS.has(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(startedAt: number) {
|
||||||
|
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInfo(step: string, action: string, startedAt: number, details?: LogDetails) {
|
||||||
|
console.info(`[INFO] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkArray<T>(items: T[], size: number) {
|
||||||
|
const chunks: T[][] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
chunks.push(items.slice(i, i + size))
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||||
|
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLfsAction(value: unknown): LfsBatchAction | undefined {
|
||||||
|
if (!isRecord(value) || typeof value.href !== 'string') return undefined
|
||||||
|
return {
|
||||||
|
href: value.href,
|
||||||
|
header: isStringRecord(value.header) ? value.header : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLfsBatchObject(value: unknown): LfsBatchObject | null {
|
||||||
|
if (!isRecord(value) || typeof value.oid !== 'string' || typeof value.size !== 'number') return null
|
||||||
|
|
||||||
|
const actions = isRecord(value.actions)
|
||||||
|
? {
|
||||||
|
upload: parseLfsAction(value.actions.upload),
|
||||||
|
verify: parseLfsAction(value.actions.verify),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const error = isRecord(value.error) && typeof value.error.code === 'number' && typeof value.error.message === 'string'
|
||||||
|
? { code: value.error.code, message: value.error.message }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return { oid: value.oid, size: value.size, actions, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value.objects)) {
|
||||||
|
throw new Error('LFS batch response invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
const objects: LfsBatchObject[] = []
|
||||||
|
for (const object of value.objects) {
|
||||||
|
const parsed = parseLfsBatchObject(object)
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error('LFS batch object invalide')
|
||||||
|
}
|
||||||
|
objects.push(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLfsAuthorizationHeader(remote: GitRemoteConfig) {
|
||||||
|
if (remote.provider === 'github') return `token ${remote.token}`
|
||||||
|
|
||||||
|
if (!remote.username) {
|
||||||
|
throw new Error('GIT_USERNAME non configure pour Git LFS sur Gitea')
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Basic ${Buffer.from(`${remote.username}:${remote.token}`, 'utf-8').toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLfsPointer(sha256: string, size: number): string {
|
||||||
|
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLfsPointer(content: string): { oid: string; size: number } | null {
|
||||||
|
if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null
|
||||||
|
const sizeMatch = content.match(/^size (\d+)$/m)
|
||||||
|
const oidMatch = content.match(/^oid sha256:([a-f0-9]{64})$/m)
|
||||||
|
if (!sizeMatch || !oidMatch) return null
|
||||||
|
return { oid: oidMatch[1], size: parseInt(sizeMatch[1], 10) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitLfsFiles(files: PushFile[]) {
|
||||||
|
const lfsFiles: LfsPushFile[] = []
|
||||||
|
const regularFiles: PushFile[] = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (isLfsFile(file.path)) {
|
||||||
|
const buffer = Buffer.from(file.contentBase64, 'base64')
|
||||||
|
const oid = createHash('sha256').update(buffer).digest('hex')
|
||||||
|
lfsFiles.push({ ...file, oid, size: buffer.length })
|
||||||
|
} else {
|
||||||
|
regularFiles.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lfsFiles, regularFiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadToLfs(remote: GitRemoteConfig, objects: LfsObject[]): Promise<void> {
|
||||||
|
if (objects.length === 0) return
|
||||||
|
|
||||||
|
const batches = chunkArray(objects, LFS_BATCH_SIZE)
|
||||||
|
|
||||||
|
for (let i = 0; i < batches.length; i++) {
|
||||||
|
await uploadToLfsBatch(remote, batches[i], i + 1, batches.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToLfsBatch(
|
||||||
|
remote: GitRemoteConfig,
|
||||||
|
objects: LfsObject[],
|
||||||
|
batchNumber: number,
|
||||||
|
totalBatches: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const startedAt = performance.now()
|
||||||
|
logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} started`, startedAt, {
|
||||||
|
objects: objects.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchRes = await fetch(remote.lfsBatchUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.git-lfs+json',
|
||||||
|
'Content-Type': 'application/vnd.git-lfs+json',
|
||||||
|
'Authorization': getLfsAuthorizationHeader(remote),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
operation: 'upload',
|
||||||
|
transfers: ['basic'],
|
||||||
|
objects: objects.map((object) => ({ oid: object.oid, size: object.size })),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!batchRes.ok) {
|
||||||
|
const text = await batchRes.text()
|
||||||
|
console.error(`[ERROR] Git LFS -> Batch ${batchNumber}/${totalBatches} failed | Timer: ${formatElapsed(startedAt)}`, {
|
||||||
|
objects: objects.length,
|
||||||
|
status: batchRes.status,
|
||||||
|
})
|
||||||
|
throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchData: unknown = await batchRes.json()
|
||||||
|
const batchObjects = parseLfsBatchResponse(batchData)
|
||||||
|
const objectMap = new Map(objects.map((object) => [object.oid, object]))
|
||||||
|
|
||||||
|
for (const object of batchObjects) {
|
||||||
|
if (object.error) {
|
||||||
|
throw new Error(`LFS error for ${object.oid}: ${object.error.message} (${object.error.code})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!object.actions?.upload) continue
|
||||||
|
|
||||||
|
const local = objectMap.get(object.oid)
|
||||||
|
if (!local) continue
|
||||||
|
|
||||||
|
const uploadAction = object.actions.upload
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
...uploadAction.header,
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadRes = await fetch(uploadAction.href, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: Buffer.from(local.contentBase64, 'base64'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
const text = await uploadRes.text()
|
||||||
|
throw new Error(`LFS upload failed for ${object.oid} (${uploadRes.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.actions.verify) {
|
||||||
|
const verifyAction = object.actions.verify
|
||||||
|
const verifyHeaders: Record<string, string> = {
|
||||||
|
'Accept': 'application/vnd.git-lfs+json',
|
||||||
|
'Content-Type': 'application/vnd.git-lfs+json',
|
||||||
|
...verifyAction.header,
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyRes = await fetch(verifyAction.href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: verifyHeaders,
|
||||||
|
body: JSON.stringify({ oid: object.oid, size: object.size }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verifyRes.ok) {
|
||||||
|
const text = await verifyRes.text()
|
||||||
|
throw new Error(`LFS verify failed for ${object.oid} (${verifyRes.status}): ${text}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} done`, startedAt, {
|
||||||
|
objects: objects.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { getRemoteFileEntry, getRequiredContentEntrySha, readRemoteFolder } from '../content'
|
||||||
|
import { getRepoApiPath, requestGitJson } from '../http'
|
||||||
|
import { buildLfsPointer, splitLfsFiles, uploadToLfs } from '../lfs'
|
||||||
|
import type { GitProvider, GitRemoteConfig, LfsPushFile, PushFilesParams } from '../types'
|
||||||
|
import type { PushFile } from '@/lib/types'
|
||||||
|
import { isRecord } from '@/lib/guards'
|
||||||
|
|
||||||
|
interface GiteaFileOperation {
|
||||||
|
content?: string
|
||||||
|
operation: 'create' | 'update' | 'delete'
|
||||||
|
path: string
|
||||||
|
sha?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGiteaCommitUrl(value: unknown, remote: GitRemoteConfig, branch: string) {
|
||||||
|
if (isRecord(value) && isRecord(value.commit)) {
|
||||||
|
if (typeof value.commit.html_url === 'string') return value.commit.html_url
|
||||||
|
if (typeof value.commit.sha === 'string') return `${remote.webUrl}/commit/${value.commit.sha}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${remote.webUrl}/commits/branch/${branch}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommittedFiles(regularFiles: PushFile[], lfsFiles: LfsPushFile[]): PushFile[] {
|
||||||
|
return [
|
||||||
|
...regularFiles,
|
||||||
|
...lfsFiles.map((file) => ({
|
||||||
|
path: file.path,
|
||||||
|
contentBase64: Buffer.from(buildLfsPointer(file.oid, file.size), 'utf-8').toString('base64'),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGiteaOperations(
|
||||||
|
remote: GitRemoteConfig,
|
||||||
|
branch: string,
|
||||||
|
committedFiles: PushFile[],
|
||||||
|
deletePaths: string[],
|
||||||
|
) {
|
||||||
|
const newFilePaths = new Set(committedFiles.map((file) => file.path))
|
||||||
|
const operations: GiteaFileOperation[] = []
|
||||||
|
|
||||||
|
for (const file of committedFiles) {
|
||||||
|
const existing = await getRemoteFileEntry(remote, file.path, branch)
|
||||||
|
operations.push({
|
||||||
|
content: file.contentBase64,
|
||||||
|
operation: existing ? 'update' : 'create',
|
||||||
|
path: file.path,
|
||||||
|
sha: existing ? getRequiredContentEntrySha(existing, file.path) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of deletePaths) {
|
||||||
|
if (newFilePaths.has(path)) continue
|
||||||
|
|
||||||
|
const existing = await getRemoteFileEntry(remote, path, branch)
|
||||||
|
if (!existing) continue
|
||||||
|
|
||||||
|
operations.push({
|
||||||
|
operation: 'delete',
|
||||||
|
path,
|
||||||
|
sha: getRequiredContentEntrySha(existing, path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return operations
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGiteaProvider(remote: GitRemoteConfig, branch: string): GitProvider {
|
||||||
|
return {
|
||||||
|
getRemoteFolder(folderPath) {
|
||||||
|
return readRemoteFolder(remote, branch, folderPath)
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushFiles({ files, deletePaths, commitMessage }: PushFilesParams) {
|
||||||
|
const { lfsFiles, regularFiles } = splitLfsFiles(files)
|
||||||
|
|
||||||
|
await uploadToLfs(
|
||||||
|
remote,
|
||||||
|
lfsFiles.map((file) => ({ oid: file.oid, size: file.size, contentBase64: file.contentBase64 })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const committedFiles = buildCommittedFiles(regularFiles, lfsFiles)
|
||||||
|
const operations = await buildGiteaOperations(remote, branch, committedFiles, deletePaths)
|
||||||
|
|
||||||
|
if (operations.length === 0) {
|
||||||
|
return { commitUrl: `${remote.webUrl}/commits/branch/${branch}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await requestGitJson(remote, `${getRepoApiPath(remote)}/contents`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
branch,
|
||||||
|
files: operations,
|
||||||
|
message: commitMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { commitUrl: getGiteaCommitUrl(data, remote, branch) }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Octokit } from '@octokit/rest'
|
||||||
|
import { readRemoteFolder } from '../content'
|
||||||
|
import { buildLfsPointer, splitLfsFiles, uploadToLfs } from '../lfs'
|
||||||
|
import type { GitProvider, GitRemoteConfig, PushFilesParams } from '../types'
|
||||||
|
|
||||||
|
export function createGitHubProvider(remote: GitRemoteConfig, branch: string): GitProvider {
|
||||||
|
const octokit = new Octokit({
|
||||||
|
auth: remote.token,
|
||||||
|
baseUrl: remote.apiBaseUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
getRemoteFolder(folderPath) {
|
||||||
|
return readRemoteFolder(remote, branch, folderPath)
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushFiles({ files, deletePaths, commitMessage }: PushFilesParams) {
|
||||||
|
const { lfsFiles, regularFiles } = splitLfsFiles(files)
|
||||||
|
|
||||||
|
await uploadToLfs(
|
||||||
|
remote,
|
||||||
|
lfsFiles.map((file) => ({ oid: file.oid, size: file.size, contentBase64: file.contentBase64 })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { owner, repo } = remote
|
||||||
|
const { data: ref } = await octokit.git.getRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: `heads/${branch}`,
|
||||||
|
})
|
||||||
|
const latestCommitSha = ref.object.sha
|
||||||
|
|
||||||
|
const { data: commit } = await octokit.git.getCommit({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
commit_sha: latestCommitSha,
|
||||||
|
})
|
||||||
|
|
||||||
|
const allFiles = [...regularFiles, ...lfsFiles]
|
||||||
|
|
||||||
|
const blobResults = await Promise.all(
|
||||||
|
allFiles.map((file) => {
|
||||||
|
const lfs = lfsFiles.find((lfsFile) => lfsFile.path === file.path)
|
||||||
|
if (lfs) {
|
||||||
|
const pointer = buildLfsPointer(lfs.oid, lfs.size)
|
||||||
|
return octokit.git.createBlob({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
content: Buffer.from(pointer, 'utf-8').toString('base64'),
|
||||||
|
encoding: 'base64',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return octokit.git.createBlob({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
content: file.contentBase64,
|
||||||
|
encoding: 'base64',
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const newFilePaths = new Set(files.map((file) => file.path))
|
||||||
|
const deleteEntries = deletePaths
|
||||||
|
.filter((path) => !newFilePaths.has(path))
|
||||||
|
.map((path) => ({
|
||||||
|
path,
|
||||||
|
mode: '100644' as const,
|
||||||
|
type: 'blob' as const,
|
||||||
|
sha: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { data: newTree } = await octokit.git.createTree({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
base_tree: commit.tree.sha,
|
||||||
|
tree: [
|
||||||
|
...allFiles.map((file, index) => ({
|
||||||
|
path: file.path,
|
||||||
|
mode: '100644' as const,
|
||||||
|
type: 'blob' as const,
|
||||||
|
sha: blobResults[index].data.sha,
|
||||||
|
})),
|
||||||
|
...deleteEntries,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: newCommit } = await octokit.git.createCommit({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
message: commitMessage,
|
||||||
|
tree: newTree.sha,
|
||||||
|
parents: [latestCommitSha],
|
||||||
|
})
|
||||||
|
|
||||||
|
await octokit.git.updateRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: `heads/${branch}`,
|
||||||
|
sha: newCommit.sha,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { commitUrl: newCommit.html_url || `${remote.webUrl}/commit/${newCommit.sha}` }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { PushFile, RemoteFile } from '@/lib/types'
|
||||||
|
|
||||||
|
export type GitProviderName = 'github' | 'gitea'
|
||||||
|
|
||||||
|
export interface GitRemoteConfig {
|
||||||
|
apiBaseUrl: string
|
||||||
|
lfsBatchUrl: string
|
||||||
|
owner: string
|
||||||
|
provider: GitProviderName
|
||||||
|
repo: string
|
||||||
|
token: string
|
||||||
|
username?: string
|
||||||
|
webUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LfsObject {
|
||||||
|
oid: string
|
||||||
|
size: number
|
||||||
|
contentBase64: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LfsPushFile = PushFile & {
|
||||||
|
oid: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushFilesParams {
|
||||||
|
commitMessage: string
|
||||||
|
deletePaths: string[]
|
||||||
|
files: PushFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitProvider {
|
||||||
|
getRemoteFolder(folderPath: string): Promise<{ exists: boolean; files: RemoteFile[] }>
|
||||||
|
pushFiles(params: PushFilesParams): Promise<{ commitUrl: string }>
|
||||||
|
}
|
||||||
-403
@@ -1,403 +0,0 @@
|
|||||||
import { createHash } from 'crypto'
|
|
||||||
import { Octokit } from '@octokit/rest'
|
|
||||||
import { LFS_EXTENSIONS } from './constants'
|
|
||||||
import type { PushFile, RemoteFile } from './types'
|
|
||||||
|
|
||||||
const LFS_BATCH_SIZE = 100
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Octokit helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function isHttpError(err: unknown): err is { status: number } {
|
|
||||||
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOctokit(): Octokit {
|
|
||||||
const token = process.env.GITHUB_TOKEN
|
|
||||||
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 configure')
|
|
||||||
|
|
||||||
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/)
|
|
||||||
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/)
|
|
||||||
const shortMatch = url.match(/^([^/]+)\/([^/]+)$/)
|
|
||||||
|
|
||||||
const match = httpsMatch || sshMatch || shortMatch
|
|
||||||
if (!match) throw new Error(`Format GIT_REPO_URL invalide: "${url}"`)
|
|
||||||
|
|
||||||
return { owner: match[1], repo: match[2] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Git LFS helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Check if a file path should be tracked by LFS based on its extension. */
|
|
||||||
function isLfsFile(filePath: string): boolean {
|
|
||||||
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
|
|
||||||
return LFS_EXTENSIONS.has(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build an LFS pointer file (text content stored in the Git blob). */
|
|
||||||
function buildLfsPointer(sha256: string, size: number): string {
|
|
||||||
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse an LFS pointer to extract the real file size. Returns null if not a pointer. */
|
|
||||||
function parseLfsPointer(content: string): { oid: string; size: number } | null {
|
|
||||||
if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null
|
|
||||||
const sizeMatch = content.match(/^size (\d+)$/m)
|
|
||||||
const oidMatch = content.match(/^oid sha256:([a-f0-9]{64})$/m)
|
|
||||||
if (!sizeMatch || !oidMatch) return null
|
|
||||||
return { oid: oidMatch[1], size: parseInt(sizeMatch[1], 10) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsed(startedAt: number) {
|
|
||||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
function logInfo(step: string, action: string, startedAt: number, details?: Record<string, unknown>) {
|
|
||||||
console.info(`[INFO] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunkArray<T>(items: T[], size: number) {
|
|
||||||
const chunks: T[][] = []
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i += size) {
|
|
||||||
chunks.push(items.slice(i, i + size))
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LfsObject {
|
|
||||||
oid: string
|
|
||||||
size: number
|
|
||||||
contentBase64: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload binary objects to the Git LFS server via the Batch API.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. POST to the LFS batch endpoint with operation "upload"
|
|
||||||
* 2. For each object that has an "upload" action, PUT the binary content
|
|
||||||
* 3. If the server omits "actions", the object already exists — skip upload
|
|
||||||
*/
|
|
||||||
async function uploadToLfs(
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
objects: LfsObject[],
|
|
||||||
): Promise<void> {
|
|
||||||
if (objects.length === 0) return
|
|
||||||
|
|
||||||
const batches = chunkArray(objects, LFS_BATCH_SIZE)
|
|
||||||
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
|
||||||
await uploadToLfsBatch(owner, repo, batches[i], i + 1, batches.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadToLfsBatch(
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
objects: LfsObject[],
|
|
||||||
batchNumber: number,
|
|
||||||
totalBatches: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const startedAt = performance.now()
|
|
||||||
logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} started`, startedAt, {
|
|
||||||
objects: objects.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
const token = process.env.GITHUB_TOKEN!
|
|
||||||
const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch`
|
|
||||||
|
|
||||||
// 1. Batch request — ask for upload URLs
|
|
||||||
const batchRes = await fetch(lfsUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/vnd.git-lfs+json',
|
|
||||||
'Content-Type': 'application/vnd.git-lfs+json',
|
|
||||||
'Authorization': `token ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
operation: 'upload',
|
|
||||||
transfers: ['basic'],
|
|
||||||
objects: objects.map((o) => ({ oid: o.oid, size: o.size })),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!batchRes.ok) {
|
|
||||||
const text = await batchRes.text()
|
|
||||||
console.error(`[ERROR] Git LFS -> Batch ${batchNumber}/${totalBatches} failed | Timer: ${formatElapsed(startedAt)}`, {
|
|
||||||
objects: objects.length,
|
|
||||||
status: batchRes.status,
|
|
||||||
})
|
|
||||||
throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchData = (await batchRes.json()) as {
|
|
||||||
objects: Array<{
|
|
||||||
oid: string
|
|
||||||
size: number
|
|
||||||
actions?: {
|
|
||||||
upload?: { href: string; header?: Record<string, string> }
|
|
||||||
verify?: { href: string; header?: Record<string, string> }
|
|
||||||
}
|
|
||||||
error?: { code: number; message: string }
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Upload each object that has an upload action
|
|
||||||
const objectMap = new Map(objects.map((o) => [o.oid, o]))
|
|
||||||
|
|
||||||
for (const obj of batchData.objects) {
|
|
||||||
if (obj.error) {
|
|
||||||
throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No actions = server already has this object, skip
|
|
||||||
if (!obj.actions?.upload) continue
|
|
||||||
|
|
||||||
const local = objectMap.get(obj.oid)
|
|
||||||
if (!local) continue
|
|
||||||
|
|
||||||
const uploadAction = obj.actions.upload
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
...uploadAction.header,
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = Buffer.from(local.contentBase64, 'base64')
|
|
||||||
|
|
||||||
const uploadRes = await fetch(uploadAction.href, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!uploadRes.ok) {
|
|
||||||
const text = await uploadRes.text()
|
|
||||||
throw new Error(`LFS upload failed for ${obj.oid} (${uploadRes.status}): ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Verify if required
|
|
||||||
if (obj.actions.verify) {
|
|
||||||
const verifyAction = obj.actions.verify
|
|
||||||
const verifyHeaders: Record<string, string> = {
|
|
||||||
'Accept': 'application/vnd.git-lfs+json',
|
|
||||||
'Content-Type': 'application/vnd.git-lfs+json',
|
|
||||||
...verifyAction.header,
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyRes = await fetch(verifyAction.href, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: verifyHeaders,
|
|
||||||
body: JSON.stringify({ oid: obj.oid, size: obj.size }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!verifyRes.ok) {
|
|
||||||
const text = await verifyRes.text()
|
|
||||||
throw new Error(`LFS verify failed for ${obj.oid} (${verifyRes.status}): ${text}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} done`, startedAt, {
|
|
||||||
objects: objects.length,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Read remote folder contents (with real file sizes for LFS files)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function getRemoteFolder(
|
|
||||||
folderPath: string,
|
|
||||||
): Promise<{ exists: boolean; files: RemoteFile[] }> {
|
|
||||||
const octokit = getOctokit()
|
|
||||||
const { owner, repo } = parseRepoUrl()
|
|
||||||
const branch = process.env.GIT_BRANCH ?? 'main'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await octokit.repos.getContent({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
path: folderPath,
|
|
||||||
ref: branch,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
return { exists: false, files: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// For LFS-tracked files, the "size" from getContent is the pointer size (~130 bytes),
|
|
||||||
// not the real file size. We need to fetch each LFS pointer to get the real size.
|
|
||||||
const files: RemoteFile[] = await Promise.all(
|
|
||||||
data.map(async (f): Promise<RemoteFile> => {
|
|
||||||
if (!isLfsFile(f.name) || f.size > 1024) {
|
|
||||||
// Not LFS or too large to be a pointer — use size as-is
|
|
||||||
return { name: f.name, size: f.size }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the blob content to check if it's an LFS pointer
|
|
||||||
try {
|
|
||||||
const { data: fileData } = await octokit.repos.getContent({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
path: `${folderPath}/${f.name}`,
|
|
||||||
ref: branch,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!Array.isArray(fileData) && 'content' in fileData && fileData.content) {
|
|
||||||
const content = Buffer.from(fileData.content, 'base64').toString('utf-8')
|
|
||||||
const pointer = parseLfsPointer(content)
|
|
||||||
if (pointer) {
|
|
||||||
return { name: f.name, size: pointer.size }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!isHttpError(err) || err.status !== 404) throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: f.name, size: f.size }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return { exists: true, files }
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (isHttpError(err) && err.status === 404) {
|
|
||||||
return { exists: false, files: [] }
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Push all files in a single commit (with optional deletions + LFS support)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function pushAllToGitHub(
|
|
||||||
files: PushFile[],
|
|
||||||
deletePaths: string[],
|
|
||||||
commitMessage: string,
|
|
||||||
): Promise<{ commitUrl: string }> {
|
|
||||||
const octokit = getOctokit()
|
|
||||||
const { owner, repo } = parseRepoUrl()
|
|
||||||
const branch = process.env.GIT_BRANCH ?? 'main'
|
|
||||||
|
|
||||||
// --- Separate LFS files from regular files ---
|
|
||||||
const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = []
|
|
||||||
const regularFiles: PushFile[] = []
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
if (isLfsFile(f.path)) {
|
|
||||||
const buf = Buffer.from(f.contentBase64, 'base64')
|
|
||||||
const oid = createHash('sha256').update(buf).digest('hex')
|
|
||||||
lfsFiles.push({ ...f, oid, size: buf.length })
|
|
||||||
} else {
|
|
||||||
regularFiles.push(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Upload LFS objects to the LFS server ---
|
|
||||||
if (lfsFiles.length > 0) {
|
|
||||||
await uploadToLfs(
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
lfsFiles.map((f) => ({ oid: f.oid, size: f.size, contentBase64: f.contentBase64 })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get latest commit on branch
|
|
||||||
const { data: ref } = await octokit.git.getRef({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
ref: `heads/${branch}`,
|
|
||||||
})
|
|
||||||
const latestCommitSha = ref.object.sha
|
|
||||||
|
|
||||||
// 2. Get that commit's tree
|
|
||||||
const { data: commit } = await octokit.git.getCommit({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
commit_sha: latestCommitSha,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Create blobs — LFS files get pointer blobs, regular files get raw blobs
|
|
||||||
const allFiles = [...regularFiles, ...lfsFiles]
|
|
||||||
|
|
||||||
const blobResults = await Promise.all(
|
|
||||||
allFiles.map((f) => {
|
|
||||||
const lfs = lfsFiles.find((lf) => lf.path === f.path)
|
|
||||||
if (lfs) {
|
|
||||||
// Create a blob with the LFS pointer text (NOT the binary content)
|
|
||||||
const pointer = buildLfsPointer(lfs.oid, lfs.size)
|
|
||||||
return octokit.git.createBlob({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
content: Buffer.from(pointer, 'utf-8').toString('base64'),
|
|
||||||
encoding: 'base64',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Regular file — push content as-is
|
|
||||||
return octokit.git.createBlob({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
content: f.contentBase64,
|
|
||||||
encoding: 'base64',
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 4. Build tree entries: new/changed files + deletions
|
|
||||||
const newFilePaths = new Set(files.map((f) => f.path))
|
|
||||||
const deleteEntries = deletePaths
|
|
||||||
.filter((p) => !newFilePaths.has(p))
|
|
||||||
.map((p) => ({
|
|
||||||
path: p,
|
|
||||||
mode: '100644' as const,
|
|
||||||
type: 'blob' as const,
|
|
||||||
sha: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { data: newTree } = await octokit.git.createTree({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
base_tree: commit.tree.sha,
|
|
||||||
tree: [
|
|
||||||
...allFiles.map((f, i) => ({
|
|
||||||
path: f.path,
|
|
||||||
mode: '100644' as const,
|
|
||||||
type: 'blob' as const,
|
|
||||||
sha: blobResults[i].data.sha,
|
|
||||||
})),
|
|
||||||
...deleteEntries,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. Create a single commit
|
|
||||||
const { data: newCommit } = await octokit.git.createCommit({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
message: commitMessage,
|
|
||||||
tree: newTree.sha,
|
|
||||||
parents: [latestCommitSha],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 6. Update branch ref
|
|
||||||
await octokit.git.updateRef({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
ref: `heads/${branch}`,
|
|
||||||
sha: newCommit.sha,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { commitUrl: newCommit.html_url }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown, fallback = 'Erreur inconnue') {
|
||||||
|
return error instanceof Error ? error.message : fallback
|
||||||
|
}
|
||||||
+1
-32
@@ -1,11 +1,5 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Nextcloud WebDAV client
|
|
||||||
// Uses native fetch — no npm package needed.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const MAX_VERSIONS = 1000
|
const MAX_VERSIONS = 1000
|
||||||
|
|
||||||
// Lazy-cached config to avoid recomputing on every request
|
|
||||||
let cachedConfig: { davBase: string; auth: string } | null = null
|
let cachedConfig: { davBase: string; auth: string } | null = null
|
||||||
|
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
@@ -19,7 +13,6 @@ function getConfig() {
|
|||||||
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)')
|
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public share WebDAV: https://cloud.example.com/public.php/webdav/
|
|
||||||
const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav`
|
const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav`
|
||||||
const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64')
|
const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64')
|
||||||
|
|
||||||
@@ -32,10 +25,6 @@ function davUrl(davBase: string, path: string): string {
|
|||||||
return `${davBase}/${clean}`
|
return `${davBase}/${clean}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Low-level WebDAV helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function davRequest(
|
async function davRequest(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -57,12 +46,7 @@ async function davRequest(
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
async function folderExists(path: string): Promise<boolean> {
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Check if a folder exists on the Nextcloud instance. */
|
|
||||||
export async function folderExists(path: string): Promise<boolean> {
|
|
||||||
const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' })
|
const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' })
|
||||||
|
|
||||||
if (res.status === 404) return false
|
if (res.status === 404) return false
|
||||||
@@ -72,10 +56,6 @@ export async function folderExists(path: string): Promise<boolean> {
|
|||||||
throw new Error(`PROPFIND ${path} failed (${res.status}): ${text.slice(0, 200)}`)
|
throw new Error(`PROPFIND ${path} failed (${res.status}): ${text.slice(0, 200)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a folder and all parent segments if they don't exist.
|
|
||||||
* Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists).
|
|
||||||
*/
|
|
||||||
export async function mkdirRecursive(path: string): Promise<void> {
|
export async function mkdirRecursive(path: string): Promise<void> {
|
||||||
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
|
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
|
||||||
let current = ''
|
let current = ''
|
||||||
@@ -84,14 +64,12 @@ export async function mkdirRecursive(path: string): Promise<void> {
|
|||||||
current += '/' + seg
|
current += '/' + seg
|
||||||
const res = await davRequest('MKCOL', current + '/')
|
const res = await davRequest('MKCOL', current + '/')
|
||||||
if (res.status !== 201 && res.status !== 405) {
|
if (res.status !== 201 && res.status !== 405) {
|
||||||
// 201 = created, 405 = already exists — both are fine
|
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
|
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload a file (overwrite if exists). */
|
|
||||||
export async function uploadFile(path: string, content: Buffer): Promise<void> {
|
export async function uploadFile(path: string, content: Buffer): Promise<void> {
|
||||||
const res = await davRequest('PUT', path, content, {
|
const res = await davRequest('PUT', path, content, {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
@@ -104,7 +82,6 @@ export async function uploadFile(path: string, content: Buffer): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move (rename) a folder or file. */
|
|
||||||
export async function moveFolder(from: string, to: string): Promise<void> {
|
export async function moveFolder(from: string, to: string): Promise<void> {
|
||||||
const { davBase } = getConfig()
|
const { davBase } = getConfig()
|
||||||
const destination = davUrl(davBase, to) + '/'
|
const destination = davUrl(davBase, to) + '/'
|
||||||
@@ -120,14 +97,6 @@ export async function moveFolder(from: string, to: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// High-level: find next available version folder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the next available Vx folder for archiving.
|
|
||||||
* E.g. if V1/coffeetest exists but V2/coffeetest doesn't, returns "V2".
|
|
||||||
*/
|
|
||||||
export async function findNextVersion(
|
export async function findNextVersion(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
folderName: string,
|
folderName: string,
|
||||||
|
|||||||
+38
-28
@@ -1,42 +1,40 @@
|
|||||||
import { extname } from 'path'
|
import { extname } from 'path'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { sanitizeFilename } from './sanitize'
|
import { sanitizeFilename } from './sanitize'
|
||||||
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants'
|
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE, TEXTURE_EXTENSIONS } from './constants'
|
||||||
import type { ParsedFile } from './types'
|
import { getTextureNamingError } from './asset-naming'
|
||||||
|
import type { GitModelMode, ParsedFile } from './types'
|
||||||
|
|
||||||
export interface ParsedUpload {
|
interface ParsedUpload {
|
||||||
folderName: string
|
folderName: string
|
||||||
files: ParsedFile[]
|
files: ParsedFile[]
|
||||||
/** Any extra string fields from the FormData (e.g. "action") */
|
gitModelMode: GitModelMode
|
||||||
extra: Record<string, string>
|
}
|
||||||
|
|
||||||
|
function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode {
|
||||||
|
if (value === 'draco-glb' || value === 'keep-gltf') return value
|
||||||
|
throw new Error('Mode Git invalide')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a multi-file FormData upload request.
|
|
||||||
* Validates file extensions, file sizes, and returns parsed files.
|
|
||||||
* Extra string fields (beyond folderName, files, fileTypes, textureNames)
|
|
||||||
* are returned in `extra`.
|
|
||||||
*/
|
|
||||||
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
|
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
const folderValue = formData.get('folderName')
|
const folderValue = formData.get('folderName')
|
||||||
const folderName = typeof folderValue === 'string' ? folderValue.trim() || 'assets' : 'assets'
|
if (typeof folderValue !== 'string' || folderValue.trim() === '') {
|
||||||
|
throw new Error('Nom de dossier manquant')
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderName = folderValue.trim()
|
||||||
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
||||||
|
if (!safeFolderName) {
|
||||||
|
throw new Error('Nom de dossier invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitModelMode = parseGitModelMode(formData.get('gitModelMode'))
|
||||||
|
|
||||||
const rawFiles = formData.getAll('files')
|
const rawFiles = formData.getAll('files')
|
||||||
const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string')
|
const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string')
|
||||||
const textureNames = formData.getAll('textureNames').filter((value): value is string => typeof value === 'string')
|
const textureNames = formData.getAll('textureNames').filter((value): value is string => typeof value === 'string')
|
||||||
|
|
||||||
// Collect extra string fields
|
|
||||||
const knownKeys = new Set(['folderName', 'files', 'fileTypes', 'textureNames'])
|
|
||||||
const extra: Record<string, string> = {}
|
|
||||||
for (const [key, value] of formData.entries()) {
|
|
||||||
if (!knownKeys.has(key) && typeof value === 'string') {
|
|
||||||
extra[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runtime validation: ensure entries are actual File objects
|
|
||||||
const fileEntries: File[] = []
|
const fileEntries: File[] = []
|
||||||
for (const entry of rawFiles) {
|
for (const entry of rawFiles) {
|
||||||
if (!(entry instanceof File)) {
|
if (!(entry instanceof File)) {
|
||||||
@@ -56,7 +54,6 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
|||||||
const file = fileEntries[i]
|
const file = fileEntries[i]
|
||||||
if (!file || file.size === 0) continue
|
if (!file || file.size === 0) continue
|
||||||
|
|
||||||
// File size limit
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
|
`Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
|
||||||
@@ -67,10 +64,10 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
|||||||
const texName = textureNames[i] || ''
|
const texName = textureNames[i] || ''
|
||||||
|
|
||||||
const originalSafe = sanitizeFilename(file.name)
|
const originalSafe = sanitizeFilename(file.name)
|
||||||
const ext = extname(originalSafe).toLowerCase()
|
const originalExt = extname(originalSafe).toLowerCase()
|
||||||
|
|
||||||
if (!ALL_ALLOWED_EXTENSIONS.has(ext)) {
|
if (!ALL_ALLOWED_EXTENSIONS.has(originalExt)) {
|
||||||
throw new Error(`Extension non autorisee: "${ext}"`)
|
throw new Error(`Extension non autorisee: "${originalExt}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let filename: string
|
let filename: string
|
||||||
@@ -80,7 +77,20 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
|||||||
filename = originalSafe
|
filename = originalSafe
|
||||||
}
|
}
|
||||||
|
|
||||||
const isModel = MODEL_EXTENSIONS.has(ext)
|
const filenameExt = extname(filename).toLowerCase()
|
||||||
|
if (filenameExt !== originalExt) {
|
||||||
|
throw new Error(`Nom de fichier incoherent : ${filename} ne correspond pas a l'extension originale ${originalExt}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textureNamingError = TEXTURE_EXTENSIONS.has(filenameExt)
|
||||||
|
? getTextureNamingError(filename)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (textureNamingError) {
|
||||||
|
throw new Error(textureNamingError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isModel = MODEL_EXTENSIONS.has(filenameExt)
|
||||||
if (isModel) {
|
if (isModel) {
|
||||||
if (filename.toLowerCase() !== 'model.gltf') {
|
if (filename.toLowerCase() !== 'model.gltf') {
|
||||||
throw new Error('Le modele doit etre nomme model.gltf')
|
throw new Error('Le modele doit etre nomme model.gltf')
|
||||||
@@ -100,5 +110,5 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
|||||||
throw new Error('Un seul fichier model.gltf est autorise')
|
throw new Error('Un seul fichier model.gltf est autorise')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { folderName: safeFolderName, files: parsed, extra }
|
return { folderName: safeFolderName, files: parsed, gitModelMode }
|
||||||
}
|
}
|
||||||
|
|||||||
+368
-25
@@ -1,63 +1,324 @@
|
|||||||
import { compressTextureBuffer } from '@/lib/texture-compression'
|
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, prepareTextureDelivery } from '@/lib/texture-compression'
|
||||||
import { classifyAssetCategory } from '@/lib/asset-classification'
|
import { classifyAssetCategory } from '@/lib/asset-classification'
|
||||||
|
import { normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
|
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
|
||||||
|
import { getErrorMessage, isRecord } from '@/lib/guards'
|
||||||
import { getModelAssetPath } from '@/lib/model-paths'
|
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 {
|
interface PrepareGitAssetsParams {
|
||||||
folderName: string
|
folderName: string
|
||||||
parsedFiles: ParsedFile[]
|
parsedFiles: ParsedFile[]
|
||||||
|
gitModelMode: GitModelMode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PrepareGitAssetsResult {
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
||||||
filesToPush: PushFile[]
|
type JsonObject = { [key: string]: JsonValue }
|
||||||
modelFilename: string
|
|
||||||
assetSummaries: PreparedAssetSummary[]
|
interface PreparedTexturePlan {
|
||||||
|
filename: string
|
||||||
|
buffer: Buffer
|
||||||
|
category: PreparedAssetSummary['category']
|
||||||
compressed: boolean
|
compressed: boolean
|
||||||
compressionError?: string
|
format: 'ktx2' | 'webp' | 'original'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareGitAssets({
|
const KHR_TEXTURE_BASISU = 'KHR_texture_basisu'
|
||||||
folderName,
|
|
||||||
parsedFiles,
|
function isJsonValue(value: unknown): value is JsonValue {
|
||||||
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
|
if (value === null) return true
|
||||||
|
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.every(isJsonValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isRecord(value) && Object.values(value).every(isJsonValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonValue(content: string) {
|
||||||
|
const parsed: unknown = JSON.parse(content)
|
||||||
|
|
||||||
|
if (!isJsonValue(parsed)) {
|
||||||
|
throw new Error('model.gltf contient un JSON invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonObject(value: JsonValue): value is JsonObject {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJsonObjectArray(value: JsonValue | undefined) {
|
||||||
|
if (!Array.isArray(value)) return null
|
||||||
|
return value.every(isJsonObject) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringArray(value: JsonValue | undefined) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExtensionUsed(gltf: JsonObject, extensionName: string) {
|
||||||
|
const extensionsUsed = new Set(getStringArray(gltf.extensionsUsed))
|
||||||
|
extensionsUsed.add(extensionName)
|
||||||
|
gltf.extensionsUsed = [...extensionsUsed]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateExtensions(value: JsonObject): JsonObject {
|
||||||
|
if (!isJsonObject(value.extensions)) {
|
||||||
|
value.extensions = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureFilenameMap(parsedFiles: ParsedFile[]) {
|
||||||
|
const filenameMap = new Map<string, string>()
|
||||||
|
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
|
||||||
|
|
||||||
|
for (const file of parsedFiles) {
|
||||||
|
const ext = extname(file.filename).toLowerCase()
|
||||||
|
if (!TEXTURE_EXTENSIONS.has(ext)) continue
|
||||||
|
|
||||||
|
const normalizedFilename = normalizeTextureFilename(file.filename)
|
||||||
|
if (!normalizedFilename) continue
|
||||||
|
|
||||||
|
const normalizedKey = normalizedFilename.toLowerCase()
|
||||||
|
const group = normalizedGroups.get(normalizedKey) || []
|
||||||
|
group.push({ original: file.filename, normalized: normalizedFilename })
|
||||||
|
normalizedGroups.set(normalizedKey, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of normalizedGroups.values()) {
|
||||||
|
if (group.length > 1) continue
|
||||||
|
|
||||||
|
const [{ original, normalized }] = group
|
||||||
|
filenameMap.set(original.toLowerCase(), normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filenameMap
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferencedFilename(uri: string) {
|
||||||
|
const cleanUri = decodeURIComponent(uri.split(/[?#]/)[0] || '')
|
||||||
|
return cleanUri.split(/[\\/]/).pop()?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteGltfUris(value: JsonValue, filenameMap: Map<string, string>): JsonValue {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => rewriteGltfUris(entry, filenameMap))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object') return value
|
||||||
|
|
||||||
|
const rewritten: Record<string, JsonValue> = {}
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
if (key === 'uri' && typeof entry === 'string') {
|
||||||
|
const filename = getReferencedFilename(entry)
|
||||||
|
rewritten[key] = filename ? filenameMap.get(filename) || entry : entry
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rewritten[key] = rewriteGltfUris(entry, filenameMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKtx2TextureExtensions(value: JsonValue, ktx2Filenames: Set<string>) {
|
||||||
|
if (ktx2Filenames.size === 0 || !isJsonObject(value)) return value
|
||||||
|
|
||||||
|
const images = getJsonObjectArray(value.images)
|
||||||
|
const textures = getJsonObjectArray(value.textures)
|
||||||
|
if (!images || !textures) return value
|
||||||
|
|
||||||
|
let hasKtx2Texture = false
|
||||||
|
|
||||||
|
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
|
||||||
|
const image = images[imageIndex]
|
||||||
|
const uri = image.uri
|
||||||
|
if (typeof uri !== 'string') continue
|
||||||
|
|
||||||
|
const filename = getReferencedFilename(uri)
|
||||||
|
if (!filename || !ktx2Filenames.has(filename)) continue
|
||||||
|
|
||||||
|
image.mimeType = 'image/ktx2'
|
||||||
|
|
||||||
|
for (const texture of textures) {
|
||||||
|
if (texture.source !== imageIndex) continue
|
||||||
|
|
||||||
|
const extensions = getOrCreateExtensions(texture)
|
||||||
|
extensions[KHR_TEXTURE_BASISU] = { source: imageIndex }
|
||||||
|
delete texture.source
|
||||||
|
hasKtx2Texture = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasKtx2Texture) {
|
||||||
|
addExtensionUsed(value, KHR_TEXTURE_BASISU)
|
||||||
|
const extensionsRequired = new Set(getStringArray(value.extensionsRequired))
|
||||||
|
extensionsRequired.add(KHR_TEXTURE_BASISU)
|
||||||
|
value.extensionsRequired = [...extensionsRequired]
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareModelBuffer(
|
||||||
|
buffer: Buffer,
|
||||||
|
filenameMap: Map<string, string>,
|
||||||
|
ktx2Filenames = new Set<string>(),
|
||||||
|
) {
|
||||||
|
if (filenameMap.size === 0) return buffer
|
||||||
|
|
||||||
|
const parsed = parseJsonValue(buffer.toString('utf-8'))
|
||||||
|
const rewritten = rewriteGltfUris(parsed, filenameMap)
|
||||||
|
const withKtx2 = addKtx2TextureExtensions(rewritten, ktx2Filenames)
|
||||||
|
|
||||||
|
return Buffer.from(JSON.stringify(withKtx2, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompressionWarnings(warnings: string[]) {
|
||||||
|
if (warnings.length === 0) return undefined
|
||||||
|
if (warnings.length === 1) return warnings[0]
|
||||||
|
return `${warnings[0]} (${warnings.length - 1} autre(s) texture(s) en fallback WebP.)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function reserveDeliveryFilename(filename: string, usedStems: Set<string>) {
|
||||||
|
const extension = extname(filename)
|
||||||
|
const stem = filename.slice(0, -extension.length)
|
||||||
|
let candidateStem = stem
|
||||||
|
let suffix = 2
|
||||||
|
|
||||||
|
while (usedStems.has(candidateStem.toLowerCase())) {
|
||||||
|
candidateStem = `${stem}_${suffix}`
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usedStems.add(candidateStem.toLowerCase())
|
||||||
|
return `${candidateStem}${extension}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareTexturePlans(
|
||||||
|
parsedFiles: ParsedFile[],
|
||||||
|
textureFilenameMap: Map<string, string>,
|
||||||
|
) {
|
||||||
|
const plans = new Map<string, PreparedTexturePlan>()
|
||||||
|
const warnings: string[] = []
|
||||||
|
const usedDeliveryStems = new Set<string>()
|
||||||
|
|
||||||
|
for (const pf of parsedFiles) {
|
||||||
|
const ext = extname(pf.filename).toLowerCase()
|
||||||
|
if (!TEXTURE_EXTENSIONS.has(ext)) continue
|
||||||
|
|
||||||
|
const normalizedFilename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
|
||||||
|
const categoryFilename = normalizeTextureFilename(normalizedFilename) || normalizedFilename
|
||||||
|
const category = classifyAssetCategory(categoryFilename)
|
||||||
|
const deliveryFilename = reserveDeliveryFilename(normalizedFilename, usedDeliveryStems)
|
||||||
|
const delivery = await prepareTextureDelivery(deliveryFilename, pf.buffer)
|
||||||
|
|
||||||
|
if (delivery.warning) {
|
||||||
|
warnings.push(delivery.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
plans.set(pf.filename.toLowerCase(), {
|
||||||
|
filename: delivery.asset.filename,
|
||||||
|
buffer: delivery.asset.buffer,
|
||||||
|
category,
|
||||||
|
compressed: delivery.asset.compressed,
|
||||||
|
format: delivery.format,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plans,
|
||||||
|
warning: formatCompressionWarnings(warnings),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareSeparateFiles(
|
||||||
|
folderName: string,
|
||||||
|
parsedFiles: ParsedFile[],
|
||||||
|
textureFilenameMap: Map<string, string>,
|
||||||
|
) {
|
||||||
const filesToPush: PushFile[] = []
|
const filesToPush: PushFile[] = []
|
||||||
const assetSummaries: PreparedAssetSummary[] = []
|
const assetSummaries: PreparedAssetSummary[] = []
|
||||||
|
const { plans: texturePlans, warning: textureCompressionWarning } = await prepareTexturePlans(
|
||||||
|
parsedFiles,
|
||||||
|
textureFilenameMap,
|
||||||
|
)
|
||||||
|
const deliveryFilenameMap = new Map(textureFilenameMap)
|
||||||
|
const ktx2Filenames = new Set<string>()
|
||||||
let modelFilename = ''
|
let modelFilename = ''
|
||||||
let compressed = false
|
let compressed = false
|
||||||
let compressionError: string | undefined
|
let compressionError: string | undefined = textureCompressionWarning
|
||||||
|
|
||||||
|
for (const [originalFilename, texturePlan] of texturePlans) {
|
||||||
|
deliveryFilenameMap.set(originalFilename, texturePlan.filename)
|
||||||
|
|
||||||
|
if (texturePlan.format === 'ktx2') {
|
||||||
|
ktx2Filenames.add(texturePlan.filename.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const pf of parsedFiles) {
|
for (const pf of parsedFiles) {
|
||||||
let content = pf.buffer
|
let content = pf.buffer
|
||||||
|
let filename = pf.filename
|
||||||
|
|
||||||
if (pf.isModel) {
|
if (pf.isModel) {
|
||||||
|
content = prepareModelBuffer(pf.buffer, deliveryFilenameMap, ktx2Filenames)
|
||||||
modelFilename = pf.filename
|
modelFilename = pf.filename
|
||||||
|
|
||||||
assetSummaries.push({
|
assetSummaries.push({
|
||||||
filename: pf.filename,
|
filename,
|
||||||
kind: 'model',
|
kind: 'model',
|
||||||
compressed: false,
|
compressed: false,
|
||||||
})
|
})
|
||||||
} else {
|
} else if (texturePlans.has(pf.filename.toLowerCase())) {
|
||||||
const category = classifyAssetCategory(pf.filename)
|
const texturePlan = texturePlans.get(pf.filename.toLowerCase())
|
||||||
|
if (!texturePlan) continue
|
||||||
|
|
||||||
const textureResult = await compressTextureBuffer(pf.filename, pf.buffer)
|
compressed ||= texturePlan.compressed
|
||||||
content = textureResult.buffer
|
|
||||||
compressed ||= textureResult.compressed
|
|
||||||
|
|
||||||
if (textureResult.error && !compressionError) {
|
|
||||||
compressionError = textureResult.error
|
|
||||||
}
|
|
||||||
|
|
||||||
assetSummaries.push({
|
assetSummaries.push({
|
||||||
filename: pf.filename,
|
filename: texturePlan.filename,
|
||||||
|
kind: 'texture',
|
||||||
|
category: texturePlan.category,
|
||||||
|
compressed: texturePlan.compressed,
|
||||||
|
})
|
||||||
|
|
||||||
|
filesToPush.push({
|
||||||
|
path: getModelAssetPath(folderName, texturePlan.filename),
|
||||||
|
contentBase64: texturePlan.buffer.toString('base64'),
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
|
||||||
|
const categoryFilename = textureFilenameMap.get(pf.filename.toLowerCase()) || normalizeTextureFilename(pf.filename) || pf.filename
|
||||||
|
const category = classifyAssetCategory(categoryFilename)
|
||||||
|
|
||||||
|
assetSummaries.push({
|
||||||
|
filename,
|
||||||
kind: category === 'assets' ? 'asset' : 'texture',
|
kind: category === 'assets' ? 'asset' : 'texture',
|
||||||
category,
|
category,
|
||||||
compressed: textureResult.compressed,
|
compressed: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
filesToPush.push({
|
filesToPush.push({
|
||||||
path: getModelAssetPath(folderName, pf.filename),
|
path: getModelAssetPath(folderName, filename),
|
||||||
contentBase64: content.toString('base64'),
|
contentBase64: content.toString('base64'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -68,5 +329,87 @@ export async function prepareGitAssets({
|
|||||||
assetSummaries,
|
assetSummaries,
|
||||||
compressed,
|
compressed,
|
||||||
compressionError,
|
compressionError,
|
||||||
|
deliveryMode: 'keep-gltf' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prepareDracoGlb(
|
||||||
|
folderName: string,
|
||||||
|
parsedFiles: ParsedFile[],
|
||||||
|
textureFilenameMap: Map<string, string>,
|
||||||
|
): Promise<PreparedGitAssetsResult> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
deliveryMode: 'draco-glb',
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const fallback = await prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap)
|
||||||
|
const message = getErrorMessage(err, 'Compression Blender echouee')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
compressionError: fallback.compressionError
|
||||||
|
? `${message}. ${fallback.compressionError}`
|
||||||
|
: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(tmpFolder, { recursive: true, force: true }).catch((err) => {
|
||||||
|
console.warn('[WARN] Blender temp cleanup failed', {
|
||||||
|
folderName,
|
||||||
|
error: getErrorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareGitAssets({
|
||||||
|
folderName,
|
||||||
|
parsedFiles,
|
||||||
|
gitModelMode,
|
||||||
|
}: PrepareGitAssetsParams): Promise<PreparedGitAssetsResult> {
|
||||||
|
const textureFilenameMap = getTextureFilenameMap(parsedFiles)
|
||||||
|
|
||||||
|
if (gitModelMode === 'keep-gltf') {
|
||||||
|
return prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepareDracoGlb(folderName, parsedFiles, textureFilenameMap)
|
||||||
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,7 @@
|
|||||||
import { basename } from 'path'
|
import { basename } from 'path'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a filename: strip path components, replace special chars,
|
* Normalize uploaded filenames before storing them or writing Git paths.
|
||||||
* collapse underscores, lowercase.
|
|
||||||
*/
|
*/
|
||||||
export function sanitizeFilename(name: string): string {
|
export function sanitizeFilename(name: string): string {
|
||||||
return basename(name)
|
return basename(name)
|
||||||
|
|||||||
+158
-3
@@ -1,12 +1,133 @@
|
|||||||
import { extname } from 'path'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { mkdir, readFile, rm } from 'fs/promises'
|
||||||
|
import { extname, join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
|
import { getErrorMessage } from './guards'
|
||||||
|
|
||||||
interface TextureCompressionResult {
|
export interface TextureCompressionResult {
|
||||||
buffer: Buffer
|
buffer: Buffer
|
||||||
compressed: boolean
|
compressed: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TextureDeliveryAsset {
|
||||||
|
filename: string
|
||||||
|
buffer: Buffer
|
||||||
|
compressed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextureDeliveryResult {
|
||||||
|
asset: TextureDeliveryAsset
|
||||||
|
format: 'ktx2' | 'webp' | 'original'
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEXTURE_SIZE = 1024
|
||||||
|
const WEBP_QUALITY = 82
|
||||||
|
const KTX2_EXTENSION = '.ktx2'
|
||||||
|
const WEBP_EXTENSION = '.webp'
|
||||||
|
|
||||||
|
function getTextureMaxSize() {
|
||||||
|
const value = Number(process.env.TEXTURE_MAX_SIZE || DEFAULT_TEXTURE_SIZE)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_TEXTURE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceExtension(filename: string, extension: string) {
|
||||||
|
return filename.replace(/\.[^.]+$/, extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureFamily(filename: string) {
|
||||||
|
return filename
|
||||||
|
.replace(/\.[^.]+$/, '')
|
||||||
|
.split(/[_-]/)[0]
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKtx2Flags(filename: string) {
|
||||||
|
const family = getTextureFamily(filename)
|
||||||
|
const flags = ['--t2', '--genmipmap']
|
||||||
|
|
||||||
|
if (family === 'color' || family === 'diffuse') {
|
||||||
|
flags.push('--assign_oetf', 'srgb')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === 'normal') {
|
||||||
|
flags.push('--encode', 'uastc', '--zcmp', '18')
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
flags.push('--encode', 'etc1s', '--clevel', '2', '--qlevel', '128')
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
function runToktx(args: string[]) {
|
||||||
|
const toktxPath = process.env.TOKTX_PATH || 'toktx'
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const output: string[] = []
|
||||||
|
const proc = spawn(toktxPath, args)
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data: Buffer) => output.push(data.toString()))
|
||||||
|
proc.stderr.on('data', (data: Buffer) => output.push(data.toString()))
|
||||||
|
proc.on('error', (err) => reject(err))
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(output.join('').trim() || `toktx exited with code ${code}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeResizedPng(input: Buffer, outputPath: string) {
|
||||||
|
await sharp(input)
|
||||||
|
.resize(getTextureMaxSize(), getTextureMaxSize(), { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.png()
|
||||||
|
.toFile(outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressTextureToKtx2(filename: string, buffer: Buffer): Promise<TextureDeliveryAsset> {
|
||||||
|
const tmpFolder = join(/* turbopackIgnore: true */ tmpdir(), `upload-gltf-ktx2-${randomUUID()}`)
|
||||||
|
const inputPath = join(/* turbopackIgnore: true */ tmpFolder, 'texture.png')
|
||||||
|
const outputFilename = replaceExtension(filename, KTX2_EXTENSION)
|
||||||
|
const outputPath = join(/* turbopackIgnore: true */ tmpFolder, outputFilename)
|
||||||
|
|
||||||
|
await mkdir(/* turbopackIgnore: true */ tmpFolder, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeResizedPng(buffer, inputPath)
|
||||||
|
await runToktx([...getKtx2Flags(filename), outputPath, inputPath])
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: outputFilename,
|
||||||
|
buffer: await readFile(/* turbopackIgnore: true */ outputPath),
|
||||||
|
compressed: true,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(/* turbopackIgnore: true */ tmpFolder, { recursive: true, force: true }).catch((err) => {
|
||||||
|
console.warn('[WARN] KTX2 temp cleanup failed', {
|
||||||
|
filename,
|
||||||
|
error: getErrorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressTextureToWebp(filename: string, buffer: Buffer): Promise<TextureDeliveryAsset> {
|
||||||
|
return {
|
||||||
|
filename: replaceExtension(filename, WEBP_EXTENSION),
|
||||||
|
buffer: await sharp(buffer)
|
||||||
|
.resize(getTextureMaxSize(), getTextureMaxSize(), { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: WEBP_QUALITY })
|
||||||
|
.toBuffer(),
|
||||||
|
compressed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function compressTextureBuffer(
|
export async function compressTextureBuffer(
|
||||||
filename: string,
|
filename: string,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
@@ -35,7 +156,7 @@ export async function compressTextureBuffer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = getErrorMessage(err, String(err))
|
||||||
return {
|
return {
|
||||||
buffer,
|
buffer,
|
||||||
compressed: false,
|
compressed: false,
|
||||||
@@ -45,3 +166,37 @@ export async function compressTextureBuffer(
|
|||||||
|
|
||||||
return { buffer, compressed: false }
|
return { buffer, compressed: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function prepareTextureDelivery(
|
||||||
|
filename: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<TextureDeliveryResult> {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
asset: await compressTextureToKtx2(filename, buffer),
|
||||||
|
format: 'ktx2',
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const ktx2Error = getErrorMessage(err, String(err))
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
asset: await compressTextureToWebp(filename, buffer),
|
||||||
|
format: 'webp',
|
||||||
|
warning: `Compression KTX2 impossible pour ${filename}. Fallback WebP utilise. Detail : ${ktx2Error}`,
|
||||||
|
}
|
||||||
|
} catch (webpErr) {
|
||||||
|
const webpError = getErrorMessage(webpErr, String(webpErr))
|
||||||
|
|
||||||
|
return {
|
||||||
|
asset: {
|
||||||
|
filename,
|
||||||
|
buffer,
|
||||||
|
compressed: false,
|
||||||
|
},
|
||||||
|
format: 'original',
|
||||||
|
warning: `Compression KTX2 impossible pour ${filename}. Compression WebP impossible. Texture originale conservee. Details : ${ktx2Error} / ${webpError}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-3
@@ -1,5 +1,3 @@
|
|||||||
import type { AssetCategory } from './asset-classification'
|
|
||||||
|
|
||||||
export interface ParsedFile {
|
export interface ParsedFile {
|
||||||
filename: string
|
filename: string
|
||||||
buffer: Buffer
|
buffer: Buffer
|
||||||
@@ -11,11 +9,19 @@ export interface PushFile {
|
|||||||
contentBase64: string
|
contentBase64: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GitModelMode = 'draco-glb' | 'keep-gltf'
|
||||||
|
|
||||||
|
export type DriveAction = 'new' | 'replace'
|
||||||
|
|
||||||
export type FileChange = 'new' | 'changed' | 'unchanged'
|
export type FileChange = 'new' | 'changed' | 'unchanged'
|
||||||
|
|
||||||
|
type FileDiffStatus = 'changed' | 'new' | 'deleted'
|
||||||
|
|
||||||
|
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets'
|
||||||
|
|
||||||
export interface FileDiff {
|
export interface FileDiff {
|
||||||
name: string
|
name: string
|
||||||
status: 'changed' | 'new' | 'deleted'
|
status: FileDiffStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteFile {
|
export interface RemoteFile {
|
||||||
@@ -29,3 +35,40 @@ export interface PreparedAssetSummary {
|
|||||||
category?: AssetCategory
|
category?: AssetCategory
|
||||||
compressed: boolean
|
compressed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StagingUploadResult {
|
||||||
|
stagingId: string
|
||||||
|
folderName: string
|
||||||
|
filesCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckUploadResult {
|
||||||
|
exists: boolean
|
||||||
|
diffs: FileDiff[]
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriveUploadResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitUploadResult {
|
||||||
|
success: boolean
|
||||||
|
filename?: string
|
||||||
|
warning?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedGitAssetsResult {
|
||||||
|
filesToPush: PushFile[]
|
||||||
|
modelFilename: string
|
||||||
|
assetSummaries: PreparedAssetSummary[]
|
||||||
|
compressed: boolean
|
||||||
|
deliveryMode: GitModelMode
|
||||||
|
compressionError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedStageAssetsResult extends PreparedGitAssetsResult {
|
||||||
|
folderName: string
|
||||||
|
}
|
||||||
|
|||||||
+170
-101
@@ -1,51 +1,156 @@
|
|||||||
// ---------------------------------------------------------------------------
|
import { getErrorMessage, isRecord } from './guards'
|
||||||
// Client-side API helpers for upload operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import type { FolderEntry } from './client-types'
|
import type { FolderEntry } from './client-types'
|
||||||
import type { FileDiff } from './types'
|
import type {
|
||||||
|
CheckUploadResult,
|
||||||
|
DriveAction,
|
||||||
|
DriveUploadResult,
|
||||||
|
FileDiff,
|
||||||
|
GitModelMode,
|
||||||
|
GitUploadResult,
|
||||||
|
StagingUploadResult,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
export interface CheckResult {
|
interface CompressionWarningPayload {
|
||||||
exists: boolean
|
compressionError?: unknown
|
||||||
diffs: FileDiff[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StageResult {
|
interface SuccessfulUploadData extends CompressionWarningPayload {
|
||||||
stagingId: string
|
success: true
|
||||||
folderName: string
|
exists?: unknown
|
||||||
filesCount: number
|
diffs?: unknown
|
||||||
|
stagingId?: unknown
|
||||||
|
folderName?: unknown
|
||||||
|
filesCount?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
type UploadJsonBody =
|
||||||
return typeof value === 'object' && value !== null
|
| { stagingId: string }
|
||||||
}
|
| { stagingId: string; action: DriveAction }
|
||||||
|
|
||||||
|
const RESPONSE_PREVIEW_MAX_LENGTH = 160
|
||||||
|
|
||||||
function getApiError(data: unknown, fallback: string) {
|
function getApiError(data: unknown, fallback: string) {
|
||||||
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
|
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getClientRequestError(err: unknown, label: string) {
|
||||||
|
return `${label}: ${getErrorMessage(err)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompressionWarning(data: CompressionWarningPayload) {
|
||||||
|
if (typeof data.compressionError !== 'string') return undefined
|
||||||
|
|
||||||
|
return `Compression GLB impossible. Le modele a ete prepare en GLTF separe. Detail : ${data.compressionError}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadJsonHeaders(secret: string) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-upload-secret': secret.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponsePreview(body: string) {
|
||||||
|
return body
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, RESPONSE_PREVIEW_MAX_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnexpectedJsonResponseError(res: Response, body: string) {
|
||||||
|
const contentType = res.headers.get('content-type') || 'type inconnu'
|
||||||
|
const preview = getResponsePreview(body)
|
||||||
|
const detail = preview ? ` Detail : ${preview}` : ''
|
||||||
|
|
||||||
|
if (res.status === 413) {
|
||||||
|
return `Upload trop volumineux ou bloque par le proxy (${res.status}). Verifiez la limite de taille Coolify/Nginx.${detail}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
||||||
|
return `API upload indisponible (${res.status}). Le serveur a probablement redemarre ou plante pendant le traitement.${detail}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Reponse serveur inattendue (${res.status}, ${contentType}).${detail}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUploadJson(res: Response): Promise<unknown> {
|
||||||
|
const body = await res.text()
|
||||||
|
|
||||||
|
if (body.trim() === '') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') || ''
|
||||||
|
|
||||||
|
if (!contentType.toLowerCase().includes('json')) {
|
||||||
|
throw new Error(getUnexpectedJsonResponseError(res, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(body)
|
||||||
|
} catch {
|
||||||
|
throw new Error(getUnexpectedJsonResponseError(res, body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postUploadJson(
|
||||||
|
endpoint: string,
|
||||||
|
secret: string,
|
||||||
|
body: UploadJsonBody,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getUploadJsonHeaders(secret),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await readUploadJson(res)
|
||||||
|
return { res, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSuccessfulUploadData(data: unknown): data is SuccessfulUploadData {
|
||||||
|
return isRecord(data) && data.success === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(err: unknown) {
|
||||||
|
return err instanceof DOMException && err.name === 'AbortError'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetworkUploadError(err: unknown, label: string) {
|
||||||
|
return isAbortError(err) ? 'Upload annule' : getClientRequestError(err, label)
|
||||||
|
}
|
||||||
|
|
||||||
function isFileDiff(value: unknown): value is FileDiff {
|
function isFileDiff(value: unknown): value is FileDiff {
|
||||||
return isRecord(value)
|
return isRecord(value)
|
||||||
&& typeof value.name === 'string'
|
&& typeof value.name === 'string'
|
||||||
&& (value.status === 'new' || value.status === 'changed' || value.status === 'deleted')
|
&& (value.status === 'new' || value.status === 'changed' || value.status === 'deleted')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
function parseFileDiffs(value: unknown): FileDiff[] {
|
||||||
// Shared FormData builder
|
if (!Array.isArray(value)) {
|
||||||
// ---------------------------------------------------------------------------
|
throw new Error('Reponse serveur invalide')
|
||||||
|
}
|
||||||
|
|
||||||
function buildUploadFormData(
|
const diffs: FileDiff[] = []
|
||||||
folder: FolderEntry,
|
|
||||||
extra?: Record<string, string>,
|
for (const diff of value) {
|
||||||
): FormData {
|
if (!isFileDiff(diff)) {
|
||||||
|
throw new Error('Reponse serveur invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
diffs.push(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUploadFormData(folder: FolderEntry, gitModelMode: GitModelMode): FormData {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('folderName', folder.folderName)
|
formData.append('folderName', folder.folderName)
|
||||||
|
formData.append('gitModelMode', gitModelMode)
|
||||||
if (extra) {
|
|
||||||
for (const [key, value] of Object.entries(extra)) {
|
|
||||||
formData.append(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append('files', folder.modelFile)
|
formData.append('files', folder.modelFile)
|
||||||
formData.append('fileTypes', 'model')
|
formData.append('fileTypes', 'model')
|
||||||
@@ -60,51 +165,45 @@ function buildUploadFormData(
|
|||||||
return formData
|
return formData
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Check folder diffs against remote (GitHub)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a folder already exists on the remote repo and compute diffs.
|
|
||||||
* Throws on auth/network errors so callers can surface them to the user.
|
|
||||||
*/
|
|
||||||
export async function checkFolderDiffs(
|
export async function checkFolderDiffs(
|
||||||
stagingId: string,
|
stagingId: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<CheckResult> {
|
): Promise<CheckUploadResult> {
|
||||||
const res = await fetch('/api/upload/check', {
|
const { res, data } = await postUploadJson('/api/upload/check', secret, { stagingId }, signal)
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-upload-secret': secret.trim(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ stagingId }),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
|
|
||||||
// Surface auth/server errors to the caller
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
|
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRecord(data) || data.success !== true || data.exists !== true) {
|
if (!isSuccessfulUploadData(data)) {
|
||||||
return { exists: false, diffs: [] }
|
throw new Error('Reponse serveur invalide')
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffs = Array.isArray(data.diffs) ? data.diffs.filter(isFileDiff) : []
|
const warning = getCompressionWarning(data)
|
||||||
|
|
||||||
return { exists: true, diffs }
|
if (data.exists !== true) {
|
||||||
|
return {
|
||||||
|
exists: false,
|
||||||
|
diffs: [],
|
||||||
|
warning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
diffs: parseFileDiffs(data.diffs),
|
||||||
|
warning,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stageUpload(
|
export async function stageUpload(
|
||||||
folder: FolderEntry,
|
folder: FolderEntry,
|
||||||
|
gitModelMode: GitModelMode,
|
||||||
secret: string,
|
secret: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<StageResult> {
|
): Promise<StagingUploadResult> {
|
||||||
const formData = buildUploadFormData(folder)
|
const formData = buildUploadFormData(folder, gitModelMode)
|
||||||
const res = await fetch('/api/upload/stage', {
|
const res = await fetch('/api/upload/stage', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'x-upload-secret': secret.trim() },
|
headers: { 'x-upload-secret': secret.trim() },
|
||||||
@@ -112,9 +211,9 @@ export async function stageUpload(
|
|||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
const data: unknown = await res.json()
|
const data = await readUploadJson(res)
|
||||||
|
|
||||||
if (!res.ok || !isRecord(data) || data.success !== true) {
|
if (!res.ok || !isSuccessfulUploadData(data)) {
|
||||||
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
|
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,77 +228,47 @@ export async function stageUpload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Upload original files to Nextcloud Drive
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Upload original files to Nextcloud Drive. */
|
|
||||||
export async function uploadDrive(
|
export async function uploadDrive(
|
||||||
stagingId: string,
|
stagingId: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
action: 'new' | 'replace',
|
action: DriveAction,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<DriveUploadResult> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/upload/drive', {
|
const { res, data } = await postUploadJson('/api/upload/drive', secret, { stagingId, action }, signal)
|
||||||
method: 'POST',
|
if (!res.ok || !isSuccessfulUploadData(data)) {
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-upload-secret': secret.trim(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ stagingId, action }),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
if (!res.ok || !isRecord(data) || data.success !== true) {
|
|
||||||
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
|
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
|
||||||
}
|
}
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
return { success: false, error: getNetworkUploadError(err, 'Erreur Drive') }
|
||||||
return { success: false, error: 'Upload annule' }
|
|
||||||
}
|
|
||||||
return { success: false, error: 'Erreur reseau (Drive)' }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Upload files to GitHub
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Upload files to GitHub. */
|
|
||||||
export async function uploadGit(
|
export async function uploadGit(
|
||||||
stagingId: string,
|
stagingId: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
onProgress: (pct: number) => void,
|
onProgress: (pct: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ success: boolean; filename?: string; error?: string }> {
|
): Promise<GitUploadResult> {
|
||||||
onProgress(10)
|
onProgress(10)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/upload/git', {
|
const { res, data } = await postUploadJson('/api/upload/git', secret, { stagingId }, signal)
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-upload-secret': secret.trim(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ stagingId }),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
onProgress(80)
|
onProgress(80)
|
||||||
const data: unknown = await res.json()
|
|
||||||
|
|
||||||
if (!res.ok || !isRecord(data) || data.success !== true) {
|
if (!res.ok || !isSuccessfulUploadData(data)) {
|
||||||
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
|
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress(100)
|
onProgress(100)
|
||||||
return { success: true, filename: typeof data.folderName === 'string' ? data.folderName : undefined }
|
return {
|
||||||
|
success: true,
|
||||||
|
filename: typeof data.folderName === 'string' ? data.folderName : undefined,
|
||||||
|
warning: getCompressionWarning(data),
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
return { success: false, error: getNetworkUploadError(err, 'Erreur Git') }
|
||||||
return { success: false, error: 'Upload annule' }
|
|
||||||
}
|
|
||||||
return { success: false, error: 'Erreur reseau' }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-9
@@ -1,4 +1,6 @@
|
|||||||
export type DriveAction = 'new' | 'replace'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getErrorMessage, isRecord } from './guards'
|
||||||
|
import type { DriveAction } from './types'
|
||||||
|
|
||||||
interface StagingRequestBody {
|
interface StagingRequestBody {
|
||||||
stagingId: string
|
stagingId: string
|
||||||
@@ -8,11 +10,22 @@ interface DriveRequestBody extends StagingRequestBody {
|
|||||||
action: DriveAction
|
action: DriveAction
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
const UPLOAD_LOCK_ERROR = 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.'
|
||||||
return typeof value === 'object' && value !== null
|
|
||||||
|
export function uploadErrorResponse(error: unknown, status: number, fallback?: string) {
|
||||||
|
const message = getErrorMessage(error, fallback)
|
||||||
|
return NextResponse.json({ success: false, error: message }, { status })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseStagingRequestBody(value: unknown): StagingRequestBody {
|
export function uploadErrorMessageResponse(message: string, status: number) {
|
||||||
|
return NextResponse.json({ success: false, error: message }, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadLockConflictResponse() {
|
||||||
|
return uploadErrorMessageResponse(UPLOAD_LOCK_ERROR, 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStagingRequestBody(value: unknown): StagingRequestBody {
|
||||||
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
|
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
|
||||||
throw new Error('stagingId manquant')
|
throw new Error('stagingId manquant')
|
||||||
}
|
}
|
||||||
@@ -20,9 +33,22 @@ export function parseStagingRequestBody(value: unknown): StagingRequestBody {
|
|||||||
return { stagingId: value.stagingId }
|
return { stagingId: value.stagingId }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDriveRequestBody(value: unknown): DriveRequestBody {
|
export async function readStagingRequestBody(req: Request): Promise<StagingRequestBody> {
|
||||||
const { stagingId } = parseStagingRequestBody(value)
|
const body: unknown = await req.json()
|
||||||
const action = isRecord(value) && value.action === 'replace' ? 'replace' : 'new'
|
return parseStagingRequestBody(body)
|
||||||
|
}
|
||||||
return { stagingId, action }
|
|
||||||
|
function parseDriveRequestBody(value: unknown): DriveRequestBody {
|
||||||
|
const { stagingId } = parseStagingRequestBody(value)
|
||||||
|
|
||||||
|
if (!isRecord(value) || (value.action !== 'new' && value.action !== 'replace')) {
|
||||||
|
throw new Error('Action Drive invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stagingId, action: value.action }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readDriveRequestBody(req: Request): Promise<DriveRequestBody> {
|
||||||
|
const body: unknown = await req.json()
|
||||||
|
return parseDriveRequestBody(body)
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-19
@@ -3,9 +3,18 @@ import { dirname, join } from 'path'
|
|||||||
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
|
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { TMP_DIR } from '@/lib/constants'
|
import { TMP_DIR } from '@/lib/constants'
|
||||||
|
import { getErrorMessage, isRecord } from '@/lib/guards'
|
||||||
import { getModelAssetPath } from '@/lib/model-paths'
|
import { getModelAssetPath } from '@/lib/model-paths'
|
||||||
import { prepareGitAssets } from '@/lib/prepare-git-assets'
|
import { prepareGitAssets } from '@/lib/prepare-git-assets'
|
||||||
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
|
import type {
|
||||||
|
AssetCategory,
|
||||||
|
GitModelMode,
|
||||||
|
ParsedFile,
|
||||||
|
PreparedAssetSummary,
|
||||||
|
PreparedStageAssetsResult,
|
||||||
|
PushFile,
|
||||||
|
StagingUploadResult,
|
||||||
|
} from '@/lib/types'
|
||||||
|
|
||||||
const STAGING_ROOT = join(TMP_DIR, 'staging')
|
const STAGING_ROOT = join(TMP_DIR, 'staging')
|
||||||
const STAGING_TTL_MS = 60 * 60 * 1000
|
const STAGING_TTL_MS = 60 * 60 * 1000
|
||||||
@@ -19,6 +28,7 @@ interface StagedOriginalFile {
|
|||||||
interface StagedPreparedData {
|
interface StagedPreparedData {
|
||||||
modelFilename: string
|
modelFilename: string
|
||||||
compressed: boolean
|
compressed: boolean
|
||||||
|
deliveryMode: GitModelMode
|
||||||
compressionError?: string
|
compressionError?: string
|
||||||
assetSummaries: PreparedAssetSummary[]
|
assetSummaries: PreparedAssetSummary[]
|
||||||
}
|
}
|
||||||
@@ -26,20 +36,12 @@ interface StagedPreparedData {
|
|||||||
interface StagingManifest {
|
interface StagingManifest {
|
||||||
stagingId: string
|
stagingId: string
|
||||||
folderName: string
|
folderName: string
|
||||||
|
gitModelMode: GitModelMode
|
||||||
createdAt: number
|
createdAt: number
|
||||||
originals: StagedOriginalFile[]
|
originals: StagedOriginalFile[]
|
||||||
prepared?: StagedPreparedData
|
prepared?: StagedPreparedData
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreparedStageAssetsResult {
|
|
||||||
folderName: string
|
|
||||||
filesToPush: PushFile[]
|
|
||||||
modelFilename: string
|
|
||||||
assetSummaries: PreparedAssetSummary[]
|
|
||||||
compressed: boolean
|
|
||||||
compressionError?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStageDir(stagingId: string) {
|
function getStageDir(stagingId: string) {
|
||||||
return join(STAGING_ROOT, stagingId)
|
return join(STAGING_ROOT, stagingId)
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,69 @@ function getManifestPath(stagingId: string) {
|
|||||||
return join(getStageDir(stagingId), 'manifest.json')
|
return join(getStageDir(stagingId), 'manifest.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGitModelMode(value: unknown): value is GitModelMode {
|
||||||
|
return value === 'draco-glb' || value === 'keep-gltf'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssetCategory(value: unknown): value is AssetCategory {
|
||||||
|
return value === 'color'
|
||||||
|
|| value === 'diffuse'
|
||||||
|
|| value === 'roughness'
|
||||||
|
|| value === 'normal'
|
||||||
|
|| value === 'metalness'
|
||||||
|
|| value === 'height'
|
||||||
|
|| value === 'opacity'
|
||||||
|
|| value === 'orm'
|
||||||
|
|| value === 'ao'
|
||||||
|
|| value === 'assets'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreparedAssetSummary(value: unknown): value is PreparedAssetSummary {
|
||||||
|
return isRecord(value)
|
||||||
|
&& typeof value.filename === 'string'
|
||||||
|
&& (value.kind === 'model' || value.kind === 'texture' || value.kind === 'asset')
|
||||||
|
&& (value.category === undefined || isAssetCategory(value.category))
|
||||||
|
&& typeof value.compressed === 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStagedOriginalFile(value: unknown): value is StagedOriginalFile {
|
||||||
|
return isRecord(value)
|
||||||
|
&& typeof value.filename === 'string'
|
||||||
|
&& typeof value.size === 'number'
|
||||||
|
&& typeof value.isModel === 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStagedPreparedData(value: unknown): value is StagedPreparedData {
|
||||||
|
return isRecord(value)
|
||||||
|
&& typeof value.modelFilename === 'string'
|
||||||
|
&& typeof value.compressed === 'boolean'
|
||||||
|
&& isGitModelMode(value.deliveryMode)
|
||||||
|
&& (value.compressionError === undefined || typeof value.compressionError === 'string')
|
||||||
|
&& Array.isArray(value.assetSummaries)
|
||||||
|
&& value.assetSummaries.every(isPreparedAssetSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStagingManifest(value: unknown): value is StagingManifest {
|
||||||
|
return isRecord(value)
|
||||||
|
&& typeof value.stagingId === 'string'
|
||||||
|
&& typeof value.folderName === 'string'
|
||||||
|
&& isGitModelMode(value.gitModelMode)
|
||||||
|
&& typeof value.createdAt === 'number'
|
||||||
|
&& Array.isArray(value.originals)
|
||||||
|
&& value.originals.every(isStagedOriginalFile)
|
||||||
|
&& (value.prepared === undefined || isStagedPreparedData(value.prepared))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStagingManifest(content: string) {
|
||||||
|
const parsed: unknown = JSON.parse(content)
|
||||||
|
|
||||||
|
if (!isStagingManifest(parsed)) {
|
||||||
|
throw new Error('Manifest de staging invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureParentDir(filePath: string) {
|
async function ensureParentDir(filePath: string) {
|
||||||
await mkdir(dirname(filePath), { recursive: true })
|
await mkdir(dirname(filePath), { recursive: true })
|
||||||
}
|
}
|
||||||
@@ -65,7 +130,7 @@ async function writeManifest(manifest: StagingManifest) {
|
|||||||
await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8')
|
await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupExpiredStagingUploads() {
|
async function cleanupExpiredStagingUploads() {
|
||||||
if (!existsSync(STAGING_ROOT)) return
|
if (!existsSync(STAGING_ROOT)) return
|
||||||
|
|
||||||
const entries = await readdir(STAGING_ROOT, { withFileTypes: true })
|
const entries = await readdir(STAGING_ROOT, { withFileTypes: true })
|
||||||
@@ -81,13 +146,23 @@ export async function cleanupExpiredStagingUploads() {
|
|||||||
if (now - manifest.createdAt > STAGING_TTL_MS) {
|
if (now - manifest.createdAt > STAGING_TTL_MS) {
|
||||||
await cleanupStagingUpload(stagingId)
|
await cleanupStagingUpload(stagingId)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
await cleanupStagingUpload(stagingId).catch(() => {})
|
await cleanupStagingUpload(stagingId).catch((cleanupErr) => {
|
||||||
|
console.warn('[WARN] Staging cleanup failed', {
|
||||||
|
stagingId,
|
||||||
|
error: getErrorMessage(cleanupErr),
|
||||||
|
originalError: getErrorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createStagingUpload(folderName: string, parsedFiles: ParsedFile[]) {
|
export async function createStagingUpload(
|
||||||
|
folderName: string,
|
||||||
|
parsedFiles: ParsedFile[],
|
||||||
|
gitModelMode: GitModelMode,
|
||||||
|
): Promise<StagingUploadResult> {
|
||||||
await cleanupExpiredStagingUploads()
|
await cleanupExpiredStagingUploads()
|
||||||
|
|
||||||
const stagingId = randomUUID()
|
const stagingId = randomUUID()
|
||||||
@@ -106,6 +181,7 @@ export async function createStagingUpload(folderName: string, parsedFiles: Parse
|
|||||||
const manifest: StagingManifest = {
|
const manifest: StagingManifest = {
|
||||||
stagingId,
|
stagingId,
|
||||||
folderName,
|
folderName,
|
||||||
|
gitModelMode,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
originals,
|
originals,
|
||||||
}
|
}
|
||||||
@@ -122,7 +198,7 @@ export async function createStagingUpload(folderName: string, parsedFiles: Parse
|
|||||||
export async function readStagedManifest(stagingId: string): Promise<StagingManifest> {
|
export async function readStagedManifest(stagingId: string): Promise<StagingManifest> {
|
||||||
const manifestPath = getManifestPath(stagingId)
|
const manifestPath = getManifestPath(stagingId)
|
||||||
const content = await readFile(manifestPath, 'utf-8')
|
const content = await readFile(manifestPath, 'utf-8')
|
||||||
return JSON.parse(content) as StagingManifest
|
return parseStagingManifest(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readOriginalParsedFiles(stagingId: string, manifest: StagingManifest): Promise<ParsedFile[]> {
|
async function readOriginalParsedFiles(stagingId: string, manifest: StagingManifest): Promise<ParsedFile[]> {
|
||||||
@@ -137,11 +213,11 @@ async function readOriginalParsedFiles(stagingId: string, manifest: StagingManif
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise<PushFile[]> {
|
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest, prepared: StagedPreparedData): Promise<PushFile[]> {
|
||||||
const preparedDir = getPreparedDir(stagingId)
|
const preparedDir = getPreparedDir(stagingId)
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
manifest.originals.map(async (file) => {
|
prepared.assetSummaries.map(async (file) => {
|
||||||
const buffer = await readFile(join(preparedDir, file.filename))
|
const buffer = await readFile(join(preparedDir, file.filename))
|
||||||
return {
|
return {
|
||||||
path: getModelAssetPath(manifest.folderName, file.filename),
|
path: getModelAssetPath(manifest.folderName, file.filename),
|
||||||
@@ -156,7 +232,11 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
|
|||||||
|
|
||||||
if (!manifest.prepared) {
|
if (!manifest.prepared) {
|
||||||
const parsedFiles = await readOriginalParsedFiles(stagingId, manifest)
|
const parsedFiles = await readOriginalParsedFiles(stagingId, manifest)
|
||||||
const prepared = await prepareGitAssets({ folderName: manifest.folderName, parsedFiles })
|
const prepared = await prepareGitAssets({
|
||||||
|
folderName: manifest.folderName,
|
||||||
|
parsedFiles,
|
||||||
|
gitModelMode: manifest.gitModelMode,
|
||||||
|
})
|
||||||
const preparedDir = getPreparedDir(stagingId)
|
const preparedDir = getPreparedDir(stagingId)
|
||||||
await mkdir(preparedDir, { recursive: true })
|
await mkdir(preparedDir, { recursive: true })
|
||||||
|
|
||||||
@@ -171,6 +251,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
|
|||||||
manifest.prepared = {
|
manifest.prepared = {
|
||||||
modelFilename: prepared.modelFilename,
|
modelFilename: prepared.modelFilename,
|
||||||
compressed: prepared.compressed,
|
compressed: prepared.compressed,
|
||||||
|
deliveryMode: prepared.deliveryMode,
|
||||||
compressionError: prepared.compressionError,
|
compressionError: prepared.compressionError,
|
||||||
assetSummaries: prepared.assetSummaries,
|
assetSummaries: prepared.assetSummaries,
|
||||||
}
|
}
|
||||||
@@ -180,10 +261,11 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
folderName: manifest.folderName,
|
folderName: manifest.folderName,
|
||||||
filesToPush: await buildPreparedPushFiles(stagingId, manifest),
|
filesToPush: await buildPreparedPushFiles(stagingId, manifest, manifest.prepared),
|
||||||
modelFilename: manifest.prepared.modelFilename,
|
modelFilename: manifest.prepared.modelFilename,
|
||||||
assetSummaries: manifest.prepared.assetSummaries,
|
assetSummaries: manifest.prepared.assetSummaries,
|
||||||
compressed: manifest.prepared.compressed,
|
compressed: manifest.prepared.compressed,
|
||||||
|
deliveryMode: manifest.prepared.deliveryMode,
|
||||||
compressionError: manifest.prepared.compressionError,
|
compressionError: manifest.prepared.compressionError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+307
-50
@@ -1,28 +1,100 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Client-side folder validation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
||||||
import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming'
|
import { getTextureNamingError, normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
import type { TextureFile } from '@/lib/client-types'
|
import { getErrorMessage, isRecord } from '@/lib/guards'
|
||||||
|
import type { TextureDiagnosticIssue, TextureDiagnosticReport, TextureFile } from '@/lib/client-types'
|
||||||
|
|
||||||
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
|
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
|
||||||
|
|
||||||
interface GltfBufferReference {
|
interface GltfBufferReference {
|
||||||
uri?: unknown
|
uri?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfImageReference {
|
||||||
|
uri?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfTextureReference {
|
||||||
|
source?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfTextureInfo {
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfPbrMaterial {
|
||||||
|
baseColorTexture?: GltfTextureInfo
|
||||||
|
metallicRoughnessTexture?: GltfTextureInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfMaterialReference {
|
||||||
|
name?: string
|
||||||
|
alphaMode?: string
|
||||||
|
pbrMetallicRoughness?: GltfPbrMaterial
|
||||||
|
normalTexture?: GltfTextureInfo
|
||||||
|
occlusionTexture?: GltfTextureInfo
|
||||||
|
emissiveTexture?: GltfTextureInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfMeshPrimitive {
|
||||||
|
material?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfMeshReference {
|
||||||
|
name?: string
|
||||||
|
primitives?: GltfMeshPrimitive[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfNodeReference {
|
||||||
|
name?: string
|
||||||
|
mesh?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GltfJson {
|
interface GltfJson {
|
||||||
buffers?: GltfBufferReference[]
|
buffers?: GltfBufferReference[]
|
||||||
|
images?: GltfImageReference[]
|
||||||
|
materials?: GltfMaterialReference[]
|
||||||
|
meshes?: GltfMeshReference[]
|
||||||
|
nodes?: GltfNodeReference[]
|
||||||
|
textures?: GltfTextureReference[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discriminated union: either valid (with model) or invalid (with errors). */
|
type ValidationResult =
|
||||||
export type ValidationResult =
|
| {
|
||||||
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
|
ok: true
|
||||||
|
model: File
|
||||||
|
textures: TextureFile[]
|
||||||
|
warnings: string[]
|
||||||
|
textureReport: TextureDiagnosticReport
|
||||||
|
}
|
||||||
| { ok: false; errors: string[] }
|
| { ok: false; errors: string[] }
|
||||||
|
|
||||||
|
function isGltfBufferReference(value: unknown): value is GltfBufferReference {
|
||||||
|
return isRecord(value) && (value.uri === undefined || typeof value.uri === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
function isGltfJson(value: unknown): value is GltfJson {
|
function isGltfJson(value: unknown): value is GltfJson {
|
||||||
return typeof value === 'object' && value !== null
|
if (!isRecord(value)) return false
|
||||||
|
if (value.buffers === undefined) return true
|
||||||
|
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecordArray(value: unknown): Record<string, unknown>[] {
|
||||||
|
return Array.isArray(value) ? value.filter(isRecord) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUri(uri: string) {
|
||||||
|
const cleanUri = uri.split(/[?#]/)[0] || ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(cleanUri)
|
||||||
|
} catch {
|
||||||
|
return cleanUri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferencedFilename(uri: string) {
|
||||||
|
return decodeUri(uri).split(/[\\/]/).pop()?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReferencedBufferNames(gltf: GltfJson) {
|
function getReferencedBufferNames(gltf: GltfJson) {
|
||||||
@@ -30,7 +102,7 @@ function getReferencedBufferNames(gltf: GltfJson) {
|
|||||||
.map((buffer) => (typeof buffer.uri === 'string' ? buffer.uri : undefined))
|
.map((buffer) => (typeof buffer.uri === 'string' ? buffer.uri : undefined))
|
||||||
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
|
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
|
||||||
.filter((uri) => !uri.startsWith('data:'))
|
.filter((uri) => !uri.startsWith('data:'))
|
||||||
.map((uri) => decodeURIComponent(uri.split(/[?#]/)[0] || '').split(/[\\/]/).pop()?.toLowerCase())
|
.map(getReferencedFilename)
|
||||||
.filter((filename): filename is string => Boolean(filename))
|
.filter((filename): filename is string => Boolean(filename))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,60 +110,50 @@ function getFileExtension(filename: string) {
|
|||||||
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileStem(filename: string) {
|
function normalizeMatchName(name: string) {
|
||||||
return filename.replace(/\.[^.]+$/, '')
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextureNamingError(file: File) {
|
function getOpacityTarget(filename: string) {
|
||||||
const stem = getFileStem(file.name)
|
const normalizedFilename = normalizeTextureFilename(filename) || filename
|
||||||
const [prefix, ...targetParts] = stem.split('_')
|
const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
|
||||||
const family = getAssetFamily(prefix)
|
return match ? match[1] || '' : undefined
|
||||||
const extension = file.name.split('.').pop()
|
|
||||||
|
|
||||||
if (family && targetParts.every(Boolean)) return null
|
|
||||||
|
|
||||||
const aliasSuggestion = getForbiddenAssetFamilyAlias(prefix)
|
|
||||||
|
|
||||||
if (aliasSuggestion && targetParts.every(Boolean)) {
|
|
||||||
const target = targetParts.join('_')
|
|
||||||
return `Convention invalide : ${file.name}. Utilisez ${aliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${aliasSuggestion}.${extension} pour tout le modele.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const reversedParts = stem.split('_')
|
function getTextureFiles(supportFiles: File[]) {
|
||||||
const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined
|
return supportFiles.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
|
||||||
const reversedAliasSuggestion = reversedParts.length > 1
|
|
||||||
? getForbiddenAssetFamilyAlias(reversedParts[reversedParts.length - 1])
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (reversedFamily) {
|
|
||||||
const target = reversedParts.slice(0, -1).join('_')
|
|
||||||
return `Convention invalide : ${file.name}. Utilisez ${reversedFamily}_${target}.${extension} pour cibler "${target}", ou ${reversedFamily}.${extension} pour tout le modele.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reversedAliasSuggestion) {
|
function getTextureDisplayName(file: File) {
|
||||||
const target = reversedParts.slice(0, -1).join('_')
|
return file.webkitRelativePath || file.name
|
||||||
return `Convention invalide : ${file.name}. Utilisez ${reversedAliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${reversedAliasSuggestion}.${extension} pour tout le modele.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `Asset inconnu : ${file.name}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.`
|
async function parseGltfModel(model: File) {
|
||||||
}
|
|
||||||
|
|
||||||
async function getGltfWarnings(model: File, supportFiles: File[]) {
|
|
||||||
const warnings: string[] = []
|
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(await model.text())
|
parsed = JSON.parse(await model.text())
|
||||||
} catch {
|
} catch {
|
||||||
return warnings
|
throw new Error('model.gltf contient un JSON invalide')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGltfJson(parsed)) return warnings
|
if (!isGltfJson(parsed)) {
|
||||||
|
throw new Error('model.gltf a une structure invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGltfWarnings(gltf: GltfJson, supportFiles: File[]) {
|
||||||
|
const warnings: string[] = []
|
||||||
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
|
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
|
||||||
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
|
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
|
||||||
|
|
||||||
for (const bufferName of getReferencedBufferNames(parsed)) {
|
for (const bufferName of getReferencedBufferNames(gltf)) {
|
||||||
if (!bufferName.endsWith('.bin') || supportFilenames.has(bufferName)) continue
|
if (!bufferName.endsWith('.bin') || supportFilenames.has(bufferName)) continue
|
||||||
|
|
||||||
if (binFiles.length === 1) {
|
if (binFiles.length === 1) {
|
||||||
@@ -107,6 +169,184 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
|
|||||||
return warnings
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReferencedImageNames(gltf: GltfJson) {
|
||||||
|
return asRecordArray(gltf.images)
|
||||||
|
.map((image) => (typeof image.uri === 'string' ? image.uri : undefined))
|
||||||
|
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
|
||||||
|
.filter((uri) => !uri.startsWith('data:'))
|
||||||
|
.map(getReferencedFilename)
|
||||||
|
.filter((filename): filename is string => Boolean(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelTargetNames(gltf: GltfJson) {
|
||||||
|
const names = new Set<string>()
|
||||||
|
|
||||||
|
for (const material of asRecordArray(gltf.materials)) {
|
||||||
|
if (typeof material.name === 'string') names.add(normalizeMatchName(material.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mesh of asRecordArray(gltf.meshes)) {
|
||||||
|
if (typeof mesh.name === 'string') names.add(normalizeMatchName(mesh.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of asRecordArray(gltf.nodes)) {
|
||||||
|
if (typeof node.name === 'string') names.add(normalizeMatchName(node.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...names].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIssue(
|
||||||
|
issues: TextureDiagnosticIssue[],
|
||||||
|
severity: TextureDiagnosticIssue['severity'],
|
||||||
|
title: string,
|
||||||
|
detail: string,
|
||||||
|
) {
|
||||||
|
issues.push({ severity, title, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDuplicateFilenameIssues(files: File[], issues: TextureDiagnosticIssue[]) {
|
||||||
|
const filesByName = new Map<string, File[]>()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const key = file.name.toLowerCase()
|
||||||
|
filesByName.set(key, [...(filesByName.get(key) || []), file])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [filename, duplicates] of filesByName) {
|
||||||
|
if (duplicates.length < 2) continue
|
||||||
|
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Nom de fichier duplique',
|
||||||
|
`${filename} existe plusieurs fois dans le dossier. La preview et le Git utilisent un dossier plat, donc une texture peut remplacer une autre.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpacityIssues(gltf: GltfJson, textureFiles: File[], issues: TextureDiagnosticIssue[]) {
|
||||||
|
const targetNames = getModelTargetNames(gltf)
|
||||||
|
|
||||||
|
for (const file of textureFiles) {
|
||||||
|
const target = getOpacityTarget(file.name)
|
||||||
|
if (target === undefined) continue
|
||||||
|
|
||||||
|
if (target === '') {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Opacity globale',
|
||||||
|
`${file.name} s'applique a tous les materiaux dans la preview. Si tout devient transparent, utilisez plutot un nom cible comme opacity_porte.png.`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTarget = normalizeMatchName(target)
|
||||||
|
const hasTarget = targetNames.some((name) => name.includes(normalizedTarget))
|
||||||
|
|
||||||
|
if (!hasTarget) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Opacity cible introuvable',
|
||||||
|
`${file.name} cible "${target}", mais aucun noeud, mesh ou materiau du GLTF ne correspond. La map ne sera pas appliquee automatiquement.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransparentMaterialIssues(gltf: GltfJson, issues: TextureDiagnosticIssue[]) {
|
||||||
|
const materials = asRecordArray(gltf.materials)
|
||||||
|
if (materials.length === 0) return
|
||||||
|
|
||||||
|
const transparentMaterials = materials.filter((material) => {
|
||||||
|
const alphaMode = typeof material.alphaMode === 'string' ? material.alphaMode.toUpperCase() : 'OPAQUE'
|
||||||
|
return alphaMode === 'BLEND' || alphaMode === 'MASK'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (transparentMaterials.length !== materials.length) return
|
||||||
|
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Tous les materiaux sont transparents',
|
||||||
|
'Le GLTF declare tous ses materiaux en alphaMode BLEND ou MASK. Si ce n\'est pas voulu, le probleme vient probablement de l\'export du modele.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureDiagnosticReport(gltf: GltfJson, supportFiles: File[]): TextureDiagnosticReport {
|
||||||
|
const issues: TextureDiagnosticIssue[] = []
|
||||||
|
const textureFiles = getTextureFiles(supportFiles)
|
||||||
|
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
|
||||||
|
const referencedImageNames = getReferencedImageNames(gltf)
|
||||||
|
const referencedImageSet = new Set(referencedImageNames)
|
||||||
|
|
||||||
|
for (const imageName of referencedImageNames) {
|
||||||
|
const extension = getFileExtension(imageName)
|
||||||
|
|
||||||
|
if (!TEXTURE_EXTENSIONS.has(extension)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'error',
|
||||||
|
'Format texture non supporte',
|
||||||
|
`${imageName} est reference par model.gltf, mais seuls .png, .jpg, .jpeg et .webp sont acceptes.`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportFilenames.has(imageName)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'error',
|
||||||
|
'Texture referencee absente',
|
||||||
|
`${imageName} est utilisee par model.gltf mais absente du dossier. Ajoutez la texture ou re-exportez le modele.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuplicateFilenameIssues(supportFiles, issues)
|
||||||
|
|
||||||
|
for (const file of textureFiles) {
|
||||||
|
if (referencedImageSet.has(file.name.toLowerCase()) || getOpacityTarget(file.name) !== undefined) continue
|
||||||
|
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Texture non referencee',
|
||||||
|
`${getTextureDisplayName(file)} est dans le dossier, mais model.gltf ne la reference pas. Elle risque de ne pas apparaitre dans le viewer.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpacityIssues(gltf, textureFiles, issues)
|
||||||
|
getTransparentMaterialIssues(gltf, issues)
|
||||||
|
|
||||||
|
const hasErrors = issues.some((issue) => issue.severity === 'error')
|
||||||
|
const hasWarnings = issues.some((issue) => issue.severity === 'warning')
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
summary: 'Problemes detectes : certaines textures risquent de ne pas charger.',
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasWarnings) {
|
||||||
|
return {
|
||||||
|
status: 'warning',
|
||||||
|
summary: 'Textures chargeables, mais certains points peuvent expliquer un rendu incorrect.',
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
summary: 'Chargement textures OK. Si le rendu semble faux, le probleme vient probablement de l\'export ou du shading du modele.',
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
||||||
const textures: TextureFile[] = []
|
const textures: TextureFile[] = []
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
@@ -131,7 +371,7 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
|||||||
|
|
||||||
const textureNamingErrors = supportFiles
|
const textureNamingErrors = supportFiles
|
||||||
.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
|
.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
|
||||||
.map(getTextureNamingError)
|
.map((file) => getTextureNamingError(file.name))
|
||||||
.filter((error): error is string => Boolean(error))
|
.filter((error): error is string => Boolean(error))
|
||||||
|
|
||||||
errors.push(...textureNamingErrors)
|
errors.push(...textureNamingErrors)
|
||||||
@@ -144,7 +384,24 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
|||||||
return { ok: false, errors }
|
return { ok: false, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings = await getGltfWarnings(modelFiles[0], supportFiles)
|
let warnings: string[] = []
|
||||||
|
let textureReport: TextureDiagnosticReport | undefined
|
||||||
|
|
||||||
return { ok: true, model: modelFiles[0], textures, warnings }
|
try {
|
||||||
|
const gltf = await parseGltfModel(modelFiles[0])
|
||||||
|
warnings = getGltfWarnings(gltf, supportFiles)
|
||||||
|
textureReport = getTextureDiagnosticReport(gltf, supportFiles)
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(getErrorMessage(err, 'model.gltf invalide'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { ok: false, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!textureReport) {
|
||||||
|
return { ok: false, errors: ['model.gltf invalide'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, model: modelFiles[0], textures, warnings, textureReport }
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+95
-101
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "upload-gltf",
|
"name": "upload-gltf",
|
||||||
"version": "0.1.5",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "upload-gltf",
|
"name": "upload-gltf",
|
||||||
"version": "0.1.5",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@react-three/drei": "^10.7.0",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"next": "^16.2.1",
|
"next": "16.2.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"three": "^0.183.0"
|
"three": "^0.183.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.0",
|
"@types/node": "^22.19.17",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/three": "^0.183.0",
|
"@types/three": "^0.183.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.5.0",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.10",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -637,15 +637,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz",
|
||||||
"integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==",
|
"integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz",
|
||||||
"integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==",
|
"integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -659,9 +659,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz",
|
||||||
"integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
|
"integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -675,9 +675,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz",
|
||||||
"integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
|
"integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -694,9 +694,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz",
|
||||||
"integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
|
"integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -713,9 +713,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz",
|
||||||
"integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
|
"integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -732,9 +732,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz",
|
||||||
"integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
|
"integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -751,9 +751,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz",
|
||||||
"integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
|
"integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -767,9 +767,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz",
|
||||||
"integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
|
"integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1016,9 +1016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-three/fiber": {
|
"node_modules/@react-three/fiber": {
|
||||||
"version": "9.5.0",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
|
||||||
"integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==",
|
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
@@ -1085,13 +1085,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.17",
|
"version": "22.19.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||||
"integrity": "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g==",
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.20.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/offscreencanvas": {
|
"node_modules/@types/offscreencanvas": {
|
||||||
@@ -1101,22 +1101,22 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.0.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.0.6",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-lo6MuY+rFr8kIiFnr+7TzO+Av0wUPcEcepiPV4epGP0eTQpkDfp9czudg73isV8UxKauCUNlL1N8fXhcnx4iBw==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-reconciler": {
|
"node_modules/@types/react-reconciler": {
|
||||||
@@ -1208,9 +1208,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.27",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||||
"integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
|
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1228,8 +1228,8 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"browserslist": "^4.28.1",
|
"browserslist": "^4.28.2",
|
||||||
"caniuse-lite": "^1.0.30001774",
|
"caniuse-lite": "^1.0.30001787",
|
||||||
"fraction.js": "^5.3.4",
|
"fraction.js": "^5.3.4",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"postcss-value-parser": "^4.2.0"
|
"postcss-value-parser": "^4.2.0"
|
||||||
@@ -1399,9 +1399,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001784",
|
"version": "1.0.30001792",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||||
"integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==",
|
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1967,12 +1967,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz",
|
||||||
"integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==",
|
"integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.2.2",
|
"@next/env": "16.2.5",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -1986,14 +1986,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.2",
|
"@next/swc-darwin-arm64": "16.2.5",
|
||||||
"@next/swc-darwin-x64": "16.2.2",
|
"@next/swc-darwin-x64": "16.2.5",
|
||||||
"@next/swc-linux-arm64-gnu": "16.2.2",
|
"@next/swc-linux-arm64-gnu": "16.2.5",
|
||||||
"@next/swc-linux-arm64-musl": "16.2.2",
|
"@next/swc-linux-arm64-musl": "16.2.5",
|
||||||
"@next/swc-linux-x64-gnu": "16.2.2",
|
"@next/swc-linux-x64-gnu": "16.2.5",
|
||||||
"@next/swc-linux-x64-musl": "16.2.2",
|
"@next/swc-linux-x64-musl": "16.2.5",
|
||||||
"@next/swc-win32-arm64-msvc": "16.2.2",
|
"@next/swc-win32-arm64-msvc": "16.2.5",
|
||||||
"@next/swc-win32-x64-msvc": "16.2.2",
|
"@next/swc-win32-x64-msvc": "16.2.5",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2140,9 +2140,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2340,32 +2340,26 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.0.4",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
"integrity": "sha512-6RpEg9/n0sThnO+2CaMLWuvL1iyctm9/lcSTwvmyCoJYD5eiIrwxevXtrMqrtUr96HCdQB8/Yf+oK1QGy8kXEQ==",
|
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.0.4",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||||
"integrity": "sha512-JiVlwQwuINIQf2+XUjtRFtLxhTE6hcyX7ZyCmY0HM7I/Kgi7qyXThkzwzg6uCfu3rTg9Ofe1x8qWYrfqthIrzg==",
|
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.25.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.4"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom/node_modules/scheduler": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-use-measure": {
|
"node_modules/react-use-measure": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||||
@@ -2896,9 +2890,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.7.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2910,9 +2904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.20.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "upload-gltf",
|
"name": "upload-gltf",
|
||||||
"version": "0.1.5",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"next": "^16.2.4",
|
"next": "16.2.5",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|||||||
Generated
-1947
File diff suppressed because it is too large
Load Diff
@@ -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_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()
|
||||||
Reference in New Issue
Block a user