Files
upload-gltf/lib/upload-staging.ts
2026-05-13 17:50:26 +02:00

284 lines
8.4 KiB
TypeScript

import { randomUUID } from 'crypto'
import { dirname, join } from 'path'
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { TMP_DIR } from '@/lib/constants'
import { getErrorMessage, isRecord } from '@/lib/guards'
import { getModelAssetPath } from '@/lib/model-paths'
import { prepareGitAssets } from '@/lib/prepare-git-assets'
import type {
AssetCategory,
GitModelMode,
ParsedFile,
PreparedAssetSummary,
PreparedStageAssetsResult,
PushFile,
StagingUploadResult,
} from '@/lib/types'
const STAGING_ROOT = join(TMP_DIR, 'staging')
const STAGING_TTL_MS = 60 * 60 * 1000
interface StagedOriginalFile {
filename: string
size: number
isModel: boolean
}
interface StagedPreparedData {
modelFilename: string
compressed: boolean
deliveryMode: GitModelMode
compressionError?: string
assetSummaries: PreparedAssetSummary[]
}
interface StagingManifest {
stagingId: string
folderName: string
gitModelMode: GitModelMode
createdAt: number
originals: StagedOriginalFile[]
prepared?: StagedPreparedData
}
function getStageDir(stagingId: string) {
return join(STAGING_ROOT, stagingId)
}
function getOriginalDir(stagingId: string) {
return join(getStageDir(stagingId), 'original')
}
function getPreparedDir(stagingId: string) {
return join(getStageDir(stagingId), 'prepared')
}
function getManifestPath(stagingId: string) {
return join(getStageDir(stagingId), 'manifest.json')
}
function isGitModelMode(value: unknown): value is GitModelMode {
return value === 'draco-glb' || value === 'keep-gltf'
}
function isAssetCategory(value: unknown): value is AssetCategory {
return value === 'color'
|| value === 'diffuse'
|| value === 'roughness'
|| value === 'normal'
|| value === 'metalness'
|| value === 'height'
|| value === 'opacity'
|| value === 'orm'
|| value === 'ao'
|| value === 'assets'
}
function isPreparedAssetSummary(value: unknown): value is PreparedAssetSummary {
return isRecord(value)
&& typeof value.filename === 'string'
&& (value.kind === 'model' || value.kind === 'texture' || value.kind === 'asset')
&& (value.category === undefined || isAssetCategory(value.category))
&& typeof value.compressed === 'boolean'
}
function isStagedOriginalFile(value: unknown): value is StagedOriginalFile {
return isRecord(value)
&& typeof value.filename === 'string'
&& typeof value.size === 'number'
&& typeof value.isModel === 'boolean'
}
function isStagedPreparedData(value: unknown): value is StagedPreparedData {
return isRecord(value)
&& typeof value.modelFilename === 'string'
&& typeof value.compressed === 'boolean'
&& isGitModelMode(value.deliveryMode)
&& (value.compressionError === undefined || typeof value.compressionError === 'string')
&& Array.isArray(value.assetSummaries)
&& value.assetSummaries.every(isPreparedAssetSummary)
}
function isStagingManifest(value: unknown): value is StagingManifest {
return isRecord(value)
&& typeof value.stagingId === 'string'
&& typeof value.folderName === 'string'
&& isGitModelMode(value.gitModelMode)
&& typeof value.createdAt === 'number'
&& Array.isArray(value.originals)
&& value.originals.every(isStagedOriginalFile)
&& (value.prepared === undefined || isStagedPreparedData(value.prepared))
}
function parseStagingManifest(content: string) {
const parsed: unknown = JSON.parse(content)
if (!isStagingManifest(parsed)) {
throw new Error('Manifest de staging invalide')
}
return parsed
}
async function ensureParentDir(filePath: string) {
await mkdir(dirname(filePath), { recursive: true })
}
async function writeManifest(manifest: StagingManifest) {
await ensureParentDir(getManifestPath(manifest.stagingId))
await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8')
}
async function cleanupExpiredStagingUploads() {
if (!existsSync(STAGING_ROOT)) return
const entries = await readdir(STAGING_ROOT, { withFileTypes: true })
const now = Date.now()
for (const entry of entries) {
if (!entry.isDirectory()) continue
const stagingId = entry.name
try {
const manifest = await readStagedManifest(stagingId)
if (now - manifest.createdAt > STAGING_TTL_MS) {
await cleanupStagingUpload(stagingId)
}
} catch (err) {
await cleanupStagingUpload(stagingId).catch((cleanupErr) => {
console.warn('[WARN] Staging cleanup failed', {
stagingId,
error: getErrorMessage(cleanupErr),
originalError: getErrorMessage(err),
})
})
}
}
}
export async function createStagingUpload(
folderName: string,
parsedFiles: ParsedFile[],
gitModelMode: GitModelMode,
): Promise<StagingUploadResult> {
await cleanupExpiredStagingUploads()
const stagingId = randomUUID()
const originalDir = getOriginalDir(stagingId)
await mkdir(originalDir, { recursive: true })
const originals: StagedOriginalFile[] = []
for (const file of parsedFiles) {
const filePath = join(originalDir, file.filename)
await ensureParentDir(filePath)
await writeFile(filePath, file.buffer)
originals.push({ filename: file.filename, size: file.buffer.length, isModel: file.isModel })
}
const manifest: StagingManifest = {
stagingId,
folderName,
gitModelMode,
createdAt: Date.now(),
originals,
}
await writeManifest(manifest)
return {
stagingId,
folderName,
filesCount: originals.length,
}
}
export async function readStagedManifest(stagingId: string): Promise<StagingManifest> {
const manifestPath = getManifestPath(stagingId)
const content = await readFile(manifestPath, 'utf-8')
return parseStagingManifest(content)
}
async function readOriginalParsedFiles(stagingId: string, manifest: StagingManifest): Promise<ParsedFile[]> {
const originalDir = getOriginalDir(stagingId)
return Promise.all(
manifest.originals.map(async (file) => ({
filename: file.filename,
buffer: await readFile(join(originalDir, file.filename)),
isModel: file.isModel,
})),
)
}
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest, prepared: StagedPreparedData): Promise<PushFile[]> {
const preparedDir = getPreparedDir(stagingId)
return Promise.all(
prepared.assetSummaries.map(async (file) => {
const buffer = await readFile(join(preparedDir, file.filename))
return {
path: getModelAssetPath(manifest.folderName, file.filename),
contentBase64: buffer.toString('base64'),
}
}),
)
}
export async function ensurePreparedStagingAssets(stagingId: string): Promise<PreparedStageAssetsResult> {
const manifest = await readStagedManifest(stagingId)
if (!manifest.prepared) {
const parsedFiles = await readOriginalParsedFiles(stagingId, manifest)
const prepared = await prepareGitAssets({
folderName: manifest.folderName,
parsedFiles,
gitModelMode: manifest.gitModelMode,
})
const preparedDir = getPreparedDir(stagingId)
await mkdir(preparedDir, { recursive: true })
for (const file of prepared.filesToPush) {
const filename = file.path.split('/').pop()
if (!filename) continue
const outputPath = join(preparedDir, filename)
await ensureParentDir(outputPath)
await writeFile(outputPath, Buffer.from(file.contentBase64, 'base64'))
}
manifest.prepared = {
modelFilename: prepared.modelFilename,
compressed: prepared.compressed,
deliveryMode: prepared.deliveryMode,
compressionError: prepared.compressionError,
assetSummaries: prepared.assetSummaries,
}
await writeManifest(manifest)
}
return {
folderName: manifest.folderName,
filesToPush: await buildPreparedPushFiles(stagingId, manifest, manifest.prepared),
modelFilename: manifest.prepared.modelFilename,
assetSummaries: manifest.prepared.assetSummaries,
compressed: manifest.prepared.compressed,
deliveryMode: manifest.prepared.deliveryMode,
compressionError: manifest.prepared.compressionError,
}
}
export async function readStagedOriginalFiles(stagingId: string): Promise<{ folderName: string; files: ParsedFile[] }> {
const manifest = await readStagedManifest(stagingId)
return {
folderName: manifest.folderName,
files: await readOriginalParsedFiles(stagingId, manifest),
}
}
export async function cleanupStagingUpload(stagingId: string) {
await rm(getStageDir(stagingId), { recursive: true, force: true })
}