(event, filePath)
| 13334 | thumbnail = await generateThumbnail(model.filePath); |
| 13335 | } catch (renderError) { |
| 13336 | console.error(`Error generating 3D thumbnail for ${model.filePath}:`, renderError); |
| 13337 | } |
| 13338 | } |
| 13339 | |
| 13340 | // 3. Validate and fallback to default if necessary (STL/3MF only reach here) |
| 13341 | if (!thumbnail || typeof thumbnail !== 'string' || !thumbnail.startsWith('data:image') || isFailurePlaceholderThumbnail(thumbnail)) { |
| 13342 | thumbnail = '3d.png'; |
| 13343 | } |
| 13344 | |
| 13345 | // 4. Save real renders only — never lock in failure placeholders |
| 13346 | if (!saved3mfEmbedsViaBatch && thumbnail !== '3d.png') { |
| 13347 | await window.electron.saveThumbnail(model.filePath, thumbnail); |
| 13348 | } else if (!saved3mfEmbedsViaBatch) { |
| 13349 | await window.electron.saveThumbnail(model.filePath, '3d.png'); |
| 13350 | } |
| 13351 | |
| 13352 | // 5. Hash during bulk jobs is optional (extra I/O / memory); skip in Docker headless. |
| 13353 | if (!skipHash && (!model.hash || model.hash === '')) { |
| 13354 | try { |
| 13355 | await window.electron.calculateFileHash(model.filePath); |
| 13356 | } catch (hashError) { |
| 13357 | console.error(`Error calculating hash for ${model.filePath}:`, hashError); |
| 13358 | // Continue even if hash calculation fails |
| 13359 | } |
| 13360 | } |
| 13361 | |
| 13362 | thumbnail = null; |
| 13363 | |
| 13364 | } catch (error) { |
| 13365 | console.error(`Failed to generate thumbnail for ${model.filePath}:`, error); |
| 13366 | // Do not persist typed STL/3MF placeholders — that blocks retries (common on Docker timeouts). |
| 13367 | try { |
| 13368 | await window.electron.saveThumbnail(model.filePath, '3d.png'); |
| 13369 | } catch (saveError) { |
| 13370 | console.error(`Failed to save default thumbnail for ${model.filePath}:`, saveError); |
| 13371 | } |
| 13372 | } finally { |
| 13373 | processedInBatch++; |
| 13374 | processedCount = processedInBatch; |
| 13375 | const absoluteProcessed = progressOffset + processedInBatch; |
| 13376 | generatedThumbnailsCount = absoluteProcessed; |
| 13377 | |
| 13378 | // Update progress |
| 13379 | if (hasProgressUI) { |
| 13380 | const progress = Math.floor((absoluteProcessed / progressTotal) * 100); |
| 13381 | activeProgressBar.style.width = `${progress}%`; |
| 13382 | activeProgressText.textContent = `Processing ${absoluteProcessed}/${progressTotal} (${progress}%)`; |
| 13383 | } |
| 13384 | if (overlay && !isCancelled) { |
| 13385 | overlay.update(absoluteProcessed, progressTotal); |
| 13386 | } |
| 13387 | if (!isCancelled) { |
| 13388 | await reportHeadlessProgress(absoluteProcessed, progressTotal); |
| 13389 | } |
| 13390 | |
| 13391 | // Between models: yield + occasional soft GC. Soft dispose only — |
| 13392 | // never forceContextLoss (restarts GPU process → Skia OOM log storms). |
| 13393 | if (maxConcurrentThumbnails === 1) { |
no test coverage detected