mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary
Adds `HDRIManager` to load `.hdr/.exr` files as equirectangular
environment maps via **three.js** `RGBELoader/EXRLoader`
- Uploads HDRI files to the server via `/upload/image` API so they
persist across page reloads
- Restores HDRI state (enabled, **intensity**, **background**) from node
properties on reload
- Auto-enables "**Show as Background**" on successful upload for
immediate visual feedback
- Hides standard directional lights when HDRI is active; restores them
when disabled
- Hides the Light Intensity control while HDRI is active (lights have no
effect when HDRI overrides scene lighting)
- Limits HDRI availability to PBR-capable formats (.gltf, .glb, .fbx,
.obj); automatically disables when switching to an incompatible model
- Adds intensity slider and "**Show as Background**" toggle to the HDRI
panel
## How to Use HDRI Environment Lighting
1. Load a 3D model using a Load3D or Load3DViewer node (supported
formats: .gltf, .glb, .fbx, .obj)
2. Open the control panel → go to the Light tab
3. Click the globe icon to open the **HDRI panel**
4. Click Upload HDRI and select a` .hdr` or `.exr` file
5. The environment lighting applies automatically — the scene background
also updates to preview the panorama
6. Use the intensity slider to adjust the strength of the environment
lighting
7. Toggle Show as Background to show or hide the HDRI panorama behind
the model
## Screenshots
https://github.com/user-attachments/assets/1ec56ef0-853e-452f-ae2b-2474c9d0d781
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10818-feat-load3d-add-optional-HDRI-environment-lighting-to-3D-preview-nodes-3366d73d365081ea8c7ad9226b8b1e2f)
by [Unito](https://www.unito.io)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Adds new HDRI loading/rendering path and persists new
`LightConfig.hdri` state, touching Three.js rendering, file uploads, and
node property restoration. Risk is moderate due to new async flows and
potential compatibility/performance issues with model switching and
renderer settings.
>
> **Overview**
> Adds optional **HDRI environment lighting** to Load3D previews,
including a new `HDRIManager` that loads `.hdr`/`.exr` files into
Three.js environment/background and exposes controls for enable/disable,
background display, and intensity.
>
> Extends `LightConfig` with an `hdri` block that is persisted on nodes
and restored on reload; `useLoad3d` now uploads HDRI files, loads them
into `Load3d`, maps scene light intensity to HDRI intensity, and
auto-disables HDRI when the current model format doesn’t support it.
>
> Updates the UI to include embedded HDRI controls under the Light panel
(with dismissable overlays and icon updates), adjusts light intensity
behavior when HDRI is active, and adds tests/strings/utilities
(`getFilenameExtension`, `mapSceneLightIntensityToHdri`, new constants)
to support the feature.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b12c9722dc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Terry Jia <terryjia88@gmail.com>
150 lines
3.8 KiB
TypeScript
150 lines
3.8 KiB
TypeScript
import { t } from '@/i18n'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import { api } from '@/scripts/api'
|
|
import { app } from '@/scripts/app'
|
|
|
|
class Load3dUtils {
|
|
static async uploadTempImage(
|
|
imageData: string,
|
|
prefix: string,
|
|
fileType: string = 'png'
|
|
) {
|
|
const blob = await fetch(imageData).then((r) => r.blob())
|
|
const name = `${prefix}_${Date.now()}.${fileType}`
|
|
const file = new File([blob], name, {
|
|
type: fileType === 'mp4' ? 'video/mp4' : 'image/png'
|
|
})
|
|
|
|
const body = new FormData()
|
|
body.append('image', file)
|
|
body.append('subfolder', 'threed')
|
|
body.append('type', 'temp')
|
|
|
|
const resp = await api.fetchApi('/upload/image', {
|
|
method: 'POST',
|
|
body
|
|
})
|
|
|
|
if (resp.status !== 200) {
|
|
const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}`
|
|
useToastStore().addAlert(err)
|
|
throw new Error(err)
|
|
}
|
|
|
|
return await resp.json()
|
|
}
|
|
|
|
static readonly MAX_UPLOAD_SIZE_MB = 100
|
|
|
|
static async uploadFile(file: File, subfolder: string) {
|
|
let uploadPath
|
|
|
|
const fileSizeMB = file.size / 1024 / 1024
|
|
if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) {
|
|
const message = t('toastMessages.fileTooLarge', {
|
|
size: fileSizeMB.toFixed(1),
|
|
maxSize: this.MAX_UPLOAD_SIZE_MB
|
|
})
|
|
console.warn(
|
|
'[Load3D] uploadFile: file too large',
|
|
fileSizeMB.toFixed(2),
|
|
'MB'
|
|
)
|
|
useToastStore().addAlert(message)
|
|
return undefined
|
|
}
|
|
|
|
try {
|
|
const body = new FormData()
|
|
body.append('image', file)
|
|
|
|
body.append('subfolder', subfolder)
|
|
|
|
const resp = await api.fetchApi('/upload/image', {
|
|
method: 'POST',
|
|
body
|
|
})
|
|
|
|
if (resp.status === 200) {
|
|
const data = await resp.json()
|
|
let path = data.name
|
|
|
|
if (data.subfolder) {
|
|
path = data.subfolder + '/' + path
|
|
}
|
|
|
|
uploadPath = path
|
|
} else {
|
|
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
|
}
|
|
} catch (error) {
|
|
console.error('[Load3D] uploadFile: exception', error)
|
|
useToastStore().addAlert(
|
|
error instanceof Error
|
|
? error.message
|
|
: t('toastMessages.fileUploadFailed')
|
|
)
|
|
}
|
|
|
|
return uploadPath
|
|
}
|
|
|
|
static getFilenameExtension(url: string): string | undefined {
|
|
const queryString = url.split('?')[1]
|
|
if (queryString) {
|
|
const filename = new URLSearchParams(queryString).get('filename')
|
|
if (filename) return filename.split('.').pop()?.toLowerCase()
|
|
}
|
|
return url.split('?')[0].split('.').pop()?.toLowerCase()
|
|
}
|
|
|
|
static splitFilePath(path: string): [string, string] {
|
|
const folder_separator = path.lastIndexOf('/')
|
|
if (folder_separator === -1) {
|
|
return ['', path]
|
|
}
|
|
return [
|
|
path.substring(0, folder_separator),
|
|
path.substring(folder_separator + 1)
|
|
]
|
|
}
|
|
|
|
static getResourceURL(
|
|
subfolder: string,
|
|
filename: string,
|
|
type: string = 'input'
|
|
): string {
|
|
const params = [
|
|
'filename=' + encodeURIComponent(filename),
|
|
'type=' + type,
|
|
'subfolder=' + subfolder,
|
|
app.getRandParam().substring(1)
|
|
].join('&')
|
|
|
|
return `/view?${params}`
|
|
}
|
|
|
|
static async uploadMultipleFiles(files: FileList, subfolder: string = '3d') {
|
|
const uploadPromises = Array.from(files).map((file) =>
|
|
this.uploadFile(file, subfolder)
|
|
)
|
|
|
|
await Promise.all(uploadPromises)
|
|
}
|
|
|
|
static mapSceneLightIntensityToHdri(
|
|
sceneIntensity: number,
|
|
sceneMin: number,
|
|
sceneMax: number
|
|
): number {
|
|
const span = sceneMax - sceneMin
|
|
const t = span > 0 ? (sceneIntensity - sceneMin) / span : 0
|
|
const clampedT = Math.min(1, Math.max(0, t))
|
|
const mapped = clampedT * 5
|
|
const minHdri = 0.25
|
|
return Math.min(5, Math.max(minHdri, mapped))
|
|
}
|
|
}
|
|
|
|
export default Load3dUtils
|