Compare commits
5 Commits
71b4b2c905
...
72e12a6e3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 72e12a6e3d | |||
| 83b2b405b4 | |||
| 3cfb3a21a9 | |||
| 81c513ee1f | |||
| 377ed7cfb3 |
@@ -1,9 +1,14 @@
|
|||||||
UPLOAD_SECRET_KEY=your-secret-key-here
|
UPLOAD_SECRET_KEY=your-secret-key-here
|
||||||
|
GIT_PROVIDER=gitea
|
||||||
GIT_USERNAME=your-gitea-username
|
GIT_USERNAME=your-gitea-username
|
||||||
GIT_TOKEN=your-git-provider-token
|
GIT_TOKEN=your-git-provider-token
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://git.example.com/your-org/your-repo
|
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
|
||||||
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
|
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
|
||||||
|
|||||||
+13
-2
@@ -24,15 +24,26 @@ RUN npm run build
|
|||||||
# --- Stage 3: Production -----------------------------------------------------
|
# --- Stage 3: Production -----------------------------------------------------
|
||||||
FROM node:20-slim AS runner
|
FROM node:20-slim AS runner
|
||||||
|
|
||||||
|
ARG KTX_SOFTWARE_VERSION=4.4.2
|
||||||
|
|
||||||
LABEL maintainer="La Fabrik Durable"
|
LABEL maintainer="La Fabrik Durable"
|
||||||
LABEL description="Secure GLTF upload interface with Draco compression and Git push"
|
LABEL description="Secure GLTF upload interface with Draco/KTX2 compression and Git push"
|
||||||
|
|
||||||
# Blender is required for server-side Draco GLB export.
|
# 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 \
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
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
|
||||||
- **Git remote** — Delivers Draco-compressed GLB assets by default, with an optional GLTF delivery mode for specific models
|
- **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'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.
|
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.
|
||||||
|
|
||||||
@@ -12,9 +12,10 @@ Built for La Fabrik Durable's internal use, but open-sourced for anyone looking
|
|||||||
- [**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/)
|
- [**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**](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
|
- [**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**](https://v3.tailwindcss.com/docs/installation) for styling
|
- [**Tailwind CSS**](https://v3.tailwindcss.com/docs/installation) for styling
|
||||||
- [**Octokit**](https://github.com/octokit/rest.js/#readme) for pushing via a GitHub-compatible API (GitHub or Gitea)
|
- [**Octokit**](https://github.com/octokit/rest.js/#readme) + provider adapters for GitHub and Gitea uploads
|
||||||
- [**Nextcloud WebDAV**](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html) 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**](https://sharp.pixelplumbing.com/install/) for server-side texture compression
|
- [**Sharp**](https://sharp.pixelplumbing.com/install/) for server-side WebP texture fallback
|
||||||
|
- [**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
|
- [**npm lockfile + Coolify** (Docker)](https://coolify.io/docs/applications/build-packs/dockerfile) for hosting
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -87,7 +88,7 @@ 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. 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` + compressed textures workflow and shows a warning. For specific models, "Envoyer en GLTF" keeps that separate GLTF delivery mode from the start.
|
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)
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ All files are uploaded to `VF/` (not just diffs), because the move operation emp
|
|||||||
- 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
|
- 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
|
- Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit
|
||||||
|
|
||||||
### Commit messages
|
### Commit messages
|
||||||
@@ -153,7 +155,7 @@ 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. Default Git delivery pushes `model.glb` when Blender compression succeeds. If Blender fails, the app falls back to separate `model.gltf`, `.bin`, and compressed textures with a warning. "Envoyer en GLTF" always uses separate GLTF delivery.
|
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.
|
||||||
|
|
||||||
@@ -162,6 +164,7 @@ Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
|
|||||||
- Large uploads are staged once, but the Drive upload remains sequential.
|
- Large uploads are staged once, but the Drive upload remains sequential.
|
||||||
- Git LFS batch uploads are sequential by batch.
|
- 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.
|
- 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
|
||||||
@@ -186,6 +189,7 @@ 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
|
||||||
@@ -206,21 +210,30 @@ lib/
|
|||||||
├── 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 for GitHub-compatible remotes
|
├── 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
|
├── 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
|
||||||
scripts/
|
scripts/
|
||||||
└── compress.py # Blender Draco compression script
|
└── compress.py # Blender Draco compression script
|
||||||
Dockerfile # Multi-stage build: Node 20 slim + Blender + tini
|
Dockerfile # Multi-stage build: Node 20 slim + Blender + KTX-Software + tini
|
||||||
docker-entrypoint.sh # Upload temp setup + Blender availability check
|
docker-entrypoint.sh # Upload temp setup + Blender/toktx availability check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -237,10 +250,14 @@ Copy `.env.example` to `.env.local` and fill in the values:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
UPLOAD_SECRET_KEY=your-secret-key-here
|
UPLOAD_SECRET_KEY=your-secret-key-here
|
||||||
|
GIT_PROVIDER=gitea
|
||||||
GIT_USERNAME=your-gitea-username
|
GIT_USERNAME=your-gitea-username
|
||||||
GIT_TOKEN=your-git-provider-token
|
GIT_TOKEN=your-git-provider-token
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://git.example.com/your-org/your-repo
|
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
|
||||||
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
|
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
|
||||||
@@ -251,10 +268,13 @@ NEXTCLOUD_BASE_PATH=Models
|
|||||||
| Variable | Description | Required |
|
| Variable | Description | Required |
|
||||||
|----------|-------------|----------|
|
|----------|-------------|----------|
|
||||||
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
|
| `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_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_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_BRANCH` | Target branch (default: main) | No |
|
||||||
| `GIT_REPO_URL` | Target GitHub or Gitea repository URL (`owner/repo`, HTTPS, or SSH) | Yes |
|
| `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_URL` | Nextcloud instance URL | Yes |
|
||||||
| `NEXTCLOUD_SHARE_TOKEN` | Public share token (the part after `/s/` in the share link) | 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_SHARE_PASSWORD` | Public share password (empty if none) | No |
|
||||||
@@ -262,6 +282,34 @@ NEXTCLOUD_BASE_PATH=Models
|
|||||||
|
|
||||||
> GitHub tokens need `Contents: Read and write`. Gitea tokens need repository read/write access.
|
> 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
|
> 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)
|
### Production (Coolify / Docker)
|
||||||
@@ -280,6 +328,7 @@ After a security patch:
|
|||||||
docker build -t upload-gltf .
|
docker build -t upload-gltf .
|
||||||
docker run -p 3000:3000 \
|
docker run -p 3000:3000 \
|
||||||
-e UPLOAD_SECRET_KEY=your-key \
|
-e UPLOAD_SECRET_KEY=your-key \
|
||||||
|
-e GIT_PROVIDER=gitea \
|
||||||
-e GIT_USERNAME=your-gitea-username \
|
-e GIT_USERNAME=your-gitea-username \
|
||||||
-e GIT_TOKEN=token_xxx \
|
-e GIT_TOKEN=token_xxx \
|
||||||
-e GIT_REPO_URL=https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik \
|
-e GIT_REPO_URL=https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik \
|
||||||
@@ -288,7 +337,7 @@ docker run -p 3000:3000 \
|
|||||||
upload-gltf
|
upload-gltf
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker image runs the Next.js app, Blender Draco compression, and server-side asset preparation in a single container. The `docker-entrypoint.sh` script creates the upload temp directory and reports Blender availability before launching the app.
|
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
|
### Secret rotation
|
||||||
|
|
||||||
@@ -327,9 +376,10 @@ git push origin main && git push gitea main
|
|||||||
|------|------------|
|
|------|------------|
|
||||||
| 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.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { validateUploadSecret } from '@/lib/auth'
|
import { validateUploadSecret } from '@/lib/auth'
|
||||||
import { getRemoteFolder, pushAllToGit } 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'
|
||||||
|
|||||||
+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 Git
|
avec versioning, puis envoyes aux devs via Git
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ 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({
|
||||||
|
|||||||
+32
-18
@@ -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 {
|
||||||
@@ -75,25 +76,38 @@ 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">
|
<>
|
||||||
Deposez un dossier complet contenant votre modele 3D nomme
|
<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">
|
||||||
{' '}<span className="font-mono text-gray-200">model.gltf</span>
|
Deposez un dossier complet contenant votre modele 3D nomme
|
||||||
{' '}ainsi que toutes les textures et fichiers binaires necessaires.
|
{' '}<span className="font-mono text-gray-200">model.gltf</span>
|
||||||
{' '}Les fichiers associes peuvent etre en
|
{' '}ainsi que toutes les textures et fichiers binaires necessaires.
|
||||||
{' '}<span className="font-mono text-gray-200">.png</span>,
|
{' '}Les fichiers associes peuvent etre en
|
||||||
{' '}<span className="font-mono text-gray-200">.jpg</span>
|
{' '}<span className="font-mono text-gray-200">.png</span>,
|
||||||
{' '}<span className="font-mono text-gray-200">.webp</span>
|
{' '}<span className="font-mono text-gray-200">.jpg</span>
|
||||||
{' '}ou <span className="font-mono text-gray-200">.bin</span>.
|
{' '}<span className="font-mono text-gray-200">.webp</span>
|
||||||
{' '}Utilisez un nom simple si la texture s'applique au modele entier, et un nom detaille si elle correspond a une partie precise du modele,
|
{' '}ou <span className="font-mono text-gray-200">.bin</span>.
|
||||||
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>,
|
{' '}Utilisez un nom simple si la texture s'applique au modele entier, et un nom detaille si elle correspond a une partie precise du modele,
|
||||||
{' '}<span className="font-mono text-gray-200">roughness_tuyaux.png</span>,
|
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>,
|
||||||
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</span>
|
{' '}<span className="font-mono text-gray-200">roughness_tuyaux.png</span>,
|
||||||
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>.
|
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</span>
|
||||||
{' '}Les exports classiques comme <span className="font-mono text-gray-200">porte_baseColor.png</span>
|
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>.
|
||||||
{' '}ou <span className="font-mono text-gray-200">porte_normal_opengl.png</span> sont normalises automatiquement pour Git.
|
{' '}Les exports classiques comme <span className="font-mono text-gray-200">porte_baseColor.png</span>
|
||||||
</p>
|
{' '}ou <span className="font-mono text-gray-200">porte_normal_opengl.png</span> sont normalises automatiquement pour Git.
|
||||||
|
</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 && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { formatBytes } from '@/lib/format-bytes'
|
|||||||
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } 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 })
|
||||||
|
|
||||||
@@ -101,12 +102,15 @@ 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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ModelViewer
|
<div className="grid gap-2 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start">
|
||||||
url={entry.modelUrl}
|
<TextureDiagnosticsPanel report={entry.textureReport} />
|
||||||
assetUrls={entry.assetUrls}
|
<ModelViewer
|
||||||
filename={entry.modelFile.name}
|
url={entry.modelUrl}
|
||||||
size={formatBytes(entry.modelFile.size)}
|
assetUrls={entry.assetUrls}
|
||||||
/>
|
filename={entry.modelFile.name}
|
||||||
|
size={formatBytes(entry.modelFile.size)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ else
|
|||||||
echo "[upload-gltf] WARNING: Blender not found. GLB Draco compression will fall back to separate GLTF delivery."
|
echo "[upload-gltf] WARNING: Blender not found. GLB Draco compression will fall back to separate GLTF delivery."
|
||||||
fi
|
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 "$@"
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ export interface TextureFile {
|
|||||||
file: File
|
file: File
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
||||||
|
|
||||||
export interface FolderEntry {
|
export interface FolderEntry {
|
||||||
@@ -20,6 +34,7 @@ export interface FolderEntry {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
+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(['.glb', '.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'
|
||||||
|
|
||||||
|
|||||||
@@ -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 }>
|
||||||
|
}
|
||||||
-626
@@ -1,626 +0,0 @@
|
|||||||
import { createHash } from 'crypto'
|
|
||||||
import { Octokit } from '@octokit/rest'
|
|
||||||
import { LFS_EXTENSIONS } from './constants'
|
|
||||||
import { isRecord } from './guards'
|
|
||||||
import type { PushFile, RemoteFile } from './types'
|
|
||||||
|
|
||||||
const LFS_BATCH_SIZE = 100
|
|
||||||
|
|
||||||
type LogDetails = Record<string, string | number | boolean | undefined>
|
|
||||||
|
|
||||||
interface GitRemoteConfig {
|
|
||||||
apiBaseUrl: string
|
|
||||||
lfsBatchUrl: string
|
|
||||||
owner: string
|
|
||||||
provider: 'github' | 'gitea'
|
|
||||||
repo: string
|
|
||||||
token: string
|
|
||||||
webUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitApiError extends Error {
|
|
||||||
constructor(message: string, public status: number) {
|
|
||||||
super(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHttpError(err: unknown): err is { status: number } {
|
|
||||||
return isRecord(err) && typeof err.status === 'number'
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodePath(path: string) {
|
|
||||||
return path.split('/').map(encodeURIComponent).join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRepoApiPath(remote: GitRemoteConfig) {
|
|
||||||
return `/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.repo)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 getGitUsername() {
|
|
||||||
return process.env.GIT_USERNAME?.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLfsAuthorizationHeader(remote: GitRemoteConfig) {
|
|
||||||
if (remote.provider === 'github') return `token ${remote.token}`
|
|
||||||
|
|
||||||
const username = getGitUsername()
|
|
||||||
if (!username) {
|
|
||||||
throw new Error('GIT_USERNAME non configure pour Git LFS sur Gitea')
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Basic ${Buffer.from(`${username}:${remote.token}`, 'utf-8').toString('base64')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOctokit(remote: GitRemoteConfig): Octokit {
|
|
||||||
return new Octokit({
|
|
||||||
auth: remote.token,
|
|
||||||
baseUrl: remote.apiBaseUrl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanRepoName(repo: string) {
|
|
||||||
return repo.replace(/\/+$/, '').replace(/\.git$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRemoteConfig(host: string, owner: string, repo: string, protocol = 'https:'): GitRemoteConfig {
|
|
||||||
const normalizedHost = host.toLowerCase()
|
|
||||||
const origin = `${protocol === 'http:' ? 'http' : 'https'}://${host}`
|
|
||||||
const isGitHub = normalizedHost === 'github.com'
|
|
||||||
|
|
||||||
return {
|
|
||||||
apiBaseUrl: isGitHub ? 'https://api.github.com' : `${origin}/api/v1`,
|
|
||||||
lfsBatchUrl: `${origin}/${owner}/${repo}.git/info/lfs/objects/batch`,
|
|
||||||
owner,
|
|
||||||
provider: isGitHub ? 'github' : 'gitea',
|
|
||||||
repo,
|
|
||||||
token: getGitToken(),
|
|
||||||
webUrl: `${origin}/${owner}/${repo}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRepoUrl(): 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) {
|
|
||||||
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}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLfsFile(filePath: string): boolean {
|
|
||||||
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
|
|
||||||
return LFS_EXTENSIONS.has(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLfsPointer(sha256: string, size: number): string {
|
|
||||||
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
&& typeof value.path === 'string'
|
|
||||||
&& typeof value.sha === 'string'
|
|
||||||
&& typeof value.size === 'number'
|
|
||||||
&& (value.content === undefined || typeof value.content === 'string')
|
|
||||||
&& (value.type === undefined || typeof value.type === 'string')
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeBase64Content(content: string) {
|
|
||||||
return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8')
|
|
||||||
}
|
|
||||||
|
|
||||||
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()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LfsObject {
|
|
||||||
oid: string
|
|
||||||
size: number
|
|
||||||
contentBase64: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type LfsPushFile = PushFile & {
|
|
||||||
oid: string
|
|
||||||
size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LfsBatchAction {
|
|
||||||
href: string
|
|
||||||
header?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LfsBatchObject {
|
|
||||||
oid: string
|
|
||||||
size: number
|
|
||||||
actions?: {
|
|
||||||
upload?: LfsBatchAction
|
|
||||||
verify?: LfsBatchAction
|
|
||||||
}
|
|
||||||
error?: { code: number; message: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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((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: unknown = await batchRes.json()
|
|
||||||
const batchObjects = parseLfsBatchResponse(batchData)
|
|
||||||
|
|
||||||
const objectMap = new Map(objects.map((o) => [o.oid, o]))
|
|
||||||
|
|
||||||
for (const obj of batchObjects) {
|
|
||||||
if (obj.error) {
|
|
||||||
throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushAllToGitea(
|
|
||||||
remote: GitRemoteConfig,
|
|
||||||
regularFiles: PushFile[],
|
|
||||||
lfsFiles: LfsPushFile[],
|
|
||||||
deletePaths: string[],
|
|
||||||
commitMessage: string,
|
|
||||||
branch: string,
|
|
||||||
): Promise<{ commitUrl: string }> {
|
|
||||||
const committedFiles: PushFile[] = [
|
|
||||||
...regularFiles,
|
|
||||||
...lfsFiles.map((file) => ({
|
|
||||||
path: file.path,
|
|
||||||
contentBase64: Buffer.from(buildLfsPointer(file.oid, file.size), 'utf-8').toString('base64'),
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
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?.sha,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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: existing.sha,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRemoteFolder(
|
|
||||||
folderPath: string,
|
|
||||||
): Promise<{ exists: boolean; files: RemoteFile[] }> {
|
|
||||||
const remote = parseRepoUrl()
|
|
||||||
const branch = process.env.GIT_BRANCH ?? 'main'
|
|
||||||
|
|
||||||
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 (f: unknown): Promise<RemoteFile> => {
|
|
||||||
if (!isGitContentEntry(f)) {
|
|
||||||
throw new Error(`Reponse Git invalide pour ${folderPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLfsFile(f.name) || f.size > 1024) {
|
|
||||||
return { name: f.name, size: f.size }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileData = await getRemoteFileEntry(remote, `${folderPath}/${f.name}`, branch)
|
|
||||||
|
|
||||||
if (fileData?.content) {
|
|
||||||
const content = decodeBase64Content(fileData.content)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pushAllToGit(
|
|
||||||
files: PushFile[],
|
|
||||||
deletePaths: string[],
|
|
||||||
commitMessage: string,
|
|
||||||
): Promise<{ commitUrl: string }> {
|
|
||||||
const remote = parseRepoUrl()
|
|
||||||
const branch = process.env.GIT_BRANCH ?? 'main'
|
|
||||||
|
|
||||||
const lfsFiles: LfsPushFile[] = []
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lfsFiles.length > 0) {
|
|
||||||
await uploadToLfs(
|
|
||||||
remote,
|
|
||||||
lfsFiles.map((f) => ({ oid: f.oid, size: f.size, contentBase64: f.contentBase64 })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remote.provider === 'gitea') {
|
|
||||||
return pushAllToGitea(remote, regularFiles, lfsFiles, deletePaths, commitMessage, branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
const octokit = getOctokit(remote)
|
|
||||||
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((f) => {
|
|
||||||
const lfs = lfsFiles.find((lf) => lf.path === f.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: f.contentBase64,
|
|
||||||
encoding: 'base64',
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
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}` }
|
|
||||||
}
|
|
||||||
+182
-14
@@ -3,7 +3,7 @@ import { existsSync } from 'fs'
|
|||||||
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||||
import { extname, join } from 'path'
|
import { extname, join } from 'path'
|
||||||
import { compressWithBlender } from '@/lib/blender'
|
import { compressWithBlender } from '@/lib/blender'
|
||||||
import { compressTextureBuffer } from '@/lib/texture-compression'
|
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 { normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
|
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
|
||||||
@@ -18,6 +18,17 @@ interface PrepareGitAssetsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
type JsonObject = { [key: string]: JsonValue }
|
||||||
|
|
||||||
|
interface PreparedTexturePlan {
|
||||||
|
filename: string
|
||||||
|
buffer: Buffer
|
||||||
|
category: PreparedAssetSummary['category']
|
||||||
|
compressed: boolean
|
||||||
|
format: 'ktx2' | 'webp' | 'original'
|
||||||
|
}
|
||||||
|
|
||||||
|
const KHR_TEXTURE_BASISU = 'KHR_texture_basisu'
|
||||||
|
|
||||||
function isJsonValue(value: unknown): value is JsonValue {
|
function isJsonValue(value: unknown): value is JsonValue {
|
||||||
if (value === null) return true
|
if (value === null) return true
|
||||||
@@ -43,6 +54,34 @@ function parseJsonValue(content: string) {
|
|||||||
return parsed
|
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[]) {
|
function getTextureFilenameMap(parsedFiles: ParsedFile[]) {
|
||||||
const filenameMap = new Map<string, string>()
|
const filenameMap = new Map<string, string>()
|
||||||
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
|
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
|
||||||
@@ -97,11 +136,115 @@ function rewriteGltfUris(value: JsonValue, filenameMap: Map<string, string>): Js
|
|||||||
return rewritten
|
return rewritten
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
|
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
|
if (filenameMap.size === 0) return buffer
|
||||||
|
|
||||||
const parsed = parseJsonValue(buffer.toString('utf-8'))
|
const parsed = parseJsonValue(buffer.toString('utf-8'))
|
||||||
return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), '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(
|
async function prepareSeparateFiles(
|
||||||
@@ -111,16 +254,30 @@ async function prepareSeparateFiles(
|
|||||||
) {
|
) {
|
||||||
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
|
let filename = pf.filename
|
||||||
|
|
||||||
if (pf.isModel) {
|
if (pf.isModel) {
|
||||||
content = prepareModelBuffer(pf.buffer, textureFilenameMap)
|
content = prepareModelBuffer(pf.buffer, deliveryFilenameMap, ktx2Filenames)
|
||||||
modelFilename = pf.filename
|
modelFilename = pf.filename
|
||||||
|
|
||||||
assetSummaries.push({
|
assetSummaries.push({
|
||||||
@@ -128,24 +285,35 @@ async function prepareSeparateFiles(
|
|||||||
kind: 'model',
|
kind: 'model',
|
||||||
compressed: false,
|
compressed: false,
|
||||||
})
|
})
|
||||||
|
} else if (texturePlans.has(pf.filename.toLowerCase())) {
|
||||||
|
const texturePlan = texturePlans.get(pf.filename.toLowerCase())
|
||||||
|
if (!texturePlan) continue
|
||||||
|
|
||||||
|
compressed ||= texturePlan.compressed
|
||||||
|
|
||||||
|
assetSummaries.push({
|
||||||
|
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 {
|
} else {
|
||||||
filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
|
filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
|
||||||
const categoryFilename = textureFilenameMap.get(pf.filename.toLowerCase()) || normalizeTextureFilename(pf.filename) || pf.filename
|
const categoryFilename = textureFilenameMap.get(pf.filename.toLowerCase()) || normalizeTextureFilename(pf.filename) || pf.filename
|
||||||
const category = classifyAssetCategory(categoryFilename)
|
const category = classifyAssetCategory(categoryFilename)
|
||||||
|
|
||||||
const textureResult = await compressTextureBuffer(filename, pf.buffer)
|
|
||||||
content = textureResult.buffer
|
|
||||||
compressed ||= textureResult.compressed
|
|
||||||
|
|
||||||
if (textureResult.error && !compressionError) {
|
|
||||||
compressionError = textureResult.error
|
|
||||||
}
|
|
||||||
|
|
||||||
assetSummaries.push({
|
assetSummaries.push({
|
||||||
filename,
|
filename,
|
||||||
kind: category === 'assets' ? 'asset' : 'texture',
|
kind: category === 'assets' ? 'asset' : 'texture',
|
||||||
category,
|
category,
|
||||||
compressed: textureResult.compressed,
|
compressed: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+156
-2
@@ -1,13 +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'
|
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,
|
||||||
@@ -46,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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+291
-9
@@ -1,7 +1,7 @@
|
|||||||
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
||||||
import { getTextureNamingError } from '@/lib/asset-naming'
|
import { getTextureNamingError, normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
import { getErrorMessage, isRecord } from '@/lib/guards'
|
import { getErrorMessage, isRecord } from '@/lib/guards'
|
||||||
import type { TextureFile } from '@/lib/client-types'
|
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]
|
||||||
|
|
||||||
@@ -9,12 +9,64 @@ interface GltfBufferReference {
|
|||||||
uri?: string
|
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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationResult =
|
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 {
|
function isGltfBufferReference(value: unknown): value is GltfBufferReference {
|
||||||
@@ -27,12 +79,30 @@ function isGltfJson(value: unknown): value is GltfJson {
|
|||||||
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
|
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) {
|
||||||
return (gltf.buffers || [])
|
return (gltf.buffers || [])
|
||||||
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +110,29 @@ function getFileExtension(filename: string) {
|
|||||||
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGltfWarnings(model: File, supportFiles: File[]) {
|
function normalizeMatchName(name: string) {
|
||||||
const warnings: string[] = []
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpacityTarget(filename: string) {
|
||||||
|
const normalizedFilename = normalizeTextureFilename(filename) || filename
|
||||||
|
const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
|
||||||
|
return match ? match[1] || '' : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureFiles(supportFiles: File[]) {
|
||||||
|
return supportFiles.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureDisplayName(file: File) {
|
||||||
|
return file.webkitRelativePath || file.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseGltfModel(model: File) {
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -54,10 +145,15 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
|
|||||||
throw new Error('model.gltf a une structure invalide')
|
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) {
|
||||||
@@ -73,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[] = []
|
||||||
@@ -111,8 +385,12 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let warnings: string[] = []
|
let warnings: string[] = []
|
||||||
|
let textureReport: TextureDiagnosticReport | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
warnings = await getGltfWarnings(modelFiles[0], supportFiles)
|
const gltf = await parseGltfModel(modelFiles[0])
|
||||||
|
warnings = getGltfWarnings(gltf, supportFiles)
|
||||||
|
textureReport = getTextureDiagnosticReport(gltf, supportFiles)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(getErrorMessage(err, 'model.gltf invalide'))
|
errors.push(getErrorMessage(err, 'model.gltf invalide'))
|
||||||
}
|
}
|
||||||
@@ -121,5 +399,9 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
|||||||
return { ok: false, errors }
|
return { ok: false, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, model: modelFiles[0], textures, warnings }
|
if (!textureReport) {
|
||||||
|
return { ok: false, errors: ['model.gltf invalide'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, model: modelFiles[0], textures, warnings, textureReport }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user