fix: prevent duplicate uploads and group asset commits

This commit is contained in:
Tom Boullay
2026-04-24 16:58:49 +02:00
parent fe8a6f0f54
commit 53c4c0ed60
15 changed files with 329 additions and 152 deletions
+10
View File
@@ -7,6 +7,7 @@ import {
uploadFile,
findNextVersion,
} from '@/lib/nextcloud'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -56,6 +57,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, error: message }, { status: 400 })
}
if (!acquireUploadLock(folderName)) {
return NextResponse.json(
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
{ status: 409 },
)
}
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
const vfFolderPath = `${basePath}/VF/${folderName}`
@@ -95,5 +103,7 @@ export async function POST(req: NextRequest) {
{ success: false, error: `Drive echoue: ${message}` },
{ status: 500 },
)
} finally {
releaseUploadLock(folderName)
}
}
+76 -60
View File
@@ -5,6 +5,7 @@ import { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
import { buildCommitMessage } from '@/lib/commit-message'
import { classifyFileChanges } from '@/lib/diff-files'
import { prepareGitAssets } from '@/lib/prepare-git-assets'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -31,67 +32,82 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, error: message }, { status: 400 })
}
// --- Process files (compress model + textures for Git) ---
const {
filesToPush,
modelFilename,
compressed,
compressionError,
textureNames,
} = await prepareGitAssets({ folderName, parsedFiles })
// --- Detect existing files and classify changes ---
const folderPath = `public/models/${folderName}`
let remoteFileMap: Map<string, number>
try {
const remote = await getRemoteFolder(folderPath)
remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size]))
} catch {
remoteFileMap = new Map()
}
const isReplace = remoteFileMap.size > 0
const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
// If nothing changed, don't create an empty commit
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
return NextResponse.json({
success: true,
folderName,
filesCount: 0,
compressed,
compressionError: compressionError || undefined,
message: 'Aucun fichier modifie — rien a envoyer.',
})
}
// --- Build commit message ---
const commitMessage = buildCommitMessage(
folderName, modelFilename, textureNames,
compressed, isReplace, fileChanges, deletedFileNames,
)
// --- Push all in one commit ---
try {
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
return NextResponse.json({
success: true,
folderName,
filesCount: changedFilesToPush.length,
compressed,
compressionError: compressionError || undefined,
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`,
commitUrl,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue'
if (!acquireUploadLock(folderName)) {
return NextResponse.json(
{ success: false, error: `Push GitHub echoue: ${message}` },
{ status: 500 },
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
{ status: 409 },
)
}
try {
// --- Process files (compress model + textures for Git) ---
const {
filesToPush,
modelFilename,
compressed,
compressionError,
assetSummaries,
} = await prepareGitAssets({ folderName, parsedFiles })
// --- Detect existing files and classify changes ---
const folderPath = `public/models/${folderName}`
let remoteFileMap: Map<string, number>
try {
const remote = await getRemoteFolder(folderPath)
remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size]))
} catch {
remoteFileMap = new Map()
}
const isReplace = remoteFileMap.size > 0
const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
// If nothing changed, don't create an empty commit
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
return NextResponse.json({
success: true,
folderName,
filesCount: 0,
compressed,
compressionError: compressionError || undefined,
message: 'Aucun fichier modifie — rien a envoyer.',
})
}
// --- Build commit message ---
const commitMessage = buildCommitMessage(
folderName,
modelFilename,
assetSummaries,
isReplace,
fileChanges,
deletedFileNames,
)
// --- Push all in one commit ---
try {
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
return NextResponse.json({
success: true,
folderName,
filesCount: changedFilesToPush.length,
compressed,
compressionError: compressionError || undefined,
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`,
commitUrl,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue'
return NextResponse.json(
{ success: false, error: `Push GitHub echoue: ${message}` },
{ status: 500 },
)
}
} finally {
releaseUploadLock(folderName)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default function Home() {
<UploadZone />
<footer className="text-gray-500 text-xs text-center">
<footer className="mt-3 text-gray-500 text-xs text-center">
Modeles : <span className="font-mono text-gray-400">.glb</span>
<span className="mx-2">·</span>
Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</span>
+9 -3
View File
@@ -35,6 +35,8 @@ export default function UploadZone() {
const {
isUploading,
isChecking,
isResolvingDriveError,
globalError,
setGlobalError,
overwriteConfirm,
@@ -43,6 +45,7 @@ export default function UploadZone() {
setNoChangesFolder,
driveError,
handleUpload,
handleOverwriteCancel,
handleDriveContinue,
handleDriveCancel,
handleCancel,
@@ -93,7 +96,7 @@ export default function UploadZone() {
secret={secret}
secretVisible={secretVisible}
secretError={secretError}
disabled={isUploading}
disabled={isUploading || isChecking}
onChange={handleSecretChange}
onToggleVisible={toggleSecretVisible}
/>
@@ -101,7 +104,7 @@ export default function UploadZone() {
{entries.length === 0 && (
<div>
<FolderDropzone
isUploading={isUploading}
isUploading={isUploading || isChecking}
onFolderSelected={handleFolderSelected}
onError={setGlobalError}
/>
@@ -128,6 +131,7 @@ export default function UploadZone() {
<ActionButtons
isUploading={isUploading}
isChecking={isChecking}
isSecretEmpty={isSecretEmpty}
hasPendingOrErrors={hasPendingOrErrors}
allDone={allDone}
@@ -141,8 +145,9 @@ export default function UploadZone() {
<OverwriteConfirmModal
folderName={overwriteConfirm.folderName}
diffs={overwriteConfirm.diffs}
onCancel={() => setOverwriteConfirm(null)}
onCancel={handleOverwriteCancel}
onConfirm={proceedUpload}
disabled={isUploading || isChecking || isResolvingDriveError}
/>
)}
@@ -162,6 +167,7 @@ export default function UploadZone() {
error={driveError.error}
onCancel={handleDriveCancel}
onContinue={handleDriveContinue}
disabled={isResolvingDriveError}
/>
)}
</div>
+6 -2
View File
@@ -35,6 +35,7 @@ interface ModalActionsProps {
onConfirm: () => void
/** Tailwind classes for the confirm button (default: white bg) */
confirmClassName?: string
disabled?: boolean
}
export function ModalActions({
@@ -43,20 +44,23 @@ export function ModalActions({
onCancel,
onConfirm,
confirmClassName = 'bg-white text-[#000000] hover:bg-gray-200',
disabled = false,
}: ModalActionsProps) {
return (
<div className="flex gap-3">
<button
onClick={onCancel}
disabled={disabled}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-black-600"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`flex-1 font-medium text-sm py-2.5 px-4 rounded-xl transition-colors duration-150 ${confirmClassName}`}
disabled={disabled}
className={`flex-1 font-medium text-sm py-2.5 px-4 rounded-xl transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed ${confirmClassName}`}
>
{confirmLabel}
</button>
+16 -12
View File
@@ -1,5 +1,6 @@
interface ActionButtonsProps {
isUploading: boolean
isChecking: boolean
isSecretEmpty: boolean
hasPendingOrErrors: boolean
allDone: boolean
@@ -11,6 +12,7 @@ interface ActionButtonsProps {
export default function ActionButtons({
isUploading,
isChecking,
isSecretEmpty,
hasPendingOrErrors,
allDone,
@@ -19,11 +21,12 @@ export default function ActionButtons({
onCancel,
onReset,
}: ActionButtonsProps) {
const cantUpload = isSecretEmpty
const cantUpload = isSecretEmpty || isChecking
const isBusy = isUploading || isChecking
return (
<div className="flex gap-3">
{!isUploading && hasPendingOrErrors && (
{!isBusy && hasPendingOrErrors && (
<button
onClick={onUpload}
disabled={cantUpload}
@@ -38,18 +41,19 @@ export default function ActionButtons({
</button>
)}
{isUploading && (
<button
onClick={onCancel}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150
{isBusy && (
<button
onClick={onCancel}
disabled={!isUploading}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-black-600"
>
Annuler
</button>
)}
>
{isChecking ? 'Verification...' : 'Annuler'}
</button>
)}
{(allDone || hasErrors) && !isUploading && (
{(allDone || hasErrors) && !isBusy && (
<button
onClick={onReset}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
+3
View File
@@ -5,12 +5,14 @@ interface DriveErrorModalProps {
error: string
onCancel: () => void
onContinue: () => void
disabled?: boolean
}
export default function DriveErrorModal({
error,
onCancel,
onContinue,
disabled = false,
}: DriveErrorModalProps) {
return (
<Modal ariaLabelledBy="drive-error-title">
@@ -41,6 +43,7 @@ export default function DriveErrorModal({
confirmLabel="Envoyer sur Git seulement"
onCancel={onCancel}
onConfirm={onContinue}
disabled={disabled}
/>
</Modal>
)
+2 -2
View File
@@ -2,7 +2,7 @@
// Drive/Git status sub-line for FolderCard
// ---------------------------------------------------------------------------
import { SpinnerIcon, XIcon, InfoIcon } from '@/components/ui/icons'
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
import type { FolderEntry } from '@/lib/client-types'
interface DriveStatusLineProps {
@@ -35,7 +35,7 @@ export default function DriveStatusLine({ driveStatus, driveError }: DriveStatus
)}
{driveStatus === 'skipped' && (
<>
<InfoIcon className="w-3 h-3 text-yellow-400" />
<WarningIcon className="w-3 h-3 text-yellow-400" />
<span className="text-xs text-yellow-400">Drive ignore</span>
</>
)}
@@ -7,6 +7,7 @@ interface OverwriteConfirmModalProps {
diffs: FileDiff[]
onCancel: () => void
onConfirm: () => void
disabled?: boolean
}
export default function OverwriteConfirmModal({
@@ -14,6 +15,7 @@ export default function OverwriteConfirmModal({
diffs,
onCancel,
onConfirm,
disabled = false,
}: OverwriteConfirmModalProps) {
return (
<Modal ariaLabelledBy="overwrite-title">
@@ -68,6 +70,7 @@ export default function OverwriteConfirmModal({
onCancel={onCancel}
onConfirm={onConfirm}
confirmClassName="bg-yellow-600 text-[#000000] hover:bg-yellow-500"
disabled={disabled}
/>
</Modal>
)
+98 -52
View File
@@ -26,6 +26,8 @@ export function useUploadOrchestrator({
resetEntries,
}: UseUploadOrchestratorParams) {
const [isUploading, setIsUploading] = useState(false)
const [isChecking, setIsChecking] = useState(false)
const [isResolvingDriveError, setIsResolvingDriveError] = useState(false)
const [globalError, setGlobalError] = useState<string | null>(null)
const [overwriteConfirm, setOverwriteConfirm] = useState<{
folderName: string
@@ -39,6 +41,7 @@ export function useUploadOrchestrator({
const abortRef = useRef<AbortController | null>(null)
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
const uploadActionRef = useRef(false)
// Refs for values used inside callbacks to avoid stale closures
const secretRef = useRef(secret)
@@ -67,63 +70,73 @@ export function useUploadOrchestrator({
// ---- Main upload flow: Drive first, then Git ----
const proceedUpload = useCallback(async () => {
if (uploadActionRef.current) return
uploadActionRef.current = true
setOverwriteConfirm(null)
setIsChecking(false)
setIsUploading(true)
setGlobalError(null)
const controller = new AbortController()
abortRef.current = controller
const currentEntries = entriesRef.current
try {
const currentEntries = entriesRef.current
for (let i = 0; i < currentEntries.length; i++) {
if (currentEntries[i].status === 'success') continue
if (controller.signal.aborted) break
for (let i = 0; i < currentEntries.length; i++) {
if (currentEntries[i].status === 'success') continue
if (controller.signal.aborted) break
const folderEntry = currentEntries[i]
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
const folderEntry = currentEntries[i]
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
// ---- Step 1: Drive upload ----
updateEntry(i, {
status: 'uploading',
progress: 1,
error: undefined,
driveStatus: 'uploading',
driveError: undefined,
})
// ---- Step 1: Drive upload ----
updateEntry(i, {
status: 'uploading',
progress: 1,
error: undefined,
driveStatus: 'uploading',
driveError: undefined,
})
const driveResult = await uploadDrive(
folderEntry,
secretRef.current,
driveAction as 'new' | 'replace',
controller.signal,
)
const driveResult = await uploadDrive(
folderEntry,
secretRef.current,
driveAction as 'new' | 'replace',
controller.signal,
)
if (!driveResult.success) {
updateEntry(i, { driveStatus: 'error', driveError: driveResult.error })
setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i })
return
if (!driveResult.success) {
updateEntry(i, { driveStatus: 'error', driveError: driveResult.error })
setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i })
return
}
updateEntry(i, { driveStatus: 'success', progress: 50 })
// ---- Step 2: Git upload ----
await pushGit(i, controller.signal)
}
updateEntry(i, { driveStatus: 'success', progress: 50 })
// ---- Step 2: Git upload ----
await pushGit(i, controller.signal)
} finally {
abortRef.current = null
setIsUploading(false)
uploadActionRef.current = false
}
abortRef.current = null
setIsUploading(false)
}, [updateEntry, pushGit])
// ---- Handlers ----
const handleUpload = useCallback(async () => {
if (uploadActionRef.current || isChecking || isUploading) return
if (!secretRef.current.trim()) {
setSecretError("La cle d'acces est requise")
return
}
if (entriesRef.current.length === 0) return
uploadActionRef.current = true
setIsChecking(true)
setSecretError(null)
setGlobalError(null)
@@ -140,47 +153,60 @@ export function useUploadOrchestrator({
if (check.exists) {
if (check.diffs.length === 0) {
setNoChangesFolder(folder.folderName)
uploadActionRef.current = false
setIsChecking(false)
return
}
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
uploadActionRef.current = false
setIsChecking(false)
return
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
setGlobalError(message)
uploadActionRef.current = false
setIsChecking(false)
return
}
uploadActionRef.current = false
await proceedUpload()
}, [setSecretError, proceedUpload])
}, [setSecretError, proceedUpload, isChecking, isUploading])
const handleDriveContinue = useCallback(async () => {
if (!driveError) return
if (!driveError || uploadActionRef.current) return
uploadActionRef.current = true
setIsResolvingDriveError(true)
const idx = driveError.folderIndex
setDriveError(null)
updateEntry(idx, { driveStatus: 'skipped' })
try {
updateEntry(idx, { driveStatus: 'skipped' })
const signal = abortRef.current?.signal
await pushGit(idx, signal)
const signal = abortRef.current?.signal
await pushGit(idx, signal)
const currentEntries = entriesRef.current
for (let i = idx + 1; i < currentEntries.length; i++) {
if (currentEntries[i].status === 'success') continue
if (abortRef.current?.signal.aborted) break
const currentEntries = entriesRef.current
for (let i = idx + 1; i < currentEntries.length; i++) {
if (currentEntries[i].status === 'success') continue
if (abortRef.current?.signal.aborted) break
updateEntry(i, {
status: 'uploading',
progress: 0,
error: undefined,
driveStatus: 'skipped',
})
updateEntry(i, {
status: 'uploading',
progress: 0,
error: undefined,
driveStatus: 'skipped',
})
await pushGit(i, abortRef.current?.signal)
await pushGit(i, abortRef.current?.signal)
}
} finally {
abortRef.current = null
setIsUploading(false)
setIsResolvingDriveError(false)
uploadActionRef.current = false
}
abortRef.current = null
setIsUploading(false)
}, [driveError, updateEntry, pushGit])
const handleDriveCancel = useCallback(() => {
@@ -197,25 +223,40 @@ export function useUploadOrchestrator({
abortRef.current?.abort()
abortRef.current = null
uploadActionRef.current = false
setIsChecking(false)
setIsResolvingDriveError(false)
setIsUploading(false)
}, [driveError, updateEntry])
const handleCancel = useCallback(() => {
if (isChecking) {
uploadActionRef.current = false
setIsChecking(false)
return
}
abortRef.current?.abort()
abortRef.current = null
uploadActionRef.current = false
setIsResolvingDriveError(false)
setIsUploading(false)
}, [])
}, [isChecking])
const handleReset = useCallback(() => {
resetEntries()
setGlobalError(null)
setIsChecking(false)
setIsUploading(false)
setIsResolvingDriveError(false)
setDriveError(null)
checkResultRef.current = { exists: false, diffs: [] }
uploadActionRef.current = false
}, [resetEntries])
return {
isUploading,
isChecking,
isResolvingDriveError,
globalError,
setGlobalError,
overwriteConfirm,
@@ -224,6 +265,11 @@ export function useUploadOrchestrator({
setNoChangesFolder,
driveError,
handleUpload,
handleOverwriteCancel: () => {
setOverwriteConfirm(null)
uploadActionRef.current = false
setIsChecking(false)
},
handleDriveContinue,
handleDriveCancel,
handleCancel,
+23
View File
@@ -0,0 +1,23 @@
export type AssetCategory = 'color' | 'roughness' | 'normal' | 'metalness' | 'assets'
export function classifyAssetCategory(filename: string): AssetCategory {
const name = filename.toLowerCase().replace(/\.[^.]+$/, '')
if (name.includes('base_color') || name.includes('_color') || name === 'color') {
return 'color'
}
if (name.includes('roughness')) {
return 'roughness'
}
if (name.includes('normal')) {
return 'normal'
}
if (name.includes('metallic') || name.includes('metalness')) {
return 'metalness'
}
return 'assets'
}
+36 -15
View File
@@ -1,4 +1,6 @@
import type { AssetCategory } from './asset-classification'
import type { FileChange } from './types'
import type { PreparedAssetSummary } from './types'
/**
* Build a formatted commit message based on the upload context.
@@ -12,8 +14,7 @@ import type { FileChange } from './types'
export function buildCommitMessage(
folderName: string,
modelFilename: string,
textureNames: string[],
compressed: boolean,
assetSummaries: PreparedAssetSummary[],
isReplace: boolean,
fileChanges: Map<string, FileChange>,
deletedFileNames: string[],
@@ -25,36 +26,56 @@ export function buildCommitMessage(
const lines: string[] = [title, '']
// Model section — show status for new, changed, or unchanged
const modelSummary = assetSummaries.find((asset) => asset.kind === 'model')
const modelChange = fileChanges.get(modelFilename.toLowerCase())
if (modelChange === 'new') {
lines.push('📦 Model')
lines.push(`${modelFilename}${compressed ? ' (compressed)' : ''}`)
lines.push(`${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ''}`)
} else if (modelChange === 'changed') {
lines.push('📦 Model')
lines.push(` 🔄 ${modelFilename}${compressed ? ' (compressed)' : ''}`)
lines.push(` 🔄 ${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ''}`)
} else if (modelChange === 'unchanged') {
lines.push('📦 Model')
lines.push(` ↔️ ${modelFilename} (inchange)`)
lines.push(` ↔️ ${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ' (inchange)'}`)
}
const textureLines: string[] = []
const grouped = new Map<AssetCategory, string[]>()
for (const textureName of textureNames) {
const change = fileChanges.get(textureName.toLowerCase())
for (const asset of assetSummaries) {
if (asset.kind === 'model' || !asset.category) continue
const change = fileChanges.get(asset.filename.toLowerCase())
if (change === 'new') {
textureLines.push(`${textureName}`)
const current = grouped.get(asset.category) || []
current.push(`${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
grouped.set(asset.category, current)
} else if (change === 'changed') {
textureLines.push(` 🔄 ${textureName}`)
const current = grouped.get(asset.category) || []
current.push(` 🔄 ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
grouped.set(asset.category, current)
}
}
for (const name of deletedFileNames) {
textureLines.push(`${name} (supprime)`)
const sectionTitles: Record<AssetCategory, string> = {
color: '🎨 Textures (color)',
roughness: '🪶 Textures (roughness)',
normal: '🧭 Textures (normal)',
metalness: '🔩 Textures (metalness)',
assets: '🧩 Assets',
}
if (textureLines.length > 0) {
lines.push('🎨 Textures')
lines.push(...textureLines)
for (const category of ['color', 'roughness', 'normal', 'metalness', 'assets'] as const) {
const entries = grouped.get(category)
if (!entries || entries.length === 0) continue
lines.push('')
lines.push(sectionTitles[category])
lines.push(...entries)
}
if (deletedFileNames.length > 0) {
lines.push('')
lines.push('🗑 Deleted')
lines.push(...deletedFileNames.map((name) => `${name}`))
}
return lines.join('\n')
+21 -5
View File
@@ -4,7 +4,8 @@ import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises'
import { TMP_DIR } from '@/lib/constants'
import { compressWithBlender } from '@/lib/blender'
import { compressTextureBuffer } from '@/lib/texture-compression'
import type { ParsedFile } from '@/lib/types'
import { classifyAssetCategory } from '@/lib/asset-classification'
import type { ParsedFile, PreparedAssetSummary } from '@/lib/types'
interface PushFile {
path: string
@@ -19,7 +20,7 @@ interface PrepareGitAssetsParams {
interface PrepareGitAssetsResult {
filesToPush: PushFile[]
modelFilename: string
textureNames: string[]
assetSummaries: PreparedAssetSummary[]
compressed: boolean
compressionError?: string
}
@@ -29,7 +30,7 @@ export async function prepareGitAssets({
parsedFiles,
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
const filesToPush: PushFile[] = []
const textureNames: string[] = []
const assetSummaries: PreparedAssetSummary[] = []
let modelFilename = ''
let compressed = false
let compressionError: string | undefined
@@ -39,6 +40,7 @@ export async function prepareGitAssets({
if (pf.isModel) {
modelFilename = pf.filename
let modelCompressed = false
const tmpFolder = join(TMP_DIR, folderName)
await mkdir(tmpFolder, { recursive: true })
@@ -54,6 +56,7 @@ export async function prepareGitAssets({
if (result.success && existsSync(compressedPath)) {
content = await readFile(compressedPath)
compressed = true
modelCompressed = true
await unlink(compressedPath).catch(() => {})
} else {
compressionError = result.error
@@ -62,8 +65,14 @@ export async function prepareGitAssets({
await unlink(tmpFilePath).catch(() => {})
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
}
assetSummaries.push({
filename: pf.filename,
kind: 'model',
compressed: modelCompressed,
})
} else {
textureNames.push(pf.filename)
const category = classifyAssetCategory(pf.filename)
const textureResult = await compressTextureBuffer(pf.filename, pf.buffer)
content = textureResult.buffer
@@ -71,6 +80,13 @@ export async function prepareGitAssets({
if (textureResult.error && !compressionError) {
compressionError = textureResult.error
}
assetSummaries.push({
filename: pf.filename,
kind: category === 'assets' ? 'asset' : 'texture',
category,
compressed: textureResult.compressed,
})
}
filesToPush.push({
@@ -82,7 +98,7 @@ export async function prepareGitAssets({
return {
filesToPush,
modelFilename,
textureNames,
assetSummaries,
compressed,
compressionError,
}
+9
View File
@@ -2,6 +2,8 @@
// Shared types
// ---------------------------------------------------------------------------
import type { AssetCategory } from './asset-classification'
export interface ParsedFile {
filename: string
buffer: Buffer
@@ -19,3 +21,10 @@ export interface RemoteFile {
name: string
size: number
}
export interface PreparedAssetSummary {
filename: string
kind: 'model' | 'texture' | 'asset'
category?: AssetCategory
compressed: boolean
}
+16
View File
@@ -0,0 +1,16 @@
const activeUploads = new Set<string>()
function buildKey(folderName: string) {
return folderName.toLowerCase()
}
export function acquireUploadLock(folderName: string): boolean {
const key = buildKey(folderName)
if (activeUploads.has(key)) return false
activeUploads.add(key)
return true
}
export function releaseUploadLock(folderName: string) {
activeUploads.delete(buildKey(folderName))
}