Compare commits

..

1 Commits

Author SHA1 Message Date
Alexis Rolland
115d8c4f72 Update commit author login retrieval in CLA workflow 2026-07-09 22:09:20 +08:00
31 changed files with 224 additions and 955 deletions

View File

@@ -38,9 +38,11 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -1,158 +0,0 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning — Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 — Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -1,13 +1,7 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
@@ -45,34 +39,4 @@ test.describe('Preview as Text node', () => {
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
wstest(
'restoring workflow restores state',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview as Text')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
const preview = node.root.locator('textarea')
await test.step('node previews execution result', async () => {
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
execution.executed('', id, { text: 'massive fennec ears' })
await expect(preview).toHaveValue('massive fennec ears')
})
await test.step('swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.getTab(0).click()
await expect(node.root).toBeHidden()
await comfyPage.menu.topbar.getTab(1).click()
await expect(node.root).toBeVisible()
})
await expect(preview, 'previous output is restored').toHaveValue(
'massive fennec ears'
)
}
)
})

View File

@@ -203,7 +203,9 @@ describe('useLoad3d', () => {
getModelInfo: vi.fn().mockReturnValue(null),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
domElement: mockCanvas
renderer: {
domElement: mockCanvas
} as Partial<Load3d['renderer']> as Load3d['renderer']
}
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
@@ -290,7 +292,7 @@ describe('useLoad3d', () => {
mockNode.flags.collapsed = true
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
expect(mockLoad3d.domElement!.hidden).toBe(true)
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
})
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {

View File

@@ -239,7 +239,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.domElement.hidden = this.flags.collapsed ?? false
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
}
)

View File

@@ -43,8 +43,13 @@ function makeMockEventManager() {
} satisfies EventManagerInterface
}
function makeElement() {
return document.createElement('div')
function makeRenderer(opts: { withParent?: boolean } = {}) {
const canvas = document.createElement('canvas')
if (opts.withParent) {
const parent = document.createElement('div')
parent.appendChild(canvas)
}
return { domElement: canvas } as unknown as THREE.WebGLRenderer
}
describe('ControlsManager', () => {
@@ -59,19 +64,33 @@ describe('ControlsManager', () => {
})
describe('construction', () => {
it('attaches OrbitControls to the interaction element', () => {
const element = makeElement()
it('attaches OrbitControls to the canvas parent when one exists', () => {
const renderer = makeRenderer({ withParent: true })
manager = new ControlsManager(element, camera, events)
manager = new ControlsManager(renderer, camera, events)
expect(mockOrbitControls).toHaveBeenCalledWith(camera, element)
expect(mockOrbitControls).toHaveBeenCalledWith(
camera,
renderer.domElement.parentElement
)
expect(manager.controls.enableDamping).toBe(true)
})
it('falls back to the canvas itself when there is no parent', () => {
const renderer = makeRenderer({ withParent: false })
manager = new ControlsManager(renderer, camera, events)
expect(mockOrbitControls).toHaveBeenCalledWith(
camera,
renderer.domElement
)
})
})
describe('init', () => {
it('emits cameraChanged with a perspective state when the controls fire end', () => {
manager = new ControlsManager(makeElement(), camera, events)
manager = new ControlsManager(makeRenderer(), camera, events)
camera.position.set(1, 2, 3)
camera.zoom = 1.25
manager.controls.target.set(4, 5, 6)
@@ -90,7 +109,7 @@ describe('ControlsManager', () => {
it('reports orthographic camera type when initialized with one', () => {
const ortho = new THREE.OrthographicCamera()
ortho.zoom = 0.5
manager = new ControlsManager(makeElement(), ortho, events)
manager = new ControlsManager(makeRenderer(), ortho, events)
manager.init()
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
@@ -104,7 +123,7 @@ describe('ControlsManager', () => {
describe('updateCamera', () => {
it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => {
manager = new ControlsManager(makeElement(), camera, events)
manager = new ControlsManager(makeRenderer(), camera, events)
camera.position.set(7, 8, 9)
manager.controls.target.set(1, 1, 1)
@@ -120,7 +139,7 @@ describe('ControlsManager', () => {
describe('update / reset', () => {
it('update delegates to controls.update', () => {
manager = new ControlsManager(makeElement(), camera, events)
manager = new ControlsManager(makeRenderer(), camera, events)
manager.update()
@@ -128,7 +147,7 @@ describe('ControlsManager', () => {
})
it('reset clears the target back to the origin and refreshes', () => {
manager = new ControlsManager(makeElement(), camera, events)
manager = new ControlsManager(makeRenderer(), camera, events)
manager.controls.target.set(5, 6, 7)
manager.reset()
@@ -140,7 +159,7 @@ describe('ControlsManager', () => {
describe('dispose', () => {
it('disposes the underlying OrbitControls', () => {
manager = new ControlsManager(makeElement(), camera, events)
manager = new ControlsManager(makeRenderer(), camera, events)
manager.dispose()
@@ -150,7 +169,7 @@ describe('ControlsManager', () => {
describe('detach / attach', () => {
it('detach disables OrbitControls interaction', () => {
manager = new ControlsManager(makeElement(), camera, events)
manager = new ControlsManager(makeRenderer(), camera, events)
expect(manager.controls.enabled).toBe(true)
manager.detach()
@@ -159,7 +178,7 @@ describe('ControlsManager', () => {
})
it('attach re-enables OrbitControls interaction', () => {
manager = new ControlsManager(makeElement(), camera, events)
manager = new ControlsManager(makeRenderer(), camera, events)
manager.detach()
manager.attach()

View File

@@ -12,14 +12,15 @@ export class ControlsManager implements ControlsManagerInterface {
private camera: THREE.Camera
constructor(
interactionElement: HTMLElement,
renderer: THREE.WebGLRenderer,
camera: THREE.Camera,
eventManager: EventManagerInterface
) {
this.eventManager = eventManager
this.camera = camera
this.controls = new OrbitControls(camera, interactionElement)
const container = renderer.domElement.parentElement || renderer.domElement
this.controls = new OrbitControls(camera, container)
this.controls.enableDamping = true
}

View File

@@ -55,7 +55,7 @@ function makeMockOrbitControls() {
describe('GizmoManager', () => {
let scene: THREE.Scene
let interactionElement: HTMLElement
let renderer: THREE.WebGLRenderer
let camera: THREE.PerspectiveCamera
let orbitControls: ReturnType<typeof makeMockOrbitControls>
let manager: GizmoManager
@@ -66,7 +66,9 @@ describe('GizmoManager', () => {
vi.clearAllMocks()
scene = new THREE.Scene()
interactionElement = document.createElement('div')
renderer = {
domElement: document.createElement('canvas')
} as unknown as THREE.WebGLRenderer
camera = new THREE.PerspectiveCamera()
orbitControls = makeMockOrbitControls()
onTransformChange = vi.fn()
@@ -78,7 +80,7 @@ describe('GizmoManager', () => {
manager = new GizmoManager(
scene,
interactionElement,
renderer,
orbitControls,
() => camera,
onTransformChange

View File

@@ -15,19 +15,19 @@ export class GizmoManager {
private activeCamera: THREE.Camera
private mode: GizmoMode = 'translate'
private scene: THREE.Scene
private interactionElement: HTMLElement
private renderer: THREE.WebGLRenderer
private orbitControls: OrbitControls
private onTransformChange?: () => void
constructor(
scene: THREE.Scene,
interactionElement: HTMLElement,
renderer: THREE.WebGLRenderer,
orbitControls: OrbitControls,
getActiveCamera: () => THREE.Camera,
onTransformChange?: () => void
) {
this.scene = scene
this.interactionElement = interactionElement
this.renderer = renderer
this.orbitControls = orbitControls
this.activeCamera = getActiveCamera()
this.onTransformChange = onTransformChange
@@ -36,7 +36,7 @@ export class GizmoManager {
init(): void {
this.transformControls = new TransformControls(
this.activeCamera,
this.interactionElement
this.renderer.domElement
)
this.transformControls.addEventListener('dragging-changed', (event) => {

View File

@@ -1,8 +1,6 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import { HDRIManager } from './HDRIManager'
import Load3dUtils from './Load3dUtils'
@@ -78,12 +76,7 @@ describe('HDRIManager', () => {
dispose: vi.fn()
})
manager = new HDRIManager(
scene,
{} as THREE.WebGLRenderer,
createRendererViewState(),
eventManager
)
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
})
afterEach(() => {

View File

@@ -2,14 +2,12 @@ import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import Load3dUtils from './Load3dUtils'
import type { EventManagerInterface } from './interfaces'
export class HDRIManager {
private scene: THREE.Scene
private viewState: RendererViewState
private renderer: THREE.WebGLRenderer
private pmremGenerator: THREE.PMREMGenerator
private eventManager: EventManagerInterface
@@ -35,11 +33,10 @@ export class HDRIManager {
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
viewState: RendererViewState,
eventManager: EventManagerInterface
) {
this.scene = scene
this.viewState = viewState
this.renderer = renderer
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
this.pmremGenerator.compileEquirectangularShader()
this.eventManager = eventManager
@@ -104,8 +101,8 @@ export class HDRIManager {
this.scene.environment = envMap
this.scene.environmentIntensity = this._intensity
this.scene.background = this._showAsBackground ? this.hdriTexture : null
this.viewState.toneMapping = THREE.ACESFilmicToneMapping
this.viewState.toneMappingExposure = 1.0
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: this._isEnabled,
showAsBackground: this._showAsBackground
@@ -117,8 +114,8 @@ export class HDRIManager {
if (this.scene.background === this.hdriTexture) {
this.scene.background = null
}
this.viewState.toneMapping = THREE.NoToneMapping
this.viewState.toneMappingExposure = 1.0
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: false,
showAsBackground: this._showAsBackground

View File

@@ -334,7 +334,7 @@ describe('Load3d', () => {
const sceneResize = vi.fn()
Object.assign(ctx.load3d, {
view: { canvas, setSize },
renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
@@ -361,21 +361,26 @@ describe('Load3d', () => {
const updateAspectRatio = vi.fn()
const renderBackground = vi.fn()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
const scene = {} as THREE.Scene
Object.assign(ctx.load3d, {
view: {
width: 800,
height: 600,
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
renderer: {
setViewport,
setScissor,
setScissorTest,
setClearColor,
clear,
render
}
renderer: {
domElement: canvas,
setViewport,
setScissor,
setScissorTest,
setClearColor,
clear,
render
},
targetWidth: 400,
targetHeight: 200,
@@ -410,7 +415,7 @@ describe('Load3d', () => {
})
Object.assign(ctx.load3d, {
view: { canvas },
renderer: { domElement: canvas },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
@@ -433,7 +438,7 @@ describe('Load3d', () => {
expect(args[3]).toBe(400)
})
it('handleResize scales the view size by getZoomScaleCallback', () => {
it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
@@ -448,10 +453,10 @@ describe('Load3d', () => {
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setSize = vi.fn()
const setPixelRatio = vi.fn()
Object.assign(ctx.load3d, {
view: { canvas, setSize },
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
getZoomScaleCallback: () => 2.5,
targetWidth: 0,
targetHeight: 0,
@@ -462,10 +467,10 @@ describe('Load3d', () => {
ctx.load3d.handleResize()
expect(setSize).toHaveBeenCalledWith(1000, 1000)
expect(setPixelRatio).toHaveBeenCalledWith(2.5)
})
it('handleResize caps the zoom scale at 3', () => {
it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
@@ -480,42 +485,10 @@ describe('Load3d', () => {
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setSize = vi.fn()
const setPixelRatio = vi.fn()
Object.assign(ctx.load3d, {
view: { canvas, setSize },
getZoomScaleCallback: () => 10,
targetWidth: 0,
targetHeight: 0,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
})
ctx.load3d.handleResize()
expect(setSize).toHaveBeenCalledWith(1200, 1200)
})
it('handleResize defaults to scale 1 when no getZoomScaleCallback is provided', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 400,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 400,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setSize = vi.fn()
Object.assign(ctx.load3d, {
view: { canvas, setSize },
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
getZoomScaleCallback: undefined,
targetWidth: 0,
targetHeight: 0,
@@ -526,7 +499,7 @@ describe('Load3d', () => {
ctx.load3d.handleResize()
expect(setSize).toHaveBeenCalledWith(400, 400)
expect(setPixelRatio).toHaveBeenCalledWith(1)
})
})
@@ -537,8 +510,7 @@ describe('Load3d', () => {
const viewHelperRender = vi.fn()
const controlsUpdate = vi.fn()
const renderMainScene = vi.fn()
const beginRender = vi.fn()
const blit = vi.fn()
const resetViewport = vi.fn()
Object.assign(ctx.load3d, {
STATUS_MOUSE_ON_NODE: true,
@@ -553,16 +525,13 @@ describe('Load3d', () => {
},
viewHelperManager: {
update: viewHelperUpdate,
render: viewHelperRender
viewHelper: { render: viewHelperRender }
},
controlsManager: { update: controlsUpdate },
recordingManager: { getIsRecording: vi.fn(() => false) },
renderMainScene,
view: {
beginRender,
blit,
renderer: { setScissorTest: vi.fn() }
}
resetViewport,
renderer: {}
})
;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation()
@@ -577,10 +546,9 @@ describe('Load3d', () => {
expect(animationUpdate).toHaveBeenCalledOnce()
expect(viewHelperUpdate).toHaveBeenCalledOnce()
expect(controlsUpdate).toHaveBeenCalledOnce()
expect(beginRender).toHaveBeenCalledOnce()
expect(renderMainScene).toHaveBeenCalledOnce()
expect(resetViewport).toHaveBeenCalledOnce()
expect(viewHelperRender).toHaveBeenCalledOnce()
expect(blit).toHaveBeenCalledOnce()
// Cancel the queued rAF so the test doesn't leak frames.
loop.stop()
@@ -592,10 +560,12 @@ describe('Load3d', () => {
Object.assign(ctx.load3d, {
renderLoop: { stop },
resizeObserver: null,
contextMenuAbortController: null,
view: {
canvas,
dispose: vi.fn()
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: canvas
},
sceneManager: { ...ctx.sceneManager, dispose: vi.fn() },
cameraManager: { ...ctx.cameraManager, dispose: vi.fn() },

View File

@@ -67,7 +67,7 @@ class Load3d extends Viewport3d {
private hasLoadedModel: boolean = false
constructor(
container: HTMLElement,
container: Element | HTMLElement,
deps: Load3dDeps,
options: Load3DOptions = {}
) {
@@ -255,8 +255,8 @@ class Load3d extends Viewport3d {
this.sceneManager.backgroundTexture &&
this.sceneManager.backgroundMesh
) {
const containerWidth = this.domElement.clientWidth
const containerHeight = this.domElement.clientHeight
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(

View File

@@ -64,16 +64,16 @@ function makeStream(): MediaStream {
} as unknown as MediaStream
}
function makeSourceCanvas(): HTMLCanvasElement {
function makeRenderer(): THREE.WebGLRenderer {
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 600
return canvas
return { domElement: canvas } as unknown as THREE.WebGLRenderer
}
describe('RecordingManager', () => {
let scene: THREE.Scene
let sourceCanvas: HTMLCanvasElement
let renderer: THREE.WebGLRenderer
let events: ReturnType<typeof makeMockEventManager>
let manager: RecordingManager
let rafSpy: ReturnType<typeof vi.spyOn>
@@ -104,9 +104,9 @@ describe('RecordingManager', () => {
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
scene = new THREE.Scene()
sourceCanvas = makeSourceCanvas()
renderer = makeRenderer()
events = makeMockEventManager()
manager = new RecordingManager(scene, sourceCanvas, events)
manager = new RecordingManager(scene, renderer, events)
})
afterEach(() => {

View File

@@ -11,7 +11,7 @@ export class RecordingManager {
private recordingStream: MediaStream | null = null
private recordingIndicator: THREE.Sprite | null = null
private scene: THREE.Scene
private sourceCanvas: HTMLCanvasElement
private renderer: THREE.WebGLRenderer
private eventManager: EventManagerInterface
private recordingStartTime: number = 0
private recordingDuration: number = 0
@@ -21,11 +21,11 @@ export class RecordingManager {
constructor(
scene: THREE.Scene,
sourceCanvas: HTMLCanvasElement,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface
) {
this.scene = scene
this.sourceCanvas = sourceCanvas
this.renderer = renderer
this.eventManager = eventManager
this.setupRecordingIndicator()
}
@@ -61,7 +61,7 @@ export class RecordingManager {
}
try {
const sourceCanvas = this.sourceCanvas
const sourceCanvas = this.renderer.domElement
const sourceWidth = sourceCanvas.width
const sourceHeight = sourceCanvas.height

View File

@@ -3,9 +3,6 @@ import * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RendererView } from '@/renderer/three/RendererView'
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import type { EventManagerInterface } from './interfaces'
import Load3dUtils from './Load3dUtils'
import { SceneManager } from './SceneManager'
@@ -68,32 +65,6 @@ function makeMockEventManager() {
} satisfies EventManagerInterface
}
function makeView(
renderer: THREE.WebGLRenderer,
width = 400,
height = 300
): RendererView {
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
configurable: true,
value: width
})
Object.defineProperty(canvas, 'clientHeight', {
configurable: true,
value: height
})
return {
renderer,
canvas,
state: createRendererViewState(),
width,
height,
beginRender: vi.fn(),
blit: vi.fn(),
setSize: vi.fn()
} as unknown as RendererView
}
function makeRenderer() {
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
@@ -149,7 +120,7 @@ describe('SceneManager', () => {
camera = new THREE.PerspectiveCamera()
events = makeMockEventManager()
manager = new SceneManager(
makeView(renderer),
renderer,
() => camera,
() => ({}) as unknown as OrbitControls,
events
@@ -621,22 +592,20 @@ describe('SceneManager', () => {
function makeSceneManager(
pixelRatio = 1,
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera,
viewSize?: { width: number; height: number }
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera
) {
const renderer = makeMockRenderer(pixelRatio)
const view = makeView(renderer, viewSize?.width, viewSize?.height)
const camera = cameraOverride ?? new THREE.PerspectiveCamera()
const eventManager = makeMockEventManager()
const manager = new SceneManager(
view,
renderer,
() => camera,
vi.fn() as unknown as () => InstanceType<
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
>,
eventManager
)
return { manager, renderer, view, camera, eventManager }
return { manager, renderer, camera, eventManager }
}
describe('SceneManager.captureScene', () => {
@@ -679,19 +648,6 @@ describe('SceneManager.captureScene', () => {
expect(calls.at(-1)).toEqual([400, 300])
})
it('restores the view state first and resizes the background to the view size, not the shared buffer size', async () => {
const { manager, view } = makeSceneManager(1, undefined, {
width: 640,
height: 480
})
const handleResize = vi.spyOn(manager, 'handleResize')
await manager.captureScene(1920, 1080)
expect(view.beginRender).toHaveBeenCalledOnce()
expect(handleResize).toHaveBeenCalledWith(640, 480)
})
it('restores perspective camera aspect after capture', async () => {
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
const { manager } = makeSceneManager(1, camera)

View File

@@ -2,8 +2,6 @@ import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { RendererView } from '@/renderer/three/RendererView'
import Load3dUtils from './Load3dUtils'
import {
type BackgroundRenderModeType,
@@ -40,18 +38,16 @@ export class SceneManager implements SceneManagerInterface {
private eventManager: EventManagerInterface
private renderer: THREE.WebGLRenderer
private view: RendererView
private getActiveCamera: () => THREE.Camera
constructor(
view: RendererView,
renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
_getControls: () => OrbitControls,
eventManager: EventManagerInterface
) {
this.view = view
this.renderer = view.renderer
this.renderer = renderer
this.eventManager = eventManager
this.scene = new THREE.Scene()
@@ -69,7 +65,7 @@ export class SceneManager implements SceneManagerInterface {
// forceRender directly into onDirty caused a per-frame render-setDirty
// cascade that made splats visibly "balloon" during camera interaction.
this.sparkRenderer = new SparkRenderer({
renderer: view.renderer,
renderer,
onDirty: () => {
const resolve = this.nextSparkDirtyResolve
this.nextSparkDirtyResolve = null
@@ -108,8 +104,7 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundMesh.position.set(0, 0, 0)
this.backgroundScene.add(this.backgroundMesh)
this.view.state.clearColor.set(0x000000)
this.view.state.clearAlpha = 0
this.renderer.setClearColor(0x000000, 0)
}
init(): void {}
@@ -255,8 +250,8 @@ export class SceneManager implements SceneManagerInterface {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
this.view.canvas.clientWidth,
this.view.canvas.clientHeight
this.renderer.domElement.clientWidth,
this.renderer.domElement.clientHeight
)
}
@@ -374,8 +369,6 @@ export class SceneManager implements SceneManagerInterface {
width: number,
height: number
): Promise<{ scene: string; mask: string; normal: string }> {
this.view.beginRender()
const originalSize = new THREE.Vector2()
this.renderer.getSize(originalSize)
const originalPixelRatio = this.renderer.getPixelRatio()
@@ -502,10 +495,7 @@ export class SceneManager implements SceneManagerInterface {
this.renderer.setPixelRatio(originalPixelRatio)
this.renderer.setSize(originalSize.x, originalSize.y)
this.renderer.outputColorSpace = originalOutputColorSpace
this.handleResize(
this.view.canvas.clientWidth,
this.view.canvas.clientHeight
)
this.handleResize(originalSize.x, originalSize.y)
}
}

View File

@@ -2,13 +2,18 @@ import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapterCapabilities } from './ModelAdapter'
import { SceneModelManager } from './SceneModelManager'
import type { EventManagerInterface } from './interfaces'
function createMockRenderer(): THREE.WebGLRenderer {
return {
outputColorSpace: THREE.SRGBColorSpace,
dispose: vi.fn()
} as unknown as THREE.WebGLRenderer
}
function createMockEventManager(): EventManagerInterface {
return {
addEventListener: vi.fn(),
@@ -25,7 +30,7 @@ function createManager(
} = {}
) {
const scene = overrides.scene ?? new THREE.Scene()
const viewState = createRendererViewState()
const renderer = createMockRenderer()
const eventManager = overrides.eventManager ?? createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const getActiveCamera = () => camera
@@ -38,7 +43,7 @@ function createManager(
const manager = new SceneModelManager(
scene,
viewState,
renderer,
eventManager,
getActiveCamera,
setupCamera,
@@ -49,7 +54,7 @@ function createManager(
return {
manager,
scene,
viewState,
renderer,
eventManager,
camera,
setupCamera,
@@ -62,6 +67,7 @@ function createManagerWithPose(opts: {
pose: { size: THREE.Vector3; center: THREE.Vector3 } | null
}) {
const scene = new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const setupCamera = vi.fn()
@@ -73,7 +79,7 @@ function createManagerWithPose(opts: {
const manager = new SceneModelManager(
scene,
createRendererViewState(),
renderer,
eventManager,
() => camera,
setupCamera,
@@ -468,7 +474,7 @@ describe('SceneModelManager', () => {
})
it('switches to depth material', async () => {
const { manager, viewState } = createManager()
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
@@ -476,7 +482,7 @@ describe('SceneModelManager', () => {
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
})
it('restores original material when switching back', async () => {
@@ -507,16 +513,16 @@ describe('SceneModelManager', () => {
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
})
it('sets view color space to SRGB for non-depth modes', async () => {
const { manager, viewState } = createManager()
it('sets renderer color space to SRGB for non-depth modes', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
manager.setMaterialMode('normal')
expect(viewState.outputColorSpace).toBe(THREE.SRGBColorSpace)
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
})
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {

View File

@@ -2,8 +2,6 @@ import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapterCapabilities } from './ModelAdapter'
import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter'
@@ -39,7 +37,7 @@ export class SceneModelManager implements ModelManagerInterface {
showSkeleton: boolean = false
private scene: THREE.Scene
private viewState: RendererViewState
private renderer: THREE.WebGLRenderer
private eventManager: EventManagerInterface
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
@@ -54,7 +52,7 @@ export class SceneModelManager implements ModelManagerInterface {
constructor(
scene: THREE.Scene,
viewState: RendererViewState,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface,
getActiveCamera: () => THREE.Camera,
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
@@ -70,7 +68,7 @@ export class SceneModelManager implements ModelManagerInterface {
} | null = () => null
) {
this.scene = scene
this.viewState = viewState
this.renderer = renderer
this.eventManager = eventManager
this.activeCamera = getActiveCamera()
this.setupCamera = setupCamera
@@ -198,9 +196,9 @@ export class SceneModelManager implements ModelManagerInterface {
}
if (mode === 'depth') {
this.viewState.outputColorSpace = THREE.LinearSRGBColorSpace
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
} else {
this.viewState.outputColorSpace = THREE.SRGBColorSpace
this.renderer.outputColorSpace = THREE.SRGBColorSpace
}
if (this.currentModel) {

View File

@@ -15,16 +15,6 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
private readonly helperCamera = new THREE.OrthographicCamera(
-2,
2,
2,
-2,
0,
4
)
private readonly savedViewport = new THREE.Vector4()
constructor(
_renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
@@ -34,25 +24,10 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.eventManager = eventManager
this.helperCamera.position.set(0, 0, 2)
}
init(): void {}
render(renderer: THREE.WebGLRenderer, size: number): void {
const helper = this.viewHelper
if (!helper.isViewHelper) return
helper.quaternion.copy(this.getActiveCamera().quaternion).invert()
helper.updateMatrixWorld()
renderer.clearDepth()
renderer.getViewport(this.savedViewport)
renderer.setViewport(0, 0, size, size)
renderer.render(helper, this.helperCamera)
renderer.setViewport(this.savedViewport)
}
dispose(): void {
if (this.viewHelper) {
this.viewHelper.dispose()

View File

@@ -394,10 +394,14 @@ describe('Viewport3d', () => {
initialRenderTimer: null,
startAnimation: vi.fn(),
renderLoop: { stop: vi.fn() },
resizeObserver: null,
disposeContextMenuGuard: null,
view: {
canvas: document.createElement('canvas'),
dispose: vi.fn()
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: Object.assign(document.createElement('canvas'), {
remove: vi.fn()
})
},
disposeManagers: vi.fn()
})

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'
import type { RendererView } from '@/renderer/three/RendererView'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
@@ -19,10 +19,8 @@ import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
const VIEW_HELPER_SIZE = 128
export type Viewport3dDeps = {
view: RendererView
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
@@ -31,8 +29,7 @@ export type Viewport3dDeps = {
viewHelperManager: ViewHelperManager
}
export class Viewport3d {
protected readonly view: RendererView
export class Viewport3d extends WebGLViewport {
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
@@ -60,14 +57,13 @@ export class Viewport3d {
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
private viewPixelScale = 1
constructor(
container: HTMLElement,
container: Element | HTMLElement,
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
this.view = deps.view
super(deps.renderer)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -98,15 +94,7 @@ export class Viewport3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.view.observeResize(container, () => this.handleResize())
}
get renderer(): THREE.WebGLRenderer {
return this.view.renderer
}
get domElement(): HTMLCanvasElement {
return this.view.canvas
this.observeResize(container, () => this.handleResize())
}
start(): void {
@@ -132,7 +120,7 @@ export class Viewport3d {
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.view.canvas,
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
@@ -171,21 +159,15 @@ export class Viewport3d {
forceRender(): void {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderView()
this.INITIAL_RENDER_DONE = true
}
private renderView(): void {
this.view.beginRender()
this.renderMainScene()
this.resetViewport()
this.renderer.setScissorTest(false)
this.viewHelperManager.render(
this.renderer,
VIEW_HELPER_SIZE * this.viewPixelScale
)
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.view.blit()
this.INITIAL_RENDER_DONE = true
}
protected tickPerFrame(delta: number): void {
@@ -237,8 +219,8 @@ export class Viewport3d {
}
renderMainScene(): void {
const viewWidth = this.view.width
const viewHeight = this.view.height
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
@@ -247,16 +229,15 @@ export class Viewport3d {
}
}
this.renderer.setViewport(0, 0, viewWidth, viewHeight)
this.renderer.setScissor(0, 0, viewWidth, viewHeight)
this.renderer.setScissorTest(true)
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: viewWidth, height: viewHeight },
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
@@ -265,23 +246,34 @@ export class Viewport3d {
this.cameraManager.updateAspectRatio(width / height)
} else {
this.renderer.setClearColor(
this.view.state.clearColor,
this.view.state.clearAlpha
)
this.renderer.clear()
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
protected startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderView()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
@@ -354,7 +346,7 @@ export class Viewport3d {
}
handleResize(): void {
const parentElement = this.view.canvas.parentElement
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
@@ -365,7 +357,7 @@ export class Viewport3d {
const containerHeight = parentElement.clientHeight
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.viewPixelScale = Math.min(zoomScale, 3)
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
@@ -374,20 +366,17 @@ export class Viewport3d {
}
}
this.view.setSize(
containerWidth * this.viewPixelScale,
containerHeight * this.viewPixelScale
)
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
@@ -409,7 +398,7 @@ export class Viewport3d {
this.disposeManagers()
this.view.dispose()
this.disposeRenderer()
}
protected disposeManagers(): void {

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapter, ModelAdapterCapabilities } from './ModelAdapter'
@@ -20,14 +20,7 @@ vi.mock('three', async () => {
rendererCtor(opts)
}
setSize() {}
setPixelRatio() {}
setClearColor() {}
getSize(target: { set(x: number, y: number): unknown }) {
target.set(300, 300)
return target
}
forceContextLoss() {}
dispose() {}
}
}
})
@@ -170,12 +163,6 @@ function makeAdapter(overrides: Partial<ModelAdapter> = {}): ModelAdapter {
}
describe('createLoad3d', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn()
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
})
it('constructs the renderer with alpha + antialias and appends it to the container', () => {
rendererCtor.mockClear()
const container = createContainer()

View File

@@ -1,6 +1,4 @@
import type * as THREE from 'three'
import { RendererView } from '@/renderer/three/RendererView'
import * as THREE from 'three'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
@@ -19,9 +17,25 @@ import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import type { Load3DOptions } from './interfaces'
function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
const view = new RendererView(container)
const renderer = view.renderer
function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
renderer.setSize(300, 300)
renderer.setClearColor(0x282828)
renderer.autoClear = false
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.classList.add(
'absolute',
'inset-0',
'h-full',
'w-full',
'outline-none'
)
container.appendChild(renderer.domElement)
return renderer
}
function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
const renderer = createRenderer(container)
const eventManager = new EventManager()
// Shared mutable handle: LoaderManager writes the active adapter on each
// load; SceneModelManager reads it for capability/bounds/dispose lookups
@@ -36,7 +50,7 @@ function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
const getControls = () => controlsManager.controls
const sceneManager = new SceneManager(
view,
renderer,
getActiveCamera,
getControls,
eventManager
@@ -44,7 +58,7 @@ function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
cameraManager = new CameraManager(renderer, eventManager)
controlsManager = new ControlsManager(
container,
renderer,
cameraManager.activeCamera,
eventManager
)
@@ -54,7 +68,6 @@ function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
const hdriManager = new HDRIManager(
sceneManager.scene,
renderer,
view.state,
eventManager
)
const viewHelperManager = new ViewHelperManager(
@@ -66,7 +79,7 @@ function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
const modelManager = new SceneModelManager(
sceneManager.scene,
view.state,
renderer,
eventManager,
getActiveCamera,
(size, center) => cameraManager.setupForModel(size, center),
@@ -85,14 +98,14 @@ function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
)
const recordingManager = new RecordingManager(
sceneManager.scene,
view.canvas,
renderer,
eventManager
)
const animationManager = new AnimationManager(eventManager)
gizmoManager = new GizmoManager(
sceneManager.scene,
container,
renderer,
controlsManager.controls,
getActiveCamera,
() => {
@@ -106,7 +119,7 @@ function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
)
return {
view,
renderer,
eventManager,
sceneManager,
cameraManager,
@@ -124,7 +137,7 @@ function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
}
export function createLoad3d(
container: HTMLElement,
container: Element | HTMLElement,
options?: Load3DOptions
): Load3d {
return new Load3d(container, buildLoad3dDeps(container), options)

View File

@@ -9,9 +9,7 @@ import {
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
useExtensionService().registerExtension({
name: 'Comfy.PreviewAny',
@@ -32,11 +30,5 @@ useExtensionService().registerExtension({
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
},
onNodeOutputsUpdated(nodeOutputs) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
}
}
})

View File

@@ -1,126 +0,0 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RendererView } from './RendererView'
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class WebGLRenderer {
domElement = document.createElement('canvas')
autoClear = true
outputColorSpace = ''
toneMapping = 0
toneMappingExposure = 1
setSize(width: number, height: number) {
this.domElement.width = width
this.domElement.height = height
}
getSize(target: { set(x: number, y: number): unknown }) {
target.set(this.domElement.width, this.domElement.height)
return target
}
setPixelRatio() {}
setClearColor = vi.fn()
forceContextLoss = vi.fn()
dispose = vi.fn()
}
return { ...actual, WebGLRenderer }
})
const drawImage = vi.fn()
beforeEach(() => {
drawImage.mockClear()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage,
globalCompositeOperation: 'source-over'
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
})
describe('RendererView', () => {
it('appends its own canvas to the container', () => {
const container = document.createElement('div')
const view = new RendererView(container)
expect(view.canvas.parentElement).toBe(container)
view.dispose()
expect(view.canvas.parentElement).toBeNull()
})
it('setSize resizes the view canvas and grows the shared buffer to fit', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.setSize(640.4, 480.6)
expect(view.width).toBe(640)
expect(view.height).toBe(481)
expect(view.canvas.width).toBe(640)
expect(view.canvas.height).toBe(481)
expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(640)
expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(481)
view.dispose()
})
it('setSize clamps degenerate sizes to 1 pixel', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.setSize(0, -5)
expect(view.width).toBe(1)
expect(view.height).toBe(1)
view.dispose()
})
it('beginRender applies the view state and regrows a shrunken shared buffer', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.setSize(400, 300)
view.state.toneMapping = THREE.ACESFilmicToneMapping
view.state.toneMappingExposure = 0.5
view.state.outputColorSpace = THREE.LinearSRGBColorSpace
view.state.clearAlpha = 0.25
view.renderer.setSize(50, 40)
view.beginRender()
expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(400)
expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(300)
expect(view.renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping)
expect(view.renderer.toneMappingExposure).toBe(0.5)
expect(view.renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(view.renderer.setClearColor).toHaveBeenCalledWith(
view.state.clearColor,
0.25
)
view.dispose()
})
it('blit copies the bottom-left region of the shared buffer into the view canvas', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.renderer.setSize(1000, 800)
view.setSize(400, 300)
view.blit()
expect(drawImage).toHaveBeenCalledWith(
view.renderer.domElement,
0,
500,
400,
300,
0,
0,
400,
300
)
view.dispose()
})
})

View File

@@ -1,88 +0,0 @@
import type * as THREE from 'three'
import type {
RendererViewState,
SharedRendererHandle
} from './sharedWebGLRenderer'
import {
acquireSharedRenderer,
applyRendererViewState,
createRendererViewState,
ensureRendererSize
} from './sharedWebGLRenderer'
export class RendererView {
readonly renderer: THREE.WebGLRenderer
readonly canvas: HTMLCanvasElement
readonly state: RendererViewState = createRendererViewState()
width = 1
height = 1
private readonly context: CanvasRenderingContext2D
private readonly handle: SharedRendererHandle
private resizeObserver: ResizeObserver | null = null
constructor(container: HTMLElement) {
this.canvas = document.createElement('canvas')
this.canvas.classList.add(
'absolute',
'inset-0',
'h-full',
'w-full',
'outline-none'
)
const context = this.canvas.getContext('2d')
if (!context) {
throw new Error('Failed to create 2D context for 3D view')
}
this.context = context
this.handle = acquireSharedRenderer()
this.renderer = this.handle.renderer
container.appendChild(this.canvas)
}
setSize(width: number, height: number): void {
this.width = Math.max(1, Math.round(width))
this.height = Math.max(1, Math.round(height))
if (this.canvas.width !== this.width) this.canvas.width = this.width
if (this.canvas.height !== this.height) this.canvas.height = this.height
ensureRendererSize(this.renderer, this.width, this.height)
}
beginRender(): void {
ensureRendererSize(this.renderer, this.width, this.height)
applyRendererViewState(this.renderer, this.state)
}
blit(): void {
const source = this.renderer.domElement
this.context.globalCompositeOperation = 'copy'
this.context.drawImage(
source,
0,
source.height - this.height,
this.width,
this.height,
0,
0,
this.width,
this.height
)
}
observeResize(target: Element, onResize: () => void): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => onResize())
this.resizeObserver.observe(target)
}
dispose(): void {
this.resizeObserver?.disconnect()
this.resizeObserver = null
this.canvas.remove()
this.handle.release()
}
}

View File

@@ -1,140 +0,0 @@
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import {
acquireSharedRenderer,
applyRendererViewState,
createRendererViewState,
ensureRendererSize
} from './sharedWebGLRenderer'
const { rendererCtor, forceContextLoss, dispose } = vi.hoisted(() => ({
rendererCtor: vi.fn(),
forceContextLoss: vi.fn(),
dispose: vi.fn()
}))
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class WebGLRenderer {
domElement = document.createElement('canvas')
autoClear = true
outputColorSpace = ''
toneMapping = 0
toneMappingExposure = 1
width = 0
height = 0
constructor(opts: unknown) {
rendererCtor(opts)
}
setSize(width: number, height: number) {
this.width = width
this.height = height
}
getSize(target: THREE.Vector2) {
target.set(this.width, this.height)
return target
}
setPixelRatio() {}
setClearColor = vi.fn()
forceContextLoss = forceContextLoss
dispose = dispose
}
return { ...actual, WebGLRenderer }
})
describe('acquireSharedRenderer', () => {
it('returns the same renderer for concurrent views and disposes after the last release', () => {
rendererCtor.mockClear()
forceContextLoss.mockClear()
dispose.mockClear()
const first = acquireSharedRenderer()
const second = acquireSharedRenderer()
expect(second.renderer).toBe(first.renderer)
expect(rendererCtor).toHaveBeenCalledTimes(1)
first.release()
expect(dispose).not.toHaveBeenCalled()
const disposedWhenContextLostFired: boolean[] = []
second.renderer.domElement.addEventListener('webglcontextlost', () => {
disposedWhenContextLostFired.push(dispose.mock.calls.length > 0)
})
second.release()
expect(forceContextLoss).toHaveBeenCalledTimes(1)
expect(dispose).toHaveBeenCalledTimes(1)
expect(disposedWhenContextLostFired).toEqual([false])
})
it('creates a fresh renderer after the previous one was torn down', () => {
rendererCtor.mockClear()
const first = acquireSharedRenderer()
first.release()
const second = acquireSharedRenderer()
expect(rendererCtor).toHaveBeenCalledTimes(2)
expect(second.renderer).not.toBe(first.renderer)
second.release()
})
it('ignores double release from the same handle', () => {
dispose.mockClear()
const first = acquireSharedRenderer()
const second = acquireSharedRenderer()
first.release()
first.release()
expect(dispose).not.toHaveBeenCalled()
second.release()
expect(dispose).toHaveBeenCalledTimes(1)
})
})
describe('ensureRendererSize', () => {
it('grows the drawing buffer per-axis but never shrinks it', () => {
const handle = acquireSharedRenderer()
const renderer = handle.renderer
renderer.setSize(300, 300)
ensureRendererSize(renderer, 500, 200)
expect(renderer.getSize(new THREE.Vector2())).toEqual(
new THREE.Vector2(500, 300)
)
ensureRendererSize(renderer, 400, 250)
expect(renderer.getSize(new THREE.Vector2())).toEqual(
new THREE.Vector2(500, 300)
)
handle.release()
})
})
describe('applyRendererViewState', () => {
it('writes the per-view state onto the renderer', () => {
const handle = acquireSharedRenderer()
const renderer = handle.renderer
const state = createRendererViewState()
state.toneMapping = THREE.ACESFilmicToneMapping
state.toneMappingExposure = 0.5
state.outputColorSpace = THREE.LinearSRGBColorSpace
state.clearColor.set(0x123456)
state.clearAlpha = 0.25
applyRendererViewState(renderer, state)
expect(renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping)
expect(renderer.toneMappingExposure).toBe(0.5)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(renderer.setClearColor).toHaveBeenCalledWith(state.clearColor, 0.25)
handle.release()
})
})

View File

@@ -1,78 +0,0 @@
import * as THREE from 'three'
export type RendererViewState = {
toneMapping: THREE.ToneMapping
toneMappingExposure: number
outputColorSpace: THREE.ColorSpace
clearColor: THREE.Color
clearAlpha: number
}
export function createRendererViewState(): RendererViewState {
return {
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 1.0,
outputColorSpace: THREE.SRGBColorSpace,
clearColor: new THREE.Color(0x000000),
clearAlpha: 0
}
}
export function applyRendererViewState(
renderer: THREE.WebGLRenderer,
state: RendererViewState
): void {
renderer.toneMapping = state.toneMapping
renderer.toneMappingExposure = state.toneMappingExposure
renderer.outputColorSpace = state.outputColorSpace
renderer.setClearColor(state.clearColor, state.clearAlpha)
}
export type SharedRendererHandle = {
renderer: THREE.WebGLRenderer
release: () => void
}
let sharedRenderer: THREE.WebGLRenderer | null = null
let viewCount = 0
function createSharedRenderer(): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
renderer.setPixelRatio(1)
renderer.setSize(300, 300)
renderer.autoClear = false
renderer.outputColorSpace = THREE.SRGBColorSpace
return renderer
}
export function acquireSharedRenderer(): SharedRendererHandle {
sharedRenderer ??= createSharedRenderer()
viewCount++
const renderer = sharedRenderer
let released = false
return {
renderer,
release() {
if (released) return
released = true
viewCount--
if (viewCount > 0 || sharedRenderer !== renderer) return
sharedRenderer = null
renderer.forceContextLoss()
renderer.domElement.dispatchEvent(
new Event('webglcontextlost', { bubbles: true, cancelable: true })
)
renderer.dispose()
}
}
}
export function ensureRendererSize(
renderer: THREE.WebGLRenderer,
width: number,
height: number
): void {
const size = renderer.getSize(new THREE.Vector2())
if (size.width >= width && size.height >= height) return
renderer.setSize(Math.max(size.width, width), Math.max(size.height, height))
}

View File

@@ -7,12 +7,10 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import type { SettingParams } from '@/platform/settings/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Keybinding } from '@/platform/keybindings/types'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyCommand } from '@/stores/commandStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import type { AuthUserInfo } from '@/types/authTypes'
import type { BottomPanelExtension } from '@/types/extensionTypes'
@@ -267,9 +265,5 @@ export interface ComfyExtension {
*/
onAuthUserLogout?(): Promise<void> | void
onNodeOutputsUpdated?(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
): void
[key: string]: unknown
}

View File

@@ -142,7 +142,12 @@ describe('PackVersionSelectorPopover', () => {
})
it('shows loading state while fetching versions', async () => {
mockGetPackVersions.mockImplementationOnce(() => new Promise(() => {}))
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
renderComponent()