mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-13 11:11:00 +00:00
Compare commits
5 Commits
update-ing
...
feature/lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb4f794238 | ||
|
|
63435bdb34 | ||
|
|
20255da61f | ||
|
|
c2dba8f4ee | ||
|
|
6f579c5992 |
@@ -150,7 +150,7 @@
|
||||
"playwright/no-element-handle": "error",
|
||||
"playwright/no-eval": "error",
|
||||
"playwright/no-focused-test": "error",
|
||||
"playwright/no-force-option": "off",
|
||||
"playwright/no-force-option": "error",
|
||||
"playwright/no-networkidle": "error",
|
||||
"playwright/no-page-pause": "error",
|
||||
"playwright/no-skipped-test": "error",
|
||||
|
||||
@@ -351,7 +351,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
await btn.click().catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
|
||||
@@ -71,7 +71,7 @@ export class Topbar {
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.hover()
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
await tab.locator('.close-button').click()
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
|
||||
@@ -151,6 +151,7 @@ export class BuilderSelectHelper {
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
@@ -199,6 +200,7 @@ export class BuilderSelectHelper {
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
)
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -74,6 +74,51 @@ export class CanvasHelper {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a canvas-element-relative position to absolute page coordinates.
|
||||
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||
* cause Playwright's actionability check to fail on the canvas locator.
|
||||
*/
|
||||
private async toAbsolute(position: Position): Promise<Position> {
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
return { x: box.x + position.x, y: box.y + position.y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
|
||||
* Bypasses Playwright's actionability checks on the canvas locator, which
|
||||
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
|
||||
*/
|
||||
async mouseClickAt(
|
||||
position: Position,
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
}
|
||||
): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
const modifiers = options?.modifiers ?? []
|
||||
for (const mod of modifiers) await this.page.keyboard.down(mod)
|
||||
try {
|
||||
await this.page.mouse.click(abs.x, abs.y, {
|
||||
button: options?.button
|
||||
})
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click at canvas-element-relative coordinates using `page.mouse`.
|
||||
*/
|
||||
async mouseDblclickAt(position: Position): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
await this.page.mouse.dblclick(abs.x, abs.y)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickEmptySpace(): Promise<void> {
|
||||
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
||||
await this.nextFrame()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
@@ -356,7 +355,11 @@ export class NodeReference {
|
||||
}
|
||||
async click(
|
||||
position: 'title' | 'collapse',
|
||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
moveMouseToEmptyArea?: boolean
|
||||
}
|
||||
) {
|
||||
let clickPos: Position
|
||||
switch (position) {
|
||||
@@ -377,12 +380,7 @@ export class NodeReference {
|
||||
delete options.moveMouseToEmptyArea
|
||||
}
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
|
||||
if (moveMouseToEmptyArea) {
|
||||
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
}
|
||||
@@ -499,31 +497,18 @@ export class NodeReference {
|
||||
|
||||
await expect(async () => {
|
||||
// Try just clicking the enter button first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
position: subgraphButtonPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
// Double-click to enter subgraph
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseDblclickAt(position)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
|
||||
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click({ force: true })
|
||||
await helpButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await deleteButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -65,7 +65,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click({ force: true })
|
||||
await infoButton.click()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await deleteButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -120,7 +120,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const bypassButton = comfyPage.page.getByTestId('bypass-button')
|
||||
await expect(bypassButton).toBeVisible()
|
||||
await bypassButton.click({ force: true })
|
||||
await bypassButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
@@ -128,7 +128,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await bypassButton.click({ force: true })
|
||||
await bypassButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
@@ -147,7 +147,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await convertButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler should be gone, replaced by a subgraph node
|
||||
@@ -175,7 +175,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await convertButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -200,13 +200,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const frameButton = comfyPage.page.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
await expect(frameButton).toBeVisible()
|
||||
await comfyPage.page
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
).toBeVisible()
|
||||
await comfyPage.selectionToolbox
|
||||
.getByRole('button', { name: /Frame Nodes/i })
|
||||
.click({ force: true })
|
||||
.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
@@ -62,7 +62,7 @@ test.describe(
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click({ force: true })
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
@@ -126,9 +126,7 @@ test.describe(
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page
|
||||
.getByText('Rename', { exact: true })
|
||||
.click({ force: true })
|
||||
await comfyPage.page.getByText('Rename', { exact: true }).click()
|
||||
const input = comfyPage.page.locator(
|
||||
'.group-title-editor.node-title-editor .editable-text input'
|
||||
)
|
||||
@@ -153,11 +151,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.click({ position: { x: 0, y: 50 }, force: true })
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt({ x: 0, y: 50 })
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).toBeHidden()
|
||||
|
||||
@@ -199,12 +199,7 @@ test.describe(
|
||||
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
@@ -235,12 +230,7 @@ test.describe(
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
@@ -266,12 +256,7 @@ test.describe(
|
||||
const stepsWidget2 = await ksampler2.getWidget(2)
|
||||
const widgetPos2 = await stepsWidget2.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos2,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
|
||||
|
||||
const unpromoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
|
||||
@@ -94,6 +94,7 @@ async function connectSlots(
|
||||
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
|
||||
const toLoc = slotLocator(page, to.nodeId, to.index, true)
|
||||
await expectVisibleAll(fromLoc, toLoc)
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await fromLoc.dragTo(toLoc, { force: true })
|
||||
await nextFrame()
|
||||
}
|
||||
@@ -192,6 +193,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -218,6 +220,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -22,10 +22,8 @@ test.describe('Vue Integer Widget', () => {
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
// Verify widget is disabled when linked
|
||||
await controls.incrementButton.click({ force: true })
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await controls.decrementButton.click({ force: true })
|
||||
await expect(controls.incrementButton).toBeDisabled()
|
||||
await expect(controls.decrementButton).toBeDisabled()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -113,13 +113,6 @@ export type {
|
||||
CreateWorkflowVersionRequest,
|
||||
CreateWorkflowVersionResponse,
|
||||
CreateWorkflowVersionResponses,
|
||||
CreateWorkspaceApiKeyData,
|
||||
CreateWorkspaceApiKeyError,
|
||||
CreateWorkspaceApiKeyErrors,
|
||||
CreateWorkspaceApiKeyRequest,
|
||||
CreateWorkspaceApiKeyResponse,
|
||||
CreateWorkspaceApiKeyResponse2,
|
||||
CreateWorkspaceApiKeyResponses,
|
||||
CreateWorkspaceData,
|
||||
CreateWorkspaceError,
|
||||
CreateWorkspaceErrors,
|
||||
@@ -558,12 +551,6 @@ export type {
|
||||
ListWorkflowsErrors,
|
||||
ListWorkflowsResponse,
|
||||
ListWorkflowsResponses,
|
||||
ListWorkspaceApiKeysData,
|
||||
ListWorkspaceApiKeysError,
|
||||
ListWorkspaceApiKeysErrors,
|
||||
ListWorkspaceApiKeysResponse,
|
||||
ListWorkspaceApiKeysResponse2,
|
||||
ListWorkspaceApiKeysResponses,
|
||||
ListWorkspaceInvitesData,
|
||||
ListWorkspaceInvitesError,
|
||||
ListWorkspaceInvitesErrors,
|
||||
@@ -674,11 +661,6 @@ export type {
|
||||
ResubscribeResponse,
|
||||
ResubscribeResponse2,
|
||||
ResubscribeResponses,
|
||||
RevokeWorkspaceApiKeyData,
|
||||
RevokeWorkspaceApiKeyError,
|
||||
RevokeWorkspaceApiKeyErrors,
|
||||
RevokeWorkspaceApiKeyResponse,
|
||||
RevokeWorkspaceApiKeyResponses,
|
||||
RevokeWorkspaceInviteData,
|
||||
RevokeWorkspaceInviteError,
|
||||
RevokeWorkspaceInviteErrors,
|
||||
@@ -736,12 +718,6 @@ export type {
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateHubWorkflowData,
|
||||
UpdateHubWorkflowError,
|
||||
UpdateHubWorkflowErrors,
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -789,13 +765,6 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
VerifyApiKeyRequest,
|
||||
VerifyApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyData,
|
||||
VerifyWorkspaceApiKeyError,
|
||||
VerifyWorkspaceApiKeyErrors,
|
||||
VerifyWorkspaceApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyResponses,
|
||||
ViewFileData,
|
||||
ViewFileError,
|
||||
ViewFileErrors,
|
||||
@@ -810,7 +779,6 @@ export type {
|
||||
WorkflowVersionContentResponse,
|
||||
WorkflowVersionResponse,
|
||||
Workspace,
|
||||
WorkspaceApiKeyInfo,
|
||||
WorkspaceSummary,
|
||||
WorkspaceWithRole
|
||||
} from './types.gen'
|
||||
|
||||
413
packages/ingest-types/src/types.gen.ts
generated
413
packages/ingest-types/src/types.gen.ts
generated
@@ -50,72 +50,6 @@ export type HubAssetUploadUrlRequest = {
|
||||
content_type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token:
|
||||
*
|
||||
* * field omitted or null — leave unchanged
|
||||
* * string field = "" — clear (for clearable string fields)
|
||||
* * array field = [] — clear the list
|
||||
* * any other value — set to the provided value
|
||||
*
|
||||
* Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value".
|
||||
* Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint.
|
||||
*
|
||||
*/
|
||||
export type UpdateHubWorkflowRequest = {
|
||||
/**
|
||||
* Display name. Not clearable. Null/omit leaves unchanged; empty string is invalid.
|
||||
*/
|
||||
name?: string | null
|
||||
/**
|
||||
* Workflow description. Send "" to clear. Null/omit leaves unchanged.
|
||||
*/
|
||||
description?: string | null
|
||||
/**
|
||||
* Full replacement of tag slugs. Must exist in hub_labels. Send [] to clear. Null/omit leaves unchanged.
|
||||
*/
|
||||
tags?: Array<string> | null
|
||||
/**
|
||||
* Full replacement of model slugs. Must exist in hub_labels. Send [] to clear. Null/omit leaves unchanged.
|
||||
*/
|
||||
models?: Array<string> | null
|
||||
/**
|
||||
* Full replacement of custom_node slugs. Must exist in hub_labels. Send [] to clear. Null/omit leaves unchanged.
|
||||
*/
|
||||
custom_nodes?: Array<string> | null
|
||||
/**
|
||||
* Tutorial URL. Send "" to clear. Null/omit leaves unchanged.
|
||||
*/
|
||||
tutorial_url?: string | null
|
||||
/**
|
||||
* Thumbnail kind. Null/omit leaves unchanged; not clearable via this endpoint. If set to image_comparison, both the thumbnail and comparison thumbnail must resolve to a value on the stored record after this update is applied (either already present and not being cleared, or supplied as a token in this request).
|
||||
*
|
||||
*/
|
||||
thumbnail_type?: 'image' | 'video' | 'image_comparison'
|
||||
/**
|
||||
* Token from POST /api/hub/assets/upload-url for a newly uploaded thumbnail. Null/omit leaves the existing thumbnail unchanged. Send "" to clear. (PATCH does not accept an existing public URL here — to keep the current thumbnail, simply omit the field.)
|
||||
*
|
||||
*/
|
||||
thumbnail_token?: string | null
|
||||
/**
|
||||
* Token from POST /api/hub/assets/upload-url for a newly uploaded comparison thumbnail. Null/omit leaves unchanged. Send "" to clear. (PATCH does not accept an existing public URL here — to keep the current comparison thumbnail, simply omit the field.)
|
||||
*
|
||||
*/
|
||||
thumbnail_comparison_token?: string | null
|
||||
/**
|
||||
* Full replacement of sample images. Each element is either a token from /api/hub/assets/upload-url or an existing public URL. Send [] to clear. Null/omit leaves unchanged.
|
||||
*
|
||||
*/
|
||||
sample_image_tokens_or_urls?: Array<string> | null
|
||||
/**
|
||||
* Admin-only full replacement of the hub_workflow_detail.metadata JSON object. Null/omit leaves unchanged. Send {} to clear all keys. Accepts arbitrary JSON (size, vram, open_source, media_type, logos, etc.).
|
||||
*
|
||||
*/
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
export type PublishHubWorkflowRequest = {
|
||||
/**
|
||||
* Username of the hub profile to publish under. The authenticated user must belong to the workspace that owns this profile.
|
||||
@@ -330,26 +264,8 @@ export type HubWorkflowTemplateEntry = {
|
||||
thumbnailVariant?: string
|
||||
mediaType?: string
|
||||
mediaSubtype?: string
|
||||
/**
|
||||
* Workflow asset size in bytes.
|
||||
*/
|
||||
size?: number
|
||||
/**
|
||||
* Approximate VRAM requirement in bytes.
|
||||
*/
|
||||
vram?: number
|
||||
/**
|
||||
* Usage count reported upstream.
|
||||
*/
|
||||
usage?: number
|
||||
/**
|
||||
* Search ranking score reported upstream.
|
||||
*/
|
||||
searchRank?: number
|
||||
/**
|
||||
* Whether the template belongs to a module marked as essential.
|
||||
*/
|
||||
isEssential?: boolean
|
||||
openSource?: boolean
|
||||
profile?: HubProfileSummary
|
||||
tutorialUrl?: string
|
||||
@@ -1193,133 +1109,6 @@ export type JwksResponse = {
|
||||
keys: Array<JwkKey>
|
||||
}
|
||||
|
||||
export type VerifyApiKeyResponse = {
|
||||
/**
|
||||
* Firebase UID of the key creator
|
||||
*/
|
||||
user_id: string
|
||||
/**
|
||||
* User's email address
|
||||
*/
|
||||
email: string
|
||||
/**
|
||||
* User's display name
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Whether the user is an admin
|
||||
*/
|
||||
is_admin: boolean
|
||||
/**
|
||||
* Workspace ID for billing attribution
|
||||
*/
|
||||
workspace_id: string
|
||||
/**
|
||||
* Type of workspace
|
||||
*/
|
||||
workspace_type: 'personal' | 'team'
|
||||
/**
|
||||
* User's role in the workspace
|
||||
*/
|
||||
role: 'owner' | 'member'
|
||||
/**
|
||||
* Whether the workspace has available funds for usage
|
||||
*/
|
||||
has_funds: boolean
|
||||
/**
|
||||
* Whether the workspace has an active subscription
|
||||
*/
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export type VerifyApiKeyRequest = {
|
||||
/**
|
||||
* The full plaintext API key to verify
|
||||
*/
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export type ListWorkspaceApiKeysResponse = {
|
||||
api_keys: Array<WorkspaceApiKeyInfo>
|
||||
}
|
||||
|
||||
export type WorkspaceApiKeyInfo = {
|
||||
/**
|
||||
* API key ID
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Workspace this key belongs to
|
||||
*/
|
||||
workspace_id: string
|
||||
/**
|
||||
* User who created this key
|
||||
*/
|
||||
user_id: string
|
||||
/**
|
||||
* User-provided label
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* First 8 chars after prefix for display
|
||||
*/
|
||||
key_prefix: string
|
||||
/**
|
||||
* When the key expires (if set)
|
||||
*/
|
||||
expires_at?: string
|
||||
/**
|
||||
* Last time the key was used
|
||||
*/
|
||||
last_used_at?: string
|
||||
/**
|
||||
* When the key was revoked (if revoked)
|
||||
*/
|
||||
revoked_at?: string
|
||||
/**
|
||||
* When the key was created
|
||||
*/
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type CreateWorkspaceApiKeyResponse = {
|
||||
/**
|
||||
* API key ID
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* User-provided label
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* The full plaintext API key (only shown once)
|
||||
*/
|
||||
key: string
|
||||
/**
|
||||
* First 8 chars after prefix for display
|
||||
*/
|
||||
key_prefix: string
|
||||
/**
|
||||
* When the key expires (if set)
|
||||
*/
|
||||
expires_at?: string
|
||||
/**
|
||||
* When the key was created
|
||||
*/
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type CreateWorkspaceApiKeyRequest = {
|
||||
/**
|
||||
* User-provided label for the key
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Optional expiration timestamp
|
||||
*/
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export type AcceptInviteResponse = {
|
||||
/**
|
||||
* ID of the workspace joined
|
||||
@@ -6130,163 +5919,6 @@ export type RemoveWorkspaceMemberResponses = {
|
||||
export type RemoveWorkspaceMemberResponse =
|
||||
RemoveWorkspaceMemberResponses[keyof RemoveWorkspaceMemberResponses]
|
||||
|
||||
export type ListWorkspaceApiKeysData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/api/workspace/api-keys'
|
||||
}
|
||||
|
||||
export type ListWorkspaceApiKeysErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: ErrorResponse
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: ErrorResponse
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse
|
||||
}
|
||||
|
||||
export type ListWorkspaceApiKeysError =
|
||||
ListWorkspaceApiKeysErrors[keyof ListWorkspaceApiKeysErrors]
|
||||
|
||||
export type ListWorkspaceApiKeysResponses = {
|
||||
/**
|
||||
* List of API keys
|
||||
*/
|
||||
200: ListWorkspaceApiKeysResponse
|
||||
}
|
||||
|
||||
export type ListWorkspaceApiKeysResponse2 =
|
||||
ListWorkspaceApiKeysResponses[keyof ListWorkspaceApiKeysResponses]
|
||||
|
||||
export type CreateWorkspaceApiKeyData = {
|
||||
body: CreateWorkspaceApiKeyRequest
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/api/workspace/api-keys'
|
||||
}
|
||||
|
||||
export type CreateWorkspaceApiKeyErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: ErrorResponse
|
||||
/**
|
||||
* Not a workspace member or personal workspace
|
||||
*/
|
||||
403: ErrorResponse
|
||||
/**
|
||||
* Workspace not found
|
||||
*/
|
||||
404: ErrorResponse
|
||||
/**
|
||||
* Validation error
|
||||
*/
|
||||
422: ErrorResponse
|
||||
/**
|
||||
* Key limit reached
|
||||
*/
|
||||
429: ErrorResponse
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse
|
||||
}
|
||||
|
||||
export type CreateWorkspaceApiKeyError =
|
||||
CreateWorkspaceApiKeyErrors[keyof CreateWorkspaceApiKeyErrors]
|
||||
|
||||
export type CreateWorkspaceApiKeyResponses = {
|
||||
/**
|
||||
* API key created (plaintext returned once)
|
||||
*/
|
||||
201: CreateWorkspaceApiKeyResponse
|
||||
}
|
||||
|
||||
export type CreateWorkspaceApiKeyResponse2 =
|
||||
CreateWorkspaceApiKeyResponses[keyof CreateWorkspaceApiKeyResponses]
|
||||
|
||||
export type RevokeWorkspaceApiKeyData = {
|
||||
body?: never
|
||||
path: {
|
||||
/**
|
||||
* API key ID to revoke
|
||||
*/
|
||||
id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/api/workspace/api-keys/{id}'
|
||||
}
|
||||
|
||||
export type RevokeWorkspaceApiKeyErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: ErrorResponse
|
||||
/**
|
||||
* Not authorized to revoke this key
|
||||
*/
|
||||
403: ErrorResponse
|
||||
/**
|
||||
* API key not found
|
||||
*/
|
||||
404: ErrorResponse
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse
|
||||
}
|
||||
|
||||
export type RevokeWorkspaceApiKeyError =
|
||||
RevokeWorkspaceApiKeyErrors[keyof RevokeWorkspaceApiKeyErrors]
|
||||
|
||||
export type RevokeWorkspaceApiKeyResponses = {
|
||||
/**
|
||||
* API key revoked
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type RevokeWorkspaceApiKeyResponse =
|
||||
RevokeWorkspaceApiKeyResponses[keyof RevokeWorkspaceApiKeyResponses]
|
||||
|
||||
export type VerifyWorkspaceApiKeyData = {
|
||||
body: VerifyApiKeyRequest
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/admin/api/keys/verify'
|
||||
}
|
||||
|
||||
export type VerifyWorkspaceApiKeyErrors = {
|
||||
/**
|
||||
* Invalid key or unauthorized
|
||||
*/
|
||||
401: ErrorResponse
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse
|
||||
}
|
||||
|
||||
export type VerifyWorkspaceApiKeyError =
|
||||
VerifyWorkspaceApiKeyErrors[keyof VerifyWorkspaceApiKeyErrors]
|
||||
|
||||
export type VerifyWorkspaceApiKeyResponses = {
|
||||
/**
|
||||
* Key is valid
|
||||
*/
|
||||
200: VerifyApiKeyResponse
|
||||
}
|
||||
|
||||
export type VerifyWorkspaceApiKeyResponse =
|
||||
VerifyWorkspaceApiKeyResponses[keyof VerifyWorkspaceApiKeyResponses]
|
||||
|
||||
export type GetUserData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -6466,51 +6098,6 @@ export type SetReviewStatusResponses = {
|
||||
export type SetReviewStatusResponse2 =
|
||||
SetReviewStatusResponses[keyof SetReviewStatusResponses]
|
||||
|
||||
export type UpdateHubWorkflowData = {
|
||||
body: UpdateHubWorkflowRequest
|
||||
path: {
|
||||
share_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/admin/api/hub/workflows/{share_id}'
|
||||
}
|
||||
|
||||
export type UpdateHubWorkflowErrors = {
|
||||
/**
|
||||
* Bad request - invalid field, unknown label slug, or invalid media token
|
||||
*/
|
||||
400: ErrorResponse
|
||||
/**
|
||||
* Unauthorized - authentication required
|
||||
*/
|
||||
401: ErrorResponse
|
||||
/**
|
||||
* Forbidden - insufficient permissions
|
||||
*/
|
||||
403: ErrorResponse
|
||||
/**
|
||||
* Not found - no published workflow for the given share_id
|
||||
*/
|
||||
404: ErrorResponse
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse
|
||||
}
|
||||
|
||||
export type UpdateHubWorkflowError =
|
||||
UpdateHubWorkflowErrors[keyof UpdateHubWorkflowErrors]
|
||||
|
||||
export type UpdateHubWorkflowResponses = {
|
||||
/**
|
||||
* Updated hub workflow detail
|
||||
*/
|
||||
200: HubWorkflowDetail
|
||||
}
|
||||
|
||||
export type UpdateHubWorkflowResponse =
|
||||
UpdateHubWorkflowResponses[keyof UpdateHubWorkflowResponses]
|
||||
|
||||
export type GetDeletionRequestData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
170
packages/ingest-types/src/zod.gen.ts
generated
170
packages/ingest-types/src/zod.gen.ts
generated
@@ -20,32 +20,6 @@ export const zHubAssetUploadUrlRequest = z.object({
|
||||
content_type: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token:
|
||||
*
|
||||
* * field omitted or null — leave unchanged
|
||||
* * string field = "" — clear (for clearable string fields)
|
||||
* * array field = [] — clear the list
|
||||
* * any other value — set to the provided value
|
||||
*
|
||||
* Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value".
|
||||
* Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint.
|
||||
*
|
||||
*/
|
||||
export const zUpdateHubWorkflowRequest = z.object({
|
||||
name: z.string().min(1).nullish(),
|
||||
description: z.string().nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
models: z.array(z.string()).nullish(),
|
||||
custom_nodes: z.array(z.string()).nullish(),
|
||||
tutorial_url: z.string().nullish(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_token: z.string().nullish(),
|
||||
thumbnail_comparison_token: z.string().nullish(),
|
||||
sample_image_tokens_or_urls: z.array(z.string()).nullish(),
|
||||
metadata: z.record(z.unknown()).nullish()
|
||||
})
|
||||
|
||||
export const zPublishHubWorkflowRequest = z.object({
|
||||
username: z.string(),
|
||||
name: z.string(),
|
||||
@@ -160,43 +134,8 @@ export const zHubWorkflowTemplateEntry = z.object({
|
||||
thumbnailVariant: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
mediaSubtype: z.string().optional(),
|
||||
size: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
vram: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
usage: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
searchRank: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
isEssential: z.boolean().optional(),
|
||||
size: z.number().optional(),
|
||||
vram: z.number().optional(),
|
||||
openSource: z.boolean().optional(),
|
||||
profile: zHubProfileSummary.optional(),
|
||||
tutorialUrl: z.string().optional(),
|
||||
@@ -702,52 +641,6 @@ export const zJwksResponse = z.object({
|
||||
keys: z.array(zJwkKey)
|
||||
})
|
||||
|
||||
export const zVerifyApiKeyResponse = z.object({
|
||||
user_id: z.string(),
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
is_admin: z.boolean(),
|
||||
workspace_id: z.string(),
|
||||
workspace_type: z.enum(['personal', 'team']),
|
||||
role: z.enum(['owner', 'member']),
|
||||
has_funds: z.boolean(),
|
||||
is_active: z.boolean()
|
||||
})
|
||||
|
||||
export const zVerifyApiKeyRequest = z.object({
|
||||
api_key: z.string()
|
||||
})
|
||||
|
||||
export const zWorkspaceApiKeyInfo = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspace_id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
last_used_at: z.string().datetime().optional(),
|
||||
revoked_at: z.string().datetime().optional(),
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
export const zListWorkspaceApiKeysResponse = z.object({
|
||||
api_keys: z.array(zWorkspaceApiKeyInfo)
|
||||
})
|
||||
|
||||
export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
key: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
export const zCreateWorkspaceApiKeyRequest = z.object({
|
||||
name: z.string(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
export const zAcceptInviteResponse = z.object({
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string()
|
||||
@@ -2536,52 +2429,6 @@ export const zRemoveWorkspaceMemberData = z.object({
|
||||
*/
|
||||
export const zRemoveWorkspaceMemberResponse = z.void()
|
||||
|
||||
export const zListWorkspaceApiKeysData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of API keys
|
||||
*/
|
||||
export const zListWorkspaceApiKeysResponse2 = zListWorkspaceApiKeysResponse
|
||||
|
||||
export const zCreateWorkspaceApiKeyData = z.object({
|
||||
body: zCreateWorkspaceApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* API key created (plaintext returned once)
|
||||
*/
|
||||
export const zCreateWorkspaceApiKeyResponse2 = zCreateWorkspaceApiKeyResponse
|
||||
|
||||
export const zRevokeWorkspaceApiKeyData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* API key revoked
|
||||
*/
|
||||
export const zRevokeWorkspaceApiKeyResponse = z.void()
|
||||
|
||||
export const zVerifyWorkspaceApiKeyData = z.object({
|
||||
body: zVerifyApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Key is valid
|
||||
*/
|
||||
export const zVerifyWorkspaceApiKeyResponse = zVerifyApiKeyResponse
|
||||
|
||||
export const zGetUserData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2641,19 +2488,6 @@ export const zSetReviewStatusData = z.object({
|
||||
*/
|
||||
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
|
||||
|
||||
export const zUpdateHubWorkflowData = z.object({
|
||||
body: zUpdateHubWorkflowRequest,
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Updated hub workflow detail
|
||||
*/
|
||||
export const zUpdateHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zGetDeletionRequestData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -139,6 +140,7 @@ const {
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
|
||||
@@ -6,19 +6,21 @@
|
||||
@pointerup.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<div class="show-menu relative">
|
||||
<div class="relative">
|
||||
<Button
|
||||
ref="menuTriggerRef"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('menu.showMenu')"
|
||||
class="rounded-full"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<i class="pi pi-bars text-lg text-base-foreground" />
|
||||
<i class="icon-[lucide--menu] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-show="isMenuOpen"
|
||||
ref="menuPanelRef"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
@@ -42,7 +44,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
|
||||
<SceneControls
|
||||
v-if="showSceneControls"
|
||||
@@ -51,6 +52,9 @@
|
||||
v-model:background-image="sceneConfig!.backgroundImage"
|
||||
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
:hdri-active="
|
||||
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
|
||||
"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
/>
|
||||
|
||||
@@ -70,11 +74,19 @@
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
/>
|
||||
|
||||
<LightControls
|
||||
v-if="showLightControls"
|
||||
v-model:light-intensity="lightConfig!.intensity"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
/>
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
<LightControls
|
||||
v-model:light-intensity="lightConfig!.intensity"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:hdri-config="lightConfig!.hdri"
|
||||
/>
|
||||
|
||||
<HDRIControls
|
||||
v-model:hdri-config="lightConfig!.hdri"
|
||||
:has-background-image="!!sceneConfig?.backgroundImage"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ExportControls
|
||||
v-if="showExportControls"
|
||||
@@ -85,10 +97,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
@@ -117,6 +131,17 @@ const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
const lightConfig = defineModel<LightConfig>('lightConfig')
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
const menuPanelRef = ref<HTMLElement | null>(null)
|
||||
const menuTriggerRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: isMenuOpen,
|
||||
getOverlayEl: () => menuPanelRef.value,
|
||||
getTriggerEl: () => menuTriggerRef.value?.$el ?? null,
|
||||
onDismiss: () => {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
})
|
||||
const activeCategory = ref<string>('scene')
|
||||
const categoryLabels: Record<string, string> = {
|
||||
scene: 'load3d.scene',
|
||||
@@ -160,21 +185,26 @@ const selectCategory = (category: string) => {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const categoryIcons = {
|
||||
scene: 'icon-[lucide--image]',
|
||||
model: 'icon-[lucide--box]',
|
||||
camera: 'icon-[lucide--camera]',
|
||||
light: 'icon-[lucide--sun]',
|
||||
export: 'icon-[lucide--download]'
|
||||
} as const
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons = {
|
||||
scene: 'pi pi-image',
|
||||
model: 'pi pi-box',
|
||||
camera: 'pi pi-camera',
|
||||
light: 'pi pi-sun',
|
||||
export: 'pi pi-download'
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return `${icons[category]} text-base-foreground text-lg`
|
||||
const icon =
|
||||
category in categoryIcons
|
||||
? categoryIcons[category as keyof typeof categoryIcons]
|
||||
: 'icon-[lucide--circle]'
|
||||
return cn(icon, 'text-lg text-base-foreground')
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
}>()
|
||||
|
||||
const handleBackgroundImageUpdate = (file: File | null) => {
|
||||
@@ -185,19 +215,7 @@ const handleExportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
|
||||
const closeSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
if (!target.closest('.show-menu')) {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
const handleHDRIFileUpdate = (file: File | null) => {
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeSlider)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeSlider)
|
||||
})
|
||||
</script>
|
||||
|
||||
148
src/components/load3d/controls/HDRIControls.vue
Normal file
148
src/components/load3d/controls/HDRIControls.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div v-if="!hasBackgroundImage || hdriConfig?.hdriPath" class="flex flex-col">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: hdriConfig?.hdriPath
|
||||
? $t('load3d.hdri.changeFile')
|
||||
: $t('load3d.hdri.uploadFile'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="
|
||||
hdriConfig?.hdriPath
|
||||
? $t('load3d.hdri.changeFile')
|
||||
: $t('load3d.hdri.uploadFile')
|
||||
"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<i class="icon-[lucide--upload] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<template v-if="hdriConfig?.hdriPath">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.hdri.label'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn('rounded-full', hdriConfig?.enabled && 'ring-2 ring-white/50')
|
||||
"
|
||||
:aria-label="$t('load3d.hdri.label')"
|
||||
@click="toggleEnabled"
|
||||
>
|
||||
<i class="icon-[lucide--globe] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.hdri.showAsBackground'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
hdriConfig?.showAsBackground && 'ring-2 ring-white/50'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('load3d.hdri.showAsBackground')"
|
||||
@click="toggleShowAsBackground"
|
||||
>
|
||||
<i class="icon-[lucide--image] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.hdri.removeFile'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.hdri.removeFile')"
|
||||
@click="onRemoveHDRI"
|
||||
>
|
||||
<i class="icon-[lucide--x] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
SUPPORTED_HDRI_EXTENSIONS,
|
||||
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
|
||||
} from '@/extensions/core/load3d/constants'
|
||||
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasBackgroundImage = false } = defineProps<{
|
||||
hasBackgroundImage?: boolean
|
||||
}>()
|
||||
|
||||
const hdriConfig = defineModel<HDRIConfig>('hdriConfig')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
}>()
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
if (file) {
|
||||
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
|
||||
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
|
||||
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
if (!hdriConfig.value) return
|
||||
hdriConfig.value = {
|
||||
...hdriConfig.value,
|
||||
enabled: !hdriConfig.value.enabled
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowAsBackground() {
|
||||
if (!hdriConfig.value) return
|
||||
hdriConfig.value = {
|
||||
...hdriConfig.value,
|
||||
showAsBackground: !hdriConfig.value.showAsBackground
|
||||
}
|
||||
}
|
||||
|
||||
function onRemoveHDRI() {
|
||||
emit('updateHdriFile', null)
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,24 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="showLightIntensityButton" class="show-light-intensity relative">
|
||||
<div
|
||||
v-if="embedded && showIntensityControl"
|
||||
class="flex w-[200px] flex-col gap-2 rounded-lg bg-black/50 p-3 shadow-lg"
|
||||
>
|
||||
<span class="text-sm font-medium text-base-foreground">{{
|
||||
$t('load3d.lightIntensity')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showIntensityControl" class="relative">
|
||||
<Button
|
||||
ref="triggerRef"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.lightIntensity'),
|
||||
showDelay: 300
|
||||
@@ -12,19 +29,20 @@
|
||||
:aria-label="$t('load3d.lightIntensity')"
|
||||
@click="toggleLightIntensity"
|
||||
>
|
||||
<i class="pi pi-sun text-lg text-base-foreground" />
|
||||
<i class="icon-[lucide--sun] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showLightIntensity"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
|
||||
style="width: 150px"
|
||||
ref="panelRef"
|
||||
class="absolute top-0 left-12 w-[200px] rounded-lg bg-black/50 p-3 shadow-lg"
|
||||
>
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,20 +50,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import type {
|
||||
HDRIConfig,
|
||||
MaterialMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const hdriConfig = defineModel<HDRIConfig | undefined>('hdriConfig')
|
||||
|
||||
const showLightIntensityButton = computed(
|
||||
() => materialMode.value === 'original'
|
||||
const { embedded = false } = defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const usesHdriIntensity = computed(
|
||||
() => !!hdriConfig.value?.hdriPath?.length && !!hdriConfig.value?.enabled
|
||||
)
|
||||
const showLightIntensity = ref(false)
|
||||
|
||||
const showIntensityControl = computed(() => materialMode.value === 'original')
|
||||
|
||||
const lightIntensityMaximum = useSettingStore().get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
@@ -57,23 +85,49 @@ const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const sliderMin = computed(() =>
|
||||
usesHdriIntensity.value ? 0 : lightIntensityMinimum
|
||||
)
|
||||
const sliderMax = computed(() =>
|
||||
usesHdriIntensity.value ? 5 : lightIntensityMaximum
|
||||
)
|
||||
const sliderStep = computed(() =>
|
||||
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
|
||||
)
|
||||
|
||||
const sliderValue = computed(() => {
|
||||
if (usesHdriIntensity.value) {
|
||||
return [hdriConfig.value?.intensity ?? 1]
|
||||
}
|
||||
return [lightIntensity.value ?? lightIntensityMinimum]
|
||||
})
|
||||
|
||||
const showLightIntensity = ref(false)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: showLightIntensity,
|
||||
getOverlayEl: () => panelRef.value,
|
||||
getTriggerEl: () => triggerRef.value?.$el ?? null,
|
||||
onDismiss: () => {
|
||||
showLightIntensity.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function toggleLightIntensity() {
|
||||
showLightIntensity.value = !showLightIntensity.value
|
||||
}
|
||||
|
||||
function closeLightSlider(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
if (!target.closest('.show-light-intensity')) {
|
||||
showLightIntensity.value = false
|
||||
function onSliderUpdate(value: number[] | undefined) {
|
||||
if (!value?.length) return
|
||||
const next = value[0]
|
||||
if (usesHdriIntensity.value) {
|
||||
const h = hdriConfig.value
|
||||
if (!h) return
|
||||
hdriConfig.value = { ...h, intensity: next }
|
||||
} else {
|
||||
lightIntensity.value = next
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeLightSlider)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeLightSlider)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,53 +11,55 @@
|
||||
<i class="pi pi-table text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.backgroundColor'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.backgroundColor')"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
<i class="pi pi-palette text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="colorPickerRef"
|
||||
type="color"
|
||||
:value="backgroundColor"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@input="
|
||||
updateBackgroundColor(($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="!hdriActive">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.backgroundColor'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.backgroundColor')"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
<i class="pi pi-palette text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="colorPickerRef"
|
||||
type="color"
|
||||
:value="backgroundColor"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@input="
|
||||
updateBackgroundColor(($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.uploadBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.uploadBackgroundImage')"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<i class="pi pi-image text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="imagePickerRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@change="uploadBackgroundImage"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.uploadBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.uploadBackgroundImage')"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<i class="pi pi-image text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="imagePickerRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@change="uploadBackgroundImage"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="hasBackgroundImage">
|
||||
<Button
|
||||
@@ -112,6 +114,10 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { hdriActive = false } = defineProps<{
|
||||
hdriActive?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import { rgbToHsl } from '@/utils/colorUtil'
|
||||
|
||||
const getPixelAlpha = (
|
||||
data: Uint8ClampedArray,
|
||||
@@ -47,39 +48,8 @@ const rgbToHSL = (
|
||||
g: number,
|
||||
b: number
|
||||
): { h: number; s: number; l: number } => {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0
|
||||
let s = 0
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 360,
|
||||
s: s * 100,
|
||||
l: l * 100
|
||||
}
|
||||
const hsl = rgbToHsl({ r, g, b })
|
||||
return { h: hsl.h * 360, s: hsl.s * 100, l: hsl.l * 100 }
|
||||
}
|
||||
|
||||
const rgbToLab = (rgb: {
|
||||
|
||||
@@ -23,7 +23,17 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn(),
|
||||
getResourceURL: vi.fn(),
|
||||
uploadFile: vi.fn()
|
||||
uploadFile: vi.fn(),
|
||||
mapSceneLightIntensityToHdri: vi.fn(
|
||||
(scene: number, min: number, max: number) => {
|
||||
const span = max - min
|
||||
const t = span > 0 ? (scene - min) / 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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -72,7 +82,13 @@ describe('useLoad3d', () => {
|
||||
state: null
|
||||
},
|
||||
'Light Config': {
|
||||
intensity: 5
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
},
|
||||
'Resource Folder': ''
|
||||
},
|
||||
@@ -122,6 +138,11 @@ describe('useLoad3d', () => {
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||
setShowSkeleton: vi.fn(),
|
||||
loadHDRI: vi.fn().mockResolvedValue(undefined),
|
||||
setHDRIEnabled: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
clearHDRI: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
@@ -167,7 +188,13 @@ describe('useLoad3d', () => {
|
||||
fov: 75
|
||||
})
|
||||
expect(composable.lightConfig.value).toEqual({
|
||||
intensity: 5
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
})
|
||||
expect(composable.isRecording.value).toBe(false)
|
||||
expect(composable.hasRecording.value).toBe(false)
|
||||
@@ -476,7 +503,7 @@ describe('useLoad3d', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
|
||||
expect(mockNode.properties['Light Config']).toEqual({
|
||||
expect(mockNode.properties['Light Config']).toMatchObject({
|
||||
intensity: 10
|
||||
})
|
||||
})
|
||||
@@ -912,6 +939,97 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hdri controls', () => {
|
||||
it('should call setHDRIEnabled when hdriConfig.enabled changes', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call setHDRIAsBackground when hdriConfig.showAsBackground changes', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, showAsBackground: true }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setHDRIAsBackground).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call setHDRIIntensity when hdriConfig.intensity changes', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, intensity: 2.5 }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
|
||||
})
|
||||
|
||||
it('should upload file, load HDRI and update hdriConfig', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/view?filename=env.hdr'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/view?filename=env.hdr'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const file = new File([''], 'env.hdr', { type: 'image/x-hdr' })
|
||||
await composable.handleHDRIFileUpdate(file)
|
||||
|
||||
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
|
||||
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
|
||||
'http://localhost/view?filename=env.hdr'
|
||||
)
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('3d/env.hdr')
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should clear HDRI when file is null', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: '3d/env.hdr',
|
||||
showAsBackground: true,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
|
||||
await composable.handleHDRIFileUpdate(null)
|
||||
|
||||
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null node ref', () => {
|
||||
const nodeRef = ref(null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import { toRef } from '@vueuse/core'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -58,8 +60,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
})
|
||||
|
||||
const lightConfig = ref<LightConfig>({
|
||||
intensity: 5
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
})
|
||||
const lastNonHdriLightIntensity = ref(lightConfig.value.intensity)
|
||||
|
||||
const isRecording = ref(false)
|
||||
const hasRecording = ref(false)
|
||||
@@ -185,8 +194,45 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
|
||||
const savedLightConfig = node.properties['Light Config'] as LightConfig
|
||||
const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false
|
||||
if (savedLightConfig) {
|
||||
lightConfig.value = savedLightConfig
|
||||
lightConfig.value = {
|
||||
intensity: savedLightConfig.intensity ?? lightConfig.value.intensity,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
...savedLightConfig.hdri,
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
lastNonHdriLightIntensity.value = lightConfig.value.intensity
|
||||
}
|
||||
|
||||
const hdri = lightConfig.value.hdri
|
||||
let hdriLoaded = false
|
||||
if (hdri?.hdriPath) {
|
||||
const hdriUrl = api.apiURL(
|
||||
Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(hdri.hdriPath),
|
||||
'input'
|
||||
)
|
||||
)
|
||||
try {
|
||||
await load3d.loadHDRI(hdriUrl)
|
||||
hdriLoaded = true
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore HDRI:', error)
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hdriLoaded && savedHdriEnabled) {
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: { ...lightConfig.value.hdri!, enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
@@ -213,6 +259,39 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
} else if (cameraStateToRestore) {
|
||||
load3d.setCameraState(cameraStateToRestore)
|
||||
}
|
||||
|
||||
applySceneConfigToLoad3d()
|
||||
applyLightConfigToLoad3d()
|
||||
}
|
||||
|
||||
const applySceneConfigToLoad3d = () => {
|
||||
if (!load3d) return
|
||||
const cfg = sceneConfig.value
|
||||
load3d.toggleGrid(cfg.showGrid)
|
||||
if (!lightConfig.value.hdri?.enabled) {
|
||||
load3d.setBackgroundColor(cfg.backgroundColor)
|
||||
}
|
||||
if (cfg.backgroundRenderMode) {
|
||||
load3d.setBackgroundRenderMode(cfg.backgroundRenderMode)
|
||||
}
|
||||
}
|
||||
|
||||
const applyLightConfigToLoad3d = () => {
|
||||
if (!load3d) return
|
||||
const cfg = lightConfig.value
|
||||
load3d.setLightIntensity(cfg.intensity)
|
||||
const hdri = cfg.hdri
|
||||
if (!hdri) return
|
||||
load3d.setHDRIIntensity(hdri.intensity)
|
||||
load3d.setHDRIAsBackground(hdri.showAsBackground)
|
||||
load3d.setHDRIEnabled(hdri.enabled)
|
||||
}
|
||||
|
||||
const persistLightConfigToNode = () => {
|
||||
const n = nodeRef.value
|
||||
if (n) {
|
||||
n.properties['Light Config'] = lightConfig.value
|
||||
}
|
||||
}
|
||||
|
||||
const getModelUrl = (modelPath: string): string | null => {
|
||||
@@ -260,22 +339,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
watch(
|
||||
sceneConfig,
|
||||
async (newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
(newValue) => {
|
||||
if (nodeRef.value) {
|
||||
nodeRef.value.properties['Scene Config'] = newValue
|
||||
load3d.toggleGrid(newValue.showGrid)
|
||||
load3d.setBackgroundColor(newValue.backgroundColor)
|
||||
|
||||
await load3d.setBackgroundImage(newValue.backgroundImage || '')
|
||||
|
||||
if (newValue.backgroundRenderMode) {
|
||||
load3d.setBackgroundRenderMode(newValue.backgroundRenderMode)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.showGrid,
|
||||
(showGrid) => {
|
||||
load3d?.toggleGrid(showGrid)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.backgroundColor,
|
||||
(color) => {
|
||||
if (!load3d || lightConfig.value.hdri?.enabled) return
|
||||
load3d.setBackgroundColor(color)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.backgroundImage,
|
||||
async (image) => {
|
||||
if (!load3d) return
|
||||
await load3d.setBackgroundImage(image || '')
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.backgroundRenderMode,
|
||||
(mode) => {
|
||||
if (mode) load3d?.setBackgroundRenderMode(mode)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
modelConfig,
|
||||
(newValue) => {
|
||||
@@ -302,14 +403,54 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
)
|
||||
|
||||
watch(
|
||||
lightConfig,
|
||||
(newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
nodeRef.value.properties['Light Config'] = newValue
|
||||
load3d.setLightIntensity(newValue.intensity)
|
||||
() => lightConfig.value.intensity,
|
||||
(intensity) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (!lightConfig.value.hdri?.enabled) {
|
||||
lastNonHdriLightIntensity.value = intensity
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
persistLightConfigToNode()
|
||||
load3d.setLightIntensity(intensity)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lightConfig.value.hdri?.intensity,
|
||||
(intensity) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (intensity === undefined) return
|
||||
persistLightConfigToNode()
|
||||
load3d.setHDRIIntensity(intensity)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lightConfig.value.hdri?.showAsBackground,
|
||||
(show) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (show === undefined) return
|
||||
persistLightConfigToNode()
|
||||
load3d.setHDRIAsBackground(show)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lightConfig.value.hdri?.enabled,
|
||||
(enabled, prevEnabled) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (enabled === undefined) return
|
||||
if (enabled && prevEnabled === false) {
|
||||
lastNonHdriLightIntensity.value = lightConfig.value.intensity
|
||||
}
|
||||
if (!enabled && prevEnabled === true) {
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
intensity: lastNonHdriLightIntensity.value
|
||||
}
|
||||
}
|
||||
persistLightConfigToNode()
|
||||
load3d.setHDRIEnabled(enabled)
|
||||
}
|
||||
)
|
||||
|
||||
watch(playing, (newValue) => {
|
||||
@@ -377,6 +518,98 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHDRIFileUpdate = async (file: File | null) => {
|
||||
const capturedLoad3d = load3d
|
||||
if (!capturedLoad3d) return
|
||||
|
||||
if (!file) {
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
hdriPath: '',
|
||||
enabled: false,
|
||||
showAsBackground: false
|
||||
}
|
||||
}
|
||||
capturedLoad3d.clearHDRI()
|
||||
return
|
||||
}
|
||||
|
||||
const resourceFolder =
|
||||
(nodeRef.value?.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim()
|
||||
? `3d/${resourceFolder.trim()}`
|
||||
: '3d'
|
||||
|
||||
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
|
||||
if (!uploadedPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-validate: node may have been removed during upload
|
||||
if (load3d !== capturedLoad3d) return
|
||||
|
||||
const hdriUrl = api.apiURL(
|
||||
Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(uploadedPath),
|
||||
'input'
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
loadingMessage.value = t('load3d.loadingHDRI')
|
||||
await capturedLoad3d.loadHDRI(hdriUrl)
|
||||
|
||||
if (load3d !== capturedLoad3d) return
|
||||
|
||||
let sceneMin = 1
|
||||
let sceneMax = 10
|
||||
if (getActivePinia() != null) {
|
||||
const settingStore = useSettingStore()
|
||||
sceneMin = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMinimum'
|
||||
) as number
|
||||
sceneMax = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
) as number
|
||||
}
|
||||
const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri(
|
||||
lightConfig.value.intensity,
|
||||
sceneMin,
|
||||
sceneMax
|
||||
)
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
hdriPath: uploadedPath,
|
||||
enabled: true,
|
||||
showAsBackground: true,
|
||||
intensity: mappedHdriIntensity
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load HDRI:', error)
|
||||
capturedLoad3d.clearHDRI()
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
hdriPath: '',
|
||||
enabled: false,
|
||||
showAsBackground: false
|
||||
}
|
||||
}
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadHDRI'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
if (!file) {
|
||||
sceneConfig.value.backgroundImage = ''
|
||||
@@ -642,6 +875,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
|
||||
@@ -295,7 +295,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = ''
|
||||
modelWidget.value = 'none'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
223
src/extensions/core/load3d/HDRIManager.test.ts
Normal file
223
src/extensions/core/load3d/HDRIManager.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
|
||||
const { mockFromEquirectangular, mockDisposePMREM } = vi.hoisted(() => ({
|
||||
mockFromEquirectangular: vi.fn(),
|
||||
mockDisposePMREM: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./Load3dUtils', () => ({
|
||||
default: {
|
||||
getFilenameExtension: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof THREE>()
|
||||
class MockPMREMGenerator {
|
||||
compileEquirectangularShader = vi.fn()
|
||||
fromEquirectangular = mockFromEquirectangular
|
||||
dispose = mockDisposePMREM
|
||||
}
|
||||
return { ...actual, PMREMGenerator: MockPMREMGenerator }
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/EXRLoader', () => {
|
||||
class EXRLoader {
|
||||
load(
|
||||
_url: string,
|
||||
resolve: (t: THREE.Texture) => void,
|
||||
_onProgress: undefined,
|
||||
_reject: (e: unknown) => void
|
||||
) {
|
||||
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
|
||||
}
|
||||
}
|
||||
return { EXRLoader }
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/RGBELoader', () => {
|
||||
class RGBELoader {
|
||||
load(
|
||||
_url: string,
|
||||
resolve: (t: THREE.Texture) => void,
|
||||
_onProgress: undefined,
|
||||
_reject: (e: unknown) => void
|
||||
) {
|
||||
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
|
||||
}
|
||||
}
|
||||
return { RGBELoader }
|
||||
})
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('HDRIManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let eventManager: ReturnType<typeof makeMockEventManager>
|
||||
let manager: HDRIManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
scene = new THREE.Scene()
|
||||
eventManager = makeMockEventManager()
|
||||
|
||||
mockFromEquirectangular.mockReturnValue({
|
||||
texture: new THREE.Texture(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
|
||||
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts disabled with default intensity', () => {
|
||||
expect(manager.isEnabled).toBe(false)
|
||||
expect(manager.showAsBackground).toBe(false)
|
||||
expect(manager.intensity).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadHDRI', () => {
|
||||
it('loads .exr files without error', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('exr')
|
||||
|
||||
await expect(
|
||||
manager.loadHDRI('http://example.com/env.exr')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads .hdr files without error', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
|
||||
await expect(
|
||||
manager.loadHDRI('http://example.com/env.hdr')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies to scene immediately when already enabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
manager.setEnabled(true)
|
||||
// No texture loaded yet so scene.environment stays null
|
||||
expect(scene.environment).toBeNull()
|
||||
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
|
||||
expect(scene.environment).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not apply to scene when disabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
|
||||
expect(scene.environment).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('applies environment map to scene when enabled after loading', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
|
||||
manager.setEnabled(true)
|
||||
|
||||
expect(scene.environment).not.toBeNull()
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith('hdriChange', {
|
||||
enabled: true,
|
||||
showAsBackground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('removes environment map from scene when disabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.setEnabled(false)
|
||||
|
||||
expect(scene.environment).toBeNull()
|
||||
expect(eventManager.emitEvent).toHaveBeenLastCalledWith('hdriChange', {
|
||||
enabled: false,
|
||||
showAsBackground: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setIntensity', () => {
|
||||
it('updates scene intensity when enabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.setIntensity(2.5)
|
||||
|
||||
expect(scene.environmentIntensity).toBe(2.5)
|
||||
expect(manager.intensity).toBe(2.5)
|
||||
})
|
||||
|
||||
it('stores intensity without applying when disabled', () => {
|
||||
manager.setIntensity(3)
|
||||
|
||||
expect(manager.intensity).toBe(3)
|
||||
expect(scene.environmentIntensity).not.toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setShowAsBackground', () => {
|
||||
it('sets scene background texture when enabled and showing as background', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.setShowAsBackground(true)
|
||||
|
||||
expect(scene.background).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears scene background when showAsBackground is false', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
manager.setShowAsBackground(true)
|
||||
|
||||
manager.setShowAsBackground(false)
|
||||
|
||||
expect(scene.background).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes HDRI from scene and resets state', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.clear()
|
||||
|
||||
expect(manager.isEnabled).toBe(false)
|
||||
expect(scene.environment).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes PMREMGenerator', () => {
|
||||
manager.dispose()
|
||||
|
||||
expect(mockDisposePMREM).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
142
src/extensions/core/load3d/HDRIManager.ts
Normal file
142
src/extensions/core/load3d/HDRIManager.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as THREE from 'three'
|
||||
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
|
||||
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
|
||||
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
export class HDRIManager {
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private pmremGenerator: THREE.PMREMGenerator
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
private hdriTexture: THREE.Texture | null = null
|
||||
private envMapTarget: THREE.WebGLRenderTarget | null = null
|
||||
|
||||
private _isEnabled: boolean = false
|
||||
private _showAsBackground: boolean = false
|
||||
private _intensity: number = 1
|
||||
|
||||
get isEnabled() {
|
||||
return this._isEnabled
|
||||
}
|
||||
|
||||
get showAsBackground() {
|
||||
return this._showAsBackground
|
||||
}
|
||||
|
||||
get intensity() {
|
||||
return this._intensity
|
||||
}
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
|
||||
this.pmremGenerator.compileEquirectangularShader()
|
||||
this.eventManager = eventManager
|
||||
}
|
||||
|
||||
async loadHDRI(url: string): Promise<void> {
|
||||
const ext = Load3dUtils.getFilenameExtension(url)
|
||||
|
||||
let newTexture: THREE.Texture
|
||||
if (ext === 'exr') {
|
||||
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
|
||||
new EXRLoader().load(url, resolve, undefined, reject)
|
||||
})
|
||||
} else {
|
||||
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
|
||||
new RGBELoader().load(url, resolve, undefined, reject)
|
||||
})
|
||||
}
|
||||
|
||||
newTexture.mapping = THREE.EquirectangularReflectionMapping
|
||||
const newEnvMapTarget = this.pmremGenerator.fromEquirectangular(newTexture)
|
||||
|
||||
// Dispose old resources only after the new one is ready
|
||||
this.hdriTexture?.dispose()
|
||||
this.envMapTarget?.dispose()
|
||||
this.hdriTexture = newTexture
|
||||
this.envMapTarget = newEnvMapTarget
|
||||
|
||||
if (this._isEnabled) {
|
||||
this.applyToScene()
|
||||
}
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._isEnabled = enabled
|
||||
if (enabled) {
|
||||
if (this.envMapTarget) {
|
||||
this.applyToScene()
|
||||
}
|
||||
} else {
|
||||
this.removeFromScene()
|
||||
}
|
||||
}
|
||||
|
||||
setShowAsBackground(show: boolean): void {
|
||||
this._showAsBackground = show
|
||||
if (this._isEnabled && this.envMapTarget) {
|
||||
this.applyToScene()
|
||||
}
|
||||
}
|
||||
|
||||
setIntensity(intensity: number): void {
|
||||
this._intensity = intensity
|
||||
if (this._isEnabled) {
|
||||
this.scene.environmentIntensity = intensity
|
||||
}
|
||||
}
|
||||
|
||||
private applyToScene(): void {
|
||||
const envMap = this.envMapTarget?.texture
|
||||
if (!envMap) return
|
||||
this.scene.environment = envMap
|
||||
this.scene.environmentIntensity = this._intensity
|
||||
this.scene.background = this._showAsBackground ? this.hdriTexture : null
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: this._isEnabled,
|
||||
showAsBackground: this._showAsBackground
|
||||
})
|
||||
}
|
||||
|
||||
private removeFromScene(): void {
|
||||
this.scene.environment = null
|
||||
if (this.scene.background === this.hdriTexture) {
|
||||
this.scene.background = null
|
||||
}
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: false,
|
||||
showAsBackground: this._showAsBackground
|
||||
})
|
||||
}
|
||||
|
||||
private clearResources(): void {
|
||||
this.removeFromScene()
|
||||
this.hdriTexture?.dispose()
|
||||
this.envMapTarget?.dispose()
|
||||
this.hdriTexture = null
|
||||
this.envMapTarget = null
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clearResources()
|
||||
this._isEnabled = false
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearResources()
|
||||
this.pmremGenerator.dispose()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export class LightingManager implements LightingManagerInterface {
|
||||
currentIntensity: number = 3
|
||||
private scene: THREE.Scene
|
||||
private eventManager: EventManagerInterface
|
||||
private lightMultipliers = new Map<THREE.Light, number>()
|
||||
|
||||
constructor(scene: THREE.Scene, eventManager: EventManagerInterface) {
|
||||
this.scene = scene
|
||||
@@ -25,59 +26,53 @@ export class LightingManager implements LightingManagerInterface {
|
||||
this.scene.remove(light)
|
||||
})
|
||||
this.lights = []
|
||||
this.lightMultipliers.clear()
|
||||
}
|
||||
|
||||
setupLights(): void {
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
|
||||
this.scene.add(ambientLight)
|
||||
this.lights.push(ambientLight)
|
||||
const addLight = (light: THREE.Light, multiplier: number) => {
|
||||
this.scene.add(light)
|
||||
this.lights.push(light)
|
||||
this.lightMultipliers.set(light, multiplier)
|
||||
}
|
||||
|
||||
addLight(new THREE.AmbientLight(0xffffff, 0.5), 0.5)
|
||||
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||
mainLight.position.set(0, 10, 10)
|
||||
this.scene.add(mainLight)
|
||||
this.lights.push(mainLight)
|
||||
addLight(mainLight, 0.8)
|
||||
|
||||
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
|
||||
backLight.position.set(0, 10, -10)
|
||||
this.scene.add(backLight)
|
||||
this.lights.push(backLight)
|
||||
addLight(backLight, 0.5)
|
||||
|
||||
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
|
||||
leftFillLight.position.set(-10, 0, 0)
|
||||
this.scene.add(leftFillLight)
|
||||
this.lights.push(leftFillLight)
|
||||
addLight(leftFillLight, 0.3)
|
||||
|
||||
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
|
||||
rightFillLight.position.set(10, 0, 0)
|
||||
this.scene.add(rightFillLight)
|
||||
this.lights.push(rightFillLight)
|
||||
addLight(rightFillLight, 0.3)
|
||||
|
||||
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
|
||||
bottomLight.position.set(0, -10, 0)
|
||||
this.scene.add(bottomLight)
|
||||
this.lights.push(bottomLight)
|
||||
addLight(bottomLight, 0.2)
|
||||
}
|
||||
|
||||
setLightIntensity(intensity: number): void {
|
||||
this.currentIntensity = intensity
|
||||
this.lights.forEach((light) => {
|
||||
if (light instanceof THREE.DirectionalLight) {
|
||||
if (light === this.lights[1]) {
|
||||
light.intensity = intensity * 0.8
|
||||
} else if (light === this.lights[2]) {
|
||||
light.intensity = intensity * 0.5
|
||||
} else if (light === this.lights[5]) {
|
||||
light.intensity = intensity * 0.2
|
||||
} else {
|
||||
light.intensity = intensity * 0.3
|
||||
}
|
||||
} else if (light instanceof THREE.AmbientLight) {
|
||||
light.intensity = intensity * 0.5
|
||||
}
|
||||
light.intensity = intensity * (this.lightMultipliers.get(light) ?? 1)
|
||||
})
|
||||
|
||||
this.eventManager.emitEvent('lightIntensityChange', intensity)
|
||||
}
|
||||
|
||||
setHDRIMode(hdriActive: boolean): void {
|
||||
this.lights.forEach((light) => {
|
||||
light.visible = !hdriActive
|
||||
})
|
||||
}
|
||||
|
||||
reset(): void {}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
HDRIConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
@@ -74,7 +75,7 @@ class Load3DConfiguration {
|
||||
loadFolder,
|
||||
cameraState
|
||||
)
|
||||
if (modelWidget.value) {
|
||||
if (modelWidget.value && modelWidget.value !== 'none') {
|
||||
onModelWidgetUpdate(modelWidget.value)
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ class Load3DConfiguration {
|
||||
|
||||
const lightConfig = this.loadLightConfig()
|
||||
this.applyLightConfig(lightConfig)
|
||||
if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri)
|
||||
}
|
||||
|
||||
private loadSceneConfig(): SceneConfig {
|
||||
@@ -140,13 +142,27 @@ class Load3DConfiguration {
|
||||
}
|
||||
|
||||
private loadLightConfig(): LightConfig {
|
||||
const hdriDefaults: HDRIConfig = {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
|
||||
if (this.properties && 'Light Config' in this.properties) {
|
||||
return this.properties['Light Config'] as LightConfig
|
||||
const saved = this.properties['Light Config'] as Partial<LightConfig>
|
||||
return {
|
||||
intensity:
|
||||
saved.intensity ??
|
||||
(useSettingStore().get('Comfy.Load3D.LightIntensity') as number),
|
||||
hdri: { ...hdriDefaults, ...(saved.hdri ?? {}) }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
|
||||
} as LightConfig
|
||||
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number,
|
||||
hdri: hdriDefaults
|
||||
}
|
||||
}
|
||||
|
||||
private loadModelConfig(): ModelConfig {
|
||||
@@ -190,6 +206,15 @@ class Load3DConfiguration {
|
||||
this.load3d.setLightIntensity(config.intensity)
|
||||
}
|
||||
|
||||
private applyHDRISettings(config: HDRIConfig) {
|
||||
if (!config.hdriPath) return
|
||||
this.load3d.setHDRIIntensity(config.intensity)
|
||||
this.load3d.setHDRIAsBackground(config.showAsBackground)
|
||||
if (config.enabled) {
|
||||
this.load3d.setHDRIEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
private applyModelConfig(config: ModelConfig) {
|
||||
this.load3d.setUpDirection(config.upDirection)
|
||||
this.load3d.setMaterialMode(config.materialMode)
|
||||
@@ -201,7 +226,10 @@ class Load3DConfiguration {
|
||||
) {
|
||||
let isFirstLoad = true
|
||||
return async (value: string | number | boolean | object) => {
|
||||
if (!value) return
|
||||
if (!value || value === 'none') {
|
||||
this.load3d.clearModel()
|
||||
return
|
||||
}
|
||||
|
||||
const filename = value as string
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
@@ -54,6 +55,7 @@ class Load3d {
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
hdriManager: HDRIManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
loaderManager: LoaderManager
|
||||
modelManager: SceneModelManager
|
||||
@@ -126,6 +128,12 @@ class Load3d {
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.hdriManager = new HDRIManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.viewHelperManager = new ViewHelperManager(
|
||||
this.renderer,
|
||||
this.getActiveCamera.bind(this),
|
||||
@@ -635,6 +643,33 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
async loadHDRI(url: string): Promise<void> {
|
||||
await this.hdriManager.loadHDRI(url)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setHDRIEnabled(enabled: boolean): void {
|
||||
this.hdriManager.setEnabled(enabled)
|
||||
this.lightingManager.setHDRIMode(enabled)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setHDRIAsBackground(show: boolean): void {
|
||||
this.hdriManager.setShowAsBackground(show)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setHDRIIntensity(intensity: number): void {
|
||||
this.hdriManager.setIntensity(intensity)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
clearHDRI(): void {
|
||||
this.hdriManager.clear()
|
||||
this.lightingManager.setHDRIMode(false)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
@@ -858,6 +893,7 @@ class Load3d {
|
||||
this.cameraManager.dispose()
|
||||
this.controlsManager.dispose()
|
||||
this.lightingManager.dispose()
|
||||
this.hdriManager.dispose()
|
||||
this.viewHelperManager.dispose()
|
||||
this.loaderManager.dispose()
|
||||
this.modelManager.dispose()
|
||||
|
||||
25
src/extensions/core/load3d/Load3dUtils.test.ts
Normal file
25
src/extensions/core/load3d/Load3dUtils.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
|
||||
describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
|
||||
it('maps scene slider low end to a small positive HDRI intensity', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(1, 1, 10)).toBe(0.25)
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(10, 1, 10)).toBe(5)
|
||||
})
|
||||
|
||||
it('maps midpoint proportionally', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(5.5, 1, 10)).toBeCloseTo(
|
||||
2.5
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps scene ratio and HDRI ceiling', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(-10, 1, 10)).toBe(0.25)
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(100, 1, 10)).toBe(5)
|
||||
})
|
||||
|
||||
it('uses minimum HDRI when span is zero', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25)
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,15 @@ class Load3dUtils {
|
||||
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) {
|
||||
@@ -122,6 +131,19 @@ class Load3dUtils {
|
||||
|
||||
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
|
||||
|
||||
@@ -16,3 +16,9 @@ export const SUPPORTED_EXTENSIONS = new Set([
|
||||
])
|
||||
|
||||
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
|
||||
|
||||
export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
|
||||
|
||||
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
|
||||
...SUPPORTED_HDRI_EXTENSIONS
|
||||
].join(',')
|
||||
|
||||
@@ -47,6 +47,14 @@ export interface CameraConfig {
|
||||
|
||||
export interface LightConfig {
|
||||
intensity: number
|
||||
hdri?: HDRIConfig
|
||||
}
|
||||
|
||||
export interface HDRIConfig {
|
||||
enabled: boolean
|
||||
hdriPath: string
|
||||
showAsBackground: boolean
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export interface EventCallback<T = unknown> {
|
||||
|
||||
@@ -1988,7 +1988,16 @@
|
||||
"openIn3DViewer": "Open in 3D Viewer",
|
||||
"dropToLoad": "Drop 3D model to load",
|
||||
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)",
|
||||
"uploadingModel": "Uploading 3D model..."
|
||||
"uploadingModel": "Uploading 3D model...",
|
||||
"loadingHDRI": "Loading HDRI...",
|
||||
"hdri": {
|
||||
"label": "HDRI Environment",
|
||||
"uploadFile": "Upload HDRI (.hdr, .exr)",
|
||||
"changeFile": "Change HDRI",
|
||||
"removeFile": "Remove HDRI",
|
||||
"showAsBackground": "Show as Background",
|
||||
"intensity": "Intensity"
|
||||
}
|
||||
},
|
||||
"imageCrop": {
|
||||
"loading": "Loading...",
|
||||
@@ -2083,7 +2092,9 @@
|
||||
"failedToUpdateMaterialMode": "Failed to update material mode",
|
||||
"failedToUpdateEdgeThreshold": "Failed to update edge threshold",
|
||||
"failedToUploadBackgroundImage": "Failed to upload background image",
|
||||
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}"
|
||||
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}",
|
||||
"failedToLoadHDRI": "Failed to load HDRI file",
|
||||
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
|
||||
},
|
||||
"nodeErrors": {
|
||||
"render": "Node Render Error",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function isTransparent(color: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
function rgbToHsl({ r, g, b }: RGB): HSL {
|
||||
export function rgbToHsl({ r, g, b }: RGB): HSL {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
Reference in New Issue
Block a user