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, uploadFile,
findNextVersion, findNextVersion,
} from '@/lib/nextcloud' } from '@/lib/nextcloud'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -56,6 +57,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, error: message }, { status: 400 }) 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 basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
const vfFolderPath = `${basePath}/VF/${folderName}` const vfFolderPath = `${basePath}/VF/${folderName}`
@@ -95,5 +103,7 @@ export async function POST(req: NextRequest) {
{ success: false, error: `Drive echoue: ${message}` }, { success: false, error: `Drive echoue: ${message}` },
{ status: 500 }, { status: 500 },
) )
} finally {
releaseUploadLock(folderName)
} }
} }
+19 -3
View File
@@ -5,6 +5,7 @@ import { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
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 { prepareGitAssets } from '@/lib/prepare-git-assets' import { prepareGitAssets } from '@/lib/prepare-git-assets'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -31,13 +32,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, error: message }, { status: 400 }) 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 },
)
}
try {
// --- Process files (compress model + textures for Git) --- // --- Process files (compress model + textures for Git) ---
const { const {
filesToPush, filesToPush,
modelFilename, modelFilename,
compressed, compressed,
compressionError, compressionError,
textureNames, assetSummaries,
} = await prepareGitAssets({ folderName, parsedFiles }) } = await prepareGitAssets({ folderName, parsedFiles })
// --- Detect existing files and classify changes --- // --- Detect existing files and classify changes ---
@@ -70,8 +79,12 @@ export async function POST(req: NextRequest) {
// --- Build commit message --- // --- Build commit message ---
const commitMessage = buildCommitMessage( const commitMessage = buildCommitMessage(
folderName, modelFilename, textureNames, folderName,
compressed, isReplace, fileChanges, deletedFileNames, modelFilename,
assetSummaries,
isReplace,
fileChanges,
deletedFileNames,
) )
// --- Push all in one commit --- // --- Push all in one commit ---
@@ -94,4 +107,7 @@ export async function POST(req: NextRequest) {
{ status: 500 }, { status: 500 },
) )
} }
} finally {
releaseUploadLock(folderName)
}
} }
+1 -1
View File
@@ -15,7 +15,7 @@ export default function Home() {
<UploadZone /> <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> Modeles : <span className="font-mono text-gray-400">.glb</span>
<span className="mx-2">·</span> <span className="mx-2">·</span>
Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</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 { const {
isUploading, isUploading,
isChecking,
isResolvingDriveError,
globalError, globalError,
setGlobalError, setGlobalError,
overwriteConfirm, overwriteConfirm,
@@ -43,6 +45,7 @@ export default function UploadZone() {
setNoChangesFolder, setNoChangesFolder,
driveError, driveError,
handleUpload, handleUpload,
handleOverwriteCancel,
handleDriveContinue, handleDriveContinue,
handleDriveCancel, handleDriveCancel,
handleCancel, handleCancel,
@@ -93,7 +96,7 @@ export default function UploadZone() {
secret={secret} secret={secret}
secretVisible={secretVisible} secretVisible={secretVisible}
secretError={secretError} secretError={secretError}
disabled={isUploading} disabled={isUploading || isChecking}
onChange={handleSecretChange} onChange={handleSecretChange}
onToggleVisible={toggleSecretVisible} onToggleVisible={toggleSecretVisible}
/> />
@@ -101,7 +104,7 @@ export default function UploadZone() {
{entries.length === 0 && ( {entries.length === 0 && (
<div> <div>
<FolderDropzone <FolderDropzone
isUploading={isUploading} isUploading={isUploading || isChecking}
onFolderSelected={handleFolderSelected} onFolderSelected={handleFolderSelected}
onError={setGlobalError} onError={setGlobalError}
/> />
@@ -128,6 +131,7 @@ export default function UploadZone() {
<ActionButtons <ActionButtons
isUploading={isUploading} isUploading={isUploading}
isChecking={isChecking}
isSecretEmpty={isSecretEmpty} isSecretEmpty={isSecretEmpty}
hasPendingOrErrors={hasPendingOrErrors} hasPendingOrErrors={hasPendingOrErrors}
allDone={allDone} allDone={allDone}
@@ -141,8 +145,9 @@ export default function UploadZone() {
<OverwriteConfirmModal <OverwriteConfirmModal
folderName={overwriteConfirm.folderName} folderName={overwriteConfirm.folderName}
diffs={overwriteConfirm.diffs} diffs={overwriteConfirm.diffs}
onCancel={() => setOverwriteConfirm(null)} onCancel={handleOverwriteCancel}
onConfirm={proceedUpload} onConfirm={proceedUpload}
disabled={isUploading || isChecking || isResolvingDriveError}
/> />
)} )}
@@ -162,6 +167,7 @@ export default function UploadZone() {
error={driveError.error} error={driveError.error}
onCancel={handleDriveCancel} onCancel={handleDriveCancel}
onContinue={handleDriveContinue} onContinue={handleDriveContinue}
disabled={isResolvingDriveError}
/> />
)} )}
</div> </div>
+6 -2
View File
@@ -35,6 +35,7 @@ interface ModalActionsProps {
onConfirm: () => void onConfirm: () => void
/** Tailwind classes for the confirm button (default: white bg) */ /** Tailwind classes for the confirm button (default: white bg) */
confirmClassName?: string confirmClassName?: string
disabled?: boolean
} }
export function ModalActions({ export function ModalActions({
@@ -43,20 +44,23 @@ export function ModalActions({
onCancel, onCancel,
onConfirm, onConfirm,
confirmClassName = 'bg-white text-[#000000] hover:bg-gray-200', confirmClassName = 'bg-white text-[#000000] hover:bg-gray-200',
disabled = false,
}: ModalActionsProps) { }: ModalActionsProps) {
return ( return (
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onCancel} onClick={onCancel}
disabled={disabled}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm 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" hover:bg-black-600"
> >
{cancelLabel} {cancelLabel}
</button> </button>
<button <button
onClick={onConfirm} 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} {confirmLabel}
</button> </button>
+10 -6
View File
@@ -1,5 +1,6 @@
interface ActionButtonsProps { interface ActionButtonsProps {
isUploading: boolean isUploading: boolean
isChecking: boolean
isSecretEmpty: boolean isSecretEmpty: boolean
hasPendingOrErrors: boolean hasPendingOrErrors: boolean
allDone: boolean allDone: boolean
@@ -11,6 +12,7 @@ interface ActionButtonsProps {
export default function ActionButtons({ export default function ActionButtons({
isUploading, isUploading,
isChecking,
isSecretEmpty, isSecretEmpty,
hasPendingOrErrors, hasPendingOrErrors,
allDone, allDone,
@@ -19,11 +21,12 @@ export default function ActionButtons({
onCancel, onCancel,
onReset, onReset,
}: ActionButtonsProps) { }: ActionButtonsProps) {
const cantUpload = isSecretEmpty const cantUpload = isSecretEmpty || isChecking
const isBusy = isUploading || isChecking
return ( return (
<div className="flex gap-3"> <div className="flex gap-3">
{!isUploading && hasPendingOrErrors && ( {!isBusy && hasPendingOrErrors && (
<button <button
onClick={onUpload} onClick={onUpload}
disabled={cantUpload} disabled={cantUpload}
@@ -38,18 +41,19 @@ export default function ActionButtons({
</button> </button>
)} )}
{isUploading && ( {isBusy && (
<button <button
onClick={onCancel} onClick={onCancel}
disabled={!isUploading}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm 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 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" hover:bg-black-600"
> >
Annuler {isChecking ? 'Verification...' : 'Annuler'}
</button> </button>
)} )}
{(allDone || hasErrors) && !isUploading && ( {(allDone || hasErrors) && !isBusy && (
<button <button
onClick={onReset} onClick={onReset}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
+3
View File
@@ -5,12 +5,14 @@ interface DriveErrorModalProps {
error: string error: string
onCancel: () => void onCancel: () => void
onContinue: () => void onContinue: () => void
disabled?: boolean
} }
export default function DriveErrorModal({ export default function DriveErrorModal({
error, error,
onCancel, onCancel,
onContinue, onContinue,
disabled = false,
}: DriveErrorModalProps) { }: DriveErrorModalProps) {
return ( return (
<Modal ariaLabelledBy="drive-error-title"> <Modal ariaLabelledBy="drive-error-title">
@@ -41,6 +43,7 @@ export default function DriveErrorModal({
confirmLabel="Envoyer sur Git seulement" confirmLabel="Envoyer sur Git seulement"
onCancel={onCancel} onCancel={onCancel}
onConfirm={onContinue} onConfirm={onContinue}
disabled={disabled}
/> />
</Modal> </Modal>
) )
+2 -2
View File
@@ -2,7 +2,7 @@
// Drive/Git status sub-line for FolderCard // 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' import type { FolderEntry } from '@/lib/client-types'
interface DriveStatusLineProps { interface DriveStatusLineProps {
@@ -35,7 +35,7 @@ export default function DriveStatusLine({ driveStatus, driveError }: DriveStatus
)} )}
{driveStatus === 'skipped' && ( {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> <span className="text-xs text-yellow-400">Drive ignore</span>
</> </>
)} )}
@@ -7,6 +7,7 @@ interface OverwriteConfirmModalProps {
diffs: FileDiff[] diffs: FileDiff[]
onCancel: () => void onCancel: () => void
onConfirm: () => void onConfirm: () => void
disabled?: boolean
} }
export default function OverwriteConfirmModal({ export default function OverwriteConfirmModal({
@@ -14,6 +15,7 @@ export default function OverwriteConfirmModal({
diffs, diffs,
onCancel, onCancel,
onConfirm, onConfirm,
disabled = false,
}: OverwriteConfirmModalProps) { }: OverwriteConfirmModalProps) {
return ( return (
<Modal ariaLabelledBy="overwrite-title"> <Modal ariaLabelledBy="overwrite-title">
@@ -68,6 +70,7 @@ export default function OverwriteConfirmModal({
onCancel={onCancel} onCancel={onCancel}
onConfirm={onConfirm} onConfirm={onConfirm}
confirmClassName="bg-yellow-600 text-[#000000] hover:bg-yellow-500" confirmClassName="bg-yellow-600 text-[#000000] hover:bg-yellow-500"
disabled={disabled}
/> />
</Modal> </Modal>
) )
+51 -5
View File
@@ -26,6 +26,8 @@ export function useUploadOrchestrator({
resetEntries, resetEntries,
}: UseUploadOrchestratorParams) { }: UseUploadOrchestratorParams) {
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [isChecking, setIsChecking] = useState(false)
const [isResolvingDriveError, setIsResolvingDriveError] = useState(false)
const [globalError, setGlobalError] = useState<string | null>(null) const [globalError, setGlobalError] = useState<string | null>(null)
const [overwriteConfirm, setOverwriteConfirm] = useState<{ const [overwriteConfirm, setOverwriteConfirm] = useState<{
folderName: string folderName: string
@@ -39,6 +41,7 @@ export function useUploadOrchestrator({
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] }) const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
const uploadActionRef = useRef(false)
// Refs for values used inside callbacks to avoid stale closures // Refs for values used inside callbacks to avoid stale closures
const secretRef = useRef(secret) const secretRef = useRef(secret)
@@ -67,13 +70,17 @@ export function useUploadOrchestrator({
// ---- Main upload flow: Drive first, then Git ---- // ---- Main upload flow: Drive first, then Git ----
const proceedUpload = useCallback(async () => { const proceedUpload = useCallback(async () => {
if (uploadActionRef.current) return
uploadActionRef.current = true
setOverwriteConfirm(null) setOverwriteConfirm(null)
setIsChecking(false)
setIsUploading(true) setIsUploading(true)
setGlobalError(null) setGlobalError(null)
const controller = new AbortController() const controller = new AbortController()
abortRef.current = controller abortRef.current = controller
try {
const currentEntries = entriesRef.current const currentEntries = entriesRef.current
for (let i = 0; i < currentEntries.length; i++) { for (let i = 0; i < currentEntries.length; i++) {
@@ -110,20 +117,26 @@ export function useUploadOrchestrator({
// ---- Step 2: Git upload ---- // ---- Step 2: Git upload ----
await pushGit(i, controller.signal) await pushGit(i, controller.signal)
} }
} finally {
abortRef.current = null abortRef.current = null
setIsUploading(false) setIsUploading(false)
uploadActionRef.current = false
}
}, [updateEntry, pushGit]) }, [updateEntry, pushGit])
// ---- Handlers ---- // ---- Handlers ----
const handleUpload = useCallback(async () => { const handleUpload = useCallback(async () => {
if (uploadActionRef.current || isChecking || isUploading) return
if (!secretRef.current.trim()) { if (!secretRef.current.trim()) {
setSecretError("La cle d'acces est requise") setSecretError("La cle d'acces est requise")
return return
} }
if (entriesRef.current.length === 0) return if (entriesRef.current.length === 0) return
uploadActionRef.current = true
setIsChecking(true)
setSecretError(null) setSecretError(null)
setGlobalError(null) setGlobalError(null)
@@ -140,25 +153,35 @@ export function useUploadOrchestrator({
if (check.exists) { if (check.exists) {
if (check.diffs.length === 0) { if (check.diffs.length === 0) {
setNoChangesFolder(folder.folderName) setNoChangesFolder(folder.folderName)
uploadActionRef.current = false
setIsChecking(false)
return return
} }
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs }) setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
uploadActionRef.current = false
setIsChecking(false)
return return
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = err instanceof Error ? err.message : 'Erreur inconnue'
setGlobalError(message) setGlobalError(message)
uploadActionRef.current = false
setIsChecking(false)
return return
} }
uploadActionRef.current = false
await proceedUpload() await proceedUpload()
}, [setSecretError, proceedUpload]) }, [setSecretError, proceedUpload, isChecking, isUploading])
const handleDriveContinue = useCallback(async () => { const handleDriveContinue = useCallback(async () => {
if (!driveError) return if (!driveError || uploadActionRef.current) return
uploadActionRef.current = true
setIsResolvingDriveError(true)
const idx = driveError.folderIndex const idx = driveError.folderIndex
setDriveError(null) setDriveError(null)
try {
updateEntry(idx, { driveStatus: 'skipped' }) updateEntry(idx, { driveStatus: 'skipped' })
const signal = abortRef.current?.signal const signal = abortRef.current?.signal
@@ -178,9 +201,12 @@ export function useUploadOrchestrator({
await pushGit(i, abortRef.current?.signal) await pushGit(i, abortRef.current?.signal)
} }
} finally {
abortRef.current = null abortRef.current = null
setIsUploading(false) setIsUploading(false)
setIsResolvingDriveError(false)
uploadActionRef.current = false
}
}, [driveError, updateEntry, pushGit]) }, [driveError, updateEntry, pushGit])
const handleDriveCancel = useCallback(() => { const handleDriveCancel = useCallback(() => {
@@ -197,25 +223,40 @@ export function useUploadOrchestrator({
abortRef.current?.abort() abortRef.current?.abort()
abortRef.current = null abortRef.current = null
uploadActionRef.current = false
setIsChecking(false)
setIsResolvingDriveError(false)
setIsUploading(false) setIsUploading(false)
}, [driveError, updateEntry]) }, [driveError, updateEntry])
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
if (isChecking) {
uploadActionRef.current = false
setIsChecking(false)
return
}
abortRef.current?.abort() abortRef.current?.abort()
abortRef.current = null abortRef.current = null
uploadActionRef.current = false
setIsResolvingDriveError(false)
setIsUploading(false) setIsUploading(false)
}, []) }, [isChecking])
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
resetEntries() resetEntries()
setGlobalError(null) setGlobalError(null)
setIsChecking(false)
setIsUploading(false) setIsUploading(false)
setIsResolvingDriveError(false)
setDriveError(null) setDriveError(null)
checkResultRef.current = { exists: false, diffs: [] } checkResultRef.current = { exists: false, diffs: [] }
uploadActionRef.current = false
}, [resetEntries]) }, [resetEntries])
return { return {
isUploading, isUploading,
isChecking,
isResolvingDriveError,
globalError, globalError,
setGlobalError, setGlobalError,
overwriteConfirm, overwriteConfirm,
@@ -224,6 +265,11 @@ export function useUploadOrchestrator({
setNoChangesFolder, setNoChangesFolder,
driveError, driveError,
handleUpload, handleUpload,
handleOverwriteCancel: () => {
setOverwriteConfirm(null)
uploadActionRef.current = false
setIsChecking(false)
},
handleDriveContinue, handleDriveContinue,
handleDriveCancel, handleDriveCancel,
handleCancel, 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 { FileChange } from './types'
import type { PreparedAssetSummary } from './types'
/** /**
* Build a formatted commit message based on the upload context. * Build a formatted commit message based on the upload context.
@@ -12,8 +14,7 @@ import type { FileChange } from './types'
export function buildCommitMessage( export function buildCommitMessage(
folderName: string, folderName: string,
modelFilename: string, modelFilename: string,
textureNames: string[], assetSummaries: PreparedAssetSummary[],
compressed: boolean,
isReplace: boolean, isReplace: boolean,
fileChanges: Map<string, FileChange>, fileChanges: Map<string, FileChange>,
deletedFileNames: string[], deletedFileNames: string[],
@@ -25,36 +26,56 @@ export function buildCommitMessage(
const lines: string[] = [title, ''] const lines: string[] = [title, '']
// Model section — show status for new, changed, or unchanged // Model section — show status for new, changed, or unchanged
const modelSummary = assetSummaries.find((asset) => asset.kind === 'model')
const modelChange = fileChanges.get(modelFilename.toLowerCase()) const modelChange = fileChanges.get(modelFilename.toLowerCase())
if (modelChange === 'new') { if (modelChange === 'new') {
lines.push('📦 Model') lines.push('📦 Model')
lines.push(`${modelFilename}${compressed ? ' (compressed)' : ''}`) lines.push(`${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ''}`)
} else if (modelChange === 'changed') { } else if (modelChange === 'changed') {
lines.push('📦 Model') lines.push('📦 Model')
lines.push(` 🔄 ${modelFilename}${compressed ? ' (compressed)' : ''}`) lines.push(` 🔄 ${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ''}`)
} else if (modelChange === 'unchanged') { } else if (modelChange === 'unchanged') {
lines.push('📦 Model') 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) { for (const asset of assetSummaries) {
const change = fileChanges.get(textureName.toLowerCase()) if (asset.kind === 'model' || !asset.category) continue
const change = fileChanges.get(asset.filename.toLowerCase())
if (change === 'new') { 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') { } 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) { const sectionTitles: Record<AssetCategory, string> = {
textureLines.push(`${name} (supprime)`) color: '🎨 Textures (color)',
roughness: '🪶 Textures (roughness)',
normal: '🧭 Textures (normal)',
metalness: '🔩 Textures (metalness)',
assets: '🧩 Assets',
} }
if (textureLines.length > 0) { for (const category of ['color', 'roughness', 'normal', 'metalness', 'assets'] as const) {
lines.push('🎨 Textures') const entries = grouped.get(category)
lines.push(...textureLines) 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') 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 { TMP_DIR } from '@/lib/constants'
import { compressWithBlender } from '@/lib/blender' import { compressWithBlender } from '@/lib/blender'
import { compressTextureBuffer } from '@/lib/texture-compression' 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 { interface PushFile {
path: string path: string
@@ -19,7 +20,7 @@ interface PrepareGitAssetsParams {
interface PrepareGitAssetsResult { interface PrepareGitAssetsResult {
filesToPush: PushFile[] filesToPush: PushFile[]
modelFilename: string modelFilename: string
textureNames: string[] assetSummaries: PreparedAssetSummary[]
compressed: boolean compressed: boolean
compressionError?: string compressionError?: string
} }
@@ -29,7 +30,7 @@ export async function prepareGitAssets({
parsedFiles, parsedFiles,
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> { }: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
const filesToPush: PushFile[] = [] const filesToPush: PushFile[] = []
const textureNames: string[] = [] const assetSummaries: PreparedAssetSummary[] = []
let modelFilename = '' let modelFilename = ''
let compressed = false let compressed = false
let compressionError: string | undefined let compressionError: string | undefined
@@ -39,6 +40,7 @@ export async function prepareGitAssets({
if (pf.isModel) { if (pf.isModel) {
modelFilename = pf.filename modelFilename = pf.filename
let modelCompressed = false
const tmpFolder = join(TMP_DIR, folderName) const tmpFolder = join(TMP_DIR, folderName)
await mkdir(tmpFolder, { recursive: true }) await mkdir(tmpFolder, { recursive: true })
@@ -54,6 +56,7 @@ export async function prepareGitAssets({
if (result.success && existsSync(compressedPath)) { if (result.success && existsSync(compressedPath)) {
content = await readFile(compressedPath) content = await readFile(compressedPath)
compressed = true compressed = true
modelCompressed = true
await unlink(compressedPath).catch(() => {}) await unlink(compressedPath).catch(() => {})
} else { } else {
compressionError = result.error compressionError = result.error
@@ -62,8 +65,14 @@ export async function prepareGitAssets({
await unlink(tmpFilePath).catch(() => {}) await unlink(tmpFilePath).catch(() => {})
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
} }
assetSummaries.push({
filename: pf.filename,
kind: 'model',
compressed: modelCompressed,
})
} else { } else {
textureNames.push(pf.filename) const category = classifyAssetCategory(pf.filename)
const textureResult = await compressTextureBuffer(pf.filename, pf.buffer) const textureResult = await compressTextureBuffer(pf.filename, pf.buffer)
content = textureResult.buffer content = textureResult.buffer
@@ -71,6 +80,13 @@ export async function prepareGitAssets({
if (textureResult.error && !compressionError) { if (textureResult.error && !compressionError) {
compressionError = textureResult.error compressionError = textureResult.error
} }
assetSummaries.push({
filename: pf.filename,
kind: category === 'assets' ? 'asset' : 'texture',
category,
compressed: textureResult.compressed,
})
} }
filesToPush.push({ filesToPush.push({
@@ -82,7 +98,7 @@ export async function prepareGitAssets({
return { return {
filesToPush, filesToPush,
modelFilename, modelFilename,
textureNames, assetSummaries,
compressed, compressed,
compressionError, compressionError,
} }
+9
View File
@@ -2,6 +2,8 @@
// Shared types // Shared types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import type { AssetCategory } from './asset-classification'
export interface ParsedFile { export interface ParsedFile {
filename: string filename: string
buffer: Buffer buffer: Buffer
@@ -19,3 +21,10 @@ export interface RemoteFile {
name: string name: string
size: number 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))
}